Skip to content

feat: profile-widgets phase 1+2 + brand pack §11 + windows build fix#40

Merged
ntatschner merged 23 commits into
mainfrom
feat/profile-widgets-phase1-2
May 20, 2026
Merged

feat: profile-widgets phase 1+2 + brand pack §11 + windows build fix#40
ntatschner merged 23 commits into
mainfrom
feat/profile-widgets-phase1-2

Conversation

@ntatschner
Copy link
Copy Markdown
Collaborator

@ntatschner ntatschner commented May 19, 2026

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.md
Plan: docs/superpowers/plans/2026-05-19-profile-data-display-redesign-phase1-2.md

When the flag is OFF (default), the profile page renders byte-identical to main via a LegacyCards extraction. 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)

  • Additive users.profile_layout JSONB column (NULL = use DEFAULT_LAYOUT web-side).
  • ProfileLayoutStore trait + 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 via tower::ServiceExt::oneshot + real JWTs from fresh_pair(). Includes the audit-emission assertion.
  • New audit action profile_layout.updated as pub const ACTION_PROFILE_LAYOUT_UPDATED. Best-effort emission per CLAUDE.md.
  • utoipa annotations, openapi.rs paths + schemas, bin/openapi.rs mod stubs, TS client regenerated.

Web framework

  • widgets/types.tsWidgetId, WidgetSize, ViewerCtx, WidgetDef contract.
  • widgets/WidgetFrame.tsx — server-component chrome using .ss-card + .metric-card* + .ss-eyebrow. aria-label={title} so getByRole('region') works.
  • lib/profile-layout.tsPROFILE_WIDGETS_ENABLED flag, DEFAULT_LAYOUT, projectLayout (drops unknown ids, appends missing as enabled:false — forward-compat for Plan 2's edit mode), getProfileLayoutForRender.
  • widgets/registry.tsWIDGETS, WIDGETS_BY_ID, REGISTERED_IDS.

The four converted widgets

  • sessions.tsxthe headline redesign. Compact: "N sessions · Xh played" + Last-played mini-card. Expanded: list with when (relative + ISO tooltip) + duration + event-count pill. Raw s.id removed (lives only in the row's href).
  • heatmap.tsx — thin wrap of DayHeatmap. Compact = 30d, expanded = 180d.
  • orgs.tsx — uses new OrgsCardInner (extracted in fix commit) to avoid double .ss-card chrome.
  • entities.tsx — inline nav card matching the existing entities-nav-card styling.

Page wiringapps/web/src/app/u/[handle]/page.tsx gates on PROFILE_WIDGETS_ENABLED && viewerCtx.isOwner with <ProfileWidgetGrid ctx={viewerCtx} /> or <LegacyCards ... /> fallback. Promise.allSettled per widget; one widget's failure can't blank the page. Per-widget rejection logs call=widget.<id> for observability.

Testsapps/web/e2e/profile-widgets.spec.ts with all 5 plan-named tests. 4 are test.skip() until playwright.config.ts webServer env sets the flag (4 specific follow-ups documented inline for Plan 2). 1 runnable test verifies the legacy flag-off path produces zero data-widget-size cards.

Review findings caught + fixed (5 fix commits at the top after the implementation pass):

# Finding Fix commit
1 Sessions widget dropped the event_count chip (SessionSummary schema DOES have it) ad127ab
2 WidgetFrame had no accessible name (getByRole('region') would fail when flag enabled) 6293454
3 LegacyCards extraction silently reordered the production path (Distribution moved to bottom) 6462372
4 ProfileWidgetGrid used console.warn instead of logger.warn 6293454 (bundled with #2)
5 PUT route audit emission was untested 4efd3bb
6 Orgs widget rendered double .ss-card chrome 25e905e (extracted OrgsCardInner)

Brand pack §11 compliance wired into the live web app — 1 commit

New /about route mirrors docs/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; metadata expanded with metadataBase + openGraph + twitter + icons pointing at /social/og.png; social PNGs copied into apps/web/public/social/; new CompassStar inline-SVG component replaces literal star char in the marketing nav and TopBar. Closes the gap where the brand pack landed in standalone artifacts only. (commit cde8ad7)

Local Windows build fix — 1 commit

output: 'standalone' is now env-gated behind NEXT_STANDALONE_BUILD=1; default OFF so pnpm --filter web build no longer EPERMs on Windows trying to symlink pnpm's nested node_modules. Dockerfile builder stage sets the env var so prod images still get the standalone bundle the runtime stage requires. (commit c484783)

What's NOT in this PR (deferred to follow-up plans)

  • Plan 2: Edit-mode UX (pencil icon, drag/visibility/size controls, server action, ?edit=1 routing, auto-save, @dnd-kit/sortable dependency). Will widen the framework to visitors when share-toggle plumbing lands.
  • Plan 3: Five new widgets (combat_mission, economy, travel, records, recent_activity) + 5 new share toggles.
  • Plan 4: hangar widget (cookie-gated, refresh-via-tray) + Loadout parser-side roll-up on BurstSummary in starstats-core + Re-parse trigger + Loadout widget + flag flip on live.

Invariants honoured (per CLAUDE.md)

  • Migration is additive only + byte-immutable (IF NOT EXISTS, no NOT NULL, no default).
  • Audit emission is best-effort wrapped in if let Err(e) = ... { tracing::warn!(...) }.
  • Server holds zero RSI credentials.
  • humanTitleForEntry is the only event-headline path (no new headlines in this PR).
  • In-Transit filter set unaffected.
  • events.sent_at is sync truth — unrelated to layout column.
  • Promise.allSettled for the multi-widget render.
  • h1 type plateau respected — widgets render <h2 className="metric-card__title"> only.
  • ss-eyebrow partially absorbed: WidgetFrame owns the eyebrow slot for the four converted widgets.
  • Theme tokens scoped (no new themed CSS introduced).
  • Closed-vocabulary enum (WidgetSize) with as_str round-trip.
  • Trait + Postgres + Memory store pattern mirrors share_metadata / share_reports.
  • 401 reserved for auth failures only (validation errors → 400).

Test plan

  • CI: Build & Test workflow passes on ubuntu-latest + windows-latest
  • cargo test --workspace --exclude starstats-client (expect 424 incl. 5 new profile_layout_routes + 6 store tests)
  • pnpm --filter web typecheck clean
  • pnpm --filter web build clean
  • Local smoke (profile widgets): set NEXT_PUBLIC_PROFILE_WIDGETS=1 in apps/web/.env.local, run pnpm --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.
  • Visit another user's /u/<handle> (or while signed-out) → legacy path (no data-widget-size attributes in DOM).
  • Unset the flag → page falls back to legacy for owner too. Confirm Distribution sits between Heatmap and Sessions (original order).
  • Hit GET /v1/users/me/profile-layout with a bearer token → {"layout":null,"source":"default"} for a fresh user.
  • PUT a 2-entry layout → 200 + canonical-form response + audit log row with action="profile_layout.updated" + non-empty diff.
  • PUT an oversized array (33 entries) → 400 (not 401).
  • Visual: /about renders verbatim §11 disclaimer + attribution lockup at desktop / tablet / mobile widths
  • Visual: signed-in and signed-out footers show About + Fandom FAQ + RSI Fankit + attribution chip without wrap collapse
  • OG preview: scrape https://starstats.dev/ via a card validator → resolves /social/og.png (1200x630)
  • Compass-star primitive renders in marketing nav (/) and signed-in TopBar (/dashboard)
  • Production smoke: docker build apps/web/Dockerfile still emits a working standalone bundle in .next/standalone/

Notes

  • Draft because Plan 1 is phase 1+2 of the 4-plan widget feature; this PR is primarily to run CI rather than request immediate review.
  • The brand pack and Windows build-fix pieces are independently shippable if you want to cherry-pick them onto a focused PR off main.
  • The widget work itself can also ship behind the flag — NEXT_PUBLIC_PROFILE_WIDGETS defaults OFF, so merging this PR doesn't change anything users see until the flag is flipped.

🤖 Generated with Claude Code

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.
Nigel Tatschner 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.
@ntatschner ntatschner force-pushed the feat/profile-widgets-phase1-2 branch from 481c2a4 to ec74ff1 Compare May 20, 2026 00:11
Nigel Tatschner 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.
@ntatschner ntatschner marked this pull request as ready for review May 20, 2026 03:02
Copilot AI review requested due to automatic review settings May 20, 2026 03:02
@ntatschner ntatschner merged commit ac3f60a into main May 20, 2026
8 checks passed
@ntatschner ntatschner deleted the feat/profile-widgets-phase1-2 branch May 20, 2026 03:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-layout routes with audit emission and OpenAPI registration.
  • Add a Next.js widget framework (WidgetFrame, registry, converted Sessions/Heatmap/Orgs/Entities widgets) and gate profile rendering behind NEXT_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 },
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants