From 44b91f03b5e25c05855b0e9f5e2be5c457879cd9 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 3 Sep 2026 06:16:29 +0200 Subject: [PATCH 01/66] docs: hi-fi pages design spec, gap analysis and implementation plan --- .../plans/2026-09-03-hifi-pages.md | 336 ++++++++++++++++++ .../specs/2026-09-03-hifi-pages-design.md | 107 ++++++ .../specs/hifi-gap/auth-screens-02-07.md | 170 +++++++++ .../hifi-gap/flags-files-confirms-19-21.md | 138 +++++++ .../landing-errors-mobile-01-08-28.md | 135 +++++++ .../specs/hifi-gap/permissions-14-15.md | 111 ++++++ .../specs/hifi-gap/settings-16-18.md | 104 ++++++ ...l-dashboard-doctor-branding-00-09-27-26.md | 153 ++++++++ .../hifi-gap/tasks-workers-audit-22-25.md | 195 ++++++++++ .../superpowers/specs/hifi-gap/users-10-13.md | 216 +++++++++++ 10 files changed, 1665 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-hifi-pages.md create mode 100644 docs/superpowers/specs/2026-09-03-hifi-pages-design.md create mode 100644 docs/superpowers/specs/hifi-gap/auth-screens-02-07.md create mode 100644 docs/superpowers/specs/hifi-gap/flags-files-confirms-19-21.md create mode 100644 docs/superpowers/specs/hifi-gap/landing-errors-mobile-01-08-28.md create mode 100644 docs/superpowers/specs/hifi-gap/permissions-14-15.md create mode 100644 docs/superpowers/specs/hifi-gap/settings-16-18.md create mode 100644 docs/superpowers/specs/hifi-gap/shell-dashboard-doctor-branding-00-09-27-26.md create mode 100644 docs/superpowers/specs/hifi-gap/tasks-workers-audit-22-25.md create mode 100644 docs/superpowers/specs/hifi-gap/users-10-13.md diff --git a/docs/superpowers/plans/2026-09-03-hifi-pages.md b/docs/superpowers/plans/2026-09-03-hifi-pages.md new file mode 100644 index 00000000..079c1b16 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-hifi-pages.md @@ -0,0 +1,336 @@ +# Hi-Fi Pages Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make all 28 screens of the "Hi-Fi Pages" design deck real in this app — structure, copy, states and the small backend additions they need — and verify every screen in a browser. + +**Architecture:** Shared primitives land first in `packages/ui` (stat card, segmented control, confirm dialog, password inputs, relative time, initials, page-shell slots, split auth shell, theme). Then each module's screens are brought to the deck by an owner task with disjoint file ownership so tasks can run in parallel in one worktree. Backend additions are small and local to each module (new props, a few endpoints, two columns on `users`). Audit-log label resolution runs last because it touches every module's `register_audit_links`. + +**Tech Stack:** Python 3.12 / FastAPI / SQLModel / Alembic; Inertia.js + React 19 + Tailwind 4 + shadcn (vendored under `packages/ui/src/components/ui`); vitest + testing-library; pytest with the `simple_module_test` fixtures; Playwright for browser verification. + +**Spec:** `docs/superpowers/specs/2026-09-03-hifi-pages-design.md` — the decisions. Per-screen evidence: `docs/superpowers/specs/hifi-gap/*.md`. Deck source (one file per screen): `/tmp/hifi/screens/NN-.html`, tokens `/tmp/hifi/tokens.html`, sample data `/tmp/hifi/script.js`. If `/tmp/hifi` is gone, re-extract it from `docs/superpowers/specs/hifi-gap/` (the gap files quote every string) — the deck HTML is not committed. + +## Global Constraints + +- Follow `CLAUDE.md` (repo root) and `docs/framework-conventions.md`. In particular: **300-line cap** on `.py/.ts/.tsx`; **no user-visible string literals in `.tsx`** — every string goes through `t(keys..…)` from `@simple-module-py/i18n` (wrap technical literals in `` or mark `// i18n-exempt: `); SQLModel for every model/DTO; per-module `SM__*` settings; Zod schemas built inside hooks. +- After adding catalog keys run `make gen-i18n` (Task 0 adds it) — never hand-edit `packages/i18n/src/{keys.generated,generated-resources}.ts`. +- Copy is the deck's copy, verbatim, except the departures listed in the spec. Sentence case ("Feature flags", "Go home"). Status/scope/type values that the deck shows lowercase are lowercase. +- Typography/colour: use the existing tokens (`font-[var(--font-display)]` = Sora for headings/values, `font-mono` for keys/ids/commands, `text-primary-700` / `bg-primary-600/10` for the emerald "soft" pill, amber = `text-amber-700 bg-amber-50 border-amber-200`, red = `text-red-700 bg-red-50 border-red-200`, blue = `text-blue-700 bg-blue-50 border-blue-200`). Never hardcode hex in TSX unless the deck value is a data value (colour swatch). +- Tables: header cells `bg-secondary/40 text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground`; in-card footers use `border-t px-4 py-3 flex items-center justify-between text-sm text-muted-foreground`. +- Pagination copy everywhere: "Showing {from}–{to} of {total}" (en dash), buttons "Previous" / "Next", always visible. +- Phone (< `lg`): minimum 44px hit targets on primary controls (`min-h-11`), no horizontal scroll at 390px. +- Tests: Python in `modules//tests/` using `client`/`authenticated_client`; JS in `modules//tests-js/*.test.tsx` or `packages/ui/src/**/*.test.tsx`. Run scoped commands while other tasks are in flight (see each task's "Verify" step); the full `make lint && make test && make doctor` runs in Task 12. +- **Parallel-safe git:** commit only your own paths with `git add -- ` (never `-A`, `-a`, `stash`, `reset`, `checkout -- .`). If git reports `index.lock`, wait 3 s and retry. Shared files other tasks also edit (`packages/ui/locales/en.json`, `packages/ui/src/index.ts`, `modules/users/users/locales/en.json`, `modules/users/users/contracts/schemas.py`): make surgical `Edit`s, never rewrite the file; if an edit fails because the file changed, re-read and retry. +- Do not edit files owned by another task (each task lists its files). If you need a change there, write it in your report under "Cross-task requests". + +--- + +### Task 0: Menu-section regression fix + `make gen-i18n` + +**Files:** +- Modify: `modules/branding/branding/module.py` (menu item: `section=MenuSection.ADMIN_SIDEBAR`, `order=105` — verify current state first; gap file says they were dropped by #280, the worktree base may already have them) +- Modify: `modules/feature_flags/feature_flags/module.py` (same check) +- Test: `modules/branding/tests/test_menu_section.py`, `modules/feature_flags/tests/test_menu_section.py` +- Create: `scripts/gen_i18n.py` +- Modify: `Makefile` (add `gen-i18n` target next to `gen-pages`) + +**Interfaces:** +- Produces: `make gen-i18n` — regenerates `packages/i18n/src/{generated-resources,keys.generated}.ts` from every installed module's `locales/`, `host/locales`, `packages/ui/locales` without booting the app. + +- [ ] **Step 1: Menu tests.** For each module write a test that builds a `MenuRegistry`, calls `Module().register_menu_items(registry)`, and asserts the item with `url == MENU_URL` has `section == MenuSection.ADMIN_SIDEBAR` (and `order == 105` for Branding). Run them; fix `module.py` if they fail (the base commit may already be correct — then the tests simply guard it). +- [ ] **Step 2: `scripts/gen_i18n.py`:** + ```python + """Regenerate the typed i18n key files without booting the host. `make gen-i18n`.""" + from pathlib import Path + from simple_module_core.discovery import discover_modules + from simple_module_hosting.i18n_manifest import emit_frontend_types_for_modules + from simple_module_hosting.settings import Settings + + ROOT = Path(__file__).resolve().parent.parent + + if __name__ == "__main__": + emit_frontend_types_for_modules(Settings(), discover_modules(), ROOT) + print("i18n key files regenerated") + ``` + Makefile: `gen-i18n:` → `uv run --project host python scripts/gen_i18n.py`. Add `gen-i18n` to `.PHONY`. +- [ ] **Step 3: Verify** `make gen-i18n` runs and leaves `git status` clean for the generated files (no key changes yet). `uv run pytest modules/branding/tests/test_menu_section.py modules/feature_flags/tests/test_menu_section.py -q`. +- [ ] **Step 4: Commit** `git add -- scripts/gen_i18n.py Makefile modules/branding modules/feature_flags && git commit -m "fix: guard admin-sidebar menu sections; add make gen-i18n"`. + +--- + +### Task 1: Shared UI primitives (`packages/ui` components) + +**Files:** +- Modify: `packages/ui/src/components/StatCard.tsx` (+ update `StatCard.test.tsx`) +- Create: `packages/ui/src/components/SegmentedControl.tsx` (+ `.test.tsx`) +- Create: `packages/ui/src/components/ConfirmActionDialog.tsx` (+ `.test.tsx`) +- Create: `packages/ui/src/components/PasswordInput.tsx`, `packages/ui/src/components/PasswordStrength.tsx` (+ `password-strength.test.ts` for `scorePassword`) +- Modify: `packages/ui/src/lib/relative-time.ts` (+ `.test.ts`); Create: `packages/ui/src/hooks/use-relative-time.ts` +- Create: `packages/ui/src/lib/initials.ts` (+ `.test.ts`) +- Create: `packages/ui/src/lib/theme.ts` (+ `.test.ts`) +- Modify: `packages/ui/locales/en.json` (surgical), `packages/ui/src/index.ts` (add exports) + +**Interfaces (produces — other tasks code against these exactly):** +```ts +// StatCard.tsx — deck layout: label top-left, optional icon tile top-right, value (Sora 25–30px), delta inline text +interface StatCardProps { + label: string; + value: React.ReactNode; + icon?: LucideIcon; // optional now ("plain" stats) + delta?: string; // rendered as inline text after/under the value, coloured by deltaTone + deltaTone?: 'success' | 'info' | 'warning' | 'destructive' | 'secondary'; + suffix?: string; // muted text right after the value, e.g. "/ 8" + tone?: 'default' | 'warning' | 'destructive'; // tints the whole card (bg + border + value colour) + valueClassName?: string; + className?: string; +} +// SegmentedControl.tsx — `--sec` track, active option = card bg + shadow; role="radiogroup"/"radio" +interface SegmentedOption { value: V; label: string; count?: number; disabled?: boolean } +interface SegmentedControlProps { + value: V; onChange: (next: V) => void; options: SegmentedOption[]; + 'aria-label': string; size?: 'sm' | 'md'; className?: string; +} +// ConfirmActionDialog.tsx — over ui/alert-dialog. Controlled. +interface ConfirmActionDialogProps { + open: boolean; onOpenChange: (open: boolean) => void; + tone?: 'destructive' | 'primary'; // icon tile tint + confirm button variant + icon: LucideIcon; + title: React.ReactNode; description: React.ReactNode; + confirmLabel: string; cancelLabel: string; + onConfirm: () => void; busy?: boolean; + confirmText?: { expected: string; label: string; placeholder?: string }; // type-to-confirm (mono input, case-insensitive match), gates the confirm button + children?: React.ReactNode; // extra block between description and buttons (args box) +} +// PasswordInput.tsx — Input with trailing show/hide text button +interface PasswordInputProps extends Omit, 'type'> { showLabel: string; hideLabel: string } +// PasswordStrength.tsx +export type StrengthLevel = 'none' | 'weak' | 'ok' | 'strong'; +export function scorePassword(pw: string): { level: StrengthLevel; percent: number } +// '' → none/0; <8 chars or all digits → weak/33; ≥8 with letters+digits → ok/66; ≥12 with 3 classes → strong/100 +interface PasswordStrengthProps { password: string; labels: Record, string>; hint?: string; className?: string } +// relative-time.ts +export const RELATIVE_AGE_KEYS = { unknown, justNow, seconds, minutes, hours, days: 'ui.relative_time.days_ago', months: 'ui.relative_time.months_ago', years: 'ui.relative_time.years_ago' } +export function relativeAge(ageMs: number): RelativeAge // buckets: <10s justNow, <1m seconds, <1h minutes, <24h hours, <30d days, <365d months, else years +export const RELATIVE_UNTIL_KEYS = { minutes: 'ui.relative_time.in_minutes', hours: 'ui.relative_time.in_hours', days: 'ui.relative_time.in_days', expired: 'ui.relative_time.expired' } +export function relativeUntil(msUntil: number): RelativeAge // ≤0 expired, <1h minutes, <24h hours, else days +// hooks/use-relative-time.ts +export function useRelativeTime(): { ago: (iso: string | null | undefined) => string; until: (iso: string | null | undefined) => string } +// uses useT(); `now` sampled once per render; returns t(ui.relative_time.unknown) for unparsable input +// lib/initials.ts +export function initials(name?: string | null, email?: string | null): string +// "Dana Rivera" → "DR"; "admin" → "AD"; "rob@example.com" (no name) → "RO"; nothing → "?" +// lib/theme.ts +export type ThemePreference = 'light' | 'dark' | 'system'; +export const THEME_STORAGE_KEY = 'sm.theme'; +export function readThemePreference(): ThemePreference; // localStorage, default 'system' +export function resolveTheme(pref: ThemePreference, prefersDark: boolean): 'light' | 'dark'; +export function applyTheme(pref: ThemePreference): void; // toggles `dark` on +export function setThemePreference(pref: ThemePreference): void; // persists + applies +export function initTheme(): () => void; // apply + subscribe to matchMedia('(prefers-color-scheme: dark)'); returns unsubscribe +``` +- Catalog keys to add in `packages/ui/locales/en.json` (namespace `ui`): `relative_time.days_ago` "{count}d ago", `relative_time.months_ago` "{count}mo ago", `relative_time.years_ago` "{count}y ago", `relative_time.in_minutes` "in {count}m", `relative_time.in_hours` "in {count}h", `relative_time.in_days` "in {count}d", `relative_time.expired` "expired". + +- [ ] **Step 1:** Tests first for `scorePassword`, `relativeAge`/`relativeUntil` buckets, `initials`, `theme` (jsdom: `applyTheme('dark')` adds the class, `resolveTheme('system', true) === 'dark'`), `SegmentedControl` (renders radios, click calls `onChange`, counts rendered), `ConfirmActionDialog` (confirm disabled until `confirmText.expected` typed; `onConfirm` called), `StatCard` (tone class + suffix). Run `npx vitest run packages/ui` — expect failures. +- [ ] **Step 2:** Implement each component. `StatCard`: `Card` → `CardContent` with a flex row (label / icon tile `h-8 w-8 rounded-lg bg-primary-600/10 text-primary-700`), value `font-[var(--font-display)] text-[26px] font-bold tracking-tight`, delta `text-sm` coloured by tone, `tone='warning'` → `bg-amber-50/60 border-amber-200 [&_.stat-value]:text-amber-700`, `destructive` → red equivalents. `ConfirmActionDialog`: `AlertDialogContent` with a 40px icon tile (`bg-red-50 text-red-600` / `bg-primary-600/10 text-primary-700`), left-aligned title/description, optional children, footer Cancel (outline) + confirm (`variant="destructive"` or default). +- [ ] **Step 3:** Export everything from `packages/ui/src/index.ts`; `make gen-i18n`. +- [ ] **Step 4: Verify** `npx vitest run packages/ui`, `npx tsc --noEmit -p packages/ui/tsconfig.json`, `npx biome check packages/ui`. +- [ ] **Step 5: Commit** `git add -- packages/ui packages/i18n/src && git commit -m "feat(ui): deck primitives — StatCard layout, SegmentedControl, ConfirmActionDialog, password inputs, relative time, initials, theme"`. + +--- + +### Task 2: App shell, PageShell slots, split auth shell, theme boot + +**Files:** +- Modify: `packages/ui/src/components/page-heading.tsx`, `packages/ui/src/components/PageShell.tsx`, `packages/ui/src/components/AppTopbar.tsx`, `packages/ui/src/components/LocaleSwitcher.tsx`, `packages/ui/src/layouts/{SidebarLayout,SidebarUserMenu,AuthenticatedLayout,AdminLayout,AuthCardShell}.tsx` +- Create: `packages/ui/src/layouts/MobileBar.tsx` (extracted from SidebarLayout to stay under 300 lines), `packages/ui/src/layouts/AuthSplitAside.tsx` +- Modify: `host/client_app/app.tsx` (call `initTheme()` in `setup`), `packages/ui/locales/en.json` (surgical), tests `packages/ui/src/components/AppTopbar.test.tsx`, `packages/ui/src/layouts/*.test.tsx` as needed +- Reads (do not modify): Task 1's `initials`, `theme`, `SegmentedControl` + +**Interfaces (produces):** +```ts +// page-heading.tsx +interface Heading { title: string; url: string; section?: string; back?: string; mono?: boolean; mobileAction?: { label: string; href?: string; onClick?: () => void } } +export function useReportPageHeading(heading: Omit): void // keep the old (title, section) overload working +export function usePageChrome(currentUrl: string): Heading | null +// PageShell.tsx +interface PageShellProps { + title: string; description?: React.ReactNode; children; actions?; maxWidth?: 'full' | 'screen-xl'; section?: string; + titleClassName?: string; leading?: React.ReactNode; badge?: React.ReactNode; + mobileAction?: { label: string; href?: string; onClick?: () => void }; back?: string; mono?: boolean; +} +// AuthCardShell.tsx +interface AuthCardShellProps { children; variant?: 'card' | 'split-dark' | 'split-light'; aside?: React.ReactNode; width?: 'md' | 'lg' } +// 'card' = today's centred glass card (default). 'split-dark' = dark brand column (bg-landing-bg, blob, lockup, copyright) left + card right. +// 'split-light' = light column with `aside` content (no card) left + card right. Both collapse to a single column below `lg` (aside on top). +// AuthSplitAside.tsx — dark column content used by Login: lockup + h2 + p + check rows + "© {year} {appName}"; props { heading, body, checks: string[] } +``` +- Shell requirements (spec § Shell): sidebar active item `bg-primary text-white` (app) / `bg-red-600 text-white` (admin) with `rounded-lg`, 15px labels; nav rows `min-h-11 lg:min-h-0`; topbar adds a **Log out** `Link method="post" as="button"` (outline, from the `userDropdown` item with `method === 'post'`, label from `ui.topbar.log_out`); locale pill = text code (`EN`) in a bordered 8px-radius pill; with one locale render a non-interactive pill with `title={t(ui.switcher.single_locale)}`; sidebar user row avatar = `initials(name, email)` on `bg-white/10 text-white` (no ring). Phone bar: left = `back` chevron `Link` when the page declares one else hamburger; title = `usePageChrome` title (`font-mono` when `mono`), else app name; right = `mobileAction` (text link, `min-h-11`) else avatar initials. Locale control moves into the drawer footer above the user row. Drawer `w-full sm:w-72 lg:w-64`, header `✕` + app name, rows without icons below `lg` (`NavIcon` gets `className="hidden lg:block"`). +- Catalog keys (`ui`): `topbar.log_out` "Log out", `switcher.single_locale` "Only {locale} is enabled", `sidebar.back` "Back". +- Keep `SidebarLayout.tsx` ≤ 300 lines by moving the mobile bar into `MobileBar.tsx`. + +- [ ] **Step 1:** Tests: `AppTopbar` renders "Log out" when a post item exists; `PageShell` reports `mobileAction`/`back` (render inside `PageHeadingProvider`, read via a probe component using `usePageChrome`); `AuthCardShell variant="split-dark"` renders the aside. Run `npx vitest run packages/ui` → fail. +- [ ] **Step 2:** Implement. Run the same tests → pass. `make gen-i18n`. +- [ ] **Step 3: Verify** `npx vitest run packages/ui host`, `npx tsc --noEmit -p packages/ui/tsconfig.json`, `npx tsc --noEmit -p host/client_app/tsconfig.json`, `npx biome check packages/ui host/client_app`. +- [ ] **Step 4: Commit** `git add -- packages/ui host/client_app/app.tsx packages/i18n/src && git commit -m "feat(shell): deck shell — solid active nav, topbar log out + locale pill, phone bar title/action/back, split auth shell, theme boot"`. + +--- + +### Task 3: Landing + error screens (host) + +**Files:** +- Modify: `host/client_app/pages/Landing.tsx`, `host/client_app/pages/Error.tsx`, `host/client_app/components/CopyCommand.tsx` (+ test), `host/locales/en.json`, `packages/ui/src/components/ErrorScreen.tsx`, `packages/ui/src/layouts/PublicLayout.tsx` (nav: one "Sign in" / "Open dashboard"), `packages/ui/locales/en.json` (surgical: `public_nav.sign_in` "Sign in", `public_nav.open_dashboard` "Open dashboard") +- Modify: `framework/hosting/simple_module_hosting/_error_handlers.py` (+ `framework/hosting/tests/test_error_page_shared_props.py`): add `required_permission` to the Error page props, parsed from a `HTTPException.detail` of the form "Permission required: " (see `simple_module_hosting/permissions.py`); `None` otherwise. +- Evidence: `docs/superpowers/specs/hifi-gap/landing-errors-mobile-01-08-28.md` §01 and §08; deck `/tmp/hifi/screens/01-landing.html`, `08-errors.html`. + +**Requirements:** every delta in the gap file's §01.4 and §08.4 except: keep the CopyCommand wrapping behaviour (delta 6), keep footer links (spec departure), nav shows "Sign in" (anon) or "Open dashboard" (authed) and no "Sign up". Landing badge reads "✦ Batteries-included FastAPI + Inertia starter". Error page: numeral colour per status (`text-amber-700` 403, `text-primary-700` 404, `text-red-600` 5xx) at `text-[64px]`, bordered card `max-w-md`, no HTTP pill, titles "No access" / "Not found" / "Something broke", descriptions per deck (403 uses `required_permission` in `` when present, else "Your role doesn't include the permission this page needs. Ask an admin to grant it."), 500 shows `CopyableId` with `label={id.slice(0,8)}` prefixed `req_` and a **Retry** button (`router.reload()`), other statuses keep Go back; "Go home" → `/dashboard/` when `auth.isAuthenticated` else `/`. Extend the same rules to 401/419/422/429/503 (keep their existing copy, sentence-case the titles). + +- [ ] **Step 1:** Python test: a 403 raised by `RequiresPermission("settings.manage")` renders the Error page with `required_permission == "settings.manage"`. JS test: `CopyCommand` shows the visible "Copy" label and "✓ Copied" after click. Run → fail. +- [ ] **Step 2:** Implement backend + pages + copy. `make gen-i18n`. +- [ ] **Step 3: Verify** `uv run pytest framework/hosting/tests host/tests -q`, `npx vitest run host packages/ui`, `npx tsc --noEmit -p host/client_app/tsconfig.json`, `npx biome check host/client_app packages/ui/src/components/ErrorScreen.tsx packages/ui/src/layouts/PublicLayout.tsx`, `node scripts/check_untranslated_strings.mjs` (only your files matter). +- [ ] **Step 4: Commit** own paths, `feat(host): landing and error screens per the hi-fi deck`. + +--- + +### Task 4: Public auth screens (users auth_local + keycloak) + +**Files:** +- Modify: `modules/users/users/pages/{Login,Register,ForgotPassword,ResetPassword,VerifyEmail,AcceptInvite}.tsx` (split large ones into `modules/users/users/auth_local/components/*.tsx` — that directory is not scanned as pages), `modules/users/users/auth_local/{views.py,api.py,invite_preview.py}`, new `modules/users/users/auth_local/token_preview.py`, `modules/users/users/contracts/schemas.py` (surgical: `LoginRequest.remember: bool = False`, `AcceptInviteRequest.full_name: str | None = None`), `modules/users/users/locales/en.json` (surgical), `modules/users/users/settings.py` only if a lifetime constant is missing +- Modify: `modules/keycloak/keycloak/pages/{Login,LoggedOut}.tsx`, `modules/keycloak/keycloak/endpoints/views.py`, `modules/keycloak/keycloak/provider.py` (public path), `modules/keycloak/keycloak/locales/en.json` +- Modify: `tests/e2e/test_{audit_log_ui,document_titles,error_pages,i18n_rendering,settings_ui,shell_ui}.py` — the login button is now "Sign in" +- Tests: `modules/users/tests/test_auth_screens_props.py` (new), extend `modules/users/tests/test_views.py`, `modules/keycloak/tests/test_views.py` +- Evidence: `hifi-gap/auth-screens-02-07.md`; deck `02`–`07`. + +**Interfaces (consumes):** `AuthCardShell variant`, `AuthSplitAside`, `PasswordInput`, `PasswordStrength`, `useRelativeTime`. **Produces:** invite preview reads optional `invited_by` (display name) claim and `exp` → `invite.invited_by_name: str | None`, `invite.expires_at: str | None` (Task 5 mints the claim; tolerate its absence). + +**Requirements:** every delta in §02–§07 of the gap file, with these rulings: "Keep me signed in for 30 days" posts `remember: true` and the login endpoint sets the session cookie max-age to 30 days (else the configured default) — implement by setting `request.session` max-age via the response cookie (see how the session cookie is issued in `auth_local/api.py`; if the session middleware owns the cookie, set `request.scope["session_max_age"]`-style override only if supported — otherwise issue the cookie explicitly with `max_age=30*24*3600`); "Waiting on you" is a state of `Login.tsx` shown after `LOGIN_USER_NOT_VERIFIED` with the mono email chip and "Resend verification email"; forgot view passes `reset_link_lifetime_minutes` and `mailer_delivers`; reset view decodes the token on GET (`token_preview.decode_reset_token(token, secret) -> {email} | None`, `verify_exp=True`) and renders the "Link expired" card when it is `None`; "Save and sign in" resets then POSTs login with the decoded email; verify view decodes with `verify_exp=False` to pass `email` for "Resend verification" and `verification_lifetime_hours`; Keycloak gets `GET /keycloak/logged-out` (public path) + `realm_url` prop and both pages render inside `AuthCardShell`. Update the six e2e files' `get_by_role("button", name="Log in")` to "Sign in". + +- [ ] **Step 1:** Python tests: login with `remember` sets a cookie with `Max-Age=2592000`; forgot page props carry `reset_link_lifetime_minutes == 60` and `mailer_delivers`; reset page with an expired token renders `expired: true`; verify page passes `email` for an expired token; invite preview returns `invited_by_name`/`expires_at` when the claims are present; keycloak `/keycloak/logged-out` is 200 unauthenticated. Run → fail. +- [ ] **Step 2:** Implement backend, then pages. `make gen-i18n`. +- [ ] **Step 3: Verify** `uv run pytest modules/users/tests modules/keycloak/tests -q`, `npx tsc --noEmit -p modules/users/tsconfig.json -p modules/keycloak/tsconfig.json` (run each), `npx biome check modules/users modules/keycloak`, `node scripts/check_untranslated_strings.mjs`. +- [ ] **Step 4: Commit** own paths, `feat(users,keycloak): public auth screens per the hi-fi deck`. + +--- + +### Task 5: Users admin screens — list, add people, edit user, profile + +**Files:** +- Modify: `modules/users/users/pages/Users/{Index,AddPeople,Edit}.tsx`, `modules/users/users/pages/Users/components/*`, `modules/users/users/admin/components/*`, `modules/users/users/pages/Profile.tsx` (+ new `modules/users/users/auth_local/components/{PasswordCard,SessionsCard,PreferencesCard,ProfileDetailsCard}.tsx`), `modules/users/users/admin/{views.py,api.py,queries.py,service.py,bulk_invite.py}`, `modules/users/users/models/user.py`, `modules/users/users/contracts/schemas.py` (surgical), `modules/users/users/manager.py` (invite token claim), `modules/users/users/provider.py` (session_version check), `modules/users/users/mailer/{__init__,console,smtp}.py` + template (invite message), `modules/users/users/auth_local/views.py` **only** the `profile_page` function (Task 4 owns the rest — coordinate by editing just that function), `modules/users/users/auth_local/api.py` **only** to add `POST /me/password` and `POST /me/sessions/revoke-all` (append at the end), `modules/users/users/locales/en.json` (surgical) +- Create: `host/migrations/versions/_users_invited_at_session_version.py` via `make migration msg="users invited_at and session_version"` then review it +- Tests: `modules/users/tests/test_users_list_invites.py`, `test_admin_resend_invite.py`, `test_profile_password.py`, `test_sessions_revoke.py`, `test_bulk_invite_message.py`; JS `modules/users/tests-js/EmailChipInput.test.tsx` +- Evidence: `hifi-gap/users-10-13.md`; deck `10`–`13`. + +**Interfaces (consumes):** `StatCard` (no icon), `SegmentedControl`, `ConfirmActionDialog`, `PasswordInput`, `PasswordStrength`, `useRelativeTime`, `initials`, `PageShell` `leading`/`badge`/`mobileAction`/`back`, `theme.ts` (`setThemePreference`). **Produces:** invite tokens carry `invited_by` (inviter display name) — mint through one helper `manager.mint_invite_token(user, invited_by: str | None)` used by bulk invite and resend; `User.invited_at`, `User.session_version`; `UserListItem.invited_at`, `.invite_expires_at`, `.state: 'active'|'unverified'|'invited'|'disabled'`. + +**Requirements:** every delta in §10–§13 with the spec's rulings: Verified filter folds into Status (`all/active/unverified/invited/disabled`); kebab menu = Edit / Resend invite (invited only) / Copy reset link / Disable|Enable; whole row links to edit; "Last batch" is in-memory (React state) with Retry re-posting the single address; no redirect after a fully successful batch; Recent activity reads `audit_log` through `request.app.state` duck-typing (`getattr(app.state.sm, "audit_links", None)` is not enough — import `audit_log.service` lazily inside a `try/except ImportError` and pass `recent_activity: list[{at, summary, href}] | None`); Profile: `profile_page` passes `user` (`UserRead`), Password card posts `POST /api/users/me/password {current_password, new_password}` (403-style 400 for SSO users), Sessions card shows "This browser · signed in {ago}" from `last_login_at` and "Sign out everywhere" posts `POST /api/users/me/sessions/revoke-all` which increments `session_version`, revokes refresh tokens, and clears the current session (provider's `resolve_user` compares the session's stored `session_version` with the user's and rejects mismatches — store it in the session at login); Preferences: Language select posts the existing set-locale form, Theme select calls `setThemePreference`. Phone: users rows fold to cards (`sm:hidden` card list with avatar, email, "role · status · 2h ago", chevron), `mobileAction={{label: '+ Add', href: '/admin/users/add'}}`. + +- [ ] **Step 1:** Python tests first (list shows `state == 'invited'` for an invited user and `'unverified'` for a self-signup; resend endpoint 202 + mailer called + `invited_at` refreshed; password change rejects a wrong current password and accepts a valid change; revoke-all bumps `session_version` and the old cookie no longer authenticates; bulk invite passes `message` to the mailer). JS test for the chip input (Enter adds, invalid chip flagged, paste splits on commas/whitespace). Run → fail. +- [ ] **Step 2:** Migration, models, backend, then pages. Keep every `.tsx` under 300 lines by splitting components. `make gen-i18n`. +- [ ] **Step 3: Verify** `uv run pytest modules/users/tests -q`, `npx vitest run modules/users`, `npx tsc --noEmit -p modules/users/tsconfig.json`, `npx biome check modules/users`, `node scripts/check_untranslated_strings.mjs`, `uv run alembic -c host/alembic.ini check` (or `make doctor` if the app boots in your environment). +- [ ] **Step 4: Commit** own paths, `feat(users): admin users, add people, edit user and profile per the hi-fi deck`. + +--- + +### Task 6: Permissions screens — role edit, user grants + +**Files:** +- Modify: `modules/permissions/permissions/pages/{RoleEdit,UserEdit}.tsx`, `modules/permissions/permissions/pages/components/*` (add `GroupCard.tsx`, `PlainStat.tsx` only if `StatCard` without icon does not fit), `modules/permissions/permissions/locales/en.json` +- Tests: `modules/permissions/tests-js/RoleEdit.test.tsx` (filter by key, granted-only), `UserEdit.test.tsx` (badges) +- Evidence: `hifi-gap/permissions-14-15.md`; deck `14`, `15`. + +**Interfaces (consumes):** `StatCard`, `ui/checkbox` (`checked="indeterminate"`), `PageShell`. + +**Requirements:** every delta in §14.4 and §15.4; rulings: keep header actions (no sticky bar), keep dirty-gating, group names render as the registry's display name with the key prefix as a muted mono tag only when they differ, show both "direct" and "granted by {role}" pills when both apply, multiple roles → "granted by {a}, {b}", rows keep registry order, Cancel → `/admin/users/`, after save stay on the page (change the 303 target only if the current redirect leaves the page — check `endpoints/views.py`; if it redirects to the users list keep it, do not change backend), leave-guard ported from `modules/users/users/pages/Users/Edit.tsx`. + +- [ ] **Step 1:** JS tests first → fail. **Step 2:** implement + `make gen-i18n`. **Step 3: Verify** `npx vitest run modules/permissions`, `npx tsc --noEmit -p modules/permissions/tsconfig.json`, `npx biome check modules/permissions`, `node scripts/check_untranslated_strings.mjs`. **Step 4: Commit** `feat(permissions): role and grant editors per the hi-fi deck`. + +--- + +### Task 7: Settings screens — store, new override, module settings + +**Files:** +- Modify: `modules/settings/settings/pages/{Browse,Create,Edit,ModulesEdit}.tsx`, `modules/settings/settings/pages/components/*` (add `ResolvedValue.tsx`, `ScopeTabs.tsx`, `ModuleFieldRow.tsx` as needed), `modules/settings/settings/endpoints/views.py`, `modules/settings/settings/service.py`, `modules/settings/settings/_module_settings.py` (choices, env meta), `modules/settings/settings/locales/en.json`, `modules/settings/settings/constants.py` +- Tests: `modules/settings/tests/test_store_filters.py`, `test_known_keys_meta.py`, `test_testable_checks.py`; JS `modules/settings/tests-js/{ModuleForm,ScopeTabs}.test.tsx` +- Evidence: `hifi-gap/settings-16-18.md`; deck `16`–`18`. + +**Interfaces (consumes):** `SegmentedControl`, `ui/switch`, `ui/select`, `ConfirmActionDialog` (delete override), `useRelativeTime`. **Produces:** `browse` view accepts `scope`, `q`, `page` and returns `settings`, `pagination {page, per_page, total}`, `counts {all, system, tenant, user}`, `filters {scope, q}`; `known_keys[]` gains `env_var`, `env_set`, `default`, `requires_restart`, `is_secret`, `choices`; `testable: dict[str, list[str]]`. + +**Requirements:** every delta in §16–§18 with the spec's rulings (keep app IA; server-side filter/search/paging at 20/page; description dropped from the table; secrets stay masked in the Resolved value panel; "env fallback" row shows "not read" when the module declares no env prefix; Reveal is not implemented — the secret row reads "write-only · never returned" with a "Set new value" link; fields stay grouped by their declared `group` (flat when none); last test result kept in `sessionStorage` keyed by package). + +- [ ] **Step 1:** Python tests first (scope filter + counts + paging; known_keys meta; testable shape) → fail. **Step 2:** backend, then pages; `make gen-i18n`. **Step 3: Verify** `uv run pytest modules/settings/tests -q`, `npx vitest run modules/settings`, `npx tsc --noEmit -p modules/settings/tsconfig.json`, `npx biome check modules/settings`, `node scripts/check_untranslated_strings.mjs`. **Step 4: Commit** `feat(settings): store, new override and module settings per the hi-fi deck`. + +--- + +### Task 8: Feature flags + file storage + destructive confirms + +**Files:** +- Modify: `modules/feature_flags/feature_flags/pages/Browse.tsx`, `pages/components/{TenantPicker,ToggleConfirmDialog}.tsx`, `modules/feature_flags/feature_flags/{module.py,constants.py,locales/en.json}` (register audit links; `MENU_LABEL` "Feature flags") +- Modify: `modules/file_storage/file_storage/pages/Browse.tsx`, `pages/components/*` (add `UploadsCard.tsx`, `SelectionFooter.tsx`), `pages/upload-queue.ts`, `pages/constants.ts`, `modules/file_storage/file_storage/{endpoints/views.py,endpoints/api.py,service.py,settings.py,locales/en.json}` +- Tests: `modules/feature_flags/tests/test_audit_links.py`; `modules/file_storage/tests/test_browse_props.py`, `test_bulk_delete.py`, `test_uploader_filter.py`; JS `modules/file_storage/tests-js/upload-queue.test.ts` +- Evidence: `hifi-gap/flags-files-confirms-19-21.md`; deck `19`–`21`. + +**Interfaces (consumes):** `SegmentedControl`, `ConfirmActionDialog`, `useRelativeTime`. **Produces:** `POST /api/file-storage/files/bulk-delete {ids: [...]}` → `{deleted: n}`; browse props `backend`, `used_bytes`, `quota_bytes | null`, `max_file_size_bytes`, `allowed_content_types | null`, `uploaders: [{id, label, count}]`, `filters.uploaded_by`; `StoredFile.uploaded_by_label`. + +**Requirements:** every delta in §19–§21 with the spec's rulings (files stay in the app shell; quota segment only when `SM_FILE_STORAGE_QUOTA_BYTES` is set; unrestricted types read "any type"; uploader label resolved from `users.models.User` by `created_by` in the view (lazy import inside try/except, `None` → "—"); failure reasons parsed from the API's JSON `detail`; user-delete dialog is owned by Task 5 — you only restyle flag toggle, file delete here; the task retry dialog is Task 9's). + +- [ ] **Step 1:** Python tests first → fail. **Step 2:** implement; `make gen-i18n`. **Step 3: Verify** `uv run pytest modules/feature_flags/tests modules/file_storage/tests -q`, `npx vitest run modules/file_storage modules/feature_flags`, `npx tsc --noEmit -p modules/feature_flags/tsconfig.json` and `-p modules/file_storage/tsconfig.json`, `npx biome check modules/feature_flags modules/file_storage`, `node scripts/check_untranslated_strings.mjs`. **Step 4: Commit** `feat(feature_flags,file_storage): deck screens, uploads card, bulk delete, shared confirms`. + +--- + +### Task 9: Background tasks — index, task detail, workers (+ retry confirm) + +**Files:** +- Modify: `modules/background_tasks/background_tasks/pages/{Index,Detail,Workers}.tsx`, `pages/components/*` (add `TracebackCard.tsx`, `TaskFilters.tsx`, `WorkerCard.tsx`), `pages/constants.ts`, `modules/background_tasks/background_tasks/{endpoints/views.py,endpoints/api_admin.py,service.py,contracts/schemas.py,worker_inspector.py,locales/en.json}` +- Tests: `modules/background_tasks/tests/test_index_filters.py` (queue filter, success_24h), `test_retry_failed_bulk.py`, `test_detail_props.py` (max_retries), `test_workers_uptime.py` +- Evidence: `hifi-gap/tasks-workers-audit-22-25.md` §22–§24 and `flags-files-confirms-19-21.md` §21 (retry card); deck `22`–`24`, `21`. + +**Interfaces (consumes):** `StatCard` (`tone`), `SegmentedControl`, `ConfirmActionDialog`, `useRelativeTime`, `PageShell` (`titleClassName`, `badge`, `mono`, `back`). **Produces:** `POST /api/background_tasks/admin/executions/retry-failed?status=&queue=` → `{queued: n}`; index props `status_counts.success_24h`, `queues: list[str]`, `filters.queue`; detail prop `max_retries`; `WorkerInfo.uptime_seconds`, workers props `broker_url_redacted`. + +**Requirements:** every delta in §22–§24 with the spec's rulings (stat tiles stay clickable filters; "Retry all failed" includes stuck, respects the current filter, and confirms through `ConfirmActionDialog`; Worker column dropped; duration "—" until finished; timestamps `d MMM HH:mm:ss`; Copy copies the traceback text; retry dialog says "This one has already been retried {count} time(s)." only when `retries > 0`; offline worker subline "celery {ver} · offline"; start command `uv run celery -A scripts.run_worker:celery worker -l info -Q {queues}`). Phone order on Detail: status strip, two fact cards, traceback, full-width "Retry task" (`lg:hidden`). + +- [ ] **Step 1:** Python tests first → fail. **Step 2:** implement; `make gen-i18n`. **Step 3: Verify** `uv run pytest modules/background_tasks/tests -q`, `npx vitest run modules/background_tasks`, `npx tsc --noEmit -p modules/background_tasks/tsconfig.json`, `npx biome check modules/background_tasks`, `node scripts/check_untranslated_strings.mjs`. **Step 4: Commit** `feat(background_tasks): executions, detail and workers per the hi-fi deck`. + +--- + +### Task 10: Dashboard, Doctor (real data), Branding + +**Files:** +- Modify: `modules/dashboard/dashboard/pages/{Home,Doctor}.tsx`, `pages/components/*` (delete `DemoPlaceholders.tsx` and `doctor-data.ts`; add `doctor/{ChecksCard,MigrationsCard,DevServerCard,TerminalPanel}.tsx`), `modules/dashboard/dashboard/{endpoints/views.py,stats.py,locales/en.json}`, Create `modules/dashboard/dashboard/doctor.py` +- Modify: `framework/hosting/simple_module_hosting/{app_builder.py,migrations.py}` and `framework/core/simple_module_core/services.py` (add `diagnostics: DiagnosticsState` holder — a small dataclass with `results: list[Diagnostic]`, `ran_at`, and a `rerun()` closure — to `Services`), tests `framework/hosting/tests/test_diagnostics_state.py`, `test_migration_listing.py` +- Modify: `modules/branding/branding/pages/Manage.tsx`, `modules/branding/branding/components/*` (add `PreviewTabs.tsx`, `ImageDropzones.tsx`), `modules/branding/branding/{presets.py,locales/en.json}` +- Tests: `modules/dashboard/tests/test_doctor_props.py`, `test_dashboard_month_delta.py`; `modules/branding/tests/test_presets.py`; JS `modules/branding/tests-js/dirty-state.test.tsx` +- Evidence: `hifi-gap/shell-dashboard-doctor-branding-00-09-27-26.md` §09, §27, §26; deck `09`, `27`, `26`. + +**Interfaces (consumes):** `StatCard` (`tone`, `suffix`), `SegmentedControl`, `PageShell`. **Produces:** `app.state.sm.diagnostics` (see above); `list_migrations(project_root, current_revision) -> list[{id, module, message, applied}]` in `simple_module_hosting/migrations.py`; Doctor view props `checks`, `migrations`, `dev_server`, `stats {checks_passing, checks_total, modules_loaded, pending_migrations, python_version}`; `POST /admin/doctor/rerun` (admin) re-runs diagnostics and redirects back; dashboard prop `users_created_this_month`. + +**Requirements:** every delta in §09/§27/§26 with the spec's rulings: Doctor runs on real data only (delete fixtures); in production (diagnostics skipped at boot) the checks card shows "Diagnostics run in development only" via an empty state; "Fix"/"Generate"/"Apply pending" copy the command to the clipboard (`make migrations` / `make migrate`); dev-server rows come from settings (`vite` from `SM_VITE_DEV_URL`, `api` from the request's port, `worker` from `app.state.background_tasks` last snapshot if present else "—"); Branding presets become the deck's four (emerald `#0f766e`, slate `#475569`, indigo `#4f46e5`, amber `#b45309`) and stage locally (no immediate POST); images still upload on pick; footer editor restyled to the deck (text + link chips + "+ add link"); preview tabs App / Sign-in / Email with the banner in the brand colour only in the preview; dashboard `DemoPlaceholders` removed. + +- [ ] **Step 1:** Python tests first → fail. **Step 2:** implement; `make gen-i18n`. **Step 3: Verify** `uv run pytest modules/dashboard/tests modules/branding/tests framework/hosting/tests framework/core/tests -q`, `npx vitest run modules/dashboard modules/branding`, `npx tsc --noEmit -p modules/dashboard/tsconfig.json` and `-p modules/branding/tsconfig.json`, `npx biome check modules/dashboard modules/branding`, `node scripts/check_untranslated_strings.mjs`. **Step 4: Commit** `feat(dashboard,branding): dashboard tiles, real-data doctor and branding editor per the hi-fi deck`. + +--- + +### Task 11: Audit log (+ entity labels across modules) — runs after Tasks 3–10 + +**Files:** +- Modify: `modules/audit_log/audit_log/pages/Browse.tsx`, `pages/components/*` (add `ChangesList.tsx`, `DateRangeField.tsx`), `modules/audit_log/audit_log/{endpoints/views.py,endpoints/api.py,service.py,resolve.py,locales/en.json}` +- Modify: `framework/core/simple_module_core/audit_links.py` (+ `framework/core/tests/test_audit_links.py`): `AuditLink` gains `table_name: str | None = None` and `label_resolver: Callable[[Session, list[str]], Awaitable[dict[str, str]]] | None = None` +- Modify: `register_audit_links` in `modules/{users,settings,background_tasks,feature_flags}/**/module.py` to pass `table_name` and a resolver (users → full_name or email; settings → key; background_tasks → task_name; feature_flags → flag name) +- Tests: `modules/audit_log/tests/test_export_csv.py`, `test_actor_filter_by_name.py`, `test_entity_labels.py`; JS `modules/audit_log/tests-js/ChangesList.test.tsx` +- Evidence: `hifi-gap/tasks-workers-audit-22-25.md` §25; deck `25`. + +**Interfaces (consumes):** `ui/calendar` + `ui/popover` for the range, `useRelativeTime` not needed (absolute times). **Produces:** `GET /api/audit-log/export.csv?` streaming `text/csv` (columns: time, action, entity_type, entity_id, entity_label, actor, changes as `field: old → new; …`), permission `audit_log.view`; browse props `entries[].entity.display`, `.table_name`. + +**Requirements:** every delta in §25.4 with the spec's rulings: type tag = table name; export honours the current filters (all pages); date range is date-only, `to_date` treated as end of day; actor filter accepts a UUID (exact) or text (`ilike` on users' name/email, resolved to ids in the view); the correlation link stays under the Time cell. + +- [ ] **Step 1:** Python tests first → fail. **Step 2:** core registry, module hooks, audit_log backend, then page; `make gen-i18n`. **Step 3: Verify** `uv run pytest modules/audit_log/tests framework/core/tests -q`, `npx vitest run modules/audit_log`, `npx tsc --noEmit -p modules/audit_log/tsconfig.json`, `npx biome check modules/audit_log framework/core`, `node scripts/check_untranslated_strings.mjs`. **Step 4: Commit** `feat(audit_log): deck audit screen, CSV export, entity labels`. + +--- + +### Task 12: Integration, full gates, browser verification + +**Files:** anything the fix wave needs; screenshots under `qa-shots/hifi/` (gitignored? check `.gitignore`; if not ignored, do not commit them). + +- [ ] **Step 1:** `make gen-i18n && make gen-pages`; `make lint`; `make test`; `make doctor` — fix everything. +- [ ] **Step 2:** Start the app from the worktree on non-default ports: `SM_VITE_PORT=5051 npm run dev` and `SM_VITE_DEV_URL=http://localhost:5051 uv run --project host uvicorn host.main:app --port 8001` (after `make migrate`). Bootstrap admin from `.env`. +- [ ] **Step 3:** For every deck screen open the route at 1440×900 and 390×720 with Playwright, compare against `/tmp/hifi/screens/NN-*.html`, record mismatches, fix, re-shoot. Screens and routes: landing `/`; login `/users/login`; register `/users/register` (needs `SM_USERS_ALLOW_SIGNUP=true`); forgot `/users/forgot-password` (+ sent state); reset `/users/reset-password?token=` (+ expired); verify `/users/verify?token=`; invite `/users/invite/accept?token=`; keycloak `/keycloak/login`, `/keycloak/logged-out` (only when `SM_AUTH_PROVIDER=keycloak` — otherwise render the pages through a vitest snapshot instead); errors `/admin` as a non-admin (403), `/nope` (404), a forced 500 via a test route if one exists (else the vitest render); dashboard `/dashboard/`; users `/admin/users/`; add people `/admin/users/add`; edit `/admin/users/`; profile `/users/me`; role `/admin/permissions/roles//edit`; grants `/admin/permissions/users//edit`; settings store `/admin/settings/store`; new override `/admin/settings/create`; module settings `/admin/settings/`; flags `/admin/feature-flags/`; files `/file-storage/` (+ delete dialog); tasks `/admin/background-tasks/`; task detail `/admin/background-tasks/`; workers `/admin/background-tasks/workers`; audit `/admin/audit-log/`; branding `/admin/branding/`; doctor `/admin/doctor/`; mobile = dashboard, drawer open, users, task detail at 390px. +- [ ] **Step 4:** Run `make test-e2e` against the running server (`E2E_BASE_URL=http://localhost:8001`). Fix failures. +- [ ] **Step 5:** Commit the fix wave; write the verification summary into the SDD ledger. diff --git a/docs/superpowers/specs/2026-09-03-hifi-pages-design.md b/docs/superpowers/specs/2026-09-03-hifi-pages-design.md new file mode 100644 index 00000000..8aac6f61 --- /dev/null +++ b/docs/superpowers/specs/2026-09-03-hifi-pages-design.md @@ -0,0 +1,107 @@ +# Hi-Fi Pages — implementing every screen of the deck + +**Date:** 2026-09-03 +**Source:** Claude Design project `4ad8fe06-c68f-4444-bcf7-0d0b4839e681`, file `Hi-Fi Pages.dc.html` (+ `support.js`). The copy used here was cached on 2026-08-19; the live project could not be re-fetched in this session (Claude Design MCP not authorised), so any edits made to the deck after that date are not reflected. +**Baseline:** `origin/main` @ 3f64f3c, branch `worktree-hifi-pages`. +**Gap analysis:** per-screen findings live in [`hifi-gap/`](hifi-gap/). This document records the decisions; the gap files hold the evidence. + +## Goal + +Make every one of the deck's 28 screens (8 public, 10 app, 9 ops, 1 mobile board) match the design in structure, copy and behaviour, on top of the shell work already shipped in #271 and the admin split shipped in #274. Where the deck and a later product decision disagree, the product decision wins and is listed under *Deliberate departures*. + +## Deliberate departures from the deck + +| Deck | Decision | Why | +|---|---|---| +| One charcoal shell with `Main / Operations / System` nav, Permissions as a top-level item | Keep the registry-driven app/admin split and the red-tinted admin sidebar | #274 post-dates the deck and CLAUDE.md mandates the split; Permissions has no index page. Only the *styling* of nav items follows the deck (solid primary pill, 44px rows on phones). | +| No footer inside the app frame | Keep `BrandingFooter` | Footer links became admin-configurable in #282/#287, after the deck. | +| Landing badge "Batteries-included **Django** + Inertia starter" | "Batteries-included **FastAPI** + Inertia starter" | Deck typo. | +| Settings nav lands on the raw override table; module forms are a sub-page | Keep `/admin/settings/` = module forms, `/admin/settings/store` = overrides | Decided in #261 (item 1o). Each screen is restyled in place. | +| Files under an "Ops" admin group | Stays at `/file-storage/` in the app shell | Moving it means `/admin/files` + `AdminLayout` + `ADMIN_SIDEBAR` together; not a design change. | +| Profile → Preferences → "Task failure emails" toggle | **Omitted** | No notification subsystem exists; a toggle that does nothing is worse than none. Everything else on the Preferences card ships. | +| Profile → Sessions lists other devices with UA / IP | Lists **this browser** only, plus "Sign out everywhere" | Browser auth is a signed cookie, not a server-side session store. "Sign out everywhere" is real: it bumps a per-user `session_version` that the auth provider checks. | +| Workers offline card "last heartbeat 6m ago" | "offline" without an age | Celery inspect cannot report a worker that did not answer; persisting last-seen is a separate feature. | +| Storage subtitle "1.2 GB of 5 GB used" | "… · 1.2 GB used · 25 MB per file", quota segment appears only when `SM_FILE_STORAGE_QUOTA_BYTES` is set | No quota concept existed; adding the setting is cheap, inventing a number is not. | +| Doctor "Fix" / "Generate" / "Apply pending" run tools | They **copy the command** to the clipboard | Running Alembic from a web request is not something this app should do. | +| Locale pill "EN" always visible | Visible always; a single-locale install renders it as a static label | A control that opens nothing is noise, but the deck's placement is kept. | + +## Cross-cutting building blocks (packages/ui) + +These are built first because three or more screens depend on each. + +- **`StatCard`** — relaid out to the deck: label top-left, optional icon top-right, value in Sora, delta as inline coloured text (not a badge). New `tone: 'default' | 'warning' | 'destructive'` tints the whole card (Doctor "Pending migrations", tasks "Failed"/"Stuck", users "Pending invites" number). `icon` becomes optional so the users/grants "plain" stats use the same component. +- **`SegmentedControl`** — `--sec` track with a raised card-coloured active chip, optional inline counts. Used by users (Users/Roles), settings store (scope tabs), tasks (status filter), flags (scope), add-people (mode), branding preview (App/Sign-in/Email). +- **`ConfirmActionDialog`** — one destructive/primary confirm over `AlertDialog`: icon tile, title, description, optional `confirmText` (type-to-confirm, mono input), optional children (args box), `confirmLabel`/`cancelLabel` props (labels come from the caller's catalog). Replaces the four ad-hoc dialogs (file delete, user delete, task retry, flag toggle). +- **`PasswordInput`** (show/hide) and **`PasswordStrength`** (bar + label; scoring: length ≥ 8, not all digits, mixed classes; labels weak/ok/strong) for login, register, reset, invite, profile. +- **`relative-time.ts`** gains day/month/year buckets and a future form ("in 5d") with matching `ui.relative_time.*` keys. +- **`initials()`** helper — two letters from name or email; used by every avatar (sidebar user row, users list, edit header, profile). +- **`PageShell`** gains `titleClassName` (mono task names), `leading` (avatar), `badge` (status pill next to the title), `mobileAction` (compact label + href/onClick shown in the phone bar), and `back` (href for the phone bar's chevron). +- **`AuthCardShell`** gains `variant="split"` with an `aside` slot: dark brand column (Login) or light intro column (Register, Accept invite). Its card surface uses semantic tokens so the `.dark` theme works. +- **Theme** — `light | dark | system` stored in `localStorage`, applied to `` on boot (`host/client_app/app.tsx`) and from the Profile preference. The semantic tokens already define `.dark`; components with hardcoded light colours are fixed as found during verification. + +## Shell (00, 28) + +- Sidebar active item: solid `bg-primary text-white` pill (app) / `bg-red-600 text-white` (admin); 15px labels; rows `min-h-11` below `lg`. +- Topbar: breadcrumb · "Search ⌘K" · locale pill · **Log out** (POST, from the `userDropdown` logout item). +- Sidebar user row: two-letter initials on a neutral surface. +- Phone bar (< lg): back chevron when the page declares `back`, else hamburger; **page title** (from the heading context; mono when the shell is told so); right slot = `mobileAction` or the avatar. Locale control moves to the drawer footer. +- Drawer: full-width below `sm`, `✕` + app name header, no icons on rows, active pill. +- Menu regression: restore `section=MenuSection.ADMIN_SIDEBAR` (+ Branding `order=105`) dropped by #280, with a registry test. + +## Public screens (01–08) + +- **Landing**: copy pass (badge, h1, subtitle, six feature cards, quickstart body, CTA strip), helper line under the terminal, visible "Copy / ✓ Copied" label, grey terminal comments, primary CTA anchors `#quickstart`, nav shows one "Sign in" (or "Open dashboard" when signed in). +- **Errors**: numeral coloured per status (amber/emerald/red) at 64px inside a bordered card; no "HTTP n" pill; sentence-case titles ("No access", "Not found", "Something broke"); new descriptions; 403 names the missing permission from a new `required_permission` prop; 500 shows the short `req_` id chip with copy and a **Retry** action; "Go home" targets the dashboard when signed in. +- **Login**: dark split layout; "Sign in" heading and button; "Forgot password?"; show/hide password; "Keep me signed in for 30 days" (new `remember` flag → 30-day cookie); rule divider "or" + "Continue with {provider}"; footer "No account? Register — or ask an admin to invite you." (register link only when signup is allowed); a "Waiting on you" state replaces the inline unverified banner. +- **Register**: light split with intro + two check rows; "Optional" name placeholder; strength meter + helper; field-level confirm error; "Create account"; "Sign in" link. +- **Forgot / Reset**: "valid for {minutes} minutes" from settings; sent state with ✉ tile, bold email, amber console-mailer callout (only when the mailer does not deliver) and a resend countdown; reset page "Set a new password" / "Confirm" / strength / "Save and sign in" (signs in with the email decoded from the token); "Link expired" state rendered on GET when the token is dead. +- **Verify**: "Email verified" / "Go to sign in"; "Link expired" amber card with "Resend verification" (email decoded from the expired token, `verify_exp=False`); lifetime from settings. +- **Accept invite**: light split; "{inviter} invited you to {app}" (falls back to "You've been invited to {app}"), summary card Email / Role / Expires, form with Full name, "Join workspace", helper line. Backend: `invited_by` claim on the invite token, `expires_at` from `exp`, optional `full_name` on accept. +- **Keycloak**: both interstitials inside `AuthCardShell`; realm URL prop; "Not redirected? Continue manually"; the signed-out page gets a real route (`/keycloak/logged-out`, public) and becomes the post-logout target. + +## App screens (09–18) + +- **Dashboard**: new `StatCard` layout; "+{n} this month" (new `users_created_this_month` stat), "all loaded", meta gains "· all checks healthy"; two-row `ModuleTile` with "loaded · healthy / degraded / no checks" and "Open / No view"; `DemoPlaceholders` removed. +- **Users list**: plain stat row; segmented Users/Roles; "Status: all ▾" / "Role: all ▾" (Verified folded into Status: all / active / unverified / invited / disabled); flex search; two-letter avatars; **invited vs unverified** (new `invited_at` column + migration) with tinted invited rows ("invited 2d ago · expires in 5d", **Resend** via new admin endpoint); dimmed disabled rows; relative "Last seen"; row click → edit; kebab menu (Edit / Resend / Copy reset link / Disable); in-card "Showing 1–20 of N" footer. +- **Add people**: mode control above the cards ("Invite by email" / "Create account"); page-level amber mailer banner naming the mailer with a "Configure SMTP" link to module settings; chip email input (Enter/paste to add, invalid chips red, "N addresses · M invalid", send count = valid chips); "Roles for everyone in this batch"; "Message (optional)" threaded through the bulk-invite schema and both mailers; two-column with a persistent "Last batch" card (✓ Copy link / ✕ Retry, "{sent} sent · {failed} failed", 7-day expiry note from settings); no redirect after a fully successful batch. +- **Edit user**: header with avatar, name, "email · joined Mon YYYY · last login 2h ago", status pill, "{n} unsaved changes" / Discard / Save changes; Details card holds name, email, roles and "Manage permissions →"; one Account card (Sign-in, Created, Verified, Disabled at, Disable/Enable, Copy reset link, Mark verified); **Recent activity** card from the audit log (actor = this user, hidden when `audit_log` is not installed) with "See all in the audit log →"; Danger zone copy and red-tinted style. +- **Profile**: view passes a real `user` prop (fixes the blank-name bug); two columns: Details (avatar, email, name, "Save details"), Password (current/new/confirm + strength, new `POST /api/users/me/password`, hidden for SSO users), Sessions (this browser + "Sign out everywhere" via `session_version`), Preferences (Language via the locale form, Theme light/dark/system). +- **Role edit**: "Edit role: {name}"; Reset / Cancel / Save role; "Filter modules or permissions…" searches keys too; "Granted only" toggle; bold granted count + flat bar; two-column card grid; tri-state header checkbox; "n / m"; muted off keys; no footer badge; leave-guard ported from Users/Edit. +- **User grants**: "Permissions — {email}"; "{name} · effective permissions combine role grants and direct grants"; Cancel / Save grants; plain stats (Roles pill, Direct grants, Effective n / total); legend "direct grant" / "from role"; two-column cards with "{n} effective / {total}"; single-column rows switch-first with "direct" / "granted by {role}" pills. +- **Settings store**: description per deck; "Per-module forms" / "+ New override"; scope tabs with counts; "Search keys…"; server-side paging (20/page) with "Showing a–b of N"; columns Scope / Key (scope id as sub-line) / Type (short, lowercase) / Value (hex swatch) / Actions "Edit · Delete". +- **New override**: title/subtitle/labels per deck; `1.3fr 1fr` grid; suggestion dropdown with "Registered by modules" header and "{type} · env {VAR}" / "{type} · default {v}" meta; **Resolved value** panel (this override / env fallback / module default, with the restart note); `known_keys` enriched with `env_var`, `env_set`, `default`, `requires_restart`, `is_secret` (secrets masked) and the settings registry's definitions. +- **Module settings**: sidebar shows package names with "· n overridden"; header outside the card ("`users` settings", subtitle, "{n} unsaved", Save); rows `170px 1fr 210px` with trailing meta (env var · default / "overridden in DB · Revert" / "write-only · never returned"); Revert only for DB overrides; overridden inputs highlighted; `Switch` for booleans, select for enum-pattern strings; Test connection in the card footer with the last result ("✓ Last test succeeded 4m ago", kept in sessionStorage); `testable` becomes `{package: [check names]}`. + +## Ops screens (19–27) + +- **Feature flags**: copy pass; segmented scope (system + tenants + "Other…" for a new id); columns Name / Description / System (on/off) / Effective / Actions; text "Clear override"; tinted overridden rows; audit footer note; "View change history →" to the audit log filtered on `FeatureFlagOverride` (module registers audit links). +- **File storage**: "File storage" + backend/usage/limit subtitle; drag-and-drop strip; "Uploads in progress" card with cancel (`xhr.abort`) and Retry, real failure reasons from the API body; "Type: image/png (12)" trigger; "Uploaded by" filter with resolved names; relative "When"; checkbox selection + "Delete selected" (new bulk endpoint); footer "{n} selected · showing a–b of N"; "Download" text link; delete confirm via the shared dialog (name in curly quotes, backend in the copy, "Delete file"). +- **Confirms**: user delete uses the destructive variant (bug: it was primary) with "Type the email to confirm"; retry says "Queue retry" with "This one has already been retried {n} time(s)." and a one-line args/kwargs box; flag toggle restyled on the shared dialog. +- **Background tasks**: copy; Workers + "Retry all failed" (new bulk endpoint, failed + stuck, current filter) in the header; five tiles Queued / Running / Succeeded 24h (new windowed count) / Failed (red) / Stuck (amber) that still act as filters; segmented All / Failed / Running / Stuck + "Queue: all ▾" (new `queue` filter and `queues` prop); six columns (Worker dropped), mono task names, tinted lowercase pills, relative "Queued", "—" duration until finished, text "Retry", clickable rows, in-card paging. +- **Task detail**: mono title with inline status pill; "execution {id} · attempt {n} of {max}" (`max_retries` prop); "Back to executions"; Details in deck order with Duration and an Exception footer; Arguments / Keyword arguments side by side; terminal-styled Traceback with Copy and a highlighted last line; `320px 1fr` grid; phone order = status strip, facts, traceback, bottom "Retry task". +- **Workers**: header actions ("Last updated HH:MM:SS", Refresh, Executions); two-column fleet; mono hostnames, "celery 5.4.0 · uptime 4d 2h" (new `uptime_seconds`), primary dots, tinted pills, dimmed offline cards, mono queue chips; broker-unreachable state shows `SM_BG_TASKS_BROKER_URL=`; the start command literal is corrected to the real `celery -A scripts.run_worker:celery worker -Q …`. +- **Audit log**: copy; **Export CSV** (streaming endpoint honouring filters); filter grid with labels above (Entity type / Action / Actor "Anyone" / Date range popover) and Apply + outline Clear; Time as `d MMM HH:mm:ss` mono; borderless lowercase action pills; entity cell = resolved display name link + muted table-name tag (registry gains a label resolver hook, implemented by users/settings/background_tasks/feature_flags); actor by name or email; "field old → new" with `null`/`""` distinct, "+{n} more fields" after 2, "no changes recorded"; in-card paging with `2,431` formatting. +- **Branding**: description; header "{n} unsaved changes" (amber) / Discard / Publish branding with dirty tracking (presets and text stage locally; images still upload on pick); `1.15fr 1fr`; App name + Primary colour on one row; four lowercase preset chips matching the deck colours; three dashed image dropzones in one row; footer text + links editor per deck (existing `FooterLinksField` restyled); "Design pack: emerald ▾" in the form foot; "Live preview" with App / Sign-in / Email tabs, topbar and footer strips, caption. +- **Doctor**: **real data** — `checks` from `run_diagnostics` (boot result kept on `app.state`, re-run on POST), `migrations` from Alembic's script directory with applied/pending status, `dev_server` from settings (vite/api ports, worker from the last workers snapshot when the module is present); "Copy report" + "↻ Re-run checks"; stats "Checks passing 7 / 8", "Modules loaded", tinted "Pending migrations", "Python"; one-line pass rows and expandable warn rows with "Fix" (copies the command); migrations rows with "Generate" / "Apply pending" (copy); Dev server rows; terminal transcript panel. Fixture data is deleted. + +## Data, migrations, endpoints (summary) + +| Module | Change | +|---|---| +| users | `invited_at`, `session_version` columns (+ migration); `remember` on login; `message` on bulk invite; `invited_by` claim + `expires_at` in invite preview; `full_name` on accept; `POST /api/users/admin/{id}/resend-invite`; `POST /api/users/me/password`; `POST /api/users/me/sessions/revoke-all`; profile view passes `user`; forgot/reset/verify views pass lifetimes, mailer flag and decoded email | +| keycloak | `realm_url` prop; `GET /keycloak/logged-out` (public) | +| hosting | `required_permission` on 403 error pages; diagnostics result kept on `app.state.sm`; Alembic revision listing helper | +| dashboard | `users_created_this_month`; Doctor props + `POST /admin/doctor/rerun` | +| settings | store view: `scope`, `q`, `page` + `counts`, `pagination`; `known_keys` enrichment; `testable` as `{package: [checks]}` | +| feature_flags | `register_audit_links` | +| file_storage | `used_bytes`, `backend`, limits, optional `quota_bytes` setting; `created_by` filter + uploader facet + resolved labels; `POST /api/file-storage/files/bulk-delete` | +| background_tasks | `success_24h` count; `queue` filter + `queues`; `POST …/executions/retry-failed`; `max_retries` on detail; `uptime_seconds` on workers; redacted broker url | +| audit_log | `GET /api/audit-log/export.csv`; actor-by-name filter; entity display labels via a registry label hook | +| core | `AuditLink` gains an optional `label_resolver`; `MenuItem` section fix | + +## Testing + +- Python: every new endpoint/prop gets a test in its module's `tests/` (pattern: `authenticated_client`); migration covered by the existing autogenerate-drift check. +- JS: new `packages/ui` primitives get vitest specs (`SegmentedControl`, `ConfirmActionDialog`, `PasswordStrength`, `relative-time` buckets, `initials`); module components with logic (email chips, module settings row meta, changes list) get `tests-js/` specs. +- Gates: `make lint` (incl. untranslated-string and 300-line checks), `make test`, `make doctor`. +- Browser: every screen is opened at 1440 and 390 wide through Playwright against `make dev`, compared against `/tmp/hifi/screens/*.html`, and screenshots are kept under `qa-shots/hifi/`. diff --git a/docs/superpowers/specs/hifi-gap/auth-screens-02-07.md b/docs/superpowers/specs/hifi-gap/auth-screens-02-07.md new file mode 100644 index 00000000..d4d8085a --- /dev/null +++ b/docs/superpowers/specs/hifi-gap/auth-screens-02-07.md @@ -0,0 +1,170 @@ +# Hi-Fi deck gap analysis — auth-screens-02-07 + +Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md. + +# Design-vs-implementation gap analysis: auth screens (02–07) + +**Shared context.** All six `users` pages render inside `/home/anto/Repos/simple_module_python/packages/ui/src/layouts/AuthCardShell.tsx`: one centered glass card, `max-w-md` (448px), `rounded-3xl p-7 bg-white/85`, two emerald blur blobs, and a `BrandingMark` lockup (badge + `branding.appName`, default "SimpleModule", caption `python`) *inside* the card. No footer/copyright, and `bg-white/85` is hardcoded so the `.dark` variant (which the deck tokens define) is not honoured. Fonts already match the deck (`--font-display: Sora`, `--font-sans: DM Sans`, `--font-mono: JetBrains Mono` in `packages/ui/src/styles/globals.css`); primary is already emerald. Deck inputs are ~46px tall / radius 10px and buttons ~48px; the app's shadcn `Input` is `h-9 rounded-md` and `Button size="lg"` is `h-10`. The deck uses three distinct page shapes — a dark-brand split (Login), a light two-column "intro + card" (Register, Invite), and a single centered card (Forgot/Verify/Keycloak) — while the app has only the third. A shell variant (e.g. `AuthCardShell variant="split" aside={…}`) is the prerequisite for screens 02, 03 and 06. No `PasswordInput` (show/hide) or password-strength component exists anywhere in the repo (`ui/progress.tsx` and `ui/checkbox.tsx` do exist). + +--- + +## 02 — Log in + +**1. Route / files.** `GET /users/login` → `Users/Login` in `/home/anto/Repos/simple_module_python/modules/users/users/auth_local/views.py` (L33–69). Page: `/home/anto/Repos/simple_module_python/modules/users/users/pages/Login.tsx`. Props: `allow_signup`, `dev_accounts`, `login_redirect_url`, `oauth_providers[{name, display_name}]`. Submit: `POST /api/users/auth/login` (`auth_local/api.py` L81–118). + +**2. Deck structure.** 1440×860 grid `1.05fr 1fr`. +- Left pane, `#0f172a` with teal blur blob: lockup "S" + "simple_module_py"; H2 "One admin surface for every module you install."; p "Users, permissions, settings, files, background tasks and audit history — all wired up on boot."; check rows "Sessions, invites and password reset built in", "Keycloak SSO when you need it"; footer "© 2026 simple_module_py". +- Right pane, 420px column: H1 "Sign in"; p "Use your workspace email and password."; label "Email" / placeholder "you@example.com"; label "Password" with right-aligned link "Forgot password?"; password input shown focused with trailing "Show" toggle; checked checkbox "Keep me signed in for 30 days"; primary "Sign in"; rule-divider "or"; outline "Continue with Keycloak SSO"; footer "No account? Register — or ask an admin to invite you." + +**3. Already matches.** Field order, forgot link right-aligned on the password label baseline, full-width primary submit, gated sign-up line, outline buttons for OAuth providers, font/colour tokens. + +**4. Deltas.** +1. Layout: single card vs dark brand column + form column. Add a split variant to `AuthCardShell.tsx` (aside slot, dark background, lockup, copyright) and use it in `Login.tsx`. Aside copy needs new keys (`packages/ui/locales/en.json`, e.g. `ui.auth_aside.*`). +2. Copy (`modules/users/users/locales/en.json` → `users.login.*`): heading "Welcome back" → "Sign in"; subtitle "Log in to your workspace." → "Use your workspace email and password."; `forgot_link` "Forgot?" → "Forgot password?"; `submit` "Log in" → "Sign in". Six e2e files pin `get_by_role("button", name="Log in")` (`tests/e2e/test_{audit_log_ui,document_titles,error_pages,i18n_rendering,settings_ui,shell_ui}.py`) — update alongside. +3. Show/hide password toggle missing. Create `packages/ui/src/components/PasswordInput.tsx` (Eye/EyeOff, translated `aria-label`) and use it here and in 03/04/06. +4. "Keep me signed in for 30 days" checkbox missing entirely (`Login.tsx`); requires backend (see §5). +5. Divider + SSO: app shows a `border-t` with mono caption "Or continue with" and buttons labelled `display_name`; deck shows a rule "or" and "Continue with Keycloak SSO". Add key `users.login.continue_with` = "Continue with {name}", restyle divider, and move the OAuth block *above* the "No account?" line (deck order). +6. Footer: "Don't have an account? Sign up" → "No account? Register — or ask an admin to invite you." (`no_account`, `sign_up`, plus a new suffix key). +7. Input/button sizing (h-9/h-10 vs ~46/48px, radius 10) — decide on a `size="lg"` for `Input` or auth-page overrides. +8. Deck has no dev quick-login, inline error, or needs-verification banner; app has all three — keep (05's "Waiting on you" covers the unverified case). + +**5. Backend/props.** A `remember` field on `POST /api/users/auth/login` that varies the cookie max-age (`auth_local/api.py`; today fixed at `cookie_max_age_seconds` = 14 days in `settings.py`). The SSO button label is settings-driven (`oauth_oidc_display_name`, default "OIDC") — nothing to add, but "Keycloak SSO" only appears if an admin sets it. + +**6. Ambiguities.** "30 days" vs the 14-day cookie / 30-day refresh-token settings — derive from a setting or fix copy. Whether "— or ask an admin to invite you." remains when `allow_signup` is false. "Continue with Keycloak SSO" must be the users-module OIDC provider (SM020 forbids the `keycloak` module coexisting with `users`), not a link to `/keycloak/login`. Narrow-viewport behaviour of the dark pane (deck is 1440 only). "Show" as text vs icon. + +--- + +## 03 — Register + +**1. Route / files.** `GET /users/register` (404 unless `allow_signup`) → `Users/Register`, no props (`auth_local/views.py` L85–89). Page: `/home/anto/Repos/simple_module_python/modules/users/users/pages/Register.tsx`. Submit: fastapi-users `POST /api/users/auth/register` with `full_name`. + +**2. Deck structure.** 940px two-column grid, vertically centred. +- Left (no card): lockup "S" + "simple_module_py"; H1 "Create your account"; p "Open registration is on for this instance. The first account becomes the admin; later ones get the default role."; checks "Email verification is required before first sign-in", "Admins can close registration in `users.allow_signup`". +- Right card (radius 14, padding 32, shadow-lg): "Full name" placeholder "Optional"; "Email" value "rob@example.com"; "Password" + strength bar (78%) label "strong" + helper "At least 8 characters and not all numbers."; "Confirm password" in error state (red border) + "Passwords do not match"; primary "Create account"; "Already have an account? Sign in". + +**3. Already matches.** H1 "Create your account", field order (name, email, password, confirm), have-account line linking to `LOGIN_PATH`, weak-password server errors surfaced. + +**4. Deltas.** +1. Layout: single card vs intro column + form card (light variant of the split shell). `Register.tsx`. +2. Subtitle: app "Public signup — controlled by `SM_USERS_ALLOW_SIGNUP`." → deck paragraph + two check rows, referencing the settings key `users.allow_signup` (not the env var). Replace `users.register.subtitle_prefix` with `intro`, `bullet_verification`, `bullet_close_prefix`. +3. Full-name placeholder "Your name" → "Optional" (new register-specific key; `users.common.name_placeholder` is shared). +4. Strength meter missing. New `packages/ui/src/components/PasswordStrength.tsx` (wrap `ui/progress`), labels need keys ("strong", "ok", presumably "weak"). +5. Helper text "At least 8 characters and not all numbers." missing (app has placeholder "8+ characters"). Note the server also rejects passwords containing the email (`manager.py` L45–53) — deck helper omits it. +6. Field-level error: deck shows red border on Confirm + inline "Passwords do not match"; app shows one `text-destructive` paragraph under the form with "Passwords do not match." and no `aria-invalid`. Change in `Register.tsx`. +7. Submit "Sign up" → "Create account" (`users.register.submit`). +8. "Already have an account? Log in" → "… Sign in" — app reuses `users.common.log_in`; add `users.common.sign_in` rather than repurposing. +9. Success state ("Check your inbox / We've sent a verification link to {email}.") exists in app, absent in deck — keep. + +**5. Backend/props.** None required. If the "first account becomes the admin" line is kept, confirm it against `users/bootstrap.py` (the admin is seeded from `bootstrap_email`/`bootstrap_password` env, so the claim may be false for this codebase). + +**6. Ambiguities.** Strength scoring and thresholds; truth of the "first account becomes the admin" claim; whether `require_verification=False` should suppress the "Email verification is required…" bullet; mobile stacking order. + +--- + +## 04 — Forgot / reset password + +**1. Route / files.** `GET /users/forgot-password` → `Users/ForgotPassword` (no props); `GET /users/reset-password?token=` → `Users/ResetPassword` `{token}` (`auth_local/views.py` L92–99). Pages: `/home/anto/Repos/simple_module_python/modules/users/users/pages/ForgotPassword.tsx`, `/home/anto/Repos/simple_module_python/modules/users/users/pages/ResetPassword.tsx`. APIs: `POST /api/users/auth/forgot-password` (202 always), `POST /api/users/auth/reset-password` (stock fastapi-users). + +**2. Deck structure.** Four cards (28px padding), each with an uppercase eyebrow: +- "1 · Request": H2 "Forgot password"; p "We'll email a one-time link valid for 60 minutes."; "Email" / "you@example.com"; primary "Send reset link"; link "Back to sign in". +- "2 · Sent": ✉ tile (44px, soft emerald); H2 "Check your inbox"; p "If an account exists for **you@example.com**, a reset link is on its way. The same message shows either way."; amber callout "Console mailer: the link is in the server log."; link "Resend in 0:42". +- "3 · New password": H2 "Set a new password"; "New password"; "Confirm"; strength bar (60%) "ok"; primary "Save and sign in". +- "Edge case" (red border): ⧗ red tile; H2 "Link expired"; p "Reset links last 60 minutes and work once. Request a fresh one — the old link is now dead."; primary "Request a new link". + +**3. Already matches.** "Forgot password" heading, "Send reset link" button, "Check your inbox" sent title, anti-enumeration behaviour, "New password" label, console-mailer mention (as a sentence). + +**4. Deltas.** +1. Request subtitle "We'll email you a one-time reset link." → "We'll email a one-time link valid for {minutes} minutes." (`users.forgot_password.subtitle`; minutes from a prop, not hardcoded). +2. Back link "Remembered? Log in" → "Back to sign in" (collapse `remembered` + `common.log_in`; also align `common.back_to_login` "Back to log in" used on Register/Forgot-sent/Verify). +3. Sent state: app renders a small green inline banner with `CheckCircle2` and a combined sentence "If {email} has an account, a reset link is on its way. The console mailer logs it to stdout." Deck: page-level ✉ tile + H2 + paragraph with bold email + separate amber callout + countdown "Resend in 0:42" that becomes a resend action. Split `sent_body` / `console_mailer_note`; gate the callout on the mailer; add the resend timer + re-POST in `ForgotPassword.tsx`. +4. Reset form: H1 "Reset password" → "Set a new password"; drop subtitle "Choose a new password and you'll be redirected to log in."; label "Confirm password" → "Confirm"; submit "Reset password" → "Save and sign in"; add strength meter (`ResetPassword.tsx`, `users.reset_password.*`). +5. Expired-link state: app only shows inline "Reset failed. The link may have expired." after submit (and `no_token` when absent). Deck wants a distinct red-bordered "Link expired" card with ⧗ icon and "Request a new link" → `/users/forgot-password`. Add a state branch in `ResetPassword.tsx`. +6. Eyebrows "1 · Request" etc. are storyboard chrome — do not implement. + +**5. Backend/props.** (a) `reset_link_lifetime_minutes` prop (`reset_password_token_lifetime_seconds` = 3600) on the forgot view; (b) `mailer_delivers` prop — `admin/views.py` L114–123 already computes this, reuse it on `forgot_password_page`; (c) optionally pre-decode the reset token on `GET /users/reset-password` (mirroring `invite_preview.py`) so "Link expired" renders on load, not after submit; (d) "Save and sign in" literally requires a reset+login wrapper (stock endpoint only resets; app then `router.visit(LOGIN_PATH)`) — pattern exists in `accept_invite` (`auth_local/api.py` L143–177); (e) resend is subject to the 10/5-min throughput limiter — countdown should be ≥ that cadence or expect 429s. + +**6. Ambiguities.** Whether "Save and sign in" means auto-login; countdown length ("0:42" is illustrative); expired state on GET vs after POST; "work once" holds today (token carries a password fingerprint) — fine to keep. + +--- + +## 05 — Email verification + +**1. Route / files.** `GET /users/verify?token=` → `Users/VerifyEmail` `{token}` (`auth_local/views.py` L102–104). Page: `/home/anto/Repos/simple_module_python/modules/users/users/pages/VerifyEmail.tsx` — POSTs `/api/users/auth/verify` on mount; states `pending | success | already_verified | error`. The unverified-login case is handled inline on `Login.tsx` (L138–152) when the API returns `LOGIN_USER_NOT_VERIFIED`. + +**2. Deck structure.** Three left-aligned cards (32px padding): +- ✓ soft-emerald tile (46px); H2 "Email verified"; p "Your address is confirmed. You can sign in now."; primary "Go to sign in". +- ⧗ amber tile, amber card border; H2 "Link expired"; p "Verification links last 24 hours. We can send a new one to the same address."; outline "Resend verification". +- ✉ grey tile; H2 "Waiting on you"; p "Shown when an unverified account tries to sign in. Nothing else is reachable until it's done."; mono chip "rob@example.com". + +**3. Already matches.** Success state with icon + login button; error state with icon; single-card icon/title/description/action shape. + +**4. Deltas.** +1. Success copy: "Email verified!" → "Email verified"; "Your account is now active." → "Your address is confirmed. You can sign in now."; button "Log in" → "Go to sign in" (`users.verify_email.success_*`, new key for the button). +2. Expired: "Verification failed" / "Verification link expired or invalid. Please request a new one." + "Back to log in" link → "Link expired" / "Verification links last {hours} hours. We can send a new one to the same address." + outline button "Resend verification" that POSTs `/api/users/auth/request-verify-token`. Card gets an amber border and amber tile (app uses red `XCircle`). `VerifyEmail.tsx`. +3. Lifetime copy: "24 hours" vs `verification_token_lifetime_seconds` = 7 days — pass as prop or change copy. +4. "Waiting on you" interstitial does not exist. Today it is an amber inline banner on Login ("Verify your email" + "Resend verification email"). Implement as a state branch in `Login.tsx` (email is already in component state), rendering the ✉ tile, heading, and a mono email chip; add `users.login.waiting_*` keys. +5. Icon tile: app 48px round `bg-secondary` with a coloured lucide icon, centred text; deck 46px rounded-13 tile tinted per state, left-aligned content. +6. `pending` and `already_verified` states exist in app, not in deck — keep. + +**5. Backend/props.** For "Resend verification" on an expired token the page needs the address: decode the token with `verify_exp=False` server-side (new helper beside `invite_preview.py`) and pass `email`; plus `verification_lifetime_hours`. Otherwise fall back to an email input. + +**6. Ambiguities.** The "Waiting on you" description reads as designer annotation, not user copy — needs real copy. Whether resend requires re-entering the email. Whether the pending spinner state should adopt the same tile style. + +--- + +## 06 — Accept invite + +**1. Route / files.** `GET /users/invite/accept?token=` → `Users/AcceptInvite` `{token, invite: {email, roles[], already_accepted} | null}` (`auth_local/views.py` L107–121; `auth_local/invite_preview.py`). Page: `/home/anto/Repos/simple_module_python/modules/users/users/pages/AcceptInvite.tsx`. Submit: `POST /api/users/auth/accept-invite` `{token, password}` (verify + set password + login) → `router.visit('/dashboard/')`. + +**2. Deck structure.** 900px two-column, centred. +- Left: lockup "S" + "Acme Admin"; H1 "Dana invited you to Acme Admin"; p "Set a password to finish. Your email and role are fixed by the invite."; summary card rows: "Email" → mono `rob@example.com`; "Role" → pill "viewer"; "Expires" → "in 5 days". +- Right card (padding 34, shadow-lg): H2 "Accept invite"; "Full name" (value "Rob Meyer"); "Password" (focused); "Confirm password"; primary "Join workspace"; helper "Accepting verifies your email — no second step." + +**3. Already matches.** Invitee email and role pills shown before the password ask; password + confirm; submit lands on the dashboard; already-used notice. + +**4. Deltas.** +1. Layout: single card with a green banner vs intro + summary card left, form card right (light split shell). `AcceptInvite.tsx`. +2. Headline "You've been invited as {email}" + "Set your password" → "{inviter} invited you to {appName}" + card H2 "Accept invite". Inviter name is unavailable today (see §5); fallback "You've been invited to {appName}". +3. Subtitle "Pick a password and you'll be signed in." → "Set a password to finish. Your email and role are fixed by the invite." +4. Summary card: replace the banner's "Access:" pills with a key/value card — Email (mono), Role (pill), Expires (relative; `packages/ui/src/lib/relative-time.ts` exists). +5. "Full name" field missing from the form (and from the API body). +6. Submit "Set password & sign in" → "Join workspace" (`users.accept_invite.submit`). +7. Helper "Accepting verifies your email — no second step." missing (new key). +8. Mono `token=…` echo line (L143–148) is not in the deck — remove or keep for support. +9. `already_accepted` amber note exists in app, not in deck — keep. + +**5. Backend/props.** (a) `inviter_name`: the invite JWT only carries `sub`/`email`/`aud` (`manager.py` L149–165) and `invited_by` is only recorded as `assigned_by` on the role row (`admin/service.py` L125–153) — add an `invited_by` claim when minting in `admin/bulk_invite.py`/`admin/api.py`, or store on the user, and surface it from `preview_invite`. (b) `expires_at` from the JWT `exp` in `preview_invite`. (c) Optional `full_name` on `AcceptInviteRequest` (`contracts/schemas.py`) applied in `accept_invite` (`auth_local/api.py` L148–177). Update `modules/users/tests/test_views.py::test_accept_invite_returns_200` and preview tests. + +**6. Ambiguities.** Rendering for multiple roles (deck shows one pill); what to show when `invite` is `null` (expired/tampered) — deck has no state, reuse 04/05 "Link expired"; whether Full name is required or pre-filled from an admin-created account; inviter fallback when the admin account is gone. + +--- + +## 07 — Keycloak SSO + +**1. Route / files.** `GET /keycloak/login` → `Keycloak/Login` (`/home/anto/Repos/simple_module_python/modules/keycloak/keycloak/endpoints/views.py` L16–18); page `/home/anto/Repos/simple_module_python/modules/keycloak/keycloak/pages/Login.tsx` calls `router.get('/api/keycloak/auth/login')` on mount and renders a bare centred paragraph. `POST /keycloak/logout` clears the session and redirects to Keycloak end-session with `post_logout_redirect_uri=/keycloak/login` (L20–41). **`Keycloak/LoggedOut` (`pages/LoggedOut.tsx`) is never rendered by any view** — it appears only in the Vite manifest, so the signed-out interstitial is unreachable today. + +**2. Deck structure.** Two centred cards (padding 40, centred text): +- ⇥ soft-emerald tile (52px); H2 "Redirecting to your identity provider"; p "Taking you to `sso.acme.co/realms/acme`. You'll come straight back once signed in."; 220px progress bar; link "Not redirected? Continue manually". +- ⏻ grey tile; H2 "You're signed out"; p "Your app session and the Keycloak session both ended. Close the browser to be certain on a shared machine."; primary "Sign in again" + outline "Back to site". + +**3. Already matches.** Redirect-on-mount; "Sign in again" label (`keycloak.logout.sign_in_again`). + +**4. Deltas.** +1. Login page has no shell: wrap in `AuthCardShell` (gains `BrandingHead`/`BrandingBanner`), add the icon tile, H2 "Redirecting to your identity provider" (current "Redirecting to identity provider…"), paragraph with the realm URL in ``, an indeterminate `ui/progress`, and "Not redirected? Continue manually" as ``. `pages/Login.tsx`, `locales/en.json`. +2. Interaction: `router.get()` issues an Inertia XHR against an endpoint that 302s to an external origin; a full-page `window.location.assign('/api/keycloak/auth/login')` is the safer redirect (verify in browser — not run here). +3. Signed-out page unreachable: add `GET /keycloak/logged-out` rendering `Keycloak/LoggedOut` in `endpoints/views.py`, point `post_logout_redirect_uri` at it (L38), and add the path to `get_public_paths()` in `keycloak/provider.py` L45–47 so `AuthMiddleware` doesn't bounce it. +4. Signed-out copy/shape: "Signed Out" → "You're signed out"; "You have been signed out successfully." → the deck sentence; underlined `Link` → primary `Button` "Sign in again" (`/keycloak/login`) plus outline "Back to site" (`/`); wrap in the shell with a `Power` icon tile. `pages/LoggedOut.tsx`, `keycloak.logout.*` keys. + +**5. Backend/props.** `realm_url` prop (`f"{settings.server_url}/realms/{settings.realm}"` from `request.app.state.keycloak.settings`) on the login view; the new logged-out route + public-path registration; redirect target change. Check `modules/keycloak/tests` for assertions on the logout redirect. + +**6. Ambiguities.** What the realm line shows when `server_url`/`realm` are blank in dev; progress bar as indeterminate vs a timed reveal of the manual link; whether the redirect card should also serve the users-module OAuth providers. + +--- + +## Overall summary (largest gap first) + +1. **Login** — new dark split layout, remember-me (needs a backend flag), show/hide toggle, SSO divider/label/order, four copy changes that also break six e2e selectors. +2. **Accept invite** — light split layout with a summary card, plus three backend additions (inviter name, expiry, full name on accept). +3. **Forgot / reset** — two missing states (sent-with-callout+resend, link-expired), strength meter, "Save and sign in" auto-login, mailer/lifetime props. +4. **Keycloak** — small code but a structural bug: the signed-out page is orphaned; both interstitials need a shell, realm prop, and a new public route. +5. **Register / Verify** — Register is layout + strength meter + field-level errors + copy; Verify is mostly copy plus a resend action (needs email prop) and a new "Waiting on you" state on Login. \ No newline at end of file diff --git a/docs/superpowers/specs/hifi-gap/flags-files-confirms-19-21.md b/docs/superpowers/specs/hifi-gap/flags-files-confirms-19-21.md new file mode 100644 index 00000000..87c6db1d --- /dev/null +++ b/docs/superpowers/specs/hifi-gap/flags-files-confirms-19-21.md @@ -0,0 +1,138 @@ +# Hi-Fi deck gap analysis — flags-files-confirms-19-21 + +Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md. + +## Gap analysis: Feature flags (19), File storage (20), Destructive confirms (21) + +Read-only; no files modified. Deck copy is quoted verbatim. + +--- + +### Screen 19 — Feature flags (`/tmp/hifi/screens/19-flags.html`) + +**1. Route + files** +- Route `GET /admin/feature-flags/` (`?tenant_id=`), actions `POST /admin/feature-flags/{name}/toggle|clear` — `/home/anto/Repos/simple_module_python/modules/feature_flags/feature_flags/endpoints/views.py` +- Page `/home/anto/Repos/simple_module_python/modules/feature_flags/feature_flags/pages/Browse.tsx`, `pages/components/TenantPicker.tsx`, `pages/components/ToggleConfirmDialog.tsx`, `locales/en.json`, `module.py` + +**2. Design structure** +1. H1 `Feature flags`; sub `Runtime toggles. A tenant override wins over the system value.` +2. Scope card: label `Scope` + segmented control `system | acme-co | globex` (active = raised white chip); helper `Viewing overrides for tenant acme-co — unset flags follow the system value.`; right-aligned link `View change history →` +3. Table card, uppercase 11px headers: `Name | Description | System | Effective | Actions` + - Name: mono flag name; overridden rows add an outlined emerald pill `override` and get a soft-emerald row background + - Description: muted, `—` when empty + - System: lowercase `on` / `off` + - Effective: 38x22 toggle + `Enabled` (weight 500) / `Disabled` (muted) + - Actions: emerald text link `Clear override` on overridden rows; muted lowercase `following system` otherwise +4. Card footer: `Every toggle is written to the audit log with the actor and the previous value.` +5. No empty state, no row count, no confirm dialog shown. + +**3. Already matches** +PageShell title/description; tenant scoping via `?tenant_id`; mono flag name; override badge; Switch + Enabled/Disabled; Clear override on overridden rows only; "following system" fallback; uppercase-tracked table headers; `description || '—'`. Data-wise `FeatureFlagView` already carries `system_enabled`. Audit capture is already real: `FeatureFlagOverride` uses `AuditMixin`, and `framework/db/simple_module_db/audit.py` diffs every flush, so the footer claim is true today when `audit_log` is installed. + +**4. Deltas** +1. Copy — `locales/en.json`: `browse.title` "Feature Flags" → `Feature flags`; `browse.description` → `Runtime toggles. A tenant override wins over the system value.`; `browse.viewing_tenant` → `Viewing overrides for tenant {tenant_id} — unset flags follow the system value.` (render tenant in ``); `table.name` "Flag" → `Name`; `table.overridden` "Override active" → `override`; `table.following_system` → lowercase `following system`. +2. Scope picker — replace the `Select` + "Other tenant…" form in `TenantPicker.tsx` with a segmented control (`FilterPills`-style, but the deck's chip style is raised-white-on-secondary, not outlined pills) reading `Scope` and listing `system` + `tenants`. Keep a way to type a new tenant id (see ambiguity 1). +3. Columns — `Browse.tsx`: rename `Default` → `System` and show `system_enabled ?? default_enabled` as lowercase `on`/`off` (new keys `table.on`/`table.off`); rename `Status` → `Effective`; drop the `System: {value}` sub-line under the switch (it becomes the column). +4. Actions cell — replace the `RotateCcw` icon-only ghost button with a text `Button variant="link"` reading `Clear override`. +5. Overridden row background — add `bg-primary/5` (deck `var(--soft)`) to `TableRow` when `flag.overridden`; badge should be `variant="outline"` with emerald border/text, not `secondary`. +6. Footer — add a `CardFooter`/`

` under the table with new key `browse.audit_note`. +7. Scope card — move `TenantPicker` helper text inline (same row) and add `View change history →` as a `Link` to `/admin/audit-log/?entity_type=FeatureFlagOverride` (that query param already exists in `modules/audit_log/audit_log/endpoints/views.py`). Add `register_audit_links` to `module.py` so audit rows link back. +8. Remove the right-aligned `{count} flags` line (not in deck). +9. Keep the existing empty state (deck omits it; needed for zero registered flags). + +**5. Backend/props needed** +None for the table. Optional: `audit_log_url` prop (or gate the link on the `audit_log` module being installed, mirroring `has_permissions_module` in users). + +**6. Ambiguities** +1. Deck lists a closed set of tenants; there is no tenant registry — `tenants` only contains tenants with overrides. Where does "name a new tenant" live? +2. In system scope, what does the `System` column show — the code default? (Likely yes; then `Effective` = system override or default.) +3. Deck shows no toggle confirm; keep `ToggleConfirmDialog` (recommended — it guards the "for everyone" case) and restyle via the shared dialog below. +4. "previous value" on a first-time override is `created` (no prior row) in the audit trail; the inherited value is not recorded. + +--- + +### Screen 20 — File storage (`/tmp/hifi/screens/20-files.html`) + +**1. Route + files** +- `GET /file-storage/` (`?q=&content_type=&page=`) — `/home/anto/Repos/simple_module_python/modules/file_storage/file_storage/endpoints/views.py`; JSON `/api/file-storage/{upload,files/{id},files/{id}/download}` — `endpoints/api.py` +- `pages/Browse.tsx`, `pages/components/{FileFilterBar,UploadDropzone,UploadProgressRows}.tsx`, `pages/upload-queue.ts`, `pages/constants.ts`, `locales/en.json`, `service.py`, `settings.py` + +**2. Design structure** +1. H1 `File storage`; sub `Backend s3 · 1.2 GB of 5 GB used · 25 MB per file`; header actions: outline `Delete selected`, primary `Upload files` +2. Dashed emerald dropzone strip: `Drop files here` + `or click to browse · pdf, png, csv, sql · max 25 MB` +3. Card `Uploads in progress` / `Stays put while you filter or page the table`; rows: name (190px) + progress bar + `64%` + `✕`; failed row: `Failed — exceeds the 25 MB limit` + link `Retry` + `✕` +4. Filter row: search `Search filenames…`; dropdown `Type: image/png (12) ▾`; dropdown `Uploaded by ▾` +5. Table card, uppercase headers: `[checkbox] | Filename | Type | Size | Uploaded by | When | Actions`; selected row soft-emerald; uploader values `sam`, `system`, `— unknown`; When = `2h ago`, `yesterday`, `3d ago`, `1w ago`; Actions = emerald text link `Download` +6. Card footer: `1 selected · showing 1–20 of 74` + `Previous` / `Next` buttons + +**3. Already matches** +Search (debounced) + content-type facet select with counts and family grouping; XHR progress per file; failed rows persist until dismissed with `X`; jobs survive filter/page navigation (`preserveState`); per-row Download; filename/type/size columns; `—` for unknown uploader; Previous/Next; filter-aware empty states (keep). + +**4. Deltas** +1. Header copy — `en.json`: `browse.title` "Files" → `File storage`; `browse.upload_button` "Upload file" → `Upload files`; `filters.search_placeholder` → `Search filenames…`; `table.filename` "Name" → `Filename`. +2. Header subtitle — `Browse.tsx`: render `Backend {backend} · {used} of {quota} used · {max} per file` from new props (see §5). +3. Dropzone — `UploadDropzone.tsx` is a button with a hidden input; add a real drag-and-drop strip (`onDragOver/onDrop`, click-to-browse) with `Drop files here` and `or click to browse · {types} · max {size}`. Keep the header `Upload files` button; both call `start`. +4. Uploads card — move `UploadProgressRows` out of the table into its own `Card` titled `Uploads in progress` with subtitle; give in-flight rows a `✕` cancel (wire `xhr.abort()` in `upload-queue.ts`; currently abort is only listened to), and failed rows a `Retry` (queue needs to keep the `File` object per job). +5. Failure reason — `upload-queue.ts` discards the response; parse `detail.message` from the 413/415 body (`api.py` already returns `file_storage.errors.too_large` / `bad_type`) and show `Failed — {reason}` instead of the generic `Upload failed`. +6. Type filter trigger — show `Type: {value} ({count})` when a value is set (`FileFilterBar.tsx`). +7. `Uploaded by` filter — new dropdown; `service.list_files` already accepts `created_by`, but `views.py` does not expose it and there is no uploader facet. +8. Uploader display — the cell currently prints a raw UUID (`uploaded_by = created_by`). Resolve to `full_name || email` server-side (precedent: `modules/audit_log/audit_log/resolve.py::resolve_actors`; note it imports `users.models` directly — check `make doctor` coupling rules). +9. `When` column — add relative time. `created_at` is already in props and the key `table.uploaded_at` exists but is unused. No relative-time helper exists anywhere; add one in `packages/ui` (`Intl.RelativeTimeFormat`). +10. Selection + bulk delete — add `Checkbox` column, `Delete selected` header button, selected-row highlight, footer `{n} selected · showing {from}–{to} of {total}`. Needs a bulk endpoint (§5). +11. Footer/pagination — move Previous/Next into the card footer and always show the range text (currently centered under the card, only when `totalPages > 1`, reads `Page X of Y`). +12. Actions — deck shows only a `Download` text link; delete moves to bulk. Header styling: add the uppercase-tracked classes the flags table already uses. +13. Delete confirm — see screen 21. + +**5. Backend/props needed** +- `browse` view: `backend` (settings.backend), `max_file_size_bytes`, `allowed_content_types`, `used_bytes` (needs `SUM(size_bytes)` — nothing exists), `quota_bytes` (no setting exists; would be new in `settings.py`), `uploaders` facet, `uploaded_by` filter passthrough, resolved uploader labels. +- Bulk delete endpoint (`POST /api/file-storage/files/delete` or `DELETE` with ids); only single-file delete exists. + +**6. Ambiguities** +1. Quota: there is no storage quota concept; "5 GB" must be a new setting or the segment dropped. +2. `pdf, png, csv, sql` implies a whitelist; default `allowed_content_types=None` (any). Copy when unrestricted? +3. Deck default max is `25 MB`; code default is 100 MB — display only, or change the default? +4. `system` vs `— unknown` uploader: no "system" actor exists; both are `created_by=None` today. +5. Deck's "Delete selected" leads to a single-file confirm; see 21. +6. Deck places Files under the "Ops" admin nav; the module mounts at `/file-storage/` in `AuthenticatedLayout`, group "Content". Moving it means `/admin/files` + `AdminLayout` + `ADMIN_SIDEBAR` together (CLAUDE.md rule). + +--- + +### Screen 21 — Destructive confirms (`/tmp/hifi/screens/21-confirm.html`) + +**1. Routes + files** +- File delete: inline `AlertDialog` in `file_storage/pages/Browse.tsx` (lines 190–222) + `locales/en.json#delete_dialog` +- User delete: `/home/anto/Repos/simple_module_python/modules/users/users/pages/Users/components/DangerZone.tsx` + `users/locales/en.json#danger_zone` +- Retry: `/home/anto/Repos/simple_module_python/modules/background_tasks/background_tasks/pages/components/RetryConfirmDialog.tsx` + `locales/en.json#retry_dialog` +- Primitive: `/home/anto/Repos/simple_module_python/packages/ui/src/components/ui/alert-dialog.tsx` (has `AlertDialogMedia`, `AlertDialogAction variant`) + +**2. Design structure** (all three: 40px rounded icon tile above a left-aligned Sora title, muted body, right-aligned `Cancel` outline + filled action) +- A. trash glyph in red/10 tile; title `Delete “q3-report.pdf”?`; body `This removes the file from the s3 backend. Links already shared will stop working immediately.`; action `Delete file` (red) +- B. warning glyph red tile; title `Delete sam@example.com?`; body `Sessions end at once and the account cannot be restored. Audit entries are kept.`; label `Type the email to confirm`; mono input placeholder `sam@example.com`; action `Delete user` (red) +- C. circular-arrow glyph in emerald-soft tile; title `Retry files.generate_thumbnail?`; body `A new execution is queued with the same arguments. This one has already been retried once.`; mono secondary box `args ["a91f2c"] · kwargs {"size": 512}`; action `Queue retry` (emerald) + +**3. Already matches** +All three exist as `AlertDialog`s with title-echoing name/email/task; user delete already has type-to-confirm gated `disabled={!confirmed}`; retry already renders args/kwargs in mono; `Cancel` everywhere. + +**4. Deltas** +1. File delete (`file_storage/en.json`): title uses curly quotes `“{name}”`; description → `This removes the file from the {backend} backend. Links already shared will stop working immediately.`; confirm `Delete` → `Delete file`. In `Browse.tsx` use `AlertDialogAction variant="destructive"` instead of the className override; `backend` is already in the Inertia payload (`StoredFileOut.backend`) but missing from the TSX `StoredFile` interface. +2. User delete (`DangerZone.tsx` / `users/en.json`): `confirm_body` → `Sessions end at once and the account cannot be restored. Audit entries are kept.`; prompt → single key `Type the email to confirm`; input `font-mono`; **the action button is currently default (emerald) — must be `variant="destructive"`**; add icon tile. +3. Retry (`RetryConfirmDialog.tsx` / `background_tasks/en.json`): description → `A new execution is queued with the same arguments.` + conditional `This one has already been retried once.` (needs `retries` prop — available on `Execution`); collapse the `

` into one mono box `args {args} · kwargs {kwargs}` on `bg-secondary`; confirm `Retry task` → `Queue retry`; add emerald icon tile. Keep the `no_args` branch (deck doesn't cover it). +4. Shared component — yes, one `ConfirmActionDialog` in `packages/ui/src/components/` (exported from `packages/ui/src/index.ts`) covers all three plus `ToggleConfirmDialog`: props `tone: 'destructive' | 'primary'`, `icon: LucideIcon`, `title`, `description`, `children` (slot for the type-to-confirm block or the args box), `confirmLabel`, `cancelLabel`, `onConfirm`, `confirmDisabled`, `busy`, and either `trigger` or `open/onOpenChange` (flags uses controlled, the others use trigger). Optional `confirmText` prop renders the mono input and gates the action. Labels must be props: `packages/ui` already imports `@simple-module-py/i18n`, but no shared `ui.*.cancel` key exists (only `ui.sidebar.close`), and the untranslated-string check forbids literals. + +**5. Backend/props needed** +None. `backend` (file), `email` (user), `retries`/`args`/`kwargs` (task) are all already delivered. + +**6. Ambiguities** +1. "retried once" — derive from `retries === 1`, `retries >= 1`, or `retried_from_id != null`? Copy for 2+ retries? +2. Deck's mono box shows args inline; long payloads (Detail page uses a `
`) need a truncation rule.
+3. User type-to-confirm currently matches case-insensitively; keep?
+4. Deck tiles use emoji glyphs; map to lucide `Trash2` / `TriangleAlert` / `RefreshCcw`.
+5. Which action opens the file confirm: the row (single) or `Delete selected` (bulk needs a count-based title)?
+
+---
+
+### Overall ranking by gap size
+
+1. **File storage (20)** — largest: new dropzone, separate uploads card with cancel/retry and real failure reasons, checkbox selection + bulk delete + footer, `Uploaded by` filter and name resolution, `When` relative-time column, backend/quota subtitle. Needs new view props, an aggregate, a bulk endpoint, possibly a quota setting and a route/layout move.
+2. **Feature flags (19)** — medium: segmented scope control, column rename/reshuffle (`System` as its own column), text `Clear override`, row highlight, audit footer + history link, copy pass. No backend work.
+3. **Destructive confirms (21)** — smallest per dialog but cross-cutting: copy changes, one real bug (user-delete action button not destructive), a conditional retry sentence, and the opportunity to fold four ad-hoc dialogs into one shared `packages/ui` component.
\ No newline at end of file
diff --git a/docs/superpowers/specs/hifi-gap/landing-errors-mobile-01-08-28.md b/docs/superpowers/specs/hifi-gap/landing-errors-mobile-01-08-28.md
new file mode 100644
index 00000000..0be1fe64
--- /dev/null
+++ b/docs/superpowers/specs/hifi-gap/landing-errors-mobile-01-08-28.md
@@ -0,0 +1,135 @@
+# Hi-Fi deck gap analysis — landing-errors-mobile-01-08-28
+
+Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md.
+
+Gap analysis complete. All findings below are read-only; no files were modified.
+
+Repo root for every path below: `/home/anto/Repos/simple_module_python/`. Deck files: `/tmp/hifi/screens/{01-landing,08-errors,28-mobile}.html`, data in `/tmp/hifi/script.js`.
+
+---
+
+## 01 — Landing
+
+**1. Route / files**
+- `GET /` → `host/routes.py::landing` → `inertia.render("Landing", {"isAuthenticated"})`
+- `host/client_app/pages/Landing.tsx` (wrapped in `PublicLayout`), `host/client_app/components/CopyCommand.tsx`
+- `packages/ui/src/layouts/PublicLayout.tsx` (nav), `packages/ui/src/components/BrandingFooter.tsx` + `packages/ui/src/lib/brand.ts` (footer links)
+- Copy: `host/locales/en.json` → `landing.*`; nav labels `packages/ui/locales/en.json` → `public_nav.*`
+
+**2. Deck structure**
+1. Sticky nav: emerald 32px "S" tile + wordmark `simple_module_py` · right: `Docs` `Modules` `GitHub` | divider | bordered chip `EN` | outline button `Sign in` (→ dashboard).
+2. Hero (two blurred blobs, pri @ .13 top-right, pri8 @ .13 mid-left): pill `✦ Batteries-included Django + Inertia starter`; h1 60px Sora `Modular Python apps,` / gradient line `assembled not glued`; sub `Every feature is a self-contained module with its own routes, migrations, permissions and Inertia views. Drop one in, and the host wires it up on boot.`; buttons: primary `Scaffold a project` (href `#quickstart`), outline `Read the docs`; 560px dark terminal strip `$ uvx --from simple_module_cli smpy new my-app` with text button `Copy` → `✓ Copied` (emerald, 1.6 s); helper line `No account needed to run it locally. Sign-in is only for the hosted admin UI.`
+3. Features (secondary bg): eyebrow `How it works`; h2 `One process · many modules · zero glue.`; 3×2 cards (icon tile, h3 17px, desc 14.5px; hover = border pri + shadow-lg):
+   `Schema-first` / `Each module declares its models, routes and permissions in one place; the host reads that declaration on boot.` · `Module system` / `Drop a package into modules/ and its URLs, migrations and menu entries register themselves.` · `Inertia views` / `React pages served straight from Python views. No separate API layer to keep in sync.` · `Devtools` / `make new-module scaffolds a working module, complete with tests and a page shell.` · `Auth included` / `Email and cookie sessions, invites, password reset and optional Keycloak SSO out of the box.` · `Diagnostics` / `Every module can register health checks; make doctor and the Doctor screen report on all of them.`
+4. `#quickstart` (1fr / 1.2fr): eyebrow `Quickstart`; h2 `Working app in five commands.`; body `Land on http://localhost:8000 with users, dashboard and permissions pre-wired. Sign in with the admin account you bootstrap and go from there.`; check-list `users` — `Email + cookie sessions via fastapi-users`, `dashboard` — `Authenticated home with module tiles`, `permissions` — `Per-module permission registry`; terminal window (3 dots, `~/my-app — bash`) with 5 numbered steps, comments grey, `✓` lines emerald.
+5. CTA strip: gradient card, h3 `Already running it?`, p `Open the admin UI to manage users, modules and background tasks.`, white button `Sign in to the admin UI`, white-outline `GitHub →`.
+6. Footer: `© 2026 simple_module_py` · `Docs` `Changelog` `License`.
+
+**3. Already matches** — section order and skeleton are 1:1 (blobs, badge, two-line gradient h1, two CTAs, CopyCommand, 6 feature cards with the same icon assignments, quickstart split with identical checklist and terminal text, gradient CTA strip, footer). Fonts already configured in `packages/ui/src/styles/globals.css`.
+
+**4. Deltas**
+1. Badge copy: impl `v0.1 · Python 3.12 · experimental` → deck `✦ Batteries-included Django + Inertia starter` (see ambiguity re "Django"). `en.json landing.badge`.
+2. h1: impl `Modular monoliths for Python —` / `without the boilerplate.` → `Modular Python apps,` / `assembled not glued`. `en.json hero_title_line1/2`.
+3. Subtitle differs entirely. `en.json hero_subtitle`.
+4. Primary CTA: impl `Start your project` + Rocket icon → auth/dashboard route; deck `Scaffold a project` → in-page `#quickstart`, no icon. `en.json cta_get_started` + href in `Landing.tsx:110-115`.
+5. `Read the docs` label matches; impl adds BookOpen icon (deck none). `Landing.tsx:116-121`.
+6. CopyCommand: deck shows a visible text label `Copy` / `✓ Copied`; impl is icon-only + sr-only text. Add visible label in `CopyCommand.tsx:56-72` (keys `copy_command`="Copy command"/`command_copied`="Copied" exist; deck wording is shorter). Keep impl's wrapping — the deck's `nowrap; text-overflow:ellipsis` is exactly the mobile regression the component's docstring fixed.
+7. Missing helper line under the terminal (`No account needed to run it locally…`). Add to `Landing.tsx` after line 124 + new key.
+8. All six feature titles/descs differ (impl: `Per-module schema`, `Discovered at boot`, `Inertia + React`, `Async SQLModel`, `Built-in auth`, `make doctor`). Keys map 1:1 in `en.json landing.features.*`.
+9. Card hover: impl `hover:border-primary-200`; deck full-primary border + `shadow-lg`. `Landing.tsx:143`. Card text sizes 16/14 vs deck 17/14.5 (minor).
+10. Terminal `pre`: deck greys the `# n.` comment lines; impl renders the whole `QUICKSTART` template literal in slate-200. Split comments into muted spans, `Landing.tsx:24-35, 210-215`.
+11. CTA strip: impl heading `Ready to ship modules?` → `Already running it?`; impl body has three auth-state variants → deck single `Open the admin UI to manage users, modules and background tasks.`; impl primary `Open Dashboard`/`Sign up`/`Log in` → deck `Sign in to the admin UI`; `GitHub →` label matches but deck styles it as a white outline, impl `ghost`. `en.json cta_*`, `Landing.tsx:221-256`.
+12. Nav: deck single outline `Sign in`; impl `Log in` + conditional `Sign up`, or `Open Dashboard` when authed (`PublicLayout.tsx:73-88`). Deck locale control is a bordered `EN` chip; impl `LocaleSwitcher` is a Globe icon and returns `null` with one locale (`LocaleSwitcher.tsx:41`). Deck nav is opaque; impl is `bg-background/80 backdrop-blur` (cosmetic).
+13. Footer: impl shows logo mark + app name + caption `© 2026 · MIT` and links `Docs · Changelog · GitHub`; deck `© 2026 simple_module_py` and `Docs · Changelog · License`. `BRAND_FOOTER_LINKS` in `brand.ts:45-49` (shared with the app shell — changing it affects both). Labels there are untranslated literals (documented blind spot).
+14. Quickstart body: impl has an Oxford comma (`users, dashboard, and permissions`); deck doesn't. Trivial.
+
+**5. Backend/props** — none needed. Note `host/routes.py:31` passes a page prop `isAuthenticated` that `Landing.tsx` never reads (it uses shared `auth.isAuthenticated`) — dead prop. If the deck's single "Sign in" CTA is adopted, the `signup.allowed` three-way branching can collapse.
+
+**6. Ambiguities** — (a) "Django" in the badge is wrong for a FastAPI product; almost certainly a deck typo. (b) Should the primary CTA stay auth-aware or be a pure `#quickstart` anchor as drawn? (c) Deck never shows "Sign up" — hide it, or keep the `signup.allowed` conditional? (d) Is `EN` a switcher (always visible) or static? (e) `License` link target unspecified. (f) `Read the docs` href not given (impl → repo README).
+
+---
+
+## 08 — Error screens
+
+**1. Route / files**
+- No dedicated route. `framework/hosting/simple_module_hosting/_error_handlers.py::render_error_page` renders page `Error` for statuses `{401,403,404,419,422,429,500,503}` (`_INERTIA_ERROR_STATUSES`), from `http_exception_handler`, `not_found_error_handler`, `request_validation_error_handler`, `unhandled_exception_handler`. Props: `status`, `message`, `correlation_id` (uuid4 hex, from `_observability.py:49`), `login_url` (401/419 only), `maintenance`.
+- `host/client_app/pages/Error.tsx` (no layout — full-bleed even when signed in), `packages/ui/src/components/ErrorScreen.tsx`, `packages/ui/src/components/CopyableId.tsx`.
+- Copy: `host/locales/en.json error.*`; badge `packages/ui/locales/en.json errors.http_badge` = `HTTP {code}`.
+
+**2. Deck structure** — deck caption: `One component. The status picks the numeral, the message and the single action offered.` Each is a centred bordered card (`--card`, radius 14, padding 40, shadow), 14px gap:
+- **403**: numeral `403` 64px Sora, amber `#b45309`; h2 `No access`; p (max 280px) `Your role doesn't include settings.manage. Ask an admin to grant it.`; primary `Go home` (→ dashboard) + outline `Go back`.
+- **404**: numeral in `--pri7`; `Not found`; `That page or record doesn't exist. It may have been deleted.`; `Go home` / `Go back`.
+- **500**: numeral red `#dc2626`; `Something broke`; `The server hit an error. Quote this id if you report it.`; mono chip (sec bg, radius 8) `req_7f4c19ab · copy`; `Go home` / `Retry`.
+
+**3. Already matches** — single `ErrorScreen` component; accent mapping 403→warning, 404→primary, 500→destructive is identical; numeral/title/description/actions order; correlation id with click-to-copy; Go home + Go back present; Sora display font.
+
+**4. Deltas**
+1. Numeral colour: `ErrorScreen.tsx:55-63` always uses a fixed emerald gradient regardless of `accent`; deck colours the numeral per status. Use `ACCENT_COLOR[accent]` (the blob already does).
+2. Numeral size: impl `clamp(72px,12vw,120px)` vs deck 64px. `ErrorScreen.tsx:59`.
+3. Impl renders an `HTTP 403` pill above the numeral (`ErrorScreen.tsx:45-54`); deck has none.
+4. Container: deck is a bordered card; impl is a full-viewport page with an accent blob and no card. `ErrorScreen.tsx:37-44`.
+5. Titles: impl `Forbidden` / `Page Not Found` / `Server Error` → `No access` / `Not found` / `Something broke`. `en.json error.forbidden_title, not_found_title, server_error_title`. Deck h2 is 21px; impl 2xl/3xl.
+6. Descriptions: 403 impl `You don't have permission to access this page.` → deck names the missing permission in ``; 404 impl `The page you're looking for doesn't exist or has been moved.` → `That page or record doesn't exist. It may have been deleted.`; 500 impl `Something went wrong on our end. Please try again later.` → `The server hit an error. Quote this id if you report it.` Note `Error.tsx:101` prefers the server `message` over catalog copy — e.g. `/admin` 403 shows `Administrator access required`, so the catalog text is only a fallback.
+7. Button case: impl `Go Home` / `Go Back` → deck `Go home` / `Go back` (`en.json error.go_home/go_back`). Deck buttons have no icons; impl uses Home + LifeBuoy (`Error.tsx:141-150`).
+8. 500 secondary action: deck `Retry` instead of `Go back`. Add status-conditional secondary + key `error.retry` in `Error.tsx`.
+9. Correlation chip: deck only on 500, one mono chip reading `req_7f4c19ab · copy` (8-char id + inline "copy"); impl shows on every status, with a label line `Reference this ID if you contact support` above and a bordered chip containing the full 32-char hex + copy icon (`Error.tsx:118-131`, `CopyableId.tsx`). Use `CopyableId label={id.slice(0,8)}`; gate `details` on `status >= 500` if following deck; drop/shorten the label line.
+10. `Go home` target: deck → dashboard; impl → `/` (`Error.tsx:142`). Shared `auth` props are available on the error page (`_error_handlers.py:136-138`), so it can be `/dashboard/` when authenticated.
+11. Description measure: deck 14px/1.7 max 280px; impl `text-base max-w-md`.
+
+**5. Backend/props needed** — To render `Your role doesn't include settings.manage`, the page needs the missing permission name; today only a free-text `message` arrives. Add e.g. `required_permission` to the render props in `_error_handlers.py:143-154`, populated from the permission-guard dependency (via `request.state` or structured `HTTPException.detail`). `Retry` is client-only (`router.reload()`/`location.reload()`). Short id and `req_` prefix are presentational.
+
+**6. Ambiguities** — (a) caption says "single action" but every card has two buttons. (b) 403s are not always permission-based (role-gated `/admin`), so the `` sentence needs a fallback. (c) `req_` prefix vs raw uuid; is `· copy` a label inside one chip-button or a separate control? (d) Show the id on 403/404 (impl) or 500 only (deck)? (e) 401/419/422/429/503/maintenance are not drawn — extend the deck rules to them. (f) Should a signed-in user's error page sit inside the sidebar shell (neither does today)?
+
+---
+
+## 28 — Mobile
+
+**1. Route / files** — Deck caption: `Sidebar becomes a drawer; tables become cards. 390px wide, 44px minimum hit targets.` Four 390×720 frames map to:
+- **Dashboard** → `/dashboard/`, `modules/dashboard/dashboard/pages/Home.tsx` + `components/ModuleTile.tsx`, `packages/ui/src/components/StatCard.tsx`, `AuthenticatedLayout`.
+- **Drawer (open)** → `packages/ui/src/layouts/SidebarLayout.tsx` (mobile bar lines 128-167, overlay 169-177, `aside` 179-275), `SidebarUserMenu.tsx`, `AdminSectionLink.tsx`, themes in `AuthenticatedLayout.tsx`/`AdminLayout.tsx`.
+- **Users list** → `/admin/users/`, `modules/users/users/pages/Users/Index.tsx`, `modules/users/users/admin/components/{UserRow,IndexFilters}.tsx`, `AdminLayout`.
+- **Task detail** → `/admin/background-tasks/{id}`, `modules/background_tasks/background_tasks/pages/Detail.tsx`, `AdminLayout`.
+
+**2. Deck structure**
+- **Shell**: dark `#16191f` status bar (`9:41`, `▮▮▮` — device chrome, ignore) + 56px dark app bar: left `☰` (or `‹` on detail), centre-left page title in Sora 15 bold white (mono 14 for `generate_thumbnail`), right contextual slot: avatar `AD` (dashboard), emerald text action `+ Add` (users), nothing (detail). **No bottom nav, no breadcrumb, no locale control, no search.**
+- **Drawer**: full-width dark panel replacing the screen; bar `✕` + `Acme Admin`; groups `Main` → `Dashboard` (active: solid `--pri` bg, white, radius 10), `Users`, `Permissions`, `Settings`; `Operations` → `Files`, `Background tasks`, `Audit log`. Items 15px/500, `13px 12px` padding (~46px rows), **no icons**. Pinned footer: avatar `AD` 34px + `admin` / `admin@example.com`.
+- **Dashboard**: 2×2 stat cards, label above value (`Total users` 128, `Active 7d` 41, `Modules` 12, `Health` `OK` in pri7), label 12px muted, value 22px Sora. Card `System` → 2-col tiles: mono name + 7px dot only (`users`, `settings`, `audit_log` emerald; `bg_tasks` amber).
+- **Users**: search box `Search…`; pills `All` (solid fg/bg inversion) `Active` `Invited` (outline); user cards: 40px initials avatar, email 14px/500, meta 12.5px `admin · active · 2h ago` / `editor · unverified` (amber) / `viewer · active · 3d`, trailing `›`.
+- **Task detail**: row `failed` pill (red on red/10) + `attempt 2 of 3`; 2-col cards `Queue`→`media`, `Duration`→`12.4s`; dark `#0f172a` traceback block (`Traceback` grey, `file_storage/tasks.py:88`, `UnidentifiedImageError` salmon); full-width 50px primary `Retry task` at bottom.
+
+**3. Already matches** — 56px dark mobile bar with hamburger (`h-[var(--app-chrome-h)]`=3.5rem); left-sliding drawer with scrim and `✕`; grouped nav; pinned user row with avatar/name/email at drawer foot (`SidebarUserMenu`); `AppTopbar` hidden below `lg`; no bottom nav; dashboard stats already `grid-cols-2` on phones and System tiles `grid-cols-2`; PageShell stacks title/actions on mobile; `FilterPills` and `relative-time` helpers already exist in `packages/ui`.
+
+**4. Deltas**
+1. **Bar title**: deck puts the page title in the bar; impl puts `BrandingMark` (app name) there and the title only in the PageShell h1. `SidebarLayout.tsx:154-161` — `usePageHeading(currentUrl)` is already available from `PageHeadingProvider`; render it, and let the brand live in the drawer header only.
+2. **Bar right slot**: deck = avatar / `+ Add` / none; impl = `LocaleSwitcher` (`SidebarLayout.tsx:164-166`). Needs an actions slot reported from `PageShell` through `page-heading.tsx` context (compact label like `+ Add`), and locale relocated to the drawer footer.
+3. **Back chevron** on detail pages (`‹` instead of `☰`): impl always hamburger. `PageShell.section` could double as the back target; `SidebarLayout.tsx:132-153`.
+4. **Drawer geometry**: deck full-screen (390px) panel, header `✕ + app name`; impl 256px `w-64` side panel over a `bg-black/60` scrim (`SidebarLayout.tsx:181`). Change to `w-full sm:w-64` or similar.
+5. **Hit targets (44px rule)**: nav rows are `py-2.5 text-sm` (~40px, `SidebarLayout.tsx:237`); hamburger/close/locale are `icon-sm` (32px); `Button size="sm"` is 32px, default 36px (`packages/ui/src/components/ui/button.tsx:21-28`). Deck rows ~46px, CTA 50px. Add `min-h-11` on `` and hides Role/Status (`hidden sm:table-cell`) and Last seen (`hidden lg:table-cell`, `UserRow.tsx:67-82`), so on a phone a row is avatar + name + email + pencil — role, status, last-seen are simply gone, not folded. Deck folds them into a meta line `role · status · 2h ago` on a fully tappable card with `›`. Fix in `UserRow.tsx` (meta line `sm:hidden`, row-as-link) or a card list branch in `Index.tsx:201-247`; use `packages/ui/src/lib/relative-time.ts`.
+12. **Users filters**: impl search `Input` + three `Select`s (Status/Role/Verified, `IndexFilters.tsx`) + `Tabs` (Users/Roles) + `UserStats` 4 cards; deck search + pills `All / Active / Invited` and no stats/tabs. Swap to `FilterPills.tsx` below `sm`; decide whether `UserStats`/Tabs hide on mobile. Deck `Invited` = impl `verified=no` (the `StatusBadge` already labels this "Invited").
+13. **`+ Add`**: impl renders full `Add people` button under the title via PageShell `actions`; deck moves it into the bar. Depends on delta 2; needs a short label key.
+14. **Task detail**: impl = title + `Task execution {id}` + `Back to tasks`/`Retry task` `size="sm"` (32px) top-right, then Details card (10 `dl` rows), Args/Kwargs/Result/Traceback in light `bg-muted` `
`s (`Detail.tsx:121-206`). Deck = status pill + `attempt 2 of 3`, two fact cards (Queue, Duration), dark slate traceback with coloured lines, full-width bottom `Retry task`. Reorder for `` row in a 2-col grid with matching border logic; `Save`/`Reset` disabled-when-clean is a superset of the deck.
+
+## 4. Deltas
+1. **Title copy** — `edit.title` "Edit permissions for {role}" → `Edit role: {role}` (`en.json`).
+2. **Action labels/order/icon** — currently `Discard` · `Save changes`(+Check icon) · `Back`(ghost). Deck: `Reset` · `Cancel` · `Save role`, no icon, Cancel as outline. Change `edit.reset_button`→"Reset", `edit.cancel_link`→"Cancel", `edit.submit_button`→"Save role" (`en.json`); reorder and drop `` / make Cancel `variant="outline"` in `RoleEdit.tsx`.
+3. **Single column → 2-column card grid** — `flex flex-col gap-3` → `grid gap-3.5 lg:grid-cols-2 items-start` (`RoleEdit.tsx`).
+4. **Card header control** — replace Package icon + ghost `Select all`/`Clear` button with a tri-state `Checkbox` (`packages/ui/src/components/ui/checkbox.tsx`, Radix supports `checked="indeterminate"`) at the *left* of the name; wire to existing `toggleGroup`. Add `aria-label` key (e.g. `edit.toggle_group_label`) in `en.json`; `select_all_group`/`clear_group` keys become unused.
+5. **Count format** — `{granted}/{total}` → `{granted} / {total}` (`RoleEdit.tsx`).
+6. **Search scope + placeholder** — deck filters `modules or permissions`; current matches group name only and placeholder is the shared "Filter modules…". Add a role-specific key (`edit.filter_placeholder`: "Filter modules or permissions…") and extend `filtered` to also keep groups whose keys match, narrowing rows inside a group (`RoleEdit.tsx`, `en.json`).
+7. **Missing `Granted only` toggle** — add an outline toggle button (new key `edit.granted_only`) that hides unchecked rows / empty groups (`RoleEdit.tsx`, `en.json`).
+8. **Summary emphasis** — bold the granted number (`` inside the `granted_summary` interpolation or split the key); progress bar fill flat `bg-primary` instead of gradient (`RoleEdit.tsx`).
+9. **Off-state key muted** — `` should get `text-muted-foreground` when not checked (`RoleEdit.tsx`).
+10. **Remove footer badge row** `N / M permissions enabled` — not in deck (`RoleEdit.tsx`; `edit.permissions_enabled` unused).
+11. **Odd-count trailing cell** — add an empty cell for odd `permissions.length` so the last row's border-right renders like the deck (`RoleEdit.tsx`, cosmetic).
+12. **Leave-guard** — neither deck nor page has one; `Users/Edit.tsx` already implements `router.on('before')` + `beforeunload`. Recommend porting it for consistency (`RoleEdit.tsx`, new `edit.leave_warning` key). Optional.
+
+## 5. Backend/props needed
+None. `role.description`, `assigned`, `groups` suffice; "Granted only" and key-level search are client-side.
+
+## 6. Ambiguities
+- Brief mentions a "save bar", but this deck file has no sticky bar — actions live in the page header only. Decide whether to keep header actions (as now) or add a sticky bottom bar on dirty.
+- Deck group names are lowercase slugs (`file_storage`); the registry returns display names (`Users`, `Feature Flags`, `Files`, `Background Tasks`). Either render `group.name` as-is or derive the slug from the key prefix.
+- Deck's `Reset`/`Save role` are always enabled; keep the current dirty-gating or match the deck.
+- After save the server 303s to `/admin/users/`; deck doesn't say whether Save stays on the page.
+- `Cancel` target: `/admin/users/` (current) vs the Roles tab specifically.
+
+---
+
+# Screen 15 — `15-grants.html` "Permissions — sam@example.com"
+
+## 1. Route + files
+- View: `GET /admin/permissions/users/{user_id}/edit` → `Permissions/UserEdit`; save `PUT /admin/permissions/users/{user_id}` (303 → `/admin/users/`). `endpoints/views.py`.
+- Page: `/home/anto/Repos/simple_module_python/modules/permissions/permissions/pages/UserEdit.tsx`; row: `/home/anto/Repos/simple_module_python/modules/permissions/permissions/pages/components/PermissionRow.tsx`.
+- Props: `{ user:{id,email,full_name}, roles[], direct[], inherited[], inherited_by: Record, groups[] }`.
+- Copy: `en.json` `user_edit.*`. Entry: `modules/users/users/pages/Users/components/RolesCard.tsx`.
+
+## 2. Design structure
+1. Header: h1 `Permissions — sam@example.com`; subtitle `Sam Okafor · effective permissions combine role grants and direct grants`.
+2. Actions: `Cancel` (outline), `Save grants` (filled). No Reset/Discard, no icons.
+3. Stats: 3-col grid of plain cards, **label on top** (muted 12.5px, sentence case), no icons:
+   - `Roles` → pill `editor` (primary border, `--soft` bg, `--pri7` text).
+   - `Direct grants` → `2` (Sora 25px bold).
+   - `Effective` → `11` + muted `/ 24`.
+4. Toolbar: 280px search `Filter modules…`; legend: primary square `direct grant`, blue square (`rgba(37,99,235,.25)`/`#2563eb` border) `from role`. No count/progress.
+5. 2-column card grid. Card header: mono module name + muted `3 effective / 4` (no icon, no checkbox).
+6. Card body: **single-column list**, rows separated by border-bottom: `[switch] [code key flex:1] [right badge]`.
+   - Role-inherited: switch OFF, blue pill `granted by editor`.
+   - Direct: switch ON, green pill `direct` (`--pri7` on `--soft`, no border).
+   - Not held: switch OFF, key muted, no badge.
+
+## 3. Already matches
+Three-card summary with Roles badges / direct count / effective `n / total`; search with `Filter modules…`; mono group name + count; per-row switch that controls only the direct grant; role-source badge fed by `inherited_by`; disabled Save when clean.
+
+## 4. Deltas
+1. **Title** — `user_edit.title` "Permissions for {email}" → `Permissions — {email}` (`en.json`).
+2. **Subtitle** — deck is `{full_name} · effective permissions combine role grants and direct grants`; current shows *either* full name *or* the fallback. New key e.g. `user_edit.subtitle` "{name} · effective permissions combine role grants and direct grants" (`en.json`, `UserEdit.tsx`); decide fallback when `full_name` is null.
+3. **Actions** — currently `Back`(ghost) · `Discard` · `Save changes`(+icon). Deck: `Cancel`(outline) · `Save grants`. Rename `cancel_link`→"Cancel", `submit_button`→"Save grants"; drop Check icon; drop Discard (or keep, see §6) (`UserEdit.tsx`, `en.json`).
+4. **Stat cards** — deck has label-top, no icon, normal-case labels; current uses `StatCard` (icon, big value, uppercase label below) and a hand-rolled Roles card with the same shape. Either add a `variant="plain"` to `packages/ui/src/components/StatCard.tsx` or build a small local `PlainStat` in `pages/components/` and use it for all three (`UserEdit.tsx`).
+5. **Stat labels** — `direct_summary` "Direct" → `Direct grants`; `Effective` value should render `11` with muted `/ 24` suffix rather than one string (`en.json`, `UserEdit.tsx`).
+6. **Legend missing** — add `direct grant` / `from role` swatches next to the search (new keys `user_edit.legend_direct`, `user_edit.legend_role`; `UserEdit.tsx`).
+7. **2-column card grid** — `flex flex-col` → `grid lg:grid-cols-2 items-start` (`UserEdit.tsx`).
+8. **Card header** — remove the Package icon; count copy `{granted}/{total}` → `{n} effective / {total}` (new key `user_edit.group_effective`; `UserEdit.tsx`, `en.json`).
+9. **Row layout** — deck is a single-column list with switch on the **left**, key `flex-1`, badge on the right, `border-b` between rows. Current is a 2-col grid with leading effective circle (Check/Minus), key, badge, switch on the **right**. Restructure `PermissionRow.tsx`: drop the indicator span (`effective_yes/no` keys unused), move Switch first, keep `title`/`aria-label`; drop the `sm:border-r` grid classes in `UserEdit.tsx`.
+10. **Badge copy/style** — `via {role}` → `granted by {role}` (`user_edit.via_role`); deck blue pill has tinted border. Add a **`direct`** badge (new key `user_edit.direct_badge`, `border-0 bg-primary/10 text-primary-700`) for keys the switch holds on (`PermissionRow.tsx`, `en.json`).
+11. **Not-held key muted** — already done via `effective ? … : text-muted-foreground`; keep.
+12. **Leave-guard** — same optional port from `Users/Edit.tsx` as screen 14.
+
+## 5. Backend/props needed
+None; `full_name`, `roles`, `direct`, `inherited_by`, `groups` cover every element.
+
+## 6. Ambiguities
+- Key held **both** directly and via role: deck has no example. Show both badges, or `direct` only? Current shows `via role` + switch on.
+- Multiple granting roles: deck shows one name; current appends ` +N`. Decide `granted by editor, admin` vs `+N`.
+- Row ordering in the deck (`users.read, users.write, users.invite, users.delete`) is neither alphabetical nor registry order — possibly "held first". Registry returns sorted keys; decide whether to reorder.
+- Whether to keep a Discard/Reset button (deck omits it; screen 14 has one).
+- `Cancel` target: `/admin/users/` (current) vs back to `/admin/users/{id}/edit` where the "Manage permissions" link originates.
+- No unsaved-changes bar in the deck despite the brief; same decision as screen 14.
+- Post-save redirect to the users list vs staying on the page.
+
+---
+
+## Overall summary (largest gap first)
+1. **15-grants (UserEdit)** — larger gap: row anatomy inverted (switch left, no effective indicator, new `direct` badge, `granted by` copy, single-column list), stat cards need a new plain variant + relabel, legend missing, 2-col grid, title/subtitle/action copy. Touches `UserEdit.tsx`, `PermissionRow.tsx`, `StatCard.tsx` (or new local component), `en.json`.
+2. **14-role (RoleEdit)** — moderate: 2-col grid, tri-state header checkbox replacing icon + Select all/Clear, `Granted only` filter, key-level search, remove footer badge, copy for title/buttons. Touches `RoleEdit.tsx`, `en.json`.
+
+No backend or prop changes are required for either screen; all deltas are frontend + locale.
\ No newline at end of file
diff --git a/docs/superpowers/specs/hifi-gap/settings-16-18.md b/docs/superpowers/specs/hifi-gap/settings-16-18.md
new file mode 100644
index 00000000..5cf86a0c
--- /dev/null
+++ b/docs/superpowers/specs/hifi-gap/settings-16-18.md
@@ -0,0 +1,104 @@
+# Hi-Fi deck gap analysis — settings-16-18
+
+Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md.
+
+Analysis complete. All reads were done directly; no files were modified.
+
+# Settings screens — design-vs-implementation gap report
+
+**Cross-cutting IA conflict (decide first).** The deck's nav item "Settings" lands on the raw override table (screen 16, crumb `Settings`), with module forms as a sub-page (screen 18, crumb `Settings / Modules / users`). The app deliberately did the opposite: `/admin/settings/` is the module forms and the raw store was demoted to `/admin/settings/store` (`/home/anto/Repos/simple_module_python/modules/settings/settings/constants.py` lines 50-56, `pages/routes.ts` comments, `endpoints/views.py` lines 73-77). Either revert that decision or keep the app IA and restyle each screen in place. Everything below assumes the latter.
+
+---
+
+## Screen 16 — Settings (raw overrides table)
+
+**1. Route / files.** `/admin/settings/store` → `Settings/Browse` (`endpoints/views.py` `browse()` lines 68-83, props `settings: Setting[]` — full unpaginated list) → `/home/anto/Repos/simple_module_python/modules/settings/settings/pages/Browse.tsx`. Copy in `locales/en.json`.
+
+**2. Deck structure.**
+1. Header: h1 "Settings"; subtitle "Database overrides. Precedence: user beats tenant beats system beats env default."; right: outline "Per-module forms", primary "+ New override".
+2. Toolbar: segmented scope tabs in a `--sec` container: "All 42" (active, card bg + shadow), "system 28", "tenant 9", "user 5"; flex-1 search box with magnifier, placeholder "Search keys…".
+3. Table card (fills remaining height): header grid `90px 2fr 70px 1.4fr 130px` = "Scope", "Key", "Type", "Value", "Actions" (right); uppercase 11px, `--sec` bg.
+4. Rows: lowercase scope pill (system `--pri7`/`--soft`; tenant `#2563eb`; user `#b45309`); key in JetBrains Mono, with scope_id as a muted 11.5px sub-line for tenant/user rows ("acme-co", "dana@example.com"); Type as muted short label "str"/"int"; Value in mono, with a 16px colour swatch before `#0f766e`; actions "Edit · Delete" (Edit `--pri7`, Delete `#dc2626`).
+5. Footer pinned bottom: "Showing 1–20 of 42" left; "Previous" / "Next" outline buttons right. No empty state, no Description column.
+
+**3. Already matches.** PageShell title + description; two header actions (outline → modules, primary → create); card-wrapped table with 11px uppercase header; scope badges in the same three tones; mono key/value; Edit (primary) / Delete (destructive) right-aligned; empty state (extra).
+
+**4. Deltas.**
+1. Copy — `en.json` `browse.description` → "Database overrides. Precedence: user beats tenant beats system beats env default."; `modules.browse_link` ("View module settings", used only in Browse.tsx) → "Per-module forms"; `browse.new_button` → "New override" (Plus icon supplies the "+"). Drop the Box icon on the outline button (Browse.tsx line 59).
+2. Missing scope filter tabs with counts — Browse.tsx. `FilterPills` (`packages/ui/src/components/FilterPills.tsx`, exported, currently unused) exists but renders outline pills, not the deck's segmented control; add a `variant="segmented"` or hand-roll. Needs `counts` prop.
+3. Missing "Search keys…" input — Browse.tsx; follow `modules/users/users/pages/Users/Index.tsx` lines 82-118 (300 ms debounce, `router.get(..., { preserveState, preserveScroll })`).
+4. Missing pagination footer — Browse.tsx + new keys `browse.showing` ("Showing {from}–{to} of {total}"), `browse.previous`, `browse.next`. Users list uses "Page X of Y" (`users.index.page_of`); deck wants range copy.
+5. Columns: drop "Scope ID" and "Description" columns (Browse.tsx lines 89-91, 101-103, 115-117, 125-127); render `scope_id` as a sub-line under the key when non-empty.
+6. Type cell: app shows "String"/"Integer" uppercase-tracked (`value_types.*`); deck shows muted lowercase "str"/"int". Add `value_types_short.*` keys or change cell class to `text-sm text-muted-foreground`.
+7. Scope badge copy: app "System"/"Tenant"/"User" (`scopes.*`, shared with Create's select); deck lowercase. Add `lowercase` class on the badge.
+8. Colour swatch for `#rrggbb` values (Browse.tsx line 122-124) — small `isHexColor` check.
+9. Card should fill viewport with footer pinned (`mt-auto`); reuse `h-[calc(100vh-var(--app-chrome-h))]` from ModulesEdit.tsx line 41.
+10. Actions separator "·" between Edit and Delete (cosmetic).
+
+**5. Backend/props needed.** `browse()` in `endpoints/views.py` must accept `scope`, `q`, `page`, `per_page` and return `settings`, `pagination {page, per_page, total}`, `counts {all, system, tenant, user}`, `filters`. `SettingService` (`service.py`) has `list_all()` and `list_by_scope()` only — add a filtered/paginated query plus a per-scope count (group-by). Mirror `modules/users/users/admin/views.py` lines 42-83 (clamping, page overflow fix).
+
+**6. Ambiguities.** Server- vs client-side filter/search/paging (deck's "of 42" with 20 rows implies server, 20/page). Segmented control vs pill style. Where Description goes (tooltip? dropped?). Whether "str/int" short labels are desired given `value_type` is "string". Swatch rule (any hex string vs `branding.*` keys only). Keep the existing empty state (deck has none). Delete confirmation: app uses `window.confirm`; the deck has a separate "Delete confirm" dialog screen (21) for Files — unclear if Settings should share it.
+
+---
+
+## Screen 17 — New override (Create)
+
+**1. Route / files.** `/admin/settings/create` → `Settings/Create` (`views.py` `create_view()` + `_known_keys()` lines 92-117; prop `known_keys: [{key, type, description, module}]`) → `pages/Create.tsx`, `pages/components/KeyField.tsx`, `pages/components/ValueInput.tsx`. "On edit the key is locked" → `pages/Edit.tsx` at `/admin/settings/{id}/edit`.
+
+**2. Deck structure.**
+1. Header: h1 "New override"; subtitle "The value input follows the type. On edit the key is locked." No header action.
+2. Grid `1.3fr 1fr`. Left card: (a) row "Scope" select ("system ▾") | "Scope ID" input, placeholder "Leave blank for system scope"; (b) "Key" mono input (focused: `--pri` border + 3px `--soft` ring) with dropdown: header "Registered by modules" (muted, `--sec` bg), rows key-left / meta-right: `users.smtp_host` — "str · env SM_USERS_SMTP_HOST" (highlighted), `users.smtp_port` — "int · default 587", `users.smtp_user` — "str"; (c) row `150px 1fr`: "Type" select ("string ▾") | "Value" mono input ("mail.example.com"); (d) "Description" textarea, 56px, placeholder "Why this override exists."; (e) footer right: outline "Cancel", primary "Save override".
+3. Right card: h2 "Resolved value"; three rows — "this override" (pri border, soft bg) → `mail.example.com`; "env fallback" (opacity .7) → `localhost`; "module default" (opacity .7) → `""`; note "Saving takes effect on the next request — no restart needed."
+
+**3. Already matches.** Scope select, Scope ID, Key with autocomplete that also sets the type, Type select, type-driven `ValueInput`, Description textarea, Cancel + submit, muted labels, key unknown-warning (extra). Edit.tsx locks scope/scope_id/key/type via disabled inputs.
+
+**4. Deltas.**
+1. Copy (`en.json`): `create.title` "New Setting" → "New override"; `create.head_title` likewise; new `create.description` = "The value input follows the type. On edit the key is locked." passed to PageShell `description` (Create.tsx line 46); `create.submit_button` "Create" → "Save override"; `form.scope_id_placeholder` → "Leave blank for system scope"; `form.description_placeholder` → "Why this override exists.".
+2. Remove the duplicate PageShell-level "Cancel" (Create.tsx lines 48-52; same in Edit.tsx 47-51) — deck has Cancel only in the form footer.
+3. Layout: single `max-w-2xl` card → `grid lg:grid-cols-[1.3fr_1fr] gap-4 items-start` (Create.tsx line 54).
+4. Type|Value row: `sm:grid-cols-2` 50/50 → `grid-cols-[150px_1fr]` (Create.tsx lines 102-134).
+5. Suggestion dropdown (KeyField.tsx lines 75-95): add header row "Registered by modules" (new key `form.suggestions_header`); switch rows to `flex justify-between` with meta right: "{type} · env {ENV_VAR}" / "{type} · default {default}" / "{type}" instead of "module · type — description".
+6. Missing "Resolved value" panel — new `pages/components/ResolvedValue.tsx` (keeps Create.tsx under the 300-line cap). Rows: "this override" (live form value), "env fallback", "module default"; footer note (new key `create.no_restart_note`). Should flip to a restart warning when the chosen key's field has `requires_restart`.
+7. `ValueInput.tsx` uses raw ``/native `` → shared `Input`/`Textarea`; secret "Set new value" button → inline link inside the box.
+8. Enum-like strings (`mailer` has `pattern="^(console|smtp)$"`, `modules/users/users/settings.py` line 64) render as a select in the deck; app has a text input.
+9. Test connection: move `TestConnectionButton` from the header to a card footer (`mt-auto border-t pt-4`); name the check; add "✓ Last test succeeded 4m ago" / failure line (new keys + relative time).
+10. Groups: deck is flat; app renders `General`, `Google OAuth`, … h3s (users has ~40 fields, deck shows 7 curated).
+
+**5. Backend/props needed.** `testable` → `dict[package, list[check_name]]` (`views.py` `_testable_packages`) so the button reads "Test mailer connection" (`CHECK_MAILER`, `modules/users/users/module.py` lines 246-256). Last-test timestamp/result: nothing persists it today — either sessionStorage client-side or an in-memory map on `app.state.settings`. `choices` per field for select rendering: `_field_view` (`_module_settings.py` lines 150-176) would need to read `info.metadata` pattern or `json_schema_extra["choices"]`. Override counts and defaults are already in props.
+
+**6. Ambiguities.** "Reveal" contradicts the deck's own "write-only · never returned" and the server mask — keep set-new-value semantics, relabel. Where `description` goes (trailing hint vs under label vs tooltip). Flat vs grouped fields. "ENV · default" rule is inconsistent in the deck (`mailer` shows "· console", `smtp_port` omits "· 587"). Whether "2 unsaved" counts dirty fields (likely). Env badge honesty caveat as in screen 17.
+
+---
+
+## Overall ranking (largest gap first)
+
+1. **18 — Module settings**: structural row refactor (2→3 columns), header relocation, Switch/select controls, revert semantics fix, test-connection footer with last-result, several new props. Biggest surface.
+2. **16 — Settings table**: three missing features (scope tabs with counts, search, pagination) all needing new backend query/props; column and copy changes are easy.
+3. **17 — New override**: mostly copy/layout plus one new panel ("Resolved value") whose data needs `_known_keys` enrichment; smallest gap but has the trickiest semantic question (env fallback honesty).
\ No newline at end of file
diff --git a/docs/superpowers/specs/hifi-gap/shell-dashboard-doctor-branding-00-09-27-26.md b/docs/superpowers/specs/hifi-gap/shell-dashboard-doctor-branding-00-09-27-26.md
new file mode 100644
index 00000000..f1c989ba
--- /dev/null
+++ b/docs/superpowers/specs/hifi-gap/shell-dashboard-doctor-branding-00-09-27-26.md
@@ -0,0 +1,153 @@
+# Hi-Fi deck gap analysis — shell-dashboard-doctor-branding-00-09-27-26
+
+Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md.
+
+I have everything needed. Here is the gap analysis.
+
+---
+
+# Design-vs-implementation gap analysis: App shell, Dashboard, Doctor, Branding
+
+Repo root: `/home/anto/Repos/simple_module_python` (paths below are relative to it unless absolute). Deck tokens already match the app: `packages/ui/src/styles/globals.css` is themed "Emerald (SimpleModulePython HiFi)" with Sora / DM Sans / JetBrains Mono and a primary ramp anchored on #059669, so nothing below is a token-level delta.
+
+## Cross-cutting finding (fix first)
+
+**Regression: Branding and Feature flags menu entries fell out of the admin sidebar.** PR #274 (2744cf3) set `section=MenuSection.ADMIN_SIDEBAR` on both; the i18n PR #280 (9ad996d) removed the `MenuSection` import, the `section=` line, and Branding's `order=105`:
+
+- `modules/branding/branding/module.py` lines 64–75 — no `section=`, no `order=` → defaults to `SIDEBAR`, order 0, so the **app** sidebar now shows "Appearance › Branding" as its first group.
+- `modules/feature_flags/feature_flags/module.py` lines 43–56 — same, lands in app sidebar under "System".
+
+Both pages render in `AdminLayout` (menuKey `adminSidebar`), so their own sidebar no longer lists them: no active highlight, breadcrumb has no section, and `/admin` overview cards omit them. Restore `section=MenuSection.ADMIN_SIDEBAR` (+ `order=105` for Branding) and add a registry test. No test currently asserts the section for either module.
+
+---
+
+## 00 — App shell
+
+**1. Routes/files.** Every authenticated page. `packages/ui/src/layouts/SidebarLayout.tsx` (shell), `AuthenticatedLayout.tsx` (app theme), `AdminLayout.tsx` (red admin theme + "Admin Panel" badge + "Back to App"), `SidebarUserMenu.tsx`, `AdminSectionLink.tsx`; `packages/ui/src/components/{AppTopbar,CommandPalette,LocaleSwitcher,NavIcon,BrandingMark,BrandingFooter,BrandingBanner}.tsx`; menus from `framework/core/simple_module_core/menu.py` + each module's `register_menu_items`; copy `packages/ui/locales/en.json`.
+
+**2. Design structure.**
+1. 256px sidebar, `--side` #16191f, 1px white/6% right border.
+2. 64px brand row: 30px rounded-9 emerald badge "S" + wordmark "simple_module_py" (Sora 700 14.5px).
+3. Nav, padding 16px 12px. Groups "Main" [Dashboard (active), Users, Permissions, Settings], "Operations" [Feature flags, Files, Background tasks, Audit log], "System" [Branding, Doctor]. Group header 11px 700 uppercase .09em #8c93a1. Item: 17px icon, 14px 500 label, padding 10px 12px, radius 10; active = solid `--pri` bg + white; inactive #b7bdc8.
+4. User row (top border): 34px circle #2c313b with initials "AD"; "admin" 13.5px; "admin@example.com" 11.5px muted, ellipsis; "▾".
+5. 56px topbar, bg card, bottom border, padding 0 28px: crumb 13px muted left ("Dashboard", "Users / dana@example.com"); right: "Search  ⌘K" bordered pill, "EN" bordered pill, "Log out" bordered button.
+6. Content fills the rest; no footer inside the frame.
+
+**3. Already matches.** `w-64` near-black sidebar; `h-16` brand row with `BrandingMark`; grouped nav with uppercase muted headers; avatar row with name/email + chevron opening a dropdown; 56px topbar (`--app-chrome-h:3.5rem`) bg-card with 13px breadcrumb (section from registry + PageShell leaf — "Users / admin@example.com" works) and "Search ⌘K" trigger; ⌘K palette; fonts/colours.
+
+**4. Deltas.**
+1. *Two shells vs one.* Deck puts Branding/Doctor in the same emerald shell; app splits app sidebar ([Dashboard], "Content" [Files], "Administration" link) and a **red-tinted** admin sidebar ("ADMIN PANEL" badge; "Access" [Users], "Appearance" [Branding], "System" [Feature Flags, Background tasks, Settings, Audit log, Doctor], "Back to App"). Registry-driven, likely keep — but decide whether `AdminLayout.tsx` `THEME` (red `bg-admin-bg`, red accent/badge) should be re-tinted to the deck's charcoal/emerald.
+2. *Nav contents/labels.* Deck groups Main/Operations/System, lists Permissions. Registry-driven, likely keep (PR #271 explicitly declined to follow the deck). Label casing: "Feature Flags" (`modules/feature_flags/feature_flags/constants.py` MENU_LABEL / locale) vs deck "Feature flags".
+3. *Section regression* — see cross-cutting.
+4. *Active item style.* Deck solid `--pri` + white; app `bg-primary-600/15 text-primary-300 border-l-2 border-primary-400` (`AuthenticatedLayout.tsx` THEME.activeClass; red equivalent in `AdminLayout.tsx`). Icons 20px stroke 1.5 (`NavIcon.tsx`) vs deck 17px stroke 1.8.
+5. *"Log out" button missing from topbar.* Only reachable via avatar dropdown / ⌘K "Account". Add to `AppTopbar.tsx`, driven by the `userDropdown` POST item (`users.nav.logout` reads "Logout"; deck says "Log out").
+6. *"EN" pill.* `LocaleSwitcher.tsx` renders a Globe icon button and returns `null` when `supportedLocales.length <= 1` — host default is `["en"]` (`framework/hosting/simple_module_hosting/host_settings.py:33`), so a default install shows no locale control at all. Deck shows a text pill "EN" always.
+7. *Avatar.* Deck two-letter initials on neutral #2c313b; app single initial on `bg-primary-700` / `bg-red-700` with ring (`SidebarUserMenu.tsx:62–66`).
+8. *Footer.* App renders `BrandingFooter` ("© 2026 · MIT", Docs/Changelog/GitHub) under every page (`SidebarLayout.tsx:286`); deck frame has none.
+9. Minor: topbar `px-6` (24px) vs 28px; "Search" trigger has an icon + ``; wordmark `text-lg` vs 14.5px.
+
+**5. Backend/props.** None beyond the `section=` fix.
+
+**6. Ambiguities.** Duplicate vs move "Log out"; what the pill shows with one locale; admin shell colour; whether Permissions ever gets an index page.
+
+---
+
+## 09 — Dashboard
+
+**1. Routes/files.** `/dashboard/` → `modules/dashboard/dashboard/endpoints/views.py::dashboard` → `pages/Home.tsx`, `pages/components/ModuleTile.tsx`, `pages/components/DemoPlaceholders.tsx` (DEV only); data `stats.py`; copy `locales/en.json` `home.*`; shared `packages/ui/src/components/{StatCard,SectionTitle,PageShell}.tsx`.
+
+**2. Design structure.**
+1. Padding 30px 34px, gap 22. h1 "Dashboard" (Sora 700 27px); "System overview for this workspace".
+2. Four stat cards (radius 14, padding 18px 20px): row 1 = label (13px 500 muted) left, 32px soft-emerald icon square right; row 2 = value (Sora 700 30px) with delta as inline coloured text. "Total users" 128 "+6 this month" (muted) · "Active users" 41 "↑ 7d" (pri7) · "Modules" 12 "all loaded" (muted) · "Health" OK "all good" (pri7).
+3. "System" card fills remaining height: h2 "System" + right mono "Python 3.12.4 · 12 modules · all checks healthy". 4-col tile grid (radius 12, padding 14): row 1 mono name + 8px dot (pri / #d97706 / muted); row 2 status "loaded · healthy" | "loaded · degraded" | "loaded · no checks" + action "Open" (pri7) | "No view" (muted). Degraded tile bg `--soft`; no-view tile opacity .55, cursor not-allowed; hover border `--pri`.
+4. Nothing else on the page.
+
+**3. Already matches.** PageShell heading; four StatCards with the same icons; Health card logic ("OK"/"{n} alert", "all good"/"see Doctor"); System card with h2 + mono meta; 4-col grid of mono-named tiles with health dots; unreachable tiles rendered inert (permission-aware `menuTarget`).
+
+**4. Deltas.**
+1. Copy (`locales/en.json`): `home.description` "Overview of your application" → "System overview for this workspace"; "Total Users" → "Total users"; "Active Users (7d)" → "Active users".
+2. `StatCard.tsx` layout is inverted vs deck: icon top-left + delta `Badge` top-right, value, uppercase 11px label underneath. Deck: label top-left, icon top-right, value with delta as plain coloured text. Shared component — change affects Doctor and other modules.
+3. Missing deltas: "+6 this month" on Total users (needs backend, see §5); "all loaded" on Modules (static key). Active-users delta is a Badge, deck is inline pri7 text.
+4. System meta lacks the third segment "· all checks healthy" (`home.system_meta`); derive from `system_info.health_checks`.
+5. `ModuleTile.tsx` is a single row (Box icon + name + 6px dot + hover chevron). Deck is two rows with status text and an explicit "Open"/"No view" label, soft bg for degraded, dimmed not-allowed for no-view, 8px dot, hover border-primary. New keys: `loaded_healthy`, `loaded_degraded`, `loaded_no_checks`, `open`, `no_view`.
+6. `DemoPlaceholders.tsx` (Recent activity / Needs attention / Team online, DEV-gated, hardcoded names) is not in the deck; deck's System card takes the full remaining height.
+7. Minor: `SectionTitle` accent bar not in deck; grid `gap-2` vs 12px; Card radius 12 vs 14.
+
+**5. Backend/props.** `stats.py`: add `users_created_this_month` (User has `AuditMixin.created_at`, `modules/users/users/models/user.py:32`) and pass through in `views.py`. Everything else derivable client-side. Note the 30s process-wide cache.
+
+**6. Ambiguities.** Meta wording when a check is degraded; "Open" target for partly-admin modules (Users → `/admin/users/`); whether the no-view tile stays a non-link `div` (recommended); delete or keep DemoPlaceholders.
+
+---
+
+## 27 — Doctor
+
+**1. Routes/files.** `/admin/doctor/` → `modules/dashboard/dashboard/endpoints/views.py::doctor` (`admin_router`, `_require_admin`) → `pages/Doctor.tsx`; **all check/migration/dev-server/env rows come from fixtures** in `pages/components/doctor-data.ts`; copy `locales/en.json` `doctor.*`; menu `module.py` (ADMIN_SIDEBAR, System, order 220). Backend that exists: `app.state.migration` = `{current_revision, head_revision, is_current, pending_count}` (`framework/hosting/simple_module_hosting/migrations.py`); `run_diagnostics()` (`framework/core/simple_module_core/diagnostics/_runner.py`) runs **dev-only at boot** and its result is printed then discarded (`app_builder.py:133–140`).
+
+**2. Design structure.**
+1. h1 "Doctor"; "The same checks as `make doctor` — static analysis, migrations, dev server, module health." Actions: "Copy report" (outline), "↻ Re-run checks" (primary).
+2. Stats (radius 12, label 12.5px, value Sora 25px): "Checks passing" `7` + muted `/ 8`; "Modules loaded" 12; "Pending migrations" 1 as an amber-tinted card (bg rgba(180,83,9,.06), border .35, text #b45309); "Python" 3.12.4.
+3. Grid 1.6fr/1fr. Left: "Static checks" — "✓ Orphan pages — every page has a route … pass", "✓ Module metadata complete … pass", warn box "!" "Migration drift in audit_log" / "Model changes are not yet in a migration file. Run `make migrations`." / "Fix"; "✓ Locale consistency across modules … pass"; "✓ Permission registry has no duplicates … pass". Then "Recent migrations" with link actions "Generate" / "Apply pending"; rows `a3f1` `users` add invite table applied · `b7c2` `audit_log` index on entity_type, entity_id pending · `c081` `file_storage` add checksum column applied.
+4. Right: "Dev server" + "running" pill; rows `vite :5050`, `api :8000`, `worker celery@w1`. Then a dark terminal panel: "$ make doctor", "checking modules… 12 loaded", "checking pages… 26 routed", "warn: audit_log has unmigrated model changes" (yellow), "✓ 7 of 8 checks passed" (green).
+
+**3. Already matches.** Overall shape: PageShell with two header actions incl. Re-run, 4-stat row, 2:1 grid, Static checks with pass/warn rows, Recent migrations with Generate/Apply and id/module/message/status rows, Dev server with "running", a dark mono panel on the right, AdminLayout.
+
+**4. Deltas.**
+1. Data is fictional (STATIC_CHECKS cites `modules/billing/router.py:14`; MIGRATIONS 0021–0024 billing/orders; DEV_SERVER says Vite `:5173` but `Makefile` uses 5050; ENV_VARS). Wire to real data (§5).
+2. `doctor.description` → deck sentence with `make doctor`.
+3. Actions: "Re-run" + "make doctor" (Terminal icon) → "Copy report" + "↻ Re-run checks". Neither app button has an `onClick`.
+4. Stats: "Checks passed" → "Checks passing" with split `7 / 8`; "Modules" → "Modules loaded"; "Pending mig." → "Pending migrations" with a tinted-card variant (add `tone` to `StatCard.tsx`); 4th card "Health" → "Python" (`python_version` already in props). No delta badges in the deck.
+5. `CheckRow`: deck pass rows are one line "✓ label … pass"; only warn rows expand into an amber box with helper + "Fix". App renders name + hint + Badge on every row. Deck labels map to SM003/004, SM001, SM011, SM013–016; **no "permission duplicates" diagnostic exists** in `diagnostics/`.
+6. Migrations: "Apply" → "Apply pending", link-style actions not ghost icon buttons; drop the "when" column; status as coloured text; 4-char id.
+7. Remove "Installed modules" (real data — decide), "Run a command", "Environment" cards; replace with the terminal transcript panel driven by props.
+8. Dev server values as mono text, rows `vite / api / worker`.
+9. Grid `2fr_1fr` → `1.6fr_1fr` (minor).
+
+**5. Backend/props needed** (new `dashboard/doctor.py` + view props):
+- `checks[]` {code, label, status, module, message, suggestion, file} — call `run_diagnostics(modules, migration_state=app.state.migration, i18n_*)` per request or persist the boot result on `app.state.sm`. Note diagnostics are skipped outside development and the AST checks need the source tree.
+- `migrations[]` — Alembic `ScriptDirectory.walk_revisions()` (short id, `branch_labels` = module, `doc`, applied = at/below `current_revision`); add to `hosting/migrations.py`. `check_migrations` raises at boot when behind head, so `pending_count` is 0 at runtime — the deck's "Pending migrations 1" is only reachable as SM011 model drift.
+- `dev_server` — vite/api ports from settings; worker name needs Celery inspect (background_tasks already polls the fleet; cross-module).
+- "Re-run checks" → `router.reload()` if computed per request, or a POST; "Copy report" client-side.
+
+**6. Ambiguities.** Per-request analysis cost vs boot snapshot; what "Fix" does; whether "Generate"/"Apply pending" execute Alembic from a web request (risky) or copy commands; "Dev server" panel in production; what "/ 8" counts; "Copy report" format.
+
+---
+
+## 26 — Branding
+
+**1. Routes/files.** `/admin/branding/` → `modules/branding/branding/endpoints/views.py::manage` → `pages/Manage.tsx`; `components/{BannerField,BrandingPreview,DesignPackField,ImageField,PresetField}.tsx`; API `endpoints/api.py`; `settings.py`, `presets.py`, `shared_props.py`; copy `locales/en.json` `manage.*`.
+
+**2. Design structure.**
+1. h1 "Branding"; "Name, colour, logos, banner and footer — applied to every page, including the anonymous ones." Right: "4 unsaved changes" (amber), "Discard", "Publish branding".
+2. Grid 1.15fr/1fr. Form card (no header): row "App name" ("Acme Admin") + "Primary colour" (38px swatch + mono "#0f766e"); "Presets" chips emerald #0f766e (active), slate #475569, indigo #4f46e5, amber #b45309; "Announcement banner" input ("Maintenance window Sunday 02:00–04:00 UTC") + "info ▾"; "Logo · dark logo · favicon" three dashed dropzones (1fr 1fr 92px): "logo.svg ✕", dark "upload", "ico"; "Footer" input "© 2026 Acme Corp" + chips "Privacy · /privacy ✕", "Status · status.acme.co ✕", "+ add link"; foot: "One Publish applies text, images and footer together." · "Design pack: emerald ▾".
+3. "Live preview" card with segmented [App | Sign-in | Email]; frame = banner strip in the primary colour, 74px mini sidebar ("Acme", one active bar), 22px topbar strip, heading + 3 cards + primary button, footer strip "© 2026 Acme Corp · Privacy · Status"; caption "Preview covers the app shell, the sign-in card and transactional email headers — no reload needed to see a change."
+
+**3. Already matches.** Title; form + preview two-column in AdminLayout; app-name input; swatch + mono hex; preset chips with dots; banner input + severity select; logo / dark logo / favicon slots with dark surface for the dark logo; design-pack selector; live preview from form state with banner, mini sidebar (real menu labels), content mock + primary button.
+
+**4. Deltas.**
+1. `manage.description` → deck sentence (only mention "footer" if 8 is accepted).
+2. No dirty count / "Discard" / "Publish branding" in the header; app has one "Save changes"/"Saving…" at the bottom. Add dirty tracking vs props in `Manage.tsx`, use PageShell `actions`, keys `unsaved_changes_one/_other`, `discard`, `publish`.
+3. `Manage.tsx:140–143` repeats title + description in `CardHeader`; deck has none.
+4. Grid `lg:grid-cols-3` (2:1) → `lg:grid-cols-[1.15fr_1fr]`.
+5. App name + Primary colour side by side; label "Application name" → "App name"; deck has no per-field helper texts (app has six) and no "Remove" ghost next to the colour.
+6. Presets: deck 4 lowercase chips; app 7 Title-case (`presets.py`, values #10b981/#6366f1/#f59e0b/#64748b differ from deck). Active style `border-foreground bg-secondary` vs deck `--pri` border + soft bg. Deck stages presets into "unsaved changes"; app POSTs immediately (`PresetField.tsx` comment says deliberate).
+7. Images: three stacked `ImageField` rows (thumbnail + Upload/Replace/Remove + help) vs one row of three dashed dropzones with inline ✕. Deck's "logo.svg" is invalid — SVG is rejected server-side (`images.py`).
+8. **Footer editor absent.** Implemented in #237 and removed in #273/#275 ("fix: remove footer from the branding" — `FooterEditor.tsx`, `contracts/footer.py`, settings fields, shared prop all deleted). Footer is hardcoded in `packages/ui/src/lib/brand.ts` (Docs/Changelog/GitHub, "© {year} · MIT"). Reviving is a full feature; needs a product decision.
+9. Design pack: full labelled Select mid-form → compact "Design pack: emerald ▾" in the form foot; keep "None (base tokens)".
+10. "One Publish applies text, images and footer together." implies staged uploads; app uploads on file pick with a `router.reload()` each.
+11. Preview: "Preview" → "Live preview"; add App/Sign-in/Email tabs (Sign-in and Email mocks are new); add topbar strip and footer strip; banner in deck uses the brand colour whereas `BrandingBanner.tsx`/`BrandingPreview.tsx` use semantic severity colours (documented as deliberate); add the caption; second logo tile not in deck.
+12. Three "emerald" defaults: deck `--pri` #059669, deck example #0f766e, app `DEFAULT_SWATCH`/preset #10b981 (`Manage.tsx:24`, `presets.py:55`).
+13. Menu section regression (cross-cutting).
+
+**5. Backend/props.** Footer text + links (settings, DTO, PUT, shared prop, `BrandingFooter` consumer) only if 8 is accepted; staged presets need `PresetField` to set local state; Sign-in/Email previews need no new props.
+
+**6. Ambiguities.** Stage vs apply-now for presets and images (and what "Discard" does to an uploaded image); banner colour = brand vs severity; keep helper texts; footer revival vs update the deck; what the "Email" tab shows.
+
+---
+
+## Overall summary (largest gap first)
+
+1. **Doctor** — entire screen runs on fixtures; needs a diagnostics/migrations/dev-server backend, restructured cards, tinted stat variant, terminal transcript, and copy rewrite.
+2. **Branding** — footer editor was deliberately removed (product call), plus publish/discard/dirty flow, dropzone images, staged presets, preview tabs, and the menu-section regression.
+3. **Dashboard** — moderate: shared `StatCard` layout inversion, two-row `ModuleTile` with status/action copy, "+N this month" stat, meta segment.
+4. **App shell** — small: solid active nav, topbar "Log out" + always-visible locale pill, two-letter neutral avatar, footer presence, and whether the red admin theme stays.
+5. **Do first, cross-cutting** — restore `section=MenuSection.ADMIN_SIDEBAR` in `modules/branding/branding/module.py` and `modules/feature_flags/feature_flags/module.py` (dropped by 9ad996d), unify the emerald default, and change `StatCard` once since three screens depend on it.
\ No newline at end of file
diff --git a/docs/superpowers/specs/hifi-gap/tasks-workers-audit-22-25.md b/docs/superpowers/specs/hifi-gap/tasks-workers-audit-22-25.md
new file mode 100644
index 00000000..d8499990
--- /dev/null
+++ b/docs/superpowers/specs/hifi-gap/tasks-workers-audit-22-25.md
@@ -0,0 +1,195 @@
+# Hi-Fi deck gap analysis — tasks-workers-audit-22-25
+
+Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md.
+
+I have everything needed; no further reads are required. Here is the gap analysis.
+
+# Design-vs-implementation gap analysis — Ops screens 22–25
+
+Path legend (all absolute):
+- `BT` = `/home/anto/Repos/simple_module_python/modules/background_tasks/background_tasks`
+- `AL` = `/home/anto/Repos/simple_module_python/modules/audit_log/audit_log`
+- `UI` = `/home/anto/Repos/simple_module_python/packages/ui/src`
+
+Note on the deck: none of these four screens use `sc-for`; they are static samples, so `script.js` only contributes navigation (`goTasks`, `goTaskDetail`, `goWorkers`, `goConfirm`). The task-detail "Retry task" button navigates to screen 21 (`/tmp/hifi/screens/21-confirm.html`, third card), which I treat as the retry dialog design.
+
+---
+
+## 22 — Background tasks (`/tmp/hifi/screens/22-tasks.html`)
+
+**1. Route + files.** `GET /admin/background-tasks/` → `BT/endpoints/views.py::index` → `BT/pages/Index.tsx` with `BT/pages/components/{StatusStrip,WorkerHealthBanner,ExecutionRow,TasksEmpty,RetryConfirmDialog}.tsx`, `BT/pages/constants.ts`, `BT/pages/retry.ts`, `BT/locales/en.json`.
+
+**2. Design structure.**
+1. Header: h1 "Background tasks"; sub "Monitor executions and retry failed or stuck jobs". Top-right outline buttons: "Workers", "Retry all failed".
+2. Stat strip, 5 equal cards, label above value (12.5px muted / 25px Sora bold): "Queued" 12, "Running" 3, "Succeeded 24h" 840, "Failed" 7 (red tint bg `rgba(220,38,38,.06)`, red border, red label+value), "Stuck" 2 (amber `#b45309` tint). No active/selected state drawn.
+3. Toolbar, one row: search (flex:1, icon, "Search by task name…") | segmented control on `--sec`: "All" · "Failed" (active = card bg + shadow) · "Running" · "Stuck" | dropdown "Queue: all ▾".
+4. Table card, header on `--sec` uppercase 11px: "Task" 2.2fr | "Status" 110px | "Queue" 100px | "Queued" 1fr | "Duration" 100px | "Actions" 90px right. Rows: task name as `` JetBrains Mono `--pri7`; lowercase status pill (`failed` red, `stuck` amber, `running` blue `#2563eb`, `success` `--pri7` on `--soft`); queue muted; queued relative ("9m ago", "just now"); duration "12.4s" or "—"; action = text link "Retry" in `--pri7` for failed/stuck, empty otherwise. Whole row clickable → detail, hover bg `--sec`.
+5. Footer inside card (border-top): "Showing 1–20 of 231" left; "Previous" / "Next" outline right.
+No banner between strip and toolbar.
+
+**3. Already matches.** Search box + exact placeholder; StatusStrip exists and is clickable with red alarm on failed/stuck; Workers button; Task/Status/Queue/Queued/Duration/Actions columns; per-row retry gated on failed/stuck + `background_tasks.manage`; retry confirm; Previous/Next; richer empty states.
+
+**4. Deltas.**
+1. Copy: `index.title` "Background Tasks" → "Background tasks"; `index.description` → "Monitor executions and retry failed or stuck jobs" (`BT/locales/en.json`).
+2. Header actions: move "Workers" from the toolbar into `PageShell actions`; add "Retry all failed" (new key + endpoint) (`Index.tsx`).
+3. Strip tiles: impl has 6 (Failed, Stuck, Retrying, Running, Pending, Success), value-above-label, `text-lg`. Deck: 5 in order Queued(pending) / Running / Succeeded 24h / Failed / Stuck, label-above-value, card styling, Failed tile fully red-tinted and Stuck amber-tinted (not only the number). Edit `TILES`, layout and tint classes in `StatusStrip.tsx`; add labels "Queued", "Succeeded 24h" to `en.json`.
+4. Status filter: replace the 8-option `Select` with a segmented control "All / Failed / Running / Stuck". `UI/components/FilterPills.tsx` is pill-style, not segmented — extend it with a `variant="segmented"` or add a component. (`Index.tsx`)
+5. Add "Queue: all ▾" dropdown (needs backend, §5). (`Index.tsx`)
+6. Toolbar layout: single row, search `flex-1` (drop `max-w-sm`), filters right. (`Index.tsx`)
+7. Table header: apply the audit `TH` treatment (`bg-secondary/40`, 11px uppercase tracking) — `Index.tsx`.
+8. Drop the "Worker" column (deck has 6 columns); `COLUMN_COUNT` 7→6 (`Index.tsx`, `ExecutionRow.tsx`). Or keep hidden below xl — decide.
+9. Task name: `` `font-mono text-primary-700`; remove the red `exception_type` subline (deck has none) (`ExecutionRow.tsx`).
+10. Status pills: `STATUS_BADGE_VARIANT` maps failed+stuck→`destructive`, running+success→`secondary`. Replace with a per-status class map (red/amber/blue/emerald tints, borderless, like `ACTION_BADGE` in audit) in `constants.ts`; deck labels are lowercase.
+11. Queued cell: relative age via `relativeAge` from `UI/lib/relative-time.ts` (add a `days_ago` bucket in `UI/locales/en.json` — deck shows "3h ago", but multi-day rows exist) instead of `formatTs` (`ExecutionRow.tsx`).
+12. Duration: deck shows "—" for running/stuck; impl shows live elapsed. Decide.
+13. Actions: text button "Retry" (`table.retry` key already exists) instead of ghost icon; render nothing instead of "—" (`ExecutionRow.tsx`).
+14. Row click → `router.visit(detail)` + `cursor-pointer hover:bg-secondary/40`; `stopPropagation` on the retry trigger (`ExecutionRow.tsx`).
+15. Pagination: move inside the card as a footer, always visible, "Showing {from}–{to} of {total}" (add key, mirror `audit_log.browse.showing`) instead of centered "Page x of y" shown only when >1 page (`Index.tsx`, `en.json`).
+
+**5. Backend/props needed.**
+- "Succeeded 24h": `service.status_counts` counts all-time per status. Add a windowed count (`status=success AND finished_at >= now-24h`) in `BT/service.py`, expose as e.g. `status_counts.success_24h` from `views.py::index`.
+- Queue filter: `service.list(queue=…)` + `status_counts(queue=…)`, `queue: str | None = Query()` in `views.py::index`, plus a `queues: list[str]` prop (distinct queues) for the dropdown.
+- "Retry all failed": no endpoint. `BT/endpoints/api_admin.py` only has `POST /executions/{id}/retry`. Add a bulk retry (service method + endpoint + toast keys), permission `background_tasks.manage`.
+
+**6. Ambiguities.**
+- Are stat cards clickable filters (impl) or informational (deck shows no active state)? If both, "Queued"/"Succeeded 24h" tiles select statuses the segmented control cannot express.
+- Does "Retry all failed" include `stuck`? Current filter/queue only, or everything? No confirm dialog is drawn.
+- "Succeeded 24h" window basis (`finished_at` vs `queued_at`).
+- Queue list source: DB distinct, worker `active_queues`, or settings.
+- Retrying/Revoked statuses appear nowhere in the deck — hidden or folded?
+- The WorkerHealthBanner is absent from the deck; keep as an undrawn conditional state?
+- Lowercase status labels vs the repo's capitalised i18n convention (global decision).
+
+---
+
+## 23 — Task detail (`/tmp/hifi/screens/23-taskdetail.html` + `21-confirm.html` card 3)
+
+**1. Route + files.** `GET /admin/background-tasks/{execution_id}` → `views.py::detail` → `BT/pages/Detail.tsx`, `BT/pages/components/RetryConfirmDialog.tsx`, `BT/pages/retry.ts`, `BT/contracts/schemas.py::TaskExecutionDetail`.
+
+**2. Design structure.**
+1. Header: h1 = `files.generate_thumbnail` (Sora 24 wrapping Mono 22) with inline red pill "failed"; subline mono muted "execution 8f21c9de-4b17-4a90-9ac2-1f0d7e55e311 · attempt 2 of 3". Right: outline "← Back to executions", primary "↻ Retry task".
+2. Grid `320px 1fr`, gap 18.
+3. Left card, h2 "Details", label/value rows 13px: "Queue" media · "Worker" `celery@w2` (mono) · "Celery id" `c1a4…8de2` (mono, shortened) · "Queued at" 09:41:02 · "Started at" 09:41:05 · "Finished at" 09:41:17 · "Duration" 12.4s · "Heartbeat" — · "Retried from" `3b91c07a…` (mono, `--pri7` link). Footer pinned to bottom (border-top): label "Exception", value `PIL.UnidentifiedImageError` red mono.
+4. Right, top row two cards side by side: "Arguments" → `[ "a91f2c" ]`; "Keyword arguments" → `{ "size": 512 }` (one-line code on `--sec`, 8px radius, muted).
+5. "Traceback" card (flex:1): header with "Copy" text link `--pri7`; `
` dark `#0f172a` bg, `#dfe3ea` text, 12.5px/1.8 mono, final exception line in `#f8a9a0`. Deck annotation (not UI copy): "A Result card appears above the traceback when a run returns one."
+6. Confirm (screen 21): ↻ icon in soft-emerald 40px square; h2 "Retry files.generate_thumbnail?"; body "A new execution is queued with the same arguments. This one has already been retried once."; mono box `args ["a91f2c"] · kwargs {"size": 512}`; buttons "Cancel" / "Queue retry" (primary).
+
+**3. Already matches.** Title = task_name; Back + Retry in `PageShell actions`; retry gating; Details rows Queue/Worker/Celery id/Queued/Started/Finished/Heartbeat/Exception/Retried-from (8-char link); Arguments / Keyword arguments / conditional Result / Traceback cards; dialog title "Retry {name}?", args/kwargs shown, Cancel; toast + navigate to new row.
+
+**4. Deltas.**
+1. Title as mono ``: `PageShell.title` is `string` (feeds breadcrumb via `useReportPageHeading`). Add `titleClassName`/`titleAdornment` props to `UI/components/PageShell.tsx`; pass `font-mono` (`Detail.tsx`).
+2. Status pill inline with the title (deck) instead of a "Status" row inside Details; delete that row (`Detail.tsx`).
+3. Subline: `detail.description` "Task execution {id}" → "execution {id} · attempt {attempt} of {max}", mono muted; needs `max_retries` (§5) (`en.json`, `Detail.tsx`).
+4. `detail.back_button` "Back to tasks" → "Back to executions" (`en.json`).
+5. Details: add "Duration" row (move `formatDuration` from `ExecutionRow.tsx` to `constants.ts`); remove "Retries" row; reorder to deck order (`Detail.tsx`).
+6. Worker as ``; Celery id shortened `first4…last4` (impl full, `break-all`) — consider `CopyableId`; Retried-from link `text-primary-700` (`Detail.tsx`).
+7. Exception → bottom footer block (`mt-auto border-t`, label above, red mono value) rather than a plain row (`Detail.tsx`).
+8. Timestamps: deck time-only "09:41:02"; impl `toLocaleString()`. Decide.
+9. Args/Kwargs side by side (`grid-cols-2`), single-line code on `bg-secondary` (impl stacked, 2-space JSON in `bg-muted`); card titles Sora 14px (`Detail.tsx`).
+10. Traceback: dark terminal `
`, last line highlighted, "Copy" action with copied feedback (clipboard pattern from `UI/components/CopyableId.tsx`), new key `detail.copy`. Extract to `BT/pages/components/TracebackCard.tsx` — `Detail.tsx` is 213 lines against the 300 cap.
+11. Grid `lg:grid-cols-[320px_1fr]` instead of `lg:grid-cols-3` (`Detail.tsx`).
+12. Dialog: `retry_dialog.description` → "A new execution is queued with the same arguments." + pluralised "This one has already been retried {count} time(s)." (pass `retries`); `retry_dialog.confirm` "Retry task" → "Queue retry"; payload as one line `args … · kwargs …` in a bordered `bg-secondary` box; add the ↻ icon tile above the title (`RetryConfirmDialog.tsx`, `en.json`).
+
+**5. Backend/props.** `max_retries` from `app.state.background_tasks` settings (`BackgroundTasksSettings.max_retries`, `BT/settings.py`) added to the `views.py::detail` payload. Everything else (`retries`, `retried_from_id`, `heartbeat_at`, `traceback`, `result`) already ships.
+
+**6. Ambiguities.**
+- "attempt 2 of 3": is attempt `retries + 1`? Is "3" the module-wide `max_retries` or a per-task Celery option (only the former exists)?
+- Time-only timestamps hide the date for older executions — same-day-only rule, or full date in tooltip?
+- "Copy" copies the traceback only, or traceback + exception header?
+- Dialog sentence when `retries == 0` — omit, or "not been retried yet"?
+- Should the Celery id be copyable (`CopyableId`) or just shortened text?
+
+---
+
+## 24 — Workers (`/tmp/hifi/screens/24-workers.html`)
+
+**1. Route + files.** `GET /admin/background-tasks/workers` → `views.py::workers` → `BT/pages/Workers.tsx`; refresh via `GET /api/background_tasks/admin/workers` (`BT/endpoints/api_admin.py`); data from `BT/worker_inspector.py`, `BT/contracts/schemas.py::WorkerInfo/WorkerSnapshot`.
+
+**2. Design structure.**
+1. Header: h1 "Workers"; sub "Celery workers connected to the broker". Right cluster: muted "Last updated 09:44:10", outline "↻ Refresh", outline "← Executions".
+2. Fleet grid 2 columns. Card: 10px dot (`--pri` online / `--muted` offline), `celery@w1` mono 15px, subline "celery 5.4.0 (opalescent) · uptime 4d 2h" (offline: "celery 5.4.0 · last heartbeat 6m ago"), pill "Online" (`--pri7` on `--soft`) / "Offline" (muted on `--sec`); offline card `opacity:.75`. Stats 4-col: "Active" 3 · "Pool" 4 · "Processed" 1,208 · "Queues" mono outlined chips (`default`, `media`); values 19px Sora bold; offline: Active 0, Pool —, Processed —.
+3. Documented empty states (dashed boxes): "Empty state — broker unreachable" — "Shown instead of the fleet when the broker connection fails, with the error text and the setting to check." code `SM_BG_TASKS_BROKER_URL=redis://localhost:6379/0`. "Empty state — no workers connected" — "Broker is reachable but nothing is consuming. Offers the command to start one locally." code `$ python run_worker.py --queues default,media`.
+
+**3. Already matches.** Title/description; Refresh with spinner; Back button; updated-age + stale badge; WorkerCard dot/hostname/software/Online-Offline badge; Active/Pool/Processed/Queues with "—" for nulls and chip badges; broker-unreachable card with error + `SM_BG_TASKS_BROKER_URL`; no-workers card with a run command.
+
+**4. Deltas.**
+1. Move "Last updated" + "↻ Refresh" + "← Executions" into `PageShell actions`; rename `workers.back_button` to "Executions" (`Workers.tsx`, `en.json`).
+2. "Last updated 09:44:10" absolute vs impl "Updated 2m ago" + Stale badge + primary Refresh when stale. Decide (see §6).
+3. Fleet `grid gap-4 md:grid-cols-2` (impl single column) (`Workers.tsx`).
+4. Hostname as `` mono; subline `"{software} · uptime {x}"` / `"{software} · last heartbeat {ago}"` (impl only `software`). Software string: inspector emits `"py-celery:5.4.0"` — deck wants "celery 5.4.0" (`worker_inspector.py::_build_worker_info` or card).
+5. Dot `bg-primary` not `bg-green-500`; offline card `opacity-75` (`Workers.tsx`).
+6. Online/Offline pill tints (`text-primary-700 bg-primary-600/10` / `text-muted-foreground bg-secondary`) instead of `secondary`/`outline` (`Workers.tsx`).
+7. Stat values `font-[var(--font-display)] text-lg font-bold`, `total_processed.toLocaleString()` (`Workers.tsx`).
+8. Queue chips `font-mono rounded-full` (`Workers.tsx`).
+9. Broker-unreachable: show a code block `SM_BG_TASKS_BROKER_URL=` and a red title; impl only names the env var in prose (`Workers.tsx`, needs §5).
+10. No-workers command: impl prints `uv run python scripts/run_worker.py`, which is wrong — `scripts/run_worker.py` has no `main`; per its docstring the command is `uv run celery -A scripts.run_worker:celery worker -l info`. Deck's `python run_worker.py --queues default,media` also doesn't exist. Fix the literal either way; append `-Q ` (`Workers.tsx`).
+11. Empty-state visual: dashed 1.5px border box with title/paragraph/code vs impl `Card p-6` icon+text — optionally via `UI/components/EmptyState.tsx` (`Workers.tsx`).
+
+**5. Backend/props.**
+- `uptime_seconds` on `WorkerInfo` from `stats()["uptime"]` (`schemas.py`, `worker_inspector.py`, mirror in `pages/constants.ts`).
+- "last heartbeat" for offline workers: inspect cannot report anything for a worker that didn't reply; needs persisted last-seen per hostname (events or a Redis/DB record). New state, not a field tweak.
+- Broker URL (credentials redacted) in the `views.py::workers` payload and the API snapshot for the unreachable state.
+- Known queue list for the run command (settings `task_default_queue` + DB distinct).
+
+**6. Ambiguities.**
+- Absolute vs relative freshness label; keep the Stale badge?
+- Release codename "(opalescent)" isn't in `stats()` (`sw_ident`/`sw_ver`/`sw_sys` only) — drop or derive from `celery.__version__`?
+- Sort order (online first?).
+- Offline "Active 0" vs "—".
+- Which start command is canonical.
+
+---
+
+## 25 — Audit log (`/tmp/hifi/screens/25-audit.html`)
+
+**1. Route + files.** `GET /admin/audit-log/` → `AL/endpoints/views.py::browse` → `AL/pages/Browse.tsx`, `AL/pages/components/{FilterBar,EntryCells,Correlation,BrowseEmpty}.tsx`, `AL/resolve.py`, `AL/service.py`, `AL/locales/en.json`, registry `/home/anto/Repos/simple_module_python/framework/core/simple_module_core/audit_links.py`.
+
+**2. Design structure.**
+1. Header: h1 "Audit log"; sub "Field-level change history across all modules". Right: outline "Export CSV".
+2. Filter card, grid `1fr 1fr 1fr 1fr auto` items-end, labels 12.5px muted above fields: "Entity type" (select, `users_user ▾`), "Action" (select, `updated ▾`), "Actor" (input, placeholder "Anyone"), "Date range" (single field "01 Aug – 19 Aug"); "Apply" primary + "Clear" outline.
+3. Table card, header on `--sec` uppercase: "Time" 150px | "Action" 110px | "Entity" 1.4fr | "Actor" 1fr | "Changes" 1.9fr; rows `align-items:start`. Time mono muted "19 Aug 14:02:11". Action lowercase pill (`updated` blue, `deleted` red, `created` `--pri7`/`--soft`). Entity: row display name link `--pri7` ("Sam Okafor", "users.smtp_host", "rob@example.com") + small muted type tag ("users_user", "settings_setting"); unlinked ("seed.sql" `files_file`) plain. Actor: name link or muted "system". Changes: mono lines `field old → new` (field `--fg`, rest muted): "is_active true → false", "disabled_at null → 2026-08-19", `value "" → "mail.example.com"`, "source env → db", then link "+2 more fields"; deleted → "no changes recorded"; created → "7 fields set".
+4. Footer in card: "Showing 1–50 of 2,431" + "Previous"/"Next".
+No correlation link/banner drawn.
+
+**3. Already matches.** Closest of the four: `TH` header styling on `bg-secondary/40`; colour-coded action badges; entity links via registry; resolved actor names linked; "System" for null actor; changes `field old→new` with show-more/less, "{count} fields set", "—" for deletes; "Showing {from}–{to} of {total} entries" + Previous/Next; Apply/Clear; filtered/unfiltered empty states.
+
+**4. Deltas.**
+1. Copy: `browse.title` → "Audit log"; `browse.description` → "Field-level change history across all modules" (`en.json`).
+2. "Export CSV" in `PageShell actions` — missing (`Browse.tsx`, new key, §5).
+3. FilterBar → `grid sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1fr_auto] items-end`, labels `text-xs text-muted-foreground` above inputs (`FilterBar.tsx`).
+4. Labels: "Entity Type" → "Entity type"; "User ID" → "Actor"; placeholder → "Anyone"; From/To `datetime-local` pair → one "Date range" popover (`UI/components/ui/calendar.tsx` + `popover.tsx` exist; react-day-picker 10 is installed) (`FilterBar.tsx`, `en.json`).
+5. Clear button `variant="outline"` not `ghost` (`FilterBar.tsx`).
+6. Table labels: "Timestamp" → "Time", "User" → "Actor" (`en.json`).
+7. Time cell: `d MMM HH:mm:ss` via `Intl.DateTimeFormat`, `font-mono text-xs` (impl `toLocaleString()` sans) (`Browse.tsx`).
+8. Action pill: borderless tints, `created` on emerald (`text-primary-700 bg-primary-600/10`) instead of Tailwind green; lowercase per deck (`ACTION_BADGE` in `Browse.tsx`).
+9. Entity cell: deck = resolved row display name as the link + muted *table-name* tag; impl = kind label ("User") + short id. Needs §5 (`EntryCells.tsx`).
+10. Actor "system" lowercase (`changes.system_user`) (`en.json`).
+11. Changes: spaces around the arrow (`true → false`, impl glues `true→false`); render `null` and `""` distinctly via `JSON.stringify` (impl coerces null to `""`); field `text-foreground` not `font-semibold`; `changes.show_more` "Show {count} more…" → "+{count} more fields"; `changes.no_changes` "—" → "no changes recorded" (`Browse.tsx::ChangesList`, `en.json`). Consider extracting `ChangesList` — `Browse.tsx` is 264/300 lines.
+12. Show-more threshold 3 (impl) vs 2 (deck).
+13. Pagination inside the card footer, always shown, `total.toLocaleString()` ("2,431") (`Browse.tsx`).
+14. `align-top` on every cell, not just Time (`Browse.tsx`).
+15. Correlation link/banner not in deck — keep or demote (§6).
+
+**5. Backend/props.**
+- CSV export: `AL/endpoints/api.py` has only `GET /`. Add a streaming CSV endpoint honouring the same filters (`service.py` iterator + `csv`), permission `audit_log.view`.
+- Entity display names: registry only maps class name → URL template + kind label. Add a per-`AuditLink` batch label resolver (or `register_audit_labels` hook), call it from `views.py::browse`, emit `entity.display`; add table name (`__tablename__`) for the tag (`audit_links.py`, `resolve.py`, module `register_audit_links` in users/settings/background_tasks).
+- Actor filter: `service.list_entries` does `user_id ==` exact. Resolve name/email → user ids in `views.py` (or accept both: UUID → exact, else `ilike` on users).
+- Date range: `from_date`/`to_date` already accepted; if the picker is date-only, treat `to_date` as end-of-day in `views.py`.
+- Entity-type dropdown values: `distinct_entity_types()` returns class names ("User"); deck shows `users_user`.
+
+**6. Ambiguities.**
+- Lowercase raw values (pills, "system") vs repo i18n capitalisation.
+- Type tag: table name, class name, or translated label.
+- Export scope (current filter / page / all) and how `changes` is flattened.
+- Date range with or without time.
+- Actor search semantics (contains on name/email vs exact id).
+- Where the correlation pivot lives if not under Time.
+
+---
+
+## Overall ranking (largest gap first)
+
+1. **22 Background tasks** — strip redesign (5 tiles incl. "Succeeded 24h", tinted alarm cards), segmented filter + queue dropdown, "Retry all failed", clickable rows, mono names, tinted pills, relative times, in-card pagination; three backend additions (24h count, queue filter, bulk retry).
+2. **23 Task detail** — near-complete restructure: mono title + inline pill + attempt subline (needs `PageShell` extension and `max_retries`), Details reorder/duration/exception footer, side-by-side args/kwargs, terminal traceback with copy + highlight, dialog copy/icon/CTA. Backend need is one prop.
+3. **25 Audit log** — visually closest already, but two real backend features (entity display names, CSV export, actor-by-name filter) plus filter-bar grid, date-range picker, and a dozen copy/format changes.
+4. **24 Workers** — mostly styling and header placement; one schema field (uptime); "last heartbeat" for offline workers needs new persisted state; the current start-worker command literal is wrong and should be fixed regardless.
\ No newline at end of file
diff --git a/docs/superpowers/specs/hifi-gap/users-10-13.md b/docs/superpowers/specs/hifi-gap/users-10-13.md
new file mode 100644
index 00000000..9623d83d
--- /dev/null
+++ b/docs/superpowers/specs/hifi-gap/users-10-13.md
@@ -0,0 +1,216 @@
+# Hi-Fi deck gap analysis — users-10-13
+
+Generated 2026-09-02 from the cached deck (fetched 2026-08-19) vs main @ a8ab6bb. Read-only findings; decisions live in ../2026-09-03-hifi-pages-design.md.
+
+# Design-vs-implementation gap analysis — Users / Add people / Edit user / Profile
+
+All repo paths below are under `/home/anto/Repos/simple_module_python/`; the users module root is `/home/anto/Repos/simple_module_python/modules/users/users/`. Deck copy is quoted verbatim. No files were modified.
+
+Two cross-cutting findings that affect several screens:
+
+- **Relative time.** The deck uses "2h ago / 3d ago / 1mo ago" everywhere. `packages/ui/src/lib/relative-time.ts` (`relativeAge`) only buckets up to hours (`ui.relative_time.{just_now,seconds_ago,minutes_ago,hours_ago}` in `packages/ui/locales/en.json`); it needs `days_ago`/`months_ago` buckets + keys.
+- **Auth resolution is session-cookie based, not DB-token based.** `modules/users/users/provider.py:28-57` authenticates browser requests from Starlette's signed session (`session[SESSION_USER_ID_KEY]`, with a cached `UserContext`), not from `users_access_token`. This determines what "Sessions" / "Sign out everywhere" can actually do (see Profile §5).
+
+---
+
+## 10 — Users list (`/tmp/hifi/screens/10-users.html`)
+
+### 1. Route + files
+- `GET /admin/users/` → `modules/users/users/admin/views.py::admin_index` → Inertia `Users/Users/Index`
+- Page: `modules/users/users/pages/Users/Index.tsx`; sub-components `modules/users/users/admin/components/{IndexFilters,RolesTab,UserRow,UsersEmpty}.tsx`, `modules/users/users/pages/Users/components/UserStats.tsx`
+- Data: `modules/users/users/admin/queries.py` (`list_users`, `count_user_states`, `list_roles`), DTO `UserListItem` in `modules/users/users/contracts/schemas.py:118-128`
+- Copy: `modules/users/users/locales/en.json` → `index.*`, `filters.*`, `user_row.*`, `empty.*`
+
+### 2. Design structure
+1. Header: h1 **"Users"**, sub **"People with access to this workspace"**; right: primary button **"+ Add people"**.
+2. Four flat stat cards (label over big number, no icon, no delta badge): **"Members"** 128 · **"Active"** 119 · **"Pending invites"** 6 (number in amber `#b45309`) · **"Roles"** 4.
+3. Toolbar (one row): segmented control **"Users 128"** / **"Roles 4"**; search (flex-fills) placeholder **"Search by name or email…"**; two dropdown buttons **"Status: all ▾"**, **"Role: all ▾"**.
+4. Table card, columns (grid `2.2fr 1fr 1fr 1fr 44px`): **"Member ↑"**, **"Role"**, **"Status"**, **"Last seen"**, blank actions column.
+   - Rows: 32px round avatar with two-letter initials ("DR"), name bold + email muted; role as plain muted text; status pill **"active"** (green soft), **"unverified"** (amber), **"disabled"** (grey, whole row `opacity:.62`), **"invited"** (amber, row tinted `rgba(180,83,9,.05)`, dashed-circle **"✉"** avatar, primary line is the email, secondary **"invited 2d ago · expires in 5d"**, action cell shows **"Resend"** instead of the kebab). Last seen: **"2h ago"**, **"—"**, **"3d ago"**, **"1mo ago"**. Action cell **"⋯"**. Rows have `cursor:pointer` + hover bg.
+5. Card footer: **"Showing 1–20 of 128"** left; **"Previous"** / **"Next"** outline buttons right. Always shown.
+
+### 3. Already matches
+Title; "Add people" CTA; four stat labels; Users/Roles tabs with counts; identical search placeholder; Status + Role filters; column set and sort arrow on Member; active/invited/disabled pills; Previous/Next; "Pending invites" = active && !verified (`count_user_states`).
+
+### 4. Deltas
+1. **Description copy** — impl `index.description` = "People with access to this workspace. Invites use the configured mailer." → deck drops the second sentence. `en.json`.
+2. **Stat cards** — `UserStats.tsx` uses `StatCard` (icon + "review"/"all set" delta badge); deck is plain label+number with the pending number amber. Either a `plain` variant on `packages/ui/src/components/StatCard.tsx` or a local card in `UserStats.tsx`.
+3. **Tabs** — deck is a segmented pill with the count inline ("Users 128"); impl is shadcn `Tabs` with lucide icon + `Badge`. `Index.tsx`.
+4. **Filters** — deck has only Status and Role, rendered as label-prefixed buttons ("Status: all ▾"); impl `IndexFilters.tsx` has three `Select`s (Status/Role/**Verified**) with "All statuses"/"All roles"/"All". Drop or fold Verified; change trigger text. `IndexFilters.tsx` + `filters.*` keys.
+5. **Search width** — deck flex-fills between tabs and filters; impl `max-w-sm`. `Index.tsx`.
+6. **Avatar** — deck two-letter initials on soft-primary/grey bg; impl `UserRow.tsx` `Avatar` is one letter on a primary gradient.
+7. **"unverified" vs "invited"** — impl `StatusBadge` maps every `is_active && !is_verified` to "invited"; deck shows "unverified" (Sam, has a name) and "invited" (rob, no name) as distinct states. `UserRow.tsx` + backend (§5).
+8. **Invited-row treatment** — tinted row, dashed ✉ avatar, email as primary line, "invited 2d ago · expires in 5d", **"Resend"** action. None exists. `UserRow.tsx` + backend.
+9. **Disabled row dimming** — deck dims the whole row; impl only greys the badge. `UserRow.tsx`.
+10. **Last seen** — impl `new Date(...).toLocaleDateString()`; deck relative. `UserRow.tsx` + extend `relative-time.ts`.
+11. **Row action** — deck "⋯" kebab (implies a menu) and whole row clickable; impl a pencil `Link` only. `UserRow.tsx`.
+12. **Pagination** — deck: inside the card footer, "Showing 1–20 of 128" + buttons, always visible; impl: centered below the card, "Page {page} of {total}", hidden when one page. `Index.tsx` + new `index.showing_range` key.
+13. Impl-only, absent from deck (keep): SSO badge, `SoloAccountPrompt`, filtered/empty `UsersEmptyRow`.
+
+### 5. Backend / props / DB needed
+- **Invited vs unverified**: no column distinguishes them. `service.invite()` (`admin/service.py:125-161`) creates `is_verified=False` with a random password — identical shape to a self-signup. A `UserInvited` event exists (`contracts/events.py:18`) but nothing persists it on the user. Needs `invited_at` (+ optionally `invited_by`) on `models/user.py`, `UserListItem`, `queries.py::list_users` select, and a migration under `host/migrations/versions/`.
+- **"expires in 5d"**: invite = fastapi-users verification JWT; lifetime `verification_token_lifetime_seconds` = 7d (`modules/users/users/settings.py:51`); not stored. Derive `invited_at + lifetime` once `invited_at` exists.
+- **Resend**: no admin endpoint. Only self-service `POST /api/users/auth/request-verify-token` (used by `pages/Login.tsx:82`). Needs `POST /api/users/admin/{id}/resend-invite` in `admin/api.py` reusing `manager.generate_verification_token` + `mailer.send_invite` with the console-mailer link fallback from `admin/bulk_invite.py::_invite_link`.
+- Pending-invites count (`count_user_states`) should then key off `invited_at`.
+- `last_login_at` exists and is populated (`manager.py:137`). "Showing X–Y of N" is derivable from the existing `pagination` prop.
+
+### 6. Ambiguities
+- Kebab menu contents (edit / disable / resend / copy reset link?).
+- Whether the Verified filter is removed or merged into Status (`all/active/unverified/invited/disabled`).
+- Whether "Roles 4" still shows the role-card grid (`RolesTab.tsx`); deck never shows it.
+- Whether clicking an invited row opens the edit page.
+- Whether "Resend" should be offered for self-registered "unverified" users too.
+
+---
+
+## 11 — Add people (`/tmp/hifi/screens/11-addpeople.html`)
+
+### 1. Route + files
+- `GET /admin/users/add[?mode=create]` → `admin/views.py::admin_add_people_page` (props `roles`, `mailer_delivers`); `/invite` and `/create` 307 here.
+- Page: `modules/users/users/pages/Users/AddPeople.tsx`; components `pages/Users/components/{InviteFields,CreateUserFields,RolePicker,InviteResults}.tsx`
+- APIs: `POST /api/users/admin/invite/bulk` (`modules/users/users/admin/bulk_invite.py`), `POST /api/users/admin` (`admin/api.py::admin_create_user`)
+- Copy: `en.json` → `add_people.*`, `invite_fields.*`, `create_fields.*`, `invite_results.*`, `role_picker.*`
+
+### 2. Design structure
+1. h1 **"Add people"**; sub **"Invite by email, or create an account directly with a password you set."**
+2. Standalone segmented control: **"Invite by email"** (active) / **"Create account"**.
+3. Full-width amber banner: **"⚠ Mailer is *console* — invite links are printed to the server log instead of emailed. Each result row below carries a copy-link button."** + underlined link **"Configure SMTP"**.
+4. Grid `1.25fr 1fr`:
+   - Left card: label **"Email addresses"**; chip input (min-height 96px) with chips **"rob@example.com ✕"**, **"nia@example.com ✕"** (green) and **"not-an-email ✕"** (red); placeholder **"Paste a list, or type and press Enter…"**; helper **"3 addresses · 1 invalid"**. Then **"Roles for everyone in this batch"** with pills **"editor ✓"**, "admin", "viewer". Then **"Message (optional)"** textarea placeholder **"Added to the invite email."** Footer right: **"Cancel"**, primary **"Send 2 invites"** (count excludes the invalid chip).
+   - Right card **"Last batch"** with header meta **"2 sent · 1 failed"**; rows **"✓ dana@example.com — Copy link"**, **"✓ lee@example.com — Copy link"**, **"✕ ana@example.com — Retry"** with red sub-line **"Already a member of this workspace"** (row tinted red). Footer note **"Invites expire after 7 days. Pending invites appear in the Users table with a Resend action."**
+5. "Create account" mode is not depicted.
+
+### 3. Already matches
+Title; invite/create mode switch (`role="tablist"`); role chips; "Cancel" + "Send {count} invites" with CLDR plural; console-mailer warning concept (`invite_fields.no_mailer`); per-address results with copy-link (`InviteResults.tsx` + `CopyableId`); "Already registered" failure detail; create-mode fields (email, full name, password + hint).
+
+### 4. Deltas
+1. **Description** — impl "Invite them to set their own password, or create the account yourself." → deck copy. `en.json add_people.description`.
+2. **Mode labels/placement** — impl "Send invites" → deck "Invite by email"; toggle sits inside the card in impl, standalone above in deck. `add_people.mode_invite`, `AddPeople.tsx`.
+3. **Mailer banner** — impl: small amber `

` inside `InviteFields.tsx` ("This deployment logs invite mail instead of sending it…"); deck: page-level banner naming the mailer + "Configure SMTP" link. Use `packages/ui/src/components/InlineBanner.tsx` (`tone="warning"`) in `AddPeople.tsx`; link to `/admin/settings/` (module settings now render at the section root — `modules/settings/settings/endpoints/views.py:187-207`; `/admin/settings/modules` 308-redirects there). New keys. +4. **Email input** — deck is a chip/tag input with Enter-to-add, paste-split, per-chip validity colouring and an "N addresses · M invalid" counter; impl is a monospace `Textarea` + "One per line, or separated by commas. {count} recognised." New `EmailChipInput.tsx` under `pages/Users/components/`, client-side format check, submit count = valid chips. `InviteFields.tsx`, `invite_fields.*`. +5. **Roles label** — deck "Roles for everyone in this batch"; impl `RolePicker` default "Roles". Pass `label` in `AddPeople.tsx` (invite mode) + key. +6. **"Message (optional)"** — absent. Frontend `InviteFields.tsx`; backend §5. +7. **Layout** — deck two-column with a persistent right "Last batch" card; impl single `max-w-2xl` card, results appended below the form after submit, and `router.visit('/admin/users/')` when everything sent (`AddPeople.tsx:92-95`) so the all-sent batch is never shown. `AddPeople.tsx`, `InviteResults.tsx` (card, header "Last batch", meta "{sent} sent · {failed} failed"). +8. **Result rows** — deck has ✓/✕ glyph rows with **"Copy link"** / **"Retry"**; impl groups by status, shows links only for status `link` (intentional: no live token on screen when mail delivered — see `InviteResults.tsx` docstring; in the console-mailer case every row *is* `link`, so this coincides with the deck). "Retry" is missing. Impl-only "Dismiss", "Copy all", "All invites delivered." — keep or drop. +9. **Failure copy** — server string "Already registered" (`bulk_invite.py:56`) vs deck "Already a member of this workspace"; note it is an untranslated server literal. +10. **Footer note** about 7-day expiry — missing; needs the TTL as a prop. +11. Impl "Back to Users" header action — not in deck. + +### 5. Backend / props / DB needed +- `message` — nothing exists (`UserBulkInvite` = `emails`, `role_names`; `contracts/schemas.py:67-82`). Add to schema, thread through `bulk_invite.py`, extend `Mailer.send_invite(email, token, invited_by_name)` in `mailer/__init__.py:26`, `mailer/console.py:42`, `mailer/smtp.py:68` + template under `mailer/templates/`. +- Mailer name for the banner — `settings.mailer` (`"console"|"smtp"`, `settings.py:64`) exists but only `mailer_delivers: bool` is passed (`admin/views.py:123`). +- Invite TTL — `settings.py:51`; not passed. +- Retry — reuse the bulk endpoint with one address; no backend change. + +### 6. Ambiguities +- Do invalid chips block submit or just get excluded (deck implies excluded: "Send 2 invites" with 3 chips). +- Does "Last batch" persist across reload (needs storage) or is it in-memory only; what it shows before any submit. +- Keep or drop the redirect-to-list on full success. +- "Retry" on "Already a member" will fail again — is Retry only for transient failures (status `link`/mailer error)? +- Create-account mode: what occupies the right column. + +--- + +## 12 — Edit user (`/tmp/hifi/screens/12-edituser.html`) + +### 1. Route + files +- `GET /admin/users/{user_id}` → `admin/views.py::admin_edit_page` (props `user: UserListItem`, `roles`, `has_permissions_module`; `auth` shared). +- Page: `modules/users/users/pages/Users/Edit.tsx`; components `pages/Users/components/{DetailsCard,RolesCard,RolePicker,MetadataCard,AccountStatusCard,DangerZone}.tsx`, `useUserActions.ts` +- APIs (`admin/api.py`): `PATCH /{id}`, `PUT /{id}/roles`, `PATCH /{id}/disable|enable|verify`, `POST /{id}/reset-password-link`, `DELETE /{id}` +- Copy: `en.json` → `edit.*`, `details_card.*`, `roles_card.*`, `metadata_card.*`, `account_status.*`, `danger_zone.*` + +### 2. Design structure +1. Header: 52px avatar **"DR"**; h1 **"Dana Rivera"**; sub **"dana@example.com · joined Mar 2026 · last login 2h ago"**; pill **"active"** beside the name. Right: **"3 unsaved changes"** (muted), **"Discard"** (outline), **"Save changes"** (primary). No "Back" button. +2. Grid `1.3fr 1fr`, two rows: + - **"Details"** (left, top): **"Full name"** input, **"Email"** input (shown focused), then **"Roles"** label with pills **"admin ✓"**, "editor", "viewer" and right-aligned link **"Manage permissions →"**. + - **"Account"** (right, top): key/value rows **"Sign-in"** → mono pill **"local · password"**; **"Created"** → **"12 Mar 2026"**; **"Verified"** → **"yes"** (green text); **"Disabled at"** → **"—"**; bottom buttons **"Disable account"**, **"Copy reset link"** (both outline). + - **"Recent activity"** (left, bottom): mono timestamps + text: **"14:02:11 Changed `is_active` on sam@example.com"**, **"13:47:02 Updated setting `users.smtp_host`"**, **"09:03:40 Invited rob@example.com"**; link **"See all in the audit log →"**. + - **"Danger zone"** (right, bottom; red border, red-tinted bg, red heading): **"Deleting removes the account and its sessions. Audit entries are kept. Blocked when editing your own account."**; outline-red button **"Delete user"**. + +### 3. Already matches +One dirty state with Discard / Save changes and leave-guard; Full name + Email inputs; role chips; "Manage permissions →" (gated on permissions module); Sign-in / Created / Verified / Disabled at rows; "Disable account" + "Copy reset link" with confirm dialogs; Danger zone with typed-email confirm; self-delete blocked (`danger_zone.self_note`). + +### 4. Deltas +1. **Header** — impl `PageShell title={user.email} description={full_name}`; deck title = name, avatar, sub "email · joined {Mon YYYY} · last login {relative}", status pill. `Edit.tsx` (custom header block; `PageShell.tsx` has no leading/badge slot) + `edit.subtitle` key + relative time. +2. **"3 unsaved changes"** — impl `edit.unsaved_changes` = "Unsaved changes" (no count). Count changed fields (email, fullName, roles) in `Edit.tsx`; plural keys. +3. **"Back to Users"** action in impl header — not in deck. +4. **Roles inside Details** — impl separate `RolesCard.tsx` (`lg:col-span-2`); deck folds roles + manage link into "Details". Merge into `DetailsCard.tsx`; also field order (deck Full name first; impl Email first). +5. **One "Account" card** — impl splits into `MetadataCard.tsx` ("Metadata": Sign-in/Created/Last login/Disabled at/Verified + "Mark verified") and `AccountStatusCard.tsx` ("Account status": pill + Disable/Enable/Copy reset link). Merge into one `AccountCard.tsx`, title "Account"; keep impl-only "Mark verified", "Enable account", SSO `external_note`. +6. **Sign-in value** — impl badge "Local · password" (`metadata_card.local_badge`); deck lowercase mono pill "local · password". +7. **Date format** — impl `toLocaleString()` (date+time); deck "12 Mar 2026". `MetadataCard.tsx::fmt`. +8. **"Last login"** row moves from the card to the header. +9. **Verified "yes"** — plain green text in deck; impl `Badge`. +10. **"Recent activity" card** — missing entirely. New `RecentActivityCard.tsx`; link `/admin/audit-log/?user_id={id}` (browse view accepts `user_id`, `modules/audit_log/audit_log/endpoints/views.py:50`). +11. **Danger zone copy/style** — impl description "Permanently delete this user. This cannot be undone."; deck copy above. Impl card `border-destructive/40`, default-colour heading, filled destructive button; deck red-tinted bg, red heading, outline-red button. `DangerZone.tsx`, `danger_zone.description`. +12. **Grid** — deck `1.3fr 1fr` fixed two rows; impl `lg:grid-cols-2` with three cards spanning both columns. `Edit.tsx`. +13. Status pill in header (deck) currently lives in `AccountStatusCard.tsx`. + +### 5. Backend / props / DB needed +- **`recent_activity` prop** — data exists: `modules/audit_log/audit_log/models.py::AuditEntry` (`entity_type, entity_id, action, changes, user_id, created_at`) and `AuditLogService.list_entries(user_id=…, page_size=…)` (`service.py:57`). Human summaries ("Changed `is_active` on sam@…") need `changes` + entity resolution; helpers `resolve_actors`/`entity_link` live in `modules/audit_log/audit_log/resolve.py`. Users must not hard-depend on audit_log (it already gates `has_permissions_module` by name, `admin/views.py:167`) — resolve via `app.state`/importlib and hide the card when absent. Deck rows are things Dana *did* → filter by actor `user_id`; "about this user" would be `entity_type="User", entity_id=…`. +- Header "joined": `created_at` (AuditMixin) already in `UserListItem`. `last_login_at` present. +- No other new data. + +### 6. Ambiguities +- Activity by actor vs about the entity; row count; timestamp format (deck shows `HH:MM:SS` only — needs a date for older entries). +- Whether the header pill updates immediately after Disable (it should, via `useUserActions`). +- Where "Mark verified" / "Enable account" / SSO variants go in the merged card. +- Whether the hidden "Delete user" for self remains (deck text implies a disabled button). + +--- + +## 13 — Your profile (`/tmp/hifi/screens/13-profile.html`) + +### 1. Route + files +- `GET /users/me` → `modules/users/users/auth_local/views.py:124-126::profile_page` — renders `Users/Profile` with **empty props `{}`**. +- Page: `modules/users/users/pages/Profile.tsx` (`AuthenticatedLayout`, not `AdminLayout`). +- API: `GET/PATCH /api/users/me` (`auth_local/api.py:183-200`; `SelfProfileUpdate` = `full_name` only, `contracts/schemas.py:152`). +- Copy: `en.json` → `profile.*`, `common.*` + +### 2. Design structure +1. h1 **"Your profile"**; sub **"Name, email, password and active sessions"**. +2. Grid `1.2fr 1fr`: + - Left, **"Details"**: 56px avatar **"AD"**; beside it **"admin@example.com"** + helper **"Avatars come from the branding logo — no per-user upload."**; fields **"Full name"** ("Alex Doyle"), **"Email"** ("admin@example.com"); right-aligned primary **"Save details"**. + - Left, **"Password"**: three fields **"Current"**, **"New"**, **"Confirm"**; strength bar (72% green) + **"At least 8 characters, not all numbers"**; right-aligned outline **"Change password"**. + - Right, **"Sessions"**: green-dot row **"This browser · Chrome on macOS"** / **"Signed in 2h ago · 10.0.0.14"**; grey-dot row **"Firefox on Linux"** / **"Signed in 3d ago"** + link **"Revoke"**; red link **"Sign out everywhere"**. + - Right, **"Preferences"**: **"Language"** → **"English ▾"**; **"Theme"** → **"System ▾"**; **"Task failure emails"** → toggle (on). + +### 3. Already matches +Title "Your profile"; a card with avatar initial, editable name, email, save button. + +### 4. Deltas +1. **Description** — impl "Shown to teammates in audit logs and dropdowns." → deck copy. `profile.description`. +2. **Section/labels** — impl "Account" / "Display name" / "Save changes" → deck "Details" / "Full name" / "Save details". `profile.*`, `Profile.tsx`. +3. **Avatar block** — impl has a dead **"Upload avatar"** button + "PNG or JPG, up to 2MB." (no handler, no avatar column anywhere); deck explicitly says no per-user upload and shows the email beside the avatar. Remove; two-letter initials. +4. **Email** — impl `readOnly` + verified/unverified badge; deck plain editable-looking input, no badge (see §6). +5. **Roles badges** — impl-only; not in deck. +6. **"Password" card** — missing. Needs UI + backend (§5). Hint text matches the existing policy string in `create_fields.password_hint`. Hide for `is_external` users (no local password — `AccountStatusCard.tsx` already models this). +7. **"Sessions" card** — missing (§5). +8. **"Preferences" card** — missing (§5). +9. **Layout** — single `max-w-2xl` card → two-column grid. Sub-components: follow the `admin/components/` precedent, e.g. `modules/users/users/auth_local/components/{PasswordCard,SessionsCard,PreferencesCard}.tsx` (the Vite glob is `pages/**/*.tsx`, `manifest.py:229`, so keep non-pages out of `pages/`). +10. **Bug (pre-existing):** `Profile.tsx:33,62,100` reads `auth.user.full_name` and `auth.user.is_verified`, but `auth.user` is `{id, name, email, roles}` (`modules/auth/auth/module.py:28-34`; `UserContext` has no such fields, `modules/auth/auth/contracts/schemas.py:15-22`). The name field always loads empty and the badge always reads "unverified". Fix by having `profile_page` pass a `user` prop (`UserRead` shape) instead of `{}`. + +### 5. Backend / props / DB needed +- **Profile prop** — `auth_local/views.py::profile_page` must load the current user (`UserRead`: has `full_name`, `is_verified`, `is_external`, `last_login_at`). +- **Change password** — no endpoint. fastapi-users' `get_users_router` is not mounted (only reset/verify/register, `module.py:185-205`); `update_me` accepts `full_name` only. Add `POST /api/users/me/password {current_password, new_password}` verifying the current hash and applying `UserUpdate(password=…)` via `UserManager` (policy already enforced there); refuse for `is_external`. +- **Sessions** — the closest data is `users_access_token` (`models/access_token.py`: `token`, `created_at`, `user_id`; written by the `DatabaseStrategy` on login), but browser auth is resolved from the **Starlette signed session**, not that table (`provider.py:33-57`). So listing/revoking token rows would not sign anyone out. A real implementation needs either a server-side session store or a per-user `session_version`/per-session id stored in the session dict and checked in `resolve_user`, plus `user_agent`/`ip`/`last_seen_at` columns and a surrogate id (the raw token must never be sent to the client). Endpoints: `GET /api/users/me/sessions`, `DELETE /api/users/me/sessions/{id}`, `POST /api/users/me/sessions/revoke-all`; "everywhere" should also revoke `users_refresh_token` rows (`revoked_at` exists, `models/refresh_token.py`). Migration required. +- **Language** — cookie-based only (`framework/hosting/simple_module_hosting/i18n_middleware.py`, `POST /i18n/set-locale`, `packages/ui/src/components/LocaleSwitcher.tsx`); a select can reuse that form; no per-user persistence. +- **Theme** — no `ThemeProvider`/`next-themes` provider in `host/client_app` or `packages/ui/src/layouts`; no `dark` variant in `host/client_app/styles.css` (only `sonner.tsx` calls `useTheme`). "System ▾" requires dark-mode plumbing that does not exist. +- **Task failure emails** — nothing (no notification setting in `modules/background_tasks`). Would need a user-scoped setting (the settings module already has a user scope: `API_USER_PATH = /user/{scope_id}/{key}`, `modules/settings/settings/constants.py:62`) plus a `TaskFailed` handler that emails via the users mailer. + +### 6. Ambiguities +- Is email editable (implies re-verification + `SelfProfileUpdate.email`), or read-only as today? +- Keep roles badges / verified badge? +- Language select vs the existing topbar `LocaleSwitcher`; Theme without dark-mode support. +- "Task failure emails": all failures, or only tasks the user triggered? +- Strength-meter algorithm; whether Password/Sessions cards apply to SSO/bearer sessions. +- Deck shows Profile inside the admin shell with "Users" highlighted; impl uses `AuthenticatedLayout` (shell out of scope, but the layout choice differs). + +--- + +## Overall summary (largest gap first) + +1. **Profile (13)** — three of four cards don't exist (Password, Sessions, Preferences); each needs new backend (password endpoint, real session tracking incompatible with today's session-cookie auth, theme plumbing, a notification setting), plus a live prop bug that blanks the name and mis-reports verification. +2. **Edit user (12)** — "Recent activity" card and header (name/avatar/joined/last-login/status) are new; the rest is restructuring (merge Roles into Details, merge Metadata+Account status) and copy/date formatting, with audit data already available. +3. **Add people (11)** — chip email input, "Message (optional)" (new field through schema → mailer → template), persistent two-column "Last batch" panel with Retry, page-level mailer banner with settings link, and expiry note; core invite flow already works. +4. **Users list (10)** — mostly styling/copy (flat stats, segmented tabs, relative "Last seen", in-card pagination, row dimming) plus one real feature: distinguishing invited from unverified and a "Resend" action, which needs an `invited_at` column and an admin resend endpoint. +5. Shared prerequisites: extend `relative-time.ts` to days/months, a plain `StatCard` variant, and a `PageShell` header slot for avatar/badge. \ No newline at end of file From 0cf4eab6172cfdf3f6c22cc68f3a815019565cda Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 3 Sep 2026 06:22:49 +0200 Subject: [PATCH 02/66] fix: guard admin-sidebar menu sections; add make gen-i18n --- Makefile | 8 +++++++- modules/branding/tests/test_menu_section.py | 16 ++++++++++++++++ .../tests/test_feature_flags_menu_section.py | 15 +++++++++++++++ scripts/gen_i18n.py | 13 +++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 modules/branding/tests/test_menu_section.py create mode 100644 modules/feature_flags/tests/test_feature_flags_menu_section.py create mode 100644 scripts/gen_i18n.py diff --git a/Makefile b/Makefile index 40d0bd93..adb4f73d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker +.PHONY: install install-py install-js dev dev-api dev-ui build test test-py test-js test-e2e bench memray-run memray-flamegraph loadtest loadtest-seed loadtest-memray bench-nav lint doctor migrate migration downgrade migration-history docker-up docker-down kill new-module gen-pages gen-i18n docker-build docker-app docker-compose-app sync-module-deps ci-python-lint ci-python-typecheck ci-js-lint ci-js-typecheck ci-check-file-size ci-check-hardcoded-strings ci-check-untranslated ci-build-packages worker beat worker-docker # Install install: @@ -27,6 +27,12 @@ dev-ui: gen-pages: uv run --project host smpy host gen-pages --host-dir=host/client_app +# Regenerate packages/i18n/src/{generated-resources,keys.generated}.ts from every +# installed module's locales/, plus host/locales and packages/ui/locales — without +# booting the app. +gen-i18n: + uv run --project host python scripts/gen_i18n.py + # Install JS deps declared by installed modules into host/client_app/node_modules. # Wheel-installed modules need this; in-repo workspace modules do not. sync-module-deps: diff --git a/modules/branding/tests/test_menu_section.py b/modules/branding/tests/test_menu_section.py new file mode 100644 index 00000000..a8733a29 --- /dev/null +++ b/modules/branding/tests/test_menu_section.py @@ -0,0 +1,16 @@ +"""Guards Branding's admin-sidebar menu placement (regression: GH #280 dropped it).""" + +from __future__ import annotations + +from branding.constants import MENU_URL +from branding.module import BrandingModule +from simple_module_core.menu import MenuRegistry, MenuSection + + +def test_branding_menu_item_is_in_admin_sidebar() -> None: + registry = MenuRegistry() + BrandingModule().register_menu_items(registry) + + item = next(i for i in registry.all_items if i.url == MENU_URL) + assert item.section == MenuSection.ADMIN_SIDEBAR + assert item.order == 105 diff --git a/modules/feature_flags/tests/test_feature_flags_menu_section.py b/modules/feature_flags/tests/test_feature_flags_menu_section.py new file mode 100644 index 00000000..db48f66e --- /dev/null +++ b/modules/feature_flags/tests/test_feature_flags_menu_section.py @@ -0,0 +1,15 @@ +"""Guards FeatureFlags's admin-sidebar menu placement (regression: GH #280 dropped it).""" + +from __future__ import annotations + +from feature_flags.constants import MENU_URL +from feature_flags.module import FeatureFlagsModule +from simple_module_core.menu import MenuRegistry, MenuSection + + +def test_feature_flags_menu_item_is_in_admin_sidebar() -> None: + registry = MenuRegistry() + FeatureFlagsModule().register_menu_items(registry) + + item = next(i for i in registry.all_items if i.url == MENU_URL) + assert item.section == MenuSection.ADMIN_SIDEBAR diff --git a/scripts/gen_i18n.py b/scripts/gen_i18n.py new file mode 100644 index 00000000..159e8425 --- /dev/null +++ b/scripts/gen_i18n.py @@ -0,0 +1,13 @@ +"""Regenerate the typed i18n key files without booting the host. `make gen-i18n`.""" + +from pathlib import Path + +from simple_module_core.discovery import discover_modules +from simple_module_hosting.i18n_manifest import emit_frontend_types_for_modules +from simple_module_hosting.settings import Settings + +ROOT = Path(__file__).resolve().parent.parent + +if __name__ == "__main__": + emit_frontend_types_for_modules(Settings(), discover_modules(), ROOT) + print("i18n key files regenerated") From f3dcc65ea2d970346e79328923d9978deea9549f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 3 Sep 2026 06:26:41 +0200 Subject: [PATCH 03/66] =?UTF-8?q?feat(ui):=20deck=20primitives=20=E2=80=94?= =?UTF-8?q?=20StatCard=20layout,=20SegmentedControl,=20ConfirmActionDialog?= =?UTF-8?q?,=20password=20inputs,=20relative=20time,=20initials,=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/i18n/src/generated-resources.ts | 7 ++ packages/i18n/src/keys.generated.ts | 7 ++ packages/ui/locales/en.json | 9 +- .../components/ConfirmActionDialog.test.tsx | 73 +++++++++++ .../ui/src/components/ConfirmActionDialog.tsx | 119 ++++++++++++++++++ packages/ui/src/components/PasswordInput.tsx | 33 +++++ .../ui/src/components/PasswordStrength.tsx | 61 +++++++++ .../src/components/SegmentedControl.test.tsx | 80 ++++++++++++ .../ui/src/components/SegmentedControl.tsx | 84 +++++++++++++ packages/ui/src/components/StatCard.test.tsx | 36 +++++- packages/ui/src/components/StatCard.tsx | 76 +++++++---- .../src/components/password-strength.test.ts | 33 +++++ .../ui/src/hooks/use-relative-time.test.tsx | 52 ++++++++ packages/ui/src/hooks/use-relative-time.ts | 41 ++++++ packages/ui/src/index.ts | 29 +++++ packages/ui/src/lib/initials.test.ts | 33 +++++ packages/ui/src/lib/initials.ts | 21 ++++ packages/ui/src/lib/relative-time.test.ts | 56 ++++++++- packages/ui/src/lib/relative-time.ts | 44 ++++++- packages/ui/src/lib/theme.test.ts | 114 +++++++++++++++++ packages/ui/src/lib/theme.ts | 74 +++++++++++ 21 files changed, 1050 insertions(+), 32 deletions(-) create mode 100644 packages/ui/src/components/ConfirmActionDialog.test.tsx create mode 100644 packages/ui/src/components/ConfirmActionDialog.tsx create mode 100644 packages/ui/src/components/PasswordInput.tsx create mode 100644 packages/ui/src/components/PasswordStrength.tsx create mode 100644 packages/ui/src/components/SegmentedControl.test.tsx create mode 100644 packages/ui/src/components/SegmentedControl.tsx create mode 100644 packages/ui/src/components/password-strength.test.ts create mode 100644 packages/ui/src/hooks/use-relative-time.test.tsx create mode 100644 packages/ui/src/hooks/use-relative-time.ts create mode 100644 packages/ui/src/lib/initials.test.ts create mode 100644 packages/ui/src/lib/initials.ts create mode 100644 packages/ui/src/lib/theme.test.ts create mode 100644 packages/ui/src/lib/theme.ts diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index b7086595..f87aeb5d 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -594,11 +594,18 @@ export default { 'ui.public_nav.open_dashboard': '', 'ui.public_nav.open_menu': '', 'ui.public_nav.sign_up': '', + 'ui.relative_time.days_ago': '', + 'ui.relative_time.expired': '', 'ui.relative_time.hours_ago': '', + 'ui.relative_time.in_days': '', + 'ui.relative_time.in_hours': '', + 'ui.relative_time.in_minutes': '', 'ui.relative_time.just_now': '', 'ui.relative_time.minutes_ago': '', + 'ui.relative_time.months_ago': '', 'ui.relative_time.seconds_ago': '', 'ui.relative_time.unknown': '', + 'ui.relative_time.years_ago': '', 'ui.sidebar.close': '', 'ui.sidebar.open': '', 'ui.switcher.label': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 0605959e..86b947b6 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -788,11 +788,18 @@ export const keys = { sign_up: 'ui.public_nav.sign_up', }, relative_time: { + days_ago: 'ui.relative_time.days_ago', + expired: 'ui.relative_time.expired', hours_ago: 'ui.relative_time.hours_ago', + in_days: 'ui.relative_time.in_days', + in_hours: 'ui.relative_time.in_hours', + in_minutes: 'ui.relative_time.in_minutes', just_now: 'ui.relative_time.just_now', minutes_ago: 'ui.relative_time.minutes_ago', + months_ago: 'ui.relative_time.months_ago', seconds_ago: 'ui.relative_time.seconds_ago', unknown: 'ui.relative_time.unknown', + years_ago: 'ui.relative_time.years_ago', }, sidebar: { close: 'ui.sidebar.close', diff --git a/packages/ui/locales/en.json b/packages/ui/locales/en.json index 66c05472..8dcbba59 100644 --- a/packages/ui/locales/en.json +++ b/packages/ui/locales/en.json @@ -17,7 +17,14 @@ "just_now": "just now", "seconds_ago": "{count}s ago", "minutes_ago": "{count}m ago", - "hours_ago": "{count}h ago" + "hours_ago": "{count}h ago", + "days_ago": "{count}d ago", + "months_ago": "{count}mo ago", + "years_ago": "{count}y ago", + "in_minutes": "in {count}m", + "in_hours": "in {count}h", + "in_days": "in {count}d", + "expired": "expired" }, "nav_groups": { "access": "Access", diff --git a/packages/ui/src/components/ConfirmActionDialog.test.tsx b/packages/ui/src/components/ConfirmActionDialog.test.tsx new file mode 100644 index 00000000..0e7efbd8 --- /dev/null +++ b/packages/ui/src/components/ConfirmActionDialog.test.tsx @@ -0,0 +1,73 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Trash2 } from 'lucide-react'; +import type React from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { ConfirmActionDialog } from './ConfirmActionDialog'; + +function renderDialog(props: Partial> = {}) { + const onConfirm = vi.fn(); + const onOpenChange = vi.fn(); + render( + , + ); + return { onConfirm, onOpenChange }; +} + +describe('ConfirmActionDialog', () => { + test('shows the title, description and both buttons when open', () => { + renderDialog(); + expect(screen.getByText('Delete project')).toBeInTheDocument(); + expect(screen.getByText('This removes every dataset it holds.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + test('renders nothing while closed', () => { + renderDialog({ open: false }); + expect(screen.queryByText('Delete project')).not.toBeInTheDocument(); + }); + + test('confirms straight away when no typed confirmation is required', () => { + const { onConfirm } = renderDialog(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('holds the confirm button disabled until the expected text is typed', () => { + const { onConfirm } = renderDialog({ + confirmText: { expected: 'atlas', label: 'Type atlas to confirm' }, + }); + const confirm = screen.getByRole('button', { name: 'Delete' }); + expect(confirm).toBeDisabled(); + + const input = screen.getByLabelText('Type atlas to confirm'); + fireEvent.change(input, { target: { value: 'atl' } }); + expect(confirm).toBeDisabled(); + + fireEvent.change(input, { target: { value: 'ATLAS' } }); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + test('disables confirm while the action is in flight', () => { + renderDialog({ busy: true }); + expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled(); + }); + + test('renders extra content between the description and the buttons', () => { + renderDialog({ children:

flag: beta-search

}); + expect(screen.getByText('flag: beta-search')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/components/ConfirmActionDialog.tsx b/packages/ui/src/components/ConfirmActionDialog.tsx new file mode 100644 index 00000000..e33b3441 --- /dev/null +++ b/packages/ui/src/components/ConfirmActionDialog.tsx @@ -0,0 +1,119 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@simple-module-py/ui/components/ui/alert-dialog'; +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { Label } from '@simple-module-py/ui/components/ui/label'; +import { cn } from '@simple-module-py/ui/lib/utils'; +import type { LucideIcon } from 'lucide-react'; +import type React from 'react'; +import { useEffect, useId, useState } from 'react'; + +interface ConfirmActionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Tints the icon tile and picks the confirm button's variant. */ + tone?: 'destructive' | 'primary'; + icon: LucideIcon; + title: React.ReactNode; + description: React.ReactNode; + confirmLabel: string; + cancelLabel: string; + onConfirm: () => void; + busy?: boolean; + /** Type-to-confirm. Matched case-insensitively; gates the confirm button. */ + confirmText?: { expected: string; label: string; placeholder?: string }; + /** Extra detail between the description and the buttons — an args box, a warning. */ + children?: React.ReactNode; +} + +const TILE_TONE = { + destructive: 'bg-red-50 text-red-600', + primary: 'bg-primary-600/10 text-primary-700', +} as const; + +/** + * The one shape every "are you sure?" in this app takes. + * + * Consistency is the point: a destructive confirm should look the same + * wherever it appears, so a reader learns to slow down at the red tile rather + * than re-reading each dialog from scratch. Type-to-confirm is here rather + * than at each call site because the gate is easy to get subtly wrong — an + * exact-match check that rejects a trailing space trains people to paste + * blindly, which is the opposite of what the gate is for. + */ +export function ConfirmActionDialog({ + open, + onOpenChange, + tone = 'destructive', + icon: Icon, + title, + description, + confirmLabel, + cancelLabel, + onConfirm, + busy = false, + confirmText, + children, +}: ConfirmActionDialogProps) { + const inputId = useId(); + const [typed, setTyped] = useState(''); + + // Reopening the dialog must re-ask for the confirmation, not inherit the + // answer someone typed before they cancelled. + useEffect(() => { + if (!open) setTyped(''); + }, [open]); + + const matched = + !confirmText || typed.trim().toLowerCase() === confirmText.expected.trim().toLowerCase(); + + return ( + + + + + + {title} + {description} + + {children} + {confirmText && ( +
+ + setTyped(e.target.value)} + placeholder={confirmText.placeholder} + autoComplete="off" + className="font-mono" + /> +
+ )} + + {cancelLabel} + + {confirmLabel} + + +
+
+ ); +} diff --git a/packages/ui/src/components/PasswordInput.tsx b/packages/ui/src/components/PasswordInput.tsx new file mode 100644 index 00000000..1d435dbd --- /dev/null +++ b/packages/ui/src/components/PasswordInput.tsx @@ -0,0 +1,33 @@ +import { Input } from '@simple-module-py/ui/components/ui/input'; +import { cn } from '@simple-module-py/ui/lib/utils'; +import type React from 'react'; +import { useState } from 'react'; + +interface PasswordInputProps extends Omit, 'type'> { + showLabel: string; + hideLabel: string; +} + +/** + * A password field that can be read back. + * + * Typing a password blind is where sign-up attempts go to die, and the reveal + * is a word rather than an eye icon because "Show"/"Hide" says what will + * happen — an eye with a line through it does not tell you which state you are + * in. The labels are passed in already translated so this stays a primitive. + */ +export function PasswordInput({ showLabel, hideLabel, className, ...props }: PasswordInputProps) { + const [visible, setVisible] = useState(false); + return ( +
+ + +
+ ); +} diff --git a/packages/ui/src/components/PasswordStrength.tsx b/packages/ui/src/components/PasswordStrength.tsx new file mode 100644 index 00000000..85aad1ef --- /dev/null +++ b/packages/ui/src/components/PasswordStrength.tsx @@ -0,0 +1,61 @@ +import { cn } from '@simple-module-py/ui/lib/utils'; + +export type StrengthLevel = 'none' | 'weak' | 'ok' | 'strong'; + +/** + * How hard the password would be to guess, on a four-step scale. + * + * Deliberately coarse and deliberately local: this is feedback while typing, + * not the policy the server enforces. Length alone earns nothing — sixteen + * lowercase letters and sixteen digits are both weak — because the thing worth + * nudging people towards is variety, which is what a guesser has to search. + */ +export function scorePassword(pw: string): { level: StrengthLevel; percent: number } { + if (pw === '') return { level: 'none', percent: 0 }; + if (pw.length < 8 || /^\d+$/.test(pw)) return { level: 'weak', percent: 33 }; + + const classes = [/[a-z]/, /[A-Z]/, /\d/, /[^a-zA-Z0-9]/].filter((re) => re.test(pw)).length; + if (pw.length >= 12 && classes >= 3) return { level: 'strong', percent: 100 }; + if (/[a-zA-Z]/.test(pw) && /\d/.test(pw)) return { level: 'ok', percent: 66 }; + return { level: 'weak', percent: 33 }; +} + +interface PasswordStrengthProps { + password: string; + labels: Record, string>; + /** The rule the password must satisfy, shown alongside the verdict. */ + hint?: string; + className?: string; +} + +const BAR_TONE: Record, string> = { + weak: 'bg-red-500', + ok: 'bg-amber-500', + strong: 'bg-primary-600', +}; + +const TEXT_TONE: Record, string> = { + weak: 'text-red-700', + ok: 'text-amber-700', + strong: 'text-primary-700', +}; + +export function PasswordStrength({ password, labels, hint, className }: PasswordStrengthProps) { + const { level, percent } = scorePassword(password); + return ( +
+
+
+
+

+ {level !== 'none' && ( + {labels[level]} + )} + {hint && {hint}} +

+
+ ); +} diff --git a/packages/ui/src/components/SegmentedControl.test.tsx b/packages/ui/src/components/SegmentedControl.test.tsx new file mode 100644 index 00000000..309bc4d1 --- /dev/null +++ b/packages/ui/src/components/SegmentedControl.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +import { SegmentedControl } from './SegmentedControl'; + +const OPTIONS = [ + { value: 'all', label: 'All', count: 12 }, + { value: 'active', label: 'Active' }, + { value: 'archived', label: 'Archived', disabled: true }, +]; + +describe('SegmentedControl', () => { + test('renders one radio per option inside a labelled radiogroup', () => { + render( + {}} + options={OPTIONS} + aria-label="Filter by status" + />, + ); + expect(screen.getByRole('radiogroup', { name: 'Filter by status' })).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(3); + }); + + test('marks only the selected option as checked', () => { + render( + {}} + options={OPTIONS} + aria-label="Filter by status" + />, + ); + expect(screen.getByRole('radio', { name: /Active/ })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('radio', { name: /All/ })).toHaveAttribute('aria-checked', 'false'); + }); + + test('reports the picked value', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('radio', { name: /Active/ })); + expect(onChange).toHaveBeenCalledWith('active'); + }); + + test('renders a count beside the option that carries one', () => { + render( + {}} + options={OPTIONS} + aria-label="Filter by status" + />, + ); + expect(screen.getByRole('radio', { name: /All/ })).toHaveTextContent('12'); + }); + + test('a disabled option cannot be picked', () => { + const onChange = vi.fn(); + render( + , + ); + const archived = screen.getByRole('radio', { name: /Archived/ }); + expect(archived).toBeDisabled(); + fireEvent.click(archived); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/components/SegmentedControl.tsx b/packages/ui/src/components/SegmentedControl.tsx new file mode 100644 index 00000000..2252422f --- /dev/null +++ b/packages/ui/src/components/SegmentedControl.tsx @@ -0,0 +1,84 @@ +import { cn } from '@simple-module-py/ui/lib/utils'; + +export interface SegmentedOption { + value: V; + label: string; + /** Optional tally shown beside the label, e.g. how many rows that filter holds. */ + count?: number; + disabled?: boolean; +} + +interface SegmentedControlProps { + value: V; + onChange: (next: V) => void; + options: SegmentedOption[]; + 'aria-label': string; + size?: 'sm' | 'md'; + className?: string; +} + +const SIZES = { + sm: 'h-7 px-2.5 text-xs', + md: 'h-8 px-3 text-sm', +} as const; + +/** + * One-of-N picker rendered as a raised chip inside a recessed track. + * + * It is a radio group, not a tab list: the options filter what a page shows + * rather than swapping panels, and screen-reader users get "2 of 4 selected" + * instead of being told to look for tab panels that do not exist. + */ +export function SegmentedControl({ + value, + onChange, + options, + 'aria-label': ariaLabel, + size = 'md', + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + // biome-ignore lint/a11y/useSemanticElements: a native radio input cannot carry the raised-chip look this control is. + + ); + })} +
+ ); +} diff --git a/packages/ui/src/components/StatCard.test.tsx b/packages/ui/src/components/StatCard.test.tsx index 4bd37a2f..fcea7819 100644 --- a/packages/ui/src/components/StatCard.test.tsx +++ b/packages/ui/src/components/StatCard.test.tsx @@ -11,13 +11,39 @@ describe('StatCard', () => { expect(screen.getByText('42')).toBeInTheDocument(); }); - test('shows optional delta badge', () => { + test('renders without an icon', () => { + const { container } = render(); + expect(screen.getByText('Members')).toBeInTheDocument(); + expect(container.querySelector('svg')).toBeNull(); + }); + + test('shows the delta as text coloured by its tone', () => { render(); - expect(screen.getByText('+1')).toBeInTheDocument(); + expect(screen.getByText('+1')).toHaveClass('text-amber-700'); + }); + + test('omits the delta when not provided', () => { + render(); + expect(screen.queryByText('+1')).not.toBeInTheDocument(); + }); + + test('renders a muted suffix after the value', () => { + render(); + expect(screen.getByText('/ 8')).toBeInTheDocument(); + }); + + test('tints the whole card for a warning tone', () => { + const { container } = render(); + expect(container.querySelector('[data-slot="card"]')).toHaveClass('border-amber-200'); + }); + + test('tints the whole card for a destructive tone', () => { + const { container } = render(); + expect(container.querySelector('[data-slot="card"]')).toHaveClass('border-red-200'); }); - test('omits delta when not provided', () => { - render(); - expect(screen.queryByRole('status')).not.toBeInTheDocument(); + test('accepts an extra class on the value', () => { + render(); + expect(screen.getByText('99.9%')).toHaveClass('text-primary-700'); }); }); diff --git a/packages/ui/src/components/StatCard.tsx b/packages/ui/src/components/StatCard.tsx index b35bb600..14a5d34a 100644 --- a/packages/ui/src/components/StatCard.tsx +++ b/packages/ui/src/components/StatCard.tsx @@ -1,51 +1,81 @@ -import { Badge } from '@simple-module-py/ui/components/ui/badge'; import { Card, CardContent } from '@simple-module-py/ui/components/ui/card'; import { cn } from '@simple-module-py/ui/lib/utils'; import type { LucideIcon } from 'lucide-react'; +import type React from 'react'; interface StatCardProps { label: string; - value: string | number; - icon: LucideIcon; + value: React.ReactNode; + icon?: LucideIcon; + /** A short qualifier under the value — "+12 this week", "all healthy". */ delta?: string; deltaTone?: 'success' | 'info' | 'warning' | 'destructive' | 'secondary'; + /** Muted text right after the value, e.g. the denominator in "5 / 8". */ + suffix?: string; + /** Tints the whole card, for a figure that is itself the bad news. */ + tone?: 'default' | 'warning' | 'destructive'; + valueClassName?: string; className?: string; } +const CARD_TONE: Record, string> = { + default: '', + warning: 'bg-amber-50/60 border-amber-200 [&_.stat-value]:text-amber-700', + destructive: 'bg-red-50/60 border-red-200 [&_.stat-value]:text-red-700', +}; + +const DELTA_TONE: Record, string> = { + success: 'text-primary-700', + info: 'text-blue-700', + warning: 'text-amber-700', + destructive: 'text-red-700', + secondary: 'text-muted-foreground', +}; + +/** + * One number, read at a glance. + * + * The label leads and the value follows, because a row of these is scanned by + * label first — the figure means nothing until you know what it counts. The + * delta is plain coloured text rather than a badge: a badge competes with the + * value for attention, and the value is the point. + */ export function StatCard({ label, value, icon: Icon, delta, deltaTone = 'success', + suffix, + tone = 'default', + valueClassName, className, }: StatCardProps) { - const deltaVariant: Record, string> = { - success: 'border-primary-200 bg-primary-50 text-primary-700', - info: 'border-blue-200 bg-blue-50 text-blue-700', - warning: 'border-amber-200 bg-amber-50 text-amber-700', - destructive: 'border-red-200 bg-red-50 text-red-700', - secondary: 'border-border bg-secondary text-muted-foreground', - }; return ( - + -
- -
- {label} +
+ + {value} + + {suffix && {suffix}}
+ {delta &&
{delta}
} ); diff --git a/packages/ui/src/components/password-strength.test.ts b/packages/ui/src/components/password-strength.test.ts new file mode 100644 index 00000000..d5ec5405 --- /dev/null +++ b/packages/ui/src/components/password-strength.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { scorePassword } from './PasswordStrength'; + +// The meter is a promise about how hard the password is to guess, so the +// scoring rule is the part worth pinning down — the bar is just its picture. +describe('scorePassword', () => { + it('scores an empty password as nothing at all', () => { + expect(scorePassword('')).toEqual({ level: 'none', percent: 0 }); + }); + + it('scores anything under eight characters as weak', () => { + expect(scorePassword('abc')).toEqual({ level: 'weak', percent: 33 }); + expect(scorePassword('Aa1!Aa1')).toEqual({ level: 'weak', percent: 33 }); + }); + + it('scores an all-digit password as weak however long it is', () => { + expect(scorePassword('1234567890123456')).toEqual({ level: 'weak', percent: 33 }); + }); + + it('scores eight or more characters mixing letters and digits as ok', () => { + expect(scorePassword('password1')).toEqual({ level: 'ok', percent: 66 }); + expect(scorePassword('abcdefg12')).toEqual({ level: 'ok', percent: 66 }); + }); + + it('scores twelve or more characters over three character classes as strong', () => { + expect(scorePassword('Password1234')).toEqual({ level: 'strong', percent: 100 }); + expect(scorePassword('correct-horse1')).toEqual({ level: 'strong', percent: 100 }); + }); + + it('does not reward length alone', () => { + expect(scorePassword('abcdefghijklmnop')).toEqual({ level: 'weak', percent: 33 }); + }); +}); diff --git a/packages/ui/src/hooks/use-relative-time.test.tsx b/packages/ui/src/hooks/use-relative-time.test.tsx new file mode 100644 index 00000000..e6981000 --- /dev/null +++ b/packages/ui/src/hooks/use-relative-time.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +// Resolve keys to the key itself plus its count, so the assertions read as +// "which bucket, with what number" without depending on the English catalog. +vi.mock('@simple-module-py/i18n', () => ({ + useT: () => ({ + t: (key: string, opts?: { count?: number }) => + opts?.count === undefined ? key : `${key}:${opts.count}`, + }), +})); + +import { useRelativeTime } from './use-relative-time'; + +function Probe({ iso }: { iso: string | null | undefined }) { + const { ago, until } = useRelativeTime(); + return ( + <> + {ago(iso)} + {until(iso)} + + ); +} + +describe('useRelativeTime', () => { + test('describes a past timestamp as an age and a future one as a countdown', () => { + vi.setSystemTime(new Date('2026-09-03T12:00:00Z')); + render(); + expect(screen.getByTestId('ago')).toHaveTextContent('ui.relative_time.hours_ago:3'); + expect(screen.getByTestId('until')).toHaveTextContent('ui.relative_time.expired'); + vi.useRealTimers(); + }); + + test('counts forward to a future timestamp', () => { + vi.setSystemTime(new Date('2026-09-03T12:00:00Z')); + render(); + expect(screen.getByTestId('until')).toHaveTextContent('ui.relative_time.in_days:2'); + vi.useRealTimers(); + }); + + test('falls back to "unknown" for input it cannot read', () => { + render(); + expect(screen.getByTestId('ago')).toHaveTextContent('ui.relative_time.unknown'); + expect(screen.getByTestId('until')).toHaveTextContent('ui.relative_time.unknown'); + }); + + test('falls back to "unknown" for a missing timestamp', () => { + render(); + expect(screen.getByTestId('ago')).toHaveTextContent('ui.relative_time.unknown'); + expect(screen.getByTestId('until')).toHaveTextContent('ui.relative_time.unknown'); + }); +}); diff --git a/packages/ui/src/hooks/use-relative-time.ts b/packages/ui/src/hooks/use-relative-time.ts new file mode 100644 index 00000000..174bf7fe --- /dev/null +++ b/packages/ui/src/hooks/use-relative-time.ts @@ -0,0 +1,41 @@ +import { useT } from '@simple-module-py/i18n'; +import { + ageOf, + RELATIVE_AGE_KEYS, + type RelativeAge, + relativeAge, + relativeUntil, +} from '../lib/relative-time'; + +/** + * Translated "3h ago" / "in 2d" for an ISO timestamp. + * + * `relativeAge` and `relativeUntil` deliberately return a key and a count so + * they stay pure; nearly every caller then wants the finished sentence. This + * pairs them with the page's own `t` so a list of timestamps is one hook call + * rather than a translation dance at every cell. + * + * `now` is sampled once per render, so every timestamp on a screen is measured + * against the same instant — two rows a millisecond apart cannot disagree + * about which side of a bucket boundary they fall on. + */ +export function useRelativeTime(): { + ago: (iso: string | null | undefined) => string; + until: (iso: string | null | undefined) => string; +} { + const { t } = useT(); + const now = Date.now(); + + const render = (rel: RelativeAge) => + rel.count === undefined ? t(rel.key) : t(rel.key, { count: rel.count }); + + return { + ago: (iso) => render(relativeAge(ageOf(iso, now))), + until: (iso) => { + if (!iso) return t(RELATIVE_AGE_KEYS.unknown); + const parsed = new Date(iso).getTime(); + if (Number.isNaN(parsed)) return t(RELATIVE_AGE_KEYS.unknown); + return render(relativeUntil(parsed - now)); + }, + }; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8055590e..374676aa 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,17 +1,46 @@ +export { ConfirmActionDialog } from './components/ConfirmActionDialog'; export { ErrorBoundary } from './components/ErrorBoundary'; export { ErrorScreen } from './components/ErrorScreen'; export { FilterPills } from './components/FilterPills'; export { NavIcon } from './components/NavIcon'; export { OfflineBanner } from './components/OfflineBanner'; export { PageShell } from './components/PageShell'; +export { PasswordInput } from './components/PasswordInput'; +export { + PasswordStrength, + type StrengthLevel, + scorePassword, +} from './components/PasswordStrength'; export { SectionTitle } from './components/SectionTitle'; +export { SegmentedControl, type SegmentedOption } from './components/SegmentedControl'; export { StatCard } from './components/StatCard'; export { useOnline } from './hooks/use-online'; +export { useRelativeTime } from './hooks/use-relative-time'; export { AdminLayout } from './layouts/AdminLayout'; export { AppLayout } from './layouts/AppLayout'; export { AuthenticatedLayout } from './layouts/AuthenticatedLayout'; export { PublicLayout } from './layouts/PublicLayout'; export { SidebarLayout } from './layouts/SidebarLayout'; +export { initials } from './lib/initials'; +export { + ageOf, + isStale, + RELATIVE_AGE_KEYS, + RELATIVE_UNTIL_KEYS, + type RelativeAge, + relativeAge, + relativeUntil, + STALE_AFTER_MS, +} from './lib/relative-time'; export { shouldInterceptNavigation, startSpaLinkInterception } from './lib/spa-links'; +export { + applyTheme, + initTheme, + readThemePreference, + resolveTheme, + setThemePreference, + THEME_STORAGE_KEY, + type ThemePreference, +} from './lib/theme'; export { TONE, type Tone } from './lib/tone'; export type { MenuItem, SharedProps } from './types'; diff --git a/packages/ui/src/lib/initials.test.ts b/packages/ui/src/lib/initials.test.ts new file mode 100644 index 00000000..e5d8aa2e --- /dev/null +++ b/packages/ui/src/lib/initials.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { initials } from './initials'; + +describe('initials', () => { + it('takes the first and last word of a full name', () => { + expect(initials('Dana Rivera')).toBe('DR'); + expect(initials('ada b. lovelace')).toBe('AL'); + }); + + it('takes the first two letters of a single-word name', () => { + expect(initials('admin')).toBe('AD'); + }); + + it('falls back to the email local part when there is no name', () => { + expect(initials(null, 'rob@example.com')).toBe('RO'); + expect(initials(' ', 'rob@example.com')).toBe('RO'); + }); + + it('prefers the name over the email', () => { + expect(initials('Dana Rivera', 'rob@example.com')).toBe('DR'); + }); + + it('returns a placeholder when there is nothing to work with', () => { + expect(initials()).toBe('?'); + expect(initials(null, null)).toBe('?'); + expect(initials('', '')).toBe('?'); + }); + + it('copes with a one-character source', () => { + expect(initials('x')).toBe('X'); + expect(initials(null, 'r@example.com')).toBe('R'); + }); +}); diff --git a/packages/ui/src/lib/initials.ts b/packages/ui/src/lib/initials.ts new file mode 100644 index 00000000..de868c0e --- /dev/null +++ b/packages/ui/src/lib/initials.ts @@ -0,0 +1,21 @@ +/** + * Two letters to stand in for a person when there is no avatar image. + * + * Every list of users in the app shows a face; most accounts never upload one. + * The fallback has to work from whatever the record actually has — a full + * name, a username, or only an email — and still produce something stable and + * recognisable rather than a blank circle. + */ +export function initials(name?: string | null, email?: string | null): string { + const words = (name ?? '').trim().split(/\s+/).filter(Boolean); + if (words.length >= 2) { + return (words[0][0] + words[words.length - 1][0]).toUpperCase(); + } + if (words.length === 1) return words[0].slice(0, 2).toUpperCase(); + + // No name: the local part of the email is the closest thing to one. + const local = (email ?? '').trim().split('@')[0]; + if (local) return local.slice(0, 2).toUpperCase(); + + return '?'; +} diff --git a/packages/ui/src/lib/relative-time.test.ts b/packages/ui/src/lib/relative-time.test.ts index 62fd192f..c69d64f4 100644 --- a/packages/ui/src/lib/relative-time.test.ts +++ b/packages/ui/src/lib/relative-time.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { ageOf, isStale, RELATIVE_AGE_KEYS, relativeAge, STALE_AFTER_MS } from './relative-time'; +import { + ageOf, + isStale, + RELATIVE_AGE_KEYS, + RELATIVE_UNTIL_KEYS, + relativeAge, + relativeUntil, + STALE_AFTER_MS, +} from './relative-time'; // The wording lives in the ui catalog, so the unit under test here is which // bucket an age falls into and what count it reports — not the English. @@ -57,3 +65,49 @@ describe('ageOf', () => { expect(ageOf('not a date', now)).toBeNaN(); }); }); + +describe('relativeAge over longer spans', () => { + const DAY = 24 * 60 * 60_000; + + it('counts days once an age passes a day', () => { + expect(relativeAge(DAY)).toEqual({ key: RELATIVE_AGE_KEYS.days, count: 1 }); + expect(relativeAge(29 * DAY)).toEqual({ key: RELATIVE_AGE_KEYS.days, count: 29 }); + }); + + it('switches to months at thirty days', () => { + expect(relativeAge(30 * DAY)).toEqual({ key: RELATIVE_AGE_KEYS.months, count: 1 }); + expect(relativeAge(364 * DAY)).toEqual({ key: RELATIVE_AGE_KEYS.months, count: 12 }); + }); + + it('switches to years at a year', () => { + expect(relativeAge(365 * DAY)).toEqual({ key: RELATIVE_AGE_KEYS.years, count: 1 }); + expect(relativeAge(800 * DAY)).toEqual({ key: RELATIVE_AGE_KEYS.years, count: 2 }); + }); +}); + +describe('relativeUntil', () => { + const MINUTE = 60_000; + const HOUR = 60 * MINUTE; + const DAY = 24 * HOUR; + + it('reports anything already past as expired', () => { + expect(relativeUntil(0)).toEqual({ key: RELATIVE_UNTIL_KEYS.expired }); + expect(relativeUntil(-1)).toEqual({ key: RELATIVE_UNTIL_KEYS.expired }); + }); + + it('counts minutes below the hour, never rounding down to zero', () => { + expect(relativeUntil(30_000)).toEqual({ key: RELATIVE_UNTIL_KEYS.minutes, count: 1 }); + expect(relativeUntil(59 * MINUTE)).toEqual({ key: RELATIVE_UNTIL_KEYS.minutes, count: 59 }); + }); + + it('counts hours below a day, then days', () => { + expect(relativeUntil(HOUR)).toEqual({ key: RELATIVE_UNTIL_KEYS.hours, count: 1 }); + expect(relativeUntil(23 * HOUR)).toEqual({ key: RELATIVE_UNTIL_KEYS.hours, count: 23 }); + expect(relativeUntil(DAY)).toEqual({ key: RELATIVE_UNTIL_KEYS.days, count: 1 }); + expect(relativeUntil(10 * DAY)).toEqual({ key: RELATIVE_UNTIL_KEYS.days, count: 10 }); + }); + + it('degrades to "unknown" rather than NaN', () => { + expect(relativeUntil(Number.NaN)).toEqual({ key: RELATIVE_AGE_KEYS.unknown }); + }); +}); diff --git a/packages/ui/src/lib/relative-time.ts b/packages/ui/src/lib/relative-time.ts index c2cf986d..39719e91 100644 --- a/packages/ui/src/lib/relative-time.ts +++ b/packages/ui/src/lib/relative-time.ts @@ -10,6 +10,9 @@ const SECOND = 1000; const MINUTE = 60 * SECOND; const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; +const MONTH = 30 * DAY; +const YEAR = 365 * DAY; /** Past this, a snapshot is old enough that it may no longer describe reality. */ export const STALE_AFTER_MS = 60 * SECOND; @@ -20,10 +23,27 @@ export const RELATIVE_AGE_KEYS = { seconds: 'ui.relative_time.seconds_ago', minutes: 'ui.relative_time.minutes_ago', hours: 'ui.relative_time.hours_ago', + days: 'ui.relative_time.days_ago', + months: 'ui.relative_time.months_ago', + years: 'ui.relative_time.years_ago', } as const; export type RelativeAgeKey = (typeof RELATIVE_AGE_KEYS)[keyof typeof RELATIVE_AGE_KEYS]; +/** + * The same idea pointed forwards: how long until a deadline, not how long + * since a reading. Invitations and API keys expire, and "in 3d" answers the + * only question a reader has about an expiry date. + */ +export const RELATIVE_UNTIL_KEYS = { + minutes: 'ui.relative_time.in_minutes', + hours: 'ui.relative_time.in_hours', + days: 'ui.relative_time.in_days', + expired: 'ui.relative_time.expired', +} as const; + +export type RelativeUntilKey = (typeof RELATIVE_UNTIL_KEYS)[keyof typeof RELATIVE_UNTIL_KEYS]; + /** * Which bucket an age falls into, and the number to put in the sentence. * @@ -32,7 +52,7 @@ export type RelativeAgeKey = (typeof RELATIVE_AGE_KEYS)[keyof typeof RELATIVE_AG * what is worth testing, and it stays a pure function. */ export interface RelativeAge { - key: RelativeAgeKey; + key: RelativeAgeKey | RelativeUntilKey; count?: number; } @@ -42,7 +62,27 @@ export function relativeAge(ageMs: number): RelativeAge { if (age < 10 * SECOND) return { key: RELATIVE_AGE_KEYS.justNow }; if (age < MINUTE) return { key: RELATIVE_AGE_KEYS.seconds, count: Math.floor(age / SECOND) }; if (age < HOUR) return { key: RELATIVE_AGE_KEYS.minutes, count: Math.floor(age / MINUTE) }; - return { key: RELATIVE_AGE_KEYS.hours, count: Math.floor(age / HOUR) }; + if (age < DAY) return { key: RELATIVE_AGE_KEYS.hours, count: Math.floor(age / HOUR) }; + if (age < MONTH) return { key: RELATIVE_AGE_KEYS.days, count: Math.floor(age / DAY) }; + if (age < YEAR) return { key: RELATIVE_AGE_KEYS.months, count: Math.floor(age / MONTH) }; + return { key: RELATIVE_AGE_KEYS.years, count: Math.floor(age / YEAR) }; +} + +/** + * Which bucket a remaining lifetime falls into. + * + * Anything at or past its deadline reads as expired rather than as a negative + * countdown, and a sub-minute remainder rounds *up* — "in 0m" would read as + * already gone when there is still time to act. + */ +export function relativeUntil(msUntil: number): RelativeAge { + if (!Number.isFinite(msUntil)) return { key: RELATIVE_AGE_KEYS.unknown }; + if (msUntil <= 0) return { key: RELATIVE_UNTIL_KEYS.expired }; + if (msUntil < HOUR) { + return { key: RELATIVE_UNTIL_KEYS.minutes, count: Math.max(1, Math.floor(msUntil / MINUTE)) }; + } + if (msUntil < DAY) return { key: RELATIVE_UNTIL_KEYS.hours, count: Math.floor(msUntil / HOUR) }; + return { key: RELATIVE_UNTIL_KEYS.days, count: Math.floor(msUntil / DAY) }; } export function isStale(ageMs: number): boolean { diff --git a/packages/ui/src/lib/theme.test.ts b/packages/ui/src/lib/theme.test.ts new file mode 100644 index 00000000..68bb6c2c --- /dev/null +++ b/packages/ui/src/lib/theme.test.ts @@ -0,0 +1,114 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + applyTheme, + initTheme, + readThemePreference, + resolveTheme, + setThemePreference, + THEME_STORAGE_KEY, +} from './theme'; + +/** Stand in for matchMedia so a test can say what the OS prefers. */ +function stubPrefersDark(prefersDark: boolean) { + const listeners = new Set<(e: MediaQueryListEvent) => void>(); + const mql = { + matches: prefersDark, + addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn), + removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn), + }; + vi.stubGlobal( + 'matchMedia', + vi.fn(() => mql), + ); + return { listeners, mql }; +} + +beforeEach(() => { + localStorage.clear(); + document.documentElement.classList.remove('dark'); + vi.unstubAllGlobals(); +}); + +describe('resolveTheme', () => { + it('follows the OS only when the preference is "system"', () => { + expect(resolveTheme('system', true)).toBe('dark'); + expect(resolveTheme('system', false)).toBe('light'); + expect(resolveTheme('light', true)).toBe('light'); + expect(resolveTheme('dark', false)).toBe('dark'); + }); +}); + +describe('readThemePreference', () => { + it('defaults to "system" when nothing is stored', () => { + expect(readThemePreference()).toBe('system'); + }); + + it('reads a stored preference back', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'dark'); + expect(readThemePreference()).toBe('dark'); + }); + + it('ignores a stored value that is not a preference', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'neon'); + expect(readThemePreference()).toBe('system'); + }); +}); + +describe('applyTheme', () => { + it('adds the dark class for "dark" and removes it for "light"', () => { + stubPrefersDark(false); + applyTheme('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + applyTheme('light'); + expect(document.documentElement.classList.contains('dark')).toBe(false); + }); + + it('asks the OS when the preference is "system"', () => { + stubPrefersDark(true); + applyTheme('system'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); +}); + +describe('setThemePreference', () => { + it('persists and applies in one step', () => { + stubPrefersDark(false); + setThemePreference('dark'); + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); +}); + +describe('initTheme', () => { + it('applies the stored preference straight away', () => { + stubPrefersDark(false); + localStorage.setItem(THEME_STORAGE_KEY, 'dark'); + const stop = initTheme(); + expect(document.documentElement.classList.contains('dark')).toBe(true); + stop(); + }); + + it('follows later OS changes while the preference is "system"', () => { + const { listeners, mql } = stubPrefersDark(false); + const stop = initTheme(); + expect(document.documentElement.classList.contains('dark')).toBe(false); + + mql.matches = true; + for (const fn of listeners) fn({ matches: true } as MediaQueryListEvent); + expect(document.documentElement.classList.contains('dark')).toBe(true); + + stop(); + expect(listeners.size).toBe(0); + }); + + it('stops following the OS once an explicit preference is stored', () => { + const { listeners, mql } = stubPrefersDark(false); + localStorage.setItem(THEME_STORAGE_KEY, 'light'); + const stop = initTheme(); + + mql.matches = true; + for (const fn of listeners) fn({ matches: true } as MediaQueryListEvent); + expect(document.documentElement.classList.contains('dark')).toBe(false); + stop(); + }); +}); diff --git a/packages/ui/src/lib/theme.ts b/packages/ui/src/lib/theme.ts new file mode 100644 index 00000000..46421b45 --- /dev/null +++ b/packages/ui/src/lib/theme.ts @@ -0,0 +1,74 @@ +/** + * Light/dark preference, stored and applied. + * + * Tailwind's dark variant keys off a `dark` class on ``, so the whole + * feature is one class plus somewhere to remember the choice. "system" is the + * default and stays live: a laptop that flips to dark at sunset should take + * the app with it, which is why `initTheme` subscribes rather than reading the + * media query once at boot. + */ + +export type ThemePreference = 'light' | 'dark' | 'system'; + +export const THEME_STORAGE_KEY = 'sm.theme'; + +const DARK_QUERY = '(prefers-color-scheme: dark)'; + +function isPreference(value: unknown): value is ThemePreference { + return value === 'light' || value === 'dark' || value === 'system'; +} + +/** What the OS currently asks for; false anywhere `matchMedia` is unavailable. */ +function prefersDark(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia(DARK_QUERY).matches; +} + +export function readThemePreference(): ThemePreference { + if (typeof window === 'undefined') return 'system'; + try { + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + return isPreference(stored) ? stored : 'system'; + } catch { + // Storage is denied in private-mode Safari and in sandboxed iframes. + // Following the OS is a better answer there than throwing. + return 'system'; + } +} + +export function resolveTheme(pref: ThemePreference, prefersDarkNow: boolean): 'light' | 'dark' { + if (pref === 'system') return prefersDarkNow ? 'dark' : 'light'; + return pref; +} + +export function applyTheme(pref: ThemePreference): void { + if (typeof document === 'undefined') return; + const resolved = resolveTheme(pref, prefersDark()); + document.documentElement.classList.toggle('dark', resolved === 'dark'); +} + +export function setThemePreference(pref: ThemePreference): void { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, pref); + } catch { + // Same as reading: an un-persisted choice still applies for this session. + } + applyTheme(pref); +} + +/** + * Apply the stored preference and keep following the OS while it is "system". + * Returns the unsubscribe so a caller mounting this in an effect can clean up. + */ +export function initTheme(): () => void { + applyTheme(readThemePreference()); + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return () => {}; + } + const media = window.matchMedia(DARK_QUERY); + // Re-read the preference rather than closing over it: an explicit choice + // made after boot must stop the OS from overriding it. + const onChange = () => applyTheme(readThemePreference()); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); +} From f5ed6e247d6e3260546c14dc5c9cc25c746ae829 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 3 Sep 2026 06:30:51 +0200 Subject: [PATCH 04/66] fix: mirror app_builder's discover_modules(enabled=...) in gen_i18n.py --- scripts/gen_i18n.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/gen_i18n.py b/scripts/gen_i18n.py index 159e8425..bfe30a34 100644 --- a/scripts/gen_i18n.py +++ b/scripts/gen_i18n.py @@ -9,5 +9,15 @@ ROOT = Path(__file__).resolve().parent.parent if __name__ == "__main__": - emit_frontend_types_for_modules(Settings(), discover_modules(), ROOT) + # Env-only Settings() — no merge_host_settings, so a DB-stored + # i18n_default_locale override is invisible here. Accepted trade-off for a + # tool that must not boot the app (and thus must not touch the DB). + settings = Settings() + # Mirrors create_app's discovery call (app_builder.py) so the key union + # this emits always matches what a live boot would type: if an operator + # restricts modules via SM_MODULES_ENABLED, the generated keys track that + # same subset rather than drifting to "every installed module" while the + # running app types fewer. + modules = discover_modules(enabled=settings.modules_enabled, strict=not settings.is_development) + emit_frontend_types_for_modules(settings, modules, ROOT) print("i18n key files regenerated") From 67330e2f106aa5c94bb368c21c0b34a36f768b25 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Thu, 3 Sep 2026 06:33:49 +0200 Subject: [PATCH 05/66] =?UTF-8?q?feat(shell):=20deck=20shell=20=E2=80=94?= =?UTF-8?q?=20solid=20active=20nav,=20topbar=20log=20out=20+=20locale=20pi?= =?UTF-8?q?ll,=20phone=20bar=20title/action/back,=20split=20auth=20shell,?= =?UTF-8?q?=20theme=20boot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- host/client_app/app.tsx | 5 + packages/i18n/src/generated-resources.ts | 3 + packages/i18n/src/keys.generated.ts | 5 + packages/ui/locales/en.json | 9 +- packages/ui/src/components/AppTopbar.test.tsx | 100 ++++++++++++++- packages/ui/src/components/AppTopbar.tsx | 22 +++- .../ui/src/components/LocaleSwitcher.test.tsx | 68 ++++++---- packages/ui/src/components/LocaleSwitcher.tsx | 54 ++++++-- packages/ui/src/components/NavIcon.tsx | 11 +- packages/ui/src/components/PageShell.test.tsx | 120 ++++++++++++++++++ packages/ui/src/components/PageShell.tsx | 55 ++++++-- packages/ui/src/components/page-heading.tsx | 73 +++++++++-- packages/ui/src/index.ts | 2 + packages/ui/src/layouts/AdminLayout.tsx | 10 +- packages/ui/src/layouts/AdminSectionLink.tsx | 4 +- .../ui/src/layouts/AuthCardShell.test.tsx | 48 +++++++ packages/ui/src/layouts/AuthCardShell.tsx | 109 ++++++++++++---- .../ui/src/layouts/AuthSplitAside.test.tsx | 27 ++++ packages/ui/src/layouts/AuthSplitAside.tsx | 60 +++++++++ .../ui/src/layouts/AuthenticatedLayout.tsx | 6 +- packages/ui/src/layouts/MobileBar.test.tsx | 117 +++++++++++++++++ packages/ui/src/layouts/MobileBar.tsx | 111 ++++++++++++++++ packages/ui/src/layouts/SidebarLayout.tsx | 114 ++++++----------- packages/ui/src/layouts/SidebarUserMenu.tsx | 13 +- packages/ui/src/layouts/sidebar-theme.ts | 23 +++- 25 files changed, 983 insertions(+), 186 deletions(-) create mode 100644 packages/ui/src/components/PageShell.test.tsx create mode 100644 packages/ui/src/layouts/AuthCardShell.test.tsx create mode 100644 packages/ui/src/layouts/AuthSplitAside.test.tsx create mode 100644 packages/ui/src/layouts/AuthSplitAside.tsx create mode 100644 packages/ui/src/layouts/MobileBar.test.tsx create mode 100644 packages/ui/src/layouts/MobileBar.tsx diff --git a/host/client_app/app.tsx b/host/client_app/app.tsx index a9ef8c91..f3f14790 100644 --- a/host/client_app/app.tsx +++ b/host/client_app/app.tsx @@ -3,6 +3,7 @@ import { ErrorBoundary } from '@simple-module-py/ui/components/ErrorBoundary'; import { OfflineBanner } from '@simple-module-py/ui/components/OfflineBanner'; import { formatTitle, setTitleAppName } from '@simple-module-py/ui/lib/app-title'; import { startSpaLinkInterception } from '@simple-module-py/ui/lib/spa-links'; +import { initTheme } from '@simple-module-py/ui/lib/theme'; import { useEffect, useRef } from 'react'; import { createRoot } from 'react-dom/client'; import { bootI18nFromInitialPage, subscribeI18nToNavigation } from './i18n'; @@ -24,6 +25,10 @@ createInertiaApp({ .branding; setTitleAppName(branding?.appName); bootI18nFromInitialPage(props.initialPage.props); + // Before the first render, so a dark-theme user never sees a light frame + // flash. The returned unsubscribe is deliberately dropped: the listener + // keeps `system` following the OS for the life of the document. + initTheme(); function Root() { const boundaryRef = useRef(null); diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index f87aeb5d..23e726fe 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -606,9 +606,12 @@ export default { 'ui.relative_time.seconds_ago': '', 'ui.relative_time.unknown': '', 'ui.relative_time.years_ago': '', + 'ui.sidebar.back': '', 'ui.sidebar.close': '', 'ui.sidebar.open': '', 'ui.switcher.label': '', + 'ui.switcher.single_locale': '', + 'ui.topbar.log_out': '', 'users.accept_invite.access_label': '', 'users.accept_invite.already_used': '', 'users.accept_invite.error_bad_token': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 86b947b6..574fce81 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -802,11 +802,16 @@ export const keys = { years_ago: 'ui.relative_time.years_ago', }, sidebar: { + back: 'ui.sidebar.back', close: 'ui.sidebar.close', open: 'ui.sidebar.open', }, switcher: { label: 'ui.switcher.label', + single_locale: 'ui.switcher.single_locale', + }, + topbar: { + log_out: 'ui.topbar.log_out', }, }, users: { diff --git a/packages/ui/locales/en.json b/packages/ui/locales/en.json index 8dcbba59..caef7d0c 100644 --- a/packages/ui/locales/en.json +++ b/packages/ui/locales/en.json @@ -10,7 +10,11 @@ "admin": "Administration" }, "switcher": { - "label": "Change language" + "label": "Change language", + "single_locale": "Only {locale} is enabled" + }, + "topbar": { + "log_out": "Log out" }, "relative_time": { "unknown": "unknown", @@ -47,7 +51,8 @@ }, "sidebar": { "open": "Open sidebar", - "close": "Close sidebar" + "close": "Close sidebar", + "back": "Back" }, "public_nav": { "docs": "Docs", diff --git a/packages/ui/src/components/AppTopbar.test.tsx b/packages/ui/src/components/AppTopbar.test.tsx index a0e4f8e2..35aea90d 100644 --- a/packages/ui/src/components/AppTopbar.test.tsx +++ b/packages/ui/src/components/AppTopbar.test.tsx @@ -1,6 +1,66 @@ -import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; import type { MenuItem } from '../types'; -import { activeSection } from './AppTopbar'; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ + url: '/dashboard/', + props: { i18n: { locale: 'en', supportedLocales: ['en'], messages: {} } }, + }), + router: { visit: vi.fn(), post: vi.fn() }, + Link: ({ + href, + method, + as: renderAs, + children, + ...rest + }: { + href: string; + method?: string; + as?: string; + children: React.ReactNode; + } & Record) => + renderAs === 'button' ? ( + + ) : ( + + {children} + + ), +})); + +vi.mock('@simple-module-py/i18n', () => { + const labels: Record = { + 'ui.topbar.log_out': 'Log out', + 'ui.switcher.label': 'Change language', + 'ui.switcher.single_locale': 'Only EN is enabled', + 'ui.command_palette.trigger': 'Search', + }; + return { + useT: () => ({ t: (key: string) => labels[key] ?? key }), + keys: { + ui: { + topbar: { log_out: 'ui.topbar.log_out' }, + switcher: { label: 'ui.switcher.label', single_locale: 'ui.switcher.single_locale' }, + command_palette: { + trigger: 'ui.command_palette.trigger', + title: 'ui.command_palette.title', + description: 'ui.command_palette.description', + placeholder: 'ui.command_palette.placeholder', + empty: 'ui.command_palette.empty', + }, + nav_groups: { + navigation: 'ui.nav_groups.navigation', + account: 'ui.nav_groups.account', + }, + }, + }, + }; +}); + +import { AppTopbar, activeSection } from './AppTopbar'; const item = (label: string, url: string): MenuItem => ({ label, url, icon: 'grid' }); @@ -47,3 +107,39 @@ describe('activeSection', () => { expect(activeSection([], '/users/admin')).toBeNull(); }); }); + +const LOGOUT: MenuItem = { + label: 'Logout', + url: '/users/logout', + icon: 'log-out', + method: 'post', +}; +const PROFILE: MenuItem = { label: 'Profile', url: '/users/profile', icon: 'user' }; + +describe('AppTopbar log out', () => { + it('submits the account menu’s post item as a button', () => { + render( + , + ); + const button = screen.getByRole('button', { name: 'Log out' }); + expect(button).toHaveAttribute('data-method', 'post'); + expect(button).toHaveAttribute('data-href', '/users/logout'); + }); + + it('renders no log out control when the menu has no post item', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: 'Log out' })).toBeNull(); + }); +}); diff --git a/packages/ui/src/components/AppTopbar.tsx b/packages/ui/src/components/AppTopbar.tsx index f6acc1c3..e005ae83 100644 --- a/packages/ui/src/components/AppTopbar.tsx +++ b/packages/ui/src/components/AppTopbar.tsx @@ -1,4 +1,5 @@ import { Link } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; import { Breadcrumb, BreadcrumbItem, @@ -8,7 +9,7 @@ import { BreadcrumbSeparator, } from '@simple-module-py/ui/components/ui/breadcrumb'; import { isUnder, samePath, trimmed } from '../lib/current-path'; -import type { MenuItem } from '../types'; +import { isPostMenuItem, type MenuItem } from '../types'; import { CommandPalette } from './CommandPalette'; import { LocaleSwitcher } from './LocaleSwitcher'; import { usePageHeading } from './page-heading'; @@ -59,7 +60,12 @@ export function findSection(items: MenuItem[], sectionUrl: string | null): MenuI * the heading that is about to be rendered directly beneath it. */ export function AppTopbar({ navItems, accountItems, currentUrl, activeMenuItem }: AppTopbarProps) { + const { t } = useT(); const heading = usePageHeading(currentUrl); + // Signing out was reachable only from the avatar dropdown and ⌘K. The item + // itself stays registry-owned — whichever auth provider is installed + // contributes the one account entry that must be POSTed. + const logout = accountItems.find(isPostMenuItem); const section = activeMenuItem; // Only a genuine sub-page earns a second crumb — on a section's own index the // heading and the section name are the same word, and "Users / Users" is noise. @@ -69,7 +75,7 @@ export function AppTopbar({ navItems, accountItems, currentUrl, activeMenuItem } const leaf = heading && heading !== section?.label ? heading : null; return ( -
+
{section ? ( @@ -102,9 +108,19 @@ export function AppTopbar({ navItems, accountItems, currentUrl, activeMenuItem } -
+
+ {logout && ( + + {t(keys.ui.topbar.log_out)} + + )}
); diff --git a/packages/ui/src/components/LocaleSwitcher.test.tsx b/packages/ui/src/components/LocaleSwitcher.test.tsx index f133a708..6f1aafb1 100644 --- a/packages/ui/src/components/LocaleSwitcher.test.tsx +++ b/packages/ui/src/components/LocaleSwitcher.test.tsx @@ -1,45 +1,69 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// The install's locale list is what this control branches on, so the mocked +// shared prop is per-test rather than fixed at module scope. +const i18nProps = vi.hoisted(() => ({ + current: { locale: 'en', supportedLocales: ['en', 'es'], messages: {} } as { + locale: string; + supportedLocales: string[]; + messages: Record; + }, +})); -// Mock @inertiajs/react's usePage before importing the component under test. vi.mock('@inertiajs/react', () => ({ - usePage: () => ({ - props: { - i18n: { - locale: 'en', - supportedLocales: ['en', 'es'], - messages: {}, - }, - }, - }), + usePage: () => ({ props: { i18n: i18nProps.current } }), })); -// Mock @simple-module-py/i18n so useT resolves known keys without a real i18next instance. -vi.mock('@simple-module-py/i18n', () => ({ - useT: () => ({ - t: (key: string) => (key === 'ui.switcher.label' ? 'Change language' : key), - }), - keys: { - ui: { - switcher: { - label: 'ui.switcher.label', +vi.mock('@simple-module-py/i18n', () => { + const labels: Record = { + 'ui.switcher.label': 'Change language', + 'ui.switcher.single_locale': 'Only {locale} is enabled', + }; + return { + useT: () => ({ + t: (key: string, vars?: Record) => + Object.entries(vars ?? {}).reduce( + (text, [name, value]) => text.replace(`{${name}}`, value), + labels[key] ?? key, + ), + }), + keys: { + ui: { + switcher: { label: 'ui.switcher.label', single_locale: 'ui.switcher.single_locale' }, }, }, - }, -})); + }; +}); import { LocaleSwitcher } from './LocaleSwitcher'; describe('LocaleSwitcher', () => { + beforeEach(() => { + i18nProps.current = { locale: 'en', supportedLocales: ['en', 'es'], messages: {} }; + }); + test('renders when multiple locales supported', () => { render(); expect(screen.getByRole('button', { name: /change language/i })).toBeInTheDocument(); }); + test('shows the active locale as a text pill', () => { + render(); + expect(screen.getByRole('button', { name: /change language/i })).toHaveTextContent('EN'); + }); + test('form targets /i18n/set-locale', () => { const { container } = render(); const form = container.querySelector('form'); expect(form?.getAttribute('action')).toBe('/i18n/set-locale'); expect(form?.getAttribute('method')?.toLowerCase()).toBe('post'); }); + + test('renders a static pill when only one locale is enabled', () => { + i18nProps.current = { locale: 'en', supportedLocales: ['en'], messages: {} }; + render(); + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByTitle('Only EN is enabled')).toHaveTextContent('EN'); + }); }); diff --git a/packages/ui/src/components/LocaleSwitcher.tsx b/packages/ui/src/components/LocaleSwitcher.tsx index 54fd98bb..817e6150 100644 --- a/packages/ui/src/components/LocaleSwitcher.tsx +++ b/packages/ui/src/components/LocaleSwitcher.tsx @@ -1,14 +1,13 @@ import { usePage } from '@inertiajs/react'; import { keys, useT } from '@simple-module-py/i18n'; -import { Button } from '@simple-module-py/ui/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@simple-module-py/ui/components/ui/dropdown-menu'; -import { Globe } from 'lucide-react'; import { useRef } from 'react'; +import { cn } from '../lib/utils'; /** * Static map of locale code -> label in that locale's own language. @@ -26,20 +25,39 @@ const LOCALE_LABELS: Record = { ru: 'Русский', }; +// A bordered text pill, not a globe: the code names the language you are in, +// which an icon cannot, and it stays legible on the phone drawer's dark +// surface as well as the topbar's card. +const PILL = + 'inline-flex items-center justify-center rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground'; + interface I18nSharedProps { locale: string; supportedLocales: string[]; messages: Record; } -export function LocaleSwitcher() { +export function LocaleSwitcher({ className = '' }: { className?: string }) { const page = usePage<{ i18n?: I18nSharedProps }>(); const i18n = page.props.i18n; const formRef = useRef(null); const { t } = useT(); - if (!i18n || i18n.supportedLocales.length <= 1) { - return null; + if (!i18n) return null; + const code = i18n.locale.toUpperCase(); + + // One locale is the default install. A menu that opens onto a single option + // is noise, but dropping the control entirely leaves the topbar's right + // cluster looking unfinished — so the pill stays, inert and explained. + if (i18n.supportedLocales.length <= 1) { + return ( + + {code} + + ); } const select = (locale: string) => { @@ -63,19 +81,27 @@ export function LocaleSwitcher() { - + - {i18n.supportedLocales.map((code) => ( + {i18n.supportedLocales.map((locale) => ( select(code)} - data-active={code === i18n.locale} + key={locale} + onSelect={() => select(locale)} + data-active={locale === i18n.locale} > - {LOCALE_LABELS[code] ?? code} - {code === i18n.locale && ✓} + {LOCALE_LABELS[locale] ?? locale} + {locale === i18n.locale && ✓} ))} diff --git a/packages/ui/src/components/NavIcon.tsx b/packages/ui/src/components/NavIcon.tsx index 5726e7be..fbc74ab7 100644 --- a/packages/ui/src/components/NavIcon.tsx +++ b/packages/ui/src/components/NavIcon.tsx @@ -156,10 +156,15 @@ const ICON_MAP = { export type NavIconName = keyof typeof ICON_MAP; -export function NavIcon({ name }: { name: string }) { +/** + * `className` replaces the default box so a caller can hide the icon at a + * breakpoint — the phone drawer drops icons entirely, and a row that reserved + * their space would keep the indent without the glyph. + */ +export function NavIcon({ name, className = 'w-5 h-5' }: { name: string; className?: string }) { if (!(name in ICON_MAP)) { - return
-
-

{title}

- {description && ( -

{description}

- )} +
+ {leading} +
+
+

+ {title} +

+ {badge} +
+ {description && ( +

{description}

+ )} +
{actions &&
{actions}
}
diff --git a/packages/ui/src/components/page-heading.tsx b/packages/ui/src/components/page-heading.tsx index c904437f..764bcdbb 100644 --- a/packages/ui/src/components/page-heading.tsx +++ b/packages/ui/src/components/page-heading.tsx @@ -1,5 +1,5 @@ import { usePage } from '@inertiajs/react'; -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; /** * Lets the app shell name the page currently rendered inside it. @@ -10,13 +10,24 @@ import { createContext, useContext, useEffect, useMemo, useState } from 'react'; * (which then drifts from the heading on screen), `PageShell` reports the one * it is already rendering and the shell reads it back. * + * The phone bar needs more than a name: it carries the title, an optional back + * chevron and one compact action, none of which the layout can know. They ride + * along on the same report for the same reason. + * * The report carries the url it was made from, so a heading is only ever used * for the page it came from: during an Inertia swap the layout re-renders with * the new url before the incoming page's effect runs, and a bare string would * show the previous page's title against the new section for a frame. */ -interface Heading { +/** The phone bar's right slot — a short label, and somewhere for it to go. */ +export interface PageMobileAction { + label: string; + href?: string; + onClick?: () => void; +} + +export interface Heading { title: string; url: string; /** @@ -26,6 +37,11 @@ interface Heading { * menu, so a section the viewer cannot open is simply not shown. */ section?: string; + /** Href for the phone bar's back chevron, which replaces the hamburger. */ + back?: string; + /** Render the phone bar's title in the mono face (task names, ids). */ + mono?: boolean; + mobileAction?: PageMobileAction; } const HeadingValue = createContext(null); @@ -40,23 +56,64 @@ export function PageHeadingProvider({ children }: { children: React.ReactNode }) ); } +/** Everything the current page told the shell about itself, or null. */ +export function usePageChrome(currentUrl: string): Heading | null { + const heading = useContext(HeadingValue); + return heading && heading.url === currentUrl ? heading : null; +} + /** The current page's heading, or null when it hasn't reported one. */ export function usePageHeading(currentUrl: string): string | null { - const heading = useContext(HeadingValue); - return heading && heading.url === currentUrl ? heading.title : null; + return usePageChrome(currentUrl)?.title ?? null; } /** The section url this page declared, if any. */ export function usePageSection(currentUrl: string): string | null { - const heading = useContext(HeadingValue); - return heading && heading.url === currentUrl ? (heading.section ?? null) : null; + return usePageChrome(currentUrl)?.section ?? null; } /** Report this page's heading to the shell. No-op outside a provider. */ -export function useReportPageHeading(title: string, section?: string): void { +export function useReportPageHeading(heading: Omit): void; +/** Positional form kept for pages that only ever had a title and a section. */ +export function useReportPageHeading(title: string, section?: string): void; +export function useReportPageHeading( + headingOrTitle: Omit | string, + positionalSection?: string, +): void { const setHeading = useContext(HeadingSetter); const url = usePage().url; - const next = useMemo(() => ({ title, url, section }), [title, url, section]); + const declared = + typeof headingOrTitle === 'string' + ? { title: headingOrTitle, section: positionalSection } + : headingOrTitle; + const { title, section, back, mono } = declared; + const action = declared.mobileAction; + const actionLabel = action?.label; + const actionHref = action?.href; + const hasOnClick = Boolean(action?.onClick); + // A page re-creates its handler on every render. Keying the report on it + // would publish a new heading each time, re-render the provider, and loop — + // so the latest handler is read through a ref and the published wrapper + // stays identical across renders. + const onClickRef = useRef(action?.onClick); + onClickRef.current = action?.onClick; + const next = useMemo( + () => ({ + title, + url, + section, + back, + mono, + mobileAction: actionLabel + ? { + label: actionLabel, + href: actionHref, + onClick: hasOnClick ? () => onClickRef.current?.() : undefined, + } + : undefined, + }), + [title, url, section, back, mono, actionLabel, actionHref, hasOnClick], + ); useEffect(() => { setHeading?.(next); }, [setHeading, next]); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 374676aa..587b243b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -18,7 +18,9 @@ export { useOnline } from './hooks/use-online'; export { useRelativeTime } from './hooks/use-relative-time'; export { AdminLayout } from './layouts/AdminLayout'; export { AppLayout } from './layouts/AppLayout'; +export { AuthCardShell } from './layouts/AuthCardShell'; export { AuthenticatedLayout } from './layouts/AuthenticatedLayout'; +export { AuthSplitAside } from './layouts/AuthSplitAside'; export { PublicLayout } from './layouts/PublicLayout'; export { SidebarLayout } from './layouts/SidebarLayout'; export { initials } from './lib/initials'; diff --git a/packages/ui/src/layouts/AdminLayout.tsx b/packages/ui/src/layouts/AdminLayout.tsx index 1334d7bd..db4f4746 100644 --- a/packages/ui/src/layouts/AdminLayout.tsx +++ b/packages/ui/src/layouts/AdminLayout.tsx @@ -3,10 +3,12 @@ import { keys, useT } from '@simple-module-py/i18n'; import type React from 'react'; import { DEFAULT_SIDEBAR_THEME, SidebarLayout } from './SidebarLayout'; -// Same visual language as the app sidebar — the admin area announces itself -// through the panel badge and its own menu, not through an alarm color. +// The app sidebar's surface, with a red active pill: the admin area announces +// itself through the panel badge and its own menu, and the one row that says +// "you are here" is the one place a different colour is worth spending. const THEME = { ...DEFAULT_SIDEBAR_THEME, + activeClass: 'bg-red-600 text-white', mobileTitleLabel: 'Admin', } as const; @@ -50,11 +52,11 @@ function BackToApp() {
diff --git a/packages/ui/src/layouts/AuthCardShell.test.tsx b/packages/ui/src/layouts/AuthCardShell.test.tsx new file mode 100644 index 00000000..35028bee --- /dev/null +++ b/packages/ui/src/layouts/AuthCardShell.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { branding: { appName: 'Acme', logoUrl: null, banner: null } } }), + Head: () => null, +})); + +vi.mock('@simple-module-py/i18n', () => ({ + useT: () => ({ t: (key: string) => key }), + keys: { ui: {} }, +})); + +import { AuthCardShell } from './AuthCardShell'; + +describe('AuthCardShell', () => { + it('renders the aside beside the card in the dark split', () => { + const { container } = render( + One admin surface

}> +
+ , + ); + expect(screen.getByText('One admin surface')).toBeInTheDocument(); + expect(screen.getByRole('form', { name: 'Sign in' })).toBeInTheDocument(); + expect(container.querySelector('.bg-landing-bg')).not.toBeNull(); + }); + + it('renders the aside on a light column in the light split', () => { + const { container } = render( + Create your account

} width="lg"> + +
, + ); + expect(screen.getByText('Create your account')).toBeInTheDocument(); + expect(container.querySelector('.bg-landing-bg')).toBeNull(); + expect(container.querySelector('.max-w-lg')).not.toBeNull(); + }); + + it('keeps the centred card as the default, brand lockup included', () => { + const { container } = render( + + + , + ); + expect(screen.getByText('Acme')).toBeInTheDocument(); + expect(container.querySelector('.bg-landing-bg')).toBeNull(); + }); +}); diff --git a/packages/ui/src/layouts/AuthCardShell.tsx b/packages/ui/src/layouts/AuthCardShell.tsx index df92cb5a..92a56b03 100644 --- a/packages/ui/src/layouts/AuthCardShell.tsx +++ b/packages/ui/src/layouts/AuthCardShell.tsx @@ -6,44 +6,103 @@ import { BrandingMark } from '../components/BrandingMark'; import { BRAND_ACCENT, BRAND_DEFAULT_APP_NAME, BRAND_TECH } from '../lib/brand'; import type { SharedProps } from '../types'; +interface AuthCardShellProps { + children: React.ReactNode; + /** + * `card` centres one glass card (forgot, verify, Keycloak). The two splits + * put a column of context beside the form: `split-dark` is the brand pitch + * on near-black (sign in), `split-light` an intro on the page surface + * (register, accept invite). Both stack the column above the card below `lg`. + */ + variant?: 'card' | 'split-dark' | 'split-light'; + /** Content of the split column. Ignored by the `card` variant. */ + aside?: React.ReactNode; + /** Card width — `lg` for the longer forms (register, invite). */ + width?: 'md' | 'lg'; +} + /** - * Full-viewport centered shell for unauthenticated flows + * Full-viewport shell for unauthenticated flows * (login, register, password reset, email verify, invite accept). * * Light surface with emerald mesh blobs and a glass card — matches the - * SimpleModulePython HiFi auth screens. + * SimpleModulePython HiFi auth screens. Surfaces are semantic tokens rather + * than literal whites, so the dark theme applies here too. */ -export function AuthCardShell({ children }: { children: React.ReactNode }) { +export function AuthCardShell({ + children, + variant = 'card', + aside, + width = 'md', +}: AuthCardShellProps) { const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; const appName = branding?.appName ?? BRAND_DEFAULT_APP_NAME; const logoUrl = branding?.logoUrl ?? null; + const widthClass = width === 'lg' ? 'max-w-lg' : 'max-w-md'; + + // Absolute so the banner spans the shell's full width without the centring + // flex column shrinking it to the card's width. + const banner = ( +
+ +
+ ); + + if (variant === 'card') { + return ( +
+ + {banner} +
+ ); + } + + const dark = variant === 'split-dark'; return ( -
+
- {/* Absolute so the banner spans the shell's full width without the - centring flex column shrinking it to the card's width. */} -
- -
-
diff --git a/packages/ui/src/layouts/AuthSplitAside.test.tsx b/packages/ui/src/layouts/AuthSplitAside.test.tsx new file mode 100644 index 00000000..e35d2bec --- /dev/null +++ b/packages/ui/src/layouts/AuthSplitAside.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { branding: { appName: 'Acme', logoUrl: null, logoDarkUrl: null } } }), +})); + +import { AuthSplitAside } from './AuthSplitAside'; + +describe('AuthSplitAside', () => { + it('renders the lockup, the pitch, every check and the copyright', () => { + render( + , + ); + expect(screen.getAllByText('Acme').length).toBeGreaterThan(0); + expect( + screen.getByRole('heading', { name: 'One admin surface for every module you install.' }), + ).toBeInTheDocument(); + expect(screen.getByText('Users, permissions, settings, files.')).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(2); + expect(screen.getByText(new RegExp(`© ${new Date().getFullYear()} Acme`))).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/layouts/AuthSplitAside.tsx b/packages/ui/src/layouts/AuthSplitAside.tsx new file mode 100644 index 00000000..fba0fbad --- /dev/null +++ b/packages/ui/src/layouts/AuthSplitAside.tsx @@ -0,0 +1,60 @@ +import { usePage } from '@inertiajs/react'; +import { Check } from 'lucide-react'; +import type React from 'react'; +import { BrandingMark } from '../components/BrandingMark'; +import { BRAND_ACCENT, BRAND_DEFAULT_APP_NAME, darkSurfaceLogo } from '../lib/brand'; +import type { SharedProps } from '../types'; + +/** Stable for the lifetime of the bundle — the year only matters at page load. */ +const YEAR = new Date().getFullYear(); + +interface AuthSplitAsideProps { + /** The one sentence the product leads with. */ + heading: string; + body: string; + /** Short reassurances, each rendered as a ticked row. */ + checks: string[]; +} + +/** + * The dark column beside the sign-in card. + * + * Copy is passed in rather than held here: this is the shape of the column — + * lockup at the top, pitch in the middle, copyright pinned to the foot — and + * the words belong to the page's own catalog. + */ +export function AuthSplitAside({ heading, body, checks }: AuthSplitAsideProps): React.ReactElement { + const { branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps; + const appName = branding?.appName ?? BRAND_DEFAULT_APP_NAME; + + return ( +
+
+ +
+ +
+

+ {heading} +

+

{body}

+
    + {checks.map((check) => ( +
  • +
  • + ))} +
+
+ + {`© ${YEAR} ${appName}`} +
+ ); +} diff --git a/packages/ui/src/layouts/AuthenticatedLayout.tsx b/packages/ui/src/layouts/AuthenticatedLayout.tsx index 879cdeef..a823ed79 100644 --- a/packages/ui/src/layouts/AuthenticatedLayout.tsx +++ b/packages/ui/src/layouts/AuthenticatedLayout.tsx @@ -9,9 +9,9 @@ const THEME = { export function AuthenticatedLayout({ children }: { children: React.ReactNode }) { return ( - // Locale lives in the topbar now, beside search — the sidebar header slot - // put a language control directly under the wordmark, where it read as - // part of the branding rather than as a setting. + // Locale lives in the topbar beside search (and in the drawer footer on + // phones) — the sidebar header slot put a language control directly under + // the wordmark, where it read as part of the branding rather than a setting. {children} diff --git a/packages/ui/src/layouts/MobileBar.test.tsx b/packages/ui/src/layouts/MobileBar.test.tsx new file mode 100644 index 00000000..84a5bef9 --- /dev/null +++ b/packages/ui/src/layouts/MobileBar.test.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +const URL = '/admin/background-tasks/7'; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ url: URL, props: {} }), + Link: ({ + href, + children, + ...rest + }: { href: string; children: React.ReactNode } & Record) => ( +
+ {children} + + ), +})); + +vi.mock('@simple-module-py/i18n', () => { + const labels: Record = { + 'ui.sidebar.open': 'Open sidebar', + 'ui.sidebar.back': 'Back', + }; + return { + useT: () => ({ t: (key: string) => labels[key] ?? key }), + keys: { ui: { sidebar: { open: 'ui.sidebar.open', back: 'ui.sidebar.back' } } }, + }; +}); + +import { PageHeadingProvider, useReportPageHeading } from '../components/page-heading'; +import { MobileBar } from './MobileBar'; +import { DEFAULT_SIDEBAR_THEME } from './sidebar-theme'; + +const THEME = { ...DEFAULT_SIDEBAR_THEME, mobileTitleLabel: 'SimpleModule' }; +const USER = { name: 'Ada Rowe', email: 'ada@example.com', roles: ['admin'] }; + +function Page({ heading }: { heading: Parameters[0] }) { + useReportPageHeading(heading as never); + return null; +} + +function bar(extra: Partial> = {}) { + return ( + {}} + {...extra} + /> + ); +} + +describe('MobileBar', () => { + it('falls back to the app name and the hamburger before a page reports', () => { + const onOpen = vi.fn(); + render({bar({ onOpen })}); + expect(screen.getByText('Acme Admin')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' })); + expect(onOpen).toHaveBeenCalledTimes(1); + }); + + it('shows the page title and the user’s initials', () => { + render( + + + {bar()} + , + ); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('AR')).toBeInTheDocument(); + expect(screen.queryByText('Acme Admin')).toBeNull(); + }); + + it('swaps the hamburger for a back link when the page declares one', () => { + render( + + + {bar()} + , + ); + expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute( + 'href', + '/admin/background-tasks', + ); + expect(screen.queryByRole('button', { name: 'Open sidebar' })).toBeNull(); + expect(screen.getByText('generate_thumbnail')).toHaveClass('font-mono'); + }); + + it('renders the page’s mobile action instead of the avatar', () => { + render( + + + {bar()} + , + ); + expect(screen.getByRole('link', { name: '+ Add' })).toHaveAttribute('href', '/admin/users/add'); + expect(screen.queryByText('AR')).toBeNull(); + }); + + it('runs an onClick mobile action', () => { + const onClick = vi.fn(); + render( + + + {bar()} + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Upload' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ui/src/layouts/MobileBar.tsx b/packages/ui/src/layouts/MobileBar.tsx new file mode 100644 index 00000000..0934b5db --- /dev/null +++ b/packages/ui/src/layouts/MobileBar.tsx @@ -0,0 +1,111 @@ +import { Link } from '@inertiajs/react'; +import { keys, useT } from '@simple-module-py/i18n'; +import { Button } from '@simple-module-py/ui/components/ui/button'; +import { ChevronLeft, Menu } from 'lucide-react'; +import type React from 'react'; +import { type PageMobileAction, usePageChrome } from '../components/page-heading'; +import { initials } from '../lib/initials'; +import type { SidebarTheme } from './sidebar-theme'; +import { SIDEBAR_ICON_FOCUS } from './sidebar-theme'; + +interface MobileBarProps { + theme: SidebarTheme; + /** Shown while no page has reported a title yet. */ + appName: string; + currentUrl: string; + user?: { name: string; email: string } | null; + onOpen: () => void; +} + +// 44px is the phone hit-target floor; the bar itself is 56px, so the controls +// have room to meet it without the row growing. +const TAP = 'min-h-11 min-w-11'; + +function ActionSlot({ action }: { action: PageMobileAction }) { + const className = `${TAP} inline-flex items-center justify-center rounded-lg px-2 text-[13.5px] font-medium text-primary-300 transition-colors hover:text-white ${SIDEBAR_ICON_FOCUS}`; + if (action.href) { + return ( + + {action.label} + + ); + } + return ( + + ); +} + +/** + * The bar above every app screen on a phone. + * + * Split out of `SidebarLayout` to keep that file within the repo's 300-line + * cap. It carries what the deck's phone frames carry and nothing else: a way + * back (the drawer, or the page's own parent), the page's name, and the one + * action that page considers primary. The brand lives in the drawer header — + * repeating it here spent the only line of text on the screen restating what + * the user already knows they are inside of. + */ +export function MobileBar({ + theme, + appName, + currentUrl, + user, + onOpen, +}: MobileBarProps): React.ReactElement { + const { t } = useT(); + const chrome = usePageChrome(currentUrl); + const action = chrome?.mobileAction; + + return ( +
+ {chrome?.back ? ( + +
+ ); +} diff --git a/packages/ui/src/layouts/SidebarLayout.tsx b/packages/ui/src/layouts/SidebarLayout.tsx index ada336ae..b1971e54 100644 --- a/packages/ui/src/layouts/SidebarLayout.tsx +++ b/packages/ui/src/layouts/SidebarLayout.tsx @@ -7,6 +7,7 @@ import { TooltipProvider, TooltipTrigger, } from '@simple-module-py/ui/components/ui/tooltip'; +import { X } from 'lucide-react'; import type React from 'react'; import { useMemo, useState } from 'react'; import { AppTopbar, activeSection, findSection } from '../components/AppTopbar'; @@ -20,8 +21,9 @@ import { PageHeadingProvider, usePageSection } from '../components/page-heading' import { darkSurfaceLogo } from '../lib/brand'; import type { MenuItem, SharedProps } from '../types'; import { AdminSectionLink } from './AdminSectionLink'; +import { MobileBar } from './MobileBar'; import { SidebarUserMenu } from './SidebarUserMenu'; -import { DEFAULT_SIDEBAR_THEME, type SidebarTheme } from './sidebar-theme'; +import { DEFAULT_SIDEBAR_THEME, SIDEBAR_ICON_FOCUS, type SidebarTheme } from './sidebar-theme'; // A stable reference for "no items" — `menus?.[key] ?? []` would otherwise // mint a fresh empty array every render, and that array flows into @@ -29,12 +31,9 @@ import { DEFAULT_SIDEBAR_THEME, type SidebarTheme } from './sidebar-theme'; // render where a menu is absent instead of only when its contents change. const NO_ITEMS: MenuItem[] = []; -// The sidebar is near-black in every theme, where Button's default -// `ring-ring/50` is effectively invisible — these icon-only toggles are -// reachable by keyboard, so they get a light ring that actually shows -// (WCAG 2.4.7). Text links beside them fall back to the UA outline, which -// already reads on this surface. -const ICON_BUTTON_FOCUS = 'focus-visible:ring-white/70 focus-visible:border-white/70'; +// Phones get 44px rows; the desktop sidebar keeps the deck's tighter 40px. +const NAV_ROW = + 'flex items-center gap-3 min-h-11 lg:min-h-0 px-3 py-2.5 rounded-lg text-[15px] font-medium transition-all duration-150'; function groupMenuItems(items: MenuItem[]): { group: string; items: MenuItem[] }[] { const groups: { group: string; items: MenuItem[] }[] = []; @@ -123,46 +122,13 @@ function SidebarShell({ children, menuKey, theme, headerSlot, footerNavSlot }: S instead of a hardcoded `h-14`, so they cannot drift out of sync with each other or with pages that subtract it to fill the viewport. */}
- {/* Mobile top bar */} -
- - - - - {/* The topbar that normally carries this is desktop-only, so the - mobile bar keeps the locale control rather than losing it. */} -
- -
-
+ setSidebarOpen(true)} + /> {/* Mobile overlay */} {sidebarOpen && ( @@ -183,10 +149,20 @@ function SidebarShell({ children, menuKey, theme, headerSlot, footerNavSlot }: S {/* Sidebar */}