diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index bdf40f67..d8a2b0e7 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -829,3 +829,22 @@ Reviewer independently verified (cross-task integration checks a per-task gate s KIOSK K2b EXECUTION COMPLETE — 16 commits 094d447..9cfa7fd on branch claude/idento-kiosk-desktop-app-k2b (spec + plan + 5 tasks, 3 embedded fix rounds [Task1 inherited-manual sanitization, Task3 wrong-query retry bug, Task4 two Rust compile errors] + 1 self-initiated cross-cutting fix [Task5's api.ts 401 interceptor] + 1 final-review fix wave [stranded-lockdown chain + silent-failure logging], all reviews Approved). REMAINING OPEN ITEM before relying on this in production: live window-lockdown behavior (fullscreen taking effect, close genuinely blocked including OS-specific quit paths like macOS Cmd+Q which route through CloseRequested but were never exercised on real hardware, always-on-top/taskbar under a real WM) has never been exercised outside this sandboxed environment -- same class of gap already flagged and accepted for K3a's sidecar lifecycle and K3b's update lifecycle. This closes out the full kiosk-desktop initiative's originally-scoped self-service half (K2a covered staffed-run) -- all of K1/K2a/K2b/K3a/K3b are now implemented. === KIOSK K2b PR #105 CODERABBIT REVIEW ROUND === CodeRabbit found 4 actionable items + 1 nitpick, all verified genuine against actual source before acting (not blindly implemented, one partially declined with reasoning posted): (1) lib.rs's on_window_event only covers WindowEvent::CloseRequested -- macOS Cmd+Q and other app-level "quit the whole app" paths raise RunEvent::ExitRequested directly, never routing through a window's CloseRequested at all, so the lockdown close-guard had a real gap for exactly the OS-level quit paths the K2b final review's own recommendation #4 had flagged as needing real-hardware verification -- turns out the CODE itself never even attempted to cover this path. FIXED: added a RunEvent::ExitRequested arm to lib.rs's run() closure with the same fail-open LockdownState check, calling api.prevent_exit(). Verified safe against K3b's auto-update restart BEFORE writing the fix (not after): ExitRequestApi::prevent_exit()'s own vendored tauri-2.11.5 source has a built-in guard ("This is ignored when using AppHandle#method.restart") checking the exit code against RESTART_EXIT_CODE, so calling it unconditionally while locked can never block install_update's request_restart() flow -- confirmed via direct source read, not assumed. (2) commands.rs's exit_lockdown used a `?` chain that abandoned the remaining 3 window-property restorations after the first failure. FIXED the genuine bug (now attempts all 4 regardless, accumulates errors) but DECLINED the specific suggestion to only clear LockdownState after full success -- reasoning posted on the review thread: that would reintroduce a worse trap than the one it fixes (close-guard stays armed even if some properties already visually restored), directly contradicting the flag-ordering design the K2b final review had already scrutinized and explicitly credited as correct. (3) AgentLifecycle.tsx's boot-time stale-lockdown release (added in the final-review fix wave) could race a GENUINE SelfServicePage mount if the webview reloads while ALREADY on /checkin/:eventId/self (a scenario neither the original final-review fix nor its re-verification pass had considered -- both had only reasoned about the redirect-to-/login case). FIXED with a route guard (regex-tested against location.pathname, read once at mount via useLocation() -- AgentLifecycle confirmed already rendered inside in App.tsx) that skips the release entirely when the boot itself lands on the self-service route. (4) SelfService.tsx's scanEnabled didn't include agentHealth.data, so a wedge scan could still reach a live check-in while the "station unavailable" banner was showing -- unlike staffed Run.tsx (which has the identical gap but an operator present to notice), self-service has nobody watching. FIXED by adding Boolean(agentHealth.data) to the gate; deliberately NOT backported to Run.tsx (out of this branch's scope, K2a already shipped/reviewed). Nitpick (fixed, judged worth it given hours-long unattended sessions): AttractScreen's drift animation drove a per-frame setState (~60 rerenders/sec of the whole idle subtree including BrandSlot/LanguageToggle) -- moved to a direct DOM ref write, bypassing React's render cycle entirely. Full re-verification: cargo build clean, cargo test 21/21, typecheck/lint/build clean, 82/82 desktop tests. Fix commit 01f2acc. + +P6.1 Task 1: complete (bc391ff..4bbf479, review Spec ✅ + Approved; note: implementer hit RTK lint-masking hazard, misreported lint — reviewer verified lint green at HEAD; minor noted: no SSR-fallback test) +P6.1 Task 2: complete (4bbf479..6d26ddc, review Spec ✅ + Approved; minors noted: TabBarItemProps no HTMLAttributes spread, testid in prod markup [plan-mandated]) +P6.1 Task 3: complete (6d26ddc..96684c1, review Spec ✅ + Approved; minors noted for final review: optimistic copied-state w/o clipboard .catch [plan-mandated], no type=button on copy Button [pre-existing pattern], re-click no timer reset, single-flavor test coverage) +P6.1 Task 4: complete (96684c1..5aa7f04, review Spec ✅ + Approved; RU glossary fix gateCheckinTitle→'Станция регистрации' [brief-authorized, verified]; minor: report key miscount 17 vs 16) +P6.1 Task 5: complete (5aa7f04..fccdf75, review Spec ✅ + Approved; all 7 routes gated w/ correct flavors, /team+monitor+staff ungated verified; flaky StaffZonesDialog Escape-race noted as PRE-EXISTING [stash-verified unrelated]; minors: report math slip, review-package tail truncation [tooling]) +P6.1 Task 6: complete (fccdf75..8757654 incl. fix round 1, review Spec ✅ + Approved after fix; fix added sheet-close test + exact:true on all tab links; minors carried to final review: MoreSheet row duplication, unrolled tab links, Monitor/Attendees aria-current not directly asserted, comment placement nit) +P6.1 Task 7: complete (8757654..b121811, review Spec ✅ + Approved, zero findings) +P6.1 Task 8: complete (b121811..9e0d5ff, review Spec ✅ + Approved; no test scoping needed [verified independently]; full-suite load flakiness reproduced pre-existing w/ diff stashed; minor: redundant grid comments [plan-mandated]) +P6.1 Task 9: complete (9e0d5ff..cc09695, review Spec ✅ + Approved; zero-collision claim verified against full test read; minor: Circle icon shared by skipped/not_done — MinusCircle would match ReadinessCell precedent) + +=== PANEL P6.1 — MOBILE COMPANION FOUNDATIONS & SHELL (plan docs/superpowers/plans/2026-07-22-panel-p6.1-mobile-foundations.md, branch feature/panel-p6.1-mobile-foundations) === +Tasks 1–9 executed subagent-per-task with per-task spec+quality reviews (entries above): useIsMobile+AGENTS rules (4bbf479) → ui TabBar (6d26ddc) → ui DesktopOnlyGate (96684c1) → panel DesktopOnly wrapper+gate EN/RU (5aa7f04) → Tier-3 route gates (fccdf75) → EventTabBar/MoreSheet + ui bottom-sheet variant (54389d2 + fix 8757654: sheet-close test, exact tab-link matching) → workspace phone shell + viewport-fit (b121811) → monitor interim stack (9e0d5ff) → ReadinessStrip + Home padding (cc09695). +Task 10 final sweep: gates green — packages/ui 334/334; panel 1526 pass (2 pre-existing load flakes in UNTOUCHED files, AttendeeDrawer + BadgeEditorPage in-flight timing tests, both 103/103 green in isolation); typecheck clean; lint clean (verified via rtk proxy — the plain run reproduced the documented RTK masking artifact); build clean; PWA-lite verified pre-existing (manifest.json + icons in dist/, landed with logo refresh PR #94 — no manifest work this phase). +LIVE 390px WALK (real stack: docker compose db + Go backend + vite dev server, admin@test.com seed): login/home/overview+strip/staff light+dark all fit; monitor phone stack live over SSE (LIVE pill, station staleness dot); attendees + badge gates render correct flavors with working "Link copied" and Back to Overview; More sheet lists desktop sections and closes on row tap; desktop width restores rail/top-nav/real badge editor on the SAME url. WALK FOUND 1 REAL BUG: AppShell header min-content was 452px > 390 (org switcher + ON-PREM tag + gap-4), inflating the mobile layout viewport and dragging the fixed tab bar wide — fixed in a2f3129 (gap-2 md:gap-4, version tag hidden below sm, truncating org name), re-verified live at coherent 390: header/tab bar/scrollWidth all exactly 390, zero horizontal overflow. Note: the browser-pane emulation does not dispatch matchMedia change events, so the live resize flip needed a reload — the flip itself is unit-tested with real listener events (useIsMobile.test). +Carry-forwards: attendees gate TEMPORARY until P6.3 search list; monitor stack INTERIM until P6.2 board-8f layout; staff "signed in" liveness dropped from v1 (not in API contract). Minor findings parked for final review triage: DesktopOnlyGate optimistic copied-state w/o clipboard .catch + no type=button (pre-existing pattern); MoreSheet row markup duplication; unrolled tab links; Monitor/Attendees aria-current not directly asserted; ReadinessStrip shares Circle icon for skipped/not_done (MinusCircle would match ReadinessCell); T6 comment placement nit. +=== P6.1 FINAL WHOLE-BRANCH REVIEW (fable, 094d447..bb84eb5) — READY TO MERGE: YES, 0 Critical / 0 Important / 6 new Minor (all follow-up) === +Reviewer independently re-ran all new + adjacent suites (25/25 panel-new, 5/5 ui-new, 37/37 adjacent) and re-checked route-guard interplay (checkin beforeLoad → launch gate: one coherent gate, no loop). All 8 parked per-task minors triaged FINE-AS-FOLLOW-UP; highest-priority follow-up for P6.2 first commit: DesktopOnlyGate clipboard .catch (on-prem plain-http serves navigator.clipboard=undefined → silent no-op yet "copied" announced) + ReadinessStrip MinusCircle; when first TabBar badge consumer lands (P6.2), add sr-only badge label — dot must not ship sighted-only. New minors also noted: MoreSheet survives phone→desktop crossing while open; gate h2-without-h1 on org-level routes (P6.4 a11y sweep); `sm` disclosure in AppShell version tag is space-driven, not a second cutover (clarify in AGENTS.md some day). +P6.1 PR #106 bot review round 1: CodeRabbit 7 actionable + 2 nitpicks. 8 fixed in 382a69e (clipboard tri-state w/ copyFailedLabel prop + gateCopyFailed EN/RU, 44px bottom-sheet close, badge→sr-only string, md version-tag cutoff, aria-expanded, MinusCircle skipped, isError&&!data retain-last-known-good, typed test fixture); 1 declined w/ reason (STEP_LABEL_KEYS→shared move = standalone refactor, pre-existing pattern); badge visible-indicator part declined per board 8r. Gates green (ui 335, panel 1526, typecheck, lint). All 7 threads replied, single push. diff --git a/docs/design-briefs/customer-web-panel-mobile.md b/docs/design-briefs/customer-web-panel-mobile.md new file mode 100644 index 00000000..f4216adf --- /dev/null +++ b/docs/design-briefs/customer-web-panel-mobile.md @@ -0,0 +1,118 @@ +# Design Brief: Idento Panel — Mobile Companion (Adaptive Phone Surface) + +**Date:** 2026-07-20 +**Scope:** the phone-adaptive layer of the customer panel (`panel/`) — a curated subset of the existing app made first-class on ~390px screens, inside the same SPA, same routes, same auth. Not a separate app; not the KMP station app. Anticipated by [customer-web-panel.md](customer-web-panel.md) §4 ("Responsive stance") and extends it: the drawer-and-tablet stance grows a real phone surface for on-the-go management. +**Inputs:** panel functional map (`../../panel/src/app/router.tsx`, feature slices), master spec [2026-07-20-panel-p6-mobile-companion-design.md](../superpowers/specs/2026-07-20-panel-p6-mobile-companion-design.md), monitor board 7e + drawer board 7f in [Idento Panel.dc.html](idento-event-check-in-landing/project/), KMP app scope (`../../mobile/shared`), logo handoff ([design_handoff_idento_logo/README.md](design_handoff_idento_logo/README.md)), `backend/openapi.yaml`. + +## 1. Project Overview + +**Product:** the organizer's pocket command center. The Idento panel runs the full event lifecycle on desktop; this brief designs the subset that must work *away from the desk* — glanceable live monitoring, people lookup with exceptional actions, staff login handout, and station provisioning — as adaptive phone layouts of the existing pages. Everything else stays desktop and must *say so gracefully*. + +**The one-sentence pitch:** on event day the organizer walks the venue with the panel in their pocket — live pulse, dead-station alerts, "is Ivanov checked in?", "here's your login QR", "spin up another station" — without ever wishing they'd brought the laptop for those jobs. + +**Relationship to sibling surfaces:** the KMP mobile app remains the dedicated station scanner (registration / zone control / kiosk) — this surface never scans attendees. The desktop panel remains the authoring tool (badge editor, CSV import, zone rules, equipment, settings). The tablet monitor (board 7e) already exists; the phone monitor is its vertical sibling. + +## 2. Users & Context of Use + +- **Organizer/admin on the venue floor (primary).** Standing, walking, one hand, bright and noisy environment, venue Wi-Fi or cellular. Checks numbers between conversations; acts in bursts of seconds. Interruptions are constant — every screen must survive being glanced at for two seconds and abandoned mid-flow. +- **Organizer pre-event, out of office (secondary).** Commute / between meetings: "what's still not ready?", "how many registered overnight?" Read-mostly, calm. +- **Manager across events/orgs (secondary).** Home list, org switcher, dive into any event's overview or monitor. +- **Roles:** `admin` and `manager` see management actions; `staff` on a phone browser sees only what they see on desktop today (their surfaces live in the KMP app). Role comes per-tenant (`GET /api/tenants/{id}`), same gating as desktop. +- **Not served here:** scanning attendees, badge printing, any desk-tempo authoring. + +## 3. Core Objects & Information Architecture + +Objects unchanged (Event, Attendee, Zone, Team member/Staff, Station). The mobile IA is a **tiered cut** of the desktop IA: + +- **Tier 1 — first-class phone surfaces:** Login + QR login; Home (LiveStrip hero + event lists); Event overview (readiness + stats); Live monitor; Attendee quick lookup (search-first list + attendee card with actions); Staff (cards, assign, full-screen QR login token); Station provisioning ("Add station" → full-screen QR); shell chrome (org switcher, theme, language, impersonation banner, suspended screen). +- **Tier 2 — usable-but-basic (reflow only, no dedicated design):** Organization page, New-event dialog, event general info (read-only), all empty/error/loading states. +- **Tier 3 — desktop-only, gated:** badge editor, CSV import, Equipment hub (needs the local agent on `localhost:12345` — physically unreachable from a phone), zone rule builder, full Event Settings, check-in Launch + Station, Team page. + +**Phone navigation model:** +- Org level: existing hamburger → drawer (board 7f) — unchanged. +- **Event workspace: bottom tab bar** (recommended; see Open Questions) — Overview / Monitor / Attendees / Staff / More. "More" opens a sheet listing Tier-3 sections with their gate states plus event info. The desktop rail's readiness chips become a compact pipeline strip on Overview. +- Same URLs everywhere. A deep link to a Tier-3 page on a phone lands on its gate screen, never a broken layout. + +## 4. Key Design Problems to Solve + +- **Glanceability under motion.** The monitor and LiveStrip must read at arm's length while walking: one number per card at XXL, rate/peak/ETA as secondary, per-zone and per-station as scannable rows. Reuse board 7e's hierarchy, restacked for portrait 390px. +- **One-hand reach.** Primary loop (overview ↔ monitor ↔ lookup) lives in the bottom tab bar; destructive/rare actions stay out of thumb's accidental reach. All touch targets ≥ 44×44px without inflating desktop density (`pointer: coarse` scoped). +- **Table → search-first list.** The attendees table (7 fixed columns, bulk bar, 400px drawer) cannot reflow. Design its phone sibling as search-first: prominent search field, result rows (name, company, status pill), full-screen attendee card. No bulk operations, no import, no column editing on phone — by design, not omission. +- **Exceptional actions with ceremony, not friction.** Attendee card actions — manual check-in (with explicit "no badge printed" notice), undo check-in, block/unblock — are one tap + one confirm, using the existing ConfirmDialog severity language. These are floor-emergency tools; they must be fast but never accidental. +- **QR as a first-class screen.** Two full-screen QR moments: staff login token (staffer scans the organizer's phone with the KMP app) and station provisioning token (station device scans it). Design: max-brightness-friendly white canvas, the QR at maximum size, who/what it's for, TTL countdown, regenerate action. This flow replaces desktop's "print the QR" ritual and should feel like the *better* way. +- **The honest gate.** Tier-3 pages on a phone get a real screen, not a dead end: what this section does, why it's desktop-only (one line — e.g. "the badge editor needs a large canvas and your printer's local agent"), and "copy link for desktop". It must feel like a considered product decision, not a wall. +- **Live-data honesty on venue networks.** SSE reconnect states (existing backoff + "Updated Ns ago") must be legible on mobile: a compact staleness badge pattern that never lets stale numbers masquerade as live. +- **Interruption-proof flows.** Every multi-step phone flow (assign staff, mint QR) survives backgrounding the browser; on return, state is either intact or safely restarted — never half-committed. + +## 5. Deep-Dive: the Five Signature Phone Surfaces + +1. **Home.** LiveStrip as hero card for the running event (checked-in/total, progress, per-zone chips) → Upcoming → Past as stacked rows. New-event stays a dialog (it's small). Empty states teach, as on desktop. +2. **Event overview.** Readiness pipeline strip (attendees → badge → zones → staff → equipment as compact chips; done/not-done/skipped) + "what's next" card + 2×2 stat tiles. This is the pre-event glance screen. +3. **Monitor (phone).** Vertical stack: TotalsCard (XXL checked-in/total, rate/min, peak, ETA) → ZonesCard rows → StationsCard rows with liveness dots (`last_seen_at` math exists) → RecentFeed (last 20). Chrome-less feel, staleness badge top-right. Portrait sibling of board 7e. +4. **Attendee lookup & card.** Search-first list; card shows identity, status (checked-in + timestamp / not / blocked), zone access, and the action row (check-in / undo / block / unblock / show attendee QR). Verdict semantics share the app-wide status language (same tokens as station verdicts). +5. **Staff & provisioning.** Staff cards (name, role, zones, status) with assign/unassign via bottom-sheet picker of team members and zone picker. Per-staffer "Show login QR" → full-screen QR. Separate "Add station" entry (from Staff's More or Overview quick action) → provisioning QR with TTL. Both QR screens share one `QrDisplay` pattern. + +## 6. Visual Identity + +- **Same brand, same tokens — no mobile fork.** All colors from `packages/ui/src/theme.css` variables (primary `#00935e`, success/warning/info families, light + dark). Phone layouts must work in both themes; check AA contrast outdoors-bright (light) and low-light (dark). +- **Logo:** use the new handoff ([design_handoff_idento_logo/README.md](design_handoff_idento_logo/README.md)) — mark simplification tiers by size apply directly (24–39px drops the lanyard slot; ≤20px filled tile); PWA icons come from `assets/app-icon-*.svg`. Never center the mark by bounding box — align by the badge rect. +- **Typography:** Inter Variable, existing type ramp (`text-page-title` … `text-code`); monitor numbers may use an XXL display size consistent with board 7e rather than a new ramp entry — if a new token is needed, add it to the ramp, don't inline it. +- **Status language:** the shared icon+text+color status/verdict vocabulary — never color alone, no emoji; `StatusPill` everywhere. +- **Density:** glance surfaces (monitor, LiveStrip) run large and airy; lists run comfortable (48–56px rows); nothing on phone uses desktop-compact table density. +- **Language:** full EN/RU parity via i18n keys, as enforced repo-wide. + +## 7. Technical & UX Constraints + +- Stack fixed: React 19 + TanStack Router (code-based routes — same routes, no `/m/*` subtree) + TanStack Query + generated OpenAPI client; Tailwind v4 CSS-first tokens; **`@idento/ui` primitives only — native form controls are ESLint-banned**; lucide icons; i18next. +- **Zero new backend endpoints.** Everything is served by the existing contract (`stats`, `monitor` + SSE stream, `readiness`, attendee search/block/unblock, checkin + undo, event/zone staff, `users/{id}/qr-token`, `stations/provisioning-token`). Designs must not assume data the contract doesn't return. +- Adaptive mechanics: Tailwind reflow first; one sanctioned `useIsMobile()` for component swaps (attendees table ↔ search list); `DesktopOnlyGate` for Tier 3. Cutover at `md` (768px); reference frame 390×844. +- QR rendered locally, never via third-party chart APIs (house rule). +- Safe areas: bottom tab bar and full-screen QR respect `env(safe-area-inset-*)`; test iOS Safari toolbar-collapse behavior. +- SSE: reuse `openSseStream` backoff; no polling fallback exists — design the reconnect/stale states, don't invent transport. +- Accessibility: WCAG AA; ≥44px touch targets; `aria-live` on live counters and action results; dialogs/sheets from the existing primitives (focus trap, Escape). +- New primitives expected (confirm against boards): `TabBar`, `DesktopOnlyGate`, `QrDisplay`, list-row. They live in `@idento/ui` with light+dark and tests, per house rules. +- PWA-lite: manifest + icons only; **no service worker/offline in v1** (live-data surface; offline is the KMP app's job). + +## 8. Success Metrics + +- From unlocking the phone to live monitor numbers: **≤ 10 seconds, ≤ 3 taps** (installed PWA, active session). +- Attendee question answered ("is X checked in?") in **≤ 2 taps + typing** from anywhere in the event workspace. +- Staff login handout via on-screen QR succeeds on the first scan by the KMP app; no printed QR needed on event day. +- **Zero horizontal scrolling** at 390px anywhere in the app — every route is either adapted or gated. +- Zero accidental destructive actions on touch (confirm ceremony holds); manual check-in never silently skips the badge notice. +- Both themes pass AA on all new layouts; e2e mobile-viewport suite green in CI. + +## 9. Deliverables + +1. **Design boards** (same pipeline/format as `Idento Panel.dc.html`), phone frame 390×844, light + dark where treatment differs: + - Home (running-event hero + lists), Event overview (pipeline strip + tiles) + - Monitor phone stack + - Attendee search list + attendee card (+ action confirms incl. "no badge printed") + - Staff cards + assign bottom sheet + full-screen staff QR + - "Add station" provisioning QR (TTL, regenerate) + - Workspace bottom tab bar + "More" sheet + - `DesktopOnlyGate` screen (one per flavor: canvas-tool / agent-bound / bulk-data) + - Shell states on phone: impersonation banner, suspended screen, SSE-stale badge +2. **Primitive specs** for `TabBar`, `DesktopOnlyGate`, `QrDisplay`, list-row (anatomy, states, tokens). +3. **Adaptive rules note**: per-Tier-1-page mapping desktop → phone layout (what stacks, what swaps, what hides). + +## 10. Out of Scope + +- Any change to the KMP mobile app or desktop kiosk; any scanning/badge-printing from the phone browser. +- Offline mode, service worker, push notifications (candidate for a later phase — needs backend work). +- Mobile versions of: badge editor, CSV import, equipment hub, zone rule builder, full event settings, launch ceremony/station (gated, by design). +- Tablet redesign (board 7e monitor already covers it); desktop layout changes of any kind. +- New backend endpoints or OpenAPI changes. + +## 11. Open Questions — RESOLVED (design review, 2026-07-22) + +Design boards: `Idento Panel Mobile.dc.html` (board t8, frames 8a–8t) in the "Idento event check-in landing" Claude Design project — generated from this brief. + +1. **Workspace phone nav:** → **bottom tab bar** (board 8a). Alternatives 8b (pill row) and 8c (drawer-only) explored and rejected on the boards. +2. **Manual check-in from the attendee card on phone:** → **keep**, with the structural "no badge will be printed" notice in the confirm sheet (board 8j) + a 6s undo toast. +3. **Event info on phone:** → **read-only** in v1 (READ-ONLY chip in the More sheet, board 8n). +4. **PWA installability:** → **v1** — manifest + icons from the logo handoff, no service worker. +5. **Phase numbering:** → **P6.x** (provisional; P5.2/P5.3 stay ahead). +6. **Staff self-service:** → **yes** — a `staff`-role user in a phone browser gets an own-status page + full-screen login QR, nothing more (board 8q); the KMP app remains their working surface. + +Resolved during review, beyond the original questions: attendee card is a **state-dependent hybrid** (8i for not-checked-in, 8h for checked-in); **staff sign-in liveness is dropped from v1** (not in the API contract — cards show role/zones/assignment only), which preserves zero backend changes. diff --git a/docs/superpowers/plans/2026-07-22-panel-p6.1-mobile-foundations.md b/docs/superpowers/plans/2026-07-22-panel-p6.1-mobile-foundations.md new file mode 100644 index 00000000..1ff7165e --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-panel-p6.1-mobile-foundations.md @@ -0,0 +1,1450 @@ +# Panel P6.1 — Mobile Companion Foundations & Shell — 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 every panel route honest at phone width — a bottom TabBar for the event workspace, DesktopOnlyGate on desktop-only surfaces, adaptive conventions codified — with zero horizontal scrolling at 390px. + +**Architecture:** Adaptive layouts inside the existing `panel/` SPA (spec §5: same routes, no `/m/*`, no UA sniffing). Three mechanisms in order of preference: Tailwind reflow below `md` (768px); exactly one sanctioned `useIsMobile()` for render swaps; `DesktopOnly` gates for Tier-3 routes. New presentational primitives (`TabBar`, `DesktopOnlyGate`) live in `@idento/ui`; router/i18n-aware glue lives in `panel/`. + +**Tech Stack:** React 19, TanStack Router (code-based), TanStack Query, Tailwind v4 (CSS-first tokens), `@idento/ui` (Radix + cva), react-i18next (EN/RU key parity), Vitest + Testing Library + MSW. + +**Spec:** [2026-07-20-panel-p6-mobile-companion-design.md](../specs/2026-07-20-panel-p6-mobile-companion-design.md) · Design boards: `Idento Panel Mobile.dc.html` frames 8a (tab bar), 8e (overview strip), 8n (More sheet), 8o (gates), 8r/8s (primitive specs), 8t (adaptive map). + +**Branch:** `feature/panel-p6.1-mobile-foundations` (PR to `main`; direct push to main is blocked). + +## Global Constraints + +- UI primitives come ONLY from `@idento/ui`; native form controls are ESLint-banned in `panel/` (panel/AGENTS.md). +- Every user-facing string is an i18n key added to BOTH `panel/src/shared/i18n/en.json` and `ru.json` in the same change; `keyParity.test.ts` fails otherwise. Key prefixes name the owning surface (`gate*`, `tabBar*`, `moreSheet*`). +- Colors only via theme tokens (`text-success`, `bg-warning/10`, …) — never hex literals; light/dark come free from tokens. +- Breakpoint cutover is `md` (768px) — the ONLY desktop/phone boundary. Design reference frame 390×844. No `/m/*` routes. +- Zero backend/OpenAPI changes in this phase — do not touch `backend/openapi.yaml` or `schema.d.ts`. +- Verify before finishing any panel change: `npm test -w panel && npm run typecheck -w panel && npm run lint -w panel && npm run build -w panel` from the repo root. For `packages/ui` changes also `npm test -w packages/ui`. (Bare `tsc` is NOT the panel typecheck.) +- PWA note: `panel/public/manifest.json` + `icon-192/512.png`/`maskable-512.png`/`apple-touch-icon.png` ALREADY exist (landed with the logo refresh, PR #94). No manifest work in this phase — Task 10 verifies only. + +--- + +### Task 1: `useIsMobile()` hook + adaptive conventions in AGENTS.md + +**Files:** +- Create: `panel/src/shared/hooks/useIsMobile.ts` +- Create: `panel/src/shared/hooks/useIsMobile.test.ts` +- Modify: `panel/AGENTS.md` (append a new section at the end) + +**Interfaces:** +- Consumes: nothing. +- Produces: `useIsMobile(): boolean` — true below `md` (768px), subscribes to viewport changes. Used by Task 4's `DesktopOnly` and (in P6.3) the attendees table↔list swap. + +- [ ] **Step 1: Write the failing test** + +```ts +// panel/src/shared/hooks/useIsMobile.test.ts +import { act, renderHook } from "@testing-library/react"; +import { useIsMobile } from "./useIsMobile"; + +type Listener = (event: { matches: boolean }) => void; + +function installMatchMedia(initialMatches: boolean) { + const listeners = new Set(); + let matches = initialMatches; + const mql = { + get matches() { + return matches; + }, + media: "(max-width: 767.98px)", + addEventListener: (_type: "change", listener: Listener) => { + listeners.add(listener); + }, + removeEventListener: (_type: "change", listener: Listener) => { + listeners.delete(listener); + }, + }; + vi.stubGlobal("matchMedia", vi.fn().mockReturnValue(mql)); + return { + listeners, + setMatches(next: boolean) { + matches = next; + listeners.forEach((listener) => listener({ matches: next })); + }, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useIsMobile", () => { + it("reflects the current viewport and updates when the media query flips", () => { + const media = installMatchMedia(false); + const { result } = renderHook(() => useIsMobile()); + expect(result.current).toBe(false); + act(() => media.setMatches(true)); + expect(result.current).toBe(true); + }); + + it("unsubscribes on unmount", () => { + const media = installMatchMedia(true); + const { unmount } = renderHook(() => useIsMobile()); + expect(media.listeners.size).toBe(1); + unmount(); + expect(media.listeners.size).toBe(0); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npm test -w panel -- useIsMobile` +Expected: FAIL — cannot resolve `./useIsMobile`. + +- [ ] **Step 3: Implement the hook** + +```ts +// panel/src/shared/hooks/useIsMobile.ts +import * as React from "react"; + +// Tailwind's `md` breakpoint is 768px; .98 keeps the query strictly below it +// so CSS (`md:*`) and JS agree on which side a 768px-wide viewport is on. +const MOBILE_QUERY = "(max-width: 767.98px)"; + +function subscribe(onChange: () => void) { + const mql = window.matchMedia(MOBILE_QUERY); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); +} + +function getSnapshot(): boolean { + return window.matchMedia(MOBILE_QUERY).matches; +} + +// The ONE sanctioned JS viewport check (panel/AGENTS.md "Adaptive layout"). +// Prefer Tailwind responsive classes; reach for this only at a component +// swap point where CSS cannot express the difference (a gate, a table↔list +// swap) — never in leaf components. +export function useIsMobile(): boolean { + return React.useSyncExternalStore(subscribe, getSnapshot, () => false); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npm test -w panel -- useIsMobile` +Expected: PASS (2 tests). + +- [ ] **Step 5: Append the adaptive-rules section to `panel/AGENTS.md`** + +Append at the end of the file (after the "API workflow (openapi-first)" section): + +```markdown + +## Adaptive layout (P6 mobile companion) + +- **One cutover:** phone layouts live below Tailwind `md` (768px); the design + reference frame is 390×844. Never introduce another breakpoint for + desktop-vs-phone decisions, and never add `/m/*` routes or user-agent + sniffing — the URL space is shared and adaptation happens at render time. +- **Tailwind reflow FIRST.** If a layout can stack/reflow with responsive + classes, do that — no JS. Component swaps are the exception, not the rule. +- **Exactly one sanctioned JS viewport check:** `useIsMobile()` + (`src/shared/hooks/useIsMobile.ts`), called once per swap pair at the swap + point (a route gate, a table↔list chooser) — never sprinkled through leaf + components. +- **Tier-3 surfaces gate, never break.** Desktop-only routes (badge editor, + event settings, zones, equipment, station/launch, CSV import) render the + `DesktopOnly` wrapper (`src/shared/ui/DesktopOnly.tsx`) below `md` — a + render swap on the SAME url via `@idento/ui`'s `DesktopOnlyGate`, never a + redirect: deep links land on the gate, rotating a tablet past `md` shows + the real page. Zero horizontal scrolling at 390px is the bar — every route + is adapted or gated, nothing in between. +- **Touch targets:** interactive controls on phone-only chrome are ≥44×44px + (`min-h-11`); keep the inflation inside `md:hidden` chrome or + coarse-pointer scopes so desktop density is untouched. +- **Safe area:** fixed bottom chrome pads with + `pb-[max(…,env(safe-area-inset-bottom))]`; `panel/index.html` sets + `viewport-fit=cover` to make those insets real on iOS. +``` + +- [ ] **Step 6: Commit** + +```bash +git add panel/src/shared/hooks/useIsMobile.ts panel/src/shared/hooks/useIsMobile.test.ts panel/AGENTS.md +git commit -m "panel: add useIsMobile hook + P6 adaptive-layout conventions (P6.1 T1)" +``` + +--- + +### Task 2: `@idento/ui` TabBar primitive + +**Files:** +- Create: `packages/ui/src/components/tab-bar.tsx` +- Create: `packages/ui/src/components/tab-bar.test.tsx` +- Modify: `packages/ui/src/index.ts` (add export) + +**Interfaces:** +- Consumes: `cn` from `../lib/cn`. +- Produces (board 8r): `TabBar({ label, className, children })` — a labelled `