feat: profile-widgets phase 1+2 + brand pack §11 + windows build fix#40
Merged
Conversation
ntatschner
pushed a commit
that referenced
this pull request
May 19, 2026
… query.rs Two CI failures on PR #40, fixed together since they block the same run: - apps/web/e2e/public-profile.spec.ts:31 — `getByText('42', { exact: false })` matched two elements after the brand pack landed: the actual public profile event count `<div class="mono">42</div>` AND the new footer attribution chip's "Squadron 42™" trademark line. Scoped the assertion to `.mono` + `hasText: '42'`, mirroring the existing `.ss-eyebrow` pattern on the line above. Disclaimer text stays verbatim per brand book §11 — the selector adapts to the page, not the other way around. - crates/starstats-server/src/query.rs:2657,2662 — clippy::unnecessary_cast tripped CI on both ubuntu-latest and windows-latest after a recent change made the source values already i64: - `ENTERED_AT_RUN_LIMIT as i64` → drop cast (const is already i64) - `(i + 1) as i64` → drop cast (`i` is i64 from `0..limit`) `Vec::with_capacity(limit as usize)` retained — that one is a real i64→usize conversion. Verified: `cargo clippy -p starstats-server --all-targets -- -D warnings` exits 0.
6 tasks
added 18 commits
May 20, 2026 01:10
Spec covers owner-customisable widget framework, Sessions compact/expanded redesign, 6-phase rollout. Plan 1 implements phases 1+2: backend foundations, widget framework, and conversion of the four existing profile cards behind NEXT_PUBLIC_PROFILE_WIDGETS flag.
Owner-only endpoints for reading and writing the widget layout stored in users.profile_layout. Validation (MAX_ENTRIES=32, MAX_ID_LEN=64, ASCII alphanumeric+_-) returns 400, never 401. Audit emission is best-effort via tracing::warn! on failure. Removes the now-consumed #[allow(dead_code)] from ACTION_PROFILE_LAYOUT_UPDATED. 4 oneshot tests: get_returns_default_when_unset, put_then_get_roundtrips, put_rejects_oversized_array, put_rejects_oversized_id — all pass.
Adds apps/web/e2e/profile-widgets.spec.ts with all five test names from the Phase 1+2 plan. Flag-ON tests are guarded with test.skip() and a documented activation path (add NEXT_PUBLIC_PROFILE_WIDGETS=1 to the Next dev-server env in playwright.config.ts). The flag-OFF legacy-path test is runnable as-is against any dev-server boot.
481c2a4 to
ec74ff1
Compare
added 5 commits
May 20, 2026 01:12
Move the Distribution section back inside LegacyCards between DayHeatmap and SessionsCard, restoring the pre-Task-17 render order: OrgsCard -> Heatmap -> Distribution -> SessionsCard -> EntitiesNavCard. Pass topTypes and total as props to LegacyCards. The flag-on path (ProfileWidgetGrid) is unchanged — Distribution is not part of it.
WidgetFrame already renders <section ss-card>, eyebrow, and h2. OrgsCard was rendering its own identical shell inside it, causing nested cards and duplicate eyebrow/h2 for any owner with org data. Extract OrgsCardInner (content only, no section/eyebrow/h2) as a new named export. OrgsCard remains a thin wrapper around OrgsCardInner with the full card shell — legacy call sites in dashboard/page.tsx and u/[handle]/page.tsx are byte-identical. The orgs widget now imports OrgsCardInner so WidgetFrame supplies chrome exactly once.
There was a problem hiding this comment.
Pull request overview
This PR introduces the backend storage + API surface for per-user profile widget layouts, and wires a new server-rendered widget registry/framework into the /u/[handle] page behind a feature flag (owner-only in this phase). It also regenerates the TS API client from OpenAPI, adds supporting docs/specs, and adds Playwright coverage scaffolding for the widget path.
Changes:
- Add
users.profile_layout(JSONB) migration plus Rust store + GET/PUT/v1/users/me/profile-layoutroutes with audit emission and OpenAPI registration. - Add a Next.js widget framework (
WidgetFrame, registry, converted Sessions/Heatmap/Orgs/Entities widgets) and gate profile rendering behindNEXT_PUBLIC_PROFILE_WIDGETS+ owner check. - Add docs (design + phase plan) and Playwright e2e tests for the widget rollout.
Reviewed changes
Copilot reviewed 21 out of 22 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/api-client-ts/src/generated/schema.ts | Regenerated OpenAPI TS types for the new profile-layout endpoints and schemas. |
| docs/superpowers/specs/2026-05-19-profile-data-display-redesign-design.md | New design spec for the profile widget redesign and layout model. |
| docs/superpowers/plans/2026-05-19-profile-data-display-redesign-phase1-2.md | New implementation plan for phases 1+2 (backend + widget framework). |
| crates/starstats-server/src/profile_layout.rs | Adds ProfileLayoutStore trait, Postgres impl, and Memory impl + tests. |
| crates/starstats-server/src/profile_layout_routes.rs | Adds owner layout GET/PUT routes, validation, audit emission, and route tests. |
| crates/starstats-server/src/openapi.rs | Registers new routes/schemas and tags in OpenAPI. |
| crates/starstats-server/src/main.rs | Wires the new store + routes into the server. |
| crates/starstats-server/src/bin/openapi.rs | Includes new modules so the OpenAPI bin compiles. |
| crates/starstats-server/src/audit.rs | Adds an audit action constant for profile_layout.updated. |
| crates/starstats-server/migrations/0035_profile_layout.sql | Adds nullable users.profile_layout JSONB column (additive migration). |
| apps/web/src/lib/profile-layout.ts | Adds feature flag, default layout, projection helper, and render-time fetch helper. |
| apps/web/src/lib/api.ts | Adds web API wrappers/types for profile layout GET/PUT. |
| apps/web/src/components/OrgsCard.tsx | Extracts OrgsCardInner for widget reuse (avoids double card chrome). |
| apps/web/src/app/u/[handle]/page.tsx | Gates widget rendering for owner+flag and extracts a legacy fallback block. |
| apps/web/src/app/_components/widgets/WidgetFrame.tsx | Adds consistent widget chrome wrapper with accessibility naming. |
| apps/web/src/app/_components/widgets/types.ts | Defines widget contract types (WidgetDef, ViewerCtx, etc.). |
| apps/web/src/app/_components/widgets/sessions.tsx | New Sessions widget with compact/expanded redesign (no raw id display). |
| apps/web/src/app/_components/widgets/registry.ts | Central widget registry + lookup map. |
| apps/web/src/app/_components/widgets/orgs.tsx | New Orgs widget that fetches snapshot and renders OrgsCardInner. |
| apps/web/src/app/_components/widgets/heatmap.tsx | New Heatmap widget with 30d/180d compact/expanded modes. |
| apps/web/src/app/_components/widgets/entities.tsx | New Entities nav widget. |
| apps/web/e2e/profile-widgets.spec.ts | Adds Playwright tests/scaffolding (flag-on suite currently skipped). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+42
to
+46
| /** | ||
| * Inner content only — no <section> wrapper, no eyebrow, no outer h2. | ||
| * Used by the orgs widget so WidgetFrame supplies the card chrome once. | ||
| */ | ||
| export function OrgsCardInner({ |
Comment on lines
+59
to
+62
| pub async fn get_profile_layout( | ||
| auth: AuthenticatedUser, | ||
| Extension(store): Extension<Arc<dyn ProfileLayoutStore>>, | ||
| ) -> Result<Json<ProfileLayoutResponse>, StatusCode> { |
Comment on lines
+104
to
+120
| if let Some(layout) = &req.layout { | ||
| if layout.len() > MAX_ENTRIES { | ||
| return Err(StatusCode::BAD_REQUEST); | ||
| } | ||
| for entry in layout { | ||
| if entry.id.is_empty() || entry.id.len() > MAX_ID_LEN { | ||
| return Err(StatusCode::BAD_REQUEST); | ||
| } | ||
| if !entry | ||
| .id | ||
| .chars() | ||
| .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') | ||
| { | ||
| return Err(StatusCode::BAD_REQUEST); | ||
| } | ||
| } | ||
| } |
Comment on lines
+22
to
+30
| #[derive(Debug, Serialize, utoipa::ToSchema)] | ||
| pub struct ProfileLayoutResponse { | ||
| /// The owner's stored layout. `null` means "use default" — the | ||
| /// client (web) projects against the registry. | ||
| pub layout: Option<Vec<LayoutEntry>>, | ||
| /// `"stored"` when the row exists, `"default"` when the column is | ||
| /// NULL and the client should fall back to DEFAULT_LAYOUT. | ||
| pub source: String, | ||
| } |
Comment on lines
+119
to
+130
| sqlx::query( | ||
| r#" | ||
| UPDATE users | ||
| SET profile_layout = $1 | ||
| WHERE lower(claimed_handle) = lower($2) | ||
| "#, | ||
| ) | ||
| .bind(json_value) | ||
| .bind(owner_handle) | ||
| .execute(&self.pool) | ||
| .await?; | ||
| Ok(()) |
| */ | ||
| export const DEFAULT_LAYOUT: LayoutEntry[] = [ | ||
| { id: 'sessions', enabled: true, size: 'compact' }, | ||
| { id: 'heatmap', enabled: true, size: 'expanded' }, |
| <> | ||
| {renders.map((r, i) => { | ||
| if (r.status === 'rejected') { | ||
| logger.warn({ err: r.reason, idx: i }, 'widget render failed'); |
| * which has all four widgets enabled. */ | ||
| const profileLayoutDefault = { | ||
| status: 200, | ||
| body: { layout: null }, |
This was referenced May 20, 2026
Merged
ntatschner
pushed a commit
that referenced
this pull request
May 20, 2026
Plan 4 of the profile-widgets rollout is now merged on main (PRs #40 #42 #43 #44). Switching the flag's default from "off unless =1" to "on unless =0" engages the widget framework for all users. The env var remains as an emergency-rollback escape hatch (NEXT_PUBLIC_PROFILE_WIDGETS=0 falls back to LegacyCards). After one channel-stable release the env-var check + LegacyCards can be deleted entirely per the spec's Phase 6 "drop the flag" item.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft PR — opened to run CI. Bundles three distinct units of work that
co-traveled on this branch.
Summary
Profile widgets — Plan 1 (phases 1+2) — 24 commits
Backend foundations + web widget framework + conversion of all four existing profile cards (Heatmap, Orgs, Sessions, Entities) — including the Sessions card redesign — all behind
NEXT_PUBLIC_PROFILE_WIDGETS=1.Spec:
docs/superpowers/specs/2026-05-19-profile-data-display-redesign-design.mdPlan:
docs/superpowers/plans/2026-05-19-profile-data-display-redesign-phase1-2.mdWhen the flag is OFF (default), the profile page renders byte-identical to main via a
LegacyCardsextraction. When the flag is ON AND the viewer is the owner, the page renders through the new widget registry. Visitors always stay on legacy in Plan 1.Backend (migration
0035_profile_layout→ store → routes → audit → OpenAPI → TS client)users.profile_layout JSONBcolumn (NULL = useDEFAULT_LAYOUTweb-side).ProfileLayoutStoretrait +PostgresProfileLayoutStore+MemoryProfileLayoutStore(test_support). 6 store unit tests.GET / PUT /v1/users/me/profile-layout— owner-only, hand-rolled validation (MAX_ENTRIES=32, MAX_ID_LEN=64, ASCII[a-zA-Z0-9_-]). Validation errors → 400, never 401 (auto-logout safety). 5 route tests viatower::ServiceExt::oneshot+ real JWTs fromfresh_pair(). Includes the audit-emission assertion.profile_layout.updatedaspub const ACTION_PROFILE_LAYOUT_UPDATED. Best-effort emission per CLAUDE.md.openapi.rspaths + schemas,bin/openapi.rsmod stubs, TS client regenerated.Web framework
widgets/types.ts—WidgetId,WidgetSize,ViewerCtx,WidgetDefcontract.widgets/WidgetFrame.tsx— server-component chrome using.ss-card+.metric-card*+.ss-eyebrow.aria-label={title}sogetByRole('region')works.lib/profile-layout.ts—PROFILE_WIDGETS_ENABLEDflag,DEFAULT_LAYOUT,projectLayout(drops unknown ids, appends missing asenabled:false— forward-compat for Plan 2's edit mode),getProfileLayoutForRender.widgets/registry.ts—WIDGETS,WIDGETS_BY_ID,REGISTERED_IDS.The four converted widgets
sessions.tsx— the headline redesign. Compact:"N sessions · Xh played"+ Last-played mini-card. Expanded: list withwhen(relative + ISO tooltip) +duration+ event-count pill. Raws.idremoved (lives only in the row'shref).heatmap.tsx— thin wrap ofDayHeatmap. Compact = 30d, expanded = 180d.orgs.tsx— uses newOrgsCardInner(extracted in fix commit) to avoid double.ss-cardchrome.entities.tsx— inline nav card matching the existingentities-nav-cardstyling.Page wiring —
apps/web/src/app/u/[handle]/page.tsxgates onPROFILE_WIDGETS_ENABLED && viewerCtx.isOwnerwith<ProfileWidgetGrid ctx={viewerCtx} />or<LegacyCards ... />fallback.Promise.allSettledper widget; one widget's failure can't blank the page. Per-widget rejection logscall=widget.<id>for observability.Tests —
apps/web/e2e/profile-widgets.spec.tswith all 5 plan-named tests. 4 aretest.skip()untilplaywright.config.tswebServer env sets the flag (4 specific follow-ups documented inline for Plan 2). 1 runnable test verifies the legacy flag-off path produces zerodata-widget-sizecards.Review findings caught + fixed (5 fix commits at the top after the implementation pass):
SessionSummaryschema DOES have it)ad127abWidgetFramehad no accessible name (getByRole('region')would fail when flag enabled)6293454LegacyCardsextraction silently reordered the production path (Distribution moved to bottom)6462372ProfileWidgetGridusedconsole.warninstead oflogger.warn6293454(bundled with #2)4efd3bb.ss-cardchrome25e905e(extractedOrgsCardInner)Brand pack §11 compliance wired into the live web app — 1 commit
New
/aboutroute mirrorsdocs/About.html(verbatim §11 fan-fiction disclaimer, attribution lockup, CIG compliance link list, project credit); both signed-in and signed-out footers carry About + Fandom-FAQ + RSI-Fankit + attribution chip;metadataexpanded withmetadataBase+openGraph+twitter+ icons pointing at/social/og.png; social PNGs copied intoapps/web/public/social/; newCompassStarinline-SVG component replaces literal star char in the marketing nav and TopBar. Closes the gap where the brand pack landed in standalone artifacts only. (commitcde8ad7)Local Windows build fix — 1 commit
output: 'standalone'is now env-gated behindNEXT_STANDALONE_BUILD=1; default OFF sopnpm --filter web buildno longer EPERMs on Windows trying to symlink pnpm's nestednode_modules. Dockerfile builder stage sets the env var so prod images still get the standalone bundle the runtime stage requires. (commitc484783)What's NOT in this PR (deferred to follow-up plans)
?edit=1routing, auto-save,@dnd-kit/sortabledependency). Will widen the framework to visitors when share-toggle plumbing lands.combat_mission,economy,travel,records,recent_activity) + 5 new share toggles.hangarwidget (cookie-gated, refresh-via-tray) + Loadout parser-side roll-up onBurstSummaryinstarstats-core+ Re-parse trigger + Loadout widget + flag flip onlive.Invariants honoured (per CLAUDE.md)
IF NOT EXISTS, no NOT NULL, no default).if let Err(e) = ... { tracing::warn!(...) }.humanTitleForEntryis the only event-headline path (no new headlines in this PR).In-Transitfilter set unaffected.events.sent_atis sync truth — unrelated to layout column.Promise.allSettledfor the multi-widget render.<h2 className="metric-card__title">only.ss-eyebrowpartially absorbed: WidgetFrame owns the eyebrow slot for the four converted widgets.WidgetSize) withas_strround-trip.share_metadata/share_reports.Test plan
Build & Testworkflow passes onubuntu-latest+windows-latestcargo test --workspace --exclude starstats-client(expect 424 incl. 5 new profile_layout_routes + 6 store tests)pnpm --filter web typecheckcleanpnpm --filter web buildcleanNEXT_PUBLIC_PROFILE_WIDGETS=1inapps/web/.env.local, runpnpm --filter web dev, visit/u/<your-handle>while signed in as yourself. Expect the redesigned Sessions card (count + total hours + Last Played mini-card + event-count pill) plus Heatmap / Orgs / Entities. NO raw session UUIDs anywhere./u/<handle>(or while signed-out) → legacy path (nodata-widget-sizeattributes in DOM).GET /v1/users/me/profile-layoutwith a bearer token →{"layout":null,"source":"default"}for a fresh user.action="profile_layout.updated"+ non-emptydiff./aboutrenders verbatim §11 disclaimer + attribution lockup at desktop / tablet / mobile widthshttps://starstats.dev/via a card validator → resolves/social/og.png(1200x630)/) and signed-in TopBar (/dashboard)docker build apps/web/Dockerfilestill emits a working standalone bundle in.next/standalone/Notes
NEXT_PUBLIC_PROFILE_WIDGETSdefaults OFF, so merging this PR doesn't change anything users see until the flag is flipped.🤖 Generated with Claude Code