From a41d39294ac3ec2484f35b0ad751f168a0d1ab84 Mon Sep 17 00:00:00 2001
From: Robert Hamilton
Date: Sat, 4 Apr 2026 22:34:39 -0500
Subject: [PATCH 01/17] Release v1.0.0-beta.2
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: add Authorization header to Supabase reachability check
The PostgREST root endpoint requires both `apikey` and `Authorization`
headers. Missing the latter caused a 401 during setup, showing
"Cannot reach server" even with valid credentials.
* fix: auto-validate and surface env var connection failures on setup page
When VITE_SUPABASE_URL and VITE_SUPABASE_PUB_KEY are set but invalid,
the setup page now auto-validates on mount and shows the specific error
(e.g. "Server responded with 401") instead of silently presenting a
blank form. Also adds a banner explaining that environment variables
were detected but failed.
* fix: use Supabase client for connection validation instead of raw fetch
Raw fetch with apikey/Authorization headers only works with legacy anon
keys. New publishable keys (sb_publishable_xxx) require the Supabase
client library which handles auth headers internally. This switch
supports both key formats transparently.
* fix: resolve duplicate migration timestamp collision
Two migrations shared timestamp 20260329000001, causing a primary key
violation in supabase_migrations. Renumbered create_sharing_tables to
20260329000002 and expand_rls_for_sharing to 20260329000003.
* chore: update Claude Code configuration and skills
* feat: complete sync pull path with dynamic upsert and force-pull (Step 9) (#28)
Implement the stubbed pull/upsert path so remote changes from Supabase
are actually written to local SQLite, completing bidirectional sync.
- Fix broken timestamp parsing in upsert_row (ISO 8601 strings were
silently parsed as 0, causing all remote updates to be discarded)
- Implement dynamic column upsert via pragma_table_info with type
coercion (timestamps, booleans, JSONB) between Supabase and SQLite
- Add pull_table/pull_all for paginated force-pull via Supabase REST API
- Wire sync_force_pull command with proper state transitions
- Add auto-pull on first sign-in (detect last_pull_at == 0)
- Add 28 new tests (49 total), all passing
Co-authored-by: Robert Hamilton
* feat: events & packing lists (Step 13.5) (#30)
* feat(events): add events & packing lists feature (Step 13.5)
Introduces EVENT as a new session category with full support for event
metadata (date, location, URL, requirements) and a categorized packing
checklist with drag-and-drop reorder, optimistic toggle, progress
tracking, and countdown notifications.
Database:
- Migration adds EVENT to category CHECK constraints on session_templates
and scheduled_sessions; adds event_metadata JSONB to session_templates
and workout_logs; creates event_items table with polymorphic FK
(template XOR log), RLS policies, partial indices, and updated_at trigger
Domain:
- eventRequirementSchema, eventMetadataSchema, eventItemSchema (Zod)
- EVENT added to SessionType enum; eventMetadata optional on templates
and logs; EventItemRow added to database.types.ts
Data layer:
- toEventItem/fromEventItem mappers; eventMetadata parsing in
toSessionTemplate (JSON.parse) and toWorkoutLog (direct JSONB)
- DataAdapter interface: getEventItems, saveEventItem, updateEventItem,
deleteEventItem, toggleEventItemPacked (accepts target bool, no race),
reorderEventItems (Promise.all), cloneSessionTemplate (isPacked reset)
- SupabaseAdapter implements all methods; getSessionTemplateFull fetches
eventItems for EVENT templates; updateSessionTemplateFull uses 3-way
reconciliation to preserve item UUIDs and isPacked state
- TauriAdapter stubs (event support deferred, W-8)
Hooks:
- useEventItems, useCreateEventItem, useUpdateEventItem,
useDeleteEventItem, useToggleEventItemPacked (optimistic),
useReorderEventItems (optimistic), useNextUpcomingEvent
UI:
- event-builder/: EventTemplateForm (Sheet overlay, create/edit),
EventDetail (metadata + map link + packing), PackingList (DnD,
collapsible categories, error feedback), PackingItem (checkbox toggle),
EventProgressBar (ember on surface-steel, ARIA progressbar),
EventCard (library), EventCountdownBadge (Today screen, TODAY edge
case), RequirementEditor, EventItemEditor, shared styles.ts
- Library: EventCard for EVENT templates, NEW EVENT button
- log/$workoutId: interactive EventDetail for event logs
- history/$workoutId: read-only EventDetail for event history
- Today screen: countdown badge when next event within 30 days
- Program builder: flag icon + ember accent for EVENT sessions,
event date displayed in program-preview grid and detail sections
Native (Rust):
- event_reminders channel registered in notification.rs
- EventReminderState: background Tokio task (60s poll), checks
configured intervals (7d/3d/1d), quiet hours, packing progress in
notification body, deterministic notification ID
- schedule_event_reminders / cancel_event_reminders Tauri commands
- Clippy fixes: RemindedState type alias, needless_borrow in lib.rs,
manual_range_contains in session_reminder.rs
- Lock contention fix: single mutex guard across event loop in tick()
Tasks: S001, S002, S003, S004, S005, S006, S007, S008, S009, S010, S011, S012, S013, S014
Feature: 002-events-packing-lists
* docs(planning): mark 002-events-packing-lists complete
Updates Steps.md status to Complete, session.md with full wave
execution log and fixes applied, and implementation-plan.md.
Feature: 002-events-packing-lists
* chore(rust): apply cargo fmt across codebase
* chore: add mise config and cortex hooks
* fix(events): address PR #30 review findings
- Fix timezone bug in combineDateTime: store local date strings instead
of converting to UTC, preventing event dates from shifting by a day
- Add eventUrl validation in form validate() before save
- Switch sequential item persistence to Promise.allSettled with error
collection for partial-failure reporting
- Fix DST-unsafe daysUntil calculation using date-only comparison
- Make TauriAdapter getEventItems throw consistently with write stubs
- Add "TODAY" special case in Rust event reminder notification title
- Eliminate double hydration of requirements/items in form init
- Add EventItem mapper roundtrip tests and splitDateTime/combineDateTime
edge case tests (19 new tests)
---------
Co-authored-by: Robert Hamilton
* feat(groups): coach program assignment
Adds the ability for a group coach to assign one of their programs to a
member. The program is atomically transferred to the member's ownership
with source tagged as COACH_ASSIGNED and the coach recorded as created_by,
enabling the member to filter out programs they received vs created.
Includes the two-step AssignProgramDialog UI and integrates it into
MemberCard. Both the Tauri SQLite command and the Supabase SECURITY
DEFINER RPC perform the same three authorization checks (caller is COACH,
target is MEMBER, program owned by caller) before executing the transfer.
* fix(sync): resolve incremental push breakage and missing error state
- push.rs: timestamp_millis() -> timestamp() so last_push_at is stored
in seconds, matching the updated_at column it filters against; the
millis value was always larger than any updated_at, causing zero rows
to be found on every push after the first
- sync.rs: sync_force_push now transitions to SyncState::Error on
failure, matching the sync_force_pull pattern; previously a failed
push left the engine in Idle, hiding the error from the frontend
* docs(planning): add spec, tech, and steps for coach program assignment
* feat(chat): add chat data layer + QA fixes (#33)
* feat(chat): add chat data layer (Step 21)
Builds the full persistence layer for the chat domain across Supabase,
SQLite, Rust, and TypeScript. No UI -- pure data plumbing for Steps 22-25.
- Supabase migration: conversations, conversation_participants, messages,
media_attachments with RLS (CH-1), append-only messages (CH-7), and
direct conversation uniqueness via trigger (CH-2)
- SQLite migration: mirrors Postgres with sync_status on messages for
offline queueing (pending -> synced/failed state machine)
- Rust: ConversationRow/MessageRow/etc structs, 10 Tauri commands (CRUD
for conversations, messages, participants, media), chat tables in
SYNCABLE_TABLES for push/pull sync
- TypeScript: Zod schemas (Conversation, Message, MediaAttachment,
WorkoutSnapshot), DB row types, bidirectional mappers, 13-method
ChatAdapter facet on DataAdapter interface, Supabase and Tauri
adapter implementations, mock adapter updated
Tasks: S001, S002, S003, S004, S005, S006, S007, S008, S009, S010, S011
Feature: 004-Chat-Data-Layer
* docs(planning): add spec and steps for chat data layer (Step 21)
Feature 004 planning artifacts: Spec.md covering requirements,
testable assertions, and invariants (CH-1, CH-2, CH-4, CH-7);
Steps.md with 4-wave execution plan for db-engineer, rust-engineer,
domain-engineer, and quality-engineer.
Feature: 004-Chat-Data-Layer
* fix(qa): address all QA feedback -- sync resilience, lint, and stale tests
Rust sync hardening:
- sync/mod.rs: replace `return` with `continue` so queue flush failure no longer kills the sync loop permanently; transition to Error state when initial pull fails rather than silently proceeding to push
- sync/pull.rs: add SYNCABLE_TABLES allowlist guard in upsert_row and delete_row
- sync/push.rs: add SYNCABLE_TABLES allowlist guard in push_table
- sync/queue.rs: normalize enqueue timestamp to seconds (was millis, inconsistent with push/pull)
Frontend lint:
- packing-list.tsx: replace setState-in-useEffect anti-pattern with useMemo derivation for mutation error feedback
- event-template-form.tsx: extract splitDateTime/combineDateTime to date-time-utils.ts to fix Fast Refresh violation (non-component exports alongside component)
Tests (31 stale failures fixed):
- Update 5 test files with correct mixed-case text after CSS uppercase refactor
- Rewrite mobile-nav.test.tsx: add useAuth mock, update to 5+1 nav structure, correct icons and routes
* fix(review): resolve PR #33 review findings for chat data layer
Critical fixes (C-1 through C-5):
- Add missing INSERT policy on conversations table (C-1)
- Filter user's own messages from getUnreadCounts in Supabase adapter (C-2)
- Change z.url() to z.string().url() for media attachment URLs (C-3)
- Return Err instead of Ok(()) from SYNCABLE_TABLES allowlist guards (C-4)
- Add missing UPDATE policy on media_attachments table (C-5)
Important fixes (I-2, I-3):
- Add participant check before message insert in send_message (I-2)
- Validate direct conversations require exactly 2 participants (I-3)
Tasks created (S013-S017) for: data mapper tests, dedicated
get_messages_since command, updatedAt mapping fix, unread counts
resilience, and sync guard test coverage.
Suggestions S-1 through S-6 tracked in Ideas backlog.
Review: Context/Reviews/0001-pr33-chat-data-layer-review.md
* feat(chat): implement review remediation tasks S013-S017
S013: Add 36 data mapper unit tests for chat entities
- Conversation, ConversationParticipant, Message, MediaAttachment
- Round-trip, null handling, and Zod validation tests
S014: Add dedicated get_messages_since Rust command
- SQL-level WHERE created_at > ? filter replaces client-side filtering
- Eliminates silent truncation at 1000 message limit
S015: Fix toConversationParticipant updatedAt mapping
- Use last_read_at ?? joined_at instead of always joined_at
S016: Improve getUnreadCounts error resilience
- Single conversation failure no longer aborts all counts
- Returns partial results with console.warn for failed queries
S017: Remove #[cfg(not(test))] from sync allowlist guards
- Tests now exercise the same allowlist path as production
- Updated test fixtures to use real SYNCABLE_TABLES entries
Review: Context/Reviews/0001-pr33-chat-data-layer-review.md
---------
Co-authored-by: Robert Hamilton
* feat: Supabase Realtime, chat data layer, events & coach assignment (#34)
* docs(planning): add spec, tech, and steps for supabase realtime integration
4-phase blueprint for Step 22 (Supabase Realtime Integration).
Establishes architecture for Broadcast channel management, cursor-based
message pagination, RealtimeManager module, and all chat TanStack Query hooks.
ADR-005: cursor-based getMessages replaces offset-based (no existing callers).
* feat(chat): add Supabase Realtime broadcast channels and chat hooks
Wire up Supabase Realtime Broadcast for live message delivery and typing
indicators. Introduces the RealtimeManager singleton, cursor-based message
pagination, platform-agnostic foreground detection, and all TanStack Query
hooks needed by the Chat UI (Step 23).
New files:
- src/lib/realtime-schemas.ts -- Zod schemas for broadcast payloads
- src/lib/realtime-manager.ts -- channel lifecycle, typing state, catch-up
- src/lib/foreground-detector.ts -- visibilitychange / Tauri focus detection
- src/hooks/use-chat.ts -- useConversations, useMessages (infinite),
useSendMessage (optimistic), useRealtimeMessages, +6 more
- src/components/chat-realtime-listener.tsx -- app-level cache update listener
Modified:
- data-adapter.ts / supabase-adapter.ts / tauri-adapter.ts / chat.rs
Replace offset-based getMessages with cursor-based (before?: ISO timestamp)
- main.tsx: mount ChatRealtimeListener alongside SyncListener
- auth.tsx: call resetRealtimeManager() on SIGNED_OUT
Tasks: S001, S002, S003, S004, S005, S006, S007
* fix: resolve QA lint and code quality issues across frontend and Rust
Fix clippy errors in test code (manual_range_contains, approx_constant),
ESLint no-constant-binary-expression in utils test, and three code quality
issues in realtime-manager: validate typing payloads with Zod schema,
replace fragile onTyping callback chain with Set-based listener pattern,
and explicitly discard handleForeground Promise.
* fix(chat): address PR #34 review findings for Supabase Realtime
Multi-listener pattern (addMessageListener/addTypingListener) replacing
single-callback ownership, broadcast try/catch in useSendMessage,
channel lifecycle ownership fix, auth integration via useAuth hook,
and Wave 5 polish: named constants, toMessageFromBroadcast mapper,
simplified clearAllTyping, foreground-detector error logging and
async/await rewrite, broadcastTyping .catch(), MessagePaginationOptions
interface, user_name max(200) constraint.
---------
Co-authored-by: Robert Hamilton
* feat(chat): complete chat UI with messaging and conversations (#35)
* docs(planning): add spec, tech, and steps for chat UI (Step 23)
* feat(chat): build complete chat UI (Step 23)
Routes, conversation list, virtualized message detail, compose bar,
typing indicators, new conversation flow, group management, and
client-side blocking. Navigation updated -- COMMS replaces Social
on mobile, added after Vault on desktop sidebar with unread badge.
Conversation type extended with participantUserIds so ConversationDetail
can resolve the other user's display name and blocking state for direct
conversations. Supabase adapter fetches participants alongside
conversations; Tauri adapter was already receiving them and now uses them.
Tasks: S001, S002, S003, S004, S005, S006, S007, S008, S009, S010, S011, S012, S013, S014
Feature: 006-Chat-UI
* docs(planning): add quick plan for conversation participant IDs
Feature: 006-Chat-UI
* fix: update mobile-nav tests for Comms tab and fix Rust formatting
Update test assertions for Social→Comms rename, add useUnreadCounts
mock, and fix rustfmt formatting in event_reminder tests.
* fix(chat): resolve PR #35 review findings
Critical fixes:
- C1: Contact search filter was a no-op; now passes search prop to
ContactRow which renders null when display name does not match
- C2: Sender name in group bubbles showed raw UUID; resolved via
useUserProfile inside MessageBubble
- C3: Send failure gave zero feedback; added sendError state and
inline "Failed to send" indicator on ComposeBar
- C4: Conversation creation failure was silent; added error state
and inline message in NewConversationSheet
- C5: MessageList had no error/loading state; added spinner and
error fallback from useMessages isLoading/isError
Important fixes:
- I1: Typing broadcast used user.email; now uses displayName via
useUserProfile
- I2/I3: useBlockedUsers storage key scoped to userId; added
console.error/warn on localStorage corruption
- I4: Optimistic message had senderId undefined; now uses current
user ID from useAuth
- I5: LeaveDialog failure was silent; added inline error message
- I6: ParticipantSheet add failure was silent; shows error including
Tauri offline message
- I7: Removed rounded-full from avatar containers and unread dots
- I8: Added comment clarifying group threshold (2 selected + server
adds self = 3 total participants)
Suggestions:
- S3: Removed build-artifact comment from conversation-list.tsx
- S9: Fixed error copy from "pull to retry" to "try again"
Tasks: C1, C2, C3, C4, C5, I1, I2, I3, I4, I5, I6, I7, I8, S3, S9
Feature: 006-Chat-UI
* docs(planning): resolve PR #35 review and add test/polish tasks
Marks 0003-pr35-chat-ui-review.md as fully resolved (5 critical,
8 important, 2 suggestions fixed inline; 6 tasks created; 5
suggestions dismissed with rationale).
Adds Wave 7 tasks to Steps.md:
- S015-T: deriveItems unit tests
- S016-T: useBlockedUsers unit tests
- S017-T: formatTypingText unit tests
- S018-T: relativeTime + getInitials unit tests
- S019: Migrate useBlockedUsers to Zustand store
- S020: Wire archive action in conversation header
Feature: 006-Chat-UI
* refactor(chat): extract utils, add blocked-users store, verify review
Extract pure functions (relativeTime, getInitials, formatTypingText,
deriveItems) into chat-utils.ts and add tests. Migrate useBlockedUsers
to Zustand store with user-scoped persistence. Mark PR #35 review as
verified with follow-up tracked in GitHub issues #43-#51.
---------
Co-authored-by: Robert Hamilton
* feat: add link preview metadata with OG tags, Twitter Cards, and useMetadata hook (#53)
Add comprehensive link preview support so shared URLs display rich previews
on social platforms and messaging apps. Includes a useMetadata hook for
dynamic per-route title and description updates, used on share pages.
* fix(auth): prevent PKCE code race condition on OAuth callback (#54)
Supabase JS v2.100.1 defaults to detectSessionInUrl: true with PKCE
flow, causing _initialize() to auto-exchange the auth code before the
/auth/callback route handler can. The second exchange attempt fails
(code already consumed), redirecting users to /sign-in?reason=oauth_error.
Disable detectSessionInUrl since we have a dedicated callback route
that handles PKCE code exchange manually.
* fix(auth): enable PKCE flow for OAuth sign-in (#55)
Without explicit flowType: 'pkce', the Supabase client uses implicit
flow, returning tokens as hash fragments (#access_token=...) that the
/auth/callback route cannot read (it expects ?code= query params).
Setting flowType: 'pkce' makes Supabase return a PKCE authorization
code as a query parameter that the callback can exchange for a session.
* fix(db): resolve RLS infinite recursion on group_members and conversation_participants (#56)
The group_members and conversation_participants SELECT policies
self-referenced their own tables, triggering PostgreSQL error 42P17
(infinite recursion). This cascaded 500s to every table whose sharing
policies join group_members (workout_logs, session_templates, etc.).
Creates SECURITY DEFINER helpers (is_group_member, is_group_coach,
is_conversation_participant) that bypass RLS for inner membership
lookups and replaces all six affected policies.
Also removes the PWA manifest (manifest.webmanifest) and its link tag
since Vercel deployment protection returns 401 on the unauthenticated
manifest fetch.
* feat(builder): program builder UI with responsive week grid (#58)
* feat(builder): program builder UI with responsive week grid
Implements the complete program builder and session builder UIs.
Program builder:
- Sidebar layout (desktop) / stacked layout (mobile) at /builder route
- Drag-and-drop block reordering with @dnd-kit
- Week grid defaults to 5-day Mon-Fri view with a toggle to show all 7
days, improving readability at constrained content widths (~94px/cell
at 1024px narrowed to nothing legible; 5-day gives ~132px/cell min)
- Session slots reduced from 80px to 56px min-height with type-colored
left borders (STRENGTH/CONDITIONING/SE/MIXED/EVENT)
- Mobile block editor with vertical day-row tap targets
- Session picker bottom sheet and copy-week dialog
- Program preview overlay with per-session exercise/load tables
Session builder:
- Set scheme editor with per-type form variants
- Activity group editor and session template form
Infrastructure:
- Collapsible shadcn/ui component added
- Removed boilerplate App.tsx/App.css
- Expanded index.css with builder-specific design tokens
* fix(builder): declutter block header with overflow menu
Replace inline action buttons (move up/down, delete) with a dropdown
overflow menu in both desktop and mobile block editors. This prevents
touch target overlap on narrow viewports and isolates the destructive
delete action behind a two-tap interaction.
* feat(builder): guard destructive actions with confirmation and undo
Block and week deletions now require confirmation via AlertDialog,
showing impact ("This will remove N weeks and M sessions.") before
proceeding. Session removal fires immediately but shows a 5-second
undo toast via sonner, letting users recover accidental taps without
blocking the fast path.
- Add alert-dialog.tsx shadcn primitive (Radix AlertDialog)
- Add confirm-delete-dialog.tsx reusable builder component
- Wire block delete in BlockEditor and MobileBlockCard to dialog
- Wire week delete in WeekGrid and MobileWeekSection to dialog
- Add undo toast to SessionSlot and MobileDayRow session removal
- Mount Toaster in main.tsx (bottom-center)
* feat(builder): replace full-screen preview with inline week detail
Remove the disconnected ProgramPreview overlay from the builder flow.
Exercise details now toggle inline per-week via a visibility icon,
keeping the edit-preview feedback loop tight. Save button moves to
the header. Shared formatting helpers extracted to session-detail.tsx.
Also fixes Collapsible asChild bug in block-editor and mobile-block-editor.
* fix(builder): remove interstitial from session template create flow
Tapping "Create new template" in the session picker now opens the
inline form immediately, skipping the Library recommendation callout.
The Library option is preserved as a subtle link ("Want more options?
Create in the Library") below the form for users who need it.
Removes showCreateCallout state and the two-step reveal pattern.
* refactor(builder): consolidate session constants and polish UI details
- Extract SESSION_TINT and SESSION_TYPE_BADGE from session-slot,
mobile-block-editor, and program-preview into constants.ts
- Remove duplicate @keyframes block-enter and slot-fill in index.css
- Bump section-level labels (WEEK N, day columns, EXERCISE/SETS/LOAD)
from text-[11px] font-medium to text-xs font-semibold for visual
hierarchy over metadata badges which stay at text-[11px] font-medium
- Upgrade session picker SheetTitle to text-sm font-display
- Add empty state copy when zero blocks exist in BlockList and
MobileBlockEditor
- Clarify copy-week dialog: description now states sessions will be
replaced, and per-week "(N existing)" label gains warning colour
* feat(builder): replace collapsed save error with structured inline validation
Validation errors now appear at the relevant field rather than as a single
joined string below the save button. Program name errors render under the
name input, block name and week errors render on each block card (visible
even when collapsed), and server/auth errors remain global in the header.
* feat(builder): add recently-used templates and reduce week grid noise
Surface the 5 most recently assigned session templates at the top of the
picker sheet so users with large template libraries reach common picks
faster. Adds lastAssignedAt tracking across both Tauri/SQLite and
Supabase backends.
Reduce visual noise in the week grid: restyle empty slots as muted
"Rest" labels instead of prominent calls-to-action, bring the 5/7-day
toggle to mobile, and collapse identical weeks within a block to a
compact summary row (with manual expand).
* refactor(builder): extract session-detail helpers to fix Fast Refresh lint errors
* feat: display broadcast infrastructure (Step 27) (#59)
* feat(chat): video, image, and file sharing (#57)
feat(chat): video, image, and file sharing (Feature 007)
Full-stack media sharing: Cloudflare Stream video uploads with TUS, image/file uploads via Supabase Storage, real-time transcoding status via webhook, signed URL caching, and inline media rendering in chat.
Review: 4 critical + 10 important findings resolved and verified. 12 enhancements implemented. 1,305 tests passing.
* docs: add plan for Step 27 -- display broadcast infrastructure
Spec.md, Tech.md, and Steps.md for Feature 008. Covers DisplaySnapshot
Zod schema, publisher module, store integration, display_visible toggle,
and Supabase Realtime Broadcast channel plumbing. 14 tasks across 4 waves
with 3 agents.
* feat: add display_visible column to user profiles
SQLite and Supabase migrations for display broadcast toggle,
Tauri model and command updates to support the new field.
* feat: add DisplaySnapshot domain type and display publisher
DisplaySnapshot type with Zod schema, display-publisher for
broadcasting workout state, data-mapper and store updates.
* feat: add PushToDisplayButton and display broadcast hook
useDisplayBroadcast hook, PushToDisplayButton component,
workout log and profile route integrations.
* test: add display snapshot and publisher tests
Unit tests for DisplaySnapshot schema, display-publisher,
display-snapshot builder, and updated data-mapper/adapter tests.
* docs: update Steps.md and implementation plan for display broadcast
* fix(review): resolve PR #59 review findings
Fixes: C1 (RLS auth), C2 (image signed URLs), C3 (snapshot try-catch),
C4 (Cloudflare subdomain), I1-I9 (error handling, schema tightening,
z.url() revert, publisher readiness), M1/M3/M5/M6 (env guards,
request parsing, query error logging, JSDoc)
Tasks: S024-T, S025-T added for media-provider and timeout tests
Comments: 9 comment issues resolved
Review: Context/Reviews/0005-pr59-display-broadcast-review.md
* test(media): add unit tests for CloudflareStreamProvider and transcoding timeout
S024-T: media-provider tests covering signed URL TTL cache hit/miss,
null client error, Zod validation of malformed responses, and cache
deduplication (13 tests).
S025-T: use-media-upload tests covering 5-minute transcoding timeout
marks processing attachment as failed, unaffected ready attachments,
query cache invalidation, and timer cleanup on unmount (4 tests).
Tasks: S024-T, S025-T
Feature: 007-video-image-sharing
* docs(review): mark PR #59 review as verified
* feat(edge-functions): deploy CF video edge functions with Vault secrets and local JWT signing
Deploys three Supabase Edge Functions for Cloudflare Stream video support
(Step 25). All Cloudflare credentials read from Supabase Vault using
CF_* secret names (CF_ACCOUNT_ID, CF_STREAM_TOKEN, CF_WEBHOOK_SECRET,
CF_SIGNING_KEY_PEM, CF_SIGNING_KEY_ID, CF_CUSTOMER_SUBDOMAIN).
chat-media-signed-url: replaces Cloudflare token API round-trip with
local RS256 JWT signing via CF_SIGNING_KEY_PEM + CF_SIGNING_KEY_ID.
No outbound HTTP call needed per playback request.
chat-media-upload-url: reads CF_ACCOUNT_ID + CF_STREAM_TOKEN from Vault.
chat-media-webhook: reads CF_WEBHOOK_SECRET + CF_ACCOUNT_ID from Vault.
Adds get_secret() SQL migration granting service_role-only read access
to vault.decrypted_secrets. Fixes duplicate migration timestamp
20260403000002 -> 20260403000003 for session_template_last_assigned.
Updates mock-supabase test util to support keyed RPC results
(get_secret:SECRET_NAME) for parameter-specific vault mocking.
Removes CLOUDFLARE_ACCOUNT_ID from STANDARD_ENV test fixture.
* chore: update app icons and branding assets
Replace default Tauri icons with Ardent Forge branding across all
platforms (Android, iOS, Windows, macOS). Update app display name
in Android strings.xml and tauri.conf.json.
* fix(db): rename migration 008 and auto-recreate DB on checksum mismatch
Rename session_template_last_assigned to migration 009 to make room
for 008_add_display_visible. Add debug-only auto-recreate logic so
migration checksum mismatches during development delete and rebuild
the local database instead of crashing.
* fix(auth): use Chrome Custom Tabs for Google OAuth on Android
Replace window.open with tauri-plugin-opener's inAppBrowser mode to
launch Google OAuth in Chrome Custom Tabs instead of an embedded
WebView. Google blocks OAuth in WebViews under their "Use secure
browsers" policy. Add opener plugin registration and URL scope
permissions for mobile capability.
* chore: update Claude agent configs and mise tooling
* feat(display): display route with board and focused views (#60)
* feat(display): display route with board and focused views (Step 28)
Implements the /display route -- a full-viewport, unauthenticated gym TV
page that subscribes to the Supabase Broadcast display channel and renders
active workout sessions in real time.
Board view: CSS grid of session cards (1-4 per page), auto-cycling every
10 seconds when 5+ sessions are active. Each card shows display name,
session type, elapsed time, current exercise, set progress, and a compact
rest timer.
Focused view: full-screen single-user layout with set table (prescribed vs
actual with ember accent on current set), large rest timer with pulse
animation under 10 seconds, and distance-readable typography (2-3.5rem).
Idle placeholder: large clock with NO ACTIVE SESSIONS label.
Infrastructure added:
- display-store.ts: Zustand store with sessions Map, focus state,
pagination, staleness pruning
- display-subscriber.ts: Broadcast channel subscriber with Zod validation
and connection status callbacks
- use-timer-interpolation.ts: rAF-based countdown hook (1 React re-render
per second, not per frame)
- display_hello responder: phones re-publish their snapshot on reconnect
so the display rebuilds state after network drops
Tasks: S001, S002, S003, S004, S001-T, S005, S006, S007
Feature: 009-display-route-board
* docs(context): feature spec, tech plan, and steps for display route (009)
Adds planning artifacts for Step 28: display route with board view,
focused view, timer interpolation, and display_hello reconnection.
Feature: 009-display-route-board
* refactor(display): address QA findings from board/focused route review
- Fix React Compiler lint error: remove synchronous setSeconds(0) from
useEffect in use-timer-interpolation; derive idle value via return instead
- Extract useElapsedTime hook; remove duplicated useState+setInterval logic
from session-card and focused-view
- Add exponential backoff reconnection to display-subscriber (2s base,
30s max); destroyDisplaySubscriber cancels pending retry
- Fix focused-view snapshot type: replace Map infer gymnastics with direct
DisplaySnapshot import
- Fix pruneStale: early-return when no sessions are stale to avoid
unnecessary Map clones and Zustand notifications
- Fix board-view interval: use getState().setCurrentPage() consistently
instead of mixing captured selector with getState() reads
- Fix duplicate JSDoc on setHelloResponder in display-publisher
- Use cn() helper in page-indicator for conditional classes
- Add comment in display.tsx explaining dedicated Supabase client
- Add pruneStale edge case tests: ghost key in lastSeenAt, early-return
Map identity check
Feature: 009-display-route-board
* fix(display): address QA findings from board/focused route review
Resolves all 14 findings from PR #60 review (P6-001 through P6-014):
Critical fixes:
- Call publishHello() on reconnect via _hasConnectedBefore flag (P6-001)
- Remove dead channel with removeChannel() before nulling _channel (P6-002)
- Wrap boot() in try-catch; set disconnected status on failure (P6-003)
Store fixes:
- Merge dual sessions/lastSeenAt maps into single Map (P6-013)
- Reset currentPage to 0 in clearAllSessions (P6-004)
- Clamp setCurrentPage to [0, totalPages-1] (P6-005)
- Extract DisplayConnectionStatus to display-snapshot.ts; import everywhere (P6-014)
Subscriber fixes:
- Call onStatusChange('disconnected') before early return when client null (P6-006)
Hook fixes:
- Guard useElapsedTime against NaN timestamps; clamp elapsed to Math.max(0) (P6-007)
Comment fix:
- Update __root.tsx bypass comment to include /display (P6-008)
Tasks: P6-001, P6-002, P6-003, P6-004, P6-005, P6-006, P6-007, P6-008, P6-013, P6-014
Feature: 009-display-route-board
* docs(context): add Wave 4 follow-up tasks and mark review verified
- Add S008-S011 to Steps.md Wave 4 (reconnect retry tests, use-elapsed-time
tests, schema .min(1) hardening, invalid payload tests for session_ended
and focus events)
- Add review capture file 0006-pr60-display-route-board-review.md with all
findings and resolutions documented
- Mark review verified after confirming all fixes landed
Tasks: P6-009, P6-010, P6-011, P6-012
Feature: 009-display-route-board
* fix(display): address QA findings from PR #60 review (#61)
* feat(display): display route with board and focused views (Step 28)
Implements the /display route -- a full-viewport, unauthenticated gym TV
page that subscribes to the Supabase Broadcast display channel and renders
active workout sessions in real time.
Board view: CSS grid of session cards (1-4 per page), auto-cycling every
10 seconds when 5+ sessions are active. Each card shows display name,
session type, elapsed time, current exercise, set progress, and a compact
rest timer.
Focused view: full-screen single-user layout with set table (prescribed vs
actual with ember accent on current set), large rest timer with pulse
animation under 10 seconds, and distance-readable typography (2-3.5rem).
Idle placeholder: large clock with NO ACTIVE SESSIONS label.
Infrastructure added:
- display-store.ts: Zustand store with sessions Map, focus state,
pagination, staleness pruning
- display-subscriber.ts: Broadcast channel subscriber with Zod validation
and connection status callbacks
- use-timer-interpolation.ts: rAF-based countdown hook (1 React re-render
per second, not per frame)
- display_hello responder: phones re-publish their snapshot on reconnect
so the display rebuilds state after network drops
Tasks: S001, S002, S003, S004, S001-T, S005, S006, S007
Feature: 009-display-route-board
* docs(context): feature spec, tech plan, and steps for display route (009)
Adds planning artifacts for Step 28: display route with board view,
focused view, timer interpolation, and display_hello reconnection.
Feature: 009-display-route-board
* refactor(display): address QA findings from board/focused route review
- Fix React Compiler lint error: remove synchronous setSeconds(0) from
useEffect in use-timer-interpolation; derive idle value via return instead
- Extract useElapsedTime hook; remove duplicated useState+setInterval logic
from session-card and focused-view
- Add exponential backoff reconnection to display-subscriber (2s base,
30s max); destroyDisplaySubscriber cancels pending retry
- Fix focused-view snapshot type: replace Map infer gymnastics with direct
DisplaySnapshot import
- Fix pruneStale: early-return when no sessions are stale to avoid
unnecessary Map clones and Zustand notifications
- Fix board-view interval: use getState().setCurrentPage() consistently
instead of mixing captured selector with getState() reads
- Fix duplicate JSDoc on setHelloResponder in display-publisher
- Use cn() helper in page-indicator for conditional classes
- Add comment in display.tsx explaining dedicated Supabase client
- Add pruneStale edge case tests: ghost key in lastSeenAt, early-return
Map identity check
Feature: 009-display-route-board
* fix(display): address QA findings from board/focused route review
Resolves all 14 findings from PR #60 review (P6-001 through P6-014):
Critical fixes:
- Call publishHello() on reconnect via _hasConnectedBefore flag (P6-001)
- Remove dead channel with removeChannel() before nulling _channel (P6-002)
- Wrap boot() in try-catch; set disconnected status on failure (P6-003)
Store fixes:
- Merge dual sessions/lastSeenAt maps into single Map (P6-013)
- Reset currentPage to 0 in clearAllSessions (P6-004)
- Clamp setCurrentPage to [0, totalPages-1] (P6-005)
- Extract DisplayConnectionStatus to display-snapshot.ts; import everywhere (P6-014)
Subscriber fixes:
- Call onStatusChange('disconnected') before early return when client null (P6-006)
Hook fixes:
- Guard useElapsedTime against NaN timestamps; clamp elapsed to Math.max(0) (P6-007)
Comment fix:
- Update __root.tsx bypass comment to include /display (P6-008)
Tasks: P6-001, P6-002, P6-003, P6-004, P6-005, P6-006, P6-007, P6-008, P6-013, P6-014
Feature: 009-display-route-board
* docs(context): add Wave 4 follow-up tasks and mark review verified
- Add S008-S011 to Steps.md Wave 4 (reconnect retry tests, use-elapsed-time
tests, schema .min(1) hardening, invalid payload tests for session_ended
and focus events)
- Add review capture file 0006-pr60-display-route-board-review.md with all
findings and resolutions documented
- Mark review verified after confirming all fixes landed
Tasks: P6-009, P6-010, P6-011, P6-012
Feature: 009-display-route-board
* feat(discovery): add discovery client and server URL flow (#63)
* docs(planning): add Feature 009 plan for discovery client + server URL
Creates the full 4-phase planning artifacts for Refactor A of the onboarding
redesign: server URL input with auto-discovery replaces raw Supabase credential
entry as the primary setup flow.
Tasks: S001, S001-T, S002, S003, S003-T, S004, S004-D
Feature: 009-discovery-client-server-url
ADR-006
* feat(setup): add discovery client and server URL flow
Replace raw Supabase credential inputs on the setup screen with a
primary server address field that auto-discovers backend credentials
via GET /.well-known/ardent-forge.json. Manual Supabase URL and key
entry moves into a collapsible "Manual configuration" section as a
fallback.
- Add discoverInstance() in src/lib/discovery.ts with URL normalization,
8-second AbortController timeout, and typed DiscoveryResult discriminated
union (NETWORK_ERROR, NOT_FOUND, INVALID_RESPONSE)
- Add 11 Vitest unit tests covering happy path, URL normalization variants,
and all error branches
- Refactor setup.tsx: server URL primary input, discovery + validation
state machine, "or" divider, collapsible manual section, disabled QR
placeholder for future Refactor B
- Add /.well-known/ardent-forge.json handle block to Caddyfile with
{$SITE_URL} and {$ANON_KEY} env var interpolation
- Add ANON_KEY to caddy service environment in docker-compose.yml
- Document discovery endpoint for static host and Docker deployments
in docs/self-hosting.md; update mobile app configuration section
Tasks: S001, S001-T, S002, S003, S003-T, S004
Feature: 009-discovery-client-server-url
ADR: ADR-006
* docs(planning): mark Feature 009 Steps.md as complete
* fix(discovery): harden URL validation, use Zod, deduplicate connect logic
- Validate URL protocol via new URL() parse; reject non-http/https schemes
(file://, ftp://, etc.) before any fetch is made
- Replace manual typeof field checks with a Zod schema (DiscoverySchema)
to align with project convention and improve type safety
- Extract shared validateAndSave() helper in setup.tsx, eliminating
duplicated validate-save-init-navigate block between handleConnect
and handleDiscoverAndConnect
- Add 4 new tests: file:// rejection, ftp:// rejection, empty string,
whitespace-only input
Feature: 009-discovery-client-server-url
* fix(review): resolve PR review findings for discovery feature
Fixes (P6-001..008): add logging to all error paths, capture error
params in catch blocks, correct error type semantics (INVALID_INPUT
for validation, INVALID_RESPONSE for JSON parse), fix JSDoc, add
aria-expanded to disclosure toggle, add double-submission guard,
fix comment scope.
Refactor: unify 4 parallel state variables into a single SetupState
discriminated union with 7 phases, eliminating impossible states.
Also wraps handlers in try-catch (P6-009..011) and fixes success
state ordering as part of the refactor.
Rules: create .claude/rules/error-handling.md codifying error type
semantics and catch block conventions (P6-017, P6-018).
Tasks: S005..S007-T added to Steps.md for schema tightening and
test coverage gaps (P6-012..015).
Review: Context/Reviews/0006-discovery-client-server-url-2026-04-04.md
* fix(discovery): tighten schema validators and add missing test coverage
S005: Use z.string().url() for supabase_url, z.string().min(1) for
supabase_publishable_key and version in DiscoverySchema. Validation
is now strictest at the system boundary.
S005-T: Add test for missing supabase_publishable_key field.
S006-T: Add test for 500 HTTP response (non-404 error code).
S007-T: Add test for URL with path component being stripped.
Review: P6-012, P6-013, P6-014, P6-015
* docs(review): mark discovery feature review as verified
* feat(display): idle mode with Edge Function broadcast (#62)
* docs(planning): add feature 009 idle mode spec, tech plan, and steps
Full 4-phase blueprint for Step 29 (Idle Mode + Edge Function).
- Spec.md: 8 must-haves, 17 testable assertions, all open questions
resolved (24h clock default, /display?clock=12h override, max 3
sessions, silently hide schedule when Edge Function unreachable)
- Tech.md: 8 architecture decisions -- idle_snapshot event type
extension, Realtime HTTP broadcast API, Supabase cron schedule,
program hierarchy JOIN query, server_time drift correction, CSS-only
transitions, TanStack Router search param validation
- Steps.md: 10 tasks across 4 phases, frontend-specialist +
backend-engineer + quality-engineer, parallel Phase 1 (types + Edge
Function) and Phase 2 (ClockDisplay + IdleView + transition wrapper)
* feat(display): implement idle mode with Edge Function broadcast
Adds idle mode to the /display route -- the ambient state shown when no
workouts are active. A server-side Edge Function queries today's remaining
scheduled sessions every 60 seconds and broadcasts them to the display
channel so the TV always shows useful information.
- IdleSnapshot Zod schema + idle_snapshot event type in DisplayEventType
- ClockDisplay component (8rem Space Grotesk, server-time drift correction)
- IdleView component (clock, date, sessions list, next-up badge, footer)
- useDisplayMode hook deriving idle/board/focused from session map
- useIdleSnapshot hook subscribing to display Broadcast channel
- DisplayModeTransition wrapper (300ms fade for idle↔board, 400ms zoom for board↔focused)
- /display route composed end-to-end with all hooks and components
- display-idle-snapshot Edge Function with cron every 60 seconds
- get_display_idle_sessions RPC (service_role only, filters completed sessions)
- /display bypasses config guard in __root.tsx (public, no-auth route)
- docs/self-hosting.md: Remote Display section
Tasks: S001, S001-T, S002, S003, S003-T, S004, S005, S005-T, S006, S006-T, S007, S008, S009, S009-D
Feature: 009-idle-mode
* docs(planning): mark feature 009 idle mode as complete
Feature: 009-idle-mode
* fix(display): address QA findings from idle mode review
Frontend:
- Refactor useDisplayMode to use setState-during-render pattern (React
"storing info from previous renders") instead of refs-in-useMemo,
fixing react-hooks/refs ESLint violations and the ref-as-dep stability bug
- Add NaN guard and 60s periodic re-sync to ClockDisplay offset to handle
NTP corrections and DST transitions on long-running displays
- Wire real Realtime channel status to connectionStatus in display route,
replacing the hardcoded 'connected' placeholder
- Fix useIdleSnapshot cleanup to safely void removeChannel promise; add
dev-mode console.warn on safeParse failures for easier debugging
- Fix IdleView session key to use display_name+session_name instead of
array index
- Remove redundant cleanup() calls in display test afterEach blocks
Backend:
- Add day_of_week IS NOT NULL guard to get_display_idle_sessions RPC to
prevent silent exclusion of sessions with unset day_of_week
- Add composite index idx_workout_logs_session_user_date to prevent full
table scan on the cron-driven workout_logs LEFT JOIN
- Replace CREATE OR REPLACE with DROP IF EXISTS + CREATE for atomic
GRANT application
- Remove non-null assertions on sessions[0] in Edge Function
- Add LIMIT 3 comment and verify_jwt rationale comment in config
- Improve Edge Function error messages to include error codes and response
status for easier production debugging
Feature: 009-idle-mode
* fix(display): resolve PR review findings from 0006-idle-mode-review
Fixes: clock drift bug (P6-001), null client silence (P6-002),
lying connection status (P6-003), broad path bypass (P6-004),
duplicate channel (P6-005), missing CLOSED handling (P6-006),
production-silent parse errors (P6-007), duplicate DisplayMode type
(P6-008), unsafe RPC cast (P6-009), redundant comments (P6-010)
Tasks: S011-S014 added to Steps.md for missing test coverage
Rules: .min(1) on display-facing strings, Tech.md D4 corrected
Review: Context/Reviews/0006-idle-mode-review.md
* test(display): add review remediation tests S011-S014
S011: useIdleSnapshot hook tests (9 tests) -- valid/invalid payload,
null client, cleanup, connection status callback for all channel states
S012: DisplayModeTransition tests (8 tests) -- transition class mapping
for all mode pairs including duration assertions
S013: IdleSnapshot schema edge cases (7 tests) -- invalid session_type,
empty arrays, invalid ISO dates, .min(1) enforcement
S014: ClockDisplay correction test strengthened to assert exact time value
Review: Context/Reviews/0006-idle-mode-review.md
* docs(reviews): mark 0006-idle-mode-review as verified
All 16 findings confirmed resolved by review-verify agent.
Feature: 009-idle-mode
* fix(display): add onIdleSnapshot to subscriber test mock handlers
Fixes TS build error from merge integration -- the test's mock handlers
were missing the new onIdleSnapshot callback added to DisplayEventHandlers.
* chore(harness): update skills, hooks, and Claude Code settings
* docs: update hosting PRD and add onboarding refactoring plans
* feat(onboarding): QR code gen + scan for server setup (#64)
* docs(planning): add Feature 010 QR code gen + scan blueprint
Spec, Tech, and Steps for Refactor B of the onboarding redesign
(QR code generation in Settings + scan/paste on setup screen).
Covers invite link format, qrcode.react config, barcode scanner
plugin setup, and isTauri()-based platform branching. Updates
onboarding-refactoring-plans.md with researched plugin specifics.
Feature: 010-qr-code-gen-scan
* feat(onboarding): add QR code server sharing and scan on setup
Adds QR-based server sharing to reduce friction for onboarding new
users to a self-hosted Ardent Forge instance.
- Settings backend section now shows a "Share this server" area with
a QRCodeSVG encoding `ardentforge://connect?url=...&key=...` and a
"Copy invite link" button, guarded by hasConfig()
- Setup screen QR button activates native camera scan via
@tauri-apps/plugin-barcode-scanner (Tauri/Android) or toggles an
inline paste field (browser); both paths parse the invite link and
auto-trigger connection validation
- New src/lib/invite-link.ts provides buildInviteLink() and
parseInviteLink() as a pure shared utility
- Barcode scanner plugin registered under #[cfg(mobile)] and
barcode-scanner:default added to mobile capabilities
Tasks: S001, S002, S003, S004, S005, S006
Feature: 010-qr-code-gen-scan
* fix(qr-scan): address QA findings from code review
- Add .catch() to unawaited processInviteLink() calls in paste/keydown
handlers to prevent silent unhandled promise rejections
- Replace duplicate dynamic import of cancel with cancelRef to avoid
redundant module resolution on scan overlay dismiss
- Change perms === 'denied' guard to perms !== 'granted' for defensive
permission handling on non-standard permission states
- Move tauri-plugin-barcode-scanner to target-specific mobile dep in
Cargo.toml to avoid enlarging desktop binary
- Remove unreachable pathname fallback branch from parseInviteLink
- Add comment to buildInviteLink documenting publishable key intent
- Rename misleading test and add true malformed-URL test coverage
- Apply rustfmt formatting to db.rs
Feature: 010-qr-code-gen-scan
* fix(qr-scan): resolve P7 review findings and add component tests
Fix bare catch blocks in handleCopy and connection-validator, add
try/catch to processInviteLink to prevent UI getting stuck, handle
unhandled promise from handleScan onClick, add user-facing error
toasts for clipboard failures, fix buildInviteLink to use SCHEME/HOST
constants, downgrade parse error log to silent null return, add
aria-label to paste field, improve QR scan failure toast message.
Add component tests for processInviteLink valid/invalid flows and
backend-settings share section conditional rendering and copy button.
Tasks: S006-T1, S006-T2
Feature: 010-qr-code-gen-scan
Review: Context/Reviews/0007-pr64-qr-code-gen-scan-review.md
* docs(planning): resolve P7 review and add S006-T1/T2 tasks to Steps.md
Mark all 14 findings in the QR code gen/scan review as resolved.
Add S006-T1 (processInviteLink component tests) and S006-T2
(backend-settings share section tests) to Feature 010 Steps.md.
Feature: 010-qr-code-gen-scan
* docs(review): mark P7 review verified by review-verify agent
* refactor(review-resolve): simplify bulk triage to bulk accept
Renames bulk triage to bulk accept to better reflect the workflow --
the capture category already IS the suggested action. Removes the
requirement for a reason on discard (individual and bulk).
* chore(harness): add gitignore entries and reformat hooks
Adds .claude/backups/, .claude/worktrees/, .serena/, and
hooks/skill-activation/.state/ to .gitignore. Reformats all hook
TypeScript files to double-quote style for consistency.
* feat(builders): progressive disclosure and contextual help (Step 14.5)
* docs(planning): add Step 14.5 feature plan and user flow docs
Documents the progressive disclosure and contextual help plan
for the template builder and program builder (Step 14.5).
Adds Flow 7 builder progressive disclosure and contextual help
system diagrams to 10-user-flows.md.
Feature: 010-Progressive-Disclosure
Tasks: S001–S014-T
* feat(builders): progressive disclosure and contextual help (Step 14.5)
Adds context-aware field visibility, staged group creation, set scheme
filtering, and inline help to the template and program builders.
New primitives and utilities:
- HelpTrigger component (Popover desktop / Drawer mobile via useMediaQuery)
- shadcn Popover and Drawer with Iron & Ember styling
- useMediaQuery hook (SSR-safe, listener cleanup)
- CollapsedFieldsRow for expandable hidden field groups
- visibility-maps.ts: data-driven lookup tables for all field visibility rules
- help-content.ts: GROUP_TYPE_HELP, BLOCK_TYPE_HELP, SOURCE_HELP constants
- text-label-large and text-body-small typography utilities in index.css
Template builder changes:
- Category-aware Scoring/Time Cap visibility with CollapsedFieldsRow
- Activity group staged reveal: Stage 1 (type selector only), Stage 2 (type-
dependent rest fields + exercises)
- Set scheme type list filtered by session category; "Show all types" escape
- Load type options filtered by set scheme type; null = hide entire section
- Session picker gains "Create new event" inline creation flow
Program builder changes:
- Block type HelpTrigger with one-liner summary (desktop + mobile)
- Source badge HelpTrigger with all 7 sources
Tasks: S001, S002, S003, S004, S005, S006, S007, S008, S009, S010, S011, S012, S013
Feature: 010-Progressive-Disclosure
* fix(builders): resolve QA findings from progressive disclosure review
Fixes two blocking hook violations and seven warnings across the builder
components introduced in Step 14.5.
Errors fixed:
- Move useCallback above early return in LoadSpecEditor to satisfy
Rules of Hooks (conditional hook call)
- Rewrite useMediaQuery with useSyncExternalStore to eliminate
synchronous setState in effect and double render
Warnings fixed:
- Remove eslint-disable suppression in LoadSpecEditor; expand useEffect
deps to [allowedLoads, availableTypes, value.type, onChange]
- Replace div[role=button] nested inside in MobileDayRow with
sibling elements; fix event type to MouseEvent | KeyboardEvent
- Add useId/aria-controls pairing to CollapsedFieldsRow for screen
reader disclosure association
- Dynamic aria-label on HelpTrigger (Help: ${title} instead of static)
- Extract BLOCK_TYPE_STYLES to constants.ts; remove duplication across
block-editor and mobile-block-editor
- Remove unused placement prop from HelpTrigger and all four call sites
- Fix PopoverTitle to render matching its ComponentProps<'h2'> type
Suggestions addressed:
- Sync ladderText in DescendingRepsFields when repLadder resets externally
- Use touchLastAssigned.mutate (stable ref) in useCallback deps
- Add clientId to ActivityGroupData and ActivityData; generate via
crypto.randomUUID() at creation; use as React key instead of index
- Add aria-expanded/aria-controls to ProgramForm details toggle
Feature: 010-Progressive-Disclosure
* fix(builders): resolve PR review findings P7
Fixes: P7-001 (silent userId guard), P7-002 (JSX triplication),
P7-003 (redundant satisfies), P7-004 (unstable useEffect deps),
P7-005 (block type help duplication), P7-007 (missing JSDoc),
P7-008 (HelpEntry interface), P7-009 (non-empty tuple),
P7-010 (scheme map typing), P7-011 (constants typing)
Rules: P7-013, P7-014 added to typescript-conventions.md;
P7-015 added to error-handling.md
Deferred: P7-012 (ErrorBoundary strategy) to backlog
Discarded: P7-006 (superseded by P7-002 extraction)
Review: Context/Reviews/0007-pr65-progressive-disclosure-review.md
* fix(builders): resolve review-verify type errors and mark review passed
Fix 3 pre-existing TS errors from P7 review fixes: collapsedLabels
cast to non-empty tuple, allowedLoads.includes widened for
LoadSpec['type'], defaultTypes.includes widened for SetSchemeType.
Review file verified and checkbox marked.
* feat: implement Android CI/CD pipeline (Feature 011) (#67)
* docs(ci-cd): add full blueprint for Android CI/CD pipeline
4-phase planning for feature 011: GitHub Actions workflows covering PR
validation (lint, typecheck, unit tests, E2E via Playwright + local
Supabase, Android debug APK build), tagged release pipeline (signed AAB,
Supabase migrations gate, GitHub Release, Play Store internal track
upload), and supporting setup documentation.
Feature: 011-ci-cd-pipeline
ADR-007: GitHub Actions chosen as CI/CD platform
* ci(github-actions): implement complete Android CI/CD pipeline
Adds two GitHub Actions workflows from scratch:
- ci.yml: PR validation with three parallel jobs -- lint/typecheck/unit
tests, Playwright E2E against local Supabase, and Android debug APK
build with full Rust/NDK toolchain and caching
- release.yml: tag-triggered release pipeline -- signed AAB build and
Supabase migrations run in parallel, gated publish job creates GitHub
Release and uploads to Play Store internal track
Supporting changes:
- Add Playwright (@playwright/test) with smoke tests and test:e2e script
- Add signingConfigs block to build.gradle.kts reading from env vars
- Add .github/BRANCH_PROTECTION.md and .github/README.md
- Add CI/Release workflow badges to README.md
Tasks: S001, S002, S003, S004, S005, S006, S007, S008, S009
Feature: 011-ci-cd-pipeline
* fix(android-ci): harden pipeline per PR #67 review findings
- Guard signingConfig assignment with keystorePath null check
- Validate KEYSTORE_BASE64 before decode; check decoded file size
- Pass all secrets through env: blocks (not inline shell expansion)
- Add secret validation steps to build-release, migrate, publish jobs
- Reorder publish job: Play Store upload before GitHub Release
- Capture Supabase status once; validate non-empty/non-null values
- Add if-no-files-found: ignore to Playwright report upload
- Fix test:e2e script to use bunx playwright test
- Add webServer timeout: 30_000, stdout/stderr: pipe to playwright.config.ts
Tasks: P8-001, P8-002, P8-003, P8-004, P8-005, P8-006, P8-007, P8-008, P8-015, P8-016
* docs(ci-cd): apply post-review documentation corrections
- Tech.md: Four->Two workflows, remove armv7 target, fix port 4173,
update signing snippet to null-guard pattern
- Steps.md: add VITE_ prefix to env var names; add S010 secret validation
- ADR-007: status Proposed->Accepted
- Add PR #67 review capture (16/16 findings resolved and verified)
- Update review-resolve skill
Tasks: P8-009, P8-010, P8-011, P8-012, P8-013, P8-014, P8-015
* feat(deep-link): handle ardentforge://connect deep links (#66)
* docs(planning): add Feature 011 deep link handler planning artifacts
Full 4-phase plan for ardentforge://connect deep link handling.
Covers Tauri dispatch extension, browser /connect route, setup
screen pre-population, and transient store for settings handoff.
Feature: 011-deep-link-handler
ADR-007: transient-store-deep-link-state
* feat(deep-link): handle ardentforge://connect deep links
Implements the connect deep link handler so tapping an invite link
opens the app directly to the correct screen with credentials
pre-populated.
- Register ardentforge://connect host in Tauri deep-link config
- Add handleConnectLink() with 4-way branch: invalid, unconfigured,
same instance, different instance
- Dispatch connect vs auth in onOpenUrl listener (OAuth flow untouched)
- Add /connect browser route as fallback for web users
- Exempt /connect from root route guard
- Setup screen accepts url/key search params for auto-population
- BackendSettings consumes pending connect Zustand store on mount
- Unit tests for all 5 handleConnectLink() branches (5/5 passing)
Tasks: S001, S002, S003, S004, S005, S006, S007, S008, S009, S010
Feature: 011-deep-link-handler
ADR: ADR-007 (transient Zustand store for cross-route deep link state)
* fix(deep-link): address QA findings from review
- Add desktop deep-link scheme config + permissions to default capability
- Fix bare catch blocks in invite-link.ts and auth.tsx (error-handling convention)
- Refactor setState-in-effect in backend-settings.tsx to useRef guard
- Add HTTPS validation for url param in parseInviteLink
- Accept navigate callback in handleConnectLink (avoids full-page reload in router context)
- Update OAuth tests to mock @tauri-apps/plugin-opener instead of window.open
- Add test for getConfig() returning null with hasConfig true
- Add test for non-HTTPS url param rejection
- Fix setup.test.tsx mock to provide Route.useSearch()
Feature: 011-deep-link-handler
* fix(deep-link): address PR-66 review findings
- Wrap handleConnectLink in try-catch with toast on error (P8-001)
- Isolate connect-link error path from OAuth error handler in
auth.tsx (P8-002)
- Replace render-phase setState with useRef + useState initializers
in backend-settings.tsx (P8-003)
- Replace unsafe `as string` casts with typeof guards in
validateSearch for /connect and /setup routes (P8-004)
- Add toast feedback when auto-validate fails in /setup (P8-005)
- Replace startsWith check with new URL() + protocol/hostname
validation in parseInviteLink (P8-006)
- Fix bare catch blocks in config-store.ts to capture and log
errors (P8-007)
- Add store-boundary validation to usePendingConnect.setPending
(P8-009)
- Add state-management.md coding rule for Zustand conventions
- Add review capture with verification (0008-pr66)
- Add test step S011 to Steps.md (P8-008)
* test(deep-link): add /connect route and auth connect-dispatch tests
- Add connect.test.tsx covering search param validation, pending
store integration, and redirect behavior
- Update auth-oauth tests for connect deep-link error isolation
* chore(release): bump version to 1.0.0-beta
Update version across all manifests (package.json, tauri.conf.json,
Cargo.toml, Cargo.lock) and add CHANGELOG entry covering all features,
improvements, and bug fixes since v0.8.0.
Also carries forward the uncommitted AndroidManifest deep-link intent
filter from #66.
* chore(release): bump version to 1.0.0-beta
Update version across all manifests (package.json, tauri.conf.json,
Cargo.toml, Cargo.lock) and add CHANGELOG entry covering all features,
improvements, and bug fixes since v0.8.0.
Also carries forward the uncommitted AndroidManifest deep-link intent
filter from #66.
* fix(ci): upgrade supabase/setup-cli from v1 to v2
v1 runs on deprecated Node 20 and installs an older CLI that rejects
newer config keys (oauth_server, web3, cron, etc.), breaking the
migrate job on tagged releases.
* fix(ci): pin supabase CLI to v2.84.4 to match local version
setup-cli@v1 defaults to CLI v2.20.3 which rejects newer config keys.
Pinning to 2.84.4 matches local dev environment. setup-cli v2 is
merged but not yet tagged as a release.
* fix(supabase): remove invalid cron key from config.toml
The [functions.display-idle-snapshot.cron] section is not a valid
config.toml option. Edge function cron scheduling is configured via
pg_cron in SQL, not in config.toml. This was causing supabase link
to fail in CI.
* fix(sync): add HTTP status check and empty update_set guard in pull path (#70)
* fix(sync): add HTTP status check and empty update_set guard in pull path
pull_table deserialized Supabase responses without checking the HTTP
status code, which could silently swallow 4xx/5xx errors or produce
confusing deserialization failures. Added a status check matching the
pattern already used in push.rs.
upsert_row produced invalid SQL ("... ON CONFLICT(id) DO UPDATE SET ")
when the only matching column was `id`. Now falls back to INSERT OR
IGNORE for that edge case.
Closes #68, closes #69
* fix(lint): resolve unused var, explicit any, and unused import errors
- Prefix unused catch parameter with underscore in formatter hook
- Add eslint-disable for necessary `any` cast in test helper
- Remove unused `buildRequest` import from webhook test
* fix(lint): use bare catch block for unused error parameter
* fix(data-mapper): handle JSONB columns already parsed by Supabase PostgREST
Supabase returns JSONB columns as objects, not strings. Calling JSON.parse()
on an object stringifies it to "[object Object]" first, causing a parse error
on session_templates fields (rest_between_groups, time_cap, event_metadata),
activity_groups fields (rest_between_rounds, rest_between_activities), and
activities (set_scheme).
Adds parseJsonOrValue() helper that passes objects through directly and only
calls JSON.parse() on string values, keeping backward compatibility with the
Tauri/SQLite adapter which returns these columns as JSON strings.
* chore: batch enhancement issues cleanup (#71)
* chore: add Zod refinements to chat domain types
Add validation refinements that catch invalid data at parse time:
- conversation.ts: prevent groupId on direct conversations, enforce leftAt > joinedAt
- message.ts: prevent senderId on system messages
- media.ts: add .nonnegative() to durationSeconds and fileSizeBytes
Closes #40, closes #47
* chore: polish realtime manager (simplify clearAllTyping, fix silent catch)
- Simplify clearAllTyping to delegate to clearTypingForConversation helper
- Add error logging to broadcastTyping .catch per error-handling conventions
Closes #42, closes #49
* feat(events): wire EventCountdownBadge navigation to dedicated event route
- Create /events/$templateId route with minimal event detail page
- Update EventCountdownBadge to navigate to new route instead of /library
- Remove _templateId unused-var rename and TODO comment
Closes #31
* perf: batch getUnreadCounts queries with Promise.allSettled
Replace sequential N+1 COUNT queries with concurrent execution via
Promise.allSettled, preserving partial-results error handling.
Closes #39, closes #46
* perf: optimize chat query patterns (CH-2 trigger, findDirectConversation)
- Add error context with [tauri-adapter] prefix to findDirectConversation loop
- Create materialized direct_conversation_pairs lookup table with unique index
- Replace O(N) trigger scan with O(1) index-backed uniqueness check
- Add cleanup trigger for participant departures
Closes #41, closes #48
* docs: add quick plan for enhancement issues batch
* fix(review): resolve PR #71 review findings
Fixes (7): RLS on direct_conversation_pairs, ISO datetime comparison,
findDirectConversation all-fail throw, getUnreadCounts error enrichment,
comment accuracy (N+1, migration header, AFTER trigger return)
ADR-008 created for ZodEffects composability (dual schema export)
Deferred (4): Zod refinement execution in mappers, DELETE trigger for
participant hard-deletes, domain type tests, getUnreadCounts tests
Review: Context/Reviews/0009-pr71-enhancement-batch-review.md
* docs(review): mark PR #71 review as verified
* fix(chat): activate Zod refinements in mappers and add participant DELETE trigger
toConversation, toConversationParticipant, and toMessage now call the full
Zod schema via .parse() instead of returning plain object literals. This
activates all .refine() checks (direct/groupId invariant, leftAt > joinedAt,
system messages without senderId). Removed redundant individual enum parses.
Also adds an AFTER DELETE trigger on conversation_participants so hard-deleted
participant rows clean up their direct_conversation_pairs entry, preventing
orphaned pair keys from blocking future direct conversations.
Test updated to reflect that system messages must not carry a senderId.
Fixes: P9-008, P9-009
* chore(backlog): resolve P9-008 and P9-009
* chore(supabase): add reviewer account seed script
Idempotent SQL seed that creates a dedicated reviewer account with
pre-populated workout data, programs, and history so app reviewers
can navigate all screens without building data from scratch.
Password is externalized via PGOPTIONS current_setting to keep
credentials out of source control.
* test(feature-012): TauriAdapter unit tests, review follow-ups, and error kind sync
Comprehensive unit test coverage for TauriAdapter (140 tests), Supabase
chat adapter tests, CrashRecoveryDialog/AuthProvider event tests, and
review follow-up fixes.
- Add TauriAdapter unit tests covering all 88 class methods + 30 helpers
- Extract inner fns from Tauri chat commands for testability
- Add Supabase adapter chat method tests and getUnreadCounts happy path
- Add malformed JSON, DATABASE/INTERNAL error kind, and timestamp edge case tests
- Restore .bind() position comments in chat.rs INSERT statements
- Sync TypeScript TauriAppError kind union with Rust ErrorKind (ADR-008)
- Add adapter boundary fallback logging rule to error-handling.md
- Resolve all 11 PR review findings (P9-001 through P9-011)
Feature: 012-TauriAdapter-Tests
ADR-008
Tasks: S001-S014
* chore(tooling): enhance review-capture skill and fix formatter conventions
- Add interactive suggestion triage to review-capture skill (Step 4):
bulk actions (capture all / dismiss all), individual triage with
backlog integration, and renumber subsequent steps to 5-8
- Fix formatter.ts bare catch block to comply with error-handling rules
- Normalize formatter.ts quote style to double quotes
- Add *.jks, screenshots/, and .playwright-mcp/ to .gitignore
* fix(chat): log IPC failures and add chat domain type tests
B001: add [tauri-adapter] console.error to invokeCommand catch block so
IPC failures are visible in the dev console instead of silently dropped.
P9-010: add chat.test.ts with 18 Vitest cases covering the Zod refinements
introduced in PR #71 -- conversation groupId constraint, participant
leftAt/joinedAt ordering, system message senderId constraint, and
mediaAttachment nonnegative duration/fileSize fields.
* chore(backlog): resolve B001, P9-010, P9-011; defer S-6
Mark B001 and P9-010 as resolved. Close P9-011 (TauriAdapter already
tested; Promise.allSettled was never implemented). Defer S-6 N+1 query
(requires new Rust batch command). Add quick plan for audit session.
* chore(db): fix event_metadata column comment formatting
Rewrote the ambiguous pseudo-JSON comment on session_templates.event_metadata
and workout_logs.event_metadata to use clean dollar-quoted plaintext field
notation, removing unquoted type descriptors mixed with JSON keys.
Closes #32
* docs(planning): quick plan for GitHub issues #32 and #51
* perf(chat): eliminate N+1 IPC calls in findDirectConversation
Add a dedicated find_direct_conversation Rust command that resolves the
matching direct conversation via a single SQL join query, replacing the
previous implementation that fetched all conversations then looped over
each with a separate get_conversation call.
Feature: S-6 (backlog)
* chore(release): prepare v1.0.0-beta.2
Bump version across package.json, tauri.conf.json, and Cargo.toml.
Update Cargo.toml metadata with real project info. Update changelog
with recent fixes and improvements since beta.
* fix(ci): resolve lint failures in CI pipeline
Log caught error in formatter hook to fix no-unused-vars violation.
Add src-tauri/target to ESLint globalIgnores to prevent parsing Tauri
build artifacts.
* ci: remove Android debug build from PR pipeline
Android builds are expensive and redundant in CI since they run locally
before PRs. The release workflow already builds a signed AAB on tags.
* fix(e2e): correct smoke test route from /login to /sign-in
The auth page route is /sign-in, not /login. The E2E smoke test was
navigating to a non-existent route, causing the form locator to fail.
* fix(e2e): seed localStorage config so sign-in page renders in CI
The root route guard redirects to /setup when no config is stored.
In a fresh Playwright browser, localStorage is empty, so /sign-in
never renders. Seed a fake config before navigating to bypass the
guard and let the auth form render.
---------
Co-authored-by: Robert Hamilton
---
.claude/agents/quality-engineer.md | 4 +-
.claude/commands/blueprint.md | 14 +-
.claude/commands/impl.md | 10 +-
.claude/commands/quick.md | 20 +-
.claude/commands/review-resolve.md | 71 +-
.claude/commands/review-verify.md | 20 +-
.claude/commands/setup.md | 20 +-
.claude/rules/error-handling.md | 64 +
.claude/rules/state-management.md | 33 +
.claude/rules/typescript-conventions.md | 25 +
.claude/settings.json | 26 +
.claude/skills/codebase-navigation/SKILL.md | 2 +-
.claude/skills/impl-start/SKILL.md | 6 +-
.claude/skills/project-init/SKILL.md | 16 +-
.claude/skills/review-capture/SKILL.md | 100 +-
.claude/skills/review-resolve/SKILL.md | 400 ++-
.claude/skills/session-management/SKILL.md | 44 +-
.../session-types/development.md | 2 +-
.claude/skills/sub-agent-invocation/SKILL.md | 32 +-
.cortex/session.md | 45 +-
.github/BRANCH_PROTECTION.md | 37 +
.github/README.md | 103 +
.github/workflows/ci.yml | 98 +
.github/workflows/release.yml | 194 ++
.gitignore | 12 +
CHANGELOG.md | 37 +
Context/Backlog/Bugs.md | 6 +
Context/Backlog/Ideas.md | 33 +-
Context/Backlog/error-boundary-strategy.md | 19 +
.../ADR-006-discovery-endpoint-format.md | 58 +
.../Decisions/ADR-007-github-actions-cicd.md | 55 +
...ADR-007-transient-store-deep-link-state.md | 46 +
.../ADR-008-dual-export-zod-schemas.md | 56 +
.../ADR-008-tauri-error-kind-sync.md | 35 +
.../Features/007-video-image-sharing/Steps.md | 39 +-
.../Spec.md | 95 +
.../Steps.md | 385 +++
.../Tech.md | 328 ++
.../009-discovery-client-server-url/Spec.md | 79 +
.../009-discovery-client-server-url/Steps.md | 389 +++
.../009-discovery-client-server-url/Tech.md | 148 +
.../Features/009-display-route-board/Spec.md | 100 +
.../Features/009-display-route-board/Steps.md | 538 ++++
.../Features/009-display-route-board/Tech.md | 367 +++
Context/Features/009-idle-mode/Spec.md | 88 +
Context/Features/009-idle-mode/Steps.md | 252 ++
Context/Features/009-idle-mode/Tech.md | 340 ++
.../010-Progressive-Disclosure/Spec.md | 169 +
.../010-Progressive-Disclosure/Steps.md | 473 +++
.../010-Progressive-Disclosure/Tech.md | 256 ++
Context/Features/010-qr-code-gen-scan/Spec.md | 71 +
.../Features/010-qr-code-gen-scan/Steps.md | 325 ++
Context/Features/010-qr-code-gen-scan/Tech.md | 126 +
Context/Features/011-ci-cd-pipeline/Spec.md | 99 +
Context/Features/011-ci-cd-pipeline/Steps.md | 398 +++
Context/Features/011-ci-cd-pipeline/Tech.md | 276 ++
.../Features/011-deep-link-handler/Spec.md | 66 +
.../Features/011-deep-link-handler/Steps.md | 298 ++
.../Features/011-deep-link-handler/Tech.md | 184 ++
.../Features/012-TauriAdapter-Tests/Spec.md | 78 +
.../Features/012-TauriAdapter-Tests/Steps.md | 357 +++
.../Features/012-TauriAdapter-Tests/Tech.md | 152 +
.../2026-04-03-recently-used-templates.md | 76 +
.../2026-04-03-week-grid-density.md | 77 +
.../2026-04-04-address-all-backlog.md | 75 +
.../2026-04-04-enhancement-issues-batch.md | 138 +
.../QuickPlans/2026-04-04-gh-issues-32-51.md | 63 +
...26-04-04-s6-find-direct-conversation-n1.md | 93 +
.../0005-pr59-display-broadcast-review.md | 222 ++
...-discovery-client-server-url-2026-04-04.md | 181 ++
Context/Reviews/0006-idle-mode-review.md | 165 +
.../0006-pr60-display-route-board-review.md | 205 ++
.../0007-pr64-qr-code-gen-scan-review.md | 162 +
...0007-pr65-progressive-disclosure-review.md | 168 +
.../0008-pr66-deep-link-handler-review.md | 118 +
.../0008-pr67-ci-cd-pipeline-review.md | 180 ++
.../0009-pr71-enhancement-batch-review.md | 140 +
.../0009-pr72-tauri-adapter-tests-review.md | 138 +
README.md | 2 +
bun.lock | 24 +
docker-compose.yml | 1 +
docker/caddy/Caddyfile | 7 +
docs/03-prd-hosting.md | 158 +-
docs/10-user-flows.md | 70 +
docs/13-prd-remote-display.md | 409 +++
docs/implementation-plan.md | 293 +-
docs/onboarding-refactoring-plans.md | 256 ++
docs/self-hosting.md | 64 +-
e2e/smoke.spec.ts | 20 +
eslint.config.js | 2 +-
hooks/context-recovery/backup-core.ts | 48 +-
hooks/context-recovery/conv-backup.ts | 8 +-
hooks/context-recovery/statusline-monitor.ts | 18 +-
hooks/formatter/formatter.ts | 78 +-
hooks/skill-activation/agent-rules.json | 2 +-
hooks/skill-activation/skill-activation.ts | 11 +-
hooks/skill-activation/skill-rules.json | 2 +-
hooks/validators/validate-new-file.ts | 8 +-
mise.toml | 1 +
package.json | 11 +-
playwright.config.ts | 28 +
src-tauri/Cargo.lock | 307 +-
src-tauri/Cargo.toml | 18 +-
src-tauri/capabilities/default.json | 12 +-
src-tauri/capabilities/mobile.json | 18 +
src-tauri/gen/android/app/build.gradle.kts | 116 +-
.../android/app/src/main/AndroidManifest.xml | 30 +
.../res/mipmap-anydpi-v26/ic_launcher.xml | 5 +
.../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 3524 -> 744 bytes
.../mipmap-hdpi/ic_launcher_foreground.png | Bin 14102 -> 3593 bytes
.../res/mipmap-hdpi/ic_launcher_round.png | Bin 3524 -> 876 bytes
.../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 3377 -> 742 bytes
.../mipmap-mdpi/ic_launcher_foreground.png | Bin 9081 -> 2074 bytes
.../res/mipmap-mdpi/ic_launcher_round.png | Bin 3377 -> 844 bytes
.../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 7971 -> 1964 bytes
.../mipmap-xhdpi/ic_launcher_foreground.png | Bin 18900 -> 5314 bytes
.../res/mipmap-xhdpi/ic_launcher_round.png | Bin 7971 -> 2167 bytes
.../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 12392 -> 3462 bytes
.../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 29506 -> 8213 bytes
.../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 12392 -> 3995 bytes
.../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 16751 -> 5133 bytes
.../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 40510 -> 11181 bytes
.../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 16751 -> 5726 bytes
.../res/values/ic_launcher_background.xml | 4 +
.../app/src/main/res/values/strings.xml | 4 +-
src-tauri/icons/128x128.png | Bin 11059 -> 2600 bytes
src-tauri/icons/128x128@2x.png | Bin 23137 -> 6307 bytes
src-tauri/icons/32x32.png | Bin 2225 -> 403 bytes
src-tauri/icons/64x64.png | Bin 0 -> 990 bytes
src-tauri/icons/Square107x107Logo.png | Bin 9202 -> 2174 bytes
src-tauri/icons/Square142x142Logo.png | Bin 12530 -> 3055 bytes
src-tauri/icons/Square150x150Logo.png | Bin 13032 -> 3242 bytes
src-tauri/icons/Square284x284Logo.png | Bin 25943 -> 6950 bytes
src-tauri/icons/Square30x30Logo.png | Bin 2078 -> 412 bytes
src-tauri/icons/Square310x310Logo.png | Bin 28507 -> 7815 bytes
src-tauri/icons/Square44x44Logo.png | Bin 3419 -> 636 bytes
src-tauri/icons/Square71x71Logo.png | Bin 6027 -> 1271 bytes
src-tauri/icons/Square89x89Logo.png | Bin 7551 -> 1675 bytes
src-tauri/icons/StoreLogo.png | Bin 3971 -> 810 bytes
src-tauri/icons/icon.icns | Bin 277003 -> 76224 bytes
src-tauri/icons/icon.ico | Bin 37710 -> 9598 bytes
src-tauri/icons/icon.png | Bin 49979 -> 13065 bytes
src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 260 bytes
src-tauri/icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 552 bytes
src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 552 bytes
src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 943 bytes
src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 387 bytes
src-tauri/icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 967 bytes
src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 967 bytes
src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 1550 bytes
src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 552 bytes
src-tauri/icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 1331 bytes
src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 1331 bytes
src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 2243 bytes
src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 28215 bytes
src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 2243 bytes
src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 3764 bytes
src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 1278 bytes
src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 3092 bytes
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 3468 bytes
.../migrations/008_add_display_visible.sql | 1 +
.../009_session_template_last_assigned.sql | 2 +
src-tauri/src/commands/chat.rs | 1084 +++++--
src-tauri/src/commands/session_templates.rs | 20 +
src-tauri/src/commands/user_profile.rs | 9 +-
src-tauri/src/db.rs | 36 +-
src-tauri/src/lib.rs | 5 +
src-tauri/src/models.rs | 3 +
src-tauri/src/sync/pull.rs | 26 +-
src-tauri/tauri.conf.json | 19 +-
src/App.css | 184 --
src/App.tsx | 121 -
src/components/builders/help-content.ts | 135 +
src/components/builders/visibility-maps.ts | 92 +
src/components/chat/image-lightbox.tsx | 1 -
src/components/chat/media-message-content.tsx | 55 +-
src/components/chat/video-player.tsx | 9 +-
.../display/__tests__/clock-display.test.tsx | 67 +
.../display-mode-transition.test.tsx | 93 +
.../display/__tests__/idle-view.test.tsx | 136 +
.../__tests__/use-display-mode.test.ts | 180 ++
.../__tests__/use-idle-snapshot.test.ts | 179 ++
src/components/display/board-view.tsx | 68 +
src/components/display/clock-display.tsx | 41 +
src/components/display/connection-footer.tsx | 49 +
.../display/display-mode-transition.tsx | 33 +
src/components/display/focused-view.tsx | 57 +
src/components/display/idle-placeholder.tsx | 34 +
src/components/display/idle-view.tsx | 68 +
src/components/display/page-indicator.tsx | 24 +
src/components/display/rest-timer-display.tsx | 38 +
src/components/display/session-card.tsx | 67 +
src/components/display/set-table.tsx | 73 +
src/components/display/types.ts | 1 +
src/components/display/use-display-mode.ts | 44 +
src/components/display/use-idle-snapshot.ts | 51 +
.../event-builder/event-countdown-badge.tsx | 6 +-
.../__tests__/backend-settings.test.tsx | 131 +
src/components/profile/backend-settings.tsx | 79 +-
.../__tests__/builder-state.test.ts | 61 +-
.../program-builder/block-editor.tsx | 349 ++-
src/components/program-builder/block-list.tsx | 38 +-
.../program-builder/block-type-selector.tsx | 52 +
.../program-builder/builder-state.ts | 69 +-
.../program-builder/confirm-delete-dialog.tsx | 46 +
src/components/program-builder/constants.ts | 61 +-
.../program-builder/copy-week-dialog.tsx | 7 +-
.../program-builder/mobile-block-editor.tsx | 527 +++-
.../program-builder/program-form.tsx | 117 +-
.../program-builder/program-preview.tsx | 298 +-
.../program-builder/session-detail-utils.ts | 175 ++
.../program-builder/session-detail.tsx | 151 +
.../program-builder/session-picker-sheet.tsx | 239 +-
.../program-builder/session-slot.tsx | 68 +-
src/components/program-builder/week-grid.tsx | 77 +-
.../__tests__/session-template-form.test.tsx | 6 +-
.../__tests__/set-scheme-editor.test.tsx | 53 +-
.../session-builder/activity-editor.tsx | 19 +-
.../session-builder/activity-group-editor.tsx | 212 +-
.../session-builder/collapsed-fields-row.tsx | 35 +
.../session-builder/session-template-form.tsx | 149 +-
.../session-builder/set-scheme-editor.tsx | 299 +-
src/components/ui/alert-dialog.tsx | 136 +
src/components/ui/collapsible.tsx | 31 +
src/components/ui/drawer.tsx | 118 +
src/components/ui/dropdown-menu.tsx | 267 ++
src/components/ui/help-trigger.tsx | 64 +
src/components/ui/popover.tsx | 78 +
.../__tests__/crash-recovery-dialog.test.tsx | 320 ++
.../workout/push-to-display-button.tsx | 48 +
src/domain/types/__tests__/chat.test.ts | 149 +
.../types/__tests__/display-snapshot.test.ts | 370 +++
src/domain/types/conversation.ts | 38 +-
src/domain/types/display-snapshot.ts | 110 +
src/domain/types/index.ts | 4 +
src/domain/types/media.ts | 10 +-
src/domain/types/message.ts | 19 +-
src/domain/types/session.ts | 1 +
src/domain/types/user.ts | 1 +
src/hooks/__tests__/use-media-upload.test.ts | 185 ++
.../__tests__/use-timer-interpolation.test.ts | 111 +
src/hooks/use-display-broadcast.ts | 148 +
src/hooks/use-elapsed-time.ts | 20 +
src/hooks/use-media-query.ts | 27 +
src/hooks/use-media-upload.ts | 17 +-
src/hooks/use-session-templates.ts | 11 +
src/hooks/use-timer-interpolation.ts | 53 +
src/index.css | 58 +
src/lib/__tests__/auth-events.test.tsx | 501 +++
src/lib/__tests__/auth-oauth.test.tsx | 122 +-
src/lib/__tests__/data-mapper.test.ts | 10 +-
src/lib/__tests__/deep-link-handler.test.ts | 105 +
src/lib/__tests__/discovery.test.ts | 286 ++
.../__tests__/display-publisher-hello.test.ts | 156 +
src/lib/__tests__/display-publisher.test.ts | 223 ++
src/lib/__tests__/display-snapshot.test.ts | 266 ++
src/lib/__tests__/display-subscriber.test.ts | 299 ++
src/lib/__tests__/invite-link.test.ts | 83 +
src/lib/__tests__/media-provider.test.ts | 281 ++
.../__tests__/media-upload-service.test.ts | 5 +
src/lib/__tests__/supabase-adapter.test.ts | 511 +++
src/lib/__tests__/tauri-adapter.test.ts | 2762 +++++++++++++++++
src/lib/auth.tsx | 20 +-
src/lib/config-store.ts | 9 +-
src/lib/connection-validator.ts | 23 +-
src/lib/data-adapter.ts | 1 +
src/lib/data-mapper.ts | 50 +-
src/lib/database.types.ts | 2 +
src/lib/deep-link-handler.ts | 45 +
src/lib/discovery.ts | 113 +
src/lib/display-publisher.ts | 158 +
src/lib/display-snapshot.ts | 161 +
src/lib/display-subscriber.ts | 188 ++
src/lib/invite-link.ts | 35 +
src/lib/media-constraints.ts | 2 +-
src/lib/media-upload-service.ts | 26 +-
src/lib/pending-connect.ts | 16 +
src/lib/realtime-manager.ts | 11 +-
src/lib/realtime-schemas.ts | 4 +-
src/lib/supabase-adapter.ts | 53 +-
src/lib/tauri-adapter.ts | 45 +-
src/main.tsx | 7 +
src/routeTree.gen.ts | 64 +
src/routes/__root.tsx | 10 +-
src/routes/__tests__/connect.test.tsx | 114 +
src/routes/__tests__/setup.test.tsx | 161 +
src/routes/_authenticated/builder.tsx | 91 +-
.../_authenticated/events.$templateId.tsx | 19 +
src/routes/_authenticated/library.tsx | 8 +-
src/routes/_authenticated/log.$workoutId.tsx | 27 +
src/routes/_authenticated/profile.tsx | 34 +-
src/routes/connect.tsx | 36 +
src/routes/display.tsx | 183 ++
src/routes/setup.tsx | 409 ++-
src/stores/__tests__/display-store.test.ts | 339 ++
src/stores/active-workout-store.ts | 39 +
src/stores/display-store.ts | 185 ++
src/test/mocks/data-adapter.ts | 1 +
src/test/mocks/supabase-client.ts | 17 +-
src/test/render-helpers.tsx | 1 +
supabase/config.toml | 6 +
supabase/functions/_test-utils/helpers.ts | 57 +-
.../functions/_test-utils/mock-supabase.ts | 8 +-
.../functions/chat-media-signed-url/index.ts | 183 +-
.../chat-media-signed-url/index_test.ts | 124 +-
.../functions/chat-media-upload-url/index.ts | 73 +-
.../chat-media-upload-url/index_test.ts | 54 +-
.../functions/chat-media-webhook/index.ts | 39 +-
.../chat-media-webhook/index_test.ts | 414 ++-
.../functions/display-idle-snapshot/index.ts | 148 +
.../20260403000002_media_storage.sql | 6 +-
...3000003_session_template_last_assigned.sql | 2 +
.../20260404000001_add_display_visible.sql | 12 +
.../20260404000002_get_secret_function.sql | 27 +
...260404000003_get_display_idle_sessions.sql | 53 +
...ptimize_direct_conversation_uniqueness.sql | 122 +
...4000005_add_participant_delete_trigger.sql | 37 +
...0404000006_fix_event_metadata_comments.sql | 19 +
supabase/seed-reviewer.sql | 630 ++++
vitest.config.ts | 2 +-
320 files changed, 28974 insertions(+), 2637 deletions(-)
create mode 100644 .claude/rules/error-handling.md
create mode 100644 .claude/rules/state-management.md
create mode 100644 .claude/rules/typescript-conventions.md
create mode 100644 .github/BRANCH_PROTECTION.md
create mode 100644 .github/README.md
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/release.yml
create mode 100644 Context/Backlog/error-boundary-strategy.md
create mode 100644 Context/Decisions/ADR-006-discovery-endpoint-format.md
create mode 100644 Context/Decisions/ADR-007-github-actions-cicd.md
create mode 100644 Context/Decisions/ADR-007-transient-store-deep-link-state.md
create mode 100644 Context/Decisions/ADR-008-dual-export-zod-schemas.md
create mode 100644 Context/Decisions/ADR-008-tauri-error-kind-sync.md
create mode 100644 Context/Features/008-display-broadcast-infrastructure/Spec.md
create mode 100644 Context/Features/008-display-broadcast-infrastructure/Steps.md
create mode 100644 Context/Features/008-display-broadcast-infrastructure/Tech.md
create mode 100644 Context/Features/009-discovery-client-server-url/Spec.md
create mode 100644 Context/Features/009-discovery-client-server-url/Steps.md
create mode 100644 Context/Features/009-discovery-client-server-url/Tech.md
create mode 100644 Context/Features/009-display-route-board/Spec.md
create mode 100644 Context/Features/009-display-route-board/Steps.md
create mode 100644 Context/Features/009-display-route-board/Tech.md
create mode 100644 Context/Features/009-idle-mode/Spec.md
create mode 100644 Context/Features/009-idle-mode/Steps.md
create mode 100644 Context/Features/009-idle-mode/Tech.md
create mode 100644 Context/Features/010-Progressive-Disclosure/Spec.md
create mode 100644 Context/Features/010-Progressive-Disclosure/Steps.md
create mode 100644 Context/Features/010-Progressive-Disclosure/Tech.md
create mode 100644 Context/Features/010-qr-code-gen-scan/Spec.md
create mode 100644 Context/Features/010-qr-code-gen-scan/Steps.md
create mode 100644 Context/Features/010-qr-code-gen-scan/Tech.md
create mode 100644 Context/Features/011-ci-cd-pipeline/Spec.md
create mode 100644 Context/Features/011-ci-cd-pipeline/Steps.md
create mode 100644 Context/Features/011-ci-cd-pipeline/Tech.md
create mode 100644 Context/Features/011-deep-link-handler/Spec.md
create mode 100644 Context/Features/011-deep-link-handler/Steps.md
create mode 100644 Context/Features/011-deep-link-handler/Tech.md
create mode 100644 Context/Features/012-TauriAdapter-Tests/Spec.md
create mode 100644 Context/Features/012-TauriAdapter-Tests/Steps.md
create mode 100644 Context/Features/012-TauriAdapter-Tests/Tech.md
create mode 100644 Context/QuickPlans/2026-04-03-recently-used-templates.md
create mode 100644 Context/QuickPlans/2026-04-03-week-grid-density.md
create mode 100644 Context/QuickPlans/2026-04-04-address-all-backlog.md
create mode 100644 Context/QuickPlans/2026-04-04-enhancement-issues-batch.md
create mode 100644 Context/QuickPlans/2026-04-04-gh-issues-32-51.md
create mode 100644 Context/QuickPlans/2026-04-04-s6-find-direct-conversation-n1.md
create mode 100644 Context/Reviews/0005-pr59-display-broadcast-review.md
create mode 100644 Context/Reviews/0006-discovery-client-server-url-2026-04-04.md
create mode 100644 Context/Reviews/0006-idle-mode-review.md
create mode 100644 Context/Reviews/0006-pr60-display-route-board-review.md
create mode 100644 Context/Reviews/0007-pr64-qr-code-gen-scan-review.md
create mode 100644 Context/Reviews/0007-pr65-progressive-disclosure-review.md
create mode 100644 Context/Reviews/0008-pr66-deep-link-handler-review.md
create mode 100644 Context/Reviews/0008-pr67-ci-cd-pipeline-review.md
create mode 100644 Context/Reviews/0009-pr71-enhancement-batch-review.md
create mode 100644 Context/Reviews/0009-pr72-tauri-adapter-tests-review.md
create mode 100644 docs/13-prd-remote-display.md
create mode 100644 docs/onboarding-refactoring-plans.md
create mode 100644 e2e/smoke.spec.ts
create mode 100644 playwright.config.ts
create mode 100644 src-tauri/capabilities/mobile.json
create mode 100644 src-tauri/gen/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
create mode 100644 src-tauri/gen/android/app/src/main/res/values/ic_launcher_background.xml
create mode 100644 src-tauri/icons/64x64.png
create mode 100644 src-tauri/icons/ios/AppIcon-20x20@1x.png
create mode 100644 src-tauri/icons/ios/AppIcon-20x20@2x-1.png
create mode 100644 src-tauri/icons/ios/AppIcon-20x20@2x.png
create mode 100644 src-tauri/icons/ios/AppIcon-20x20@3x.png
create mode 100644 src-tauri/icons/ios/AppIcon-29x29@1x.png
create mode 100644 src-tauri/icons/ios/AppIcon-29x29@2x-1.png
create mode 100644 src-tauri/icons/ios/AppIcon-29x29@2x.png
create mode 100644 src-tauri/icons/ios/AppIcon-29x29@3x.png
create mode 100644 src-tauri/icons/ios/AppIcon-40x40@1x.png
create mode 100644 src-tauri/icons/ios/AppIcon-40x40@2x-1.png
create mode 100644 src-tauri/icons/ios/AppIcon-40x40@2x.png
create mode 100644 src-tauri/icons/ios/AppIcon-40x40@3x.png
create mode 100644 src-tauri/icons/ios/AppIcon-512@2x.png
create mode 100644 src-tauri/icons/ios/AppIcon-60x60@2x.png
create mode 100644 src-tauri/icons/ios/AppIcon-60x60@3x.png
create mode 100644 src-tauri/icons/ios/AppIcon-76x76@1x.png
create mode 100644 src-tauri/icons/ios/AppIcon-76x76@2x.png
create mode 100644 src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
create mode 100644 src-tauri/migrations/008_add_display_visible.sql
create mode 100644 src-tauri/migrations/009_session_template_last_assigned.sql
delete mode 100644 src/App.css
delete mode 100644 src/App.tsx
create mode 100644 src/components/builders/help-content.ts
create mode 100644 src/components/builders/visibility-maps.ts
create mode 100644 src/components/display/__tests__/clock-display.test.tsx
create mode 100644 src/components/display/__tests__/display-mode-transition.test.tsx
create mode 100644 src/components/display/__tests__/idle-view.test.tsx
create mode 100644 src/components/display/__tests__/use-display-mode.test.ts
create mode 100644 src/components/display/__tests__/use-idle-snapshot.test.ts
create mode 100644 src/components/display/board-view.tsx
create mode 100644 src/components/display/clock-display.tsx
create mode 100644 src/components/display/connection-footer.tsx
create mode 100644 src/components/display/display-mode-transition.tsx
create mode 100644 src/components/display/focused-view.tsx
create mode 100644 src/components/display/idle-placeholder.tsx
create mode 100644 src/components/display/idle-view.tsx
create mode 100644 src/components/display/page-indicator.tsx
create mode 100644 src/components/display/rest-timer-display.tsx
create mode 100644 src/components/display/session-card.tsx
create mode 100644 src/components/display/set-table.tsx
create mode 100644 src/components/display/types.ts
create mode 100644 src/components/display/use-display-mode.ts
create mode 100644 src/components/display/use-idle-snapshot.ts
create mode 100644 src/components/profile/__tests__/backend-settings.test.tsx
create mode 100644 src/components/program-builder/block-type-selector.tsx
create mode 100644 src/components/program-builder/confirm-delete-dialog.tsx
create mode 100644 src/components/program-builder/session-detail-utils.ts
create mode 100644 src/components/program-builder/session-detail.tsx
create mode 100644 src/components/session-builder/collapsed-fields-row.tsx
create mode 100644 src/components/ui/alert-dialog.tsx
create mode 100644 src/components/ui/collapsible.tsx
create mode 100644 src/components/ui/drawer.tsx
create mode 100644 src/components/ui/dropdown-menu.tsx
create mode 100644 src/components/ui/help-trigger.tsx
create mode 100644 src/components/ui/popover.tsx
create mode 100644 src/components/workout/__tests__/crash-recovery-dialog.test.tsx
create mode 100644 src/components/workout/push-to-display-button.tsx
create mode 100644 src/domain/types/__tests__/chat.test.ts
create mode 100644 src/domain/types/__tests__/display-snapshot.test.ts
create mode 100644 src/domain/types/display-snapshot.ts
create mode 100644 src/hooks/__tests__/use-media-upload.test.ts
create mode 100644 src/hooks/__tests__/use-timer-interpolation.test.ts
create mode 100644 src/hooks/use-display-broadcast.ts
create mode 100644 src/hooks/use-elapsed-time.ts
create mode 100644 src/hooks/use-media-query.ts
create mode 100644 src/hooks/use-timer-interpolation.ts
create mode 100644 src/lib/__tests__/auth-events.test.tsx
create mode 100644 src/lib/__tests__/deep-link-handler.test.ts
create mode 100644 src/lib/__tests__/discovery.test.ts
create mode 100644 src/lib/__tests__/display-publisher-hello.test.ts
create mode 100644 src/lib/__tests__/display-publisher.test.ts
create mode 100644 src/lib/__tests__/display-snapshot.test.ts
create mode 100644 src/lib/__tests__/display-subscriber.test.ts
create mode 100644 src/lib/__tests__/invite-link.test.ts
create mode 100644 src/lib/__tests__/media-provider.test.ts
create mode 100644 src/lib/__tests__/tauri-adapter.test.ts
create mode 100644 src/lib/deep-link-handler.ts
create mode 100644 src/lib/discovery.ts
create mode 100644 src/lib/display-publisher.ts
create mode 100644 src/lib/display-snapshot.ts
create mode 100644 src/lib/display-subscriber.ts
create mode 100644 src/lib/invite-link.ts
create mode 100644 src/lib/pending-connect.ts
create mode 100644 src/routes/__tests__/connect.test.tsx
create mode 100644 src/routes/__tests__/setup.test.tsx
create mode 100644 src/routes/_authenticated/events.$templateId.tsx
create mode 100644 src/routes/connect.tsx
create mode 100644 src/routes/display.tsx
create mode 100644 src/stores/__tests__/display-store.test.ts
create mode 100644 src/stores/display-store.ts
create mode 100644 supabase/functions/display-idle-snapshot/index.ts
create mode 100644 supabase/migrations/20260403000003_session_template_last_assigned.sql
create mode 100644 supabase/migrations/20260404000001_add_display_visible.sql
create mode 100644 supabase/migrations/20260404000002_get_secret_function.sql
create mode 100644 supabase/migrations/20260404000003_get_display_idle_sessions.sql
create mode 100644 supabase/migrations/20260404000004_optimize_direct_conversation_uniqueness.sql
create mode 100644 supabase/migrations/20260404000005_add_participant_delete_trigger.sql
create mode 100644 supabase/migrations/20260404000006_fix_event_metadata_comments.sql
create mode 100644 supabase/seed-reviewer.sql
diff --git a/.claude/agents/quality-engineer.md b/.claude/agents/quality-engineer.md
index e3a7f340..02f56450 100755
--- a/.claude/agents/quality-engineer.md
+++ b/.claude/agents/quality-engineer.md
@@ -268,11 +268,11 @@ Your goal is to prevent quality issues through intelligent pattern enforcement w
## Validation Mode
-When dispatched via `/team-plan` or `/build` to validate completed work, the quality-engineer operates in **validation mode** -- a read-only inspection workflow focused on verifying builder output against acceptance criteria.
+When dispatched via `/blueprint` or `/impl` to validate completed work, the quality-engineer operates in **validation mode** -- a read-only inspection workflow focused on verifying builder output against acceptance criteria.
### When Activated
-- Dispatched as a validator by team orchestration commands (`/team-plan`, `/build`)
+- Dispatched as a validator by team orchestration commands (`/blueprint`, `/impl`)
- Assigned a task whose purpose is to verify or validate another agent's completed work
- Task description references "validate", "verify", or "inspect" against acceptance criteria
diff --git a/.claude/commands/blueprint.md b/.claude/commands/blueprint.md
index 3d09e097..4939e8b1 100755
--- a/.claude/commands/blueprint.md
+++ b/.claude/commands/blueprint.md
@@ -29,7 +29,7 @@ Generate a complete 4-phase plan for major features or complex tasks. Creates Sp
1. Determine feature number (next available NNN in Context/Features/)
2. Use the `plan-spec` skill to create `Context/Features/NNN-FeatureName/Spec.md`
3. Include: Overview, Problem Statement, User Stories, Requirements (Must/Should/Won't), Testable Assertions, Open Questions, Dependencies
-4. Present to user for review and approval before proceeding
+4. Present to user for review and approval before proceeding
### Step 3: Create Tech.md (Phase 2)
1. Use the `plan-research` skill to create `Context/Features/NNN-FeatureName/Tech.md`
@@ -54,13 +54,15 @@ Present all three documents for review:
- Spec.md summary: key requirements and assertions
- Tech.md summary: architecture decisions and ADRs created
- Steps.md summary: task count, team composition, wave structure
-- Estimated execution approach: `/impl` vs `/team-impl`
Confirm or adjust before proceeding to implementation.
-Suggest next step:
-- `/impl NNN` to execute via sub-agents
-- `/team-impl NNN` to execute via Agent Teams (if cross-domain coordination needed)
+### Step 6: Suggest execution command
+ALWAYS end by recommending the most appropriate execution command with the feature number:
+- `/impl NNN` -- default for most features (hub-and-spoke sub-agent orchestration)
+- `/team-impl NNN` -- when cross-domain coordination is needed (peer-to-peer Agent Teams)
+
+Choose based on the Steps.md content: if tasks span multiple stacks with tight integration points, recommend `/team-impl`. Otherwise recommend `/impl`.
## Rules
- Each phase requires user approval before proceeding to the next
@@ -68,4 +70,4 @@ Suggest next step:
- If the feature is too small for 4-phase planning, suggest `/quick` instead
- ADRs are created during Phase 2 for any significant architectural decision
- Phase 3 must produce a Steps.md with team orchestration metadata
-- Note any ADRs created during planning in the final summary
+- Note any ADRs created during planning in the final summary
diff --git a/.claude/commands/impl.md b/.claude/commands/impl.md
index 5c29818c..591623a1 100755
--- a/.claude/commands/impl.md
+++ b/.claude/commands/impl.md
@@ -28,7 +28,7 @@ Execute a planned feature by reading Steps.md and delegating tasks to specialist
5. Parse Steps.md to extract:
- Team Members (names, agent types, resume behavior)
- Task list with assignments, dependencies, parallel flags
- - Milestones with testable assertion references and contract declarations
+ - Milestones with testable assertion references and contract declarations
### Step 3: Initialize execution session
1. Create `.cortex/session.md` with plan reference and team roster
@@ -62,7 +62,7 @@ For each wave:
**5b. Monitor and coordinate**
- Track task completion via TaskList/TaskUpdate
- Log agent activity to `.cortex/session.md`
-- If an agent is blocked, provide guidance or reassign
+- If an agent is blocked, provide guidance or reassign
**5c. Milestone checkpoint**
When all tasks before a milestone are complete:
@@ -92,7 +92,7 @@ When all tasks before a milestone are complete:
5. Update Steps.md Progress section with last completed milestone
-6. **Continue to next wave** with extracted contracts injected into agent prompts
+6. **Continue to next wave** with extracted contracts injected into agent prompts
### Step 6: Final validation
After all waves complete:
@@ -108,7 +108,7 @@ After all waves complete:
4. Present completion report
5. Suggest next steps:
- Run `/qa` for parallel stack-specific quality checks
- - Run `/review` to capture findings
+ - Run `/review-capture` to capture findings
- Run `/commit` when ready
## Agent Prompt Template
@@ -150,4 +150,4 @@ Think hard and provide thorough implementation.
- If context window is filling up, save progress to Steps.md and `.cortex/session.md`
- Parallel agents must not modify the same files
- Contract files must exist before downstream agents are spawned
-- The quality-engineer always runs last in validation mode (read-only)
+- The quality-engineer always runs last in validation mode (read-only)
diff --git a/.claude/commands/quick.md b/.claude/commands/quick.md
index 99ed39a8..42a7b4f7 100755
--- a/.claude/commands/quick.md
+++ b/.claude/commands/quick.md
@@ -1,8 +1,8 @@
----
-description: Lightweight single-file plan for small tasks
-model: sonnet
----
-
+---
+description: Lightweight single-file plan for small tasks
+model: sonnet
+---
+
# Quick Plan
Generate a lightweight, single-file plan for small tasks or exploratory work. Skips full 4-phase workflow.
@@ -45,7 +45,13 @@ This command routes to a lower-cost model tier for rapid planning:
### Step 3: Review with user
1. Present the quick plan
2. Confirm or adjust approach
-3. If approved, user can proceed with implementation
-### Step 4: Clean up
+### Step 4: Suggest execution command
+ALWAYS end by recommending the most appropriate execution command:
+- `/impl` -- default for most quick tasks (hub-and-spoke sub-agent orchestration)
+- `/team-impl` -- when the task spans multiple stacks with tight integration points (peer-to-peer Agent Teams)
+
+Quick plans typically use `/impl` since they are smaller in scope.
+
+### Step 5: Clean up
When task is complete, move quick plan to `Context/Backlog/Done/` for reference, or delete if not needed.
diff --git a/.claude/commands/review-resolve.md b/.claude/commands/review-resolve.md
index f3e5b760..021dc18c 100755
--- a/.claude/commands/review-resolve.md
+++ b/.claude/commands/review-resolve.md
@@ -1,11 +1,11 @@
---
-description: Work through captured review findings by category
+description: Work through captured review findings with interactive triage
model: opus
---
# Review Resolution
-Work through a captured review file, resolve each finding by category, generate appropriate planning artifacts, and update the review file with resolution status.
+Interactively triage and resolve review findings. Claude recommends actions, but the user makes every decision.
## When to use
- At the start of a fresh session after a review was captured
@@ -14,53 +14,44 @@ Work through a captured review file, resolve each finding by category, generate
## Workflow
-**CRITICAL:** This should run in a **fresh session** with full context budget, not in the same session that performed the review.
+**CRITICAL:** Run in a **fresh session** with full context budget -- not the session that performed the review.
### Step 1: Load the review file
-1. If user specified a file, load that one
-2. Otherwise, list files in `Context/Reviews/` and find the most recent unresolved
-3. If no unresolved reviews exist, inform the user
-4. Load the associated feature's Spec.md, Steps.md, and Tech.md for context
+Find the specified or most recent unresolved review in `Context/Reviews/`. Load associated Spec.md, Steps.md, and Tech.md for context. Parse all findings into a working list.
-### Step 2: Work through Fix-Now findings ([FIX])
-Process each in priority order (Critical, High, Medium, Low):
-1. Open referenced file and line
-2. Apply the fix
-3. Update finding: Status = Fixed, Resolution = description of change
-4. If fix is more complex than expected, reclassify to [TASK] or [ADR]
+### Step 2: Bulk accept
+Present a summary table of findings grouped by category and severity. Offer to accept suggested actions in bulk -- the category from capture IS the suggested action. User can accept all, accept by category, or skip to individual triage. Update all affected findings on disk after each bulk accept.
-### Step 3: Work through Missing Task findings ([TASK])
-For each finding:
-1. Determine next available S### number from Steps.md
-2. Add new task to Steps.md in the appropriate phase
-3. If related to a Testable Assertion, verify it exists in Spec.md
-4. Update finding: Status = Task created, Resolution = S### reference
-5. Do NOT implement the task now -- just track it
+### Step 3: Individual triage
+Process each remaining finding one at a time. For each, show the finding details and Claude's recommended action, then present a numbered menu:
-### Step 4: Work through Architectural Concern findings ([ADR])
-For each finding:
-1. Present the concern with context (what reviewer found, what Tech.md says)
-2. Ask user to decide:
- - **Create ADR** -- use the adr-create skill
- - **Dismiss** -- user provides reason
- - **Defer** -- mark for later consideration
-3. Update finding status accordingly
+1. **Fix** -- apply the fix inline
+2. **Task** -- add S### entry to Steps.md
+3. **ADR** -- create architecture decision record
+4. **Rule** -- add/update convention in rule file
+5. **Defer** -- push to tracker (`.cortex/config.json`) or backlog
+6. **Discard** -- drop the finding
-### Step 5: Work through Convention Gap findings ([RULE])
-For each finding:
-1. Identify which rule file should be updated
-2. Present the suggested addition to the user
-3. If approved, add to the appropriate rule file
-4. Update finding status
+Wait for the user's choice before proceeding. Save progress to the review file after each decision.
-### Step 6: Update review file header
-After processing all findings, update status and add Resolution Summary table.
+### Step 4: Execute actions
+Process all triaged findings in order: Fix, Task, ADR, Rule, Defer, Discard. Update each finding's status and resolution in the review file.
-### Step 7: Commit resolution work
-Use the impl-commit skill. Commit message should reference the review file.
+### Step 5: Finalize
+Update the review file header and add a Resolution Summary table. Commit via impl-commit skill referencing the review file.
-### Step 8: Suggest next steps
-- New tasks added -- "Run /impl to work through new tasks"
+### Step 6: Suggest next steps
+- New tasks -- "Run /impl to work through new tasks"
- ADRs created -- "Review the new ADRs"
- Rules updated -- "Updated rules apply to future code automatically"
+- Items deferred -- "Check the tracker/backlog for deferred items"
- Always -- "Run /review-verify to confirm all findings resolved"
+
+## Key rules
+- NEVER auto-resolve, auto-dismiss, or skip findings
+- Claude recommends but user decides
+- Discard does not require a reason
+- Bulk accept phase comes before individual triage phase
+- The review file is the single source of truth
+
+See `core/skills/review-resolve/SKILL.md` for full workflow details.
diff --git a/.claude/commands/review-verify.md b/.claude/commands/review-verify.md
index fb195712..16a4031e 100755
--- a/.claude/commands/review-verify.md
+++ b/.claude/commands/review-verify.md
@@ -1,8 +1,8 @@
----
-description: Verify all review findings have been addressed
-model: sonnet
----
-
+---
+description: Verify all review findings have been addressed
+model: sonnet
+---
+
# Review Verification
Verify that review feedback has been addressed and the implementation aligns with decisions made during review.
@@ -49,7 +49,15 @@ Briefly:
- New issues found
5. Mark review as verified in capture file
-### Step 3: Confirm review status
+### Step 3: Update review file on PASS
+If the agent's verdict is **PASS** (or **WARNING** with only deferred items):
+1. Open the review capture file
+2. Check the box: `- [x] Review verified by review-verify agent`
+3. This is the only edit -- do not modify any other content in the review file
+
+If the verdict is **FAIL**, do not check the box.
+
+### Step 4: Confirm review status
Present results:
- Which reviews were checked
- Status of each review
diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md
index f953b771..2774e3d5 100644
--- a/.claude/commands/setup.md
+++ b/.claude/commands/setup.md
@@ -92,7 +92,24 @@ Check that Cortex hooks are wired in `.claude/settings.json`:
If hooks are missing, inform the user and suggest running `bun run deploy` to install them,
or offer to add them via the merge-settings script.
-### Step 7: Summary
+### Step 7: Detect issue tracker
+
+Check for available issue tracker CLIs so `/review-resolve` can defer findings to external issues.
+
+1. **Check GitHub CLI**: Run `gh auth status` to see if `gh` is installed and authenticated
+2. **Check GitLab CLI**: Run `glab auth status` to see if `glab` is installed and authenticated
+3. **Resolve which tracker to use**:
+ - If both are found and authenticated, ask the user which they prefer
+ - If only one is found and authenticated, confirm it with the user
+ - If neither is found (or not authenticated), inform the user that deferred findings will use the local backlog instead
+4. **Store the result** in `.cortex/config.json`:
+ - GitHub: `{ "issueTracker": { "type": "github", "cli": "gh" } }`
+ - GitLab: `{ "issueTracker": { "type": "gitlab", "cli": "glab" } }`
+ - No tracker: `{ "issueTracker": { "type": "backlog" } }`
+
+If `.cortex/config.json` already exists, merge the `issueTracker` key into it without overwriting other settings.
+
+### Step 8: Summary
Present what was created/verified:
- Stacks detected
@@ -100,6 +117,7 @@ Present what was created/verified:
- CLAUDE.md status (created or updated)
- Stack rules created
- Hooks status
+- Issue tracker configured (GitHub, GitLab, or local backlog)
Suggest next steps:
1. Review the generated CLAUDE.md
diff --git a/.claude/rules/error-handling.md b/.claude/rules/error-handling.md
new file mode 100644
index 00000000..105394a8
--- /dev/null
+++ b/.claude/rules/error-handling.md
@@ -0,0 +1,64 @@
+# Error Handling Conventions
+
+## Error Type Semantics
+
+Error types at system boundaries must distinguish input validation failures from network/transport failures. Use distinct error variants (e.g., `INVALID_INPUT` vs `NETWORK_ERROR`) so callers can branch on error type for retry logic, user messaging, and logging without ambiguity.
+
+## Catch Blocks
+
+Never use bare `catch {}`. Always capture the error parameter and log it with a bracketed module prefix:
+
+```typescript
+// Bad
+try { ... } catch { return fallback }
+
+// Good
+try { ... } catch (err) {
+ console.error('[module-name] Description:', err)
+ return fallback
+}
+```
+
+This ensures errors are traceable in the dev console and production logs.
+
+## Adapter Boundary Fallbacks
+
+Safety-net coercions at adapter boundaries (null-to-default, type coercions) should log at warn level when the fallback triggers for fields expected to be non-nullable. Silent coercion hides data integrity issues.
+
+```typescript
+// Bad -- silent coercion
+function intToBool(val: number | null, fallback = false): boolean {
+ return val == null ? fallback : val !== 0
+}
+
+// Good -- logs when safety net triggers
+function intToBool(val: number | null, field: string, fallback = false): boolean {
+ if (val == null) {
+ console.warn(`[adapter] ${field}: expected non-null int, using fallback ${fallback}`)
+ return fallback
+ }
+ return val !== 0
+}
+```
+
+## User-Action Guard Clauses
+
+Guard clauses in user-action handlers (save, delete, submit) must never silently return. Always log with `[module-name]` prefix and set a user-facing error state (e.g., `setErrors()`, toast notification).
+
+```typescript
+// Bad
+const handleSave = () => {
+ if (!userId) return
+ // ...
+}
+
+// Good
+const handleSave = () => {
+ if (!userId) {
+ console.error('[my-module] Cannot save: no authenticated user')
+ setErrors((prev) => ({ ...prev, general: 'You must be signed in to save.' }))
+ return
+ }
+ // ...
+}
+```
diff --git a/.claude/rules/state-management.md b/.claude/rules/state-management.md
new file mode 100644
index 00000000..94c74435
--- /dev/null
+++ b/.claude/rules/state-management.md
@@ -0,0 +1,33 @@
+# State Management Conventions
+
+## Zustand Store Boundary Validation
+
+Zustand stores that accept domain-constrained values should validate at their own boundary, not rely solely on caller validation. This makes stores self-protecting against future callers that might bypass upstream validation.
+
+```typescript
+// Bad: relies on callers to validate
+setPending: (url, key) => set({ pending: { url, key } })
+
+// Good: validates at the store boundary
+setPending: (url, key) => {
+ if (!url.startsWith('https://') || !key) return
+ set({ pending: { url, key } })
+}
+```
+
+## Zustand + React Integration
+
+- Access store state in components via the hook: `const value = useMyStore((s) => s.value)`
+- Access store state outside React (event handlers, side effects) via `useMyStore.getState()`
+- For one-time initialization from a store, use `useState` initializer functions to avoid render-phase side effects:
+
+```typescript
+// Bad: setState during render
+const pending = usePendingConnect.getState().pending
+if (pending) {
+ setUrl(pending.url)
+} // side effect during render
+
+// Good: initialize via useState
+const [url, setUrl] = useState(() => usePendingConnect.getState().pending?.url ?? '')
+```
diff --git a/.claude/rules/typescript-conventions.md b/.claude/rules/typescript-conventions.md
new file mode 100644
index 00000000..6eb9a501
--- /dev/null
+++ b/.claude/rules/typescript-conventions.md
@@ -0,0 +1,25 @@
+# TypeScript Conventions
+
+## Exhaustive Record Constants
+
+For exhaustive Record constants, use `satisfies Record` without an explicit type annotation. This provides compile-time exhaustiveness checking while letting TypeScript infer narrower literal types.
+
+```typescript
+// Good
+const FOO = { ... } satisfies Record
+
+// Bad -- redundant annotation widens the type
+const FOO: Record = { ... } satisfies Record
+```
+
+## Domain-Keyed Record Types
+
+All Record types keyed by domain union types (SessionType, BlockType, GroupType, SetSchemeType, etc.) must use the union as the key type, not `string`. Use `satisfies` for exhaustiveness. This ensures new domain variants cause compile errors instead of silent fallbacks.
+
+```typescript
+// Good
+const STYLES = { ... } satisfies Record
+
+// Bad -- new BlockType variants silently fall back
+const STYLES: Record = { ... }
+```
diff --git a/.claude/settings.json b/.claude/settings.json
index 6dc83e28..fa03f6a3 100755
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -32,6 +32,14 @@
"command": "bun run hooks/skill-activation/skill-activation.ts"
}
]
+ },
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bun run /home/rghamilton3/workspace/cortex/output/.claude/hooks/skill-activation/skill-activation.ts"
+ }
+ ]
}
],
"PreCompact": [
@@ -43,6 +51,15 @@
"async": true
}
]
+ },
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bun run /home/rghamilton3/workspace/cortex/output/.claude/hooks/context-recovery/conv-backup.ts",
+ "async": true
+ }
+ ]
}
],
"PostToolUse": [
@@ -54,6 +71,15 @@
"command": "bun run hooks/formatter/formatter.ts"
}
]
+ },
+ {
+ "matcher": "Write|Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bun run /home/rghamilton3/workspace/cortex/output/.claude/hooks/formatter/formatter.ts"
+ }
+ ]
}
]
}
diff --git a/.claude/skills/codebase-navigation/SKILL.md b/.claude/skills/codebase-navigation/SKILL.md
index 8e9ed53b..7cbb1d03 100755
--- a/.claude/skills/codebase-navigation/SKILL.md
+++ b/.claude/skills/codebase-navigation/SKILL.md
@@ -5,7 +5,7 @@ description: Codebase structure and efficient navigation tips
# Directory Structure
-## 🏗️ Framework Organization
+## 🏗️ Claude Fast Framework Organization
```
.claude/
diff --git a/.claude/skills/impl-start/SKILL.md b/.claude/skills/impl-start/SKILL.md
index c69a4b7d..abb2b9b5 100755
--- a/.claude/skills/impl-start/SKILL.md
+++ b/.claude/skills/impl-start/SKILL.md
@@ -25,7 +25,7 @@ Begin or resume implementation of a planned feature. Restores full context from
- `Tech.md` -- architecture decisions and stack details
- `Steps.md` -- task list, team, milestones, contracts
4. Read all accepted ADRs in `Context/Decisions/` relevant to this feature
-5. Read active `.claude/rules/` for stacks this feature touches
+5. Read active `.claude/rules/` for stacks this feature touches
### Step 2: Determine execution state
1. Parse Steps.md Progress section (Status, Current task, Last milestone)
@@ -66,7 +66,7 @@ For quick plans (single-file plan, no team orchestration):
- Mark tasks complete as they are done
### Step 6: Handle completion
-- If all tasks complete: update Status to "Complete", suggest `/qa` and `/review`
+- If all tasks complete: update Status to "Complete", suggest `/qa` and `/review-capture`
- If context window filling: save progress to Steps.md and `.cortex/session.md`, tell user to start a new session
- If plan is wrong: pause, discuss with user, create ADR or update Spec.md
@@ -77,4 +77,4 @@ For quick plans (single-file plan, no team orchestration):
- Test tasks (S###-T) must produce actual test files, not just comments
- Doc tasks (S###-D) must modify the specified document
- If an ADR is needed at a milestone, use the adr-create skill before continuing
-- Save progress to Steps.md and `.cortex/session.md` frequently
+- Save progress to Steps.md and `.cortex/session.md` frequently
diff --git a/.claude/skills/project-init/SKILL.md b/.claude/skills/project-init/SKILL.md
index 5abdebd8..9b0d6410 100755
--- a/.claude/skills/project-init/SKILL.md
+++ b/.claude/skills/project-init/SKILL.md
@@ -1,7 +1,7 @@
# Skill: Project Initialization Templates
## Purpose
-Provides all template content needed by the `/setup` command.
+Provides all template content needed by the `/context-engine:init` command.
This skill is not invoked directly — it's a reference library.
## Templates
@@ -563,3 +563,17 @@ exit 0
}
}
```
+
+---
+
+### .cortex/config.json
+
+Created during scaffolding if it does not already exist. Stores project-level Cortex configuration.
+
+```json
+{
+ "issueTracker": {
+ "type": "backlog"
+ }
+}
+```
diff --git a/.claude/skills/review-capture/SKILL.md b/.claude/skills/review-capture/SKILL.md
index 42ed681e..a38cc031 100755
--- a/.claude/skills/review-capture/SKILL.md
+++ b/.claude/skills/review-capture/SKILL.md
@@ -29,25 +29,29 @@ from a GitLab MR), ask them to paste or describe the findings.
3. If no match, ask the user which feature this review belongs to
4. Load `Spec.md` and `Steps.md` from the feature directory for cross-referencing
-### Step 3: Triage each finding
+### Step 3: Triage errors and warnings
-Categorize every finding into exactly one of four categories:
+Separate the review output into two groups:
+- **Errors and warnings** -- triage automatically (this step)
+- **Suggestions** -- triage interactively with the user (Step 4)
-**[FIX] Fix-now** — Code quality issues, convention violations, missing error handling,
+For errors and warnings, categorize each into exactly one of four categories:
+
+**[FIX] Fix-now** -- Code quality issues, convention violations, missing error handling,
typos, formatting. These get fixed inline during the resolution pass. No planning
artifacts needed.
Signals: linter warnings, style violations, missing null checks, bare excepts,
unused imports, wrong function signatures, off-by-one errors.
-**[TASK] Missing task** — Work that should have been in Steps.md but wasn't. Needs a
+**[TASK] Missing task** -- Work that should have been in Steps.md but wasn't. Needs a
new S### task number so it gets tracked and verified. This includes missing tests,
undocumented endpoints, edge cases the plan didn't anticipate.
Signals: "no test for X", "this case isn't handled", "docs not updated for Y",
missing accessibility, missing validation on an input the spec didn't mention.
-**[ADR] Architectural concern** — Design problems that require a decision. Wrong
+**[ADR] Architectural concern** -- Design problems that require a decision. Wrong
abstraction, missing separation of concerns, security gaps, performance anti-patterns,
dependency issues. These warrant an ADR because they change the technical approach.
@@ -55,21 +59,97 @@ Signals: "this should be a separate service", "you're coupling X to Y", "this wo
scale past N", "this auth approach has a vulnerability", "the spec assumed X but
the implementation does Y".
-**[RULE] Convention gap** — A pattern the reviewer flagged repeatedly that the current
+**[RULE] Convention gap** -- A pattern the reviewer flagged repeatedly that the current
rules don't catch. The finding should become a rule update so future code gets it
right automatically.
Signals: same issue in 3+ files, reviewer says "we should always do X", pattern not
mentioned in any `.claude/rules/*.md` file, the post-edit hook doesn't catch it.
-### Step 4: Assign finding IDs
+### Step 4: Triage suggestions interactively
+
+If the review source produced suggestions (common with pr-review-toolkit), present
+them to the user for interactive triage. Errors and warnings skip this step -- they
+were already categorized in Step 3.
+
+**CRITICAL:** Never silently drop, auto-classify, or skip suggestions. Every
+suggestion must get an explicit user decision.
+
+#### Step 4a: Bulk triage
+
+List all suggestions with their titles so the user can see what they are deciding on:
+
+```
+## Suggestions from pr-review (N total)
+
+ 1. Consider extracting auth logic into a shared middleware
+ 2. Database queries could use connection pooling
+ 3. Error messages should include request IDs for tracing
+ 4. The retry logic could benefit from exponential backoff
+ 5. Unused CSS variables in theme.css
+
+ Bulk actions:
+ a. Capture all -- add all to review file (Claude assigns categories)
+ b. Dismiss all -- drop everything
+ c. Skip -- go straight to individual triage
+
+ Choose, or type "done" for individual triage:
+```
+
+If the user picks "Capture all", Claude assigns a category ([FIX], [TASK], [ADR],
+[RULE]) to each suggestion and adds them to the findings list. Update the review
+file on disk as a batch.
+
+If the user picks "Dismiss all", drop all suggestions. No reason required for bulk.
+
+#### Step 4b: Individual triage
+
+For each remaining suggestion that was not handled in bulk, present:
+
+```
+Suggestion X of Y: "Consider extracting auth logic into a shared middleware"
+ File: src/routes/api/users.ts:45
+ Detail: Auth check is duplicated across 4 route handlers
+
+ Recommended: Capture as [TASK] -- tracked refactoring work, not an immediate fix
+
+ Actions:
+ 1. Capture (as [TASK], or specify: fix/task/adr/rule)
+ 2. Backlog
+ 3. Dismiss
+
+ Choose [1-3]:
+```
+
+**Action handling:**
+
+- **1 (Capture):** Accept Claude's recommended category. The user can override by
+ responding "1 fix", "1 adr", etc. The suggestion becomes a finding with the chosen
+ category and enters the normal flow (gets an ID in Step 5).
+
+- **2 (Backlog):** Append an entry to `Context/Backlog/Ideas.md`:
+ ```markdown
+ ### [Suggestion title]
+ **Added:** YYYY-MM-DD
+ **Context:** [suggestion detail from pr-review]
+ **Related:** [file path from suggestion]
+ **Priority:** Low
+ ```
+ The suggestion is NOT added to the review file.
+
+- **3 (Dismiss):** Drop the suggestion. Not captured anywhere.
+
+After each individual decision, update the review file (or backlog file) on disk
+immediately. This ensures progress survives context loss.
+
+### Step 5: Assign finding IDs
Each finding gets a unique ID within the review: `P{review-number}-{sequence}`.
The review number increments based on existing files in `Context/Reviews/`.
Example: First review in the project → P1-001, P1-002, P1-003...
-### Step 5: Cross-reference with planning artifacts
+### Step 6: Cross-reference with planning artifacts
For each finding:
- Check if it relates to a Testable Assertion in Spec.md (note the assertion ID)
@@ -77,7 +157,7 @@ For each finding:
- Check if an existing ADR already covers the concern
- Note these references in the finding entry
-### Step 6: Write the review file
+### Step 7: Write the review file
Create `Context/Reviews/PR-{branch-short-name}-{YYYY-MM-DD}.md` with the
following structure:
@@ -143,7 +223,7 @@ following structure:
- [ ] Review verified by review-verify agent
```
-### Step 7: Present summary to user
+### Step 8: Present summary to user
After writing the file, present:
- Total findings by category
diff --git a/.claude/skills/review-resolve/SKILL.md b/.claude/skills/review-resolve/SKILL.md
index db5b50f9..7cc7ae00 100755
--- a/.claude/skills/review-resolve/SKILL.md
+++ b/.claude/skills/review-resolve/SKILL.md
@@ -1,152 +1,248 @@
-# Skill: Resolve Review Findings
-
-## Purpose
-Work through a captured review file, resolve each finding by category, generate
-appropriate planning artifacts, and update the review file with resolution status.
-
-## When to use
-- At the start of a fresh session after a review was captured
-- User says "resolve review", "fix review findings", "work through the review"
-- User references a specific review file in Context/Reviews/
-
-## Prerequisites
-- A review file must exist in `Context/Reviews/` (created by review-capture)
-- This should run in a **fresh session** with full context budget — not in the same
- session that performed the review
-
-## Workflow
-
-### Step 1: Load the review file
-1. If the user specified a file, load that one
-2. Otherwise, list files in `Context/Reviews/` and find the most recent with
- `Status: 🔴 Unresolved`
-3. If no unresolved reviews exist, tell the user — nothing to do
-4. Read the full review file
-5. Load the associated feature's Spec.md, Steps.md, and Tech.md for context
-
-### Step 2: Work through Fix-Now findings
-
-Process each `[FIX]` finding in priority order (Critical → High → Medium → Low):
-
-1. Open the referenced file and line
-2. Apply the fix
-3. Verify the fix doesn't break anything obvious (check imports, references)
-4. Update the finding in the review file:
- - **Status:** ✅ Fixed
- - **Resolution:** {Brief description of what was changed}
-
-If a fix turns out to be more complex than expected (requires restructuring,
-affects other modules, changes behavior), reclassify it:
-- Reclassify to [TASK] if it needs tracked work
-- Reclassify to [ADR] if it requires an architectural decision
-- Update the category label and move the finding to the correct section
-
-### Step 3: Work through Missing Task findings
-
-For each `[TASK]` finding:
-
-1. Determine the next available S### number from Steps.md
-2. Add the new task to Steps.md in the appropriate phase:
- - If it's a test → add as S###-T after the related implementation task
- - If it's documentation → add as S###-D after the related implementation task
- - If it's new implementation → add at the end of the relevant phase
-3. If the finding relates to a Testable Assertion, verify the assertion exists in
- Spec.md. If not, add it to the Testable Assertions table.
-4. Update the finding in the review file:
- - **Status:** ✅ Task created
- - **Resolution:** Added as S### in Steps.md
-
-Do NOT implement the task now — just track it. Implementation follows the normal
-impl-start workflow.
-
-### Step 4: Work through Architectural Concern findings
-
-For each `[ADR]` finding:
-
-1. Present the concern to the user with context:
- - What the reviewer found
- - What the current Tech.md says
- - What existing ADRs are relevant
-2. Ask the user to decide:
- - **Create ADR** — proceed to use the adr-create skill
- - **Dismiss** — the concern was considered but doesn't warrant a decision change
- (user must provide a reason)
- - **Defer** — mark as deferred for later consideration
-3. Update the finding in the review file:
- - If ADR created: **Status:** ✅ ADR created → **Resolution:** ADR-NNNN
- - If dismissed: **Status:** ✅ Dismissed → **Resolution:** {user's reason}
- - If deferred: **Status:** 🟡 Deferred → **Resolution:** Deferred — {reason}
-
-### Step 5: Work through Convention Gap findings
-
-For each `[RULE]` finding:
-
-1. Identify which rule file should be updated (or if a new rule file is needed)
-2. Read the current rule file
-3. Present the suggested addition to the user
-4. If approved:
- - Add the convention to the appropriate rule file
- - If the gap is something the post-edit hook could catch, update the hook too
-5. Update the finding in the review file:
- - If applied: **Status:** ✅ Rule updated → **Resolution:** Added to {rule-file}
- - If dismissed: **Status:** ✅ Dismissed → **Resolution:** {reason}
-
-### Step 6: Update review file header
-
-After processing all findings, update the review file:
-
-1. Count resolutions:
- - All resolved → **Status:** 🟢 Resolved
- - Some deferred → **Status:** 🟡 Partially resolved
- - Any unresolved → **Status:** 🔴 Unresolved (should not happen if workflow completes)
-2. Update the Resolution Checklist at the bottom (check completed items)
-3. Add a Resolution Summary section:
-
-```markdown
-## Resolution Summary
-**Resolved at:** {YYYY-MM-DD}
-**Session:** {brief description}
-
-| Category | Total | Fixed | Tasks Created | ADRs | Rules | Dismissed | Deferred |
-|---|---|---|---|---|---|---|---|
-| [FIX] | N | N | — | — | — | — | — |
-| [TASK] | N | — | N | — | — | — | — |
-| [ADR] | N | — | — | N | — | N | N |
-| [RULE] | N | — | — | — | N | N | — |
-| **Total** | **N** | | | | | | |
-```
-
-### Step 7: Commit resolution work
-
-Use the impl-commit skill to commit the fixes. The commit message should reference
-the review file:
-
-```
-fix(review): resolve PR review findings P{N}
-
-Fixes: [FIX] items resolved inline
-Tasks: S### added for [TASK] items
-ADR-NNNN created for [ADR] items
-Rules updated for [RULE] items
-
-Review: Context/Reviews/PR-{branch}-{date}.md
-```
-
-### Step 8: Suggest next steps
-
-Based on what was generated:
-- If new tasks were added → "Run impl-start to work through the new tasks"
-- If ADRs were created → "Review the new ADRs to make sure they're complete"
-- If rules were updated → "The updated rules will apply to future code automatically"
-- Always → "Run review-verify to confirm all findings are resolved"
-
-## Rules
-- ALWAYS start by loading the review file — don't work from conversation memory
-- Process findings in category order: FIX → TASK → ADR → RULE
-- Within each category, process in priority order: Critical → High → Medium → Low
-- Never skip a finding without explicit user input (dismiss or defer with reason)
-- Never auto-dismiss architectural concerns — the user must decide
-- If context is getting tight, save progress to the review file immediately
- (update statuses for what's done so far) before continuing
-- Keep the review file as the single source of truth — don't track resolutions
- anywhere else
+# Skill: Resolve Review Findings
+
+## Purpose
+Work through a captured review file using interactive triage. Every finding requires
+an explicit user decision before any action is taken. Claude may recommend an action,
+but the user always makes the final call.
+
+## When to use
+- At the start of a fresh session after a review was captured
+- User says "resolve review", "fix review findings", "work through the review"
+- User references a specific review file in Context/Reviews/
+
+## Prerequisites
+- A review file must exist in `Context/Reviews/` (created by review-capture)
+- This should run in a **fresh session** with full context budget -- not in the same
+ session that performed the review
+
+## Actions Reference
+
+Six actions are available for every finding:
+
+| # | Action | Description |
+|---|--------|-------------|
+| 1 | **Fix** | Open the file and apply the fix inline |
+| 2 | **Task** | Add a new S### entry to Steps.md for later implementation |
+| 3 | **ADR** | Create an Architecture Decision Record via the adr-create skill |
+| 4 | **Rule** | Add or update a convention in the appropriate rule file |
+| 5 | **Defer** | Push to an external tracker or the backlog for later |
+| 6 | **Discard** | Drop the finding |
+
+## Workflow
+
+### Step 1: Load the review file
+1. If the user specified a file, load that one
+2. Otherwise, list files in `Context/Reviews/` and find the most recent with
+ `Status: Unresolved`
+3. If no unresolved reviews exist, tell the user -- nothing to do
+4. Read the full review file
+5. Load the associated feature's Spec.md, Steps.md, and Tech.md for context
+6. Parse all findings into a working list, noting each finding's category, severity,
+ file reference, and description
+
+### Step 2: Bulk accept phase
+List every finding with its ID, severity, and title so the user can see what
+they are deciding on. Group by category.
+
+```
+## Findings
+
+### [FIX] -- 6 findings
+ P1-001 (Critical) Missing null check in auth handler
+ P1-002 (Critical) SQL injection in search query
+ P1-004 (High) Unclosed file handle in export
+ P1-005 (High) Wrong HTTP status on validation error
+ P1-007 (Medium) Inconsistent error message format
+
+### [TASK] -- 5 findings
+ P1-003 (High) No integration test for payment flow
+ P1-008 (Medium) Missing rate limiting on public API
+ P1-009 (Medium) Accessibility labels missing on form inputs
+ P1-010 (Medium) No migration rollback script
+ P1-012 (Low) Add changelog entry for breaking change
+
+### [ADR] -- 2 findings
+ P1-006 (High) Auth middleware stores tokens in localStorage
+ P1-011 (Medium) Tight coupling between order and inventory services
+
+### [RULE] -- 3 findings
+ P1-013 (Low) Bare except clauses in 4 files
+ P1-014 (Low) No structured logging -- raw print() in 6 files
+ P1-015 (Low) Inconsistent import ordering across modules
+
+Total: 16 findings
+
+Accept suggested actions in bulk?
+ 1. Accept all -- apply each finding's category as its action (fix 6, task 5, adr 2, rule 3)
+ 2. Accept all [FIX]
+ 3. Accept all [TASK]
+ 4. Accept all [ADR]
+ 5. Accept all [RULE]
+ 6. Skip -- go straight to individual triage
+
+You can accept multiple groups. Type "done" when ready for individual triage.
+```
+
+The category assigned during capture IS the suggested action. Bulk accept means
+"yes, do what the category says" for a group of findings at once.
+
+**CRITICAL:** Always list findings by title, not just counts. The user needs to
+see what each finding IS to decide whether to bulk-accept or individually triage.
+
+**Bulk accept rules:**
+- The user may accept multiple groups before typing "done"
+- After each bulk accept, immediately update ALL affected findings in the review
+ file on disk (batch write)
+- Show a confirmation after each accept: "Accepted N [CATEGORY] findings.
+ Remaining: M unhandled."
+- When the user types "done" or "skip", proceed to Step 3
+
+### Step 3: Individual triage phase
+Process each remaining unhandled finding one at a time. For each finding, present:
+
+```
+Finding [X of Y remaining]: [CATEGORY] Severity
+File: path/to/file.ts:NN
+Description:
+
+Recommended action:
+
+Actions:
+ 1. Fix -- apply the fix now
+ 2. Task -- add to Steps.md
+ 3. ADR -- create architecture decision record
+ 4. Rule -- add/update convention rule
+ 5. Defer -- send to tracker or backlog
+ 6. Discard -- drop this finding
+
+Choose [1-6]:
+```
+
+**Individual triage rules:**
+- Wait for the user to respond with a number (1-6) before proceeding
+- NEVER auto-resolve, auto-dismiss, or skip a finding
+- If the user picks **6 (Discard)**, proceed immediately. No reason required.
+- After the user picks an action, record the decision against the finding
+- Immediately update the review file on disk after each individual decision
+- Continue to the next finding until all are handled
+
+### Step 4: Execution phase
+After all findings have been triaged (bulk + individual), execute each action.
+Process in this order: Fix, Task, ADR, Rule, Defer, Discard.
+
+#### Fix
+1. Open the referenced file and line
+2. Apply the fix
+3. Verify the fix does not break anything obvious (check imports, references)
+4. If the fix turns out to be more complex than expected (requires restructuring,
+ affects other modules, changes behavior), inform the user and ask whether to
+ reclassify as Task or ADR
+5. Update the finding in the review file:
+ - **Status:** Fixed
+ - **Resolution:** {Brief description of what was changed}
+
+#### Task
+1. Determine the next available S### number from Steps.md
+2. Add the new task to Steps.md in the appropriate phase:
+ - Test tasks: add as S###-T after the related implementation task
+ - Documentation tasks: add as S###-D after the related implementation task
+ - New implementation: add at the end of the relevant phase
+3. If the finding relates to a Testable Assertion, verify the assertion exists in
+ Spec.md. If not, add it to the Testable Assertions table.
+4. Update the finding in the review file:
+ - **Status:** Task created
+ - **Resolution:** Added as S### in Steps.md
+
+#### ADR
+1. Use the adr-create skill to create the ADR
+2. Update the finding in the review file:
+ - **Status:** ADR created
+ - **Resolution:** ADR-NNNN
+
+#### Rule
+1. Identify which rule file should be updated (or if a new rule file is needed)
+2. Read the current rule file
+3. Add the convention to the appropriate rule file
+4. If the gap is something the post-edit hook could catch, update the hook too
+5. Update the finding in the review file:
+ - **Status:** Rule updated
+ - **Resolution:** Added to {rule-file}
+
+#### Defer
+1. Check `.cortex/config.json` for an `issueTracker` configuration:
+ - If `issueTracker.type` is `"github"`: run `gh issue create --title "" --body ""`
+ - If `issueTracker.type` is `"gitlab"`: run `glab issue create --title "" --description ""`
+ - If `issueTracker.type` is `"backlog"` or no config file exists: create an entry
+ in `Context/Backlog/` with the finding details
+2. Update the finding in the review file:
+ - **Status:** Deferred
+ - **Resolution:** {Issue URL or "Added to Context/Backlog/"}
+
+#### Discard
+1. Mark the finding in the review file:
+ - **Status:** Discarded
+ - **Resolution:** Discarded by user
+
+### Step 5: Update review file header
+After executing all actions, update the review file:
+
+1. Count resolutions:
+ - All resolved (no Deferred) -- **Status:** Resolved
+ - Some deferred -- **Status:** Partially resolved
+ - Any unresolved -- **Status:** Unresolved (should not happen if workflow completes)
+2. Update the Resolution Checklist at the bottom (check completed items)
+3. Add a Resolution Summary section:
+
+```markdown
+## Resolution Summary
+**Resolved at:** {YYYY-MM-DD}
+**Session:** {brief description}
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+|---|---|---|---|---|---|---|---|
+| [FIX] | N | N | N | -- | -- | N | N |
+| [TASK] | N | -- | N | -- | -- | N | N |
+| [ADR] | N | -- | -- | N | -- | N | N |
+| [RULE] | N | -- | -- | -- | N | N | N |
+| **Total** | **N** | | | | | | |
+```
+
+### Step 6: Commit resolution work
+Use the impl-commit skill to commit the fixes. The commit message should reference
+the review file:
+
+```
+fix(review): resolve PR review findings P{N}
+
+Fixes: [FIX] items resolved inline
+Tasks: S### added for [TASK] items
+ADR-NNNN created for [ADR] items
+Rules updated for [RULE] items
+Deferred: N items pushed to tracker/backlog
+Discarded: N items dropped
+
+Review: Context/Reviews/PR-{branch}-{date}.md
+```
+
+### Step 7: Suggest next steps
+Based on what was generated:
+- If new tasks were added -- "Run impl-start to work through the new tasks"
+- If ADRs were created -- "Review the new ADRs to make sure they're complete"
+- If rules were updated -- "The updated rules will apply to future code automatically"
+- If items were deferred -- "Check the tracker/backlog for deferred items"
+- Always -- "Run review-verify to confirm all findings are resolved"
+
+## Rules
+- ALWAYS start by loading the review file -- don't work from conversation memory
+- NEVER take action on a finding without an explicit user decision (bulk or individual)
+- NEVER auto-resolve, auto-dismiss, or skip findings
+- Claude SHOULD recommend an action for each finding, but the user makes the final call
+- Discard does NOT require a reason
+- Bulk accept phase MUST come before individual triage phase
+- After each individual decision, immediately update the review file on disk
+- After each bulk accept, batch-update all affected findings on disk
+- Within individual triage, process in priority order: Critical, High, Medium, Low
+- If context is getting tight, save progress to the review file immediately
+ (update statuses for what's done so far) before continuing
+- Keep the review file as the single source of truth -- don't track resolutions
+ anywhere else
diff --git a/.claude/skills/session-management/SKILL.md b/.claude/skills/session-management/SKILL.md
index 7611ebca..bc33cc70 100755
--- a/.claude/skills/session-management/SKILL.md
+++ b/.claude/skills/session-management/SKILL.md
@@ -41,11 +41,11 @@ These apply to ALL session types:
```
┌───────────────────────────────────────────────────────────────────────┐
-│ /team-plan + /build PIPELINE │
+│ /blueprint + /impl PIPELINE │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
-│ │ CENTRAL AI │───►│ /team-plan │───►│ PLAN FILES │ │
+│ │ CENTRAL AI │───►│ /blueprint │───►│ PLAN FILES │ │
│ │ Coordinator │ │ Plan Creation │ │ .claude/tasks/*.md │ │
│ │ Auto-invokes │ │ Stop Hooks │ │ Source of Truth │ │
│ └──────────────┘ └─────────────────┘ └──────────────────────┘ │
@@ -56,7 +56,7 @@ These apply to ALL session types:
│ │ └────────┬────────┘ │
│ │ │ │
│ │ ┌────────▼────────┐ │
-│ └───────────►│ /build │ │
+│ └───────────►│ /impl │ │
│ │ Plan Execution │ │
│ │ TaskList Sync │ │
│ └────────┬────────┘ │
@@ -92,15 +92,15 @@ PENDING → IN_PROGRESS → COMPLETE → VERIFIED
---
-## 5-Phase Session Flow (SOP: `/team-plan` + `/build`)
+## 5-Phase Session Flow (SOP: `/blueprint` + `/impl`)
The standard operating procedure for all non-trivial implementation work is:
1. Assess complexity
2. Gather context if needed
-3. Auto-invoke `/team-plan` to create a structured plan
+3. Auto-invoke `/blueprint` to create a structured plan
4. Pause for user approval
-5. Execute via `/build`
+5. Execute via `/impl`
---
@@ -112,8 +112,8 @@ Central AI receives the user request and assesses complexity:
| ------------ | -------------------------------------------------- | --------------------------------------- |
| **Trivial** | Single file, obvious fix, typo | Execute directly |
| **Moderate** | 2-5 files, clear scope, single domain | Direct sub-agent delegation |
-| **Complex** | Multi-phase, 5+ files, architectural, multi-domain | `/team-plan` → approval → `/build` |
-| **Unclear** | Vague but potentially complex | Gather context first, then `/team-plan` |
+| **Complex** | Multi-phase, 5+ files, architectural, multi-domain | `/blueprint` → approval → `/impl` |
+| **Unclear** | Vague but potentially complex | Gather context first, then `/blueprint` |
**Decision**: Route to Phase 2 (context gathering), Phase 3 (plan creation), or skip to direct execution.
@@ -121,7 +121,7 @@ Central AI receives the user request and assesses complexity:
### Phase 2: Context Gathering (if needed)
-Skip this phase if the initial request is already detailed enough for `/team-plan`.
+Skip this phase if the initial request is already detailed enough for `/blueprint`.
**For vague or incomplete requests:**
@@ -133,21 +133,21 @@ Skip this phase if the initial request is already detailed enough for `/team-pla
- Run quick Explore agents to understand architecture
- Identify relevant patterns and existing implementations
-**Output**: A comprehensive prompt incorporating all gathered context, ready to feed into `/team-plan`.
+**Output**: A comprehensive prompt incorporating all gathered context, ready to feed into `/blueprint`.
---
-### Phase 3: Plan Creation (auto-invoke `/team-plan`)
+### Phase 3: Plan Creation (auto-invoke `/blueprint`)
-Central AI auto-invokes the `/team-plan` command:
+Central AI auto-invokes the `/blueprint` command:
```
-Skill({ skill: "team-plan", args: "" })
+Skill({ skill: "blueprint", args: "" })
```
**What happens:**
-- `/team-plan` runs in the main thread with full codebase access
+- `/blueprint` runs in the main thread with full codebase access
- Analyzes the codebase directly (no sub-agents during planning)
- Creates a structured plan with team members, tasks, dependencies, acceptance criteria
- Stop hooks validate the plan has all required sections
@@ -156,7 +156,7 @@ Skill({ skill: "team-plan", args: "" })
**After plan creation, Central AI PAUSES:**
- Presents the plan summary to the user
-- Waits for approval ("go", `/build`, or feedback)
+- Waits for approval ("go", `/impl`, or feedback)
- If user requests changes, adjust the plan and re-present
**Prompt composition for auto-invocation should include:**
@@ -169,17 +169,17 @@ Skill({ skill: "team-plan", args: "" })
---
-### Phase 4: Plan Execution (user-approved `/build`)
+### Phase 4: Plan Execution (user-approved `/impl`)
-After user approval, Central AI invokes the `/build` command:
+After user approval, Central AI invokes the `/impl` command:
```
-Skill({ skill: "build", args: ".claude/tasks/.md" })
+Skill({ skill: "impl", args: ".claude/tasks/.md" })
```
**What happens:**
-- `/build` reads the plan and creates `TaskCreate` entries for each step
+- `/impl` reads the plan and creates `TaskCreate` entries for each step
- Sets dependencies with `addBlockedBy` / `addBlocks`
- Assigns owners to tasks matching team member names
- Deploys specialist agents via `Task` tool (parallel where dependencies allow)
@@ -187,7 +187,7 @@ Skill({ skill: "build", args: ".claude/tasks/.md" })
**Task List Synchronization:**
-- Plan tasks automatically become the TaskList (created by `/build`)
+- Plan tasks automatically become the TaskList (created by `/impl`)
- `TaskUpdate` marks tasks `in_progress` when starting, `completed` when done
- Press `Ctrl+T` to toggle task visibility during work
- For cross-session work, set `CLAUDE_CODE_TASK_LIST_ID` environment variable
@@ -220,9 +220,9 @@ Apply `practices/branch-completion.md` for branch handling.
## Session File Template
-**Note:** For complex work, the `/team-plan` command generates the session/blueprint file automatically. Use this template only for moderate work where you create the session file manually.
+**Note:** For complex work, the `/blueprint` command generates the session/blueprint file automatically. Use this template only for moderate work where you create the session file manually.
-For `/team-plan` output format, see `.claude/commands/team-plan.md` (Plan Format section).
+For `/blueprint` output format, see `.claude/commands/blueprint.md` (Plan Format section).
```markdown
# Session - [Title]
diff --git a/.claude/skills/session-management/session-types/development.md b/.claude/skills/session-management/session-types/development.md
index 6247fba8..edc8bae5 100755
--- a/.claude/skills/session-management/session-types/development.md
+++ b/.claude/skills/session-management/session-types/development.md
@@ -44,7 +44,7 @@ Before writing code, validate the approach:
### Phase 2: Plan Creation
**For complex development work (5+ files, multi-phase, architectural):**
-Central AI auto-invokes `/team-plan` which generates a structured plan with team members, tasks, dependencies, and acceptance criteria. See the `session-management` SKILL.md Phase 3 for details.
+Central AI auto-invokes `/blueprint` which generates a structured plan with team members, tasks, dependencies, and acceptance criteria. See the `session-management` SKILL.md Phase 3 for details.
**For moderate development work (2-5 files, clear scope):**
Create detailed, actionable tasks manually:
diff --git a/.claude/skills/sub-agent-invocation/SKILL.md b/.claude/skills/sub-agent-invocation/SKILL.md
index b2ace336..4800473a 100755
--- a/.claude/skills/sub-agent-invocation/SKILL.md
+++ b/.claude/skills/sub-agent-invocation/SKILL.md
@@ -81,28 +81,28 @@ DELIVERABLES:
| Scenario | Approach |
| -------------------------------------------------- | ---------------------------------------------------------- |
-| Multi-phase feature, 5+ files, architectural | Auto-invoke `/team-plan` → user approval → `/build` |
-| Cross-domain integration, agents need coordination | `/team-plan` → user approval → `/team-build` (Agent Teams) |
+| Multi-phase feature, 5+ files, architectural | Auto-invoke `/blueprint` → user approval → `/impl` |
+| Cross-domain integration, agents need coordination | `/blueprint` → user approval → `/team-impl` (Agent Teams) |
| Simple file edit, pattern search, single-component | Direct sub-agent delegation |
-| Ambiguous scope, needs planning | Gather context → `/team-plan` → user approval → `/build` |
+| Ambiguous scope, needs planning | Gather context → `/blueprint` → user approval → `/impl` |
| Clear scope, bounded execution | Direct delegation |
-| High-reliability task, production changes | `/team-plan` with Specialist + Quality Engineer validation |
+| High-reliability task, production changes | `/blueprint` with Specialist + Quality Engineer validation |
| Research/exploration only | Direct Explore agent or deep-researcher |
-**`/build` vs `/team-build` Decision:**
+**`/impl` vs `/team-impl` Decision:**
-- `/build`: Tasks are independent, sub-agents don't need to communicate, cost-sensitive (1x tokens)
-- `/team-build`: Agents need peer-to-peer coordination, cross-domain interfaces, contract-first spawning (2-4x tokens)
+- `/impl`: Tasks are independent, sub-agents don't need to communicate, cost-sensitive (1x tokens)
+- `/team-impl`: Agents need peer-to-peer coordination, cross-domain interfaces, contract-first spawning (2-4x tokens)
---
## Auto-Invocation Protocol
-Central AI auto-invokes `/team-plan` + `/build` (or `/team-build`) for complex work. This is the standard operating procedure for all non-trivial implementation.
+Central AI auto-invokes `/blueprint` + `/impl` (or `/team-impl`) for complex work. This is the standard operating procedure for all non-trivial implementation.
### Decision Criteria
-**Auto-invoke `/team-plan` immediately when:**
+**Auto-invoke `/blueprint` immediately when:**
- Request involves 5+ files or multiple domains
- Request is clearly multi-phase (e.g., "build feature with X, Y, Z")
@@ -126,31 +126,31 @@ Central AI auto-invokes `/team-plan` + `/build` (or `/team-build`) for complex w
**Step 1 - Plan:**
```
-Skill({ skill: "team-plan", args: "" })
+Skill({ skill: "blueprint", args: "" })
```
**Step 2 - Pause:**
-Present the plan summary to the user. Wait for approval ("go", `/build`, `/team-build`, or feedback).
+Present the plan summary to the user. Wait for approval ("go", `/impl`, `/team-impl`, or feedback).
**Step 3 - Execute (after user approval):**
For isolated sub-agent execution (default):
```
-Skill({ skill: "build", args: ".claude/tasks/.md" })
+Skill({ skill: "impl", args: ".claude/tasks/.md" })
```
For collaborative Agent Teams execution (when agents need peer-to-peer coordination):
```
-Skill({ skill: "team-build", args: ".claude/tasks/.md" })
+Skill({ skill: "team-impl", args: ".claude/tasks/.md" })
```
-Use `/team-build` when the plan involves cross-domain integration where agents need to share contracts (schemas, API specs, interfaces) and coordinate in real-time. Requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`.
+Use `/team-impl` when the plan involves cross-domain integration where agents need to share contracts (schemas, API specs, interfaces) and coordinate in real-time. Requires `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`.
### Prompt Composition for Auto-Invocation
-When composing the prompt for `/team-plan`, Central AI should include:
+When composing the prompt for `/blueprint`, Central AI should include:
1. **User's verbatim request** - Always include the original words
2. **Clarifications gathered** - Any answers from `AskUserQuestion`
@@ -162,7 +162,7 @@ When composing the prompt for `/team-plan`, Central AI should include:
```
Skill({
- skill: "team-plan",
+ skill: "blueprint",
args: "Build a new models section for the blog with 11 model profile pages. User wants: chronological model pages from Claude 3 through Opus 4.6, a models index page, and cross-links to existing posts. Existing blog structure is in apps/web/src/content/blog/blog-structure.ts. Content style should match existing guide posts. Use content-writer agents for parallel page creation."
})
```
diff --git a/.cortex/session.md b/.cortex/session.md
index b99ab756..1b1a2d38 100644
--- a/.cortex/session.md
+++ b/.cortex/session.md
@@ -1,40 +1,25 @@
-# Session: 005 - Supabase Realtime Integration
+# Session: Feature 012 -- TauriAdapter Unit Tests
-**Feature**: `Context/Features/005-Supabase-Realtime/`
-**Started**: 2026-04-02
-**Status**: Complete
+**Plan:** Context/Features/012-TauriAdapter-Tests/Steps.md
+**Started:** 2026-04-04
+**Status:** In progress
## Team
-| Name | Role | Tasks |
-|------|------|-------|
-| domain-engineer | Broadcast schemas, RealtimeManager, adapter interface | S001, S002, S005 |
-| rust-engineer | Cursor-based Rust command, Tauri adapter | S003 |
-| frontend-specialist | Foreground detector, hooks, wiring | S004, S006, S007 |
-| quality-engineer | Cross-cutting validation | S008 |
+| Role | Agent | Status |
+| ----------- | ------------ | ------- |
+| test-writer | test-writer | Pending |
## Wave Execution Log
-### Wave 1: Foundation (parallel)
-- [x] S001: Broadcast Payload Zod Schemas
-- [x] S002: Cursor-Based getMessages -- TypeScript Side
-- [x] S003: Cursor-Based get_messages -- Rust + Tauri Adapter
-- [x] S004: Foreground Detector Module
+### Wave 1: Scaffolding + Helpers + Error Handling (S001)
+- Status: Pending
-### Wave 2: RealtimeManager
-- [x] S005: RealtimeManager Module
+### Wave 2: Core CRUD Operations (S002-S004)
+- Status: Pending
-### Wave 3: Hooks + Wiring
-- [x] S006: TanStack Query Chat Hooks
-- [x] S007: RealtimeManager Initialization Wiring
+### Wave 3: Templates, Programs, Social (S005-S007)
+- Status: Pending
-### Wave 4: Validation
-- [x] S008: Quality Validation -- 22/22 assertions PASS
-
-## Activity Log
-
-- [2026-04-02] Session created. Starting Wave 1 with 4 parallel agents.
-- [2026-04-02] Wave 1 complete (S001-S004). Clean compile.
-- [2026-04-02] Wave 2 complete (S005). Clean compile.
-- [2026-04-02] Wave 3 complete (S006-S007). Clean compile.
-- [2026-04-02] Wave 4 complete (S008). All 22 assertions PASS.
+### Wave 4: Chat, Analytics, Stubs, Verification (S008-S009-T)
+- Status: Pending
diff --git a/.github/BRANCH_PROTECTION.md b/.github/BRANCH_PROTECTION.md
new file mode 100644
index 00000000..979bf705
--- /dev/null
+++ b/.github/BRANCH_PROTECTION.md
@@ -0,0 +1,37 @@
+# Branch Protection Configuration
+
+Recommended branch protection rules for the Ardent Forge repository.
+
+## `develop` branch
+
+| Setting | Value |
+| ----------------------------------- | ---------------------------------- |
+| Require pull request before merging | Yes |
+| Required approvals | 1 |
+| Require status checks to pass | Yes |
+| Required status checks | `validate`, `e2e`, `android-debug` |
+| Require branches to be up to date | Yes |
+| Restrict force pushes | Yes |
+
+## `main` branch
+
+| Setting | Value |
+| ----------------------------------- | ---------------------------------- |
+| Require pull request before merging | Yes |
+| Required approvals | 1 |
+| Require status checks to pass | Yes |
+| Required status checks | `validate`, `e2e`, `android-debug` |
+| Require branches to be up to date | Yes |
+| Restrict force pushes | Yes |
+| Restrict deletions | Yes |
+
+## Tag protection
+
+Tags matching `v*` trigger the release workflow. Consider adding a tag protection rule to prevent accidental or unauthorized tag creation.
+
+## Setup
+
+1. Go to **Settings > Branches** in the GitHub repository
+2. Add a branch protection rule for each branch above
+3. Enable the status checks listed -- they correspond to job names in `.github/workflows/ci.yml`
+4. Go to **Settings > Tags** to add tag protection rules
diff --git a/.github/README.md b/.github/README.md
new file mode 100644
index 00000000..2b5d6910
--- /dev/null
+++ b/.github/README.md
@@ -0,0 +1,103 @@
+# CI/CD Pipeline Setup
+
+Guide for configuring the Ardent Forge CI/CD pipeline.
+
+## Prerequisites
+
+- GitHub repository with Actions enabled
+- Google Play Console project with the app already uploaded manually (first upload cannot be done via API)
+- Google Cloud service account with Play Console access
+- Android upload keystore (.jks file)
+- Supabase project with CLI access
+
+## Required GitHub Secrets
+
+Configure these in **Settings > Secrets and variables > Actions**:
+
+| Secret | Description | How to obtain |
+| ---------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------- |
+| `KEYSTORE_BASE64` | Base64-encoded Android upload keystore | `base64 -w 0 upload-key.jks` |
+| `KEYSTORE_PASSWORD` | Password for the keystore file | Set during keystore creation |
+| `KEY_ALIAS` | Alias of the signing key within the keystore | Set during keystore creation |
+| `KEY_PASSWORD` | Password for the signing key | Set during keystore creation |
+| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Full JSON contents of the Play Console service account key | Google Cloud Console > IAM > Service Accounts > Keys |
+| `SUPABASE_PROJECT_REF` | Supabase project reference ID | Supabase Dashboard > Project Settings > General |
+| `SUPABASE_ACCESS_TOKEN` | Supabase personal access token | https://supabase.com/dashboard/account/tokens |
+
+## First-Time Setup
+
+### 1. Create an Android upload keystore
+
+```bash
+keytool -genkey -v -keystore upload-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias upload
+```
+
+Encode it for GitHub Secrets:
+
+```bash
+base64 -w 0 upload-key.jks | pbcopy # macOS
+base64 -w 0 upload-key.jks | xclip # Linux
+```
+
+### 2. Set up Google Play Console service account
+
+1. Go to [Google Cloud Console](https://console.cloud.google.com)
+2. Enable the **Google Play Android Developer API**
+3. Create a Service Account under **IAM & Admin > Service Accounts**
+4. Generate a JSON key for the service account
+5. In **Google Play Console > Users and Permissions**, invite the service account email
+6. Grant **Release Manager** permissions scoped to the Ardent Forge app
+
+### 3. First manual Play Store upload
+
+The Google Play Developer API cannot create a new app listing. You must manually upload the first AAB:
+
+1. Build a signed AAB locally: `bunx tauri android build --target aarch64 --aab`
+2. Go to **Google Play Console > Internal testing**
+3. Create a new release and upload the AAB
+4. After this first upload, the CI pipeline can handle subsequent releases
+
+### 4. Configure Supabase access
+
+1. Get your project reference from **Supabase Dashboard > Project Settings > General**
+2. Generate a personal access token at https://supabase.com/dashboard/account/tokens
+3. Add both as GitHub Secrets
+
+## Workflows
+
+### CI (`ci.yml`)
+
+Triggered on pull requests to `develop` and `main`. Runs three parallel jobs:
+
+- **validate** -- ESLint, TypeScript typecheck, Vitest unit tests
+- **e2e** -- Playwright E2E tests against local Supabase
+- **android-debug** -- Debug APK build to verify compilation
+
+### Release (`release.yml`)
+
+Triggered when a version tag (`v*`) is pushed. Three jobs:
+
+- **build-release** -- Builds a signed AAB (parallel with migrate)
+- **migrate** -- Runs Supabase migrations against production (parallel with build-release)
+- **publish** -- Creates GitHub Release + uploads to Play Store internal track (waits for both above)
+
+### Triggering a release
+
+```bash
+# Pre-release (from develop branch)
+git tag v1.0.0-alpha.1
+git push origin v1.0.0-alpha.1
+
+# Stable release (from main branch)
+git tag v1.0.0
+git push origin v1.0.0
+```
+
+### Tag conventions
+
+| Pattern | Branch | Play Store | GitHub Release |
+| ---------------- | ------- | -------------- | -------------- |
+| `v1.0.0-alpha.1` | develop | Internal track | Pre-release |
+| `v1.0.0-beta.1` | develop | Internal track | Pre-release |
+| `v1.0.0-rc.1` | develop | Internal track | Pre-release |
+| `v1.0.0` | main | Internal track | Stable release |
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..dd322628
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,98 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [develop, main]
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ validate:
+ name: Lint, Typecheck & Test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache Bun dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Lint
+ run: bun run lint
+
+ - name: Typecheck & Build
+ run: bun run build
+
+ - name: Unit Tests
+ run: bun run test
+
+ e2e:
+ name: E2E Tests
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache Bun dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Setup Supabase CLI
+ uses: supabase/setup-cli@v1
+ with:
+ version: 2.84.4
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Start local Supabase
+ run: bunx supabase start
+
+ - name: Set Supabase env vars
+ run: |
+ echo "VITE_SUPABASE_URL=$(bunx supabase status --output json | jq -r '.API_URL')" >> $GITHUB_ENV
+ echo "VITE_SUPABASE_ANON_KEY=$(bunx supabase status --output json | jq -r '.ANON_KEY')" >> $GITHUB_ENV
+
+ - name: Build frontend
+ run: bun run build
+
+ - name: Install Playwright browsers
+ run: bunx playwright install --with-deps chromium
+
+ - name: Run E2E tests
+ run: bun run test:e2e
+
+ - name: Upload Playwright report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ if-no-files-found: ignore
+ retention-days: 14
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..aee4d28e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,194 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ build-release:
+ name: Build Signed AAB
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Validate signing secrets
+ env:
+ KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
+ KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ run: |
+ MISSING=""
+ [ -z "$KEYSTORE_BASE64" ] && MISSING="$MISSING KEYSTORE_BASE64"
+ [ -z "$KEYSTORE_PASSWORD" ] && MISSING="$MISSING KEYSTORE_PASSWORD"
+ [ -z "$KEY_ALIAS" ] && MISSING="$MISSING KEY_ALIAS"
+ [ -z "$KEY_PASSWORD" ] && MISSING="$MISSING KEY_PASSWORD"
+ if [ -n "$MISSING" ]; then
+ echo "::error::Missing required secrets:$MISSING"
+ exit 1
+ fi
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: temurin
+ cache: gradle
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Install NDK
+ run: sdkmanager "ndk;27.0.12077973"
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: aarch64-linux-android
+
+ - name: Cache Rust
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: src-tauri -> target
+ shared-key: android
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Cache Bun dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.bun/install/cache
+ key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-bun-
+
+ - name: Install dependencies
+ run: bun install
+
+ - name: Decode keystore
+ env:
+ KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }}
+ run: |
+ if [ -z "$KEYSTORE_BASE64" ]; then
+ echo "::error::KEYSTORE_BASE64 secret is empty or not set"
+ exit 1
+ fi
+ echo "$KEYSTORE_BASE64" | base64 -d > $RUNNER_TEMP/upload-key.jks
+ FILESIZE=$(stat -c%s "$RUNNER_TEMP/upload-key.jks")
+ if [ "$FILESIZE" -lt 100 ]; then
+ echo "::error::Decoded keystore is suspiciously small (${FILESIZE} bytes) -- check KEYSTORE_BASE64 secret"
+ exit 1
+ fi
+
+ - name: Inject version
+ run: |
+ VERSION_NAME="${GITHUB_REF_NAME#v}"
+ VERSION_CODE="${{ github.run_number }}"
+ echo "tauri.android.versionName=$VERSION_NAME" > src-tauri/gen/android/app/tauri.properties
+ echo "tauri.android.versionCode=$VERSION_CODE" >> src-tauri/gen/android/app/tauri.properties
+
+ - name: Build signed AAB
+ env:
+ ANDROID_NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/27.0.12077973
+ ANDROID_KEYSTORE_PATH: ${{ runner.temp }}/upload-key.jks
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
+ run: bunx tauri android build --target aarch64 --aab
+
+ - name: Upload AAB artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: release-aab
+ path: src-tauri/gen/android/app/build/outputs/bundle/**/*.aab
+ retention-days: 30
+
+ migrate:
+ name: Supabase Migrations
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Validate Supabase secrets
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }}
+ run: |
+ MISSING=""
+ [ -z "$SUPABASE_ACCESS_TOKEN" ] && MISSING="$MISSING SUPABASE_ACCESS_TOKEN"
+ [ -z "$SUPABASE_PROJECT_REF" ] && MISSING="$MISSING SUPABASE_PROJECT_REF"
+ if [ -n "$MISSING" ]; then
+ echo "::error::Missing required secrets:$MISSING"
+ exit 1
+ fi
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Setup Supabase CLI
+ uses: supabase/setup-cli@v1
+ with:
+ version: 2.84.4
+
+ - name: Link Supabase project
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ SUPABASE_PROJECT_REF: ${{ secrets.SUPABASE_PROJECT_REF }}
+ run: bunx supabase link --project-ref "$SUPABASE_PROJECT_REF"
+
+ - name: Push migrations
+ env:
+ SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
+ run: bunx supabase db push
+
+ publish:
+ name: Publish Release
+ needs: [build-release, migrate]
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Validate Play Store secret
+ env:
+ GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
+ run: |
+ if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
+ echo "::error::Missing required secret: GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
+ exit 1
+ fi
+
+ - name: Download AAB artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: release-aab
+ path: artifacts
+
+ - name: Upload to Google Play
+ uses: r0adkll/upload-google-play@v3
+ with:
+ serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
+ packageName: com.rghsoftware.ardentforge
+ releaseFiles: artifacts/**/*.aab
+ track: internal
+ status: completed
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ tag_name: ${{ github.ref_name }}
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ generate_release_notes: true
+ files: artifacts/**/*.aab
diff --git a/.gitignore b/.gitignore
index 5bda0af1..86a439e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,15 @@ dist-ssr
*.sln
*.sw?
.claude/tasks/
+.claude/backups/
+.claude/hooks/
+.claude/worktrees/
+
+# Serena LSP server state
+.serena/
+
+# Hook runtime state
+hooks/skill-activation/.state/
# Test artifacts
test-results/
@@ -34,6 +43,9 @@ public/*.zip
# Environment secrets (never commit .env, only .env.example)
.env
.env.bak
+*.jks
# Generated Kong config (produced by scripts/generate-keys.sh from kong.yml.template)
docker/kong/kong.yml
+screenshots/
+.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e89478f6..3f354c69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,43 @@
All notable changes to Ardent Forge are documented in this file.
+## [1.0.0-beta.2] - 2026-04-04
+
+### Features
+
+- **Display system** -- gym TV display with board view, focused exercise view, and idle mode with Edge Function broadcast (#59, #60, #62)
+- **Discovery and onboarding** -- server URL discovery flow, QR code generation and scanning for device setup (#63, #64)
+- **Deep links** -- handle `ardentforge://connect` deep links for streamlined device pairing (#66)
+- **Android CI/CD** -- GitHub Actions pipeline for Android builds, signing, and Play Store deployment (#67)
+- **Progressive disclosure** -- contextual help and progressive disclosure in builders (Step 14.5)
+- **Program builder** -- responsive week grid layout for program builder UI (#58)
+- **Link previews** -- OG tags, Twitter Cards, and useMetadata hook for rich link previews (#53)
+- **Edge functions** -- Cloudflare video edge functions with Vault secrets and local JWT signing
+
+### Improvements
+
+- Updated app icons and branding assets
+- Chrome Custom Tabs for Google OAuth on Android
+- TauriAdapter unit tests and error kind sync
+- Chat domain type tests and IPC failure logging
+- Reviewer account seed script for Supabase
+- Batch enhancement issues cleanup (#71)
+
+### Bug Fixes
+
+- Chat Zod refinements in mappers and participant DELETE trigger
+- JSONB columns already parsed by Supabase PostgREST (#70)
+- Sync pull path HTTP status check and empty update_set guard (#70)
+- CI: upgrade supabase/setup-cli to v2 and pin CLI to v2.84.4
+- Invalid cron key in Supabase config.toml
+- RLS infinite recursion on group_members and conversation_participants (#56)
+- PKCE flow for OAuth sign-in (#55) and code race condition on callback (#54)
+- Display QA findings from PR review (#61)
+- Migration rename and auto-recreate DB on checksum mismatch
+- Event metadata column comment formatting
+
+---
+
## [0.8.0] - 2026-04-03
### Features
diff --git a/Context/Backlog/Bugs.md b/Context/Backlog/Bugs.md
index 9d788b03..ddf72b71 100644
--- a/Context/Backlog/Bugs.md
+++ b/Context/Backlog/Bugs.md
@@ -4,3 +4,9 @@ Known bugs not blocking current work. Add entries via the backlog-add skill.
High-priority bugs should be addressed before starting new features.
+
+## ~~B001: invokeCommand does not log before re-throwing~~ (Resolved 2026-04-04)
+
+**Severity:** High
+**File:** `src/lib/tauri-adapter.ts:516`
+**Resolution:** Added `console.error('[tauri-adapter] invokeCommand(${cmd}) failed:', e)` before the re-throw. All 140 tauri-adapter tests pass.
diff --git a/Context/Backlog/Ideas.md b/Context/Backlog/Ideas.md
index bda1a7fe..444c271b 100644
--- a/Context/Backlog/Ideas.md
+++ b/Context/Backlog/Ideas.md
@@ -8,14 +8,27 @@ skill. Prioritize via the backlog-prioritize skill.
## Chat Data Layer Refinements (PR #33 review suggestions)
**Added:** 2026-04-02
+**Updated:** 2026-04-04 -- S-1 through S-5 resolved in PR #71; S-6 partially addressed (error context added, N+1 remains)
**Source:** `Context/Reviews/0001-pr33-chat-data-layer-review.md` (S-1 through S-6)
-**Priority:** Low -- nice-to-have before Step 23 (Chat UI)
-
-| # | File | Improvement |
-|---|------|-------------|
-| S-1 | `conversation.ts` | Add `.refine()` preventing `groupId` on direct conversations |
-| S-2 | `message.ts` | Add `.refine()` preventing `senderId` on system messages |
-| S-3 | `media.ts` | Add `.nonnegative()` to `durationSeconds` and `fileSizeBytes` |
-| S-4 | `conversation.ts` | Add temporal ordering refinement (`leftAt > joinedAt`) |
-| S-5 | Supabase migration | CH-2 uniqueness trigger is O(N) per participant INSERT -- consider materialized lookup |
-| S-6 | `tauri-adapter.ts` | `findDirectConversation` is N+1 with no error context in loop |
+**Priority:** Low
+
+| # | File | Improvement | Status |
+| --- | ------------------ | ----------------------------------------------------------------------------------------------- | ------------- |
+| S-1 | `conversation.ts` | Add `.refine()` preventing `groupId` on direct conversations | Done (PR #71) |
+| S-2 | `message.ts` | Add `.refine()` preventing `senderId` on system messages | Done (PR #71) |
+| S-3 | `media.ts` | Add `.nonnegative()` to `durationSeconds` and `fileSizeBytes` | Done (PR #71) |
+| S-4 | `conversation.ts` | Add temporal ordering refinement (`leftAt > joinedAt`) | Done (PR #71) |
+| S-5 | Supabase migration | CH-2 uniqueness trigger is O(N) per participant INSERT -- consider materialized lookup | Done (PR #71) |
+| S-6 | `tauri-adapter.ts` | `findDirectConversation` is N+1; error handling added (PR #71), batch requires new Rust command | Done |
+
+## ~~P9-010: Domain type tests for conversation, message, media~~ (Resolved 2026-04-04)
+
+**Added:** 2026-04-04
+**Source:** `Context/Reviews/0009-pr71-enhancement-batch-review.md`
+**Resolution:** Created `src/domain/types/__tests__/chat.test.ts` with 18 tests covering all refinements (conversationSchema, conversationParticipantSchema, messageSchema, mediaAttachmentSchema). All pass.
+
+## ~~P9-011: Test for getUnreadCounts batching behavior~~ (Resolved 2026-04-04)
+
+**Added:** 2026-04-04
+**Source:** `Context/Reviews/0009-pr71-enhancement-batch-review.md`
+**Resolution:** TauriAdapter.getUnreadCounts already has 2 unit tests (happy path + empty). The `Promise.allSettled` batching was a suggestion for SupabaseAdapter that was never implemented (still sequential loop). No further action needed; re-open if batching is implemented.
diff --git a/Context/Backlog/error-boundary-strategy.md b/Context/Backlog/error-boundary-strategy.md
new file mode 100644
index 00000000..61338650
--- /dev/null
+++ b/Context/Backlog/error-boundary-strategy.md
@@ -0,0 +1,19 @@
+# ErrorBoundary Strategy
+
+**Source:** PR #65 review (P7-012)
+**Priority:** Medium
+**Severity:** High
+
+## Problem
+
+No `ErrorBoundary` components exist in the application. If any Radix/vaul component throws during render, the entire form crashes and the user loses all unsaved state.
+
+## Recommendation
+
+1. Add an `ErrorBoundary` wrapper around `HelpTrigger` internals so a failure degrades to a hidden help icon
+2. Add boundaries around `ActivityGroupEditor` and `BlockEditor` so a single group/block error does not destroy the whole form
+3. Consider an app-wide error boundary strategy with graceful degradation
+
+## Context
+
+This was identified during the progressive disclosure PR (#65) which adds new Popover/Drawer subtrees via `HelpTrigger` and complex conditional rendering in builders.
diff --git a/Context/Decisions/ADR-006-discovery-endpoint-format.md b/Context/Decisions/ADR-006-discovery-endpoint-format.md
new file mode 100644
index 00000000..c4191610
--- /dev/null
+++ b/Context/Decisions/ADR-006-discovery-endpoint-format.md
@@ -0,0 +1,58 @@
+# ADR-006: Discovery Endpoint Format and Discovery Client Design
+
+## Status: Proposed
+## Date: 2026-04-04
+## Feature: 009-discovery-client-server-url
+
+---
+
+## Context
+
+Ardent Forge's setup screen requires users to enter a Supabase project URL and anon key to connect to a self-hosted instance. This creates onboarding friction because these values are already configured by the instance administrator. We want users to enter just a server address and have the app discover the backend credentials automatically.
+
+## Decision
+
+### Endpoint
+
+Serve instance metadata at `/.well-known/ardent-forge.json` following [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) conventions. The response is static JSON:
+
+```json
+{
+ "version": "1",
+ "supabase_url": "https://forge.example.com",
+ "supabase_publishable_key": "eyJ..."
+}
+```
+
+All three fields are required. The `version` field enables future schema evolution without breaking existing clients.
+
+### Serving mechanism
+
+For Docker deployments, Caddy serves the endpoint directly via its `respond` directive with `{$SITE_URL}` and `{$ANON_KEY}` environment variable interpolation. No additional containers or file volumes.
+
+For static host deployments (Vercel, Netlify, Cloudflare Pages), the deployer creates `public/.well-known/ardent-forge.json` as a static file with hardcoded values.
+
+### Client design
+
+A single pure async function `discoverInstance(serverUrl: string)` in `src/lib/discovery.ts`:
+- Normalizes input (prepend `https://` if no protocol, strip trailing slash)
+- Fetches the well-known endpoint
+- Validates the response shape
+- Returns credentials or a typed error (`NETWORK_ERROR`, `NOT_FOUND`, `INVALID_RESPONSE`)
+- No side effects -- the caller orchestrates config persistence and navigation
+
+## Alternatives Considered
+
+| Alternative | Why rejected |
+|-------------|-------------|
+| Custom `/api/discover` route | Requires server-side logic; unnecessary for static metadata |
+| DNS TXT records | Requires DNS access many self-hosters lack |
+| `/.well-known/host-meta` (XML) | Heavyweight format for simple key-value data |
+| Caddy reverse proxy to static file volume | Adds a volume mount and templating step to startup |
+
+## Consequences
+
+- Users can connect with just a server address for Docker-hosted instances (zero-config discovery)
+- Static host deployers need one extra step (create the discovery file), documented in self-hosting guide
+- The `version` field allows adding optional fields (instance name, branding, feature flags) in future without breaking v1 clients
+- Cross-origin discovery is not supported in v1 (the discovery endpoint must be on the same origin as the app)
diff --git a/Context/Decisions/ADR-007-github-actions-cicd.md b/Context/Decisions/ADR-007-github-actions-cicd.md
new file mode 100644
index 00000000..dfc549ca
--- /dev/null
+++ b/Context/Decisions/ADR-007-github-actions-cicd.md
@@ -0,0 +1,55 @@
+# ADR-007: GitHub Actions for CI/CD Pipeline
+
+**Status:** Accepted
+**Date:** 2026-04-04
+**Feature:** [011-ci-cd-pipeline](../Features/011-ci-cd-pipeline/Spec.md)
+
+## Context
+
+Ardent Forge needs a CI/CD pipeline for automated testing, Android builds, Play Store deployment, and Supabase migrations. The project has zero existing CI infrastructure. The codebase is hosted on GitHub (rghsoftware/ardent-forge).
+
+## Decision
+
+Use **GitHub Actions** as the sole CI/CD platform.
+
+## Options Considered
+
+### 1. GitHub Actions (chosen)
+
+- Native GitHub integration (status checks, PR gates, release triggers, secrets)
+- No additional service to manage or pay for
+- Rich ecosystem of community actions for Android SDK, Rust, Play Store upload
+- Free tier generous for private repos (2,000 min/month)
+- `ubuntu-latest` runners include Docker (needed for local Supabase in E2E)
+
+### 2. CircleCI
+
+- Powerful caching and parallelism
+- Requires external service account and webhook setup
+- Android builds need a dedicated resource class or Docker image
+- Additional vendor dependency for a solo developer
+
+### 3. Self-hosted runners (GitHub Actions)
+
+- Full control over environment and caching
+- Requires infrastructure management, security hardening
+- Overkill for current team size and build volume
+
+### 4. Fastlane (as build orchestrator)
+
+- Mature Android build and deploy tooling
+- Ruby dependency adds complexity to the toolchain
+- Tauri builds via `bun tauri android build`, not `gradlew` directly, so Fastlane's Gradle integration adds limited value
+- Could be layered on later if needed
+
+## Rationale
+
+GitHub Actions is the simplest path from zero to a working pipeline. The repository is already on GitHub, secrets management is built in, and the community action ecosystem covers every tool in the stack (Android SDK, Rust, Bun, Supabase CLI, Play Store upload). For a solo developer, minimizing external service dependencies reduces operational overhead.
+
+## Consequences
+
+- All CI/CD configuration lives in `.github/workflows/` within the repository
+- Build minutes are consumed from the GitHub Actions free tier (2,000 min/month for private repos)
+- Android release builds (~15-20 min cold, ~8-10 min cached) will be the largest consumer of minutes
+- Future desktop or iOS pipelines can be added as additional workflow files without changing the CI platform
+- If build volume exceeds free tier, GitHub Actions billing or self-hosted runners become the next decision point
diff --git a/Context/Decisions/ADR-007-transient-store-deep-link-state.md b/Context/Decisions/ADR-007-transient-store-deep-link-state.md
new file mode 100644
index 00000000..2ded730f
--- /dev/null
+++ b/Context/Decisions/ADR-007-transient-store-deep-link-state.md
@@ -0,0 +1,46 @@
+# ADR-007: Transient Zustand Store for Cross-Route Deep Link State
+
+## Status
+
+Accepted
+
+## Context
+
+Feature 011 (Deep Link Handler) needs to pass backend credentials from a deep link event to the BackendSettings component on the `/profile` page. This happens when a configured user taps an `ardentforge://connect` link for a different server -- the app navigates to settings so the user can review and confirm the backend change.
+
+The deep link handler runs outside React's render cycle (in the Tauri `onOpenUrl` callback or during a `/connect` route redirect). It needs to communicate structured data (`url` and `key`) to a component that mounts later on a different route.
+
+## Options Considered
+
+### Option A: Route search params on `/profile`
+
+Add `connectUrl` and `connectKey` search params to the profile route. The deep link handler navigates to `/profile?connectUrl=X&connectKey=Y`, and BackendSettings reads them.
+
+**Pros:** No new state management. Pure URL-driven.
+**Cons:** Couples `/profile` to deep link concerns. Exposes backend credentials in the URL bar and browser history. The profile route's `validateSearch` gains parameters unrelated to profile functionality.
+
+### Option B: Transient Zustand store
+
+Create a small Zustand store (`usePendingConnect`) with `pending: { url, key } | null`, `setPending()`, and `clear()`. The handler sets values before navigating; BackendSettings reads and clears on mount.
+
+**Pros:** Decoupled from routing. No credential leakage into URL/history. Zustand stores are accessible outside React via `getState()`, which the non-React deep link handler needs. Values are ephemeral (cleared on consumption, lost on reload).
+**Cons:** Adds a small module. Values don't survive a full page reload (acceptable -- deep links trigger fresh app launches, so the store is populated before the target component mounts).
+
+### Option C: sessionStorage
+
+Store pending values in `sessionStorage`, read and clear in BackendSettings.
+
+**Pros:** Survives page reload. No framework dependency.
+**Cons:** Requires manual serialization. Not type-safe without wrapper. Zustand is already the project's state management tool -- adding a parallel mechanism for one use case is inconsistent.
+
+## Decision
+
+**Option B: Transient Zustand store.**
+
+The store is 10 lines of code, follows the project's existing state management pattern, and cleanly handles the non-React-to-React handoff that this feature requires. Route search params (Option A) would work functionally but leak credentials and pollute the profile route's API surface.
+
+## Consequences
+
+- New file: `src/lib/pending-connect.ts`
+- BackendSettings gains a `useEffect` that checks for pending values on mount
+- If future features need similar cross-route handoffs, this pattern can be replicated (but should not be abstracted into a framework until 3+ stores exist)
diff --git a/Context/Decisions/ADR-008-dual-export-zod-schemas.md b/Context/Decisions/ADR-008-dual-export-zod-schemas.md
new file mode 100644
index 00000000..151afd0d
--- /dev/null
+++ b/Context/Decisions/ADR-008-dual-export-zod-schemas.md
@@ -0,0 +1,56 @@
+# ADR-008: Dual Export for Zod Schemas with Refinements
+
+## Status
+
+Accepted
+
+## Context
+
+PR #71 added `.refine()` calls to `conversationSchema`, `conversationParticipantSchema`, and `messageSchema` to enforce domain invariants (e.g., direct conversations cannot have a groupId, leftAt must be after joinedAt, system messages cannot have a senderId).
+
+Adding `.refine()` to a `ZodObject` changes its type to `ZodEffects`, which loses access to `.pick()`, `.omit()`, `.extend()`, and `.merge()`. Any consumer deriving a sub-schema (e.g., a create form that picks a subset of fields) will hit a runtime error because those methods do not exist on `ZodEffects`.
+
+This affects all domain schemas in `src/domain/types/` that use refinements.
+
+## Options Considered
+
+### Option A: Only export the refined schema
+
+Keep the current single export. Consumers that need composability must work around it by duplicating the base shape or using `z.object()` from scratch.
+
+**Pros:** Simple, single export per schema.
+**Cons:** Breaks `.pick()`, `.omit()`, `.extend()`, `.merge()` for all consumers. Forces duplication when deriving sub-schemas.
+
+### Option B: Dual export (object schema + refined schema)
+
+Export both the raw `ZodObject` (for composability) and the refined `ZodEffects` (for validation). Naming convention: `fooObjectSchema` for the composable base, `fooSchema` for the validated version.
+
+**Pros:** Consumers choose composability or validation as needed. No runtime surprises. The refined schema wraps the object schema, so there is no duplication of field definitions.
+**Cons:** Two exports per schema. Consumers must know which to use.
+
+### Option C: Move refinements to standalone assertion functions
+
+Remove `.refine()` from schemas entirely. Export only the `ZodObject` and provide separate `assertConversation(data)` functions that throw on invariant violations.
+
+**Pros:** Full composability preserved. Assertions can be called explicitly at boundaries.
+**Cons:** Loses Zod's built-in error formatting and `.safeParse()` integration. Validation is no longer co-located with the schema definition.
+
+## Decision
+
+**Option B: Dual export.**
+
+Convention:
+
+- `export const conversationObjectSchema = syncableEntitySchema.extend({...})`
+- `export const conversationSchema = conversationObjectSchema.refine(...)`
+- Type inference uses the refined schema: `export type Conversation = z.infer`
+
+This keeps validation co-located with the schema, preserves composability for derived schemas, and follows the principle of least surprise -- `fooSchema` always validates, `fooObjectSchema` always composes.
+
+## Consequences
+
+- All domain schemas with refinements (`conversation.ts`, `message.ts`) will export both `*ObjectSchema` and `*Schema`
+- Consumers that derive sub-schemas (forms, API payloads) use `*ObjectSchema`
+- Consumers that validate data at boundaries use `*Schema`
+- New domain schemas should follow this pattern if they add refinements
+- Schemas without refinements continue to export a single schema (no dual export needed)
diff --git a/Context/Decisions/ADR-008-tauri-error-kind-sync.md b/Context/Decisions/ADR-008-tauri-error-kind-sync.md
new file mode 100644
index 00000000..3846b1ab
--- /dev/null
+++ b/Context/Decisions/ADR-008-tauri-error-kind-sync.md
@@ -0,0 +1,35 @@
+# ADR-008: Synchronize TypeScript TauriAppError Kind Union with Rust ErrorKind
+
+**Status:** Accepted
+**Date:** 2026-04-04
+**Context:** PR #72 review finding P9-009
+
+## Context
+
+The Rust `ErrorKind` enum (`src-tauri/src/error.rs`) includes variants: `NotFound`, `Conflict`, `Validation`, `Database`, `Internal`, `Unauthorized`, `Sync`, and `Network`.
+
+The TypeScript `TauriAppError['kind']` type (`src/lib/tauri-adapter.ts:476-480`) only lists: `NOT_FOUND | CONFLICT | VALIDATION | DATABASE | INTERNAL`.
+
+If Rust returns `UNAUTHORIZED`, `SYNC`, or `NETWORK`, TypeScript code doing exhaustive switch/case on the kind will miss them. This is pre-existing but was surfaced because PR #72 adds tests exercising the error wrapping path.
+
+## Decision
+
+**Option A: Sync the TypeScript union to match Rust.**
+
+Add `UNAUTHORIZED | SYNC | NETWORK` to the TypeScript `TauriAppError['kind']` type.
+
+**Why:**
+
+- `UNAUTHORIZED` and `SYNC` are already used in Rust production code. The frontend will need to distinguish these for auth redirects and retry logic.
+- `NETWORK` is declared in Rust (currently `#[allow(dead_code)]`) but Serde will serialize it if constructed. Including it prevents a runtime/type mismatch.
+- The `isTauriAppError` type guard only checks `typeof kind === 'string'`, so Rust can already return any variant and pass the guard. The TS union was simply lying about what values were possible.
+- Mapping unknown kinds to `INTERNAL` (Option B) hides the error's nature and prevents appropriate handling (e.g., redirecting to login on `UNAUTHORIZED`).
+- New Rust `ErrorKind` variants are rare and should cause a compile-time type mismatch in TypeScript, which is the desired failure mode.
+
+**Option B rejected:** Silent fallback to `INTERNAL` actively hides error semantics.
+
+## Consequences
+
+- Every new Rust `ErrorKind` variant requires a corresponding TypeScript update. This is intentional -- loud failures at compile time are better than silent mishandling at runtime.
+- No production callers currently switch on `kind`, so no existing code needs changes beyond the type definition.
+- Aligns with the project's `typescript-conventions.md` rule: domain-keyed types must use the union, not `string`.
diff --git a/Context/Features/007-video-image-sharing/Steps.md b/Context/Features/007-video-image-sharing/Steps.md
index d2d405e0..32a096a4 100644
--- a/Context/Features/007-video-image-sharing/Steps.md
+++ b/Context/Features/007-video-image-sharing/Steps.md
@@ -503,6 +503,41 @@
---
+### S024-T: Unit tests -- CloudflareStreamProvider (media-provider.ts)
+
+**Agent:** quality-engineer
+**Files:** `src/lib/__tests__/media-provider.test.ts`
+**Depends on:** S003
+**Parallel:** Yes (parallel with S023-T, S025-T)
+**Added by:** PR #59 review (T1, criticality 8/10)
+
+- [ ] `isExpired` TTL logic: cached URL before expiry returns cache hit
+- [ ] Cache miss on expiry: expired URL triggers new fetch
+- [ ] Null client throws meaningful error
+- [ ] Zod rejects malformed Cloudflare API response
+- [ ] Signed URL cache hit: second call with same assetId returns cached URL without API call
+
+**Acceptance:** CloudflareStreamProvider edge cases covered, especially signed URL caching with TTL
+
+---
+
+### S025-T: Unit tests -- transcoding timeout behavior (use-media-upload.ts)
+
+**Agent:** quality-engineer
+**Files:** `src/hooks/__tests__/use-media-upload.test.ts`
+**Depends on:** S010
+**Parallel:** Yes (parallel with S023-T, S024-T)
+**Added by:** PR #59 review (T2, criticality 8/10)
+
+- [ ] With fake timers: 5-minute timeout marks processing attachment as `failed`
+- [ ] Ready attachment is unaffected by timeout
+- [ ] Query cache invalidated after timeout fires
+- [ ] Timer cleared on unmount (no stale invalidations)
+
+**Acceptance:** Transcoding timeout logic fully covered including cleanup on unmount
+
+---
+
## Milestone Summary
| Wave | Tasks | Parallel | Description |
@@ -512,9 +547,9 @@
| Wave 3 | S009-S011 | S009->S010 sequential, S011 parallel | Upload pipeline: service, upload hook, attachments hook (runs parallel with Waves 2 and 4) |
| Wave 4 | S012-S017 | S012-S014 parallel, S015 after S012+S013, S016-S017 after S015 | Rendering: status indicator, file card, progress, media content, video, lightbox (runs parallel with Waves 2-3) |
| Wave 5 | S018-S021 | All 4 parallel | Integration: realtime, attachment picker, compose bar, message routing |
-| Wave 6 | S022-T, S023-T | Sequential | Validation: end-to-end + unit tests |
+| Wave 6 | S022-T, S023-T, S024-T, S025-T | S022-T sequential, S023-T/S024-T/S025-T parallel | Validation: end-to-end + unit tests |
-**Totals:** 21 implementation tasks + 2 validation tasks = 23 tasks
+**Totals:** 21 implementation tasks + 4 validation tasks = 25 tasks
**Agents:** 3 frontend specialists + 1 backend specialist + 1 quality engineer = 5 agents
**Execution mode:** `/build` (agents work on isolated vertical slices with shared contracts below)
diff --git a/Context/Features/008-display-broadcast-infrastructure/Spec.md b/Context/Features/008-display-broadcast-infrastructure/Spec.md
new file mode 100644
index 00000000..ea3ef0b9
--- /dev/null
+++ b/Context/Features/008-display-broadcast-infrastructure/Spec.md
@@ -0,0 +1,95 @@
+# Spec: Display Broadcast Infrastructure (Step 27)
+
+**Feature:** 008-display-broadcast-infrastructure
+**Status:** Draft
+**Created:** 2026-04-03
+**Source:** `docs/implementation-plan.md` Step 27, `docs/13-prd-remote-display.md` Broadcast Channel Design + Snapshot Publishing + Display Opt-Out, `docs/05-domain-model.md` UserProfile, `docs/06-invariants.md`
+
+---
+
+## Overview
+
+Build the data pipeline that powers the remote gym display. The phone constructs a `DisplaySnapshot` from the active workout Zustand store and publishes it to a Supabase Realtime Broadcast channel named `display` on every meaningful state change (set confirmed, exercise transitioned, rest timer started/expired, workout started/completed). A display visibility toggle on the user profile gates all publishing. This step produces no UI for the `/display` route itself (that is Step 28) -- it produces the Zod schema, the publisher logic wired into the existing active workout store, the visibility setting, and the Broadcast channel plumbing.
+
+## Problem Statement
+
+The active workout store manages all in-progress workout state but has no mechanism to push that state to external consumers. The existing Broadcast infrastructure (`RealtimeManager`) is scoped entirely to chat channels (`chat:{conversationId}`). A separate, independent channel is needed for display broadcast -- a single `display` channel that all phone clients publish to and all display clients subscribe to, discriminated by `user_id` in the payload.
+
+## User Stories
+
+1. **As a gym member**, when I confirm a set during my workout, the gym TV automatically updates to show my progress within 500ms, so my training partners can see what I'm doing.
+2. **As a gym member**, I can toggle "Display Visibility" off in Settings so my workout does not appear on the gym TV.
+3. **As a gym member**, I can tap "Push to Display" during my workout to make the gym TV show my session full-screen.
+4. **As a gym member**, I can tap "Return to Board" to give the TV back to the multi-user view.
+5. **As a display client** (the TV), when I subscribe to the Broadcast channel I receive self-contained snapshots that require no database lookups to render.
+6. **As a display client**, when a user finishes their workout I receive a `session_ended` event so I can remove their card.
+
+## Requirements
+
+### Must Have (P0)
+
+| ID | Requirement | Invariant | Verification |
+|----|-------------|-----------|--------------|
+| M1 | `DisplaySnapshot` Zod schema validates all fields from PRD "What Gets Pushed" table | -- | Unit test: valid snapshot passes, missing required fields rejected |
+| M2 | Active workout store publishes `workout_snapshot` on set confirmed | -- | Confirm a set in dev tools, observe Broadcast event in Supabase dashboard |
+| M3 | Active workout store publishes `workout_snapshot` on exercise transitioned | -- | Add a new exercise, observe Broadcast event |
+| M4 | Active workout store publishes `workout_snapshot` on rest timer started | -- | Start rest, observe Broadcast event with `rest_timer.state = 'running'` |
+| M5 | Active workout store publishes `workout_snapshot` on rest timer expired | -- | Let timer expire, observe Broadcast event with `rest_timer.state = 'idle'` |
+| M6 | Active workout store publishes `workout_snapshot` on workout started | -- | Start workout, observe initial Broadcast snapshot |
+| M7 | Active workout store publishes `session_ended` on workout completion | -- | Finish workout, observe `session_ended` event with `user_id` |
+| M8 | Active workout store publishes `session_ended` on workout abandoned/discarded | -- | Discard workout, observe `session_ended` event |
+| M9 | Phone publishes `focus` event with `user_id` payload | -- | Trigger focus from phone, observe event |
+| M10 | Phone publishes `unfocus` event with empty payload | -- | Trigger unfocus, observe event |
+| M11 | `display_visible` boolean exists on UserProfile (default: `true`) | -- | New user profile has `display_visible = true` |
+| M12 | When `display_visible = false`, zero Broadcast events are published | -- | Opt out, confirm set, verify no events on channel |
+| M13 | Snapshot includes correct rest timer state: `running` with `started_at` and `total_seconds`, or `idle` | -- | Start timer, verify snapshot timer fields; let expire, verify idle |
+| M14 | Publishing is fire-and-forget and does not degrade active workout UI performance | -- | Confirm set with network latency; UI feedback remains < 100ms |
+| M15 | Snapshot is self-contained -- all rendering data included, no entity IDs that require DB lookup | -- | Inspect snapshot payload: exercise names (not IDs), display name, session type label |
+
+### Should Have (P1)
+
+| ID | Requirement | Verification |
+|----|-------------|--------------|
+| S1 | Display Broadcast channel is independent from chat Broadcast channels | Verify `display` channel exists separately from `chat:*` channels |
+| S2 | Display visibility toggle is available in Settings under "Remote Display" section | Visual inspection of Settings screen |
+| S3 | "Push to Display" / "Return to Board" button appears in active workout header | Visual inspection during active workout |
+| S4 | Snapshot publishes on `rest_timer_adjusted` (user changes timer duration mid-countdown) | Adjust timer, observe updated snapshot |
+
+### Will Not Have
+
+| ID | Feature | Rationale |
+|----|---------|-----------|
+| W1 | `/display` route rendering | Step 28 |
+| W2 | Idle mode data or Edge Function | Step 29 |
+| W3 | Display-side subscription or state management | Step 28 |
+| W4 | Database persistence of snapshots | Snapshots are ephemeral by design (Constraint 3 in PRD) |
+| W5 | Display-side authentication | PRD explicitly excludes this |
+
+## Testable Assertions
+
+| ID | Assertion | How to Test |
+|----|-----------|-------------|
+| TA1 | `displaySnapshotSchema.parse(validPayload)` succeeds for a complete snapshot | Unit test with Vitest |
+| TA2 | `displaySnapshotSchema.parse(incompletePayload)` throws for missing required fields | Unit test with Vitest |
+| TA3 | `confirmSet` action publishes exactly one `workout_snapshot` event | Integration test: spy on Broadcast channel, call `confirmSet`, assert 1 event |
+| TA4 | `finishWorkout` action publishes exactly one `session_ended` event | Integration test: spy on channel, call `finishWorkout`, assert event |
+| TA5 | With `display_visible = false`, all publish actions produce zero events | Integration test: set flag false, run all actions, assert 0 events |
+| TA6 | `focus` event payload contains `{ user_id }` | Unit test on payload construction |
+| TA7 | `unfocus` event payload is `{}` | Unit test on payload construction |
+| TA8 | Snapshot `sets` array contains `prescribed` and `actual` values, not IDs | Inspect serialized snapshot in test |
+| TA9 | Snapshot `rest_timer` when running includes `started_at` (ISO) and `total_seconds` (number) | Unit test on timer snapshot construction |
+| TA10 | Publishing does not block the Zustand state update | Timing test: measure time from `confirmSet` call to state update; must be < 5ms |
+
+## Open Questions
+
+None -- all display-related architectural decisions are resolved in `13-prd-remote-display.md` Resolved Decisions table.
+
+## Dependencies
+
+| Dependency | Status | Impact |
+|------------|--------|--------|
+| Step 6: Active Workout Logging | Complete | Store actions exist to hook into |
+| Step 1.5: Design System | Complete | Settings UI for visibility toggle |
+| Supabase Realtime | Available | Broadcast channel infrastructure from chat (Feature 005) |
+| UserProfile domain type | Exists | Needs `display_visible` field added |
+| `RealtimeManager` pattern | Exists | Pattern reference; display uses independent channel, not the chat manager |
diff --git a/Context/Features/008-display-broadcast-infrastructure/Steps.md b/Context/Features/008-display-broadcast-infrastructure/Steps.md
new file mode 100644
index 00000000..3c29b4c9
--- /dev/null
+++ b/Context/Features/008-display-broadcast-infrastructure/Steps.md
@@ -0,0 +1,385 @@
+# Steps: Display Broadcast Infrastructure (Step 27)
+
+**Feature:** 008-display-broadcast-infrastructure
+**Status:** Complete
+**Created:** 2026-04-03
+
+---
+
+## Team Composition
+
+| Role | Agent | Scope |
+|------|-------|-------|
+| `backend-specialist` | backend-specialist | Migrations (Postgres + SQLite), Rust model/command changes, data mapper |
+| `frontend-specialist` | frontend-specialist | Zod schema, snapshot builder, publisher module, hook, Settings UI, Push to Display button |
+| `quality-engineer` | quality-engineer | Unit tests for schema, builder, and publisher; integration validation |
+
+---
+
+## Wave 1: Domain + Database (backend-specialist + frontend-specialist, parallel)
+
+### S001: DisplaySnapshot Zod schema and types
+
+**Agent:** frontend-specialist
+**Files:** `src/domain/types/display-snapshot.ts`, `src/domain/types/index.ts`
+**Depends on:** Nothing
+**Parallel:** Yes (independent of S002-S004)
+
+Define the `DisplaySnapshot` Zod schema and related types:
+
+- [ ] `restTimerStateSchema` -- discriminated union: `{ state: 'idle' }` or `{ state: 'running', started_at: isoDateTime, total_seconds: z.number().positive() }`
+- [ ] `displaySetSchema` -- `{ set_number: z.number().int().min(1), prescribed: z.object({ reps, weight }).optional(), actual: z.object({ reps, weight }).optional(), completed: z.boolean() }`
+- [ ] `displaySnapshotSchema` -- all 11 fields from PRD "What Gets Pushed" table: `user_id`, `display_name`, `session_name`, `workout_started_at` (isoDateTime), `current_exercise` (string), `exercise_index` (non-negative int), `total_exercises` (positive int), `sets` (array of `displaySetSchema`), `rest_timer` (`restTimerStateSchema`), `session_type` (sessionTypeSchema), `is_visible` (literal `true` -- only visible users get here)
+- [ ] `displayEventTypeSchema` -- enum: `'workout_snapshot'`, `'session_ended'`, `'focus'`, `'unfocus'`
+- [ ] Export all types from `src/domain/types/index.ts` barrel
+
+**Acceptance:** `displaySnapshotSchema.parse(validPayload)` succeeds; `.parse(incompletePayload)` throws. (TA1, TA2)
+
+---
+
+### S002: Add `displayVisible` to UserProfile domain type
+
+**Agent:** frontend-specialist
+**Files:** `src/domain/types/user.ts`
+**Depends on:** Nothing
+**Parallel:** Yes (independent of S001, S003, S004)
+
+- [ ] Add `displayVisible: z.boolean().optional()` to `userProfileSchema`
+- [ ] Default behavior: `undefined` treated as `true` (visible) by publisher
+
+**Acceptance:** `UserProfile` type accepts `displayVisible: true`, `displayVisible: false`, and `undefined`. (TA5 partial)
+
+---
+
+### S003: Postgres migration for `display_visible`
+
+**Agent:** backend-specialist
+**Files:** `supabase/migrations/20260404000001_add_display_visible.sql`
+**Depends on:** Nothing
+**Parallel:** Yes (independent of S001, S002, S004)
+
+- [ ] `ALTER TABLE user_profiles ADD COLUMN display_visible boolean NOT NULL DEFAULT true;`
+- [ ] Add column comment explaining purpose
+
+**Acceptance:** Migration applies cleanly to local Supabase; `SELECT display_visible FROM user_profiles` returns `true` for existing rows.
+
+---
+
+### S004: SQLite migration + Rust model/command changes for `display_visible`
+
+**Agent:** backend-specialist
+**Files:** `src-tauri/migrations/008_add_display_visible.sql`, `src-tauri/src/models.rs`, `src-tauri/src/commands/user_profile.rs`
+**Depends on:** Nothing
+**Parallel:** Yes (independent of S001-S003)
+
+- [ ] SQLite migration: `ALTER TABLE user_profiles ADD COLUMN display_visible INTEGER NOT NULL DEFAULT 1;`
+- [ ] Add `pub display_visible: Option` to `UserProfileRow` in `models.rs`
+- [ ] Add `pub display_visible: Option` to `UpdateUserProfileInput` struct
+- [ ] Add `display_visible` to `get_user_profile` SELECT column list and result mapping
+- [ ] Add `display_visible` to `update_user_profile` UPSERT column list, SET clause, and `.bind()` with bool-to-int conversion
+
+**Acceptance:** `tauri dev` -- update profile with `display_visible: false` -- read back -- value persists as `0` in SQLite and round-trips correctly through the command.
+
+---
+
+### S005: Data mapper + Tauri adapter for `display_visible`
+
+**Agent:** backend-specialist
+**Files:** `src/lib/data-mapper.ts`, `src/lib/tauri-adapter.ts`
+**Depends on:** S002 (type must exist), S004 (Rust command must accept the field)
+**Parallel:** No (sequential after S002 and S004)
+
+- [ ] `toUserProfile`: add `displayVisible: row.display_visible ?? undefined` (Postgres) / `displayVisible: row.display_visible === 1 ? true : row.display_visible === 0 ? false : undefined` (SQLite via Rust)
+- [ ] `fromUserProfile`: add `if (profile.displayVisible !== undefined) row.display_visible = profile.displayVisible`
+- [ ] Tauri adapter `updateUserProfile`: add `display_visible: partial.display_visible ?? null` to the input object passed to `invoke('update_user_profile')`
+
+**Acceptance:** Round-trip test: set `displayVisible: false` via adapter, read back, value is `false`. Set `displayVisible: true`, read back, value is `true`.
+
+---
+
+## Wave 2: Publisher Infrastructure (frontend-specialist, depends on Wave 1)
+
+### S006: Snapshot builder pure function
+
+**Agent:** frontend-specialist
+**Files:** `src/lib/display-snapshot.ts`
+**Depends on:** S001 (schema), S002 (UserProfile type)
+**Parallel:** Yes (parallel with S007)
+
+Implement `buildDisplaySnapshot()`:
+
+- [ ] Function signature: `(state: ActiveWorkoutState, context: SnapshotContext) => DisplaySnapshot`
+- [ ] `SnapshotContext` type: `{ userId: string, displayName: string, exerciseNameMap: Record, sessionType: SessionType }`
+- [ ] Map `state.workoutLog.title ?? 'Ad Hoc Workout'` to `session_name`
+- [ ] Map `state.workoutLog.startedAt` to `workout_started_at`
+- [ ] Derive `current_exercise` from last activity in last group, resolved via `exerciseNameMap[exerciseId]` with fallback to `'Unknown Exercise'`
+- [ ] Derive `exercise_index` and `total_exercises` by flattening all activities across all groups
+- [ ] Map current exercise's `sets` array to `DisplaySet[]` format (set_number, prescribed, actual, completed)
+- [ ] Build `rest_timer` state: if `state.restTimer` is null -> `{ state: 'idle' }`; if present -> `{ state: 'running', started_at: new Date(Date.now() - (restTimer.total - restTimer.remaining) * 1000).toISOString(), total_seconds: restTimer.total }`
+- [ ] Set `is_visible: true` (publisher already gates on visibility)
+
+**Acceptance:** Pure function with known inputs produces expected `DisplaySnapshot`. Timer state mapping correct for both running and idle cases. (TA1, TA8, TA9)
+
+---
+
+### S007: Display publisher module
+
+**Agent:** frontend-specialist
+**Files:** `src/lib/display-publisher.ts`
+**Depends on:** S001 (schema for type safety)
+**Parallel:** Yes (parallel with S006)
+
+Implement the publisher module with module-scope state:
+
+- [ ] `_channel: RealtimeChannel | null` -- lazy-created on first publish
+- [ ] `_userId: string | null` and `_displayVisible: boolean` -- set by `configureDisplayPublisher()`
+- [ ] `initDisplayPublisher(client: SupabaseClient)` -- stores client reference, does NOT create channel yet
+- [ ] `configureDisplayPublisher({ userId, displayVisible })` -- sets module-level flags
+- [ ] `publishDisplaySnapshot(snapshot: DisplaySnapshot)` -- guards: client initialized, `_displayVisible` is true. Lazy-creates channel with `client.channel('display', { broadcast: { ack: false, self: false } })`. Calls `channel.send({ type: 'broadcast', event: 'workout_snapshot', payload: snapshot })`. Fire-and-forget: `.catch(console.error)`.
+- [ ] `publishSessionEnded(userId: string)` -- same guards. Event: `session_ended`, payload: `{ user_id: userId }`.
+- [ ] `publishFocusEvent(userId: string)` -- event: `focus`, payload: `{ user_id: userId }`.
+- [ ] `publishUnfocusEvent()` -- event: `unfocus`, payload: `{}`.
+- [ ] `destroyDisplayPublisher()` -- unsubscribes channel, nulls all module-scope state.
+- [ ] All publish functions return `void` (not Promise) -- fire-and-forget pattern.
+
+**Acceptance:** With mocked Supabase client: `publishDisplaySnapshot` calls `channel.send` with correct event/payload. With `_displayVisible = false`: zero `channel.send` calls. (TA3 partial, TA5, TA10)
+
+---
+
+## Wave 3: Store Integration + UI (frontend-specialist, depends on Wave 2)
+
+### S008: Wire publisher into active workout store
+
+**Agent:** frontend-specialist
+**Files:** `src/stores/active-workout-store.ts`
+**Depends on:** S006 (builder), S007 (publisher)
+**Parallel:** No (depends on both Wave 2 tasks)
+
+Add fire-and-forget publish calls to store actions. The publisher context (userId, displayName, exerciseNameMap, sessionType) is set externally by the `useDisplayBroadcast` hook (S009) via a module-level setter.
+
+- [ ] Add module-scope `_snapshotContext: SnapshotContext | null = null`
+- [ ] Add exported `setSnapshotContext(ctx: SnapshotContext | null)` setter
+- [ ] Add internal `_publishCurrentState()` helper: if `_snapshotContext` is null, no-op. Otherwise calls `publishDisplaySnapshot(buildDisplaySnapshot(get(), _snapshotContext))`.
+- [ ] `startWorkout`: call `_publishCurrentState()` after `set()`
+- [ ] `startProgrammedWorkout`: call `_publishCurrentState()` after `set()`
+- [ ] `addExerciseToWorkout`: call `_publishCurrentState()` after `set()`
+- [ ] `confirmSet`: call `_publishCurrentState()` after `set()`
+- [ ] `startRestTimer`: call `_publishCurrentState()` after `set({ restTimer: ... })`
+- [ ] `skipRest`: call `_publishCurrentState()` after clearing timer
+- [ ] `adjustRest`: call `_publishCurrentState()` after update
+- [ ] Rest timer expiry (both Tauri `timer_expired` handler and browser `tickRest` when remaining hits 0): call `_publishCurrentState()` after `set({ restTimer: null })`
+- [ ] `finishWorkout`: call `publishSessionEnded(_snapshotContext.userId)` BEFORE `set({ ...initialState })` (state is wiped by set)
+- [ ] `discardWorkout`: call `publishSessionEnded(_snapshotContext.userId)` BEFORE `set({ ...initialState })`
+
+**Acceptance:** Each action triggers exactly one Broadcast event of the correct type. `finishWorkout` and `discardWorkout` emit `session_ended` before state reset. (TA3, TA4)
+
+---
+
+### S009: `useDisplayBroadcast` hook
+
+**Agent:** frontend-specialist
+**Files:** `src/hooks/use-display-broadcast.ts`
+**Depends on:** S007 (publisher), S008 (store context setter)
+**Parallel:** No (sequential after S008)
+
+React hook that wires the publisher lifecycle to the active workout:
+
+- [ ] Call `initDisplayPublisher(getSupabaseClient())` on mount (no-op if client is null)
+- [ ] Read `displayVisible` from `useUserProfile(userId)` query cache
+- [ ] Call `configureDisplayPublisher({ userId, displayVisible: profile?.displayVisible ?? true })` when profile data changes
+- [ ] Build `exerciseNameMap` from `useExercises()` query cache (or a lightweight lookup)
+- [ ] Derive `sessionType` from workout log's program context (if available) or default `'STRENGTH'`
+- [ ] Call `setSnapshotContext({ userId, displayName, exerciseNameMap, sessionType })` when workout is active; call `setSnapshotContext(null)` when not
+- [ ] Call `destroyDisplayPublisher()` on unmount
+- [ ] Return `{ publishFocus, publishUnfocus, isBroadcasting }` for UI consumption
+
+**Acceptance:** Hook initializes publisher on mount, configures visibility from profile, sets snapshot context when workout is active, cleans up on unmount.
+
+---
+
+### S010: Settings UI -- Remote Display section
+
+**Agent:** frontend-specialist
+**Files:** `src/routes/_authenticated/profile.tsx`
+**Depends on:** S002 (type), S005 (adapter)
+**Parallel:** Yes (parallel with S008, S009)
+
+Add "Remote Display" section to the profile/settings page:
+
+- [ ] New section after "Notifications", using the existing section pattern (border-t, uppercase heading, content)
+- [ ] `useState(null)` for local draft state
+- [ ] Effective value: `displayVisible ?? profile?.displayVisible ?? true`
+- [ ] Use the existing `ToggleRow` sub-component pattern from notification-settings (label + `` in a 48px flex row)
+- [ ] Label: "Display visibility" / Description: "Show your active workout on the gym display"
+- [ ] Include `displayVisible` in the `handleSaveSettings` updates object
+- [ ] Save persists to both Supabase and local profile cache via `useUpdateUserProfile`
+
+**Acceptance:** Toggle visible in Settings under "Remote Display". Toggling off and saving persists `display_visible = false`. Toggling on persists `true`.
+
+---
+
+### S011: "Push to Display" button component
+
+**Agent:** frontend-specialist
+**Files:** `src/components/workout/push-to-display-button.tsx`
+**Depends on:** S007 (publisher focus/unfocus functions)
+**Parallel:** Yes (parallel with S008, S009, S010)
+
+- [ ] Local `isFocused` state (boolean, default false)
+- [ ] On tap when not focused: call `publishFocusEvent(userId)`, set `isFocused = true`
+- [ ] On tap when focused: call `publishUnfocusEvent()`, set `isFocused = false`
+- [ ] Icon: `cast` Material Symbol (or `tv`); toggles between active (`ember`) and inactive (`text-secondary`) states
+- [ ] Label: "Push to Display" when not focused, "Return to Board" when focused
+- [ ] Render only when `isBroadcasting` is true (from `useDisplayBroadcast` hook)
+- [ ] 48px touch target minimum
+
+**Acceptance:** Button renders during active workout. Tap publishes `focus` event with correct `user_id`. Tap again publishes `unfocus` event with empty payload. (TA6, TA7)
+
+---
+
+### S012: Mount hook and button in active workout route
+
+**Agent:** frontend-specialist
+**Files:** `src/routes/_authenticated/tracker.tsx` (or whichever route hosts the active workout)
+**Depends on:** S009 (hook), S011 (button)
+**Parallel:** No (sequential after S009 and S011)
+
+- [ ] Call `useDisplayBroadcast(userId)` in the active workout route component
+- [ ] Render `` in the workout header/action area, passing `publishFocus`, `publishUnfocus`, `isBroadcasting` from the hook
+- [ ] Verify the hook cleans up on route exit (unmount)
+
+**Acceptance:** Active workout publishes snapshots to the `display` Broadcast channel. Push to Display button is visible and functional. Exiting the workout route cleans up the publisher.
+
+---
+
+## Wave 4: Validation (quality-engineer, depends on Waves 1-3)
+
+### S013-T: Unit tests -- Zod schema and snapshot builder
+
+**Agent:** quality-engineer
+**Files:** `src/domain/types/__tests__/display-snapshot.test.ts`, `src/lib/__tests__/display-snapshot.test.ts`
+**Depends on:** S001, S006
+**Parallel:** Yes (parallel with S014-T)
+
+- [ ] Schema: valid complete snapshot passes parse
+- [ ] Schema: missing `user_id` throws
+- [ ] Schema: missing `current_exercise` throws
+- [ ] Schema: `rest_timer` with `state: 'running'` requires `started_at` and `total_seconds`
+- [ ] Schema: `rest_timer` with `state: 'idle'` requires no extra fields
+- [ ] Builder: known state input produces correct `session_name`, `current_exercise`, `exercise_index`, `total_exercises`
+- [ ] Builder: sets array maps prescribed and actual correctly
+- [ ] Builder: null rest timer -> `{ state: 'idle' }`
+- [ ] Builder: active rest timer -> `{ state: 'running', started_at: , total_seconds: }`
+- [ ] Builder: exercise name fallback to 'Unknown Exercise' when ID not in map
+
+**Acceptance:** All assertions pass. (TA1, TA2, TA8, TA9)
+
+---
+
+### S014-T: Unit tests -- publisher guards and event emission
+
+**Agent:** quality-engineer
+**Files:** `src/lib/__tests__/display-publisher.test.ts`
+**Depends on:** S007
+**Parallel:** Yes (parallel with S013-T)
+
+- [ ] Not initialized: `publishDisplaySnapshot` is a no-op (zero `channel.send` calls)
+- [ ] Initialized but `displayVisible = false`: `publishDisplaySnapshot` is a no-op
+- [ ] Initialized and `displayVisible = true`: `publishDisplaySnapshot` calls `channel.send` with `event: 'workout_snapshot'`
+- [ ] `publishSessionEnded` sends `event: 'session_ended'` with `{ user_id }`
+- [ ] `publishFocusEvent` sends `event: 'focus'` with `{ user_id }`
+- [ ] `publishUnfocusEvent` sends `event: 'unfocus'` with `{}`
+- [ ] `destroyDisplayPublisher` calls `channel.unsubscribe`
+- [ ] Publish does not throw on `channel.send` rejection (fire-and-forget)
+
+**Acceptance:** All assertions pass. (TA3, TA4, TA5, TA6, TA7, TA10)
+
+---
+
+## Milestone Summary
+
+| Wave | Tasks | Parallel | Description |
+|------|-------|----------|-------------|
+| Wave 1 | S001-S005 | S001-S004 parallel; S005 sequential after S002+S004 | Domain types, migrations, data mapper |
+| Wave 2 | S006-S007 | Both parallel | Snapshot builder + publisher module |
+| Wave 3 | S008-S012 | S010+S011 parallel with S008+S009; S012 sequential last | Store wiring, hook, Settings UI, button |
+| Wave 4 | S013-T, S014-T | Both parallel | Unit tests for schema, builder, publisher |
+
+**Totals:** 12 implementation tasks + 2 validation tasks = 14 tasks
+**Agents:** 1 frontend specialist + 1 backend specialist + 1 quality engineer = 3 agents
+**Execution mode:** `/build` (agents work on isolated vertical slices; frontend-specialist owns most tasks sequentially through Waves 1-3)
+
+**Parallel execution strategy:** Wave 1 runs 4 tasks in parallel (S001-S004), with S005 gated on S002+S004. Wave 2 runs 2 tasks in parallel. Wave 3 runs S010+S011 in parallel with the sequential S008->S009->S012 chain. Wave 4 runs both test tasks in parallel after all implementation is complete. The backend-specialist finishes after Wave 1 (S003, S004, S005) and is released. The frontend-specialist carries the bulk of work through Waves 1-3. The quality-engineer activates only in Wave 4.
+
+---
+
+## Contract: Shared Interfaces
+
+### SnapshotContext (frontend-specialist builds in S006, consumed by store in S008 and hook in S009)
+
+```typescript
+interface SnapshotContext {
+ userId: string
+ displayName: string
+ exerciseNameMap: Record
+ sessionType: SessionType
+}
+```
+
+### DisplaySnapshot (frontend-specialist builds in S001, consumed by publisher in S007 and builder in S006)
+
+```typescript
+// Zod-inferred type from displaySnapshotSchema
+interface DisplaySnapshot {
+ user_id: string
+ display_name: string
+ session_name: string
+ workout_started_at: string // ISO 8601
+ current_exercise: string
+ exercise_index: number
+ total_exercises: number
+ sets: DisplaySet[]
+ rest_timer: RestTimerState
+ session_type: SessionType
+ is_visible: true
+}
+
+interface DisplaySet {
+ set_number: number
+ prescribed?: { reps?: number; weight?: { value: number; unit: string } }
+ actual?: { reps?: number; weight?: { value: number; unit: string } }
+ completed: boolean
+}
+
+type RestTimerState =
+ | { state: 'idle' }
+ | { state: 'running'; started_at: string; total_seconds: number }
+```
+
+### DisplayPublisher API (frontend-specialist builds in S007, consumed by store in S008, hook in S009, button in S011)
+
+```typescript
+function initDisplayPublisher(client: SupabaseClient): void
+function configureDisplayPublisher(opts: { userId: string; displayVisible: boolean }): void
+function publishDisplaySnapshot(snapshot: DisplaySnapshot): void
+function publishSessionEnded(userId: string): void
+function publishFocusEvent(userId: string): void
+function publishUnfocusEvent(): void
+function destroyDisplayPublisher(): void
+```
+
+### Store Context Setter (frontend-specialist builds in S008, consumed by hook in S009)
+
+```typescript
+function setSnapshotContext(ctx: SnapshotContext | null): void
+```
+
+### useDisplayBroadcast Return (frontend-specialist builds in S009, consumed by route in S012 and button in S011)
+
+```typescript
+interface UseDisplayBroadcastReturn {
+ publishFocus: () => void
+ publishUnfocus: () => void
+ isBroadcasting: boolean
+}
+```
diff --git a/Context/Features/008-display-broadcast-infrastructure/Tech.md b/Context/Features/008-display-broadcast-infrastructure/Tech.md
new file mode 100644
index 00000000..5544242e
--- /dev/null
+++ b/Context/Features/008-display-broadcast-infrastructure/Tech.md
@@ -0,0 +1,328 @@
+# Tech: Display Broadcast Infrastructure (Step 27)
+
+**Feature:** 008-display-broadcast-infrastructure
+**Status:** Draft
+**Created:** 2026-04-03
+
+---
+
+## Architecture Overview
+
+The display broadcast pipeline has four layers:
+
+```
+Active Workout Store (state changes)
+ |
+ v
+Snapshot Builder (pure function: state -> DisplaySnapshot)
+ |
+ v
+Display Publisher (side-effect module: snapshot -> Broadcast channel)
+ |
+ v
+Supabase Realtime Broadcast (channel: "display")
+```
+
+The phone is the only publisher. The display route (Step 28) is the only subscriber. Snapshots are ephemeral -- never persisted to any database.
+
+---
+
+## Key Decisions
+
+### D1: Publisher as a standalone module, not inline in the store
+
+**Decision:** Create `src/lib/display-publisher.ts` as a standalone module with a `publishDisplaySnapshot()` function. Store actions call into the publisher after state mutations.
+
+**Options considered:**
+- **(a) Zustand `subscribe` middleware**: Watch the store for changes and publish automatically. Rejected because the elapsed timer ticks every second, and there is no clean way to distinguish "meaningful" changes (set confirmed) from "noise" (elapsed tick) without maintaining shadow state to diff against. The filtering logic would be fragile and hard to test.
+- **(b) Inline Supabase calls in store actions**: Rejected because the store currently has zero Supabase imports, and adding them would violate the existing separation. The only non-Zustand side effects in the store are timer management (module-scope intervals and Tauri invoke/listen).
+- **(c) Standalone publisher module called from store actions**: Selected. Follows the same pattern as the Tauri timer invocations already present in the store -- the store already performs side effects, but delegates the implementation to external modules. The publisher import is lightweight and the calls are fire-and-forget.
+
+**Why (c) wins:** Explicit control over which actions trigger a publish. Testable in isolation (mock the publisher, assert calls). The store gains one new import but no new architectural pattern -- it already calls `invoke()` and `listen()` from `@tauri-apps/api`.
+
+### D2: Snapshot builder as a pure function
+
+**Decision:** `buildDisplaySnapshot(state, profile)` is a pure function in `src/lib/display-snapshot.ts` that takes the active workout state and the user profile, and returns a `DisplaySnapshot`. No Supabase client, no side effects.
+
+**Rationale:** Separation of snapshot construction (deterministic, unit-testable) from snapshot delivery (network I/O, fire-and-forget). The builder can be tested with plain objects; the publisher can be tested by mocking the Supabase channel.
+
+### D3: Single `display` Broadcast channel, independent from chat RealtimeManager
+
+**Decision:** The display publisher creates and manages its own Supabase Realtime channel named `display`. It does not use the existing `RealtimeManager` (which is scoped to chat `chat:{conversationId}` channels).
+
+**Rationale:** The chat `RealtimeManager` has subscribe/unsubscribe lifecycle, typing debounce, catch-up queries, and foreground/background handling -- none of which apply to the display publisher. The display publisher is fire-and-forget: get channel, send event, done. Creating a separate channel avoids coupling and keeps both systems independently evolvable.
+
+**Channel configuration:**
+```ts
+client.channel('display', { broadcast: { ack: false, self: false } })
+```
+- `ack: false` -- no delivery confirmation needed (fire-and-forget)
+- `self: false` -- the publishing phone does not need to receive its own snapshots
+
+### D4: `display_visible` passed in from caller, not fetched by publisher
+
+**Decision:** The publisher receives `displayVisible: boolean` as a parameter. The calling code reads this from the TanStack Query cache (`useUserProfile`). The publisher does not fetch the user profile.
+
+**Options considered:**
+- **(a) Publisher fetches profile on every publish**: Rejected. Adds an async DB read to every set confirmation -- unacceptable latency for what should be fire-and-forget.
+- **(b) Publisher reads from a Zustand store**: Rejected. No profile Zustand store exists; profile is managed via TanStack Query. Creating a new store just for one boolean is over-engineering.
+- **(c) Caller passes `displayVisible` from cached profile**: Selected. The active workout screen already has the profile in its TanStack Query cache. Passing a boolean is zero-cost.
+
+**How the wire-up works:** A `useDisplayBroadcast` hook (or inline effect in the active workout route component) reads `displayVisible` from the cached profile and passes it to the publisher. The store actions call the publisher module directly; the hook ensures the publisher is initialized with the correct visibility state.
+
+Actually, refined approach: The store actions don't know about `displayVisible`. Instead, the publisher module holds a module-level `_displayVisible` flag, set once at workout start by the hook. The store actions call `publishDisplaySnapshot(state)` unconditionally; the publisher checks the flag internally and no-ops if false. This keeps the store completely unaware of display concerns.
+
+### D5: Timer snapshot includes `started_at` for client-side interpolation
+
+**Decision:** When the rest timer is running, the snapshot includes `rest_timer: { state: 'running', started_at: ISO8601, total_seconds: number }`. When idle, it includes `rest_timer: { state: 'idle' }`. No `remaining` field -- the display calculates remaining time from `started_at` and `total_seconds`.
+
+**Rationale:** This is the timer interpolation pattern from the PRD (RD-5). It avoids per-second Broadcast traffic. The display runs a local `setInterval` or `requestAnimationFrame` countdown between the `running` and `idle` snapshots.
+
+**Implementation note:** The store's `restTimer` has `{ remaining, total }` but no `started_at`. The snapshot builder must compute `started_at` from `Date.now() - (total - remaining) * 1000` at snapshot construction time. This is approximate but sufficient for +/- 1 second accuracy (NFR-D4).
+
+### D6: Focus/unfocus as explicit publish calls, not store actions
+
+**Decision:** The "Push to Display" button calls `publishFocusEvent(userId)` and `publishUnfocusEvent()` directly on the publisher module. These are not Zustand store actions because they don't modify workout state.
+
+**Rationale:** Focus/unfocus is a display concern, not a workout state concern. Adding them to the workout store would pollute its API surface. The button component calls the publisher directly.
+
+---
+
+## File Plan
+
+### New Files
+
+| File | Purpose |
+|------|---------|
+| `src/domain/types/display-snapshot.ts` | `DisplaySnapshot` Zod schema, `RestTimerState` union type, type exports |
+| `src/lib/display-snapshot.ts` | `buildDisplaySnapshot()` pure function |
+| `src/lib/display-publisher.ts` | `initDisplayPublisher()`, `publishDisplaySnapshot()`, `publishSessionEnded()`, `publishFocusEvent()`, `publishUnfocusEvent()`, `destroyDisplayPublisher()` |
+| `src/hooks/use-display-broadcast.ts` | React hook that wires publisher lifecycle to active workout state |
+| `src/components/workout/push-to-display-button.tsx` | "Push to Display" / "Return to Board" toggle button |
+| `supabase/migrations/20260404000001_add_display_visible.sql` | Postgres `ALTER TABLE user_profiles ADD COLUMN display_visible` |
+| `src-tauri/migrations/008_add_display_visible.sql` | SQLite `ALTER TABLE user_profiles ADD COLUMN display_visible` |
+| `src/domain/types/__tests__/display-snapshot.test.ts` | Unit tests for Zod schema |
+| `src/lib/__tests__/display-snapshot.test.ts` | Unit tests for snapshot builder |
+| `src/lib/__tests__/display-publisher.test.ts` | Unit tests for publisher (mocked channel) |
+
+### Modified Files
+
+| File | Change |
+|------|--------|
+| `src/domain/types/user.ts` | Add `displayVisible: z.boolean().optional()` to `userProfileSchema` |
+| `src/domain/types/index.ts` | Re-export `DisplaySnapshot` and related types |
+| `src/lib/data-mapper.ts` | Add `display_visible` to `toUserProfile` and `fromUserProfile` |
+| `src/lib/tauri-adapter.ts` | Add `display_visible` to `UpdateUserProfileInput` field list |
+| `src-tauri/src/models.rs` | Add `display_visible: Option` to `UserProfileRow` |
+| `src-tauri/src/commands/user_profile.rs` | Add `display_visible` to `UpdateUserProfileInput` struct and UPSERT query |
+| `src/stores/active-workout-store.ts` | Add `publishDisplaySnapshot()` calls in `confirmSet`, `startWorkout`, `startProgrammedWorkout`, `addExerciseToWorkout`, `startRestTimer`, `finishWorkout`, `discardWorkout`, and rest timer expiry handler |
+| `src/routes/_authenticated/profile.tsx` | Add "Remote Display" section with `display_visible` toggle |
+| `src/routes/_authenticated/tracker.tsx` (or active workout route) | Mount `useDisplayBroadcast` hook, render `PushToDisplayButton` |
+
+---
+
+## Data Flow Detail
+
+### Snapshot Construction
+
+```
+ActiveWorkoutState UserProfile
+{ {
+ workoutLog, id,
+ loggedGroups, displayName,
+ elapsedSeconds, displayVisible,
+ restTimer, ...
+} }
+ \ /
+ v v
+ buildDisplaySnapshot(state, profile)
+ |
+ v
+ DisplaySnapshot {
+ user_id: profile.id,
+ display_name: profile.displayName ?? 'Athlete',
+ session_name: state.workoutLog.title ?? 'Ad Hoc Workout',
+ workout_started_at: state.workoutLog.startedAt,
+ current_exercise: currentExerciseName(state),
+ exercise_index: currentExerciseIndex(state),
+ total_exercises: totalExerciseCount(state),
+ sets: currentExerciseSets(state),
+ rest_timer: buildRestTimerState(state.restTimer),
+ session_type: deriveSessionType(state.workoutLog),
+ is_visible: true // always true at this point; invisible users never reach here
+ }
+```
+
+### Helper Functions in Snapshot Builder
+
+| Function | Logic |
+|----------|-------|
+| `currentExerciseName(state)` | Find the last group's last activity, look up exercise name from the activity. Since the store doesn't hold exercise names (only `exerciseId`), the publisher hook must pass in an exercise name map from the query cache. |
+| `currentExerciseIndex(state)` | Flatten all activities across all groups, find index of the last one with sets being actively logged. |
+| `totalExerciseCount(state)` | Count all unique activities across all groups. |
+| `currentExerciseSets(state)` | Return the sets array for the current activity, mapped to the display format (set_number, prescribed, actual, completed). |
+| `buildRestTimerState(restTimer)` | If null: `{ state: 'idle' }`. If present: `{ state: 'running', started_at: computed, total_seconds: restTimer.total }`. |
+| `deriveSessionType(workoutLog)` | If `programContext` exists, derive from the session template's category. Otherwise default to `'STRENGTH'`. |
+
+**Exercise name resolution:** The store holds `exerciseId` on `LoggedActivity`, not the exercise name. The snapshot needs the name string. Solution: the `useDisplayBroadcast` hook builds an `exerciseNameMap: Record` from the TanStack Query exercise cache (`useExercises`) and passes it to `buildDisplaySnapshot`. This avoids adding exercise names to the Zustand store.
+
+### Publish Triggers (wired in store actions)
+
+| Store Action | Event | Payload |
+|-------------|-------|---------|
+| `startWorkout` | `workout_snapshot` | Initial snapshot, no sets yet |
+| `startProgrammedWorkout` | `workout_snapshot` | Initial snapshot with pre-filled sets |
+| `addExerciseToWorkout` | `workout_snapshot` | New exercise added, sets empty |
+| `confirmSet` | `workout_snapshot` | Updated set completion |
+| `startRestTimer` | `workout_snapshot` | Timer state: running |
+| `tickRest` (when remaining hits 0) | `workout_snapshot` | Timer state: idle |
+| `skipRest` | `workout_snapshot` | Timer state: idle |
+| `adjustRest` | `workout_snapshot` | Timer state: running, updated total |
+| `finishWorkout` | `session_ended` | `{ user_id }` |
+| `discardWorkout` | `session_ended` | `{ user_id }` |
+
+### Publisher Module API
+
+```ts
+// display-publisher.ts
+
+/** Initialize the publisher with a Supabase client. Call once at app startup. */
+export function initDisplayPublisher(client: SupabaseClient): void
+
+/** Configure display visibility and user context. Call when profile loads/changes. */
+export function configureDisplayPublisher(opts: {
+ userId: string
+ displayVisible: boolean
+}): void
+
+/** Publish a workout snapshot. No-op if not visible or not initialized. */
+export function publishDisplaySnapshot(snapshot: DisplaySnapshot): void
+
+/** Publish session_ended event. No-op if not visible or not initialized. */
+export function publishSessionEnded(userId: string): void
+
+/** Publish focus event. */
+export function publishFocusEvent(userId: string): void
+
+/** Publish unfocus event. */
+export function publishUnfocusEvent(): void
+
+/** Tear down channel subscription. */
+export function destroyDisplayPublisher(): void
+```
+
+All publish functions are synchronous (fire-and-forget). The actual `channel.send()` returns a Promise that is intentionally not awaited -- errors are logged to console but never propagated.
+
+---
+
+## Database Changes
+
+### Postgres Migration
+
+```sql
+-- 20260404000001_add_display_visible.sql
+alter table user_profiles
+ add column display_visible boolean not null default true;
+
+comment on column user_profiles.display_visible is
+ 'Whether the user''s active workout appears on the remote gym display. Default: visible.';
+```
+
+No RLS changes needed -- the existing `user_profiles_all` policy covers all columns.
+
+### SQLite Migration
+
+```sql
+-- 008_add_display_visible.sql
+ALTER TABLE user_profiles ADD COLUMN display_visible INTEGER NOT NULL DEFAULT 1;
+```
+
+### Rust Changes
+
+**`src-tauri/src/models.rs`:** Add `pub display_visible: Option` to `UserProfileRow`.
+
+**`src-tauri/src/commands/user_profile.rs`:** Add `pub display_visible: Option` to `UpdateUserProfileInput`. Add to UPSERT column list and bind with `input.display_visible.map(|b| if b { 1i64 } else { 0i64 })`.
+
+---
+
+## Integration Points
+
+### With Active Workout Store
+
+The store gains import of `publishDisplaySnapshot` and `publishSessionEnded` from `display-publisher.ts`. Each relevant action appends a fire-and-forget call after the `set()` mutation:
+
+```ts
+// Example: confirmSet
+confirmSet(loggedActivityId: string, newSet: LoggedSet) {
+ set((state) => {
+ // ... existing immutable update logic ...
+ })
+ // Fire-and-forget display publish
+ publishDisplaySnapshot(buildDisplaySnapshot(get(), _publisherContext))
+},
+```
+
+The `_publisherContext` (profile info, exercise name map) is set by the `useDisplayBroadcast` hook via a module-level setter, following the same pattern as the module-scope timer handles.
+
+### With Settings Screen
+
+A new "Remote Display" section in the profile page, positioned after "Notifications":
+
+```tsx
+
+
+
+ Remote Display
+
+
+ setDisplayVisible(v)}
+ />
+
+```
+
+### With Active Workout UI
+
+The `PushToDisplayButton` component renders in the active workout header. It tracks local `isFocused` state and calls `publishFocusEvent` / `publishUnfocusEvent` directly.
+
+---
+
+## Risks and Mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|-----------|--------|------------|
+| Broadcast channel not available (no Supabase client in Tauri offline mode) | Medium | Publisher no-ops silently | Publisher checks `getSupabaseClient() !== null` before attempting channel creation; all publishes are guarded |
+| Exercise name not in TanStack Query cache when snapshot is built | Low | Snapshot shows exercise ID instead of name | Fallback to `exerciseId` string; the display can show "Unknown Exercise" |
+| `rest_timer.started_at` calculation drift | Low | Display timer off by 1-2 seconds | Acceptable per NFR-D4 (+/- 1 second); recalibrated on every snapshot |
+| Broadcast publish blocks UI thread | Low | Set confirmation > 100ms | All publishes are `void` (Promise not awaited); channel.send() is non-blocking in Supabase JS client |
+| Multiple tabs/windows publishing simultaneously | Low | Display receives duplicate snapshots per user | Display upserts by `user_id` -- duplicates overwrite harmlessly |
+
+---
+
+## ADR Candidates
+
+No new ADRs needed. The key decisions (D1-D6) are implementation-level choices that follow established patterns in the codebase. The architectural decisions about transport layer, snapshot model, and channel topology are already resolved in `13-prd-remote-display.md` (RD-1 through RD-13).
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+| Test | File | What |
+|------|------|------|
+| Schema validation | `display-snapshot.test.ts` | Valid/invalid payloads against Zod schema |
+| Snapshot builder | `display-snapshot.test.ts` | Pure function with known inputs, assert all output fields |
+| Timer state mapping | `display-snapshot.test.ts` | Running timer -> `{ state: 'running', started_at, total_seconds }`; null -> `{ state: 'idle' }` |
+| Publisher guards | `display-publisher.test.ts` | Not initialized -> no-op; not visible -> no-op; initialized + visible -> channel.send called |
+
+### Integration Tests
+
+| Test | How |
+|------|-----|
+| End-to-end publish | Start dev server, open Supabase dashboard Broadcast inspector, confirm a set, verify event appears |
+| Opt-out enforcement | Set `display_visible = false`, confirm a set, verify zero events on channel |
+| Focus/unfocus | Tap Push to Display, verify `focus` event with correct `user_id`; tap again, verify `unfocus` event |
diff --git a/Context/Features/009-discovery-client-server-url/Spec.md b/Context/Features/009-discovery-client-server-url/Spec.md
new file mode 100644
index 00000000..cf2417fe
--- /dev/null
+++ b/Context/Features/009-discovery-client-server-url/Spec.md
@@ -0,0 +1,79 @@
+# Feature 009: Discovery Client + Server URL Input
+
+## Status: Draft
+## Created: 2026-04-04
+
+---
+
+## Overview
+
+Replace the setup screen's raw Supabase credential inputs with a primary "Server URL" flow that auto-discovers backend credentials via a `.well-known/ardent-forge.json` endpoint. The existing manual credential entry becomes a collapsed "Advanced" fallback. This is the first step toward a tiered onboarding experience (server URL > QR code > manual entry).
+
+## Problem Statement
+
+New users connecting to a self-hosted Ardent Forge instance must currently know and enter two technical Supabase values (project URL and anon key). This creates unnecessary friction -- the instance administrator already configured these values during deployment. A single server address should be sufficient; the app can discover the rest automatically.
+
+## User Stories
+
+1. **As a new user**, I want to enter just my server address so that I can connect without asking my admin for Supabase credentials.
+2. **As a self-hosting admin**, I want my Caddy deployment to automatically serve discovery metadata so that users only need the server URL.
+3. **As a Supabase Cloud user**, I want to still enter raw credentials via a manual option so that I can connect without a discovery endpoint.
+4. **As a static-host deployer** (Vercel/Netlify/Cloudflare Pages), I want docs explaining how to create a discovery file so that my users get the simple server-URL experience.
+
+## Requirements
+
+### Must Have
+
+- **M1**: `discoverInstance(serverUrl)` function that fetches `/.well-known/ardent-forge.json`, validates shape (`version`, `supabase_url`, `supabase_publishable_key`), and returns credentials or a typed error
+- **M2**: Discovery error types: `NETWORK_ERROR` (fetch failed), `NOT_FOUND` (404 or non-JSON), `INVALID_RESPONSE` (JSON present, missing required fields)
+- **M3**: Setup screen primary input is a single "Server address" field with a "Connect" button
+- **M4**: Server URL input accepts bare domains (`forge.example.com`), full URLs (`https://forge.example.com`), and URLs with trailing slashes -- normalization happens in `discoverInstance`
+- **M5**: Successful discovery auto-pipes into existing `validateConnection` -- no changes to the validator
+- **M6**: Discovery failure shows inline error suggesting manual configuration
+- **M7**: "Manual configuration" collapsible section exposes existing Supabase URL + key inputs with its own "Connect" button that bypasses discovery
+- **M8**: Manual entry works identically to today's setup screen
+- **M9**: Caddy serves `/.well-known/ardent-forge.json` with `{$SITE_URL}` and `{$ANON_KEY}` interpolation, `Content-Type: application/json`, `Cache-Control: public, max-age=3600`
+- **M10**: `ANON_KEY` env var added to caddy service in `docker-compose.yml`
+
+### Should Have
+
+- **S1**: Discovery phase status feedback ("Looking up server...", "Could not find server configuration at this address.")
+- **S2**: QR scan button placeholder (disabled/hidden) for future Refactor B activation
+- **S3**: "Or" divider between primary and advanced sections, styled with `text-industrial` and `surface-charcoal` horizontal rules
+- **S4**: Self-hosting docs updated with instructions for creating a static discovery file on Vercel/Netlify/Cloudflare Pages
+
+### Won't Have (this refactor)
+
+- QR code scanning or generation (Refactor B)
+- Deep link handling for `ardentforge://connect` (Refactor C)
+- Storing the server URL in the config store (only Supabase credentials are persisted)
+- Changes to `validateConnection`, config store, Supabase client initialization, or root route guard
+
+## Testable Assertions
+
+| ID | Assertion |
+|----|-----------|
+| TA-1 | `discoverInstance("forge.example.com")` prepends `https://`, fetches `https://forge.example.com/.well-known/ardent-forge.json`, and returns `{ supabaseUrl, supabaseKey }` from a valid response |
+| TA-2 | `discoverInstance("https://forge.example.com/")` strips trailing slash before fetching |
+| TA-3 | `discoverInstance` returns `{ error: 'NETWORK_ERROR' }` when fetch throws |
+| TA-4 | `discoverInstance` returns `{ error: 'NOT_FOUND' }` on 404 or non-JSON response |
+| TA-5 | `discoverInstance` returns `{ error: 'INVALID_RESPONSE' }` when JSON lacks required fields |
+| TA-6 | Setup screen renders server URL field as primary input on mount |
+| TA-7 | Entering a server address and tapping "Connect" triggers `discoverInstance` then `validateConnection` |
+| TA-8 | Discovery failure displays inline error and does not call `validateConnection` |
+| TA-9 | "Manual configuration" toggle expands to show Supabase URL + key fields |
+| TA-10 | Manual "Connect" calls `validateConnection` directly, bypassing discovery |
+| TA-11 | Successful validation (either path) saves config and navigates to `/sign-in` |
+| TA-12 | `GET /.well-known/ardent-forge.json` on Docker deployment returns valid JSON with correct `supabase_url` and `supabase_publishable_key` values |
+| TA-13 | Response includes `Content-Type: application/json` and `Cache-Control: public, max-age=3600` headers |
+| TA-14 | All existing `connection-validator.test.ts` and `config-store.test.ts` tests pass without modification |
+
+## Open Questions
+
+1. ~~Should the discovery response include additional fields (e.g., instance name, branding)?~~ Not for now -- `version`, `supabase_url`, `supabase_publishable_key` is the minimum viable schema. Additional fields can be added later without breaking clients.
+
+## Dependencies
+
+- **Upstream**: None -- this is the first refactor in the onboarding redesign sequence
+- **Downstream**: Refactor B (QR scan button placeholder activated), Refactor C (deep link handling uses the same discovery result shape)
+- **External**: None -- no new third-party dependencies
diff --git a/Context/Features/009-discovery-client-server-url/Steps.md b/Context/Features/009-discovery-client-server-url/Steps.md
new file mode 100644
index 00000000..07908173
--- /dev/null
+++ b/Context/Features/009-discovery-client-server-url/Steps.md
@@ -0,0 +1,389 @@
+# Feature 009: Discovery Client + Server URL Input -- Implementation Steps
+
+## Status: Complete
+## Created: 2026-04-04
+
+---
+
+## Team Composition
+
+| Role | Agent | Stacks |
+|------|-------|--------|
+| Frontend Specialist | `frontend-specialist` | React, TypeScript, Tailwind |
+| Infrastructure Specialist | `infra-specialist` | Docker, Caddy, docker-compose |
+| Quality Engineer | `quality-engineer` | Vitest, validation |
+
+---
+
+## Wave 1: Discovery Client Module (parallel)
+
+### S001: Create `src/lib/discovery.ts`
+
+**Agent**: `frontend-specialist`
+**Parallel**: Yes (independent of S002)
+**Files**:
+- CREATE `src/lib/discovery.ts`
+
+**Instructions**:
+
+Create the discovery client module. Reference the existing `src/lib/connection-validator.ts` for conventions (AbortController timeout pattern, error typing style).
+
+Types:
+```typescript
+type DiscoveryError = 'NETWORK_ERROR' | 'NOT_FOUND' | 'INVALID_RESPONSE'
+
+type DiscoveryResult =
+ | { ok: true; supabaseUrl: string; supabaseKey: string }
+ | { ok: false; error: DiscoveryError; message: string }
+```
+
+Function `discoverInstance(serverUrl: string): Promise`:
+1. Normalize input: if no protocol (`://` absent), prepend `https://`. Strip trailing slashes.
+2. Construct discovery URL: `${normalized}/.well-known/ardent-forge.json`
+3. Fetch with 8-second AbortController timeout
+4. On fetch error (network, timeout): return `NETWORK_ERROR`
+5. On non-2xx status or response not parseable as JSON: return `NOT_FOUND`
+6. Parse JSON. Validate presence and string type of `version`, `supabase_url`, `supabase_publishable_key`. On failure: return `INVALID_RESPONSE`
+7. Return `{ ok: true, supabaseUrl: json.supabase_url, supabaseKey: json.supabase_publishable_key }`
+
+Export `DiscoveryResult`, `DiscoveryError`, and `discoverInstance`.
+
+**Acceptance**:
+- [TA-1] Bare domain gets `https://` prepended and correct URL constructed
+- [TA-2] Trailing slash stripped before fetch
+- [TA-3] Fetch failure returns `NETWORK_ERROR`
+- [TA-4] 404/non-JSON returns `NOT_FOUND`
+- [TA-5] Missing fields returns `INVALID_RESPONSE`
+
+---
+
+### S001-T: Unit tests for discovery client
+
+**Agent**: `frontend-specialist`
+**Parallel**: No (blocked by S001)
+**Files**:
+- CREATE `src/lib/__tests__/discovery.test.ts`
+
+**Instructions**:
+
+Write Vitest tests for `discoverInstance`. Mock global `fetch` (use `vi.stubGlobal`). Follow the patterns in `src/lib/__tests__/connection-validator.test.ts`.
+
+Test cases:
+1. Happy path: valid JSON response returns `{ ok: true, supabaseUrl, supabaseKey }`
+2. Bare domain normalization: `forge.example.com` fetches `https://forge.example.com/.well-known/ardent-forge.json`
+3. Full URL with trailing slash: `https://forge.example.com/` strips slash before appending path
+4. URL with `http://` protocol: preserved as-is (not forced to `https://`)
+5. Network error: fetch throws TypeError, returns `NETWORK_ERROR`
+6. Timeout: fetch throws AbortError/DOMException, returns `NETWORK_ERROR`
+7. 404 response: returns `NOT_FOUND`
+8. Non-JSON response (200 but HTML): returns `NOT_FOUND`
+9. JSON missing `version` field: returns `INVALID_RESPONSE`
+10. JSON missing `supabase_url` field: returns `INVALID_RESPONSE`
+11. JSON with all fields present but `supabase_url` is a number (not string): returns `INVALID_RESPONSE`
+
+**Acceptance**:
+- All 11 test cases pass
+- No real network calls
+
+---
+
+### S002: Caddy discovery endpoint + docker-compose env
+
+**Agent**: `infra-specialist`
+**Parallel**: Yes (independent of S001)
+**Files**:
+- MODIFY `docker/caddy/Caddyfile`
+- MODIFY `docker-compose.yml`
+
+**Instructions**:
+
+**Caddyfile**: Add a `handle` block for `/.well-known/ardent-forge.json` BEFORE the catch-all `handle` block (but after the API and Studio routes -- ordering among specific `handle` blocks does not matter, only that the catch-all is last).
+
+```caddyfile
+handle /.well-known/ardent-forge.json {
+ header Content-Type application/json
+ header Cache-Control "public, max-age=3600"
+ respond `{"version":"1","supabase_url":"{$SITE_URL}","supabase_publishable_key":"{$ANON_KEY}"}`
+}
+```
+
+Note: Use backtick quoting for the respond body to avoid escaping issues with curly braces in Caddy. The `{$SITE_URL}` and `{$ANON_KEY}` are Caddy env var placeholders, not JSON template syntax.
+
+**docker-compose.yml**: Add `ANON_KEY: ${ANON_KEY}` to the `caddy` service's `environment` block, alongside the existing `SITE_URL: ${SITE_URL}`.
+
+**Acceptance**:
+- [TA-12] `GET /.well-known/ardent-forge.json` returns valid JSON with interpolated values
+- [TA-13] Response has correct `Content-Type` and `Cache-Control` headers
+- Existing routes (API, Studio, web catch-all) still work
+
+---
+
+## Milestone 1: Discovery infrastructure complete
+
+**Gate**: S001, S001-T, S002 all complete. The discovery function can be called programmatically and the Caddy endpoint serves the expected JSON. No UI changes yet.
+
+**Contracts**:
+- `discoverInstance(serverUrl: string): Promise` is exported from `src/lib/discovery.ts`
+- `DiscoveryResult` discriminated union is exported for consumers
+
+---
+
+## Wave 2: Setup screen refactor
+
+### S003: Refactor setup screen UI
+
+**Agent**: `frontend-specialist`
+**Parallel**: No (depends on S001)
+**Blocked by**: S001
+**Files**:
+- MODIFY `src/routes/setup.tsx`
+
+**Instructions**:
+
+Refactor the setup screen. Read the current file carefully before modifying. Preserve all existing behavior for the manual entry path.
+
+**New state additions** (local useState, alongside existing `url`/`key`/`status`/`message`):
+- `serverUrl: string` -- the server address input value
+- `discoveryStatus: 'idle' | 'discovering' | 'discovery-failed'` -- tracks discovery phase
+- `discoveryMessage: string` -- error message from discovery
+- `advancedOpen: boolean` -- whether manual config section is expanded
+
+**New handler `handleDiscoverAndConnect`**:
+1. If `serverUrl` is empty, set discoveryStatus to `'discovery-failed'` with message "Server address is required." and return
+2. Set `discoveryStatus` to `'discovering'`
+3. Call `discoverInstance(serverUrl)`
+4. If `ok: false`: set `discoveryStatus` to `'discovery-failed'`, set `discoveryMessage` to result message plus a suggestion to try manual configuration. Return.
+5. If `ok: true`: set `url` and `key` from result, then call existing `handleConnect()` (which handles validation, persistence, and navigation)
+
+**Layout (top to bottom inside AuthPageShell)**:
+
+1. Heading: change "Configure Backend" to "Connect to Server" (keep same styling: `text-sm text-warm-ash`)
+
+2. Env-var warning banner: keep as-is
+
+3. **Primary section -- Server URL**:
+ - Label "Server address" using `FORGE_LABEL_CLASS`
+ - `ForgeInput` with placeholder "forge.example.com", value bound to `serverUrl`
+ - QR scan icon button: render a small disabled button with `QrCode` icon (from lucide-react) to the right of the input, `opacity-30 cursor-not-allowed`. This is a placeholder for Refactor B.
+ - "Connect" button: `bg-forge text-on-forge`, calls `handleDiscoverAndConnect`, disabled while `discoveryStatus === 'discovering'` or `status === 'validating'`
+ - Discovery status feedback:
+ - `discovering`: pulsing "Looking up server..." in `text-warm-ash` (same pattern as existing "Connecting...")
+ - `discovery-failed`: error message in `text-warning-flare`
+ - Validation status feedback: reuse existing status rendering block (shown after discovery succeeds and validation runs)
+
+4. **"Or" divider**:
+ - Horizontal flex with two `border-t border-surface-charcoal flex-1` lines and a centered `text-xs text-industrial px-3` span containing "or"
+
+5. **Manual configuration toggle**:
+ - A `button` styled as a row: "Manual configuration" text in `text-sm text-warm-ash` with a chevron icon (ChevronDown/ChevronUp from lucide-react) that toggles `advancedOpen`
+ - When expanded, renders the existing two-field form (Supabase URL + Publishable Key inputs + their own "Connect" button calling `handleConnect` directly)
+ - The existing fields' state (`url`, `key`) is shared -- if discovery populates them, they appear pre-filled in the manual section too
+
+6. Help link: keep as-is at bottom
+
+**Env var auto-validation**: Keep the existing `useEffect` that auto-triggers `handleConnect` when env vars are present. This bypasses discovery entirely (env vars provide raw credentials). No change needed.
+
+**Acceptance**:
+- [TA-6] Server URL field is primary input on mount
+- [TA-7] Entering address and tapping Connect triggers discovery then validation
+- [TA-8] Discovery failure shows inline error, does not call validateConnection
+- [TA-9] Manual configuration toggle expands to show Supabase URL + key fields
+- [TA-10] Manual Connect calls validateConnection directly
+- [TA-11] Successful validation saves config and navigates to /sign-in
+- [TA-14] Existing connection-validator and config-store tests still pass
+
+---
+
+### S003-T: Validate existing tests still pass
+
+**Agent**: `quality-engineer`
+**Parallel**: No (blocked by S003)
+**Blocked by**: S003
+**Files**: (read-only inspection)
+
+**Instructions**:
+
+Run the full test suite (`bun run test`). Verify:
+1. All tests in `src/lib/__tests__/connection-validator.test.ts` pass without modification
+2. All tests in `src/lib/__tests__/config-store.test.ts` pass without modification
+3. All tests in `src/lib/__tests__/discovery.test.ts` pass
+4. No other test failures introduced
+5. Run `bun run lint` and confirm no new lint errors
+6. Run `bun run build` and confirm TypeScript compilation succeeds
+
+Report pass/fail for each check. Do not modify any files.
+
+**Acceptance**:
+- [TA-14] All existing tests pass
+- Build and lint clean
+
+---
+
+## Milestone 2: Feature complete (UI + infrastructure)
+
+**Gate**: S003 and S003-T complete. The setup screen has the new server URL flow, manual fallback works identically to before, and all tests pass.
+
+---
+
+## Wave 3: Documentation
+
+### S004: Update self-hosting docs
+
+**Agent**: `frontend-specialist`
+**Parallel**: No (depends on S003 for final understanding of the flow)
+**Blocked by**: S003
+**Files**:
+- MODIFY `docs/self-hosting.md`
+
+**Instructions**:
+
+Add a "Discovery Endpoint" subsection. Place it under the existing "Path 2: Supabase Cloud + Static Hosting" section, after step 6.
+
+Content to add:
+
+**For static host deployments** (Vercel/Netlify/Cloudflare Pages):
+- Create `public/.well-known/ardent-forge.json` in the project root with:
+```json
+{
+ "version": "1",
+ "supabase_url": "https://.supabase.co",
+ "supabase_publishable_key": ""
+}
+```
+- Explain that this enables users to connect with just the site URL instead of entering Supabase credentials manually
+
+**For Docker deployments**:
+- Note that the discovery endpoint is served automatically by Caddy -- no manual setup required
+
+Also update the "Mobile App Configuration" section to mention that users can now enter just the server address (the app discovers credentials automatically), with manual entry still available via "Manual configuration".
+
+**Acceptance**:
+- [S4] Self-hosting docs updated with discovery file instructions for static hosts
+- Docker auto-configuration noted
+- Mobile app section updated
+
+---
+
+### S004-D: Final documentation review
+
+**Agent**: `quality-engineer`
+**Parallel**: No (blocked by S004)
+**Blocked by**: S004
+**Files**: (read-only)
+
+**Instructions**:
+
+Review the updated `docs/self-hosting.md` for:
+1. Accuracy of the discovery file JSON format (matches what Caddy serves and what `discoverInstance` expects)
+2. Correct file path for static hosts (`public/.well-known/ardent-forge.json`)
+3. No broken links or formatting issues
+4. Consistency with existing doc style and tone
+
+Report any issues found. Do not modify files.
+
+**Acceptance**:
+- Documentation is accurate and consistent
+
+---
+
+## Milestone 3: Feature shipped
+
+**Gate**: All steps complete. Discovery client, Caddy endpoint, setup screen refactor, tests, and documentation are all done.
+
+---
+
+## Execution Summary
+
+| Wave | Steps | Parallel? | Agents |
+|------|-------|-----------|--------|
+| 1 | S001, S001-T, S002 | S001 and S002 parallel; S001-T after S001 | frontend-specialist, infra-specialist |
+| 2 | S003, S003-T | Sequential | frontend-specialist, quality-engineer |
+| 3 | S004, S004-D | Sequential | frontend-specialist, quality-engineer |
+
+**Total steps**: 6 (3 implementation, 1 test suite, 2 documentation)
+**Total files touched**: 5 modified + 2 created
+- CREATE: `src/lib/discovery.ts`, `src/lib/__tests__/discovery.test.ts`
+- MODIFY: `src/routes/setup.tsx`, `docker/caddy/Caddyfile`, `docker-compose.yml`, `docs/self-hosting.md`
+
+**Dependency chain**:
+```
+S001 ──────┐
+S001-T ◄───┘──┐
+ ├── S003 ── S003-T
+S002 ─────────┘
+ S004 ── S004-D (after S003)
+```
+
+---
+
+## Wave 4: Review Findings (post-review hardening)
+
+### S005: Tighten DiscoverySchema Zod validators
+
+**Agent**: `frontend-specialist`
+**Parallel**: Yes (independent of other S00x)
+**Files**:
+- MODIFY `src/lib/discovery.ts`
+
+**Instructions**:
+
+Update `DiscoverySchema` to use stricter validators:
+- `supabase_url`: `z.string().url()`
+- `supabase_publishable_key`: `z.string().min(1)`
+- `version`: `z.string().min(1)`
+
+This ensures validation is strictest at the system boundary, catching empty strings and non-URL values before they propagate to downstream validators.
+
+**Acceptance**:
+- [TA-5] Empty string or non-URL `supabase_url` returns `INVALID_RESPONSE`
+
+**Review**: P6-012
+
+---
+
+### S005-T: Add missing test for supabase_publishable_key
+
+**Agent**: `frontend-specialist`
+**Parallel**: No (blocked by S005)
+**Blocked by**: S005
+**Files**:
+- MODIFY `src/lib/__tests__/discovery.test.ts`
+
+**Instructions**:
+
+Add test: missing `supabase_publishable_key` returns `INVALID_RESPONSE`. Follow existing pattern for the `version` and `supabase_url` missing-field tests.
+
+**Acceptance**:
+- Test passes and covers the most security-sensitive field
+
+**Review**: P6-013
+
+---
+
+### S006-T: Add test for non-404 HTTP errors (e.g., 500)
+
+**Agent**: `frontend-specialist`
+**Parallel**: Yes (independent)
+**Files**:
+- MODIFY `src/lib/__tests__/discovery.test.ts`
+
+**Instructions**:
+
+Add test: 500 response returns `NOT_FOUND` with dynamic status code in message. Verifies the `Server returned ${response.status}...` template.
+
+**Review**: P6-014
+
+---
+
+### S007-T: Add test for URL with path component
+
+**Agent**: `frontend-specialist`
+**Parallel**: Yes (independent)
+**Files**:
+- MODIFY `src/lib/__tests__/discovery.test.ts`
+
+**Instructions**:
+
+Add test: `forge.example.com/app` strips `/app` via `parsed.origin` and fetches from the origin. Documents this non-obvious normalization behavior.
+
+**Review**: P6-015
diff --git a/Context/Features/009-discovery-client-server-url/Tech.md b/Context/Features/009-discovery-client-server-url/Tech.md
new file mode 100644
index 00000000..7bcbbca3
--- /dev/null
+++ b/Context/Features/009-discovery-client-server-url/Tech.md
@@ -0,0 +1,148 @@
+# Feature 009: Discovery Client + Server URL Input -- Technical Plan
+
+## Status: Draft
+## Created: 2026-04-04
+
+---
+
+## Architecture Overview
+
+This feature adds an instance discovery layer between the user-facing setup screen and the existing connection validator. The discovery client is a pure async function that resolves a human-friendly server URL into Supabase credentials by fetching a well-known JSON file. The server side is a static Caddy `respond` directive -- no new containers, no runtime services.
+
+```
+User enters "forge.example.com"
+ |
+ v
+discoverInstance() <-- NEW: src/lib/discovery.ts
+ | fetch /.well-known/ardent-forge.json
+ | normalize URL, validate response shape
+ v
+validateConnection() <-- EXISTING: src/lib/connection-validator.ts (unchanged)
+ | Step 1: reachability
+ | Step 2: schema check
+ v
+configStore.setConfig() <-- EXISTING: src/lib/config-store.ts (unchanged)
+ |
+ v
+Navigate to /sign-in
+```
+
+The setup screen gains a new primary section (server URL) and collapses the existing credential inputs into an "Advanced" toggle. The state machine extends from `idle -> validating -> ok/error` to `idle -> discovering -> validating -> ok/error`, with a branch on discovery failure.
+
+---
+
+## Key Decisions
+
+### KD-1: Discovery endpoint path and format
+
+**Decision**: `/.well-known/ardent-forge.json` with fields `version`, `supabase_url`, `supabase_publishable_key`.
+
+**Rationale**: The `/.well-known/` prefix follows [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615), the standard for site-wide metadata. A JSON file with explicit field names is self-documenting and forward-compatible (new optional fields can be added without breaking existing clients). The `version` field enables schema evolution.
+
+**Alternatives considered**:
+- `/api/discover` -- custom route, requires server-side logic. Rejected: unnecessary complexity for static metadata.
+- DNS TXT records -- standard for email/domain verification but requires DNS access, which many self-hosters lack. Rejected.
+- `/.well-known/host-meta` -- XML-based, heavyweight. Rejected.
+
+### KD-2: Discovery client as a pure function (no side effects)
+
+**Decision**: `discoverInstance` takes a string, returns credentials or error. No config store writes, no navigation, no client creation.
+
+**Rationale**: Keeps the function trivially testable (mock fetch, assert result) and composable. The setup screen orchestrates the pipeline: discover -> validate -> persist -> navigate. This also means the discovery client can be reused by future flows (deep links, QR scan fallback) without coupling.
+
+### KD-3: URL normalization in the discovery client, not the UI
+
+**Decision**: `discoverInstance` handles `https://` prepending and trailing slash stripping internally.
+
+**Rationale**: The UI should accept whatever the user types. Normalization is a concern of the network layer. If multiple entry points call `discoverInstance` in the future (deep links, QR), they all benefit from the same normalization.
+
+### KD-4: Caddy `respond` directive (not reverse proxy or static file)
+
+**Decision**: Caddy responds directly with inline JSON using `{$SITE_URL}` and `{$ANON_KEY}` env var interpolation.
+
+**Rationale**: No additional container or file volume needed. Caddy's native `{$ENV_VAR}` syntax handles interpolation at startup. A `respond` directive is the simplest mechanism for a single static JSON response with dynamic values.
+
+**Alternative considered**: Mounting a static file from the host and templating it at `docker compose up` time. Rejected: adds a volume mount and a templating step to the startup flow.
+
+### KD-5: Setup screen state machine extension (not replacement)
+
+**Decision**: Add `discovering` and `discovery-failed` states to the existing `ConnectionUiStatus`-like local state. The existing states (`idle`, `validating`, `ok`, `unreachable`, `no-schema`) are preserved exactly.
+
+**Rationale**: The manual entry path must work identically to today. The advanced section's "Connect" button skips discovery entirely. Extending the state machine (rather than replacing it) means existing behavior is untouched and tests pass without modification.
+
+---
+
+## Stack-Specific Details
+
+### Frontend (src/)
+
+**New file**: `src/lib/discovery.ts`
+- Exports `discoverInstance(serverUrl: string): Promise`
+- `DiscoveryResult` is a discriminated union: `{ ok: true; supabaseUrl: string; supabaseKey: string }` or `{ ok: false; error: 'NETWORK_ERROR' | 'NOT_FOUND' | 'INVALID_RESPONSE'; message: string }`
+- Uses native `fetch` with an 8-second `AbortController` timeout (matching the convention in `connection-validator.ts`)
+- Response validation: checks HTTP status, `Content-Type` includes `application/json`, parses JSON, validates presence of `version` (string), `supabase_url` (string), `supabase_publishable_key` (string)
+
+**New file**: `src/lib/__tests__/discovery.test.ts`
+- Unit tests with mocked `fetch` (no real network)
+- Test cases for each error type plus happy path, URL normalization variants
+
+**Modified file**: `src/routes/setup.tsx`
+- New local state: `discoveryStatus` (`'idle' | 'discovering' | 'discovered' | 'discovery-failed'`)
+- The existing `status`/`message` state is reused for the validation phase
+- New `handleDiscoverAndConnect` function: calls `discoverInstance`, on success pipes into `handleConnect` (existing), on failure sets discovery error state
+- New UI sections: server URL input (primary), "Or" divider, "Manual configuration" collapsible
+- The existing two-field form moves inside the collapsible section with its own "Connect" button wired to `handleConnect` directly
+- Env var auto-validation logic preserved: if `VITE_SUPABASE_URL` and `VITE_SUPABASE_PUB_KEY` are set, auto-trigger manual validation on mount (skips discovery, since env vars provide raw credentials)
+- QR scan button placeholder: a disabled icon button rendered but non-functional
+
+### Docker (docker/)
+
+**Modified file**: `docker/caddy/Caddyfile`
+- Add `handle /.well-known/ardent-forge.json` block before the catch-all
+- Uses Caddy's `respond` directive with inline JSON body
+- Sets `Content-Type` and `Cache-Control` headers via `header` directive
+
+**Modified file**: `docker-compose.yml`
+- Add `ANON_KEY: ${ANON_KEY}` to the caddy service's `environment` block
+
+### Docs
+
+**Modified file**: `docs/self-hosting.md`
+- Add a "Discovery Endpoint" subsection under "Path 2: Supabase Cloud + Static Hosting"
+- Explains creating `public/.well-known/ardent-forge.json` as a static file with hardcoded values
+- Notes that Docker deployments handle this automatically via Caddy
+
+---
+
+## Integration Points
+
+| From | To | Contract |
+|------|----|----------|
+| `discoverInstance` | `/.well-known/ardent-forge.json` | HTTP GET, expects `{ version, supabase_url, supabase_publishable_key }` |
+| Setup screen | `discoverInstance` | Passes user input string, receives `DiscoveryResult` |
+| Setup screen | `validateConnection` | Unchanged -- passes `(url, key)` from either discovery or manual input |
+| Setup screen | `configStore.setConfig` | Unchanged -- passes `{ supabaseUrl, supabaseKey }` |
+| Caddy | Environment | Reads `{$SITE_URL}` (existing) and `{$ANON_KEY}` (new) |
+
+---
+
+## Risks and Mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|------------|--------|------------|
+| CORS blocks discovery fetch from a different origin | Medium | Discovery fails silently | Caddy serves the discovery endpoint from the same origin as the app. For Supabase Cloud + static host deployments, the static file is on the same origin. Cross-origin discovery is not a supported use case in v1. |
+| User enters a URL that redirects (e.g., HTTP -> HTTPS redirect) | Low | `fetch` follows redirects by default; should work. | Test with redirect scenarios. If an issue arises, document that the URL must be the final address. |
+| Caddy env var `{$ANON_KEY}` is empty or unset | Low | Discovery endpoint returns invalid JSON with empty string | `generate-keys.sh` always sets `ANON_KEY`. Add a note in self-hosting docs that all keys must be generated before first start. |
+| Future discovery schema changes break old clients | Low | Old app versions fail on new required fields | `version` field enables forward-compatible evolution. New fields should be optional. |
+
+---
+
+## Pre-existing Issue Noted
+
+The Dockerfile sets `VITE_SUPABASE_PUBLISHABLE_KEY` but the app code reads `VITE_SUPABASE_PUB_KEY`. This means env-var-based auto-configuration in Docker builds is broken. This is out of scope for Refactor A but should be tracked separately (the setup screen's manual entry flow works regardless).
+
+---
+
+## ADR Reference
+
+- **ADR-006**: Discovery Endpoint Format and Discovery Client Design (created alongside this Tech.md)
diff --git a/Context/Features/009-display-route-board/Spec.md b/Context/Features/009-display-route-board/Spec.md
new file mode 100644
index 00000000..cdf2866c
--- /dev/null
+++ b/Context/Features/009-display-route-board/Spec.md
@@ -0,0 +1,100 @@
+# 009 - Display Route + Board and Focused Views
+
+## Overview
+
+Build the `/display` route: a full-viewport, no-chrome web page that subscribes to the `display` Supabase Broadcast channel and renders active workout sessions in real time. The display is designed for a TV or large monitor in a gym, readable from 3+ meters on a 1080p screen.
+
+Three display modes: **Board** (grid of active session cards), **Focused** (full-screen single user), and **Idle** (clock + schedule, deferred to Step 29). This step implements Board and Focused modes, plus the display shell, state management, timer interpolation, and reconnection behavior.
+
+**Implementation Plan Reference:** Step 28
+**PRD Reference:** `docs/13-prd-remote-display.md`
+**Dependencies:** Step 27 (Display Broadcast Infrastructure) -- merged as PR #59
+
+## Problem Statement
+
+Step 27 built the publisher side: phones broadcast `DisplaySnapshot` events on every workout state change. There is currently no consumer of these events. The `/display` route is the subscriber that renders this data as a passive, read-only gym display.
+
+The display must:
+- Work without authentication (no login required on the TV)
+- Never query the database (pure Broadcast subscription)
+- Render smoothly with multiple concurrent sessions
+- Produce a smooth rest timer countdown from sparse snapshot updates
+- Recover gracefully from network disconnections
+- Be readable from across a gym (3+ meters on 1080p)
+
+## User Stories
+
+1. **As a gym owner**, I want to mount a screen showing who is actively training and what they are doing, so the gym feels alive and engaged.
+2. **As an athlete**, I want to push my workout to a focused full-screen view on the gym display, so my coach or training partners can follow my session in detail.
+3. **As a coach**, I want to see all active sessions at a glance on the board view, so I can monitor multiple athletes simultaneously.
+4. **As a display operator**, I want the display to recover automatically after network drops, so I can set it and forget it.
+
+## Requirements
+
+### Must Have (P0)
+
+- [ ] **M1**: `/display` route renders full-viewport with no app chrome (no nav, no sidebar, no header)
+- [ ] **M2**: Display subscribes to `display` Broadcast channel on mount
+- [ ] **M3**: Board view shows cards for all active visible sessions
+- [ ] **M4**: Board grid layout adapts from 1 to 4 sessions per page
+- [ ] **M5**: Focused view renders full set table and large timer for a single user
+- [ ] **M6**: Focus/unfocus events from phone toggle display mode
+- [ ] **M7**: Timer interpolation produces smooth countdown (within 1 second accuracy)
+- [ ] **M8**: Display auto-reconnects and rebuilds state after network drop
+- [ ] **M9**: Connection status footer shows current state (connected, reconnecting, session count)
+- [ ] **M10**: All typography readable at 3+ meters on 1080p display
+- [ ] **M11**: Iron & Ember design tokens applied throughout (zero border-radius, tonal surfaces, dual fonts)
+- [ ] **M12**: No database queries from the display route
+- [ ] **M13**: Display route is unauthenticated (no login required)
+
+### Should Have (P1)
+
+- [ ] **S1**: Board auto-cycles through pages when 5+ sessions active (10-second interval)
+- [ ] **S2**: "Push to Display" button in active workout UI exists (already built in Step 27)
+- [ ] **S3**: Idle placeholder when no sessions are active (simple clock, deferred full idle mode to Step 29)
+- [ ] **S4**: Smooth transitions between display modes (300-400ms fades)
+
+### Won't Have (this step)
+
+- **W1**: Full idle mode with scheduled sessions and Edge Function (Step 29)
+- **W2**: Authentication or user-specific display configuration
+- **W3**: Database queries or server-side state persistence
+- **W4**: Portrait orientation support (landscape only per PRD)
+
+## Testable Assertions
+
+| ID | Assertion | Validation Method |
+|----|-----------|-------------------|
+| TA-1 | `/display` route renders without authentication | Navigate to `/display` without sign-in; page loads |
+| TA-2 | No nav bar, sidebar, or header visible on `/display` | Visual inspection; DOM query for nav elements returns empty |
+| TA-3 | Incoming `workout_snapshot` event creates a session card on board | Publish mock event; card appears within 500ms |
+| TA-4 | Incoming `session_ended` event removes the session card | Publish mock event after snapshot; card removed |
+| TA-5 | Incoming `focus` event switches to focused view for that user | Publish focus event; display switches to single-user view |
+| TA-6 | Incoming `unfocus` event returns to board view | Publish unfocus event; display returns to grid |
+| TA-7 | Rest timer counts down smoothly from snapshot's `started_at` + `total_seconds` | Publish snapshot with running timer; countdown updates every second |
+| TA-8 | Grid layout shows 1 card centered, 2 in columns, 3-4 in 2x2 | Publish N snapshots; verify grid adapts |
+| TA-9 | 5+ sessions trigger page cycling every 10 seconds | Publish 5+ snapshots; pages auto-advance |
+| TA-10 | WebSocket reconnection rebuilds session map via `display_hello` | Disconnect network; reconnect; sessions reappear |
+| TA-11 | Connection status footer reflects connected/reconnecting state | Observe footer during connection changes |
+| TA-12 | Typography meets distance-readable sizing (Space Grotesk 2rem+ for headlines) | Visual inspection on 1080p monitor from 3m |
+
+## Open Questions
+
+1. **Landscape enforcement**: Should the display route use CSS to force landscape, or just recommend it in docs? PRD says "landscape only" but CSS `orientation` media queries can handle portrait gracefully.
+ - **Resolution**: Use CSS to display a "rotate device" message if portrait detected. No forced orientation lock.
+
+2. **Display route path**: Should it be `/display` (public, outside `_authenticated` layout) or something else?
+ - **Resolution**: `/display` as a top-level public route, similar to `/s/$token`. No auth required per PRD FR-1.
+
+3. **Supabase client for subscription**: The display doesn't authenticate, but needs a Supabase client to subscribe to Broadcast channels. How does it get credentials?
+ - **Resolution**: Use the same config store pattern as the rest of the app. The display reads `VITE_SUPABASE_URL` and publishable key from env/config. Broadcast channels don't require auth tokens -- the publishable key is sufficient for subscription.
+
+## Dependencies
+
+| Dependency | Status | Notes |
+|------------|--------|-------|
+| Step 27: Display Broadcast Infrastructure | Complete (PR #59) | DisplaySnapshot type, publisher, channel setup |
+| Step 1.5: Iron & Ember Design System | Complete | All design tokens, typography utilities |
+| Step 6: Active Workout Logging | Complete | Workout store, set/exercise types |
+| Supabase Realtime (Broadcast) | Available | Used by publisher; display subscribes to same channel |
+| `docs/13-prd-remote-display.md` | Reference | Full display spec with layouts and design callouts |
diff --git a/Context/Features/009-display-route-board/Steps.md b/Context/Features/009-display-route-board/Steps.md
new file mode 100644
index 00000000..d4467d75
--- /dev/null
+++ b/Context/Features/009-display-route-board/Steps.md
@@ -0,0 +1,538 @@
+# 009 - Display Route + Board and Focused Views: Implementation Steps
+
+## Team Composition
+
+| Role | Agent | Stacks |
+|------|-------|--------|
+| Frontend Specialist | `frontend` | React, TypeScript, Tailwind, Zustand, TanStack Router |
+| Quality Engineer | `qa` | Vitest, visual inspection, integration validation |
+
+Single-domain feature (all frontend). No backend or database changes. Use `/build` (parallel sub-agents), not `/team-build`.
+
+---
+
+## Implementation Waves
+
+### Wave 1: Foundation (no UI)
+
+Data layer and subscription infrastructure. Must be complete before any UI work.
+
+---
+
+#### S001: Display Zustand Store
+
+**Agent:** `frontend`
+**Files:** `src/stores/display-store.ts`
+**Dependencies:** None
+**Parallel:** Yes (with S002)
+
+Create the display state store following the pattern in `src/stores/active-workout-store.ts`.
+
+**Store shape:**
+```typescript
+interface DisplayState {
+ sessions: Map
+ focusedUserId: string | null
+ connectionStatus: 'connected' | 'reconnecting' | 'disconnected'
+ currentPage: number
+}
+```
+
+**Actions:**
+- `upsertSession(userId, snapshot)` -- insert or replace in sessions map
+- `removeSession(userId)` -- delete from map; if `userId === focusedUserId`, clear focus
+- `setFocusedUser(userId | null)` -- set focused user
+- `setConnectionStatus(status)` -- update connection state
+- `setCurrentPage(page)` -- set current page index
+- `clearAllSessions()` -- reset sessions map and focus
+
+**Derived selectors (exported functions):**
+- `getDisplayMode(state)` -- returns `'idle'` if sessions empty, `'focused'` if focusedUserId set and user in map, `'board'` otherwise
+- `getPageSessions(state)` -- returns array of snapshots for current page (4 per page)
+- `getTotalPages(state)` -- `Math.ceil(sessions.size / 4)`
+
+**Staleness handling:** `upsertSession` stores a `lastSeenAt` timestamp alongside each snapshot. Export a `pruneStale(maxAgeMs)` action that removes sessions not updated within the threshold.
+
+**Acceptance:**
+- [ ] Store creates with correct initial state
+- [ ] upsertSession adds and updates sessions
+- [ ] removeSession clears focus if focused user removed
+- [ ] getDisplayMode returns correct mode for all state combinations
+- [ ] getPageSessions paginates correctly (4 per page)
+- [ ] pruneStale removes old sessions
+
+---
+
+#### S002: Display Subscriber
+
+**Agent:** `frontend`
+**Files:** `src/lib/display-subscriber.ts`
+**Dependencies:** None
+**Parallel:** Yes (with S001)
+
+Create the Broadcast channel subscriber following the pattern in `src/lib/display-publisher.ts` and `src/lib/realtime-manager.ts`.
+
+**Module-scoped state:**
+- `_client: SupabaseClient | null`
+- `_channel: RealtimeChannel | null`
+
+**Exported functions:**
+- `initDisplaySubscriber(client)` -- store client reference
+- `subscribeToDisplay(handlers: DisplayEventHandlers)` -- create channel, register `.on('broadcast')` listeners for each event type, subscribe
+- `publishHello()` -- send `display_hello` event on channel (for reconnection)
+- `getSubscriberStatus()` -- returns current connection status
+- `destroyDisplaySubscriber()` -- unsubscribe, remove channel, reset state
+
+**Event handling:**
+- `workout_snapshot` -- validate with `displaySnapshotSchema.safeParse()`, call `handlers.onSnapshot`
+- `session_ended` -- validate payload has `user_id` string, call `handlers.onSessionEnded`
+- `focus` -- validate payload has `user_id` string, call `handlers.onFocus`
+- `unfocus` -- call `handlers.onUnfocus`
+
+**Channel status callback:** Map Supabase channel statuses to connection status:
+- `SUBSCRIBED` -> call `handlers.onStatusChange('connected')`
+- `TIMED_OUT` / `CHANNEL_ERROR` / `CLOSED` -> call `handlers.onStatusChange('reconnecting')`, clear `_channel` for retry
+- On retry success -> publish `display_hello`, call `handlers.onStatusChange('connected')`
+
+**Acceptance:**
+- [ ] Subscribes to `display` channel with `{ broadcast: { ack: false, self: false } }`
+- [ ] Validates all incoming payloads with Zod; drops invalid payloads with console.warn
+- [ ] Calls correct handler for each event type
+- [ ] publishHello sends broadcast event on channel
+- [ ] destroyDisplaySubscriber cleans up channel
+- [ ] Channel status changes dispatched to onStatusChange handler
+
+---
+
+#### S003: Timer Interpolation Hook
+
+**Agent:** `frontend`
+**Files:** `src/hooks/use-timer-interpolation.ts`
+**Dependencies:** None
+**Parallel:** Yes (with S001, S002)
+
+Create a hook that produces a smooth countdown from a `RestTimerState` snapshot.
+
+**Signature:** `useTimerInterpolation(restTimer: RestTimerState): number` -- returns `remainingSeconds` (integer, >= 0)
+
+**Logic:**
+- If `restTimer.state === 'idle'`, return 0
+- If `restTimer.state === 'running'`:
+ - Parse `started_at` as Date
+ - On each rAF frame: `remaining = total_seconds - ((Date.now() - startedAtMs) / 1000)`
+ - Update a ref, trigger React re-render only when `Math.ceil(remaining)` changes from previous render (use `useState` for the integer second value)
+ - Clamp to 0 (never negative)
+ - Cancel rAF loop when remaining reaches 0 or timer state changes to idle
+
+**Acceptance:**
+- [ ] Returns 0 when rest timer is idle
+- [ ] Counts down from total_seconds when running
+- [ ] Updates once per second (not per frame) in React state
+- [ ] Cleans up rAF on unmount
+- [ ] Handles timer state changes mid-countdown
+
+---
+
+#### S004: Phone-Side Hello Responder
+
+**Agent:** `frontend`
+**Files:** `src/lib/display-publisher.ts`, `src/hooks/use-display-broadcast.ts`
+**Dependencies:** None
+**Parallel:** Yes (with S001-S003)
+
+Add `display_hello` listener to the existing publisher so phones re-publish their snapshot when a display reconnects.
+
+**Changes to `display-publisher.ts`:**
+- Add module-scoped `_helloResponder: (() => void) | null = null`
+- Export `setHelloResponder(fn: (() => void) | null)` -- stores the callback
+- In `ensureChannel()`, after channel creation, add: `_channel.on('broadcast', { event: 'display_hello' }, () => { _helloResponder?.() })`
+
+**Changes to `use-display-broadcast.ts`:**
+- After setting snapshot context, call `setHelloResponder(() => { /* trigger _publishCurrentState from store */ })`
+- The responder function should call the store's internal publish. Since `_publishCurrentState` is private, expose a `republishCurrentState()` action on the active workout store that calls `_publishCurrentState()`.
+- On cleanup, call `setHelloResponder(null)`
+
+**Changes to `active-workout-store.ts`:**
+- Export `republishCurrentState()` action that calls `_publishCurrentState()` if workout is active
+
+**Acceptance:**
+- [ ] Publisher listens for `display_hello` events on the channel
+- [ ] When hello received and workout active, current snapshot is re-published
+- [ ] When no workout active, hello is received silently (no crash)
+- [ ] Responder cleared on hook cleanup
+
+---
+
+### Wave 1 Validation
+
+#### S001-T: Wave 1 Unit Tests
+
+**Agent:** `qa`
+**Files:** `src/stores/__tests__/display-store.test.ts`, `src/lib/__tests__/display-subscriber.test.ts`, `src/hooks/__tests__/use-timer-interpolation.test.ts`
+**Dependencies:** S001, S002, S003, S004
+**Parallel:** No (must wait for all Wave 1 tasks)
+
+Write unit tests for all Wave 1 deliverables:
+
+**display-store tests:**
+- Initial state is empty sessions, null focus, disconnected
+- upsertSession adds new session, updates existing
+- removeSession deletes and clears focus if focused user removed
+- getDisplayMode returns idle/board/focused correctly
+- getPageSessions returns correct slice for page
+- pruneStale removes sessions older than threshold
+
+**display-subscriber tests:**
+- Mock Supabase client and channel
+- Verify channel created with correct config
+- Verify event handlers called with validated payloads
+- Verify invalid payloads are dropped
+- Verify publishHello sends correct event
+- Verify destroy cleans up
+
+**use-timer-interpolation tests:**
+- Returns 0 for idle timer
+- Counts down for running timer
+- Clamps to 0
+
+**display-publisher hello responder tests:**
+- setHelloResponder stores callback
+- display_hello event triggers callback
+- Null responder doesn't crash
+
+**Acceptance:**
+- [ ] All tests pass
+- [ ] Coverage for all store actions and derived selectors
+- [ ] Coverage for subscriber event routing and validation
+- [ ] Coverage for timer interpolation edge cases
+
+---
+
+### Wave 2: Route Shell and Board View
+
+Core display UI. Depends on Wave 1 infrastructure.
+
+---
+
+#### S005: Display Route Shell
+
+**Agent:** `frontend`
+**Files:** `src/routes/display.tsx`, `src/routes/__root.tsx`, `src/components/display/connection-footer.tsx`, `src/components/display/idle-placeholder.tsx`
+**Dependencies:** S001, S002
+
+**Route file (`src/routes/display.tsx`):**
+- Create TanStack Router file route at `/display`
+- Full viewport: `h-dvh w-full overflow-hidden bg-surface-anvil`
+- No app chrome (no nav, no sidebar, no header)
+- Portrait detection: `@media (orientation: portrait)` shows "ROTATE TO LANDSCAPE" message with `screen_rotation` icon
+
+**Lifecycle (in route component or a `DisplayShell` wrapper):**
+1. On mount: call `resolveConfig()` to get Supabase URL + key
+2. If no config: show message directing user to configure the app first
+3. If config: create a dedicated Supabase client (`createClient(url, key)`) -- NOT the global singleton
+4. Call `initDisplaySubscriber(client)`
+5. Call `subscribeToDisplay(handlers)` with handlers that dispatch to `display-store`
+6. Start a 30-minute staleness prune interval
+7. On unmount: `destroyDisplaySubscriber()`, clear interval
+
+**Root route change (`__root.tsx`):**
+- Add `/display` to the `beforeLoad` bypass: `if (location.pathname === '/setup' || location.pathname.startsWith('/s/') || location.pathname === '/display') return`
+
+**ConnectionFooter component:**
+- Fixed bottom bar, `bg-surface-pit`, `h-12`, full width
+- Left side: connection dot (green for connected, amber pulsing for reconnecting) + status text
+- Right side: session count ("N active sessions") or focused user name ("Focused: Robert")
+- Font: Inter `text-sm`, `text-warm-ash`
+
+**IdlePlaceholder component (simple for Step 28):**
+- Large clock: Space Grotesk, `text-[8rem]`, `text-bone-white`, centered
+- Date below: Inter, `text-xl`, `text-warm-ash`
+- Clock updates every second via `setInterval`
+- Text: "NO ACTIVE SESSIONS" below date in `text-oxidized-edge`
+- Full idle mode with schedule data deferred to Step 29
+
+**Mode rendering logic:**
+```tsx
+{displayMode === 'idle' && }
+{displayMode === 'board' && }
+{displayMode === 'focused' && }
+```
+
+With CSS transitions: each section has `transition-opacity duration-300` and conditional `opacity-0`/`opacity-100`.
+
+**Acceptance:**
+- [ ] `/display` loads without authentication
+- [ ] No nav bar, sidebar, or header visible
+- [ ] Portrait orientation shows rotate message
+- [ ] Supabase client created from config (not global singleton)
+- [ ] Subscriber initialized and subscribed on mount
+- [ ] Subscriber destroyed on unmount
+- [ ] ConnectionFooter renders with correct status
+- [ ] IdlePlaceholder shows clock when no sessions active
+- [ ] Mode transitions have 300ms fade
+
+---
+
+#### S006: Board View and Session Cards
+
+**Agent:** `frontend`
+**Files:** `src/components/display/board-view.tsx`, `src/components/display/session-card.tsx`, `src/components/display/rest-timer-display.tsx`, `src/components/display/page-indicator.tsx`
+**Dependencies:** S001, S003, S005
+
+**BoardView component:**
+- Reads `getPageSessions(state)` and `getTotalPages(state)` from display store
+- CSS Grid layout:
+ - 1 session: `grid-cols-1`, card max-width ~60%
+ - 2 sessions: `grid-cols-2`
+ - 3-4 sessions: `grid-cols-2 grid-rows-2`
+- Grid fills viewport height minus footer (48px): `h-[calc(100dvh-3rem)]`
+- When `totalPages > 1`: render PageIndicator, start 10-second `setInterval` to advance `currentPage`
+- Page transition: fade out old page (200ms), fade in new page (300ms)
+
+**SessionCard component:**
+- Props: `snapshot: DisplaySnapshot`
+- Background: `bg-surface-iron`, zero border-radius
+- Layout (vertical stack):
+ 1. **Header row:** Display name (Space Grotesk, `text-[1.5rem]`, ALL-CAPS, `text-bone-white`) | Session type (Inter, `text-base`, ALL-CAPS, `text-warm-ash`) | Elapsed time (Space Grotesk, `text-[1.25rem]`, `text-warm-ash`)
+ 2. **Current exercise:** Inter, `text-[1.25rem]`, `text-bone-white` | "Exercise N of M" in `text-warm-ash`
+ 3. **Set progress:** Compact list of sets -- set number + weight x reps + checkmark (`check_circle` in `arc`) or pending dot
+ 4. **Rest timer:** If running, show `RestTimerDisplay` in compact mode
+
+**RestTimerDisplay component:**
+- Props: `restTimer: RestTimerState`, `variant: 'compact' | 'large'`
+- Uses `useTimerInterpolation(restTimer)` hook
+- Compact (board card): `bg-surface-steel`, Space Grotesk `text-[1.75rem]`, `text-ember`, inline
+- Large (focused view): `bg-surface-steel`, Space Grotesk `text-readout` (3.5rem), `text-ember`, centered, `animate-ember-pulse` when < 10s
+- Format: `M:SS` via existing `formatCountdown` utility
+- When timer is idle (remaining === 0), component returns null
+
+**PageIndicator component:**
+- Row of dots at bottom of board (above footer)
+- Active page: `bg-ember` dot (8px)
+- Inactive: `bg-surface-steel` dot (8px)
+- Only rendered when totalPages > 1
+
+**Acceptance:**
+- [ ] Board shows cards for all active sessions
+- [ ] Grid adapts: 1 card centered, 2 in columns, 3-4 in 2x2
+- [ ] Session card shows name, type, exercise, sets, timer
+- [ ] Set checkmarks render in `arc` color
+- [ ] Rest timer counts down smoothly
+- [ ] Page cycling works at 10-second intervals for 5+ sessions
+- [ ] Page indicator dots show current page
+- [ ] All typography meets minimum sizing from Tech.md
+
+---
+
+### Wave 3: Focused View
+
+Depends on board view components (reuses RestTimerDisplay).
+
+---
+
+#### S007: Focused View
+
+**Agent:** `frontend`
+**Files:** `src/components/display/focused-view.tsx`, `src/components/display/set-table.tsx`
+**Dependencies:** S003, S005, S006 (for RestTimerDisplay)
+
+**FocusedView component:**
+- Reads focused user's snapshot from store: `sessions.get(focusedUserId)`
+- If snapshot not found (user ended session), display store should have already cleared focus. Render nothing (mode transitions to board/idle).
+- Full viewport minus footer, vertically centered content
+
+**Layout (top to bottom):**
+1. **Header bar:** Display name + session type (Space Grotesk, `text-[2rem]`, ALL-CAPS) | Elapsed time (Space Grotesk, `text-[1.5rem]`, `text-warm-ash`), right-aligned
+2. **Exercise heading:** Current exercise name (Space Grotesk, `text-[2.5rem]`, centered) | "Exercise N of M" (Inter, `text-base`, `text-warm-ash`, centered)
+3. **Set table:** Full set-by-set breakdown
+4. **Rest timer:** Large centered timer when running
+
+**SetTable component:**
+- Props: `sets: DisplaySet[]`
+- Table layout with columns: SET | PRESCRIBED | ACTUAL | STATUS
+- Column headers: Inter, `text-base`, ALL-CAPS, `text-warm-ash`, `tracking-wider`
+- Row styling:
+ - Alternating backgrounds: `bg-surface-charcoal` / `bg-surface-anvil`
+ - Row height: generous (min `3rem`) for distance readability
+ - Font: Inter, `text-[1.25rem]`
+ - Current set (first incomplete): `border-l-2 border-ember` left accent
+ - Completed sets: `check_circle` icon in `arc` in STATUS column
+ - Pending sets: empty STATUS cell
+- PRESCRIBED column: `{weight} x {reps}` or "--" if no prescribed data
+- ACTUAL column: `{weight} x {reps}` or "--" if not yet completed
+- Weight formatting: include unit (`275 lb`, `120 kg`)
+
+**Acceptance:**
+- [ ] Focused view renders for focused user's snapshot
+- [ ] Header shows name, session type, elapsed time
+- [ ] Exercise heading shows current exercise at 2.5rem
+- [ ] Set table renders all sets with correct columns
+- [ ] Current set has ember left border accent
+- [ ] Completed sets show arc checkmark
+- [ ] Alternating row backgrounds
+- [ ] Large rest timer displays when running with ember-pulse < 10s
+- [ ] All text readable at 3+ meters
+
+---
+
+### Wave 3 Validation
+
+#### S007-T: Integration Validation
+
+**Agent:** `qa`
+**Files:** Tests across all components
+**Dependencies:** S005, S006, S007
+
+**Validation checklist:**
+
+1. **End-to-end flow test (manual or scripted):**
+ - Open `/display` in a browser tab
+ - From another tab, start a workout -> verify card appears on display
+ - Confirm a set -> verify card updates
+ - Start rest timer -> verify countdown on display
+ - Push to Display -> verify focused mode activates
+ - Unfocus -> verify return to board
+ - End workout -> verify card removed, display returns to idle
+
+2. **Multi-session test:**
+ - Publish 2 mock snapshots with different user_ids
+ - Verify 2-column grid layout
+ - Publish 5 mock snapshots -> verify page cycling
+
+3. **Reconnection test:**
+ - Subscribe display, publish snapshot, verify card
+ - Simulate disconnect (destroy channel)
+ - Verify "Reconnecting..." in footer
+ - Simulate reconnect -> verify display_hello sent
+ - Publish snapshot in response -> verify card reappears
+
+4. **Timer accuracy test:**
+ - Publish snapshot with running timer (started_at = 2 min ago, total_seconds = 180)
+ - Verify display shows ~60s remaining
+ - Wait 10 real seconds, verify countdown advanced by ~10
+
+5. **Visual review:**
+ - All Iron & Ember tokens applied (zero radius, tonal surfaces, dual fonts)
+ - Typography sizing meets distance-readable minimums
+ - Connection footer renders correctly in all states
+ - Portrait orientation shows rotate message
+ - No scrollbars visible in any mode
+
+**Acceptance:**
+- [ ] All flow scenarios pass
+- [ ] Timer interpolation accurate to within 1 second
+- [ ] Grid layouts correct for 1, 2, 3, 4, and 5+ sessions
+- [ ] Mode transitions smooth (300ms)
+- [ ] Reconnection rebuilds state
+- [ ] Visual review passes Iron & Ember compliance
+
+---
+
+### Wave 4: Review Follow-up Tasks
+
+Tasks added from PR #60 review findings.
+
+---
+
+#### S008: Reconnection retry unit tests
+
+**Agent:** `qa`
+**Files:** `src/lib/__tests__/display-subscriber.test.ts`
+**Dependencies:** S002
+**Review:** P6-009
+
+Write unit tests for the exponential backoff retry mechanism in `display-subscriber.ts`:
+- Verify delay computation (2s, 4s, 8s, ... capped at 30s)
+- Verify retry attempt resets on successful reconnect
+- Verify retry cancellation on `destroyDisplaySubscriber()`
+- Verify `publishHello()` called on reconnect (not first connect)
+
+**Acceptance:**
+- [ ] Retry delay doubles per attempt up to 30s cap
+- [ ] Retry counter resets on SUBSCRIBED
+- [ ] Destroy cancels pending retry timer
+- [ ] publishHello fires on reconnect only
+
+---
+
+#### S009: `use-elapsed-time` unit tests
+
+**Agent:** `qa`
+**Files:** `src/hooks/__tests__/use-elapsed-time.test.ts`
+**Dependencies:** None
+**Review:** P6-010
+
+Write unit tests for `useElapsedTime` hook using `renderHook`:
+- Returns 0 at start
+- Increases over time
+- Resets when `startedAt` changes
+- Returns 0 for invalid timestamps (NaN guard)
+- Handles future timestamps gracefully
+
+**Acceptance:**
+- [ ] All test cases pass
+- [ ] NaN guard tested
+
+---
+
+#### S010: Add `.min(1)` to string fields in display snapshot schema
+
+**Agent:** `frontend`
+**Files:** `src/domain/types/display-snapshot.ts`
+**Dependencies:** None
+**Review:** P6-011
+
+Add `.min(1)` validation to `user_id`, `display_name`, `session_name`, and `current_exercise` in `displaySnapshotSchema`. Update any affected tests that use empty strings.
+
+**Acceptance:**
+- [ ] Empty strings rejected by schema
+- [ ] Existing tests updated for non-empty strings
+- [ ] No regression in subscriber payload validation
+
+---
+
+#### S011: Invalid payload tests for `session_ended` and `focus` events
+
+**Agent:** `qa`
+**Files:** `src/lib/__tests__/display-subscriber.test.ts`
+**Dependencies:** S002
+**Review:** P6-012
+
+Add parameterized tests for invalid payloads on `session_ended` and `focus` events:
+- Missing `user_id`
+- Non-string `user_id`
+- Empty payload
+
+Verify each is dropped with a console.warn and handler is NOT called.
+
+**Acceptance:**
+- [ ] Invalid `session_ended` payloads dropped
+- [ ] Invalid `focus` payloads dropped
+- [ ] Parity with existing `workout_snapshot` invalid payload test
+
+---
+
+## Milestone Summary
+
+| Wave | Tasks | Parallel | Description |
+|------|-------|----------|-------------|
+| 1 | S001, S002, S003, S004 | All parallel | Store, subscriber, timer hook, hello responder |
+| 1-T | S001-T | After Wave 1 | Unit tests for all foundation code |
+| 2 | S005, S006 | S005 first, then S006 | Route shell + board view |
+| 3 | S007 | After S006 | Focused view |
+| 3-T | S007-T | After Wave 3 | Integration validation |
+| 4 | S008, S009, S010, S011 | S008-S009-S011 parallel, S010 independent | Review follow-up: tests + schema hardening |
+
+**Total tasks:** 12 (5 build + 5 test + 2 UI build)
+**Estimated new files:** 13
+**Estimated changed files:** 4 (root route, display-publisher, use-display-broadcast, display-snapshot)
+
+---
+
+## Execution Command
+
+```
+/build .claude/tasks/009-display-route-board.md
+```
+
+Use `/build` (not `/team-build`) -- single frontend domain, no cross-agent coordination needed. Wave 1 tasks are fully parallel. Wave 2-3 are sequential due to component dependencies.
diff --git a/Context/Features/009-display-route-board/Tech.md b/Context/Features/009-display-route-board/Tech.md
new file mode 100644
index 00000000..5d837d87
--- /dev/null
+++ b/Context/Features/009-display-route-board/Tech.md
@@ -0,0 +1,367 @@
+# 009 - Display Route + Board and Focused Views: Technical Architecture
+
+## Architecture Overview
+
+The display route is a **subscriber-only, read-only, unauthenticated** page that consumes `DisplaySnapshot` events from the Supabase Broadcast channel `display`. It maintains an in-memory map of active sessions and renders them as either a card grid (board) or a full-screen detail view (focused).
+
+```
+Phone A ─┐ ┌─ Board View (grid of cards)
+Phone B ──┤→ Broadcast "display" →──┤→ Focused View (single user)
+Phone C ─┘ channel └─ Idle Placeholder (clock)
+```
+
+**Zero database queries.** The display never reads from Postgres or SQLite. All data arrives via ephemeral Broadcast events. If the display reloads, it publishes a `display_hello` event and phones respond by re-publishing their current snapshot.
+
+---
+
+## Key Technical Decisions
+
+### TD-1: Route placement -- top-level public route
+
+**Decision:** Create `src/routes/display.tsx` as a top-level route outside `_authenticated` layout.
+
+**Options considered:**
+1. Under `_authenticated` layout -- requires login on TV, violates PRD FR-1.2
+2. Top-level public route (like `/s/$token`) -- no auth, no nav chrome
+3. Separate SPA entry point -- over-engineered for one route
+
+**Choice:** Option 2. Add `/display` to the `__root.tsx` `beforeLoad` bypass list alongside `/setup` and `/s/`.
+
+**Trade-off:** The display still needs a Supabase client for Broadcast subscription. It will use `resolveConfig()` to get the publishable key from env vars or config store, then create a minimal client. No auth session needed -- Broadcast channels only require the publishable key.
+
+### TD-2: State management -- dedicated Zustand store
+
+**Decision:** Create a `display-store.ts` Zustand store for display state.
+
+**Options considered:**
+1. React component state (`useState`/`useReducer`) -- sufficient but harder to test and debug
+2. Zustand store -- consistent with codebase patterns, inspectable, testable outside React
+3. Reuse active-workout-store -- wrong abstraction; display is a consumer, not a producer
+
+**Choice:** Option 2. The store holds:
+- `sessions: Map` -- active sessions keyed by `user_id`
+- `focusedUserId: string | null` -- which user is focused (null = board mode)
+- `connectionStatus: 'connected' | 'reconnecting' | 'disconnected'`
+- `currentPage: number` -- for board pagination when 5+ sessions
+
+**Derived state** (computed, not stored):
+- `displayMode`: `'idle' | 'board' | 'focused'` -- derived from `sessions.size` and `focusedUserId`
+- `totalPages`: `Math.ceil(sessions.size / 4)`
+- `pageSessionIds`: array of user_ids for the current page
+
+### TD-3: Broadcast subscription -- module-scoped manager
+
+**Decision:** Create `src/lib/display-subscriber.ts` following the same pattern as `display-publisher.ts` and `realtime-manager.ts`.
+
+**Pattern:**
+```typescript
+// Module-scoped state
+let _client: SupabaseClient | null = null
+let _channel: RealtimeChannel | null = null
+
+export function initDisplaySubscriber(client: SupabaseClient): void
+export function subscribeToDisplay(handlers: DisplayEventHandlers): void
+export function publishHello(): void
+export function destroyDisplaySubscriber(): void
+```
+
+**Event handlers interface:**
+```typescript
+interface DisplayEventHandlers {
+ onSnapshot: (snapshot: DisplaySnapshot) => void
+ onSessionEnded: (payload: { user_id: string }) => void
+ onFocus: (payload: { user_id: string }) => void
+ onUnfocus: () => void
+ onHello: () => void // Phone-side only; display publishes hello
+ onStatusChange: (status: 'connected' | 'reconnecting' | 'disconnected') => void
+}
+```
+
+**Validation:** All incoming payloads validated with Zod schemas from `src/domain/types/display-snapshot.ts` before dispatching to handlers. Invalid payloads logged and dropped.
+
+### TD-4: Timer interpolation -- requestAnimationFrame
+
+**Decision:** Use `requestAnimationFrame` for smooth timer countdown on the display.
+
+**Options considered:**
+1. `setInterval(1000)` -- simple but can drift and doesn't sync with frame rendering
+2. `requestAnimationFrame` -- syncs with display refresh, calculate remaining from wall clock
+3. React state update every second -- causes full re-render, wasteful
+
+**Choice:** Option 2. When a snapshot arrives with `rest_timer.state === 'running'`:
+1. Store `started_at` (ISO timestamp) and `total_seconds` from the snapshot
+2. On each animation frame, compute `remaining = total_seconds - ((Date.now() - Date.parse(started_at)) / 1000)`
+3. Render `Math.ceil(remaining)` as the countdown display
+4. Stop when `remaining <= 0` or a new snapshot arrives with `rest_timer.state === 'idle'`
+
+**Accuracy:** Within 1 second (NFR-D4). No Broadcast traffic during countdown.
+
+**Implementation:** A `useTimerInterpolation(restTimerState)` hook that returns `remainingSeconds: number` and manages the rAF loop internally. The hook is used by both board cards (compact timer) and focused view (large timer).
+
+### TD-5: Reconnection -- display_hello pattern
+
+**Decision:** On WebSocket reconnection, the display publishes a `display_hello` event. Active phones respond by re-publishing their current snapshot.
+
+**Flow:**
+1. Supabase client detects disconnection -> store sets `connectionStatus: 'reconnecting'`
+2. Client auto-reconnects (built-in Supabase behavior)
+3. On reconnection success -> subscriber publishes `{ type: 'broadcast', event: 'display_hello', payload: {} }`
+4. Phones listening on the same channel receive `display_hello` and call `_publishCurrentState()`
+5. Display receives snapshots and rebuilds its session map
+6. Store sets `connectionStatus: 'connected'`
+
+**Phone-side change required:** Add a `display_hello` listener to the publisher that triggers a re-publish. This is a small addition to `display-publisher.ts`.
+
+### TD-6: Grid layout -- CSS Grid with dynamic columns
+
+**Decision:** Use CSS Grid with column count derived from session count.
+
+| Sessions | Grid Template | Layout |
+|----------|---------------|--------|
+| 0 | N/A | Idle placeholder |
+| 1 | `1fr` | Single card, centered, expanded |
+| 2 | `1fr 1fr` | 2 columns, 1 row |
+| 3 | `1fr 1fr` / `auto auto` | 2 columns, 2 rows (one cell empty) |
+| 4 | `1fr 1fr` / `1fr 1fr` | 2x2 grid |
+| 5+ | `1fr 1fr` / `1fr 1fr` | 2x2 grid, auto-cycling pages |
+
+Grid is applied via Tailwind classes dynamically. Cards fill available viewport height minus the status footer (48px).
+
+### TD-7: Page cycling -- setInterval with cleanup
+
+**Decision:** When `sessions.size > 4`, advance `currentPage` every 10 seconds via `setInterval`.
+
+The interval is managed in a `useEffect` tied to `totalPages > 1`. When `totalPages` drops to 1 or below, the interval is cleared and `currentPage` resets to 0. Page transition uses a CSS fade (opacity 0 -> 1, 300ms).
+
+### TD-8: Mode transitions -- CSS transitions
+
+**Decision:** Use CSS transitions for mode changes (300-400ms fades).
+
+| Transition | Animation |
+|-----------|-----------|
+| Idle -> Board | Fade clock out (200ms), fade cards in (300ms) |
+| Board -> Idle | Fade cards out (200ms), fade clock in (300ms) |
+| Board -> Focused | Cards fade out (200ms), focused view fades in (300ms) |
+| Focused -> Board | Focused fades out (200ms), cards fade in (300ms) |
+
+Implemented with a `displayMode` state change that triggers CSS `opacity` and `visibility` transitions. No animation library needed.
+
+---
+
+## Component Architecture
+
+```
+DisplayRoute (src/routes/display.tsx)
+├── DisplayShell (full viewport, manages subscription lifecycle)
+│ ├── IdlePlaceholder (Step 28: simple clock; Step 29: full idle mode)
+│ ├── BoardView
+│ │ ├── SessionCard (per user)
+│ │ │ ├── CardHeader (name, session type, elapsed)
+│ │ │ ├── SetProgress (compact set list with checkmarks)
+│ │ │ └── CompactRestTimer (small countdown)
+│ │ └── PageIndicator (dots for 5+ sessions)
+│ └── FocusedView
+│ ├── FocusedHeader (name, session, elapsed time)
+│ ├── ExerciseHeading (current exercise, progress)
+│ ├── SetTable (PRESCRIBED / ACTUAL / STATUS columns)
+│ └── LargeRestTimer (big countdown with ember-pulse)
+└── ConnectionFooter (status dot, session count, focused user name)
+```
+
+**File organization:**
+```
+src/
+├── routes/
+│ └── display.tsx # Route definition + DisplayShell
+├── components/
+│ └── display/
+│ ├── board-view.tsx # Grid of session cards
+│ ├── session-card.tsx # Individual session card
+│ ├── focused-view.tsx # Full-screen single user
+│ ├── set-table.tsx # Focused view set table
+│ ├── idle-placeholder.tsx # Simple clock (Step 28)
+│ ├── connection-footer.tsx # Status bar
+│ ├── rest-timer-display.tsx # Shared timer component (compact + large)
+│ └── page-indicator.tsx # Pagination dots
+├── stores/
+│ └── display-store.ts # Zustand store for display state
+├── hooks/
+│ └── use-timer-interpolation.ts # rAF-based timer countdown
+└── lib/
+ └── display-subscriber.ts # Broadcast channel subscription manager
+```
+
+---
+
+## Data Flow
+
+### Snapshot Arrival
+
+```
+Broadcast event received
+ → Zod validation (displaySnapshotSchema.safeParse)
+ → display-store.upsertSession(userId, snapshot)
+ → React re-render via Zustand selector
+ → Board card or Focused view updates
+```
+
+### Focus Command
+
+```
+Broadcast "focus" event { user_id }
+ → display-store.setFocusedUser(userId)
+ → displayMode derived as 'focused'
+ → CSS transition: board fades out, focused fades in
+ → FocusedView renders with snapshot from sessions map
+```
+
+### Session End
+
+```
+Broadcast "session_ended" event { user_id }
+ → display-store.removeSession(userId)
+ → If removed user was focused → clear focus → mode reverts to board or idle
+ → Card removed from grid (or page count adjusts)
+```
+
+### Reconnection
+
+```
+WebSocket disconnects
+ → Channel status callback fires ('CLOSED' | 'TIMED_OUT')
+ → display-store.setConnectionStatus('reconnecting')
+ → Supabase client auto-reconnects
+ → Channel re-subscribes successfully
+ → display-subscriber publishes display_hello event
+ → Phones re-publish current snapshots
+ → Sessions map rebuilds
+ → display-store.setConnectionStatus('connected')
+```
+
+---
+
+## Display Store API
+
+```typescript
+interface DisplayState {
+ sessions: Map
+ focusedUserId: string | null
+ connectionStatus: 'connected' | 'reconnecting' | 'disconnected'
+ currentPage: number
+}
+
+interface DisplayActions {
+ upsertSession(userId: string, snapshot: DisplaySnapshot): void
+ removeSession(userId: string): void
+ setFocusedUser(userId: string | null): void
+ setConnectionStatus(status: DisplayState['connectionStatus']): void
+ setCurrentPage(page: number): void
+ clearAllSessions(): void
+}
+
+// Derived selectors (exported as functions, not stored)
+function getDisplayMode(state: DisplayState): 'idle' | 'board' | 'focused'
+function getPageSessions(state: DisplayState): DisplaySnapshot[]
+function getTotalPages(state: DisplayState): number
+```
+
+---
+
+## Supabase Client for Display
+
+The display route needs a Supabase client but no auth session. On mount:
+
+1. Call `resolveConfig()` to get `supabaseUrl` and `supabaseKey`
+2. If no config available (no env vars, no stored config), show a setup prompt directing to the main app
+3. If config available, call `createClient(url, key)` with minimal options (no auth persistence needed)
+4. Pass client to `initDisplaySubscriber()`
+
+The display does NOT call `initSupabaseFromConfig()` (which sets the global singleton with auth). It creates its own minimal client instance. This avoids interfering with the main app's auth state if both are open in the same browser.
+
+---
+
+## Typography Sizing (Distance-Readable)
+
+Per PRD, all text must be readable at 3+ meters on 1080p. Minimum sizes:
+
+| Element | Font | Min Size | Tailwind Class |
+|---------|------|----------|----------------|
+| Clock (idle) | Space Grotesk | 8rem | Custom `text-[8rem]` |
+| Rest timer (focused) | Space Grotesk | 3.5rem | `text-readout` |
+| Rest timer (board card) | Space Grotesk | 1.75rem | `text-[1.75rem]` |
+| User name (card) | Space Grotesk | 1.5rem | `text-[1.5rem]` |
+| User name (focused header) | Space Grotesk | 2rem | `text-[2rem]` |
+| Current exercise (focused) | Space Grotesk | 2.5rem | `text-[2.5rem]` |
+| Current exercise (card) | Inter | 1.25rem | `text-[1.25rem]` |
+| Set table text | Inter | 1.25rem | `text-[1.25rem]` |
+| Exercise progress | Inter | 1rem | `text-base` |
+| Status footer | Inter | 0.875rem | `text-sm` |
+| Session type label | Inter | 1rem | `text-base` |
+| Elapsed time | Space Grotesk | 1.25rem (card), 1.5rem (focused) | varies |
+
+---
+
+## Phone-Side Changes (display_hello listener)
+
+Step 27's `display-publisher.ts` needs a small addition: listen for `display_hello` events on the channel and respond by re-publishing the current snapshot.
+
+```typescript
+// In ensureChannel(), after channel creation:
+_channel.on('broadcast', { event: 'display_hello' }, () => {
+ // Phone responds by re-publishing current state
+ // This requires access to the active workout store's publish function
+})
+```
+
+**Implementation approach:** Export a `setHelloResponder(fn: () => void)` from the publisher module. The `useDisplayBroadcast` hook sets this to a function that calls `_publishCurrentState()` from the active workout store. When `display_hello` arrives, the publisher invokes the responder if set.
+
+---
+
+## Portrait Handling
+
+The display assumes landscape orientation (FR-1.6). If a portrait viewport is detected:
+
+```css
+@media (orientation: portrait) {
+ .display-shell { /* show rotate message, hide display content */ }
+}
+```
+
+A simple centered message: "ROTATE TO LANDSCAPE" with a `screen_rotation` Material Symbol icon. No forced orientation lock.
+
+---
+
+## Performance Considerations
+
+- **Memory target:** < 50 MB (NFR-D6). No heavy libraries. No virtualization needed (max ~20 cards).
+- **Render target:** < 100ms after snapshot receipt (NFR-D3). Zustand selectors ensure only affected cards re-render.
+- **No unnecessary re-renders:** Timer interpolation uses rAF and updates a ref, only triggering React re-render once per second (not per frame).
+- **Page cycling:** `setInterval` cleared when not needed. No leaked timers.
+- **Channel cleanup:** `destroyDisplaySubscriber()` on route unmount removes the channel.
+
+---
+
+## Integration Points
+
+| System | Integration | Direction |
+|--------|-------------|-----------|
+| display-publisher.ts | Publishes snapshots + events | Phone -> Channel |
+| display-subscriber.ts (new) | Subscribes to snapshots + events | Channel -> Display |
+| display-store.ts (new) | Holds display state | Internal |
+| active-workout-store.ts | Responds to display_hello | Phone-side (minor change) |
+| __root.tsx | Route bypass for /display | Config guard (minor change) |
+| use-display-broadcast.ts | Wires hello responder | Phone-side (minor change) |
+
+---
+
+## Risks and Mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+|------|------------|--------|------------|
+| Broadcast events lost during disconnection | Medium | Sessions stale until next state change | display_hello pattern rebuilds state on reconnect |
+| Timer drift > 1 second | Low | Visual inaccuracy | rAF calculates from wall clock, not increments |
+| Supabase Broadcast channel limits | Low | Display stops receiving | Single channel for all events; well within limits |
+| Memory leak from stale sessions | Medium | Display degrades over time | Sessions removed on `session_ended`; consider a staleness timeout (e.g., 30 min no update -> auto-remove) |
+| Display and app open in same browser tab conflict | Low | Auth state interference | Display creates its own Supabase client, doesn't use global singleton |
+
+**Staleness timeout:** If no snapshot is received for a session in 30 minutes, auto-remove it from the map. This handles the case where a phone crashes without sending `session_ended`. Implemented as a periodic check in the store or a per-session timeout.
diff --git a/Context/Features/009-idle-mode/Spec.md b/Context/Features/009-idle-mode/Spec.md
new file mode 100644
index 00000000..cac64b71
--- /dev/null
+++ b/Context/Features/009-idle-mode/Spec.md
@@ -0,0 +1,88 @@
+# Feature 009: Idle Mode + Edge Function
+
+## Overview
+
+Idle mode is the ambient display state for the `/display` route when no workouts are active. It renders a large clock, today's scheduled sessions, and a countdown to the next upcoming session -- all driven by a server-side Edge Function that publishes data every 60 seconds over the existing Supabase Broadcast channel. This is the final step of Phase 7 (Remote Display), completing the gym TV experience.
+
+## Problem Statement
+
+The `/display` route (Step 28) shows active workout sessions, but when no one is training, the screen is blank. A gym TV should always show useful information: the current time, who is training next, and when. Because the display route has no authentication and no database access, a server-side process must push schedule data to it via the same Broadcast channel used for workout snapshots.
+
+## User Stories
+
+- As a gym owner, I want the TV to show a clock and today's training schedule when no one is working out, so the display is always useful
+- As a gym member, I want to see when the next session starts on the gym TV, so I know how long until someone needs the platform
+- As a gym owner, I want the display to transition smoothly between idle and active states, so it looks professional on a wall-mounted TV
+- As a gym member, I want the idle display to update automatically without anyone touching the TV, so it stays current throughout the day
+
+## Requirements
+
+### Must Have
+
+- M-1: Large clock display updating every second, rendered in local timezone
+- M-2: Automatic transition from idle to board view when the first `workout_snapshot` event arrives
+- M-3: Automatic transition from board/focused back to idle when the last `session_ended` event fires
+- M-4: Supabase Edge Function that queries today's remaining scheduled sessions across all users
+- M-5: Edge Function publishes `idle_state` event to the `display` Broadcast channel every 60 seconds
+- M-6: Clock text readable from 5+ meters on a 1080p display (minimum 8rem Space Grotesk)
+- M-7: Display remains stable for hours of continuous operation without memory leaks or visual drift
+- M-8: Transitions between idle, board, and focused modes are smooth (300-400ms)
+
+### Should Have
+
+- S-1: Today's scheduled sessions listed below the clock when idle state data is available
+- S-2: Countdown badge showing time until the next upcoming session ("Next up in X:XX") with session name
+- S-3: Server time from idle_state used for clock sync correction (drift prevention over long runtimes)
+- S-4: Edge Function uses service_role key and never exposes credentials to the client
+
+### Won't Have (this iteration)
+
+- W-1: Authentication on the display route (it remains a public, no-auth page)
+- W-2: User interaction or touch controls on the idle screen
+- W-3: Custom idle backgrounds, themes, or branding configuration
+- W-4: Historical schedule data (only today's remaining sessions)
+- W-5: Per-user opt-out from appearing in the idle schedule (display_visible controls broadcast, not schedule visibility)
+
+## Testable Assertions
+
+| ID | Assertion | Verification |
+| ----- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
+| A-001 | Clock updates every second and displays the correct local time | Open /display with no active workouts; confirm clock ticks match system time within 1 second |
+| A-002 | Clock renders at minimum 8rem in Space Grotesk on a 1080p viewport | Inspect computed font-size on a 1920x1080 browser window |
+| A-003 | Date line renders below the clock in Inter body-large, text-secondary | Visual inspection of rendered display |
+| A-004 | When idle_state event arrives, scheduled sessions render as a list below the clock | Publish a mock idle_state event; confirm session rows appear |
+| A-005 | Countdown badge shows "Next up in M:SS" with session name when next_session is present | Publish idle_state with next_session; confirm badge renders with ember text on surface-steel |
+| A-006 | Countdown badge is absent when next_session is null | Publish idle_state with next_session: null; confirm no badge renders |
+| A-007 | Display transitions from idle to board within 300ms when first workout_snapshot arrives | Start a workout on a phone while /display is in idle mode; measure transition timing |
+| A-008 | Display transitions from board to idle within 300ms when last session_ended fires | End the only active workout; measure transition timing |
+| A-009 | Edge Function publishes idle_state event to the display channel every 60 seconds | Check Supabase function logs for invocation frequency; subscribe to channel and time events |
+| A-010 | Edge Function payload includes server_time, scheduled_sessions array, and next_session | Inspect broadcast event payload structure against schema |
+| A-011 | Edge Function filters to today's remaining (not-yet-completed) sessions only | Seed DB with past, today-completed, today-upcoming, and tomorrow sessions; verify only upcoming |
+| A-012 | Edge Function uses service_role key (not publishable key) | Inspect function env and Supabase config; confirm no client-visible credentials |
+| A-013 | Display runs for 4+ hours without memory growth exceeding 50MB above baseline | Profile /display in Chrome DevTools over extended period; monitor JS heap |
+| A-014 | No stale setInterval or requestAnimationFrame callbacks accumulate across mode transitions | Monitor active timer count in DevTools across 10+ idle-board-idle cycles |
+| A-015 | Idle mode layout uses surface-anvil background, surface-iron cards, surface-pit footer | Visual inspection against design tokens |
+| A-016 | All text meets minimum 1.25rem for TV readability | Inspect smallest computed font-size in the idle view |
+| A-017 | Board-to-focused and focused-to-board transitions animate at 400ms | Trigger focus/unfocus events; measure CSS transition durations |
+
+## Open Questions
+
+- [x] Should the Edge Function run via pg_cron (Postgres-native) or Supabase's scheduled function invocation (external cron)? -- Resolved: Use Supabase scheduled function invocation (pg_cron is not available on all Supabase plans; Edge Function cron is universally available)
+- [x] How should the display handle the case where the Edge Function is unreachable or not deployed? -- Resolved: Silently hide the schedule section; no error message shown
+- [x] Should the clock use 12-hour or 24-hour format? -- Resolved: Default 24-hour; override via URL param `/display?clock=12h`
+- [x] What is the maximum number of scheduled sessions to display? -- Resolved: Show up to 3 sessions; truncate beyond that
+
+## Dependencies
+
+| Feature | Dependency Type | Status |
+| ------- | --------------- | ------ |
+| 008 - Display Broadcast Infrastructure (Step 27) | Build dependency -- publisher and types | Complete |
+| Step 28 - Display Route + Board/Focused Views | Build dependency -- idle mode extends this route | Not started |
+| Step 11 - Program Structure | Data dependency -- scheduled_sessions table | Complete |
+| Step 3 - Supabase Project | Infrastructure dependency -- Edge Function deployment | Complete |
+
+## Revision History
+
+| Date | Change | ADR |
+| ---------- | ------------ | --- |
+| 2026-04-04 | Initial spec | -- |
diff --git a/Context/Features/009-idle-mode/Steps.md b/Context/Features/009-idle-mode/Steps.md
new file mode 100644
index 00000000..79815f39
--- /dev/null
+++ b/Context/Features/009-idle-mode/Steps.md
@@ -0,0 +1,252 @@
+# Implementation Steps: Idle Mode + Edge Function
+
+**Spec:** Context/Features/009-idle-mode/Spec.md
+**Tech:** Context/Features/009-idle-mode/Tech.md
+
+## Progress
+- **Status:** Complete
+- **Current task:** --
+- **Last milestone:** Feature complete (all 17 assertions verified)
+
+---
+
+## Team Orchestration
+
+### Team Members
+
+- **builder-frontend**
+ - Role: React/TypeScript -- domain types, display route, idle view components, hooks, transition wrapper
+ - Agent Type: frontend-specialist
+ - Resume: false
+
+- **builder-backend**
+ - Role: Supabase Edge Function -- SQL query, Realtime HTTP broadcast, cron config
+ - Agent Type: backend-engineer
+ - Resume: false
+
+- **validator**
+ - Role: Quality validation -- read-only assertion verification against Spec testable assertions
+ - Agent Type: quality-engineer
+ - Resume: false
+
+---
+
+## Tasks
+
+### Phase 1: Domain Types + Route Shell (parallel workstreams)
+
+> S001 and S004 are independent and run in parallel. S002 and S003 depend on S001.
+
+- [ ] S001: Extend `DisplayEventType` and add `IdleSnapshot` Zod schema
+ - **Assigned:** builder-frontend
+ - **Depends:** none
+ - **Parallel:** true
+ - **Details:** In `src/domain/types/display-snapshot.ts`, add `'idle_snapshot'` to the `displayEventTypeSchema` enum. Define `idleSnapshotSchema` with fields: `server_time` (isoDateTime), `scheduled_sessions` (array of `{ display_name, session_name, session_type, day_label }`), `next_session` (`{ display_name, session_name } | null`). Export `IdleSnapshot` inferred type. Update `src/domain/types/index.ts` to re-export `IdleSnapshot` and `idleSnapshotSchema`.
+
+- [ ] S001-T: Test `IdleSnapshot` schema validation
+ - **Assigned:** builder-frontend
+ - **Depends:** S001
+ - **Parallel:** false
+ - **Details:** Add to `src/domain/types/__tests__/display-snapshot.test.ts`. Scenarios: valid payload with sessions and next_session parses correctly; valid payload with next_session null parses correctly; missing server_time fails; scheduled_sessions with missing required field fails; idle_snapshot added to DisplayEventType enum.
+
+- [ ] S002: Create `/display` route shell with search param validation
+ - **Assigned:** builder-frontend
+ - **Depends:** S001
+ - **Parallel:** false
+ - **Details:** Create `src/routes/display.tsx` as a top-level file-based route (not under `_authenticated/`). Use `createFileRoute('/display')` with `validateSearch: z.object({ clock: z.enum(['12h', '24h']).optional().default('24h') })`. Component renders a full-viewport `div` with `bg-[#131313] min-h-screen` -- no sidebar, no nav, no header. Modify `src/routes/__root.tsx` `beforeLoad` to add `|| location.pathname.startsWith('/display')` to the config-check bypass (matching the existing `/s/` pattern).
+
+- [ ] S003: Create `useDisplayMode` hook
+ - **Assigned:** builder-frontend
+ - **Depends:** S001
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/use-display-mode.ts`. Accept `sessionMap: Map`, `focusedUserId: string | null`, and `idleSnapshot: IdleSnapshot | null` as inputs. Return `{ mode: 'idle' | 'board' | 'focused', previousMode: 'idle' | 'board' | 'focused' | null, focusedSnapshot: DisplaySnapshot | null }`. Mode derivation: map empty = `'idle'`; map has entries + no focusedUserId = `'board'`; focusedUserId set + user in map = `'focused'`; focusedUserId set + user NOT in map = `'board'` (focus cleared). Track previousMode via `useRef` for transition direction.
+
+- [ ] S003-T: Test `useDisplayMode` hook
+ - **Assigned:** builder-frontend
+ - **Depends:** S003
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/__tests__/use-display-mode.test.ts`. Use `renderHook`. Scenarios: empty map returns idle; map with one entry returns board; map with entry + matching focusedUserId returns focused; map with entry + non-matching focusedUserId returns board (focus auto-cleared); map empties after entries returns idle; previousMode updates correctly across transitions.
+
+- [ ] S004: Create Edge Function and cron config
+ - **Assigned:** builder-backend
+ - **Depends:** none
+ - **Parallel:** true
+ - **Details:** Create `supabase/functions/display-idle-snapshot/index.ts`. Follow the exact pattern of existing chat-media functions: Deno runtime, ESM from `esm.sh`, `Deno.serve(handler)`, CORS boilerplate (OPTIONS preflight returns `{ headers: corsHeaders }`). Use `createClient(url, serviceRoleKey)` -- no user auth header. Execute the SQL JOIN query (see Tech.md D4) to get today's remaining scheduled sessions for all `display_visible = true` users. Build `IdleSnapshot` payload: `server_time` from `new Date().toISOString()`, `scheduled_sessions` array (max 3 items, sliced after query), `next_session` as first item or null. Broadcast via `fetch(POST /realtime/v1/api/broadcast)` with service_role Bearer token and topic `"realtime:display"`. Return `{ published: true, session_count: N }` on success. Modify `supabase/config.toml` to add `[functions.display-idle-snapshot]` with `verify_jwt = false` and `[functions.display-idle-snapshot.cron]` with `schedule = "*/1 * * * *"`.
+
+- [ ] S004-T: Test Edge Function query logic and payload shape
+ - **Assigned:** builder-backend
+ - **Depends:** S004
+ - **Parallel:** false
+ - **Details:** Manual test via `supabase functions serve display-idle-snapshot` + curl. Scenarios: function responds 200 with `{ published: true }`; payload contains `server_time` ISO timestamp; `scheduled_sessions` is an array (empty if no sessions today); `scheduled_sessions` capped at 3 items; `next_session` is null when array is empty; `next_session` contains first session when array is non-empty. Verify service_role key is used (A-012): confirm `Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")` in source, not ANON key.
+
+🏁 **MILESTONE: Phase 1 complete** -- types defined, route shell exists, Edge Function deployable
+- Verify A-009: invoke function manually, confirm broadcast event appears on `display` channel
+- Verify A-010: inspect payload structure against `IdleSnapshot` schema
+- Verify A-012: confirm service_role key usage in Edge Function source
+
+**Contracts:**
+- `src/domain/types/display-snapshot.ts` -- `IdleSnapshot` Zod schema and type, updated `DisplayEventType` enum
+- `src/domain/types/index.ts` -- `IdleSnapshot` and `idleSnapshotSchema` exports
+- `supabase/functions/display-idle-snapshot/index.ts` -- deployed Edge Function broadcasting `idle_snapshot` events
+
+---
+
+### Phase 2: Idle View Components (parallel)
+
+> S005, S006, and S007 are independent of each other and run in parallel. All depend on S001 (types).
+
+- [ ] S005: Create `ClockDisplay` component
+ - **Assigned:** builder-frontend
+ - **Depends:** S001
+ - **Parallel:** true
+ - **Details:** Create `src/components/display/clock-display.tsx`. Props: `format: '12h' | '24h'`, `serverTimeCorrection?: string` (ISO timestamp, optional). Maintain internal `Date` state updated via `setInterval(..., 1000)`. On `serverTimeCorrection` prop change (when new `idle_snapshot` arrives), reset internal clock state to `new Date(serverTimeCorrection)` -- this is the drift correction (Tech.md D5). Format output: 24h = `HH:MM:SS` (using `toLocaleTimeString('en-GB')`), 12h = `H:MM:SS AM/PM`. Styles: `font-display text-[8rem] leading-none text-foreground tracking-tight` on a 1080p viewport. Cleanup `setInterval` on unmount to satisfy A-014. No border, no background -- pure text.
+
+- [ ] S005-T: Test `ClockDisplay` component
+ - **Assigned:** builder-frontend
+ - **Depends:** S005
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/__tests__/clock-display.test.tsx`. Use Vitest fake timers (`vi.useFakeTimers`). Scenarios: renders initial time; advances by 1 second after `vi.advanceTimersByTime(1000)`; 24h format renders without AM/PM; 12h format renders with AM/PM; serverTimeCorrection prop change resets displayed time to provided value; setInterval is cleared on unmount (use `vi.spyOn(global, 'clearInterval')`).
+
+- [ ] S006: Create `IdleView` component
+ - **Assigned:** builder-frontend
+ - **Depends:** S001
+ - **Parallel:** true
+ - **Details:** Create `src/components/display/idle-view.tsx`. Props: `idleSnapshot: IdleSnapshot | null`, `clockFormat: '12h' | '24h'`. Layout: full-viewport flex column with `bg-[#131313]`. Upper half: centered `ClockDisplay` + date line (`new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })` in Inter body-large text-secondary). Lower section (only when `idleSnapshot?.scheduled_sessions.length`): "TODAY'S SESSIONS" header (Space Grotesk label-large ALL-CAPS text-secondary), session rows on `bg-[#201F1F]` cards (display_name in Space Grotesk, session_type + day_label in Inter label-medium ALL-CAPS text-secondary), max 3 rows. "NEXT UP" badge (only when `idleSnapshot?.next_session`): `bg-[#353534]` with `text-[#FFB59C]` (ember), zero border-radius, "NEXT UP: [session_name]" in Space Grotesk label-large ALL-CAPS. Footer: `bg-[#0E0E0E]` fixed bottom, connection status (passed as prop `connectionStatus: 'connected' | 'reconnecting'`). Minimum font-size enforced via `text-[1.25rem]` baseline on all Inter text.
+
+- [ ] S006-T: Test `IdleView` component
+ - **Assigned:** builder-frontend
+ - **Depends:** S006
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/__tests__/idle-view.test.tsx`. Scenarios: renders clock section (A-001, A-003); renders date line below clock in text-secondary; session list renders when `idleSnapshot` has sessions (A-004); session list hidden when `idleSnapshot` is null (schedule unavailable); "NEXT UP" badge renders with ember styling when `next_session` present (A-005); "NEXT UP" badge absent when `next_session` is null (A-006); sessions capped at 3 rows when 4+ provided; footer renders with connection status.
+
+- [ ] S007: Create `DisplayModeTransition` wrapper
+ - **Assigned:** builder-frontend
+ - **Depends:** S003
+ - **Parallel:** true
+ - **Details:** Create `src/components/display/display-mode-transition.tsx`. Props: `mode: 'idle' | 'board' | 'focused'`, `previousMode: 'idle' | 'board' | 'focused' | null`, `children: React.ReactNode`. Apply `tw-animate-css` classes conditionally: idle↔board transitions use `key={mode}` on the content div + `animate-in fade-in-0 duration-300` / `animate-out fade-out-0 duration-300`; board↔focused use `animate-in zoom-in-95 fade-in-0 duration-[400ms]` / `animate-out zoom-out-95 fade-out-0 duration-[400ms]`. Use `AnimatePresence`-style key changes to trigger enter animations: changing `key` on the wrapper div forces React to unmount/remount and replay `animate-in` classes. Validate transition durations satisfy A-007 (300ms idle↔board) and A-017 (400ms board↔focused).
+
+🏁 **MILESTONE: Phase 2 complete** -- all idle view components built and unit tested
+- Verify A-001: ClockDisplay unit test passes (ticks every second)
+- Verify A-002: ClockDisplay renders at 8rem+ (visual inspection / Storybook)
+- Verify A-003: date line renders below clock in correct style
+- Verify A-004, A-005, A-006: IdleView snapshot tests pass
+- Verify A-015, A-016: visual inspection of Iron & Ember tokens and minimum font sizes
+
+**Contracts:**
+- `src/components/display/clock-display.tsx` -- ClockDisplay component, props interface
+- `src/components/display/idle-view.tsx` -- IdleView component, props interface
+- `src/components/display/display-mode-transition.tsx` -- DisplayModeTransition wrapper, props interface
+- `src/components/display/use-display-mode.ts` -- useDisplayMode hook, return shape
+
+---
+
+### Phase 3: Subscriber Hook + Route Wiring
+
+- [ ] S008: Create `useIdleSnapshot` hook
+ - **Assigned:** builder-frontend
+ - **Depends:** S001, S004
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/use-idle-snapshot.ts`. Subscribe to the `display` Broadcast channel (same channel name as the existing publisher). Filter incoming events for `event === 'idle_snapshot'`. Validate payload with `idleSnapshotSchema.safeParse()` -- discard invalid payloads silently. Store the most recent valid `IdleSnapshot` in local state and return it. On unmount, unsubscribe from channel. Handle the case where the Supabase client is not initialized (return null without throwing). This hook coexists with Step 28's subscriber hook on the same channel -- verify that two independent `client.channel('display')` calls do not duplicate events (see Tech.md unknown). If duplication is observed during testing, refactor to share the subscription via a module-level singleton (same pattern as `display-publisher.ts`).
+
+- [ ] S009: Wire display route -- compose components, drive mode transitions, handle reconnection
+ - **Assigned:** builder-frontend
+ - **Depends:** S002, S003, S005, S006, S007, S008
+ - **Parallel:** false
+ - **Details:** Implement the full `/display` route in `src/routes/display.tsx`. Compose: `useIdleSnapshot` for idle data; `useDisplayMode` (with sessionMap from Step 28's subscriber, focusedUserId from focus events, idleSnapshot); `DisplayModeTransition` wrapping the active view. Render: when mode is `'idle'` render ` `; when `'board'` render `` (Step 28 component or placeholder); when `'focused'` render `` (Step 28 component or placeholder). Reconnection: subscribe to channel `status` events; when `CLOSED` or `TIMED_OUT`, show "Reconnecting..." in footer; on reconnect, re-subscribe and publish `display_hello` per Tech.md D6 (Step 28 behavior). Pass `clockFormat` from `Route.useSearch().clock`. Ensure all subscriptions and intervals are cleaned up in `useEffect` return functions (A-013, A-014).
+
+- [ ] S009-T: Integration smoke test for display route
+ - **Assigned:** builder-frontend
+ - **Depends:** S009
+ - **Parallel:** false
+ - **Details:** Manual integration test. Open `http://localhost:5173/display` with no active workouts -- verify idle view with clock renders. Open `http://localhost:5173/display?clock=12h` -- verify 12h format. Simulate an `idle_snapshot` event via Supabase Realtime dashboard -- verify session list appears. Start a workout in another tab -- verify transition to board view. End workout -- verify transition back to idle. Verify no console errors after 5 mode transitions (A-014 quick check).
+
+- [ ] S009-D: Update self-hosting docs with display route and Edge Function
+ - **Assigned:** builder-backend
+ - **Depends:** S004, S009
+ - **Parallel:** false
+ - **Details:** Add a section to `docs/self-hosting.md` under a "Remote Display" heading. Cover: (1) the `/display` URL is public and requires no login; (2) the `display-idle-snapshot` Edge Function must be deployed for schedule data to appear; (3) cron configuration in `supabase/config.toml` (already included in the repo); (4) how to open the display on a TV browser (navigate to `https:///display`); (5) the `?clock=12h` URL param for 12-hour format.
+
+🏁 **MILESTONE: Phase 3 complete** -- full display route wired end-to-end
+- Verify A-007: idle→board transition animates in 300ms
+- Verify A-008: board→idle transition animates in 300ms
+- Verify A-017: board↔focused transitions animate at 400ms
+- Verify A-011: Edge Function filters completed sessions (manual DB seed test)
+- Verify A-013/A-014: no stale timers after multiple transitions
+
+---
+
+### Phase 4: Validation
+
+- [ ] S010: Full assertion validation
+ - **Assigned:** validator
+ - **Depends:** S001-T, S003-T, S004-T, S005-T, S006-T, S009-T
+ - **Parallel:** false
+ - **Details:** Read-only inspection. Verify all 17 testable assertions from Spec.md. Check: (A-001) ClockDisplay test passes; (A-002) 8rem font-size class present; (A-003) date line class includes text-secondary and Inter; (A-004) IdleView renders session list; (A-005/A-006) next_session badge conditional logic correct; (A-007/A-008) DisplayModeTransition uses duration-300 for idle↔board; (A-009) config.toml has `*/1 * * * *` cron; (A-010) Edge Function builds all three payload fields; (A-011) SQL query has `wl.id IS NULL` filter; (A-012) Edge Function uses `SUPABASE_SERVICE_ROLE_KEY` not ANON key; (A-013/A-014) all useEffect cleanups present; (A-015) correct hex values for surface-anvil, surface-iron, surface-pit; (A-016) minimum text-[1.25rem] class on Inter text; (A-017) duration-[400ms] on board↔focused transitions. Report pass/fail per assertion.
+
+🏁 **MILESTONE: Feature complete** -- verify all assertions, full drift check
+- All 17 testable assertions verified by validator
+- `bun run test` passes (all unit tests green)
+- `bun run build` succeeds (no TypeScript errors)
+- No TODO/FIXME stubs in new files
+
+---
+
+### Phase 5: Review Remediation Tasks
+
+> Added from review 0006-idle-mode-review.md findings.
+
+- [ ] S011: Add tests for `useIdleSnapshot` hook
+ - **Assigned:** builder-frontend
+ - **Depends:** S008
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/__tests__/use-idle-snapshot.test.ts`. Test scenarios: valid payload updates state; invalid payload discarded (state stays null); null client returns null without throwing and logs error; `removeChannel` called on unmount; connection status callback fires on SUBSCRIBED, CHANNEL_ERROR, TIMED_OUT, and CLOSED.
+
+- [ ] S012: Add tests for `DisplayModeTransition`
+ - **Assigned:** builder-frontend
+ - **Depends:** S007
+ - **Parallel:** false
+ - **Details:** Create `src/components/display/__tests__/display-mode-transition.test.tsx`. Test `getTransitionClasses` pure function: idle-to-board returns `duration-300` fade classes; board-to-focused returns `duration-[400ms]` zoom+fade classes; focused-to-board returns `duration-[400ms]` classes; null previousMode returns default fade classes.
+
+- [ ] S013: Add schema edge case tests for `IdleSnapshot`
+ - **Assigned:** builder-frontend
+ - **Depends:** S001-T
+ - **Parallel:** false
+ - **Details:** Add edge case tests to `src/domain/types/__tests__/display-snapshot.test.ts`. Scenarios: invalid session_type value (e.g., "YOGA") fails validation; empty scheduled_sessions array parses successfully; invalid ISO date string for server_time fails validation.
+
+- [ ] S014: Strengthen ClockDisplay server correction test assertion
+ - **Assigned:** builder-frontend
+ - **Depends:** S005-T
+ - **Parallel:** false
+ - **Details:** In `src/components/display/__tests__/clock-display.test.tsx`, update the server correction test to assert the rendered time matches the expected formatted output of the correction timestamp, not just that it differs from the initial time.
+
+---
+
+## Acceptance Criteria
+
+- [ ] All 17 testable assertions from Spec.md verified (A-001 through A-017)
+- [ ] All new unit tests passing (`bun run test`)
+- [ ] TypeScript build clean (`bun run build`)
+- [ ] No TODO/FIXME stubs in `src/components/display/`, `src/routes/display.tsx`, or `supabase/functions/display-idle-snapshot/`
+- [ ] `supabase/config.toml` contains cron schedule for `display-idle-snapshot`
+- [ ] Self-hosting docs updated with Remote Display section
+- [ ] `IdleSnapshot` type exported from `src/domain/types/index.ts`
+- [ ] `/display?clock=12h` renders 12-hour clock format
+
+## Validation Commands
+
+```bash
+bun run test # All unit tests pass
+bun run build # TypeScript clean, no errors
+bun run lint # ESLint clean
+
+# Manual: verify display route loads
+open http://localhost:5173/display
+open http://localhost:5173/display?clock=12h
+
+# Manual: verify Edge Function
+supabase functions serve display-idle-snapshot
+curl -X POST http://localhost:54321/functions/v1/display-idle-snapshot
+
+# Manual: verify cron config present
+grep -A3 "display-idle-snapshot" supabase/config.toml
+```
diff --git a/Context/Features/009-idle-mode/Tech.md b/Context/Features/009-idle-mode/Tech.md
new file mode 100644
index 00000000..d6060b2e
--- /dev/null
+++ b/Context/Features/009-idle-mode/Tech.md
@@ -0,0 +1,340 @@
+# Tech Plan: Idle Mode + Edge Function
+
+**Spec:** Context/Features/009-idle-mode/Spec.md
+**Stacks involved:** React/TypeScript (display route, idle view), Supabase (Edge Function, Realtime Broadcast HTTP API)
+
+---
+
+## Architecture Overview
+
+Idle mode extends the display pipeline established in Feature 008 with a second publisher: a server-side Edge Function that periodically broadcasts today's scheduled sessions. The display route (Step 28) already subscribes to the `display` Broadcast channel for `workout_snapshot` events. Idle mode adds a new `idle_snapshot` event type on the same channel, received by the same subscriber, and rendered when no active workout sessions are in the session map.
+
+```
+[Supabase Edge Function] [Active Workout Phones]
+ every 60 seconds on state change
+ | |
+ v v
+ idle_snapshot event workout_snapshot event
+ | |
+ +---------------+------------------+
+ |
+ v
+ Supabase Realtime Broadcast
+ channel: "display"
+ |
+ v
+ /display route subscriber
+ |
+ +----------+----------+
+ | |
+ Map is empty Map has entries
+ | |
+ v v
+ Idle View Board / Focused View
+```
+
+The display route manages a `Map` for active sessions (keyed by `user_id`). Idle mode adds a parallel `IdleSnapshot | null` state slot. When the session map is empty, the display renders `IdleView` using the most recent `idle_snapshot` payload (or a bare clock if no payload has arrived yet).
+
+Transition logic lives in a single `useDisplayMode` hook that derives `'idle' | 'board' | 'focused'` from these two pieces of state. CSS class toggling drives the 300ms / 400ms transitions -- no JS animation library.
+
+---
+
+## Key Decisions
+
+### D1: Extend `DisplayEventType` with `idle_snapshot`
+
+**Options considered:**
+- **(a) New Zod enum value `idle_snapshot` added to `display-snapshot.ts`** -- fits the existing discriminated event pattern; the display subscriber already switches on event type
+- **(b) Separate channel (`idle`) for the Edge Function** -- avoids modifying the shared type file but requires the display to manage two channel subscriptions with different cleanup lifecycles
+- **(c) HTTP polling from display route to a new Edge Function endpoint** -- no Broadcast channel complexity, but breaks the "no database queries from the display route" constraint (A-014 from Spec) and adds polling latency
+
+**Chosen:** Option (a)
+**Rationale:** The display already switches on `DisplayEventType`. Adding `idle_snapshot` is a one-line Zod change, consistent with the established pattern. A single channel subscription is simpler to manage and clean up. Options (b) and (c) add complexity for no benefit.
+
+**Change required:** `src/domain/types/display-snapshot.ts` -- add `'idle_snapshot'` to the `displayEventTypeSchema` enum and define `IdleSnapshot` Zod schema alongside `DisplaySnapshot`.
+
+### D2: Edge Function broadcasts via Supabase Realtime HTTP API
+
+**Options considered:**
+- **(a) Supabase Realtime HTTP Broadcast API** -- `POST /realtime/v1/api/broadcast` with a JSON body; no persistent WebSocket; works from any HTTP client including Deno Edge Functions; uses the service_role key as Bearer token
+- **(b) Persistent WebSocket in the Edge Function** -- Edge Function creates a Supabase client, subscribes to channel, sends events over WS. Rejected: Edge Functions are designed for short-lived HTTP request handlers; holding a persistent WS connection is unreliable and wasteful in this execution model
+- **(c) pg_notify / Postgres trigger to push events** -- overly complex; Supabase Broadcast is not directly driven by pg_notify in the standard setup
+
+**Chosen:** Option (a)
+**Rationale:** The Supabase Realtime HTTP broadcast endpoint is the documented, correct way to send Broadcast events from server-side code without a persistent connection. The request shape is:
+
+```
+POST https://.supabase.co/realtime/v1/api/broadcast
+Authorization: Bearer
+Content-Type: application/json
+
+{
+ "messages": [
+ {
+ "topic": "realtime:display",
+ "event": "idle_snapshot",
+ "payload": { ...IdleSnapshot fields... }
+ }
+ ]
+}
+```
+
+The `topic` must be prefixed with `realtime:` for broadcast channel routing. This is well-supported and does not require the Edge Function to maintain state between invocations.
+
+### D3: Edge Function invoked by Supabase Cron (not pg_cron)
+
+**Options considered:**
+- **(a) Supabase Edge Function with cron schedule** -- configured in `supabase/config.toml` under `[functions.display-idle-snapshot.cron]`; universally available on all Supabase plans
+- **(b) pg_cron Postgres extension** -- not available on Supabase free tier; requires Pro plan; rejected per open question resolution
+
+**Chosen:** Option (a)
+**Rationale:** Resolved in Spec open questions. Supabase Edge Function cron is the portable option. Configuration in `supabase/config.toml`:
+
+```toml
+[functions.display-idle-snapshot]
+verify_jwt = false
+
+[functions.display-idle-snapshot.cron]
+schedule = "*/1 * * * *" # every 60 seconds
+```
+
+### D4: "Today's sessions" derived by joining program hierarchy, not a dedicated column
+
+**Context:** `scheduled_sessions` has `day_of_week INTEGER (0-6)` matching JavaScript's `Date.getDay()`, but no `scheduled_time` or `completed` flag. There is no pre-computed "today's session for user X" view.
+
+**Options considered:**
+- **(a) Raw SQL JOIN traversal in Edge Function** -- walk `program_activations → programs → blocks → block_weeks → scheduled_sessions → session_templates` with a WHERE on `day_of_week = EXTRACT(DOW FROM NOW())` and a LEFT JOIN on `workout_logs` to check completion. Verbose but correct; runs in a single query with the service_role client.
+- **(b) Create a Postgres VIEW or function** -- encapsulate the join; simpler Edge Function code. Added migration complexity for a view that has only one consumer.
+- **(c) Supabase RPC function** -- same encapsulation benefit as (b), callable via `.rpc()` from the Supabase JS client.
+
+**Chosen:** Option (c) -- Supabase RPC function
+**Rationale:** The query was extracted to a `get_display_idle_sessions` Postgres function callable via `.rpc()`. This encapsulates the multi-table JOIN in a migration-managed SQL function, keeping the Edge Function code simple. The Edge Function calls `supabase.rpc('get_display_idle_sessions')` rather than embedding raw SQL.
+
+**Query sketch:**
+```sql
+SELECT
+ up.display_name,
+ st.name AS session_name,
+ ss.session_type,
+ ss.day_label
+FROM program_activations pa
+JOIN programs p ON p.id = pa.program_id
+JOIN blocks b ON b.program_id = p.id
+ AND b.ordinal = pa.current_block_ordinal
+JOIN block_weeks bw ON bw.block_id = b.id
+ AND bw.week_number = pa.current_week_number
+JOIN scheduled_sessions ss ON ss.block_week_id = bw.id
+ AND ss.day_of_week = EXTRACT(DOW FROM NOW())
+JOIN session_templates st ON st.id = ss.session_template_id
+JOIN user_profiles up ON up.id = pa.user_id
+LEFT JOIN workout_logs wl ON wl.session_template_id = ss.session_template_id
+ AND wl.user_id = pa.user_id
+ AND DATE(wl.started_at) = CURRENT_DATE
+ AND wl.completed_at IS NOT NULL
+WHERE up.display_visible = true
+ AND wl.id IS NULL -- not yet completed today
+```
+
+The Edge Function runs this with a service_role client (bypasses RLS), maps rows to `IdleSnapshot.scheduled_sessions`, and determines `next_session` as the first row (ordered by `pa.user_id` or a stable tiebreak -- no time-of-day ordering is possible since `scheduled_sessions` has no time column; order is display_name alphabetical).
+
+**Note on `next_session`:** Because there is no `scheduled_time` column, `starts_in_seconds` in the `next_session` object cannot be derived from the database. Options: (i) omit `starts_in_seconds` from the payload and derive it from the typical training time stored in user preferences (not implemented); (ii) include `starts_in_seconds: null` in the payload and display "NEXT UP" without a countdown; (iii) omit `next_session.starts_in_seconds` entirely. **Decision: omit `starts_in_seconds`** -- the countdown badge in the spec (`NEXT UP IN X:XX`) is not achievable without a scheduled time. The badge will render as `NEXT UP: ` without a countdown. This aligns with M-8 (no scheduled_time exists) and does not affect A-005 (badge renders), just changes what it says.
+
+### D5: Clock drift prevention via server_time correction
+
+The display runs `setInterval(() => setClock(new Date()), 1000)` -- standard one-second tick. Over hours of continuous operation, cumulative `setInterval` drift can amount to seconds per hour. The `idle_snapshot` payload includes `server_time` (ISO UTC timestamp from `now()` in the Edge Function).
+
+**Options considered:**
+- **(a) Replace local clock with `server_time` on each idle_snapshot** -- snaps the display clock to server time every 60 seconds; eliminates drift
+- **(b) Use `server_time` as a correction offset** -- compute `localOffset = Date.now() - serverTime`, apply on each tick
+- **(c) Ignore drift** -- acceptable for a gym TV where seconds of drift are imperceptible
+
+**Chosen:** Option (a)
+**Rationale:** The simplest, most correct approach. Every 60 seconds when the idle_snapshot arrives, reset the clock state to the server-provided timestamp. The 60-second correction window means drift never exceeds a few hundred milliseconds in practice. No offset math needed.
+
+### D6: Transitions via CSS class toggling, not Framer Motion
+
+**Context:** The existing codebase has no JS animation library. Animations use `tw-animate-css` (Tailwind-compatible keyframe classes) and custom keyframes defined in `src/index.css` (`rise`, `forge-draw`, `ember-pulse`, `block-enter`).
+
+**Chosen:** CSS transitions + `tw-animate-css` only
+**Rationale:** Adding Framer Motion for four transitions is not justified. The transitions required (fade + scale) are achievable with:
+- `animate-in fade-in-0 duration-300` / `animate-out fade-out-0 duration-300` for idle-board cross-fades
+- `animate-in zoom-in-95 duration-400` for focused expand
+- Conditional `key` prop on view containers to trigger re-mount animations
+
+Implementation: a `DisplayModeTransition` wrapper component that applies the correct `animate-in` / `animate-out` classes based on the previous and current mode. The `useDisplayMode` hook tracks both current and previous mode to drive transition direction.
+
+### D7: URL search param for clock format
+
+TanStack Router provides `validateSearch` on route definitions. The `/display` route will validate `{ clock?: '12h' | '24h' }` with a Zod schema and default to `'24h'`.
+
+```ts
+// src/routes/display.tsx
+export const Route = createFileRoute('/display')({
+ validateSearch: z.object({
+ clock: z.enum(['12h', '24h']).optional().default('24h'),
+ }),
+ component: DisplayPage,
+})
+```
+
+The display page reads `Route.useSearch().clock` to format the clock string.
+
+### D8: Display route bypasses config and auth guards
+
+The `__root.tsx` `beforeLoad` currently whitelists `/setup` and `/s/` prefix routes from the config check. The `/display` route must be added to this whitelist. It also must not redirect to sign-in if the user is unauthenticated -- the display is intentionally a public, no-auth page.
+
+**Change:** Add `|| location.pathname.startsWith('/display')` to the `beforeLoad` guard in `__root.tsx`.
+
+---
+
+## Stack-Specific Details
+
+### React/TypeScript
+
+**Files to create:**
+
+| File | Purpose |
+|------|---------|
+| `src/routes/display.tsx` | Display route shell -- full-viewport, no auth, validates `clock` search param |
+| `src/components/display/idle-view.tsx` | Clock, date, session list, countdown badge |
+| `src/components/display/use-display-mode.ts` | Hook: derives `'idle' \| 'board' \| 'focused'` from session map + focus state |
+| `src/components/display/use-idle-snapshot.ts` | Hook: subscribes to `idle_snapshot` events, returns `IdleSnapshot \| null` |
+| `src/components/display/display-mode-transition.tsx` | Wrapper applying correct tw-animate-css classes for each mode transition |
+| `src/components/display/clock-display.tsx` | Large clock component, format from `clock` search param, server_time correction |
+
+**Files to modify:**
+
+| File | Change |
+|------|--------|
+| `src/domain/types/display-snapshot.ts` | Add `idle_snapshot` to `DisplayEventType` enum; add `IdleSnapshot` Zod schema and TypeScript type |
+| `src/domain/types/index.ts` | Re-export `IdleSnapshot` |
+| `src/routes/__root.tsx` | Whitelist `/display` in `beforeLoad` config check |
+
+**Patterns to follow:**
+- Route definition: follow `src/routes/s/$token.tsx` for a public no-auth route outside `_authenticated/`
+- Hook naming: `use-[name].ts` files per `.claude/rules/react-typescript.md`
+- Search param validation: Zod schema in `validateSearch` (TanStack Router v1 pattern)
+- `useEffect` cleanup: return cleanup function from all subscriptions per rules
+
+### Supabase
+
+**Files to create:**
+
+| File | Purpose |
+|------|---------|
+| `supabase/functions/display-idle-snapshot/index.ts` | Edge Function: query today's sessions, broadcast `idle_snapshot` |
+
+**Files to modify:**
+
+| File | Change |
+|------|--------|
+| `supabase/config.toml` | Add `[functions.display-idle-snapshot]` and `[functions.display-idle-snapshot.cron]` stanzas |
+
+**Patterns to follow:**
+- Deno runtime, ESM imports from `esm.sh` (match existing chat-media functions exactly)
+- `Deno.serve(handler)` entry point
+- CORS boilerplate (OPTIONS preflight) even though this function is cron-invoked (required for local testing via `supabase functions serve`)
+- `createClient(url, serviceRoleKey)` -- no user auth header; this is a server-to-server call
+- `Deno.env.get("SUPABASE_URL")` and `Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")`
+- Error responses use `{ error: string }` JSON body with appropriate HTTP status
+
+**No new migrations needed.** The `display_visible` column on `user_profiles` was added in Feature 008. No schema changes are required for idle mode.
+
+---
+
+## Integration Points
+
+### `IdleSnapshot` schema contract
+
+The Edge Function and the display subscriber must agree on the `idle_snapshot` payload shape. This is defined once in `src/domain/types/display-snapshot.ts` and imported by both the subscriber and (conceptually) the Edge Function. The Edge Function does not import from `src/` -- it defines the shape inline as a Deno-compatible Zod schema that must be kept in sync with the canonical definition.
+
+```ts
+// Canonical definition in src/domain/types/display-snapshot.ts
+export const idleSnapshotSchema = z.object({
+ server_time: isoDateTime,
+ scheduled_sessions: z.array(z.object({
+ display_name: z.string(),
+ session_name: z.string(),
+ session_type: sessionTypeSchema,
+ day_label: z.string(),
+ })),
+ next_session: z.object({
+ display_name: z.string(),
+ session_name: z.string(),
+ }).nullable(),
+})
+
+export type IdleSnapshot = z.infer
+```
+
+### Display subscriber event routing
+
+The `useIdleSnapshot` hook subscribes to the `display` channel (same channel as the existing display subscriber in Step 28). It must filter for `event === 'idle_snapshot'` and validate with `idleSnapshotSchema.safeParse`. The Step 28 subscriber filters for `workout_snapshot`, `session_ended`, `focus`, and `unfocus`. These hooks can share a single channel subscription or create independent subscriptions to the same channel -- Supabase Realtime allows multiple subscribers to the same channel without conflict.
+
+**Decision: one channel, separate event filtering per hook.** The Step 28 subscriber hook handles workout-related events. The new `useIdleSnapshot` hook handles `idle_snapshot` events only. Both subscribe to `channel('display')` independently. Supabase's client-side channel management coalesces multiple subscriptions to the same channel name into one WebSocket message stream.
+
+### Broadcast HTTP API call from Edge Function
+
+```ts
+// In display-idle-snapshot/index.ts
+const broadcastUrl = `${Deno.env.get("SUPABASE_URL")}/realtime/v1/api/broadcast`
+const response = await fetch(broadcastUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`,
+ "apikey": Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
+ },
+ body: JSON.stringify({
+ messages: [{
+ topic: "realtime:display",
+ event: "idle_snapshot",
+ payload: idleSnapshotPayload,
+ }]
+ }),
+})
+```
+
+---
+
+## Risks and Unknowns
+
+- **Risk: Supabase Realtime HTTP broadcast topic format** -- The topic prefix (`realtime:display` vs `display`) is underdocumented. If the wrong format is used, the display subscriber will not receive the event despite the broadcast succeeding.
+ - **Mitigation:** Verify format against Supabase Realtime source / docs before implementation. Test with `supabase functions serve` locally against a running `supabase start` instance. Fallback: inspect the channel subscription topic string in the client-side publisher to confirm the expected format.
+
+- **Risk: Edge Function cron not triggering on self-hosted Docker** -- Supabase self-hosted instances using the Docker Compose stack (Step 20) may require additional configuration to enable the Edge Function runtime and cron invocation.
+ - **Mitigation:** Document in `docs/self-hosting.md` that the Edge Function runtime container must be included and the cron config must be applied. This is a deployment concern, not a code concern.
+
+- **Risk: `next_session` has no time-of-day data** -- No `scheduled_time` column means no countdown timer. The spec's "NEXT UP IN X:XX" badge is not achievable as specified.
+ - **Mitigation:** Implemented in D4 above -- badge renders as "NEXT UP: [session name]" without countdown. Testable assertion A-005 passes (badge renders with ember accent), but the countdown format changes. Update A-005 wording if needed during implementation.
+
+- **Unknown: Multiple Supabase channel subscriptions to the same channel name** -- Whether the Supabase JS client merges or duplicates subscriptions when `client.channel('display')` is called twice from different hooks.
+ - **Resolution plan:** Test locally during implementation. If subscriptions duplicate (each hook receives each event twice), refactor to a single shared channel subscription managed at the display route level, passed down as context or via a module-scope singleton matching the publisher pattern.
+
+---
+
+## Testing Strategy
+
+### Unit Tests
+
+| Test | What |
+|------|------|
+| `IdleSnapshot` Zod schema | Valid/invalid payloads, `next_session: null` case |
+| `ClockDisplay` component | 12h/24h formatting, server_time correction resets internal state |
+| `useDisplayMode` hook | Derives correct mode from session map + focus state combinations |
+| `IdleView` rendering | Sessions list renders rows; countdown badge hidden when `next_session` is null |
+
+### Integration / Manual Tests
+
+| Test | How |
+|------|-----|
+| Edge Function local run | `supabase functions serve display-idle-snapshot`, invoke via curl, verify broadcast event appears in Supabase Realtime dashboard |
+| End-to-end idle-to-board | Open `/display`, wait for idle state, start a workout on another tab/device, verify board view animates in |
+| Clock drift | Leave `/display` open for 60+ minutes, verify clock stays within 1 second of system time |
+| 12h URL param | Navigate to `/display?clock=12h`, verify clock renders in 12-hour format |
+
+---
+
+## ADR Candidates
+
+No new ADRs required. The key decisions (D1-D8) are implementation-level and follow established patterns. The architectural decision to use Broadcast for the display is already captured in `13-prd-remote-display.md`. The decision to use Supabase Edge Function cron over pg_cron was resolved as a spec open question (not architecturally novel -- follows the same pattern as `chat-retention-cleanup` from Step 26).
diff --git a/Context/Features/010-Progressive-Disclosure/Spec.md b/Context/Features/010-Progressive-Disclosure/Spec.md
new file mode 100644
index 00000000..4aabd70b
--- /dev/null
+++ b/Context/Features/010-Progressive-Disclosure/Spec.md
@@ -0,0 +1,169 @@
+# Spec: Builder Progressive Disclosure & Contextual Help
+
+**Feature:** 010-Progressive-Disclosure
+**Step:** 14.5
+**Priority:** P1
+**Status:** Draft
+**Dependencies:** Step 10 (session templates + SetScheme editor), Step 12 (program builder DnD UI), Step 1.5 (Iron & Ember design system)
+**Estimated effort:** 2.5 days
+**Can parallel with:** Steps 14-18
+
+---
+
+## Overview
+
+Add progressive disclosure and contextual help to the template builder and program builder. Fields that are irrelevant to the selected context (session category, group type, set scheme type) are hidden behind expandable rows. Domain-specific terminology is explained via inline help triggers at the point of need.
+
+## Problem Statement
+
+The template builder renders all configuration options simultaneously regardless of context. A user creating a simple strength session sees Scoring dropdowns, Time Cap fields, Cardio set schemes, and Metcon options -- none of which apply. The program builder uses TB-specific vocabulary (Accumulation, Intensification, Realization) with no inline explanation. Both builders are overwhelming even to the app's developer, presenting a flat wall of options where a guided, context-aware flow would serve better.
+
+**Current state:**
+
+- `SessionTemplateForm` renders Scoring, Time Cap, and Rest Between Groups unconditionally for all categories
+- `ActivityGroupEditor` immediately expands all fields (type selector, rest/rounds, exercises, set scheme editor) on creation
+- `SetSchemeEditor` shows all 12 types across 4 categories regardless of session category
+- `LoadSpecEditor` shows all 7 load options regardless of set scheme type (only filtering is `exerciseSupports1RM`)
+- Block type selector (`ToggleGroup`) and source selector have no explanatory text
+- No help/tooltip/popover component exists in the UI library
+
+## User Stories
+
+1. **As a user creating a Strength session**, I want to see only strength-relevant fields (no Scoring, no Time Cap) so I can focus on what matters for my workout.
+
+2. **As a power user switching categories**, I want my previously entered values preserved when fields become hidden, so I don't lose work if I change my mind.
+
+3. **As a user adding an activity group**, I want to first choose the group type before seeing configuration fields, so I'm not overwhelmed by options that depend on that choice.
+
+4. **As a user selecting a set scheme**, I want to see only the types relevant to my session category by default, with the option to expand to all 12 if needed.
+
+5. **As a new user in the program builder**, I want to understand what Accumulation, Intensification, and Realization mean without leaving the builder or consulting external documentation.
+
+6. **As a user exploring program sources**, I want a brief explanation of each source type so I know which one applies to my situation.
+
+## Requirements
+
+### Must Have (P0)
+
+**R1. HelpTrigger component**
+
+- Reusable component with `help_outline` Material Symbol icon (20px, `text-warm-ash`, `text-ember` on hover)
+- Opens Popover on desktop (>=768px), Drawer (shadcn/vaul) on mobile (<768px)
+- Props: `title` (string), `content` (ReactNode), `placement` (`'inline'` | `'section'`)
+- Popover: `bg-surface-gunmetal`, max-width 360px, `rounded-none`
+- Drawer: full-width, `bg-surface-gunmetal`, heat-blur backdrop overlay, swipe-to-dismiss via vaul
+- Dismiss: tap outside, tap icon again, swipe down (drawer)
+
+**R2. Template builder: category-aware field visibility**
+
+- When category is Strength: hide Scoring and Time Cap behind a collapsed expandable row
+- When category is SE: hide Scoring behind a collapsed expandable row
+- When category is Conditioning or Mixed: show all fields
+- Collapsed row: `bg-surface-charcoal`, `text-warm-ash` label listing hidden field names, `expand_more`/`expand_less` chevron
+- Category change preserves values in hidden fields (never cleared automatically)
+- Rest Between Groups always visible for all categories
+
+**R3. Activity group: staged reveal**
+
+- Stage 1: New group shows only group type selector + HelpTrigger for group types
+- Stage 2: After type selection, group expands to show type-relevant rest fields + exercise selector
+- Group-level field visibility by type:
+ - Rest / Rounds: only Circuit
+ - Rest / Exercises: Straight, Superset, Circuit, Couplet
+ - Rounds (count): only Circuit, Couplet
+- Type change resets group-level rest fields but preserves exercises
+
+**R4. Set scheme editor: category-filtered type list**
+
+- Strength sessions default to: FixedSets, PercentageSets, WorkToMax
+- Conditioning sessions default to: CardioSteadyState, CardioInterval, RuckMarch, EMOM, AMRAPTimed, DescendingReps
+- SE sessions default to: ForReps, TimedHold, PercentageOfMaxReps
+- Mixed sessions: all 12 (no filtering)
+- "Show all types" ghost button reveals full list; categories with no visible types are omitted
+- "Show all types" state persists as local React state on the template builder (not Zustand/localStorage); resets when builder closes and reopens
+- Category grouping headers retained when types are shown
+
+**R5. Load type: context-filtered options**
+
+- Filter load options by set scheme type per the visibility table in the feature brief
+- Hide load section entirely for: PercentageSets, PercentageOfMaxReps, CardioSteadyState, CardioInterval, RuckMarch
+
+**R6. Program builder: block type inline help**
+
+- HelpTrigger next to block type section label with explanations for all 5 types
+- One-line summary below type pills on selection in `text-warm-ash` `font-body` `text-xs`
+
+**R7. Program builder: source badge help**
+
+- HelpTrigger next to source section label with explanations for all 7 source types
+
+### Should Have (P1)
+
+**R8. Typography utility classes**
+
+- Add `text-label-large` and `body-small` utility classes to `src/index.css` mapping to the DESIGN.md type scale (Space Grotesk / Inter with appropriate sizes)
+
+**R9. Popover component**
+
+- Install/add shadcn Popover component (`@radix-ui/react-popover`) to `src/components/ui/popover.tsx`
+- Style with Iron & Ember tokens (bg-surface-gunmetal, rounded-none)
+
+### Won't Have (this step)
+
+- Tutorial flows or onboarding wizards
+- Tooltips on every field label (only on domain-specific terminology)
+- Animated transitions between visibility states (simple show/hide is sufficient)
+- Keyboard-driven help navigation
+- Persistence of "Show all types" toggle state across builder close/reopen (persists within session only)
+- Help content CMS or i18n (static strings in component files)
+
+## Testable Assertions
+
+| ID | Assertion | Requirement |
+| ----- | --------------------------------------------------------------------------------------- | ----------- |
+| TA-1 | Selecting Strength category collapses Scoring and Time Cap into a single expandable row | R2 |
+| TA-2 | Expanding the collapsed row reveals Scoring and Time Cap fields with preserved values | R2 |
+| TA-3 | Switching from Strength to Conditioning shows Scoring and Time Cap without data loss | R2 |
+| TA-4 | Tapping "+ Add group" shows only the group type selector and HelpTrigger | R3 |
+| TA-5 | Selecting Circuit group type reveals Rounds, Rest / Rounds, and Rest / Exercises fields | R3 |
+| TA-6 | Selecting Complex group type shows no rest fields (only exercise selector) | R3 |
+| TA-7 | Changing group type from Circuit to Straight preserves exercises but resets rest fields | R3 |
+| TA-8 | Strength session shows only FixedSets, PercentageSets, WorkToMax in set scheme selector | R4 |
+| TA-9 | Tapping "Show all types" reveals all 12 types with category headers | R4 |
+| TA-10 | Cardio category headers are omitted when filtering for Strength | R4 |
+| TA-11 | Selecting PercentageSets hides the load section entirely | R5 |
+| TA-12 | Selecting FixedSets shows Weight, RPE, BW, BW+, None load options | R5 |
+| TA-13 | WorkToMax shows only Weight, RPE, None load options | R5 |
+| TA-14 | HelpTrigger opens popover on desktop (>=768px viewport) | R1 |
+| TA-15 | HelpTrigger opens bottom sheet on mobile (<768px viewport) | R1 |
+| TA-16 | Block type HelpTrigger shows correct explanation for Accumulation | R6 |
+| TA-17 | Selecting Intensification block type shows one-liner below pills | R6 |
+| TA-18 | Source HelpTrigger shows correct explanation for each source type | R7 |
+| TA-19 | Existing template save/load/edit flows work unchanged | R2-R5 |
+| TA-20 | All new UI follows Iron & Ember spec (0px radius, correct surfaces, correct fonts) | R1-R7 |
+
+## Resolved Questions
+
+1. **EVENT category handling** -- Safe to ignore. EVENT sessions use a completely different UI path (event metadata, requirements, packing lists) with none of the activity group / set scheme controls that 14.5b-14.5e filter. EVENT is not shown in the template builder's category selector. No changes needed.
+
+2. **"Show all types" persistence** -- Yes, persist within the editing session, reset on close. Store as local React state on the template builder component (not Zustand, not localStorage). User "graduates" past the guardrail for the current editing session; resets when they close and reopen.
+
+3. **Mobile bottom sheet library** -- Use shadcn's Drawer component (wraps `vaul` internally). Provides swipe-to-dismiss, snap points, and visual handle out of the box. HelpTrigger conditionally renders Drawer on mobile (<768px) and Popover on desktop (>=768px). Install via `bunx --bun shadcn@latest add drawer` if not present.
+
+## Dependencies
+
+### Upstream (required before this step)
+
+- Step 10: Session templates + SetScheme editor (complete)
+- Step 12: Program builder DnD UI (complete)
+- Step 1.5: Iron & Ember design system (complete)
+
+### Downstream (enabled by this step)
+
+- Steps 14-18: Can proceed in parallel
+- Future onboarding flows can build on the HelpTrigger component
+
+### New Dependencies Introduced
+
+- `@radix-ui/react-popover` -- Popover primitive for HelpTrigger on desktop (via shadcn)
+- `vaul` -- Drawer primitive for HelpTrigger on mobile (via shadcn Drawer component)
diff --git a/Context/Features/010-Progressive-Disclosure/Steps.md b/Context/Features/010-Progressive-Disclosure/Steps.md
new file mode 100644
index 00000000..c8a3b6ca
--- /dev/null
+++ b/Context/Features/010-Progressive-Disclosure/Steps.md
@@ -0,0 +1,473 @@
+# Steps: Builder Progressive Disclosure & Contextual Help
+
+**Feature:** 010-Progressive-Disclosure
+**Step:** 14.5
+**Spec:** [Spec.md](./Spec.md)
+**Tech:** [Tech.md](./Tech.md)
+
+---
+
+## Team Composition
+
+| Role | Specialist | Stacks |
+| -------- | ------------------- | ----------------------------------- |
+| Frontend | frontend-specialist | React, TypeScript, Tailwind, shadcn |
+| Quality | quality-engineer | Testing, validation |
+
+Single-domain feature (frontend only). All tasks are independent enough for hub-and-spoke `/impl` execution. No cross-domain coordination required.
+
+---
+
+## Implementation Steps
+
+### S001: Session picker "Create new event" button
+
+**Agent:** frontend-specialist
+**Files:** `src/components/program-builder/session-picker-sheet.tsx`
+**Blocked by:** none
+**Parallel:** yes (independent of all other steps)
+
+Add a "Create new event" action alongside the existing "Create new template" button in `SessionPickerSheet`.
+
+**Implementation:**
+
+1. Add `showCreateEvent` boolean state alongside existing `showCreate`
+2. Import `EventTemplateForm` from `@/components/event-builder/event-template-form`
+3. Below the existing "Create new template" ``, add a second button: "Create new event" with the `flag` icon (matching the event visual treatment in `TemplateButton`)
+4. When `showCreateEvent === true`, render ` ` in place of the list view (same pattern as `showCreate` toggles to `SessionTemplateForm`)
+5. `handleEventCreated` callback: receives the new `SessionTemplate`, calls `onSelect(template.id, template.name, template.category)` to auto-assign to the day slot, then closes the sheet and resets state (same as `handleCreated`)
+6. `handleCancelCreateEvent`: sets `showCreateEvent` back to `false`
+7. When either `showCreate` or `showCreateEvent` is true, the other creation form and the list view are hidden
+
+**Acceptance:**
+
+- [ ] "Create new event" button visible in session picker alongside "Create new template"
+- [ ] Tapping it opens the EventTemplateForm inline
+- [ ] Saving auto-assigns the new event template to the target day slot
+- [ ] Canceling returns to the template list
+- [ ] Existing "Create new template" flow unchanged
+
+---
+
+### S002: Install shadcn Popover and Drawer primitives
+
+**Agent:** frontend-specialist
+**Files:** `src/components/ui/popover.tsx` (new), `src/components/ui/drawer.tsx` (new)
+**Blocked by:** none
+**Parallel:** yes
+
+Run `bunx --bun shadcn@latest add popover drawer` to install both primitives. After install, verify both files exist and update styling to match Iron & Ember:
+
+1. In `popover.tsx`: ensure `PopoverContent` uses `bg-surface-gunmetal rounded-none border-ghost-line/15`
+2. In `drawer.tsx`: ensure `DrawerContent` uses `bg-surface-gunmetal rounded-none`
+
+**Acceptance:**
+
+- [ ] `src/components/ui/popover.tsx` exists with Iron & Ember styling
+- [ ] `src/components/ui/drawer.tsx` exists with Iron & Ember styling
+- [ ] No regressions in existing UI
+
+---
+
+### S003: Create `useMediaQuery` hook
+
+**Agent:** frontend-specialist
+**Files:** `src/hooks/use-media-query.ts` (new)
+**Blocked by:** none
+**Parallel:** yes
+
+Create a lightweight hook using `window.matchMedia`:
+
+```typescript
+export function useMediaQuery(query: string): boolean
+```
+
+- Returns `true` when the query matches
+- Handles SSR (default `false`)
+- Cleans up listener on unmount
+- Used by HelpTrigger for the 768px desktop/mobile breakpoint
+
+**Acceptance:**
+
+- [ ] Hook returns correct boolean for viewport-based media queries
+- [ ] Listener cleaned up on unmount
+
+---
+
+### S004: Create `HelpTrigger` component
+
+**Agent:** frontend-specialist
+**Files:** `src/components/ui/help-trigger.tsx` (new)
+**Blocked by:** S002, S003
+**Parallel:** no
+
+Build the shared responsive help component per R1:
+
+1. Props: `title` (string), `content` (ReactNode), `placement` (`'inline' | 'section'`)
+2. Uses `useMediaQuery('(min-width: 768px)')` to branch rendering
+3. Desktop (>=768px): Radix `Popover` with `PopoverTrigger` (the icon button) and `PopoverContent` styled per spec
+4. Mobile (<768px): `Drawer` with `DrawerTrigger` (the icon button) and `DrawerContent` styled per spec
+5. Trigger: ` ` with `text-warm-ash hover:text-ember transition-colors`
+6. Content layout: `title` in `font-heading text-sm font-medium text-bone-white`, `content` body in `font-body text-xs text-warm-ash`
+7. Popover: `max-w-[360px]`, `bg-surface-gunmetal`, `p-4`
+8. Drawer: full-width, `bg-surface-gunmetal`, `p-4 pb-6`, visual handle via vaul default
+
+**Acceptance:**
+
+- [ ] TA-14: Opens popover on desktop (>=768px)
+- [ ] TA-15: Opens drawer on mobile (<768px)
+- [ ] Icon uses correct size, color, and hover state
+- [ ] Dismiss works via tap outside, tap icon again, swipe down (drawer)
+
+---
+
+### S005: Create help content constants
+
+**Agent:** frontend-specialist
+**Files:** `src/components/builders/help-content.ts` (new)
+**Blocked by:** none
+**Parallel:** yes
+
+Create the centralized help text file with typed constants:
+
+1. `GROUP_TYPE_HELP` -- Record
+ - Straight: "One exercise at a time. Complete all sets before moving on."
+ - Superset: "Two exercises alternated set-for-set with minimal rest."
+ - Circuit: "Three or more exercises performed back-to-back as rounds."
+ - Complex: "Multiple barbell movements performed without releasing the bar."
+ - EMOM: "Fixed work every minute on the minute for a set duration."
+ - AMRAP: "As many rounds as possible within a time cap."
+ - Couplet: "Two movements alternated for rounds, typically for time."
+
+2. `BLOCK_TYPE_HELP` -- Record
+ - Accumulation: desc per spec, oneLiner: "High volume, moderate intensity -- builds work capacity."
+ - Intensification: oneLiner: "Moderate volume, high intensity -- shifts toward heavier loads."
+ - Realization: oneLiner: "Low volume, peak intensity -- tests or expresses strength gains."
+ - Deload: oneLiner: "Reduced volume and intensity -- planned recovery."
+ - Test: oneLiner: "Maximal testing -- 1RM attempts or benchmark assessments."
+
+3. `SOURCE_HELP` -- Record
+ - All 7 sources with descriptions per spec
+
+**Rules:** No emoji, no exclamation marks, industrial vocabulary throughout.
+
+**Acceptance:**
+
+- [ ] All GroupType, BlockType, and ProgramSource values have entries
+- [ ] Text follows industrial vocabulary guidelines
+- [ ] Types are correctly keyed to domain enums
+
+---
+
+### S006: Create visibility mapping constants
+
+**Agent:** frontend-specialist
+**Files:** `src/components/builders/visibility-maps.ts` (new)
+**Blocked by:** none
+**Parallel:** yes
+
+Create the data-driven visibility lookup tables per Tech.md D2-D5:
+
+1. `CATEGORY_FIELD_VISIBILITY` -- maps SessionType to `{ scoring: boolean; timeCap: boolean }`
+2. `CATEGORY_SCHEME_TYPES` -- maps SessionType to default-visible `SetScheme['type'][]`
+3. `SCHEME_LOAD_VISIBILITY` -- maps `SetScheme['type']` to `LoadSpec['type'][] | null`
+4. `GROUP_FIELD_VISIBILITY` -- maps GroupType to `{ restBetweenRounds: boolean; restBetweenActivities: boolean; rounds: boolean }`
+
+All constants are typed against domain enums and exported for use by builder components.
+
+**Acceptance:**
+
+- [ ] All mappings match the visibility tables in the feature brief
+- [ ] TypeScript enforces exhaustive coverage of all enum values
+- [ ] Constants are independently importable
+
+---
+
+### S007: Template builder category-aware field visibility
+
+**Agent:** frontend-specialist
+**Files:** `src/components/session-builder/session-template-form.tsx`, `src/components/session-builder/collapsed-fields-row.tsx` (new)
+**Blocked by:** S006
+**Parallel:** no
+
+Implement R2: conditional field visibility based on session category.
+
+**Implementation:**
+
+1. Import `CATEGORY_FIELD_VISIBILITY` from `builders/visibility-maps`
+2. Derive `{ scoring: showScoring, timeCap: showTimeCap }` from `CATEGORY_FIELD_VISIBILITY[category]`
+3. When `showScoring === false && showTimeCap === false`: replace both fields with a `CollapsedFieldsRow` listing "Scoring, Time Cap"
+4. When `showScoring === false` only: replace Scoring with `CollapsedFieldsRow` listing "Scoring"
+5. `CollapsedFieldsRow` component:
+ - Props: `labels: string[]`, `children: ReactNode` (the hidden fields), `defaultExpanded?: boolean`
+ - Collapsed: `bg-surface-charcoal px-3 py-2`, label in `text-warm-ash font-body text-xs uppercase tracking-wider`, `expand_more` icon
+ - Expanded: same header with `expand_less` icon, children rendered below
+6. Category change: values in hidden fields are NOT cleared -- they remain in React state. Only visibility changes.
+7. Rest Between Groups remains always visible (outside the collapsed row)
+
+**Acceptance:**
+
+- [ ] TA-1: Selecting Strength collapses Scoring and Time Cap
+- [ ] TA-2: Expanding reveals fields with preserved values
+- [ ] TA-3: Switching to Conditioning shows fields without data loss
+- [ ] Rest Between Groups visible for all categories
+
+---
+
+### S008: Activity group staged reveal
+
+**Agent:** frontend-specialist
+**Files:** `src/components/session-builder/activity-group-editor.tsx`, `src/components/session-builder/session-template-form.tsx`
+**Blocked by:** S004, S005, S006
+**Parallel:** no
+
+Implement R3: two-stage group creation flow.
+
+**Implementation:**
+
+1. In `SessionTemplateForm.handleAddGroup`: change default `groupType` from `'STRAIGHT_SETS'` to `null`. Remove the default activity creation (empty activities array).
+2. Update `ActivityGroupData` local type: `groupType: GroupType | null`
+3. In `ActivityGroupEditor`: check `groupType === null` for Stage 1
+4. **Stage 1 (type selection):**
+ - Render group type `ToggleGroup` (existing 7 options)
+ - Add `HelpTrigger` next to the group type section with `GROUP_TYPE_HELP` content
+ - No rest fields, no exercises, no "Add exercise" button
+5. **Stage 2 (after type selected):**
+ - Import `GROUP_FIELD_VISIBILITY` from `builders/visibility-maps`
+ - Show rest fields conditionally based on `GROUP_FIELD_VISIBILITY[groupType]`
+ - Show "+ Select exercise" button
+ - Exercise cards render as exercises are added
+6. On type change (Stage 2 to Stage 2): reset `restBetweenRounds`, `restBetweenActivities`, `rounds` to undefined. Preserve exercises.
+7. Replace existing `GROUP_TYPES_WITH_ROUNDS` check with `GROUP_FIELD_VISIBILITY[groupType].rounds`
+
+**Acceptance:**
+
+- [ ] TA-4: "+ Add group" shows only type selector and HelpTrigger
+- [ ] TA-5: Selecting Circuit reveals Rounds, Rest / Rounds, Rest / Exercises
+- [ ] TA-6: Selecting Complex shows no rest fields
+- [ ] TA-7: Changing Circuit to Straight preserves exercises, resets rest
+
+---
+
+### S009: Set scheme editor category-filtered types
+
+**Agent:** frontend-specialist
+**Files:** `src/components/session-builder/set-scheme-editor.tsx`, `src/components/session-builder/session-template-form.tsx`, `src/components/session-builder/activity-group-editor.tsx`, `src/components/session-builder/activity-editor.tsx`
+**Blocked by:** S006
+**Parallel:** yes (independent of S007, S008)
+
+Implement R4: filter set scheme types by session category.
+
+**Implementation:**
+
+1. Thread `sessionCategory: SessionType` prop from `SessionTemplateForm` through `ActivityGroupEditor` > `ActivityEditor` > `SetSchemeEditor`
+2. Thread `showAllTypes: boolean` and `onShowAllTypesChange: (v: boolean) => void` from `SessionTemplateForm` state through the same path
+3. In `SetSchemeEditor`: import `CATEGORY_SCHEME_TYPES` from `builders/visibility-maps`
+4. Derive `defaultTypes = CATEGORY_SCHEME_TYPES[sessionCategory]`
+5. If `defaultTypes.length === 0` (Mixed): show all types, hide "Show all types" button
+6. If `!showAllTypes`: filter `SCHEME_GROUPS` to only include types in `defaultTypes`. Omit entire group headers when no types in that group are visible.
+7. Below the filtered type list: render "Show all types" ghost button (`text-warm-ash font-body text-xs uppercase tracking-wider`) that calls `onShowAllTypesChange(true)`
+8. When `showAllTypes === true`: show all 12 types with category headers, hide the toggle button
+9. On scheme type change: if the newly selected type was in the filtered set, keep `showAllTypes` unchanged. (The toggle only affects which types are visible for selection, not what's already selected.)
+
+**Acceptance:**
+
+- [ ] TA-8: Strength shows only FixedSets, PercentageSets, WorkToMax
+- [ ] TA-9: "Show all types" reveals all 12 with headers
+- [ ] TA-10: Cardio/Metcon headers omitted when filtering for Strength
+- [ ] Mixed sessions show all 12 with no toggle button
+
+---
+
+### S010: Load type context-filtered options
+
+**Agent:** frontend-specialist
+**Files:** `src/components/session-builder/set-scheme-editor.tsx` (LoadSpecEditor section)
+**Blocked by:** S006
+**Parallel:** yes (independent of S007, S008, S009)
+
+Implement R5: filter load options by set scheme type.
+
+**Implementation:**
+
+1. In `LoadSpecEditor`: accept new prop `schemeType: SetScheme['type']`
+2. Import `SCHEME_LOAD_VISIBILITY` from `builders/visibility-maps`
+3. Derive `allowedLoads = SCHEME_LOAD_VISIBILITY[schemeType]`
+4. If `allowedLoads === null`: return `null` (hide entire load section)
+5. If `allowedLoads` is an array: filter `LOAD_TYPES` constant to only include types in `allowedLoads`
+6. Preserve existing `exerciseSupports1RM` filtering (remove `percentageOf1RM` when not supported) -- apply both filters
+7. If the currently selected load type is not in the filtered list after a scheme type change, reset to `'unspecified'`
+8. Pass `schemeType` from the parent render site where `LoadSpecEditor` is invoked (inside each type-specific fields component that uses it)
+
+**Acceptance:**
+
+- [ ] TA-11: PercentageSets hides load section entirely
+- [ ] TA-12: FixedSets shows Weight, RPE, BW, BW+, None
+- [ ] TA-13: WorkToMax shows only Weight, RPE, None
+- [ ] CardioSteadyState, CardioInterval, RuckMarch hide load section
+- [ ] PercentageOfMaxReps hides load section
+
+---
+
+### S011: Program builder block type help
+
+**Agent:** frontend-specialist
+**Files:** `src/components/program-builder/block-editor.tsx`, `src/components/program-builder/mobile-block-editor.tsx`
+**Blocked by:** S004, S005
+**Parallel:** yes (independent of S007-S010)
+
+Implement R6: HelpTrigger and one-liner for block types.
+
+**Implementation:**
+
+1. Import `HelpTrigger` and `BLOCK_TYPE_HELP` into both `block-editor.tsx` and `mobile-block-editor.tsx`
+2. Add ` ` next to the block type section label
+ - Content: render all 5 block types with label + description from `BLOCK_TYPE_HELP`
+3. Below the block type `ToggleGroup`, add a one-liner:
+ - `{BLOCK_TYPE_HELP[block.blockType].oneLiner}
`
+ - Only shown when a block type is selected (always true since default is ACCUMULATION)
+4. Apply to both desktop (`BlockEditor`) and mobile (`MobileBlockEditor`) components
+5. Extract the duplicated `BLOCK_TYPE_STYLES` into `constants.ts` to reduce duplication (opportunistic cleanup since we're already editing both files)
+
+**Acceptance:**
+
+- [ ] TA-16: Block type HelpTrigger shows correct explanation for Accumulation
+- [ ] TA-17: Selecting Intensification shows one-liner below pills
+- [ ] Both desktop and mobile have identical help behavior
+
+---
+
+### S012: Program builder source badge help
+
+**Agent:** frontend-specialist
+**Files:** `src/components/program-builder/program-form.tsx`
+**Blocked by:** S004, S005
+**Parallel:** yes (independent of S007-S011)
+
+Implement R7: HelpTrigger for program source types.
+
+**Implementation:**
+
+1. Import `HelpTrigger` and `SOURCE_HELP` into `program-form.tsx`
+2. Add ` ` next to the Source section label
+3. Content: render all 7 sources with label + description from `SOURCE_HELP`
+4. Mark future sources (Marketplace, AI) with "(Future)" in their descriptions
+
+**Acceptance:**
+
+- [ ] TA-18: Source HelpTrigger shows correct explanation for each source type
+- [ ] Future sources clearly marked
+
+---
+
+### S013: Typography utility classes
+
+**Agent:** frontend-specialist
+**Files:** `src/index.css`
+**Blocked by:** none
+**Parallel:** yes
+
+Implement R8: add formal typography utility classes referenced by DESIGN.md.
+
+**Implementation:**
+
+Add to `src/index.css` alongside existing `.text-readout` and `.text-industrial`:
+
+```css
+.text-label-large {
+ font-family: var(--font-heading);
+ font-size: 0.875rem; /* 14px */
+ line-height: 1.25rem; /* 20px */
+ font-weight: 500;
+ letter-spacing: 0.01em;
+}
+
+.text-body-small {
+ font-family: var(--font-body);
+ font-size: 0.75rem; /* 12px */
+ line-height: 1rem; /* 16px */
+ font-weight: 400;
+}
+```
+
+These map to the MD3 type scale names referenced in DESIGN.md, using the project's actual font families.
+
+**Acceptance:**
+
+- [ ] `.text-label-large` and `.text-body-small` classes are defined and usable
+- [ ] Match DESIGN.md type scale intent
+
+---
+
+### S014-T: Validation pass
+
+**Agent:** quality-engineer
+**Files:** all modified/new files from S001-S013
+**Blocked by:** S001, S004, S007, S008, S009, S010, S011, S012, S013
+**Parallel:** no (runs after all build steps)
+
+Validate the full feature against acceptance criteria:
+
+1. **Functional validation:**
+ - Walk through all 20 testable assertions (TA-1 through TA-20)
+ - Verify each visibility mapping matches the spec tables exactly
+ - Test category switching preserves form values
+ - Test group type changes reset rest fields but preserve exercises
+ - Test "Show all types" persists within editing session
+ - Test event creation from session picker auto-assigns to day slot
+
+2. **Design compliance:**
+ - All new elements use 0px border radius
+ - Correct surface colors (surface-gunmetal for help, surface-charcoal for collapsed rows)
+ - Correct text colors (warm-ash for secondary, ember for hover/accent)
+ - Correct fonts (Space Grotesk for headings, Inter for body)
+ - No emoji or exclamation marks in help text
+
+3. **Regression check:**
+ - Existing template save/load/edit flows work unchanged (TA-19)
+ - Existing program builder DnD works unchanged
+ - Session picker "Create new template" flow unchanged
+ - EVENT templates in library unaffected
+
+4. **Responsive check:**
+ - HelpTrigger renders popover on desktop, drawer on mobile
+ - All builder screens functional at 390px and 2560px widths
+
+**Acceptance:**
+
+- [ ] All 20 testable assertions pass
+- [ ] No design drift from Iron & Ember spec
+- [ ] No regressions in existing flows
+
+---
+
+## Dependency Graph
+
+```
+S001 ─────────────────────────────────────────────────┐
+S002 ──────┐ │
+S003 ──────┤ │
+ ├── S004 ──┬── S008 │
+S005 ──────┤ ├── S011 ─────┐ │
+ │ └── S012 ─────┤ │
+S006 ──────┼── S007 │ │
+ ├── S009 │ │
+ └── S010 ├── S014-T │
+S013 ───────────────────────────────┤ │
+ └─────────────────┘
+```
+
+**Wave 1 (parallel):** S001, S002, S003, S005, S006, S013
+**Wave 2 (parallel, after S002+S003):** S004
+**Wave 3 (parallel, after S004+S005+S006):** S007, S008, S009, S010, S011, S012
+**Wave 4 (sequential, after all):** S014-T
+
+---
+
+## Milestone
+
+**M1: Progressive Disclosure Complete** (after S014-T)
+
+All progressive disclosure and contextual help is implemented across template builder and program builder. The session picker supports inline event creation. All visibility rules are data-driven and all help content uses industrial vocabulary per the Iron & Ember design system.
+
+**Testable assertions covered:** TA-1 through TA-20
diff --git a/Context/Features/010-Progressive-Disclosure/Tech.md b/Context/Features/010-Progressive-Disclosure/Tech.md
new file mode 100644
index 00000000..5464f2c8
--- /dev/null
+++ b/Context/Features/010-Progressive-Disclosure/Tech.md
@@ -0,0 +1,256 @@
+# Tech: Builder Progressive Disclosure & Contextual Help
+
+**Feature:** 010-Progressive-Disclosure
+**Step:** 14.5
+**Spec:** [Spec.md](./Spec.md)
+
+---
+
+## Architecture Overview
+
+This feature modifies existing builder components to add context-aware visibility and a new shared help component. No new routes, stores, or backend changes are required. All changes are frontend-only, scoped to React component rendering logic and a new UI primitive.
+
+### Component Hierarchy (affected components)
+
+```
+SessionTemplateForm -- R2: category-aware field visibility
+ | R4: "showAllTypes" state lifted here
+ +-- [new] CollapsedFieldsRow -- R2: expandable row for hidden fields
+ +-- ActivityGroupEditor -- R3: staged reveal
+ | +-- [new] GroupTypeHelp -- R3: help content for group types
+ | +-- ActivityEditor
+ | +-- SetSchemeEditor -- R4: category-filtered types, R5: filtered loads
+ | +-- LoadSpecEditor -- R5: context-filtered options
+ +-- [new] HelpTrigger -- R1: shared component
+
+BuilderPage / ProgramForm -- context for R6, R7
+ +-- BlockEditor -- R6: block type help
+ +-- MobileBlockEditor -- R6: block type help (mobile)
+ +-- ProgramForm -- R7: source help
+```
+
+### New Components
+
+| Component | Location | Purpose |
+| -------------------- | --------------------------------------------------------- | ----------------------------------------------------------------- |
+| `HelpTrigger` | `src/components/ui/help-trigger.tsx` | Shared responsive help component (Popover desktop, Drawer mobile) |
+| `Popover` | `src/components/ui/popover.tsx` | shadcn Popover primitive (new install) |
+| `Drawer` | `src/components/ui/drawer.tsx` | shadcn Drawer primitive (new install) |
+| `CollapsedFieldsRow` | `src/components/session-builder/collapsed-fields-row.tsx` | Expandable row for hidden template-level fields |
+
+### Modified Components
+
+| Component | File | Changes |
+| --------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
+| `SessionTemplateForm` | `src/components/session-builder/session-template-form.tsx` | Add category-aware visibility logic, lift `showAllTypes` state, render CollapsedFieldsRow |
+| `ActivityGroupEditor` | `src/components/session-builder/activity-group-editor.tsx` | Two-stage reveal, type-dependent field visibility |
+| `SetSchemeEditor` | `src/components/session-builder/set-scheme-editor.tsx` | Accept `sessionCategory` prop, filter SCHEME_GROUPS by category, "Show all types" toggle |
+| `LoadSpecEditor` | (inside `set-scheme-editor.tsx`) | Accept `schemeType` prop, filter LOAD_TYPES, conditionally hide entire section |
+| `BlockEditor` | `src/components/program-builder/block-editor.tsx` | Add HelpTrigger + one-liner below type pills |
+| `MobileBlockEditor` | `src/components/program-builder/mobile-block-editor.tsx` | Same as BlockEditor |
+| `ProgramForm` | `src/components/program-builder/program-form.tsx` | Add HelpTrigger next to source section |
+
+---
+
+## Key Technical Decisions
+
+### D1. HelpTrigger: Popover + Drawer dual rendering
+
+**Decision:** Use `useMediaQuery` (or `window.matchMedia`) to conditionally render shadcn Popover (desktop) or shadcn Drawer (mobile) from a single `HelpTrigger` component.
+
+**Options considered:**
+
+1. Single Popover with responsive positioning -- Rejected: Popovers don't provide swipe-to-dismiss or full-width behavior on mobile
+2. Sheet with side="bottom" -- Rejected: lacks swipe gesture, feels wrong for dismissible help
+3. **Popover (desktop) + Drawer (mobile)** -- Chosen: each primitive is purpose-built for its context
+
+**Implementation:**
+
+```tsx
+function HelpTrigger({ title, content, placement }: HelpTriggerProps) {
+ const isDesktop = useMediaQuery('(min-width: 768px)')
+ const [open, setOpen] = useState(false)
+
+ const trigger = (
+ setOpen(!open)}>
+
+
+ )
+
+ if (isDesktop) {
+ return (
+
+ {/* ... */}
+
+ )
+ }
+ return (
+
+ {/* ... */}
+
+ )
+}
+```
+
+The `useMediaQuery` hook does not exist in the codebase. Options:
+
+- Add a lightweight custom hook (~10 lines) using `window.matchMedia`
+- The shadcn Drawer docs recommend this pattern and provide an example
+
+### D2. Category visibility: data-driven mapping
+
+**Decision:** Define a `CATEGORY_FIELD_VISIBILITY` constant mapping `SessionType` to field visibility flags, rather than inline conditionals.
+
+**Rationale:** The visibility rules are a pure function of category. A lookup table is more readable, testable, and extensible than scattered `if/else` chains.
+
+```typescript
+const CATEGORY_FIELD_VISIBILITY: Record = {
+ STRENGTH: { scoring: false, timeCap: false },
+ CONDITIONING: { scoring: true, timeCap: true },
+ SE: { scoring: false, timeCap: true },
+ MIXED: { scoring: true, timeCap: true },
+ EVENT: { scoring: false, timeCap: false }, // unused but safe
+}
+```
+
+### D3. Set scheme filtering: data-driven mapping
+
+**Decision:** Define a `CATEGORY_SCHEME_TYPES` constant mapping `SessionType` to the default-visible set scheme types.
+
+```typescript
+import type { SetScheme } from '@/domain/types/set-scheme'
+
+const CATEGORY_SCHEME_TYPES: Record = {
+ STRENGTH: ['fixedSets', 'percentageSets', 'workToMax'],
+ CONDITIONING: [
+ 'cardioSteadyState',
+ 'cardioInterval',
+ 'ruckMarch',
+ 'emom',
+ 'amrapTimed',
+ 'descendingReps',
+ ],
+ SE: ['forReps', 'timedHold', 'percentageOfMaxReps'],
+ MIXED: [], // empty = show all
+ EVENT: [],
+}
+```
+
+When `CATEGORY_SCHEME_TYPES[category]` is empty, all types are shown and the "Show all types" button is hidden.
+
+### D4. Load filtering: data-driven mapping
+
+**Decision:** Define a `SCHEME_LOAD_VISIBILITY` constant mapping `SetScheme['type']` to either `null` (load section hidden) or an array of visible `LoadSpec['type']` values.
+
+```typescript
+const SCHEME_LOAD_VISIBILITY: Record = {
+ fixedSets: ['absolute', 'rpe', 'bodyweight', 'bodyweightPlus', 'unspecified'],
+ percentageSets: null, // hidden
+ workToMax: ['absolute', 'rpe', 'unspecified'],
+ forReps: ['absolute', 'bodyweight', 'bodyweightPlus', 'unspecified'],
+ timedHold: ['absolute', 'bodyweight', 'bodyweightPlus', 'unspecified'],
+ percentageOfMaxReps: null, // hidden
+ cardioSteadyState: null, // hidden
+ cardioInterval: null, // hidden
+ ruckMarch: null, // hidden
+ emom: ['absolute', 'rpe', 'bodyweight', 'bodyweightPlus', 'unspecified'],
+ amrapTimed: ['absolute', 'rpe', 'bodyweight', 'bodyweightPlus', 'unspecified'],
+ descendingReps: ['absolute', 'rpe', 'bodyweight', 'bodyweightPlus', 'unspecified'],
+}
+```
+
+### D5. Group field visibility: data-driven mapping
+
+**Decision:** Extend the existing `GROUP_TYPES_WITH_ROUNDS` pattern into a full visibility map.
+
+```typescript
+const GROUP_FIELD_VISIBILITY: Record<
+ GroupType,
+ {
+ restBetweenRounds: boolean
+ restBetweenActivities: boolean
+ rounds: boolean
+ }
+> = {
+ STRAIGHT_SETS: { restBetweenRounds: false, restBetweenActivities: true, rounds: false },
+ SUPERSET: { restBetweenRounds: false, restBetweenActivities: true, rounds: false },
+ CIRCUIT: { restBetweenRounds: true, restBetweenActivities: true, rounds: true },
+ COMPLEX: { restBetweenRounds: false, restBetweenActivities: false, rounds: false },
+ EMOM: { restBetweenRounds: false, restBetweenActivities: false, rounds: false },
+ AMRAP: { restBetweenRounds: false, restBetweenActivities: false, rounds: false },
+ COUPLET: { restBetweenRounds: false, restBetweenActivities: true, rounds: true },
+}
+```
+
+### D6. Staged group reveal: `groupType` starts as `null`
+
+**Decision:** Change the initial group creation to set `groupType: null` instead of defaulting to `STRAIGHT_SETS`. The `ActivityGroupEditor` checks for `null` and renders Stage 1 (type selector only). After selection, it transitions to Stage 2.
+
+**Impact on types:** The `ActivityGroupData` type (local to the form, not the domain schema) needs `groupType: GroupType | null`. The domain `ActivityGroup` Zod schema is unaffected since groups are only saved after type selection.
+
+**Impact on `handleAddGroup`:** No longer creates a default activity. The first activity is added after type selection (Stage 2) or when the user taps "+ Select exercise".
+
+### D7. Help content: co-located static constants
+
+**Decision:** Store help text as static constants in a dedicated file `src/components/builders/help-content.ts` rather than in each component.
+
+**Rationale:** Centralizes all help copy for easy review and editing. These are not i18n strings (per Won't Have), just organized constants.
+
+```typescript
+export const GROUP_TYPE_HELP: Record = { ... }
+export const BLOCK_TYPE_HELP: Record = { ... }
+export const SOURCE_HELP: Record = { ... }
+```
+
+---
+
+## Prop Threading
+
+New props that need to flow through the component tree:
+
+| Prop | From | To | Type |
+| ---------------------- | --------------------------- | ---------------------------------------------------------------- | ---------------------- |
+| `sessionCategory` | `SessionTemplateForm` | `SetSchemeEditor` (via `ActivityGroupEditor` > `ActivityEditor`) | `SessionType` |
+| `showAllTypes` | `SessionTemplateForm` state | `SetSchemeEditor` (via same path) | `boolean` |
+| `onShowAllTypesChange` | `SessionTemplateForm` | `SetSchemeEditor` (via same path) | `(v: boolean) => void` |
+| `schemeType` | `SetSchemeEditor` | `LoadSpecEditor` | `SetScheme['type']` |
+
+Threading `sessionCategory` through 3 levels (Form > GroupEditor > ActivityEditor > SetSchemeEditor) is acceptable for this bounded hierarchy. React Context would be overengineered for a single prop.
+
+---
+
+## Risk Assessment
+
+| Risk | Likelihood | Impact | Mitigation |
+| ---------------------------------------------------------------------------- | ---------- | ------ | ----------------------------------------------------------------------------------------------------- |
+| Set scheme editor (1205 lines) is large; changes could introduce regressions | Medium | High | Unit test the visibility mapping constants independently; manual testing of all 12 scheme types |
+| Staged group reveal changes `handleAddGroup` default behavior | Low | Medium | The change is additive (null check gates Stage 1); existing save validation catches incomplete groups |
+| "Show all types" state threading through 3 levels adds prop drilling | Low | Low | Bounded hierarchy; if it grows, extract to context later |
+| Drawer (vaul) adds a new dependency | Low | Low | It's a standard shadcn component; consistent with existing pattern |
+| Category change + value preservation could cause stale form state | Medium | Medium | Values are preserved in React state; only UI visibility changes. Validation runs on save. |
+
+---
+
+## New Dependencies
+
+| Package | Version | Purpose | Size |
+| ------------------------- | ------- | ----------------------------------------- | ----- |
+| `@radix-ui/react-popover` | latest | Popover primitive for HelpTrigger desktop | ~10KB |
+| `vaul` | latest | Drawer primitive for HelpTrigger mobile | ~8KB |
+
+Both are installed via shadcn CLI (`bunx --bun shadcn@latest add popover drawer`).
+
+---
+
+## Files Changed Summary
+
+| Category | Files | New/Modified |
+| ---------------- | --------------------------------------------------------------------------------- | ------------------------------- |
+| UI primitives | `popover.tsx`, `drawer.tsx`, `help-trigger.tsx` | New |
+| Shared constants | `help-content.ts` | New |
+| Custom hook | `use-media-query.ts` | New |
+| Template builder | `session-template-form.tsx`, `activity-group-editor.tsx`, `set-scheme-editor.tsx` | Modified |
+| Template builder | `collapsed-fields-row.tsx` | New |
+| Program builder | `block-editor.tsx`, `mobile-block-editor.tsx`, `program-form.tsx` | Modified |
+| Styles | `src/index.css` | Modified (typography utilities) |
+| **Total** | **12 files** | 6 new, 6 modified |
diff --git a/Context/Features/010-qr-code-gen-scan/Spec.md b/Context/Features/010-qr-code-gen-scan/Spec.md
new file mode 100644
index 00000000..91bbdc7f
--- /dev/null
+++ b/Context/Features/010-qr-code-gen-scan/Spec.md
@@ -0,0 +1,71 @@
+# Feature 010: QR Code Generation + Scan on Setup
+
+## Phase 1 -- Specification
+
+### Overview
+
+Add QR code-based server sharing to Ardent Forge. A configured user can generate a QR code (and copyable invite link) from Settings that encodes their backend connection details. A new user can scan that QR code (mobile) or paste the invite link (browser/desktop) on the setup screen to auto-configure their app without manually typing Supabase credentials.
+
+### Problem Statement
+
+Onboarding new users to a self-hosted Ardent Forge instance currently requires them to manually enter a server URL or raw Supabase credentials. This is error-prone and unfriendly, especially on mobile devices. There is no way for an existing user to share their server configuration with others.
+
+### User Stories
+
+1. **As a configured user**, I want to display a QR code in Settings so I can show it to someone who needs to connect to my server.
+2. **As a configured user**, I want to copy an invite link so I can send it via chat/email to someone who needs to connect.
+3. **As a new mobile user**, I want to scan a QR code on the setup screen so I can connect without typing URLs.
+4. **As a new browser/desktop user**, I want to paste an invite link on the setup screen so I can connect without typing URLs.
+5. **As a new user**, I want scanned/pasted credentials to auto-validate so I can proceed to account creation immediately.
+
+### Requirements
+
+#### Must Have
+
+- **M1**: Settings Backend section shows "Share this server" area with QR code encoding `ardentforge://connect?url={supabaseUrl}&key={publishableKey}`
+- **M2**: "Copy invite link" button copies the `ardentforge://connect?...` deep link string to clipboard with success toast
+- **M3**: QR code and invite link section only renders when `configStore.hasConfig()` is true
+- **M4**: Setup screen QR scan button opens native camera via `@tauri-apps/plugin-barcode-scanner` on Android
+- **M5**: Barcode scanner uses `windowed: true` mode with dark overlay and viewfinder cutout
+- **M6**: Browser mode replaces camera scan with a "Paste invite link" text input
+- **M7**: Successful scan/paste of a valid `ardentforge://connect` URL extracts `url` and `key` params, pre-populates the manual config fields, expands the Advanced section, and auto-triggers validation
+- **M8**: Invalid QR/link content shows error toast ("Invalid invite link")
+- **M9**: Barcode scanner permissions added to `src-tauri/capabilities/mobile.json`
+- **M10**: Existing setup flow (server URL discovery and manual entry) continues to work unchanged
+
+#### Should Have
+
+- **S1**: Explainer text below QR code: "Share this with anyone who wants to connect to this server. They will still need to create an account."
+- **S2**: QR code renders on `surface-iron` card background, centered, with Iron and Ember styling
+
+#### Will Not Have
+
+- **W1**: Deep link handler for `ardentforge://connect` (Refactor C scope)
+- **W2**: QR code expiration or revocation
+- **W3**: Embedded credentials in QR (only URL + publishable key, which are non-secret)
+
+### Testable Assertions
+
+| ID | Assertion | 验证 Method |
+| ---- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
+| TA-1 | QR code in Settings encodes `ardentforge://connect?url={encoded}&key={encoded}` with current config values | Unit test: render component, decode QR SVG content |
+| TA-2 | Copy button writes correct deep link string to clipboard | Integration test: click button, read clipboard |
+| TA-3 | QR section does not render when config store is empty | Unit test: render with empty config, assert section absent |
+| TA-4 | Tauri renders native scan button; browser renders paste field | Platform-conditional render test (`isTauri()` branch) |
+| TA-5 | Browser paste field parses valid `ardentforge://connect` URL correctly | Unit test: parse function with valid/invalid inputs |
+| TA-6 | Invalid link shows toast with "Invalid invite link" | Unit test: trigger invalid input, assert toast call |
+| TA-7 | Valid scan/paste pre-populates Advanced fields and triggers validation | Integration test: simulate input, assert field values and validation call |
+| TA-8 | Existing setup flows (discovery + manual) still work | Regression: existing test suite passes |
+
+### Open Questions
+
+None -- all resolved:
+
+1. ~~**QR code size**~~: Resolved -- determine optimal dimensions during Phase 2 research.
+2. ~~**iOS plist**~~: Resolved -- iOS is not a supported target. Dropped M11 (NSCameraUsageDescription).
+
+### Dependencies
+
+- `qrcode.react` (new dependency for QR generation)
+- `@tauri-apps/plugin-barcode-scanner` (new dependency for mobile QR scanning)
+- Existing: `sonner` (toasts), `lucide-react` (QrCode icon already imported in setup.tsx), config store, connection validator
diff --git a/Context/Features/010-qr-code-gen-scan/Steps.md b/Context/Features/010-qr-code-gen-scan/Steps.md
new file mode 100644
index 00000000..99dcc54f
--- /dev/null
+++ b/Context/Features/010-qr-code-gen-scan/Steps.md
@@ -0,0 +1,325 @@
+# Feature 010: QR Code Generation + Scan on Setup
+
+## Phase 3 -- Implementation Steps
+
+### Team Composition
+
+| Role | Agent | Stacks |
+| ------------------- | ---------- | -------------------------------------------- |
+| Frontend Specialist | `frontend` | React, TypeScript, Tailwind, TanStack Router |
+| Tauri Specialist | `tauri` | Rust, Tauri plugins, capabilities, Cargo |
+| Quality Engineer | `qa` | Testing, validation, regression |
+
+### Dependency Graph
+
+```
+S001 (deps) ──┐
+S002 (invite) ─┤──► M1: Milestone -- Foundation
+ │
+S003 (tauri) ──┘
+ │
+S004 (settings QR) ─────► M2: Milestone -- QR Generation
+ │
+S005 (setup scan/paste) ─► M3: Milestone -- QR Consumption
+ │
+S006 (tests) ─────────────► M4: Milestone -- Validation
+```
+
+---
+
+### Wave 1: Foundation (parallel)
+
+#### S001: Install frontend dependencies
+
+**Agent:** `frontend`
+**Files:** `package.json`
+**Depends on:** nothing
+**Parallel:** yes (with S002, S003)
+
+1. Run `bun add qrcode.react`
+2. Verify `qrcode.react` appears in `package.json` dependencies
+3. Run `bun install` to confirm clean lockfile
+
+**Done:** `qrcode.react` in dependencies, no lockfile errors.
+
+---
+
+#### S002: Create invite link utility
+
+**Agent:** `frontend`
+**Files:** `src/lib/invite-link.ts` (NEW)
+**Depends on:** nothing
+**Parallel:** yes (with S001, S003)
+
+1. Create `src/lib/invite-link.ts` with two exported functions:
+
+```typescript
+export function buildInviteLink(supabaseUrl: string, publishableKey: string): string
+```
+
+- Returns `ardentforge://connect?url=${encodeURIComponent(supabaseUrl)}&key=${encodeURIComponent(publishableKey)}`
+
+```typescript
+export function parseInviteLink(raw: string): { url: string; key: string } | null
+```
+
+- Parse input as URL. If scheme is not `ardentforge:` or path/host is not `connect`, return `null`.
+- Extract `url` and `key` query params. If either is missing or empty after decoding, return `null`.
+- Return `{ url, key }`.
+- Handle edge cases: extra whitespace (trim), missing protocol, garbage input (try/catch URL constructor).
+
+2. No side effects, no imports beyond standard `URL` API.
+
+**Done:** Both functions exported, pure logic, no dependencies on app state.
+
+**Assertions:** TA-5
+
+---
+
+#### S003: Register barcode scanner Tauri plugin
+
+**Agent:** `tauri`
+**Files:** `src-tauri/Cargo.toml`, `src-tauri/src/lib.rs`, `src-tauri/capabilities/mobile.json`, `package.json`
+**Depends on:** nothing
+**Parallel:** yes (with S001, S002)
+
+1. Run `bun tauri add barcode-scanner` -- this handles:
+ - Adding `tauri-plugin-barcode-scanner` to `src-tauri/Cargo.toml`
+ - Adding `@tauri-apps/plugin-barcode-scanner` to `package.json`
+ - Registering the plugin in `src-tauri/src/lib.rs` (`.plugin(tauri_plugin_barcode_scanner::init())`)
+
+2. Verify `src-tauri/src/lib.rs` has the plugin registered in the builder chain alongside existing plugins (log, notification, deep_link, opener).
+
+3. Add `"barcode-scanner:default"` to the permissions array in `src-tauri/capabilities/mobile.json`. This grants: `allow-scan`, `allow-cancel`, `allow-check-permissions`, `allow-request-permissions`, `allow-open-app-settings`, `allow-vibrate`.
+
+4. Verify the Rust project compiles: `cd src-tauri && cargo check`
+
+**Done:** Plugin registered, permissions granted, Rust compiles.
+
+**Assertions:** TA-4 (partial -- plugin available)
+
+---
+
+### >> M1: Foundation Milestone
+
+**Gate:** S001 + S002 + S003 all complete.
+**Verify:** `bun install` clean, `invite-link.ts` exists with both exports, `cargo check` passes.
+**Assertions covered:** TA-5 (invite link parsing)
+
+---
+
+### Wave 2: QR Generation in Settings
+
+#### S004: Add "Share this server" section to backend settings
+
+**Agent:** `frontend`
+**Files:** `src/components/profile/backend-settings.tsx`
+**Depends on:** S001 (qrcode.react installed), S002 (buildInviteLink available)
+**Parallel:** no
+
+1. Import `QRCodeSVG` from `qrcode.react` and `buildInviteLink` from `src/lib/invite-link.ts`.
+
+2. Add a new section below the existing backend URL display and "Change backend" button area. Structure:
+ - Section heading: "Share this server" -- use existing section heading pattern: `font-sans text-xs font-medium uppercase tracking-widest text-warm-ash`
+ - QR code card: `bg-surface-iron p-6` container, centered. Render `QRCodeSVG` with:
+ - `value={buildInviteLink(config.supabaseUrl, config.supabaseKey)}`
+ - `size={256}`
+ - `level="M"`
+ - `marginSize={4}`
+ - `fgColor="#e5e2e1"` (bone-white)
+ - `bgColor="#201f1f"` (surface-iron)
+ - `title="Scan to connect to this Ardent Forge server"`
+ - "Copy invite link" button: secondary style button below the QR code. On click:
+ - `navigator.clipboard.writeText(buildInviteLink(config.supabaseUrl, config.supabaseKey))`
+ - Show `toast('Invite link copied')` via sonner
+ - Explainer text: `text-sm text-warm-ash/70` -- "Share this with anyone who wants to connect to this server. They will still need to create an account."
+
+3. Guard the entire section with `configStore.hasConfig()` (should always be true on Settings page, but makes the contract explicit).
+
+4. Read the existing `backend-settings.tsx` patterns for state access, styling classes, and toast usage. Match exactly.
+
+**Done:** QR code and copy button visible in Settings when configured.
+
+**Assertions:** TA-1, TA-2, TA-3, S1, S2
+
+---
+
+### >> M2: QR Generation Milestone
+
+**Gate:** S004 complete.
+**Verify:** Settings page shows QR code with correct encoded value. Copy button works. Section hidden when config empty.
+**Assertions covered:** TA-1, TA-2, TA-3
+
+---
+
+### Wave 3: QR Consumption on Setup Screen
+
+#### S005: Activate QR scan/paste on setup screen
+
+**Agent:** `frontend`
+**Files:** `src/routes/setup.tsx`
+**Depends on:** S002 (parseInviteLink), S003 (barcode scanner plugin registered)
+**Parallel:** no
+
+1. Read `src/routes/setup.tsx` thoroughly. Note the existing disabled QR button at ~line 188-195 and the manual configuration (Advanced) section state.
+
+2. **Replace the disabled QR button** with platform-branched behavior:
+
+ **Tauri branch (`isTauri()` is true -- Android):**
+ - QR icon button becomes active (remove `disabled`, `opacity-30`, `cursor-not-allowed`).
+ - On tap, execute the camera permission + scan flow:
+
+ ```typescript
+ import {
+ scan,
+ cancel,
+ checkPermissions,
+ requestPermissions,
+ openAppSettings,
+ Format,
+ } from '@tauri-apps/plugin-barcode-scanner'
+
+ async function handleScan() {
+ let perms = await checkPermissions()
+ if (perms === 'prompt') perms = await requestPermissions()
+ if (perms === 'denied') {
+ toast('Camera permission required')
+ await openAppSettings()
+ return
+ }
+ const result = await scan({ windowed: true, formats: [Format.QRCode] })
+ await cancel()
+ processInviteLink(result.content)
+ }
+ ```
+
+ - Before calling `scan()`, set a `scanning` state that applies a transparent/overlay CSS class to the setup page so the native camera feed shows through.
+ - After `cancel()`, remove the overlay.
+
+ **Browser branch (`!isTauri()`):**
+ - QR icon button toggles a paste input field inline.
+ - `ForgeInput` with placeholder "Paste invite link", matching existing underline input styling.
+ - On Enter keypress or paste event, extract the value and call `processInviteLink(value)`.
+
+3. **Shared `processInviteLink(raw: string)` function** (local to setup.tsx):
+ - Call `parseInviteLink(raw)` from `src/lib/invite-link.ts`.
+ - If `null`, show `toast('Invalid invite link')` and return.
+ - If valid:
+ - Set `showAdvanced` to `true` (expand the Advanced section).
+ - Pre-populate the `advancedUrl` and `advancedKey` state fields with parsed values.
+ - Auto-trigger validation by calling the existing `handleAdvancedConnect()` function (or equivalent manual validation path).
+
+4. **Scanning overlay component** (Tauri only):
+ - When `scanning` state is true, render a full-screen overlay with:
+ - `bg-black/80` background (semi-transparent dark).
+ - Centered viewfinder cutout (transparent rectangle, ~280x280px, with `border-2 border-ember` outline).
+ - "Cancel" button at bottom that calls `cancel()` and clears the `scanning` state.
+ - The actual camera feed renders natively underneath the transparent WebView.
+
+5. Keep all existing setup screen behavior (server URL discovery, manual config, env var auto-detect) completely unchanged.
+
+**Done:** QR scan works on Android, paste works in browser, both feed into validation.
+
+**Assertions:** TA-4, TA-6, TA-7
+
+---
+
+### >> M3: QR Consumption Milestone
+
+**Gate:** S005 complete.
+**Verify:** Scan button active on Tauri, paste field on browser, both parse and auto-validate. Invalid input shows toast. Existing flows unaffected.
+**Assertions covered:** TA-4, TA-5, TA-6, TA-7
+
+---
+
+### Wave 4: Validation
+
+#### S006: Tests and regression
+
+**Agent:** `qa`
+**Files:** `src/lib/__tests__/invite-link.test.ts` (NEW), existing test suite
+**Depends on:** S004, S005
+**Parallel:** no
+
+1. **Unit tests for `invite-link.ts`:**
+ - `buildInviteLink` produces correct URL format with encoded params
+ - `parseInviteLink` round-trips with `buildInviteLink` output
+ - `parseInviteLink` returns `null` for: empty string, random text, wrong scheme (`https://connect?...`), missing `url` param, missing `key` param, malformed URL
+ - `parseInviteLink` handles: extra whitespace, trailing newlines
+
+2. **Run existing test suite:** `bun run test` -- all existing tests pass (regression check).
+
+3. **Run linter:** `bun run lint` -- no new lint errors.
+
+4. **Run type check:** `bun run build` (includes `tsc` check) -- no type errors.
+
+**Done:** All tests pass, no regressions, no lint/type errors.
+
+**Assertions:** TA-5, TA-8
+
+---
+
+#### S006-T1: Component tests for processInviteLink valid/invalid flows
+
+**Agent:** `qa`
+**Files:** `src/routes/__tests__/setup.test.tsx` (NEW)
+**Depends on:** S005, S006
+**Parallel:** no
+
+1. Create component test file for the setup page QR/paste flow:
+ - Test that a valid invite link populates url/key fields, expands Advanced section, triggers validation
+ - Test that an invalid invite link shows "Invalid invite link" toast and leaves fields unchanged
+ - Mock `validateConnection`, `getConfigStore`, and `parseInviteLink` as needed
+
+**Done:** Component tests verify processInviteLink valid/invalid paths.
+
+**Assertions:** TA-6, TA-7
+
+---
+
+#### S006-T2: Component tests for "Share this server" conditional rendering and copy button
+
+**Agent:** `qa`
+**Files:** `src/components/profile/__tests__/backend-settings.test.tsx` (NEW)
+**Depends on:** S004, S006
+**Parallel:** yes (with S006-T1)
+
+1. Create component test file for backend settings QR section:
+ - Test that QR section is absent when config is null and present when configured (TA-3)
+ - Test that copy button writes correct invite link to clipboard and shows toast (TA-2)
+ - Mock `getConfigStore` and `navigator.clipboard`
+
+**Done:** Component tests verify conditional rendering and copy behavior.
+
+**Assertions:** TA-2, TA-3
+
+---
+
+### >> M4: Validation Milestone (Final)
+
+**Gate:** S006 complete.
+**Verify:** All unit tests pass, existing suite green, lint clean, types clean.
+**Assertions covered:** All (TA-1 through TA-8)
+
+---
+
+### Execution Summary
+
+| Wave | Steps | Agent(s) | Parallel |
+| ---- | ---------------- | ------------------------- | ----------- |
+| 1 | S001, S002, S003 | frontend, frontend, tauri | yes (all 3) |
+| 2 | S004 | frontend | no |
+| 3 | S005 | frontend | no |
+| 4 | S006 | qa | no |
+
+**Total steps:** 6
+**Critical path:** S002 -> S004 -> S005 -> S006
+**Estimated execution:** All waves sequential after Wave 1 parallel foundation.
+
+### Recommended Execution
+
+```
+/impl 010
+```
+
+Tasks are isolated by domain (frontend owns most work, tauri handles plugin registration, qa validates). No cross-domain coordination needed -- `/impl` over `/team-impl`.
diff --git a/Context/Features/010-qr-code-gen-scan/Tech.md b/Context/Features/010-qr-code-gen-scan/Tech.md
new file mode 100644
index 00000000..d106c914
--- /dev/null
+++ b/Context/Features/010-qr-code-gen-scan/Tech.md
@@ -0,0 +1,126 @@
+# Feature 010: QR Code Generation + Scan on Setup
+
+## Phase 2 -- Technical Design
+
+### Architecture Overview
+
+This feature adds two independent UI surfaces connected by a shared data format:
+
+1. **QR Generation (Settings)** -- New section in `backend-settings.tsx` that renders a QR code and copy button encoding the current backend config as an `ardentforge://connect` URL.
+2. **QR Consumption (Setup)** -- Activates the existing disabled QR button in `setup.tsx` with platform-branched input (native camera scan on Android via Tauri, paste field on web browser).
+
+A shared **invite link utility** (`src/lib/invite-link.ts`) handles URL construction and parsing, used by both surfaces and reusable by Refactor C (deep link handler).
+
+### Key Decisions
+
+#### D1: QR Code Library -- `qrcode.react`
+
+**Choice:** `QRCodeSVG` from `qrcode.react`
+
+**Why:** Lightweight, client-side-only, zero external service calls, ships TypeScript types, and renders as inline SVG (no canvas or image export needed). The SVG output integrates cleanly with the Iron & Ember design system since we can control bg/fg colors via props.
+
+**Config:**
+
+- `size={256}` -- At the expected payload (~175 chars), the QR matrix is ~37x37 modules. At 256px, each module is ~6.9px, well above the ~2-3px minimum for reliable mobile camera scanning.
+- `level="M"` -- 15% error correction. Standard for mobile scanning; balances density vs. recoverability.
+- `marginSize={4}` -- QR spec-required quiet zone for scanner reliability.
+- `fgColor="#e5e2e1"` (bone-white) / `bgColor="#201f1f"` (surface-iron) -- Inverted from traditional black-on-white to match Iron & Ember. Sufficient contrast ratio (~10:1) for scanner readability.
+
+#### D2: Platform Detection -- `isTauri()` (existing)
+
+**Choice:** Use the existing `isTauri()` check from `@tauri-apps/api/core`. No new dependency needed.
+
+**Why:** There is no desktop Tauri build -- the app runs as either mobile Tauri (Android) or web browser. `isTauri() === true` means Android, `isTauri() === false` means browser. This is the same pattern used throughout the codebase (config store, auth, sync bridge, etc.).
+
+**Implementation:**
+
+```typescript
+import { isTauri } from '@tauri-apps/api/core'
+
+// Tauri = Android (native camera scan)
+// Browser = paste field fallback
+{isTauri() ? : }
+```
+
+#### D3: Barcode Scanner -- `@tauri-apps/plugin-barcode-scanner`
+
+**Choice:** Native scanner via `@tauri-apps/plugin-barcode-scanner` with `windowed: true`.
+
+**Why:** The plugin accesses the camera natively, bypassing WebView camera APIs entirely. The `windowed` mode makes the WebView transparent and renders the camera feed underneath -- the app renders a dark overlay with a viewfinder cutout.
+
+**Permission flow:**
+
+1. On scan button tap, call `checkPermissions()`.
+2. If `'prompt'`, call `requestPermissions()`.
+3. If `'denied'`, show toast with "Camera permission required" and call `openAppSettings()`.
+4. If `'granted'`, proceed to `scan({ windowed: true, formats: [Format.QRCode] })`.
+5. On result, call `cancel()` to dismiss camera, then process `result.content`.
+
+**Setup:** `bun tauri add barcode-scanner` handles Rust crate + JS bindings. Add `barcode-scanner:default` to `src-tauri/capabilities/mobile.json`.
+
+#### D4: Invite Link Format
+
+**Format:** `ardentforge://connect?url={encodedSupabaseUrl}&key={encodedPublishableKey}`
+
+**Why:** Uses the existing `ardentforge://` custom URL scheme already registered for deep links. The `/connect` path distinguishes it from the existing `/auth` path used for OAuth callbacks. Query params are URI-encoded.
+
+**Shared utility (`src/lib/invite-link.ts`):**
+
+```typescript
+export function buildInviteLink(supabaseUrl: string, publishableKey: string): string
+export function parseInviteLink(raw: string): { url: string; key: string } | null
+```
+
+`parseInviteLink` returns `null` for any string that isn't a valid `ardentforge://connect` URL with both params present. This function is reused by:
+
+- Settings copy button (build)
+- Setup scan/paste handler (parse)
+- Refactor C deep link handler (parse) -- future
+
+#### D5: Browser Fallback -- Paste Field
+
+**Choice:** Inline text input toggled by the QR icon button, not a modal or dialog.
+
+**Why:** Consistent with the setup screen's existing underline input pattern. No camera APIs needed in the browser. The user pastes an `ardentforge://connect?...` string, and on Enter/blur the app parses and auto-validates.
+
+**UX flow:**
+
+1. Tap QR icon button (same position as mobile scan button).
+2. A `ForgeInput` appears inline with placeholder "Paste invite link".
+3. On paste or Enter, run `parseInviteLink()`.
+4. Valid: expand Advanced section, pre-populate fields, auto-trigger validation.
+5. Invalid: toast "Invalid invite link", clear the input.
+
+### Files Modified
+
+| File | Change |
+| --------------------------------------------- | --------------------------------------------------------------- |
+| `src/lib/invite-link.ts` | **NEW** -- `buildInviteLink()`, `parseInviteLink()` |
+| `src/components/profile/backend-settings.tsx` | Add "Share this server" section with QR code + copy button |
+| `src/routes/setup.tsx` | Activate QR scan button; add platform-branched scan/paste logic |
+| `src-tauri/src/lib.rs` | Register `tauri_plugin_barcode_scanner` |
+| `src-tauri/Cargo.toml` | Add `tauri-plugin-barcode-scanner` dependency |
+| `src-tauri/capabilities/mobile.json` | Add `barcode-scanner:default` permission |
+| `package.json` | Add `qrcode.react`, `@tauri-apps/plugin-barcode-scanner` |
+
+### Integration Points
+
+- **Config store** (`src/lib/config-store.ts`): QR generation reads `getConfig()`. Setup scan/paste calls `setConfig()` + `initSupabaseFromConfig()` via existing `validateAndSave` flow.
+- **Connection validator** (`src/lib/connection-validator.ts`): Unchanged. Scan/paste results feed into existing `validateConnection()`.
+- **Toast system** (`sonner`): Used for copy success, invalid link errors, and camera permission denial.
+- **Discovery flow** (`src/lib/discovery.ts`): Unchanged. QR/paste is an alternative input path that bypasses discovery entirely (goes straight to manual validation).
+
+### Risks and Mitigations
+
+| Risk | Severity | Mitigation |
+| ------------------------------------------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Inverted QR colors (bone-white on surface-iron) may not scan on some low-end cameras | Medium | Test on physical Android device. Fallback: offer a high-contrast toggle or use standard black-on-white with a card overlay. |
+| Barcode scanner `windowed` mode requires WebView transparency CSS | Low | Well-documented pattern. Overlay component handles the transparent background and viewfinder cutout. |
+| Publishable key in QR code | Low | Publishable keys are non-secret by design (equivalent to a public API key). The QR does not contain the service role key. Explainer text clarifies users still need to create an account. |
+
+### Testing Strategy
+
+- **Unit tests:** `invite-link.ts` -- `buildInviteLink` round-trips with `parseInviteLink`, edge cases (missing params, wrong scheme, extra params).
+- **Component tests:** QR section renders/hides based on config state. Copy button calls clipboard API with correct string.
+- **Manual testing:** Physical Android device scan with `windowed` mode. Browser paste flow.
+- **Regression:** Existing setup flow tests pass unchanged.
diff --git a/Context/Features/011-ci-cd-pipeline/Spec.md b/Context/Features/011-ci-cd-pipeline/Spec.md
new file mode 100644
index 00000000..4d23bc48
--- /dev/null
+++ b/Context/Features/011-ci-cd-pipeline/Spec.md
@@ -0,0 +1,99 @@
+# Feature 011: CI/CD Pipeline for Android App
+
+**Status:** Draft
+**Date:** 2026-04-04
+**Author:** Claude (Cortex)
+
+---
+
+## Overview
+
+Establish a complete CI/CD pipeline using GitHub Actions for the Ardent Forge Android app. The pipeline covers PR validation (lint, typecheck, unit tests, E2E tests), Android debug/release builds, Google Play Store deployment, and Supabase migration management -- all from zero (no existing CI infrastructure).
+
+## Problem Statement
+
+Ardent Forge has no CI/CD infrastructure. All builds, tests, and deployments are manual. This creates risk of:
+
+- Regressions landing on `develop` undetected
+- Inconsistent release builds (local machine variance)
+- Manual, error-prone Play Store uploads
+- Database migrations applied ad-hoc without validation
+- No gate between code review and verified build success
+
+## User Stories
+
+1. **As a developer**, I want PRs to automatically run lint, typecheck, unit tests, and E2E tests so regressions are caught before merge.
+2. **As a developer**, I want every PR to verify the Android app compiles so I know my changes don't break the build.
+3. **As a release manager**, I want tagged releases to automatically build a signed AAB, upload it to Google Play, and apply Supabase migrations so the release process is hands-free.
+4. **As a developer**, I want CI builds to be fast via aggressive caching so the feedback loop stays tight.
+
+## Requirements
+
+### Must Have
+
+| ID | Requirement |
+| --- | --------------------------------------------------------------------------------------------------------------------------------------- |
+| M1 | **PR Validation Workflow** -- On every PR to `develop`: run ESLint, TypeScript type-check (`tsc -b`), and Vitest unit tests |
+| M2 | **E2E Testing on PRs** -- Run Playwright end-to-end tests as part of PR validation |
+| M3 | **Android Debug Build on PR** -- Build a debug APK on every PR to verify Tauri+Gradle compilation succeeds |
+| M4 | **Android Release Build on Tag** -- Build a signed release AAB/APK when a version tag (e.g., `v1.0.0`) is pushed |
+| M5 | **Artifact Publishing** -- Upload signed AAB/APK as GitHub Release assets on tagged releases |
+| M6 | **Google Play Store Deployment** -- Automatically upload the signed AAB to Google Play Console (internal/beta track) on tagged releases |
+| M7 | **Supabase Migration on Release** -- Run Supabase migrations against the production database on tagged releases |
+| M8 | **Secret Management** -- Android keystore, Play Console service account key, and Supabase credentials stored as GitHub Secrets |
+| M9 | **Dependency Caching** -- Cache Bun dependencies, Cargo/Rust toolchain, Gradle builds, and Android SDK components |
+
+### Should Have
+
+| ID | Requirement |
+| --- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| S1 | **Version auto-injection** -- Derive `versionCode` from GitHub run number or tag; inject `versionName` from git tag into `tauri.properties` |
+| S2 | **Build status badges** -- Add workflow status badges to README |
+| S3 | **Concurrency controls** -- Cancel superseded workflow runs on the same PR |
+| S4 | **Branch protection rules** -- Document required status checks for `develop` branch protection |
+
+### Won't Have (this phase)
+
+| ID | Exclusion | Rationale |
+| --- | --------------------------------------- | ---------------------------------------------------------------------- |
+| W1 | iOS build pipeline | No iOS target configured yet |
+| W2 | Desktop (Linux/macOS/Windows) builds | Desktop distribution not prioritized |
+| W3 | Play Store production track deployment | Start with internal/beta; promote manually until confidence is built |
+| W4 | Automated rollback on migration failure | Requires more sophisticated migration tooling; manual rollback for now |
+
+## Testable Assertions
+
+| ID | Assertion |
+| --- | --------------------------------------------------------------------------------------------------------------- |
+| TA1 | A PR to `develop` triggers lint + typecheck + unit tests; failure blocks merge |
+| TA2 | A PR to `develop` triggers Playwright E2E tests; failure blocks merge |
+| TA3 | A PR to `develop` triggers an Android debug APK build; failure blocks merge |
+| TA4 | Pushing a `v*` tag triggers a release build producing a signed AAB |
+| TA5 | The signed AAB is uploaded as a GitHub Release asset |
+| TA6 | The signed AAB is uploaded to Google Play Console internal/beta track |
+| TA7 | Pushing a `v*` tag triggers Supabase migrations against the production instance |
+| TA8 | CI caching reduces second-run build time by at least 40% compared to cold build |
+| TA9 | All secrets (keystore, Play Console key, Supabase credentials) are injected via GitHub Secrets, never committed |
+
+## Open Questions (Resolved)
+
+| ID | Question | Resolution |
+| --- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| OQ1 | Which Google Play track should be the initial deployment target? | **Internal testing track.** Safest starting point; promote to beta/production manually from Play Console. |
+| OQ2 | Should Supabase migrations run before or after the Play Store upload? | **Before.** If migrations break, abort the release before the app reaches users. Ensures API is ready when users update. |
+| OQ3 | Tag-based release strategy? | **Pre-release tags (`-alpha`, `-beta`, `-rc`) from `develop`; stable tags from `main`.** Two workflow triggers with branch filtering. |
+| OQ4 | Is there an existing Google Play Console project and service account? | **Yes.** Both exist and are ready for CI integration. |
+| OQ5 | Should E2E tests run against local Supabase or a staging environment? | **Local Supabase** via `supabase start`. Hermetic, no external dependencies, Docker-based. Already configured in `docker-compose.yml`. |
+
+## Dependencies
+
+| Dependency | Type | Notes |
+| -------------------------------------------- | -------- | ------------------------------------------------ |
+| GitHub repository (rghsoftware/ardent-forge) | External | Workflows live in `.github/workflows/` |
+| Google Play Console | External | Service account with upload permissions required |
+| Supabase CLI | Tooling | For `supabase db push` in CI |
+| Android SDK 36 + NDK | Tooling | Must be available on CI runner |
+| Rust + Android cross-compilation targets | Tooling | `aarch64-linux-android`, etc. |
+| Bun runtime | Tooling | For frontend build and test execution |
+| Java 21 | Tooling | For Gradle/Android builds |
+| Playwright | Tooling | For E2E test execution in CI |
diff --git a/Context/Features/011-ci-cd-pipeline/Steps.md b/Context/Features/011-ci-cd-pipeline/Steps.md
new file mode 100644
index 00000000..128f668c
--- /dev/null
+++ b/Context/Features/011-ci-cd-pipeline/Steps.md
@@ -0,0 +1,398 @@
+# Feature 011: CI/CD Pipeline -- Implementation Steps
+
+**Status:** Draft
+**Date:** 2026-04-04
+**Spec:** [Spec.md](./Spec.md)
+**Tech:** [Tech.md](./Tech.md)
+
+---
+
+## Team Composition
+
+| Role | Agent | Domain |
+| ----------------- | ------------------- | ------------------------------------------------- |
+| CI/CD Engineer | `cicd-engineer` | GitHub Actions workflows, caching, secrets |
+| Backend Engineer | `backend-engineer` | Supabase migration job, E2E Supabase setup |
+| Frontend Engineer | `frontend-engineer` | Playwright setup, E2E test scaffolding |
+| Android Engineer | `android-engineer` | Gradle signing config, build commands, Play Store |
+| Quality Engineer | `quality-engineer` | Validation of all workflows |
+
+---
+
+## Wave 1: Foundation (parallel)
+
+### S001 -- Create `.github/workflows/` directory and CI validation workflow
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/ci.yml`
+**Depends on:** none
+
+Create the PR validation workflow (`ci.yml`) with:
+
+- Trigger: `pull_request` targeting `develop` or `main`
+- Concurrency: cancel in-progress runs per PR (`ci-${{ github.ref }}`)
+- Job `validate`:
+ - `actions/checkout@v4`
+ - `oven-sh/setup-bun@v2` (pinned version)
+ - `actions/cache@v4` for `~/.bun/install/cache` keyed on `bun.lock`
+ - `bun install`
+ - `bun run lint`
+ - `bun run build` (includes `tsc -b` + Vite build)
+ - `bun run test`
+
+**Acceptance criteria:**
+
+- Workflow triggers on PRs to `develop` and `main`
+- Lint, typecheck, and unit test failures block the PR
+- Superseded runs are cancelled
+
+**Ref:** TA1
+
+---
+
+### S002 -- Add Playwright and E2E test scaffolding
+
+**Agent:** `frontend-engineer`
+**Files:** `package.json`, `playwright.config.ts`, `e2e/smoke.spec.ts`
+**Depends on:** none
+
+1. Add `@playwright/test` as a dev dependency (`bun add -d @playwright/test`)
+2. Create `playwright.config.ts`:
+ - `testDir: './e2e'`
+ - Projects: Chromium only
+ - `baseURL: 'http://localhost:4173'` (Vite preview)
+ - `webServer` config to run `bun run preview` before tests
+ - Reporter: HTML (for CI artifact upload)
+3. Create `e2e/smoke.spec.ts`:
+ - Test: app loads without crashing
+ - Test: login/auth page renders
+4. Add script to `package.json`: `"test:e2e": "playwright test"`
+5. Update `.gitignore` with `playwright-report/`, `test-results/`
+
+**Acceptance criteria:**
+
+- `bun run test:e2e` runs Playwright tests locally
+- Smoke test verifies app renders
+
+---
+
+### S003 -- Configure Android release signing in Gradle
+
+**Agent:** `android-engineer`
+**Files:** `src-tauri/gen/android/app/build.gradle.kts`
+**Depends on:** none
+
+Modify `src-tauri/gen/android/app/build.gradle.kts` to add:
+
+1. `signingConfigs` block that reads from environment variables:
+ - `ANDROID_KEYSTORE_PATH` (file path)
+ - `ANDROID_KEYSTORE_PASSWORD`
+ - `ANDROID_KEY_ALIAS`
+ - `ANDROID_KEY_PASSWORD`
+2. Only apply release signing config when env vars are present (guard with `if` so local debug builds are unaffected)
+3. Wire `signingConfig` into the `release` build type
+
+**Acceptance criteria:**
+
+- Local debug builds (`bunx tauri android build`) still work without env vars set
+- When env vars are set, release builds use the configured signing config
+- No secrets are hardcoded
+
+**Ref:** M8
+
+---
+
+## Milestone 1: PR Validation Foundation
+
+**Gate:** S001, S002, S003 all complete
+**Assertions verified:** TA1 (partial -- E2E not yet in CI), TA9
+
+---
+
+## Wave 2: CI Workflow Completion (parallel)
+
+### S004 -- Add E2E test job to CI workflow
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/ci.yml`
+**Depends on:** S001, S002
+
+Add `e2e` job to `ci.yml`:
+
+1. `actions/checkout@v4`
+2. `oven-sh/setup-bun@v2` + Bun cache
+3. `supabase/setup-cli@v1`
+4. `bun install`
+5. `bunx supabase start` (requires Docker, available on `ubuntu-latest`)
+6. `bun run build`
+7. `bunx playwright install --with-deps chromium`
+8. `bun run test:e2e`
+9. On failure: upload `playwright-report/` as artifact via `actions/upload-artifact@v4`
+
+Set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` environment variables from `bunx supabase status` output for the E2E tests.
+
+**Acceptance criteria:**
+
+- E2E job runs in parallel with `validate` job
+- E2E failure blocks PR merge
+- Playwright report is downloadable on failure
+
+**Ref:** TA2
+
+---
+
+### S005 -- Add Android debug build job to CI workflow
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/ci.yml`
+**Depends on:** S001
+
+Add `android-debug` job to `ci.yml`:
+
+1. `actions/checkout@v4`
+2. `actions/setup-java@v4` (Java 21, temurin, `cache: gradle`)
+3. `android-actions/setup-android@v3` + `sdkmanager "ndk;"`
+4. `dtolnay/rust-toolchain@stable` + `rustup target add aarch64-linux-android`
+5. `Swatinem/rust-cache@v2` with `workspaces: "src-tauri -> target"`, `shared-key: android`
+6. `oven-sh/setup-bun@v2` + Bun cache
+7. `bun install`
+8. Set `ANDROID_NDK_HOME` environment variable
+9. `bunx tauri android build --target aarch64 --apk` (debug, single arch for speed)
+10. Optionally upload debug APK as artifact
+
+**Acceptance criteria:**
+
+- Android debug build runs in parallel with `validate` and `e2e`
+- Build failure blocks PR merge
+- Caching is effective (Cargo, Gradle, Bun all cached)
+
+**Ref:** TA3
+
+---
+
+## Milestone 2: Complete PR Validation
+
+**Gate:** S004, S005 complete
+**Assertions verified:** TA1, TA2, TA3, TA8 (caching)
+
+---
+
+## Wave 3: Release Workflow (sequential within, parallel setup)
+
+### S006 -- Create release workflow with signed AAB build
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/release.yml`
+**Depends on:** S003, S005
+
+Create `release.yml` with:
+
+- Trigger: `push` tags matching `v*`
+- Job `build-release`:
+ 1. Same toolchain setup as S005 (Java, Android SDK/NDK, Rust, Bun)
+ 2. Decode keystore: `echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > $RUNNER_TEMP/upload-key.jks`
+ 3. Inject version into `src-tauri/gen/android/app/tauri.properties`:
+ - `versionName` from tag: `${GITHUB_REF_NAME#v}`
+ - `versionCode` from `${{ github.run_number }}`
+ 4. Set signing env vars: `ANDROID_KEYSTORE_PATH`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`
+ 5. `bunx tauri android build --target aarch64 --aab`
+ 6. Upload AAB as workflow artifact via `actions/upload-artifact@v4`
+
+**Acceptance criteria:**
+
+- Pushing a `v*` tag triggers the workflow
+- Signed AAB is produced and uploaded as artifact
+- Version info in AAB matches the git tag
+
+**Ref:** TA4
+
+---
+
+### S007 -- Add Supabase migration job to release workflow
+
+**Agent:** `backend-engineer`
+**Files:** `.github/workflows/release.yml`
+**Depends on:** S006
+
+Add `migrate` job to `release.yml`:
+
+1. `actions/checkout@v4`
+2. `oven-sh/setup-bun@v2`
+3. `supabase/setup-cli@v1`
+4. `bunx supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}`
+5. `bunx supabase db push`
+ - Uses `SUPABASE_ACCESS_TOKEN` secret for authentication
+
+Job runs in **parallel** with `build-release`. Both must succeed before `publish` job starts.
+
+**Acceptance criteria:**
+
+- Migration job runs on tagged releases
+- Migration failure blocks the publish job
+- Supabase credentials are injected via secrets, never committed
+
+**Ref:** TA7
+
+---
+
+### S008 -- Add publish job (GitHub Release + Play Store)
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/release.yml`
+**Depends on:** S006, S007
+
+Add `publish` job to `release.yml`, gated on both `build-release` and `migrate`:
+
+1. Download AAB artifact via `actions/download-artifact@v4`
+2. Create GitHub Release via `softprops/action-gh-release@v2`:
+ - `tag_name: ${{ github.ref_name }}`
+ - `prerelease: ${{ contains(github.ref_name, '-') }}`
+ - Attach AAB file
+ - Generate release notes from commits
+3. Upload to Play Store via `r0adkll/upload-google-play@v3`:
+ - `serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}`
+ - `packageName: com.rghsoftware.ardentforge`
+ - `releaseFiles: `
+ - `track: internal`
+ - `status: completed`
+
+**Acceptance criteria:**
+
+- Publish only runs after both build and migration succeed
+- GitHub Release created with AAB attached
+- Pre-release tags get `prerelease: true` flag
+- AAB uploaded to Play Console internal testing track
+
+**Ref:** TA5, TA6
+
+---
+
+## Milestone 3: Release Pipeline Complete
+
+**Gate:** S006, S007, S008 complete
+**Assertions verified:** TA4, TA5, TA6, TA7
+
+---
+
+## Wave 4: Polish and Documentation (parallel)
+
+### S009 -- Add concurrency controls and branch protection docs
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/ci.yml`, `.github/workflows/release.yml`
+**Depends on:** S008
+
+1. Verify concurrency groups are set on both workflows:
+ - `ci.yml`: `ci-${{ github.ref }}` with `cancel-in-progress: true`
+ - `release.yml`: `release-${{ github.ref }}` (no cancel -- don't cancel a release mid-flight)
+2. Create `.github/BRANCH_PROTECTION.md` documenting recommended branch protection settings:
+ - `develop`: require `validate`, `e2e`, `android-debug` status checks
+ - `main`: require `validate`, `e2e`, `android-debug` status checks
+3. Add workflow status badges to the top of `README.md` (if README exists)
+
+**Acceptance criteria:**
+
+- Superseded CI runs are cancelled
+- Release runs are never cancelled
+- Branch protection requirements are documented
+
+**Ref:** S3, S4
+
+---
+
+### S009-T -- Validate all workflows
+
+**Agent:** `quality-engineer`
+**Files:** all `.github/workflows/*.yml`
+**Depends on:** S008
+
+Validate:
+
+1. YAML syntax is valid for all workflow files
+2. All secret references match the documented secrets in Tech.md
+3. All action versions are pinned (no `@master` or `@main`)
+4. Caching keys are correct and consistent
+5. Job dependency graph is correct (`needs:` declarations)
+6. Concurrency groups are properly configured
+7. No secrets are exposed in logs (no `echo ${{ secrets.* }}`)
+8. Tag filtering logic correctly differentiates pre-release from stable
+9. `tauri.properties` version injection uses correct sed/echo syntax
+10. E2E job properly starts and tears down local Supabase
+
+**Acceptance criteria:**
+
+- All workflows pass validation
+- No security issues in secret handling
+- Dependency graph matches Tech.md architecture
+
+**Ref:** TA9
+
+---
+
+### S009-D -- Document secrets setup and first-time prerequisites
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/README.md`
+**Depends on:** S008
+
+Create `.github/README.md` covering:
+
+1. List of all required GitHub Secrets with descriptions
+2. How to generate and encode the upload keystore (`base64 -w 0 upload-key.jks`)
+3. How to create the Google Play Console service account
+4. How to get the Supabase access token and project ref
+5. First-time Play Store upload requirement (manual initial upload)
+6. Branch protection setup instructions
+7. How to trigger a release (tag and push workflow)
+
+**Acceptance criteria:**
+
+- A new developer can set up the pipeline from this document alone
+- All prerequisites are listed
+- No secrets or credentials are included in the document
+
+---
+
+### S010 -- Add secret validation steps to release workflow jobs
+
+**Agent:** `cicd-engineer`
+**Files:** `.github/workflows/release.yml`
+**Depends on:** S008
+
+Add an upfront "Validate secrets" step at the beginning of each release workflow job that uses secrets (`build-release`, `migrate`, `publish`). Each step should validate that all required secrets for that job are non-empty, failing fast with a clear error message instead of letting builds run 10-15 minutes before hitting opaque failures.
+
+Required validations per job:
+
+- `build-release`: `KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`
+- `migrate`: `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_REF`
+- `publish`: `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
+
+**Acceptance criteria:**
+
+- Each job fails within 10 seconds if a required secret is missing
+- Error messages clearly identify which secret is missing
+- All 7 secrets across the release workflow are validated
+
+**Ref:** TA9, PR Review P8-015
+
+---
+
+## Milestone 4: Feature Complete
+
+**Gate:** S009, S009-T, S009-D all complete
+**Assertions verified:** All (TA1-TA9)
+
+---
+
+## Summary
+
+| Wave | Steps | Parallel | Key Deliverable |
+| ---- | -------------------- | ----------------------------------- | --------------------------------------------------- |
+| 1 | S001, S002, S003 | Yes (3 agents) | CI skeleton, Playwright scaffold, Gradle signing |
+| 2 | S004, S005 | Yes (2 agents) | Complete PR validation (lint + E2E + Android debug) |
+| 3 | S006, S007, S008 | S006+S007 parallel, S008 sequential | Full release pipeline with Play Store + migrations |
+| 4 | S009, S009-T, S009-D | Yes (3 agents) | Polish, validation, documentation |
+
+**Total steps:** 10 (7 implementation + 1 test + 1 documentation + 1 polish)
+**Estimated agents in parallel (max):** 3
+**Critical path:** S001 -> S005 -> S006 -> S008 -> S009-T
diff --git a/Context/Features/011-ci-cd-pipeline/Tech.md b/Context/Features/011-ci-cd-pipeline/Tech.md
new file mode 100644
index 00000000..ef9483d3
--- /dev/null
+++ b/Context/Features/011-ci-cd-pipeline/Tech.md
@@ -0,0 +1,276 @@
+# Feature 011: CI/CD Pipeline -- Technical Plan
+
+**Status:** Draft
+**Date:** 2026-04-04
+**Spec:** [Spec.md](./Spec.md)
+
+---
+
+## Architecture Overview
+
+Two GitHub Actions workflows, triggered by different events, sharing a common toolchain setup:
+
+```
+PR to develop v*-alpha/beta/rc tag (develop) v* stable tag (main)
+ | | |
+ v v v
+[ci.yml] [release.yml] [release.yml]
+ - lint - signed AAB build - signed AAB build
+ - typecheck - GitHub Release (prerelease) - GitHub Release
+ - unit tests - Play Store (internal track) - Play Store (internal track)
+ - E2E tests - Supabase migrations (prod) - Supabase migrations (prod)
+ - debug APK build
+```
+
+### Workflow Files
+
+| File | Trigger | Purpose |
+| ------------------------------- | ----------------------------- | ------------------------------------------------------------------------ |
+| `.github/workflows/ci.yml` | PR to `develop`, PR to `main` | Lint, typecheck, unit tests, E2E tests, debug APK build |
+| `.github/workflows/release.yml` | Push tag `v*` | Signed AAB build, GitHub Release, Play Store upload, Supabase migrations |
+
+Two workflows total. `release.yml` handles both pre-release and stable tags, differentiating via tag pattern matching (`contains(github.ref, '-')` for pre-release flag).
+
+---
+
+## Toolchain and Actions
+
+### Runner
+
+**`ubuntu-latest`** for all workflows. Cheaper than macOS runners, Android SDK/NDK install is straightforward, and Tauri's Rust cross-compilation does not require macOS.
+
+### Tool Installation Order
+
+Each workflow installs tools in this dependency order:
+
+| Step | Tool | Action / Method | Version |
+| ---- | -------------------- | -------------------------------------------------- | ------------------------- |
+| 1 | Java 21 | `actions/setup-java@v4` (temurin, `cache: gradle`) | `21` |
+| 2 | Android SDK + NDK | `android-actions/setup-android@v3` + `sdkmanager` | SDK 36, NDK pinned |
+| 3 | Rust stable | `dtolnay/rust-toolchain@stable` | stable (min 1.77.2) |
+| 4 | Rust Android targets | `dtolnay/rust-toolchain` `targets` input | `aarch64-linux-android` |
+| 5 | Bun | `oven-sh/setup-bun@v2` | pinned (currently latest) |
+| 6 | Supabase CLI | `supabase/setup-cli@v1` | latest (E2E + release) |
+
+### Caching Strategy
+
+| Cache | Method | Key |
+| ------ | ------------------------------------ | ---------------------------------------------------------- |
+| Gradle | `actions/setup-java` `cache: gradle` | Auto-keyed on `*.gradle*` + wrapper properties |
+| Cargo | `Swatinem/rust-cache@v2` | `workspaces: "src-tauri -> target"`, `shared-key: android` |
+| Bun | `actions/cache@v4` | `~/.bun/install/cache` keyed on `bun.lock` |
+
+### Key Actions
+
+| Purpose | Action | Version |
+| ----------------- | ---------------------------------- | ------------------------- |
+| Checkout | `actions/checkout@v4` | v4 |
+| Java | `actions/setup-java@v4` | v4 |
+| Android SDK | `android-actions/setup-android@v3` | v3 |
+| Rust | `dtolnay/rust-toolchain@stable` | stable |
+| Rust cache | `Swatinem/rust-cache@v2` | v2 |
+| Bun | `oven-sh/setup-bun@v2` | v2 |
+| Generic cache | `actions/cache@v4` | v4 |
+| Supabase CLI | `supabase/setup-cli@v1` | v1 (CLI pinned to 2.84.4) |
+| Play Store upload | `r0adkll/upload-google-play@v3` | v3 |
+| GitHub Release | `softprops/action-gh-release@v2` | v2 |
+
+---
+
+## Workflow Details
+
+### 1. `ci.yml` -- PR Validation
+
+**Trigger:** `pull_request` targeting `develop` or `main`
+
+**Concurrency:** Cancel in-progress runs for the same PR (`concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true }`)
+
+**Jobs:**
+
+#### Job: `validate`
+
+Runs on `ubuntu-latest`. Steps:
+
+1. Checkout
+2. Setup Bun + cache
+3. `bun install`
+4. `bun run lint` (ESLint)
+5. `bun run build` (includes `tsc -b` typecheck + Vite build)
+6. `bun run test` (Vitest)
+
+#### Job: `e2e`
+
+Runs on `ubuntu-latest`. Needs Docker for local Supabase. Steps:
+
+1. Checkout
+2. Setup Bun + cache
+3. Setup Supabase CLI
+4. `bun install`
+5. `bunx supabase start` (spins up local Supabase via Docker)
+6. `bun run build` (build frontend for E2E)
+7. `bunx playwright install --with-deps chromium`
+8. `bunx playwright test`
+9. Upload Playwright report as artifact on failure
+
+#### Job: `android-debug`
+
+Runs on `ubuntu-latest`. Steps:
+
+1. Checkout
+2. Setup Java 21 (with Gradle cache)
+3. Setup Android SDK + NDK
+4. Setup Rust + Android targets + Rust cache
+5. Setup Bun + cache
+6. `bun install`
+7. `bunx tauri android build --target aarch64 --apk` (debug build, single arch for speed)
+8. Upload debug APK as artifact (optional, for manual testing)
+
+All three jobs run **in parallel**. All must pass for PR merge.
+
+### 2. `release.yml` -- Tagged Release
+
+**Trigger:** `push` with tag pattern `v*`
+
+**Branch filtering:** The tag determines pre-release vs stable:
+
+- Tags containing `-alpha`, `-beta`, `-rc` are marked as pre-release on the GitHub Release
+- Tags without a suffix are stable releases
+
+**Jobs:**
+
+#### Job: `build-release`
+
+Steps:
+
+1. Checkout
+2. Setup Java 21 (with Gradle cache)
+3. Setup Android SDK + NDK
+4. Setup Rust + Android targets + Rust cache
+5. Setup Bun + cache
+6. `bun install`
+7. Decode keystore from `KEYSTORE_BASE64` secret to `$RUNNER_TEMP/upload-key.jks`
+8. Inject version into `tauri.properties`:
+ - `versionName` from git tag (strip `v` prefix)
+ - `versionCode` from `github.run_number` (monotonically increasing)
+9. `bunx tauri android build --target aarch64 --aab` (signed release AAB)
+10. Upload AAB as workflow artifact
+
+#### Job: `migrate`
+
+Runs **before** `build-release` completes (parallel start, but release publishing waits for both). Steps:
+
+1. Checkout
+2. Setup Supabase CLI
+3. `bunx supabase link --project-ref ${{ secrets.SUPABASE_PROJECT_REF }}`
+4. `bunx supabase db push` (apply pending migrations to production)
+
+**Failure behavior:** If migrations fail, the entire release workflow fails. The AAB is not published. This protects against deploying an app that expects schema changes that did not land.
+
+#### Job: `publish`
+
+Needs: `build-release`, `migrate` (both must succeed). Steps:
+
+1. Download AAB artifact
+2. Create GitHub Release via `softprops/action-gh-release@v2`:
+ - Tag name from `github.ref`
+ - Pre-release flag: `${{ contains(github.ref_name, '-') }}`
+ - Attach AAB file
+3. Upload to Play Store via `r0adkll/upload-google-play@v3`:
+ - `track: internal`
+ - `status: completed`
+ - `packageName: com.rghsoftware.ardentforge`
+
+---
+
+## Signing Architecture
+
+**Approach:** Google Play App Signing (Google holds the app signing key; we use an upload key in CI).
+
+**Keystore handling in CI:**
+
+1. Upload keystore is base64-encoded and stored as `KEYSTORE_BASE64` GitHub Secret
+2. Decoded at build time to `$RUNNER_TEMP/upload-key.jks` (ephemeral, cleaned up with the runner)
+3. Credentials passed via environment variables to Gradle
+
+**Required modification to `src-tauri/gen/android/app/build.gradle.kts`:**
+Add a `signingConfigs` block that reads from environment variables:
+
+```kotlin
+signingConfigs {
+ create("release") {
+ val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
+ if (keystorePath != null) {
+ storeFile = file(keystorePath)
+ storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
+ keyAlias = System.getenv("ANDROID_KEY_ALIAS")
+ keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
+ }
+ }
+}
+```
+
+Then reference it in the `release` build type's `signingConfig`.
+
+---
+
+## Secrets Required
+
+| Secret | Content | Used In |
+| ---------------------------------- | ---------------------------------------------------------- | ------------- |
+| `KEYSTORE_BASE64` | Base64-encoded upload keystore (.jks) | `release.yml` |
+| `KEYSTORE_PASSWORD` | Keystore password | `release.yml` |
+| `KEY_ALIAS` | Key alias within keystore | `release.yml` |
+| `KEY_PASSWORD` | Key password | `release.yml` |
+| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | Play Console service account JSON key | `release.yml` |
+| `SUPABASE_PROJECT_REF` | Supabase remote project reference (`azaioeglqubjqbdssnmi`) | `release.yml` |
+| `SUPABASE_ACCESS_TOKEN` | Supabase personal access token for CLI auth | `release.yml` |
+
+---
+
+## E2E Testing Setup
+
+Playwright is **not yet configured** in the project. This feature will add:
+
+- `@playwright/test` as a dev dependency
+- `playwright.config.ts` configured for Chromium, targeting `http://localhost:4173` (Vite preview server)
+- `e2e/` directory for test files
+- Initial smoke test: app loads, auth page renders
+
+E2E tests run against a local Supabase instance (`bunx supabase start`) seeded with `supabase/seed.sql`. The Vite preview server is started via `bun run preview` (port 4173) pointing at the local Supabase URL.
+
+---
+
+## Version Injection Strategy
+
+The `src-tauri/gen/android/app/tauri.properties` file controls Android versioning:
+
+```properties
+tauri.android.versionName=0.8.0
+tauri.android.versionCode=8000
+```
+
+**In CI:**
+
+- `versionName` is derived from the git tag: `echo "${GITHUB_REF_NAME#v}"` (e.g., `v1.2.0` becomes `1.2.0`)
+- `versionCode` uses `${{ github.run_number }}` -- a monotonically increasing integer provided by GitHub Actions, guaranteeing each upload to Play Store has a higher version code
+
+This is written to `tauri.properties` before the build step.
+
+---
+
+## Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+| ----------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------ |
+| Cold Android build takes 15-20 min | Slow PR feedback | Aggressive caching (Cargo, Gradle, Bun); debug build targets single arch only |
+| NDK version drift on runner images | Build breakage | Pin NDK version explicitly via `sdkmanager` |
+| Supabase migration fails on release | Blocked release | Migration runs as a gate; release only publishes if migrations succeed |
+| Keystore secret rotation | Requires coordinated update | Document rotation procedure; use Play App Signing so upload key is replaceable |
+| First Play Store upload must be manual | One-time setup step | Document as a prerequisite in Steps.md |
+| `gen/android/` Gradle files modified by Tauri updates | CI build breaks after Tauri upgrade | Pin `@tauri-apps/cli` version; test upgrades in a dedicated PR |
+
+---
+
+## ADRs Created
+
+- [ADR-007: GitHub Actions for CI/CD over alternatives](../../Decisions/ADR-007-github-actions-cicd.md)
diff --git a/Context/Features/011-deep-link-handler/Spec.md b/Context/Features/011-deep-link-handler/Spec.md
new file mode 100644
index 00000000..9e0aad54
--- /dev/null
+++ b/Context/Features/011-deep-link-handler/Spec.md
@@ -0,0 +1,66 @@
+# Feature 011: Deep Link Handler for `ardentforge://connect`
+
+## Overview
+
+Enable the `ardentforge://connect?url=...&key=...` deep link to function as a first-class onboarding path. When a user taps an invite link or scans a QR code that produces one, the app opens directly to the appropriate screen with credentials pre-populated and auto-validated. This completes the onboarding link chain: QR generation (010) produces links, this feature consumes them at the OS level.
+
+## Problem Statement
+
+The `ardentforge://connect` URL scheme is already generated by the QR code and invite link features (Refactor B / Feature 010), but tapping one of these links on a device with the app installed does nothing useful. The deep link plugin only registers the `auth` host, and the `onOpenUrl` listener only processes OAuth callbacks. Users must manually copy-paste invite links into the setup screen rather than having the app handle them natively.
+
+## User Stories
+
+1. **As an unconfigured user**, I tap an `ardentforge://connect` link shared by a friend, and the app opens to the setup screen with the server credentials already filled in and validating -- I just wait for the green check and sign in.
+
+2. **As a configured user**, I tap an invite link for the same server I'm already on, and see a brief "Already connected to this server" confirmation -- no disruption to my session.
+
+3. **As a configured user**, I tap an invite link for a different server, and the app navigates me to the backend settings where I can review and confirm the switch (with the standard data-wipe warning on Tauri).
+
+4. **As a browser user**, I visit `/connect?url=...&key=...` and get the same behavior as the native deep link -- redirect to setup or settings with pre-populated values.
+
+5. **As a user who receives a malformed link**, I see an "Invalid invite link" toast rather than a crash or silent failure.
+
+## Requirements
+
+### Must Have
+
+- **M1:** Register `ardentforge://connect` as a handled deep link host in Tauri config and Android manifest
+- **M2:** Extend the `onOpenUrl` listener to dispatch based on URL host/path (`auth` vs `connect`)
+- **M3:** Implement onboarding handler that parses `url` and `key` query params from `ardentforge://connect` URLs
+- **M4:** Unconfigured app: navigate to `/setup` with pre-populated Supabase URL and key, auto-trigger validation
+- **M5:** Configured app, same instance: show "Already connected to this server" toast, no navigation
+- **M6:** Configured app, different instance: navigate to backend settings with pre-populated change form
+- **M7:** Malformed deep link (missing params): show "Invalid invite link" toast
+- **M8:** Add TanStack Router `/connect` route for browser-mode equivalent
+- **M9:** `/connect` route applies same logic: unconfigured -> setup, configured same -> toast, configured different -> settings
+- **M10:** Setup screen accepts `url` and `key` via route search params for pre-population and auto-validation
+- **M11:** Existing OAuth deep link flow (`ardentforge://auth/callback`) continues to work without regression
+
+### Won't Have (this iteration)
+
+- **W1:** Universal links / App Links (HTTPS-based deep links requiring domain verification) -- custom scheme only
+- **W2:** Deep link for other actions beyond `connect` (e.g., `ardentforge://workout/123`)
+- **W3:** Prompt to install the app if it's not installed (deferred to app store listing)
+- **W4:** Desktop app support -- this is a mobile + browser app only
+
+## Testable Assertions
+
+- **TA1:** `onOpenUrl` callback receiving `ardentforge://connect?url=X&key=Y` on an unconfigured app results in navigation to `/setup` with `url=X` and `key=Y` in search params
+- **TA2:** `onOpenUrl` callback receiving `ardentforge://connect?url=X&key=Y` where X matches current config shows toast and does not navigate
+- **TA3:** `onOpenUrl` callback receiving `ardentforge://connect?url=X&key=Y` where X differs from current config navigates to backend settings
+- **TA4:** `onOpenUrl` callback receiving `ardentforge://connect` (missing params) shows "Invalid invite link" toast
+- **TA5:** `onOpenUrl` callback receiving `ardentforge://auth/callback?code=Z` still triggers OAuth exchange (regression check)
+- **TA6:** Browser navigation to `/connect?url=X&key=Y` on unconfigured app redirects to `/setup?url=X&key=Y`
+- **TA7:** Browser navigation to `/connect?url=X&key=Y` on configured app (same) shows toast
+- **TA8:** Browser navigation to `/connect?url=X&key=Y` on configured app (different) redirects to profile/settings
+- **TA9:** Setup screen mounted with `url` and `key` search params expands advanced section, pre-populates fields, and auto-triggers validation
+- **TA10:** `tauri.conf.json` deep-link plugin config includes `connect` host entry
+
+## Open Questions
+
+1. **Backend settings pre-population**: The backend settings component (`BackendSettings`) currently uses local state for the change form. Pre-populating from a deep link requires either route search params on `/profile` or a transient Zustand store. _Recommendation: use a lightweight Zustand transient store to avoid coupling settings layout to route params._
+
+## Dependencies
+
+- **Feature 010 (QR Code Gen + Scan):** Produces the `ardentforge://connect` URLs that this feature handles. The `buildInviteLink` and `parseInviteLink` utilities from `src/lib/invite-link.ts` are reused directly.
+- **Existing deep-link plugin:** Already initialized in `lib.rs` and configured in `tauri.conf.json`. This feature extends the existing config rather than adding a new plugin.
diff --git a/Context/Features/011-deep-link-handler/Steps.md b/Context/Features/011-deep-link-handler/Steps.md
new file mode 100644
index 00000000..af024e8a
--- /dev/null
+++ b/Context/Features/011-deep-link-handler/Steps.md
@@ -0,0 +1,298 @@
+# Feature 011: Deep Link Handler -- Implementation Steps
+
+## Team Composition
+
+| Role | Agent | Stacks |
+| ------------------- | ---------- | ------------------------------------------- |
+| Frontend Specialist | `frontend` | React, TypeScript, TanStack Router, Zustand |
+
+Single-agent feature -- all changes are frontend/config, tightly coupled, and must land together.
+
+## Implementation Waves
+
+### Wave 1: Foundation (new modules + config)
+
+These tasks have no dependencies on each other and can execute in parallel.
+
+---
+
+#### S001: Create pending connect transient store
+
+**Agent:** `frontend`
+**Files:** `src/lib/pending-connect.ts` (new)
+**Depends on:** nothing
+**Parallel:** yes (with S002, S003, S004)
+
+Create `src/lib/pending-connect.ts` with a minimal Zustand store:
+
+```typescript
+interface PendingConnectState {
+ pending: { url: string; key: string } | null
+ setPending: (url: string, key: string) => void
+ clear: () => void
+}
+```
+
+Export `usePendingConnect` (the Zustand hook). The store must be accessible outside React via `usePendingConnect.getState()`.
+
+**Done when:** Store exports `usePendingConnect` with `pending`, `setPending`, `clear`.
+
+---
+
+#### S002: Create deep link handler module
+
+**Agent:** `frontend`
+**Files:** `src/lib/deep-link-handler.ts` (new)
+**Depends on:** S001 (import pending connect store)
+**Parallel:** no -- needs S001's export
+
+Create `src/lib/deep-link-handler.ts` with a single exported async function:
+
+```typescript
+export async function handleConnectLink(urlStr: string): Promise
+```
+
+Logic:
+
+1. Call `parseInviteLink(urlStr)` from `src/lib/invite-link.ts`. If null, `toast('Invalid invite link')` and return.
+2. Call `await getConfigStore().hasConfig()`.
+3. If no config: `window.location.href = '/setup?url=${encodeURIComponent(parsed.url)}&key=${encodeURIComponent(parsed.key)}'` and return.
+4. If config exists: `await getConfigStore().getConfig()` and compare `config.supabaseUrl` with `parsed.url`.
+5. Same instance: `toast('Already connected to this server')` and return.
+6. Different instance: call `usePendingConnect.getState().setPending(parsed.url, parsed.key)`, then `window.location.href = '/profile'`.
+
+Imports: `parseInviteLink` from `@/lib/invite-link`, `getConfigStore` from `@/lib/config-store`, `usePendingConnect` from `@/lib/pending-connect`, `toast` from `sonner`.
+
+**Done when:** `handleConnectLink` correctly branches for all four cases (invalid, unconfigured, same, different).
+
+---
+
+#### S003: Register `connect` host in Tauri config
+
+**Agent:** `frontend`
+**Files:** `src-tauri/tauri.conf.json`
+**Depends on:** nothing
+**Parallel:** yes (with S001, S004)
+
+Add a second entry to `plugins.deep-link.mobile`:
+
+```json
+"deep-link": {
+ "mobile": [
+ { "schemes": ["ardentforge"], "host": "auth" },
+ { "schemes": ["ardentforge"], "host": "connect" }
+ ]
+}
+```
+
+**Done when:** `tauri.conf.json` has both `auth` and `connect` hosts registered.
+
+---
+
+#### S004: Exempt `/connect` from root route guard
+
+**Agent:** `frontend`
+**Files:** `src/routes/__root.tsx`
+**Depends on:** nothing
+**Parallel:** yes (with S001, S002, S003)
+
+Add `location.pathname === '/connect'` to the existing bypass condition in `beforeLoad`:
+
+```typescript
+if (
+ location.pathname === '/setup' ||
+ location.pathname === '/connect' ||
+ location.pathname.startsWith('/s/') ||
+ location.pathname === '/display'
+)
+ return
+```
+
+**Done when:** Navigating to `/connect` does not redirect to `/setup` regardless of config state.
+
+---
+
+### Wave 2: Integration (wire up existing components)
+
+These tasks depend on Wave 1 outputs and modify existing files.
+
+---
+
+#### S005: Add dispatch branch to `onOpenUrl` listener
+
+**Agent:** `frontend`
+**Files:** `src/lib/auth.tsx`
+**Depends on:** S002
+**Parallel:** yes (with S006, S007, S008)
+
+In the `onOpenUrl` callback (around line 173), add a hostname check before the existing OAuth logic:
+
+```typescript
+const url = new URL(urlStr)
+if (url.hostname === 'connect') {
+ await handleConnectLink(urlStr)
+ return
+}
+```
+
+Add import: `import { handleConnectLink } from '@/lib/deep-link-handler'`
+
+The existing OAuth handling (code exchange, error redirect) remains completely untouched -- the new branch returns early before reaching it.
+
+**Done when:** Deep links with `connect` hostname dispatch to `handleConnectLink`; `auth` hostname links still trigger OAuth exchange.
+
+---
+
+#### S006: Create `/connect` browser route
+
+**Agent:** `frontend`
+**Files:** `src/routes/connect.tsx` (new)
+**Depends on:** S002, S004
+**Parallel:** yes (with S005, S007, S008)
+
+Create a TanStack Router file route at `src/routes/connect.tsx`:
+
+1. `validateSearch`: extract `url` and `key` as optional strings.
+2. Component: a minimal component that calls `handleConnectLink` in a `useEffect` on mount. Constructs the full `ardentforge://connect?url=...&key=...` string from search params and passes it to `handleConnectLink`. If either param is missing, show toast and redirect to `/setup`.
+3. Renders nothing visible (or a brief loading indicator) since the handler navigates away immediately.
+
+**Done when:** Browser navigation to `/connect?url=X&key=Y` triggers the same three-way branching as the Tauri deep link.
+
+---
+
+#### S007: Setup screen accepts pre-populated search params
+
+**Agent:** `frontend`
+**Files:** `src/routes/setup.tsx`
+**Depends on:** S002
+**Parallel:** yes (with S005, S006, S008)
+
+Two changes to the setup route:
+
+1. **Add `validateSearch`** to the route definition:
+
+ ```typescript
+ validateSearch: (search): { url?: string; key?: string } => ({
+ url: (search.url as string) || undefined,
+ key: (search.key as string) || undefined,
+ }),
+ ```
+
+2. **Add a `useEffect`** in `SetupPage` that checks for pre-populated values from search params (via `Route.useSearch()`). If both `url` and `key` are present:
+ - Set `url` and `key` state
+ - Set `advancedOpen` to `true`
+ - Call `validateAndSave(search.url, search.key)` to auto-trigger validation
+
+Guard with a ref to prevent double-execution (same pattern as the existing `autoValidated` ref).
+
+**Done when:** Navigating to `/setup?url=X&key=Y` expands the advanced section, pre-populates fields, and auto-validates.
+
+---
+
+#### S008: BackendSettings consumes pending connect store
+
+**Agent:** `frontend`
+**Files:** `src/components/profile/backend-settings.tsx`
+**Depends on:** S001
+**Parallel:** yes (with S005, S006, S007)
+
+Add a `useEffect` in `BackendSettings` that checks the pending connect store on mount:
+
+```typescript
+useEffect(() => {
+ const { pending, clear } = usePendingConnect.getState()
+ if (pending) {
+ setEditing(true)
+ setUrl(pending.url)
+ setKey(pending.key)
+ clear()
+ }
+}, [])
+```
+
+When pending values exist, the component opens the editing form pre-populated with the new backend's URL and key. The user reviews and clicks "Connect" to trigger the existing `handleSubmit` flow (validation, Tauri confirmation dialog, backend change).
+
+**Done when:** After a deep link for a different instance, BackendSettings opens with pre-populated fields ready for user confirmation.
+
+---
+
+### Wave 3: Verification
+
+---
+
+#### S009: Route tree regeneration + build verification
+
+**Agent:** `frontend`
+**Files:** `src/routeTree.gen.ts` (auto-generated)
+**Depends on:** S006
+**Parallel:** no
+
+Run `bun run build` to:
+
+1. Trigger TanStack Router codegen (regenerates `routeTree.gen.ts` with the new `/connect` route)
+2. Verify TypeScript compilation passes
+3. Verify no import errors or type mismatches
+
+**Done when:** `bun run build` succeeds with zero errors.
+
+---
+
+#### S010: Unit tests for deep link handler
+
+**Agent:** `frontend`
+**Files:** `src/lib/__tests__/deep-link-handler.test.ts` (new)
+**Depends on:** S002
+**Parallel:** yes (with S009)
+
+Write tests for `handleConnectLink()`:
+
+| Test case | Setup | Assertion |
+| ---------------------------------------- | ------------------------------------ | ------------------------------------------------------------- |
+| Invalid link (not ardentforge://connect) | n/a | `toast('Invalid invite link')` called |
+| Missing params | `ardentforge://connect` (no url/key) | `toast('Invalid invite link')` called |
+| Unconfigured app | Mock `hasConfig()` -> false | `window.location.href` set to `/setup?url=...&key=...` |
+| Configured, same instance | Mock `getConfig()` -> matching URL | `toast('Already connected to this server')` called |
+| Configured, different instance | Mock `getConfig()` -> different URL | `setPending` called, `window.location.href` set to `/profile` |
+
+Mock `getConfigStore` (return mock object), `toast` (from sonner), and `window.location.href` (use `Object.defineProperty` or jsdom assignment).
+
+**Done when:** All 5 test cases pass via `bun run test`.
+
+---
+
+## Milestone Summary
+
+| Wave | Tasks | Parallel? | Validates |
+| ---- | --------- | ---------------------------------------- | ----------------------------------------------- |
+| 1 | S001-S004 | S001+S003+S004 parallel, S002 after S001 | New modules exist, config updated |
+| 2 | S005-S008 | All parallel | Deep links dispatch correctly, UI pre-populates |
+| 3 | S009-S010 | S009+S010 parallel | Build passes, tests pass |
+
+#### S011: Tests for /connect route and auth.tsx connect dispatch
+
+**Agent:** `frontend`
+**Files:** `src/routes/__tests__/connect.test.tsx` (new), `src/lib/__tests__/auth-oauth.test.tsx`
+**Depends on:** S006, S005
+**Parallel:** no
+
+Add tests for:
+
+1. `/connect` route component: missing-params guard (toasts + redirects to /setup), error catch when `handleConnectLink` rejects (toasts + redirects to /setup), URL reconstruction from search params.
+2. `auth.tsx` connect dispatch: verify `url.hostname === 'connect'` routes to `handleConnectLink` instead of OAuth code exchange. Verify non-connect hostnames still go through OAuth flow.
+
+**Done when:** Both the `/connect` route edge cases and the `onOpenUrl` connect dispatch branch have test coverage.
+
+---
+
+## Milestone Summary
+
+| Wave | Tasks | Parallel? | Validates |
+| ---- | --------- | ---------------------------------------- | ----------------------------------------------- |
+| 1 | S001-S004 | S001+S003+S004 parallel, S002 after S001 | New modules exist, config updated |
+| 2 | S005-S008 | All parallel | Deep links dispatch correctly, UI pre-populates |
+| 3 | S009-S010 | S009+S010 parallel | Build passes, tests pass |
+| 4 | S011 | Independent | Route + dispatch edge cases covered |
+
+**Total tasks:** 11
+**Critical path:** S001 -> S002 -> S005 (Tauri dispatch wired up)
+**Estimated effort:** 0.5 days
diff --git a/Context/Features/011-deep-link-handler/Tech.md b/Context/Features/011-deep-link-handler/Tech.md
new file mode 100644
index 00000000..7f54d62e
--- /dev/null
+++ b/Context/Features/011-deep-link-handler/Tech.md
@@ -0,0 +1,184 @@
+# Feature 011: Deep Link Handler -- Technical Plan
+
+## Architecture Overview
+
+The deep link handler adds a dispatch layer to the existing `onOpenUrl` listener and a parallel browser route, both funneling into a shared handler function. No new plugins, no Rust changes, no schema changes.
+
+```
+ ┌──────────────────────┐
+ │ OS deep link event │
+ │ ardentforge://... │
+ └──────────┬───────────┘
+ │
+ ┌──────────▼───────────┐
+ │ onOpenUrl listener │
+ │ (auth.tsx) │
+ └──────────┬───────────┘
+ │ dispatch on hostname
+ ┌────────────────┼────────────────┐
+ │ │
+ ┌─────────▼──────────┐ ┌──────────▼──────────┐
+ │ hostname: "auth" │ │ hostname: "connect" │
+ │ (existing OAuth) │ │ handleConnectLink() │
+ └────────────────────┘ └──────────┬──────────┘
+ │
+ ┌─────────────────────┼─────────────────────┐
+ │ │ │
+ ┌─────────▼────────┐ ┌─────────▼────────┐ ┌─────────▼────────┐
+ │ No config │ │ Same instance │ │ Diff instance │
+ │ → /setup?url&key│ │ → toast │ │ → /profile │
+ └──────────────────┘ └──────────────────┘ │ + pending store │
+ └──────────────────┘
+
+ ┌──────────────────────┐
+ │ Browser: /connect │──── same handleConnectLink() ────► same 3 branches
+ └──────────────────────┘
+```
+
+## Key Decisions
+
+### D1: Shared handler function vs inline logic
+
+**Decision:** Extract `handleConnectLink(urlStr: string)` into `src/lib/deep-link-handler.ts`.
+
+Both the Tauri `onOpenUrl` callback and the browser `/connect` route need the same three-way branching logic (unconfigured / same instance / different instance). A shared function avoids duplication and is independently testable.
+
+The function uses `parseInviteLink()` from `src/lib/invite-link.ts` (already exists) and `getConfigStore()` for config checks. It returns nothing -- it performs navigation and toasts as side effects, matching the existing pattern in the auth deep link handler.
+
+**Navigation:** Uses `window.location.href` for redirects (same pattern as existing `auth.tsx` deep link handler at line 179). This works because the handler runs outside React's render cycle in Tauri mode, and in the browser `/connect` route it triggers a full page transition which is acceptable for a one-time redirect.
+
+### D2: Transient Zustand store for backend settings pre-population
+
+**Decision:** Create `src/lib/pending-connect.ts` with a minimal Zustand store.
+
+**Why not route search params on `/profile`:** The profile page is a general-purpose authenticated route. Adding `connectUrl` and `connectKey` search params couples it to deep link concerns. The search params would also be visible in the URL bar and browser history, leaking backend credentials into navigation state.
+
+**Why Zustand transient store:** Zustand stores are accessible both inside React (`usePendingConnect()` hook) and outside React (`usePendingConnect.getState().setPending(url, key)`). This lets the Tauri deep link handler (non-React) set the pending values, then the BackendSettings component (React) consumes them on mount. The store is ephemeral -- values are cleared after consumption and lost on page reload, which is the correct behavior.
+
+```typescript
+// src/lib/pending-connect.ts
+import { create } from 'zustand'
+
+interface PendingConnectState {
+ pending: { url: string; key: string } | null
+ setPending: (url: string, key: string) => void
+ clear: () => void
+}
+
+export const usePendingConnect = create((set) => ({
+ pending: null,
+ setPending: (url, key) => set({ pending: { url, key } }),
+ clear: () => set({ pending: null }),
+}))
+```
+
+### D3: Setup screen pre-population via route search params
+
+**Decision:** Add `url` and `key` search params to the `/setup` route definition.
+
+Unlike `/profile`, the setup route is purpose-built for onboarding. Search params here are semantically correct and don't leak sensitive info (the setup screen already displays these values in form fields). The route's `validateSearch` extracts and sanitizes the params. A `useEffect` in `SetupPage` detects their presence, expands the advanced section, pre-populates the fields, and auto-triggers validation.
+
+### D4: Tauri deep-link config -- add `connect` host
+
+**Decision:** Add a second entry to the `plugins.deep-link.mobile` array in `tauri.conf.json`.
+
+```json
+"deep-link": {
+ "mobile": [
+ { "schemes": ["ardentforge"], "host": "auth" },
+ { "schemes": ["ardentforge"], "host": "connect" }
+ ]
+}
+```
+
+This registers `ardentforge://connect` as a handled URL scheme on Android. The Android manifest (`gen/android/`) is auto-regenerated by Tauri on the next build. No manual manifest editing needed.
+
+### D5: Root route guard exemption for `/connect`
+
+**Decision:** Add `/connect` to the allowlist in `__root.tsx` `beforeLoad`.
+
+The root route guard redirects to `/setup` when no config exists. The `/connect` route must be reachable regardless of config state (it handles the "unconfigured" case itself). Adding `location.pathname === '/connect'` to the existing bypass condition is a one-line change.
+
+### D6: Dispatch strategy in `onOpenUrl`
+
+**Decision:** Add a hostname check at the top of the existing `onOpenUrl` callback in `auth.tsx`.
+
+```typescript
+const url = new URL(urlStr)
+if (url.hostname === 'connect') {
+ await handleConnectLink(urlStr)
+ return
+}
+// ... existing OAuth handling continues unchanged
+```
+
+This is the minimal change -- a single early return branch. The existing OAuth code is untouched, eliminating regression risk.
+
+## Stack-Specific Details
+
+### Frontend (React/TypeScript)
+
+**New files:**
+| File | Purpose |
+|------|---------|
+| `src/lib/deep-link-handler.ts` | `handleConnectLink()` -- shared handler for connect deep links |
+| `src/lib/pending-connect.ts` | Zustand transient store for pending backend change values |
+| `src/routes/connect.tsx` | TanStack Router `/connect` route (browser fallback) |
+
+**Modified files:**
+| File | Change |
+|------|--------|
+| `src/lib/auth.tsx` | Add dispatch branch in `onOpenUrl` callback (~3 lines) |
+| `src/routes/__root.tsx` | Add `/connect` to guard allowlist (~1 line) |
+| `src/routes/setup.tsx` | Add search param validation + auto-populate effect (~15 lines) |
+| `src/components/profile/backend-settings.tsx` | Consume pending connect store on mount (~10 lines) |
+
+### Tauri (Config only)
+
+**Modified files:**
+| File | Change |
+|------|--------|
+| `src-tauri/tauri.conf.json` | Add `connect` host to deep-link plugin config |
+
+No Rust code changes. No new plugins. Android manifest regenerated automatically.
+
+## Integration Points
+
+1. **`parseInviteLink()` (invite-link.ts):** Already handles `ardentforge://connect?url=...&key=...` parsing. Reused as-is by `handleConnectLink()`.
+
+2. **`getConfigStore()` (config-store.ts):** Used by `handleConnectLink()` to check current config state. All methods are async (Promise-based), which the handler already accounts for.
+
+3. **`validateAndSave()` (setup.tsx):** The setup screen's existing save pipeline. Pre-populated search params trigger this via the auto-validate `useEffect`, not by calling it directly from the handler.
+
+4. **`BackendSettings` (backend-settings.tsx):** The existing `editing` state and `url`/`key` form fields are pre-populated from the pending connect store. The existing `handleSubmit` flow (validate, confirm dialog on Tauri, apply change) runs unchanged.
+
+5. **`toast()` (sonner):** Standalone function, callable outside React. Used by `handleConnectLink()` for "Already connected" and "Invalid invite link" messages.
+
+## Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+| ------------------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
+| `onOpenUrl` dispatch breaks OAuth flow | High | Early return only on `hostname === 'connect'`; all other URLs fall through to existing handler unchanged |
+| Android manifest not regenerated | Medium | Verify `gen/android/` after `tauri.conf.json` change; if stale, `bun tauri android init` regenerates |
+| Setup `beforeLoad` guard conflicts with pre-populated params | Medium | Search params pass through redirect -- if config exists, root guard redirects to `/`, which is correct (configured users shouldn't reach setup) |
+| Pending connect store consumed before BackendSettings mounts | Low | The store is set synchronously before navigation; React render picks it up on mount. `clear()` called only after consuming values |
+| `window.location.href` loses React state | Low | Acceptable for one-time deep link redirect. Matches existing auth handler pattern. New page load bootstraps fresh state |
+
+## Testing Strategy
+
+**Unit tests (Vitest):**
+
+- `handleConnectLink()`: mock `getConfigStore()` and assert navigation + toast behavior for all three branches + invalid input
+- `parseInviteLink()`: already tested; no changes needed
+
+**Manual testing:**
+
+- Android: `adb shell am start -a android.intent.action.VIEW -d "ardentforge://connect?url=...&key=..."` to simulate deep link
+- Browser: navigate to `/connect?url=...&key=...` directly
+- Verify OAuth deep links still work after dispatch change
+
+## ADR
+
+### ADR-007: Transient Zustand store for cross-route deep link state
+
+See `Context/Decisions/ADR-007-transient-store-deep-link-state.md`.
diff --git a/Context/Features/012-TauriAdapter-Tests/Spec.md b/Context/Features/012-TauriAdapter-Tests/Spec.md
new file mode 100644
index 00000000..d98a35d5
--- /dev/null
+++ b/Context/Features/012-TauriAdapter-Tests/Spec.md
@@ -0,0 +1,78 @@
+# Feature 012: TauriAdapter Unit Tests
+
+**Status:** Done
+**Issue:** [#16](https://github.com/rghsoftware/ardent-forge/issues/16)
+**Created:** 2026-04-04
+
+## Overview
+
+Add comprehensive unit test coverage for `TauriAdapter` (`src/lib/tauri-adapter.ts`, 2308 lines, 88 class methods + 30 standalone functions). This is the entire offline-first data path -- bugs here cause silent data loss for desktop users.
+
+## Problem Statement
+
+The TauriAdapter has zero test coverage. The SupabaseAdapter has comprehensive tests (1822 lines in `src/lib/__tests__/supabase-adapter.test.ts`). Both adapters implement the same `DataAdapter` interface, so the TauriAdapter should have equivalent coverage.
+
+The TauriAdapter contains non-trivial logic in:
+
+- Type conversion helpers (SQLite integers to booleans, JSON parsing, null guards)
+- Error translation (`TauriAppError` to `AdapterError`)
+- Row-to-domain mappers (20+ functions)
+- Complex aggregation methods that compose multiple `invoke()` calls
+- Argument serialization for Rust IPC
+
+## User Stories
+
+1. As a developer, I want TauriAdapter tests so regressions in the offline data path are caught before release.
+2. As a developer, I want the test structure to mirror the SupabaseAdapter tests so both adapters are easy to compare and maintain.
+
+## Requirements
+
+### Must Have
+
+- **M1:** Test all 4 conversion helpers: `intToBool()`, `parseJson()`, `requireString()`, `isoToUnixSeconds()`
+- **M2:** Test `isTauriAppError` type guard and `AdapterError` error translation
+- **M3:** Test `invokeCommand` wrapper (success path + error wrapping)
+- **M4:** Test all 20+ row mapper functions (`toExerciseRow`, `toWorkoutLogRow`, etc.)
+- **M5:** Test CRUD operations -- exercise, workout log, logged activity group/activity/set, user profile, 1RM history
+- **M6:** Test complex aggregation methods: `getWorkoutLogFull`, `createWorkoutLogFull`, `getExerciseWorkoutHistory`, `getWeeklyVolume`, `getVaultSummary`
+- **M7:** Test session template CRUD: `getSessionTemplates`, `getSessionTemplateFull`, `createSessionTemplateFull`, `updateSessionTemplateFull`, `deleteSessionTemplate`
+- **M8:** Test program CRUD: `getPrograms`, `getProgramFull`, `createProgramFull`, `updateProgramFull`, `deleteProgram`
+- **M9:** Test program activation methods: `getActiveProgram`, `setActiveProgram`, `updateActiveProgram`, `clearActiveProgram`
+- **M10:** Test accountability group + member + invite methods
+- **M11:** Test direct connection methods
+- **M12:** Test chat/conversation methods: `createConversation`, `getConversations`, `sendMessage`, `getMessages`, `getUnreadCounts`, etc.
+- **M13:** Mock `@tauri-apps/api/core` `invoke` function (same pattern as existing auth test mocks)
+- **M14:** Test standalone helpers: `getMonday()`, `formatWeekLabel()`
+
+### Should Have
+
+- **S1:** Test filter argument serialization in `getExercises()` (category, movement pattern, search, custom-only filters)
+- **S2:** Test error paths -- `invoke` rejecting with TauriAppError vs unknown error
+- **S3:** Test null/undefined edge cases in row mappers (nullable columns)
+- **S4:** Test methods that return `null` when entity not found (`getExercise`, `getWorkoutLog`, `getSessionTemplate`, etc.)
+
+### Won't Have (this iteration)
+
+- Integration tests with actual SQLite/Tauri runtime
+- Performance benchmarks
+- E2E tests through the UI layer
+
+## Testable Assertions
+
+- **TA1:** All conversion helpers produce correct output for representative inputs (including edge cases like null, 0, empty string)
+- **TA2:** `AdapterError` correctly maps `TauriAppError` fields to `message`, `code`, `details`
+- **TA3:** Every row mapper transforms a Tauri response interface into the correct domain Row type
+- **TA4:** CRUD methods invoke the correct Tauri command name with the correct argument shape
+- **TA5:** Aggregation methods compose multiple invoke results correctly (e.g. `getWorkoutLogFull` assembles log + groups + activities + sets)
+- **TA6:** Methods that throw `not implemented` (event items, share links, clone) do so consistently
+- **TA7:** `getExercises` passes filter arguments through to invoke correctly
+- **TA8:** All tests pass (`bun run test`)
+
+## Open Questions
+
+None -- scope is well-defined by the issue and the existing SupabaseAdapter test precedent.
+
+## Dependencies
+
+- Existing `@tauri-apps/api/core` mock pattern (used in auth tests)
+- `DataAdapter` interface and domain types from `src/lib/database.types.ts` / `src/domain/types/`
diff --git a/Context/Features/012-TauriAdapter-Tests/Steps.md b/Context/Features/012-TauriAdapter-Tests/Steps.md
new file mode 100644
index 00000000..f720df0c
--- /dev/null
+++ b/Context/Features/012-TauriAdapter-Tests/Steps.md
@@ -0,0 +1,357 @@
+# Feature 012: TauriAdapter Unit Tests -- Implementation Steps
+
+**Status:** Done
+**Created:** 2026-04-04
+
+## Team Composition
+
+| Role | Agent Type | Rationale |
+| ----------- | ---------------- | -------------------------------------------------------------------------- |
+| test-writer | backend-engineer | Single specialist -- all work is in one test file with one mock dependency |
+
+This is a focused, single-file task. No cross-domain coordination needed, so a single specialist is sufficient.
+
+## Implementation Steps
+
+### Wave 1: Scaffolding + Helpers + Error Handling
+
+#### S001: Scaffold test file with mock setup, fixtures, and conversion helper tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** nothing
+**Parallel:** no (foundational)
+
+**Tasks:**
+
+1. Create `src/lib/__tests__/tauri-adapter.test.ts`
+2. Add `vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() }))` before imports
+3. Import `invoke` from `@tauri-apps/api/core`, `TauriAdapter`, `AdapterError` from `../tauri-adapter`
+4. Define Tauri response fixtures at module level (matching `TauriXxxResponse` shapes -- integers for bools, JSON strings for objects):
+ - `tauriExerciseResponse` (with `is_bilateral: 1`, `supports_1rm: 1`, `is_custom: 0`, `muscle_groups` as JSON string, etc.)
+ - `tauriWorkoutLogResponse`
+ - `tauriLoggedActivityGroupResponse`
+ - `tauriLoggedActivityResponse`
+ - `tauriLoggedSetResponse`
+ - `tauriUserProfileResponse`
+ - `tauriOneRepMaxHistoryResponse`
+ - `tauriSessionTemplateResponse`, `tauriActivityGroupResponse`, `tauriActivityResponse`
+ - `tauriProgramResponse`, `tauriBlockResponse`, `tauriBlockWeekResponse`, `tauriScheduledSessionResponse`
+ - `tauriProgramActivationResponse`
+ - `tauriAccountabilityGroupResponse`, `tauriGroupMemberResponse`, `tauriGroupInviteResponse`
+ - `tauriDirectConnectionResponse`
+ - `tauriConversationResponse`, `tauriMessageResponse`, `tauriMediaAttachmentResponse`
+5. Add `beforeEach` with `vi.clearAllMocks()` and `adapter = new TauriAdapter('user-001')`
+6. Write `describe('Error handling')`:
+ - `describe('invokeCommand')`:
+ - `it('wraps TauriAppError in AdapterError')` -- mock invoke to reject with `{ kind: 'NOT_FOUND', message: 'not found' }`, call any adapter method, assert `AdapterError` thrown with correct `kind` and `message`
+ - `it('preserves field property on AdapterError')` -- reject with `{ kind: 'VALIDATION', message: '...', field: 'name' }`
+ - `it('re-throws non-TauriAppError unchanged')` -- reject with `new Error('network')`, assert plain Error thrown
+ - `describe('AdapterError')`:
+ - `it('has name "AdapterError"')`
+ - `it('maps all error kinds')` -- test each of NOT_FOUND, CONFLICT, VALIDATION, DATABASE, INTERNAL
+
+**Acceptance criteria:**
+
+- File compiles with no TypeScript errors
+- Error handling tests pass
+- Fixtures cover all Tauri response interfaces used by the adapter
+
+---
+
+### Wave 2: Core CRUD Operations
+
+#### S002: Exercise operation tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S001
+**Parallel:** no (same file, sequential waves)
+
+**Tasks:**
+
+1. `describe('Exercise operations')`:
+ - `describe('getExercises')`:
+ - `it('returns mapped exercises with no filters')` -- mock invoke to return `[tauriExerciseResponse]`, assert result has booleans (not ints), parsed objects (not JSON strings), correct field mapping
+ - `it('passes category filter to invoke')` -- assert invoke called with `{ userId: 'user-001', category: 'BARBELL' }`
+ - `it('passes movement pattern filter')`
+ - `it('passes search filter')`
+ - `it('passes customOnly filter')`
+ - `it('returns empty array when no exercises')` -- mock invoke to return `[]`
+ - `describe('getExercise')`:
+ - `it('returns mapped exercise by id')`
+ - `it('returns null when not found')` -- mock invoke to return `null`
+ - `describe('createExercise')`:
+ - `it('invokes create command with correct args and returns mapped result')`
+
+**Validates:** TA1, TA3, TA4, TA7
+
+#### S003: Workout log operation tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S002
+
+**Tasks:**
+
+1. `describe('Workout log operations')`:
+ - `describe('getWorkoutLogs')` -- returns mapped list, passes userId and limit
+ - `describe('getWorkoutLogsSummary')` -- returns summary list with limit/offset
+ - `describe('getWorkoutLog')` -- returns single or null
+ - `describe('getWorkoutLogFull')` -- mock invoke to return `{ log, groups, activities, sets }` composite, assert all sub-arrays mapped correctly (TA5)
+ - `describe('createWorkoutLog')` -- correct args, returns mapped result
+ - `describe('updateWorkoutLog')` -- correct args
+ - `describe('deleteWorkoutLog')` -- invokes delete command
+ - `describe('createLoggedActivityGroup')` -- correct args including userId
+ - `describe('createLoggedActivity')` -- correct args
+ - `describe('createLoggedSet')` -- correct args with set field mapping
+ - `describe('updateLoggedSet')` -- correct args
+ - `describe('createWorkoutLogFull')` -- complex creation with groups/activities/sets (TA5)
+
+**Validates:** TA3, TA4, TA5
+
+#### S004: User profile and 1RM tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S003
+
+**Tasks:**
+
+1. `describe('User profile operations')`:
+ - `getUserProfile` -- returns mapped profile or null
+ - `updateUserProfile` -- passes partial updates
+2. `describe('One rep max operations')`:
+ - `saveOneRepMax` -- correct args
+ - `getOneRepMaxHistory` -- returns list
+3. `describe('Exercise history')`:
+ - `getRecentlyUsedExerciseIds` -- returns string array
+ - `getExerciseWorkoutHistory` -- returns composite `WorkoutWithSets[]`
+
+**Validates:** TA3, TA4
+
+---
+
+### Wave 3: Templates, Programs, Social
+
+#### S005: Session template operation tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S004
+
+**Tasks:**
+
+1. `describe('Session template operations')`:
+ - `getSessionTemplates` -- returns mapped list
+ - `getSessionTemplate` -- single or null
+ - `getSessionTemplateFull` -- composite with groups and activities
+ - `createSessionTemplateFull` -- complex creation
+ - `updateSessionTemplateFull` -- complex update
+ - `deleteSessionTemplate` -- invokes delete
+ - `cloneSessionTemplate` -- throws not implemented (TA6)
+
+**Validates:** TA3, TA4, TA6
+
+#### S006: Program and activation tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S005
+
+**Tasks:**
+
+1. `describe('Program operations')`:
+ - `getPrograms` -- returns mapped list
+ - `getProgramFull` -- composite with blocks/weeks/sessions
+ - `createProgramFull` -- complex creation
+ - `updateProgramFull` -- complex update
+ - `deleteProgram` -- invokes delete
+ - `assignProgramToMember` -- correct args
+2. `describe('Program activation')`:
+ - `getActiveProgram` -- returns activation or null
+ - `setActiveProgram` -- correct args including optional startDate
+ - `updateActiveProgram` -- passes partial updates
+ - `clearActiveProgram` -- invokes clear
+
+**Validates:** TA3, TA4
+
+#### S007: Accountability group, member, invite, and connection tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S006
+
+**Tasks:**
+
+1. `describe('Accountability groups')`:
+ - `createGroup`, `getGroups`, `getGroup`, `updateGroup`, `deleteGroup`
+2. `describe('Group members')`:
+ - `getGroupMembers`, `removeGroupMember`, `updateMemberRole`
+3. `describe('Group invites')`:
+ - `createInvite`, `getGroupInvites`, `revokeInvite`, `joinGroupByCode`
+4. `describe('Direct connections')`:
+ - `requestConnection`, `getConnections`, `getPendingConnections`, `acceptConnection`, `declineConnection`, `removeConnection`, `updateConnectionWriteAccess`
+5. `describe('Activity feed')`:
+ - `getGroupActivityFeed`, `getConnectionActivityFeed`
+
+**Validates:** TA3, TA4
+
+---
+
+### Wave 4: Chat, Analytics, Stubs, Verification
+
+#### S008: Chat operation tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S007
+
+**Tasks:**
+
+1. `describe('Chat operations')`:
+ - `createConversation` -- correct args including type, participantIds, optional title/groupId
+ - `getConversations` -- returns mapped list
+ - `getConversation` -- single or null
+ - `findDirectConversation` -- returns conversation or null
+ - `sendMessage` -- correct args
+ - `getMessages` -- passes before/limit options
+ - `getMessagesSince` -- passes since param
+ - `updateLastRead` -- invokes command
+ - `getUnreadCounts` -- returns Map
+ - `addParticipant` -- correct args
+ - `leaveConversation` -- invokes command
+ - `toggleArchive` -- invokes command
+ - `saveMediaAttachment` -- correct args
+ - `getMediaAttachments` -- passes messageIds array
+ - `updateMediaAttachment` -- throws not implemented (TA6)
+
+**Validates:** TA3, TA4, TA6
+
+#### S009: Analytics and standalone helper tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S008
+
+**Tasks:**
+
+1. `describe('Analytics')`:
+ - `describe('getWeeklyVolume')` -- mock invoke to return raw workout data, assert weekly aggregation with correct week labels (exercises `getMonday`/`formatWeekLabel` indirectly)
+ - `describe('getVaultSummary')` -- mock invoke, assert summary shape
+2. `describe('Not-implemented stubs')`:
+ - Test all methods that throw "not implemented": event item methods (`getEventItems`, `saveEventItem`, `updateEventItem`, `deleteEventItem`, `toggleEventItemPacked`, `reorderEventItems`), share link methods (`getShareLinks`, `getShareLinksForEntity`, `createShareLink`, `revokeShareLink`, `deleteShareLink`)
+ - Each should throw with a descriptive message (TA6)
+
+**Validates:** TA5, TA6
+
+#### S009-T: Run full test suite and fix failures
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S009
+
+**Tasks:**
+
+1. Run `bun run test` -- ensure all new tests pass
+2. Run `bun run lint` -- ensure no linting errors
+3. Fix any TypeScript errors, assertion failures, or mock issues
+4. Verify no existing tests are broken
+
+**Validates:** TA8
+
+---
+
+### Wave 5: Review Follow-ups
+
+#### S010: Add malformed JSON handling tests for parseJson
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S009-T
+
+**Tasks:**
+
+1. Add tests verifying behavior when Rust returns malformed JSON in fields like `aliases`, `muscle_groups`, `equipment_required`
+2. Test with values like `"not-valid-json{"` to catch regressions in the conversion layer
+3. Verify silent `undefined` propagation does not occur
+
+**Review ref:** P9-004
+
+---
+
+#### S011: Add DATABASE and INTERNAL error kind tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S009-T
+
+**Tasks:**
+
+1. Add error handling tests for DATABASE error kind (most common production IPC failure)
+2. Add error handling tests for INTERNAL error kind
+3. Verify `AdapterError` correctly wraps these error shapes
+
+**Review ref:** P9-005
+
+---
+
+#### S012: Add Supabase getUnreadCounts happy path test
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/supabase-adapter.test.ts`
+**Depends on:** S009-T
+
+**Tasks:**
+
+1. Add happy path test for `getUnreadCounts` where participations exist with actual unread messages
+2. Verify the returned Map contains correct conversation IDs and counts
+
+**Review ref:** P9-006
+
+---
+
+#### S013: Add isoToUnixSeconds edge case tests
+
+**Agent:** test-writer
+**Files:** `src/lib/__tests__/tauri-adapter.test.ts`
+**Depends on:** S009-T
+
+**Tasks:**
+
+1. Add tests for `isoToUnixSeconds` with invalid ISO strings, empty strings, and null timestamps
+2. Verify `NaN` is not silently propagated (could cause data corruption)
+3. Test indirectly via adapter methods that use timestamp conversion
+
+**Review ref:** P9-007
+
+---
+
+#### S014: Restore .bind() position comments in chat.rs INSERT OR REPLACE
+
+**Agent:** test-writer
+**Files:** `src-tauri/src/commands/chat.rs`
+**Depends on:** S009-T
+
+**Tasks:**
+
+1. Locate the INSERT OR REPLACE query in chat.rs (~line 640 area)
+2. Add position-numbered comments to each `.bind()` call matching the SQL parameter positions
+3. Verify comments match the actual SQL column order
+
+**Review ref:** P9-008
+
+---
+
+## Milestone Summary
+
+| Milestone | After Step | Testable Assertions |
+| ----------------- | ---------- | ----------------------------------------------------- |
+| M1: Foundation | S001 | TA1 (partial), TA2 |
+| M2: Core CRUD | S004 | TA1, TA3 (partial), TA4 (partial), TA5 (partial), TA7 |
+| M3: Full Coverage | S009 | TA3, TA4, TA5, TA6 |
+| M4: Green Suite | S009-T | TA8 |
+
+## Execution Recommendation
+
+**Use `/impl 012`** -- this is a single-specialist, single-file task with no cross-domain coordination needed.
diff --git a/Context/Features/012-TauriAdapter-Tests/Tech.md b/Context/Features/012-TauriAdapter-Tests/Tech.md
new file mode 100644
index 00000000..53a6655c
--- /dev/null
+++ b/Context/Features/012-TauriAdapter-Tests/Tech.md
@@ -0,0 +1,152 @@
+# Feature 012: TauriAdapter Unit Tests -- Technical Plan
+
+**Status:** Done
+**Created:** 2026-04-04
+
+## Architecture Overview
+
+The test file `src/lib/__tests__/tauri-adapter.test.ts` will mock the Tauri IPC layer (`invoke` from `@tauri-apps/api/core`) and exercise every exported function and class method in `src/lib/tauri-adapter.ts`.
+
+### Test Layer Diagram
+
+```
+[Test File]
+ |
+ +--> vi.mock('@tauri-apps/api/core') -- replaces invoke with vi.fn()
+ |
+ +--> import { TauriAdapter, AdapterError } from '../tauri-adapter'
+ |
+ +--> Each test:
+ 1. vi.mocked(invoke).mockResolvedValue(fixtureData)
+ 2. Call adapter method
+ 3. Assert return value shape + invoke called with correct cmd/args
+```
+
+## Key Technical Decisions
+
+### KD1: Mock Strategy -- vi.mock at module level
+
+**Decision:** Mock `@tauri-apps/api/core` with `vi.mock()` at the top of the file (before imports), exactly matching the pattern in `adapter.test.ts` and `config-store.test.ts`.
+
+```ts
+vi.mock('@tauri-apps/api/core', () => ({
+ invoke: vi.fn(),
+}))
+```
+
+**Why:** The `invokeCommand()` helper in tauri-adapter.ts imports `invoke` at module level. Vitest's hoisted `vi.mock()` intercepts this import before the module initializes, so all calls to `invoke` inside `invokeCommand` go through the mock.
+
+**Alternative considered:** Manual dependency injection (pass `invoke` as a constructor param). Rejected because it would require changing production code solely for testability and diverge from the existing pattern.
+
+### KD2: Fixture Design -- Tauri Response shapes, not Row shapes
+
+**Decision:** Fixtures must use the `TauriXxxResponse` interface shapes (integers for booleans, JSON strings for object columns), NOT the `XxxRow` types from `database.types.ts`.
+
+**Why:** The row mappers (`toExerciseRow`, etc.) are the code under test. If fixtures already used Row shapes, the mappers would be trivially passing data through. Using Tauri response shapes exercises the actual conversion logic (e.g., `intToBool(1)` -> `true`, `parseJson('{"a":1}', 'col')` -> `{a:1}`).
+
+### KD3: Testing Unexported Functions
+
+**Decision:** Test standalone helpers (`intToBool`, `parseJson`, `requireString`, `isoToUnixSeconds`, row mappers, `getMonday`, `formatWeekLabel`) indirectly through the class methods that use them.
+
+**Why:** These functions are not exported from `tauri-adapter.ts`. Rather than exporting them solely for testing:
+
+- Conversion helpers are exercised through row mapper assertions (verify the returned object has booleans, parsed objects, etc.)
+- `invokeCommand` is exercised through every adapter method that delegates to Rust (not-implemented stubs throw errors without going through `invokeCommand`)
+- `isTauriAppError` + `AdapterError` are exercised through error-path tests where `invoke` rejects with a `TauriAppError`-shaped object
+
+If any helper has edge cases that can't be reached through public API (e.g., `parseJson` with malformed JSON), we can test that by having `invoke` return a response with an invalid JSON string field.
+
+**Alternative considered:** Export helpers behind a `_testing` namespace. Rejected -- the indirect testing approach is sufficient and avoids polluting the module's public API.
+
+### KD4: Test File Structure -- Mirror SupabaseAdapter
+
+**Decision:** Organize tests in nested `describe` blocks matching the SupabaseAdapter test structure:
+
+```
+describe('TauriAdapter')
+ describe('Conversion helpers (via mapped results)')
+ describe('Error handling')
+ describe('invokeCommand')
+ describe('AdapterError')
+ describe('Exercise operations')
+ describe('getExercises')
+ describe('getExercise')
+ describe('createExercise')
+ describe('Workout log operations')
+ ...
+ describe('Session template operations')
+ ...
+ describe('Program operations')
+ ...
+ describe('Program activation')
+ ...
+ describe('Accountability groups')
+ ...
+ describe('Direct connections')
+ ...
+ describe('Chat operations')
+ ...
+ describe('Analytics')
+ describe('getWeeklyVolume')
+ describe('getVaultSummary')
+ describe('Standalone helpers')
+ describe('getMonday')
+ describe('formatWeekLabel')
+ describe('Not-implemented stubs')
+```
+
+### KD5: Standalone Helpers Are Not Exported
+
+**Decision:** `getMonday` and `formatWeekLabel` are standalone functions at the end of the file. They are NOT exported and are tested indirectly through `getWeeklyVolume`, which uses both.
+
+### KD6: beforeEach Pattern
+
+**Decision:** Each test gets a fresh adapter and cleared mocks:
+
+```ts
+let adapter: TauriAdapter
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ adapter = new TauriAdapter('user-001')
+})
+```
+
+**Why:** Matches the SupabaseAdapter pattern. `vi.clearAllMocks()` resets call counts and return values, preventing test leakage.
+
+## Stack-Specific Details
+
+### Vitest Configuration
+
+- Environment: `node` (no browser globals needed -- TauriAdapter is pure TypeScript)
+- Setup file: `src/test/setup.ts` (Testing Library cleanup only, no Tauri setup)
+- Alias: `@/` maps to `./src`
+- Worktree exclusion: test file goes in `src/lib/__tests__/` which is included in the default test run
+
+### Mock Patterns
+
+| What | How | Where |
+| ------------------------------------ | ---------------------------------------------------------------------------- | -------------------- |
+| `invoke` from `@tauri-apps/api/core` | `vi.mock()` + `vi.mocked(invoke).mockResolvedValue()` | Top of test file |
+| Error paths | `vi.mocked(invoke).mockRejectedValue({ kind: 'NOT_FOUND', message: '...' })` | Per-test |
+| Unknown errors | `vi.mocked(invoke).mockRejectedValue(new Error('network'))` | Error handling tests |
+
+### Type Considerations
+
+- Tauri response interfaces (`TauriExerciseResponse`, etc.) are not exported. Fixtures will use inline object literals typed to match the interface shapes.
+- The `TauriAppError` interface is not exported either. Error fixtures use plain objects matching `{ kind, message, field? }`.
+
+## Risks and Mitigations
+
+| Risk | Likelihood | Impact | Mitigation |
+| ------------------------------------------ | ---------- | ------ | ---------------------------------------------------------------------------------------------------- |
+| Unexported types make fixtures fragile | Medium | Low | Type assertions via `satisfies` where possible; tests will break on shape changes (which is desired) |
+| Large test file (est. 2000+ lines) | High | Low | Well-structured describe blocks; row fixtures at top; each domain section is independent |
+| `getMonday`/`formatWeekLabel` not exported | Medium | Low | Test indirectly through `getWeeklyVolume`; if insufficient coverage, extract to a utils file |
+
+## Integration Points
+
+- **No external dependencies** beyond `@tauri-apps/api/core` mock
+- **No database** needed (all SQLite access is behind `invoke`)
+- **No network** needed (pure unit tests)
+- Tests validate the contract between the TypeScript frontend and the Rust backend by asserting the exact command names and argument shapes passed to `invoke`
diff --git a/Context/QuickPlans/2026-04-03-recently-used-templates.md b/Context/QuickPlans/2026-04-03-recently-used-templates.md
new file mode 100644
index 00000000..caf09815
--- /dev/null
+++ b/Context/QuickPlans/2026-04-03-recently-used-templates.md
@@ -0,0 +1,76 @@
+# Quick Plan: Recently Used Section in Session Picker
+
+**Task:** Add a "Recently Used" section to `SessionPickerSheet` that shows the last 3-5 assigned templates pinned above the full list.
+
+**Goal:** Reduce picker friction when users have 20+ templates by surfacing the most recently assigned ones at the top without requiring any user action.
+
+---
+
+## Approach
+
+### 1. Domain type + schema (`src/domain/types/session.ts`)
+Add optional `lastAssignedAt: z.string().datetime().optional()` to `sessionTemplateSchema`. Inherits from `syncableEntitySchema` — this is purely additive.
+
+### 2. SQLite migration (`src-tauri/migrations/`)
+New file `004_session_template_last_assigned.sql`:
+```sql
+ALTER TABLE session_templates ADD COLUMN last_assigned_at INTEGER;
+```
+
+### 3. SQLite adapter update (`src-tauri/src/`)
+In the template repository's assign/update path, set `last_assigned_at = unixepoch()` when a template is assigned to a session slot. The Tauri command that fires when `onSelect` is called in the builder needs to touch this column.
+
+> Need to confirm the exact command name — likely in `src-tauri/src/commands/` or similar.
+
+### 4. Supabase migration (`supabase/migrations/`)
+New migration adding column to `session_templates`:
+```sql
+ALTER TABLE session_templates ADD COLUMN last_assigned_at TIMESTAMPTZ;
+```
+
+### 5. Hook update (`src/hooks/use-session-templates.ts`)
+Expose a `recentTemplates` derived value: filter templates where `lastAssignedAt` is set, sort by `lastAssignedAt DESC`, slice to top 5.
+
+Alternatively, add a `useRecentSessionTemplates(userId, limit)` hook that queries SQLite directly with `ORDER BY last_assigned_at DESC LIMIT 5` to keep it efficient at scale.
+
+### 6. Picker update (`src/components/program-builder/session-picker-sheet.tsx`)
+- If `recentTemplates.length > 0` and search is empty and filter is `ALL`: render a "Recent" section header + up to 5 recent template buttons above the full list.
+- Hide the recent section when the user is actively searching or filtering (it would be redundant and confusing).
+- Reuse the same `handleSelect` callback -- no behavior change on selection.
+- No new state needed.
+
+### 7. Trigger `last_assigned_at` update
+In `builder.tsx` (or wherever `onSelect` lands), after assigning the template to the draft, call a new Tauri command `touch_template_last_assigned(templateId)` or piggyback on an existing upsert.
+
+---
+
+## File Checklist
+
+| File | Change |
+|---|---|
+| `src/domain/types/session.ts` | Add `lastAssignedAt` field |
+| `src-tauri/migrations/004_*.sql` | New migration |
+| `supabase/migrations/YYYYMMDD_*.sql` | New migration |
+| `src-tauri/src/...` | Update assign path to set `last_assigned_at` |
+| `src/hooks/use-session-templates.ts` | Expose recent templates |
+| `src/components/program-builder/session-picker-sheet.tsx` | Render "Recent" section |
+| `src/routes/_authenticated/builder.tsx` | Trigger timestamp update on select |
+
+---
+
+## Verification
+
+- Assign a template to a slot; reopen the picker -- it appears in "Recent" at the top.
+- After assigning 6+ different templates, only the 5 most recent appear.
+- Typing in the search box hides the Recent section.
+- Changing the category filter (non-ALL) hides the Recent section.
+- Fresh install with no assignments: Recent section is absent (no empty header).
+- Existing templates with no `last_assigned_at` degrade gracefully (no crash, no entry in Recent).
+
+---
+
+## Risks
+
+- **Tauri command location:** Need to confirm where template assignment is persisted in the Rust layer before adding the timestamp update. Incorrect placement could miss updates or double-count.
+- **Sync conflict:** If `last_assigned_at` is synced to Supabase, a multi-device user's recent list would reflect the last device used -- acceptable behavior, but worth being aware of.
+- **Migration ordering:** SQLite migrations run at app launch via Tauri; Supabase migrations run via `npx supabase db push`. Both must be present before testing end-to-end.
diff --git a/Context/QuickPlans/2026-04-03-week-grid-density.md b/Context/QuickPlans/2026-04-03-week-grid-density.md
new file mode 100644
index 00000000..33f9c7da
--- /dev/null
+++ b/Context/QuickPlans/2026-04-03-week-grid-density.md
@@ -0,0 +1,77 @@
+# Quick Plan: Week Grid Density Improvements
+
+**Task:** Three UX improvements to reduce visual noise in the program builder week grid without losing the spatial calendar metaphor.
+
+**Goal:** Make the grid feel intentional and complete rather than sparse and unfinished, especially on mobile and in multi-week blocks.
+
+---
+
+## Change 1: Mobile 5/7-day toggle
+
+**Where:** `src/routes/_authenticated/builder.tsx` + `src/components/program-builder/mobile-block-editor.tsx`
+
+**What:** The `showWeekends` state already exists in the builder route and already flows to `BlockList` (desktop). It is never passed to `MobileBlockEditor`. `MobileDayRow` hardcodes `DAY_ORDER` ([1,2,3,4,5,6,0]) with no filtering.
+
+**Approach:**
+1. Pass `showWeekends` and `onToggleWeekends` down to `MobileBlockEditor` as props.
+2. Add the toggle button to the mobile block editor header (currently has block controls — add the same "5 days / 7 days" toggle button that desktop uses, but visible on mobile).
+3. In `MobileWeekSection`, filter `DAY_ORDER` to `WEEKDAY_COLUMNS` when `showWeekends=false`, same logic as `WeekGrid`.
+4. If a weekend session exists on a week where weekends are hidden, show the same "+N weekend session(s)" note that `WeekGrid` already shows.
+
+**Files:**
+- `src/routes/_authenticated/builder.tsx` -- pass props to MobileBlockEditor
+- `src/components/program-builder/mobile-block-editor.tsx` -- accept props, add toggle button, filter day list
+
+---
+
+## Change 2: Rest day visual treatment
+
+**Where:** `src/components/program-builder/session-slot.tsx` (desktop) + `MobileDayRow` in `mobile-block-editor.tsx`
+
+**What:** Empty slots currently look like unfilled calls-to-action (dashed border, `+` icon). This reads as "something is missing" rather than "this is a rest day". The fix is a subtle visual that reads as intentional without removing the ability to assign a session.
+
+**Approach:**
+- Replace the dashed-border `+` button for empty slots with a quieter treatment: no border, muted background (`bg-surface-gunmetal/40` or similar), and a small "Rest" label in `text-warm-ash/30` instead of a `+` icon.
+- Keep the slot fully interactive -- clicking still opens the picker. Add a hover state that reveals the `+` icon to indicate it's assignable.
+- On mobile, the empty `MobileDayRow` currently says "Tap to assign" -- replace with just the day label and a very muted right-side `+` that appears on hover/focus.
+
+**Files:**
+- `src/components/program-builder/session-slot.tsx` -- restyle empty state
+- `src/components/program-builder/mobile-block-editor.tsx` -- restyle empty `MobileDayRow`
+
+---
+
+## Change 3: Collapse identical weeks
+
+**Where:** `src/components/program-builder/block-editor.tsx` (desktop) + `mobile-block-editor.tsx` (mobile)
+
+**What:** In a 4-week block where weeks 2-4 repeat week 1's pattern (common in linear progression programs), each week currently renders full height. This triples the vertical space with duplicate structure.
+
+**Approach:**
+- Add a `weekMatchesPrevious(week: WeekDraft, previous: WeekDraft): boolean` utility in `builder-state.ts` that compares session arrays by `dayOfWeek` and `sessionTemplateId`.
+- In `BlockEditor` and `MobileBlockCard`, for any week where `weekMatchesPrevious` is true, render a collapsed summary row instead of the full grid: "Week N -- same as Week 1" with an expand chevron.
+- The collapsed row should still show the session count and type badges (e.g., "3 sessions: STR / COND / SE") so the user knows what's in the week without expanding.
+- Clicking the row or chevron expands it to the full grid inline. State is local (`expandedWeeks: Set`) in `BlockEditor`.
+- Default: all weeks expanded. The collapse only applies when `weekMatchesPrevious` returns true, so a new empty block always shows all weeks expanded.
+
+**Files:**
+- `src/components/program-builder/builder-state.ts` -- add `weekMatchesPrevious` utility
+- `src/components/program-builder/block-editor.tsx` -- render collapsed summary for matching weeks
+- `src/components/program-builder/mobile-block-editor.tsx` -- same for mobile
+
+---
+
+## Verification
+
+- Mobile builder shows 5-day grid by default; toggle switches to 7-day
+- Weekend sessions on a 5-day mobile view show the "+N weekend" note
+- Empty slots read as rest days visually; hover/focus reveals the assignable state
+- A 4-week block where weeks 2-4 are identical renders collapsed "same as Week 1" rows
+- Expanding a collapsed week shows the full grid
+- A block with all different weeks renders fully expanded with no collapsed rows
+
+## Risks
+
+- **Change 3 interaction with remove/assign**: When a week is collapsed and the user assigns or removes a session elsewhere that makes the week no longer identical, it should auto-expand. Need to invalidate the collapsed state in `handleSessionSelected` and `handleRemoveSession`.
+- **Change 2 hover on touch devices**: "hover to reveal +" won't work on mobile tap. The empty mobile row should always show a subtle `+` since there's no hover state.
+- **Change 1 weekend sessions note position**: On mobile the note goes below `MobileWeekSection` header, not below a horizontal grid row — needs a different placement than desktop.
diff --git a/Context/QuickPlans/2026-04-04-address-all-backlog.md b/Context/QuickPlans/2026-04-04-address-all-backlog.md
new file mode 100644
index 00000000..68f34bdf
--- /dev/null
+++ b/Context/QuickPlans/2026-04-04-address-all-backlog.md
@@ -0,0 +1,75 @@
+# Quick Plan: Address All Backlog
+
+**Date:** 2026-04-04
+**Task:** Resolve all open backlog items: B001 (bug), P9-010 (domain type tests), P9-011 (unread counts test), and S-6 (N+1 query).
+**Goal:** Clear the backlog to zero open items before starting the next feature.
+
+---
+
+## Items
+
+### B001 -- invokeCommand missing log (High Bug)
+
+**File:** `src/lib/tauri-adapter.ts:504-512`
+**Approach:** In the `invokeCommand` catch block, add `console.error('[tauri-adapter] invokeCommand failed:', err)` before the re-throw. Must not swallow the error -- log then rethrow.
+**Verification:** Catch block has `[tauri-adapter]` prefixed log and still re-throws `AdapterError`.
+
+---
+
+### P9-010 -- Domain type tests: conversation, message, media (High)
+
+**File:** New test file alongside existing domain type tests (e.g. `src/domain/types/__tests__/chat.test.ts` or equivalent)
+**Approach:** Add Vitest unit tests covering the Zod refinements added in PR #71:
+
+- Direct conversation + `groupId` set → rejected
+- Group conversation + `groupId` set → accepted
+- `leftAt > joinedAt` → accepted; `leftAt < joinedAt` → rejected; `leftAt === joinedAt` → check boundary
+- System message + `senderId` set → rejected
+- Negative `durationSeconds` → rejected; zero → accepted
+- Negative `fileSizeBytes` → rejected; zero → accepted
+
+**Approach:** Check existing domain test files (e.g. `src/domain/types/__tests__/`) for pattern, then add `chat.test.ts` following the same structure.
+**Verification:** `bun run test` passes with all new cases green.
+
+---
+
+### P9-011 -- getUnreadCounts batching test (Medium)
+
+**File:** Test file for `getUnreadCounts` (likely in `src/lib/__tests__/` or co-located with the adapter tests)
+**Approach:** Add Vitest tests for the `Promise.allSettled` behavior:
+
+- All participations succeed → returns full map
+- One participation fails → partial results returned (others still present); failed entry absent or defaulted
+- Empty participations array → returns empty map
+- `null` `last_read_at` → treated as "all unread" (0 read)
+ **Verification:** All four cases covered and passing.
+
+---
+
+### S-6 -- findDirectConversation N+1 (Low)
+
+**File:** `src/lib/tauri-adapter.ts` -- `findDirectConversation`
+**Approach:** Replace the per-conversation loop with a single batched query that fetches all participant sets for the candidate conversation IDs in one IPC call, then filters in JS. If a batch IPC command doesn't exist, evaluate whether to add one or leave this deferred.
+**Decision gate:** If no batch command exists and adding one is non-trivial (new Rust command needed), defer S-6 and leave it in the backlog as Low. Do not over-engineer.
+**Verification:** `findDirectConversation` issues at most 2 IPC calls regardless of candidate count.
+
+---
+
+## Execution Order
+
+1. B001 (5 min, isolated) -- fix first, trivial
+2. P9-010 (domain type tests) -- add test file, ~10-15 cases
+3. P9-011 (unread counts test) -- add test file, 4 cases
+4. S-6 (N+1 query) -- investigate batch feasibility; defer if non-trivial
+
+## Risks
+
+- Domain type test file location: check existing pattern before creating
+- S-6 may require a new Tauri command; if so, scope expands beyond quick plan -- defer
+- `getUnreadCounts` test may need mock setup matching existing adapter test patterns
+
+## Verification
+
+- `bun run test` passes
+- `bun run lint` clean
+- Backlog items B001, P9-010, P9-011 removed; S-6 either resolved or re-evaluated
diff --git a/Context/QuickPlans/2026-04-04-enhancement-issues-batch.md b/Context/QuickPlans/2026-04-04-enhancement-issues-batch.md
new file mode 100644
index 00000000..c00060a1
--- /dev/null
+++ b/Context/QuickPlans/2026-04-04-enhancement-issues-batch.md
@@ -0,0 +1,138 @@
+# Quick Plan: Batch Enhancement Issues
+
+**Task:** Address all open GitHub issues labeled "enhancement" (#31, #39-#42, #46-#49, #51)
+**Goal:** Close out review-sourced polish, perf, and small feature items in one session
+**Branch:** `chore/enhancements` (from `develop`)
+
+---
+
+## Pre-Flight: Already Resolved Items
+
+Research shows the following items from Group 4 (#42/#49) and Group 5 (#51) are **already implemented**:
+
+| Issue Item | Status | Evidence |
+| ------------------------------------------------- | ------ | ---------------------------------------------------- |
+| S009: Extract TYPING_EXPIRY_MS/TYPING_DEBOUNCE_MS | Done | `realtime-manager.ts` L17/L20 |
+| S010: Extract toMessageFromBroadcast mapper | Done | `realtime-schemas.ts` L53-62 |
+| S011: Remove empty SUBSCRIBED branch | Done | Branch does not exist in current code |
+| S013: Log error before visibilitychange fallback | Done | `foreground-detector.ts` L115-123 |
+| S015: Extract MessagePaginationOptions interface | Done | `data-adapter.ts` L97-102 |
+| S016: Flatten .then() to async/await | Done | `foreground-detector.ts` uses async/await throughout |
+| S017: Add z.string().max(200) to user_name | Done | `realtime-schemas.ts` L29 |
+| S018: Fix getSession() comment | Done | Comment does not exist; auth uses useAuth() |
+| S019: Migrate useBlockedUsers to Zustand | Done | `use-blocked-users.ts` wraps `useBlockedUsersStore` |
+
+**Action:** Close issues #42 and #49 (all items resolved). Update #51 to remove S019 (done).
+
+---
+
+## Remaining Work
+
+### Wave 1 -- Parallel (no file conflicts)
+
+#### Agent A: Zod Refinements (Issues #40/#47)
+
+**Files:** `src/domain/types/conversation.ts`, `src/domain/types/message.ts`, `src/domain/types/media.ts`
+
+1. `conversation.ts` -- Add `.refine()` preventing `groupId` on direct conversations:
+ - When `type === 'direct'`, `groupId` must be undefined
+ - Apply to `conversationSchema` (L15-21)
+
+2. `conversation.ts` -- Add temporal ordering refinement:
+ - `leftAt > joinedAt` when both are present
+ - Apply to `conversationParticipantSchema` (L27-35)
+
+3. `message.ts` -- Add `.refine()` preventing `senderId` on system messages:
+ - When `messageType === 'system'`, `senderId` must be undefined
+ - Apply to `messageSchema` (L93-100)
+
+4. `media.ts` -- Add `.nonnegative()` to numeric fields:
+ - `durationSeconds: z.number().int().nonnegative().optional()`
+ - `fileSizeBytes: z.number().int().nonnegative().optional()`
+ - Apply to `mediaAttachmentSchema` (L30-43)
+
+**Verification:** `bun run build` passes (type check); existing tests pass.
+
+#### Agent B: Realtime Remaining Polish (Issues #42/#49 leftovers)
+
+**Files:** `src/lib/realtime-manager.ts`
+
+1. S012: Simplify `clearAllTyping` iteration (L113-120) -- review and simplify if possible
+2. S014: `broadcastTyping` `.catch(() => {})` at L289-296 swallows errors silently. Per project error-handling conventions, add `[realtime-manager]` prefixed logging.
+
+**Verification:** `bun run build` passes.
+
+#### Agent C: Chat UI -- Archive Button (Issue #51, remaining item S020)
+
+**Files:** `src/components/chat/conversation-header.tsx`, parent component(s)
+
+1. Investigate whether `onArchive` prop is wired to real functionality in the parent
+2. If archive is not implemented upstream, remove the "Archive" menu option from the dropdown (both group and direct branches) to avoid a no-op button
+3. If archive IS implemented, verify it works and close S020
+
+**Verification:** Archive button either works or is removed. No silent no-ops.
+
+#### Agent D: Event Route + Badge Navigation (Issue #31)
+
+**Files:** `src/components/event-builder/event-countdown-badge.tsx`, new `src/routes/_authenticated/events.$templateId.tsx`
+
+1. Create `src/routes/_authenticated/events.$templateId.tsx` with a minimal event detail page (placeholder content is fine -- the route just needs to exist)
+2. Update `event-countdown-badge.tsx`:
+ - Remove `_templateId` destructuring rename, use `templateId` directly
+ - Update `handleNavigate` to navigate to `/events/$templateId`
+ - Remove the TODO comment
+3. Follow existing route patterns (see `_authenticated/groups.$groupId.tsx` as reference)
+
+**Verification:** `bun run build` passes; navigating from badge reaches the new route.
+
+### Wave 2 -- Sequential (shared adapter files)
+
+#### Agent E: getUnreadCounts N+1 Fix (Issues #39/#46)
+
+**Files:** `src/lib/supabase-adapter.ts`
+
+Current state (L2082-2122): loops over participations, fires one COUNT query per conversation.
+
+**Approach -- batch with single query:**
+
+- Build a single query that counts unread messages per conversation using `.in('conversation_id', conversationIds)` with group-by, or
+- Use a Supabase RPC function for server-side aggregation
+- Preserve the existing behavior: exclude own messages, handle null `last_read_at` (count all)
+- Keep `console.warn` + partial results on per-conversation errors
+
+**Verification:** `bun run build` passes; manual test confirms unread counts still display correctly.
+
+#### Agent F: Chat Query Optimizations (Issues #41/#48)
+
+**Files:** `src/lib/tauri-adapter.ts`, `supabase/migrations/` (new migration)
+
+1. **findDirectConversation N+1** (`tauri-adapter.ts` L2129-2148):
+ - Currently fetches all conversations, then calls `get_conversation` per direct conv
+ - Add error context to the loop (wrap in try/catch with `[tauri-adapter]` prefix)
+ - Consider a batched approach if a Rust command supports it, otherwise document the limitation
+
+2. **CH-2 uniqueness trigger O(N)** (migration SQL):
+ - Current trigger calls `direct_conversation_pair()` on every existing direct conversation per INSERT
+ - Create a new migration that adds a materialized lookup (e.g., `direct_conversation_pairs` table or a functional index)
+ - Ensure the trigger uses the index for O(1) lookup instead of full scan
+
+**Verification:** `bun run build` passes; `npx supabase db push` applies cleanly.
+
+---
+
+## Post-Implementation
+
+1. Close duplicate issues: #42 (dup of #49), #41 (dup of #48), #40 (dup of #47), #39 (dup of #46)
+2. Close resolved issues with commit references
+3. Run `bun run build && bun run test` for final validation
+
+---
+
+## Risks
+
+| Risk | Mitigation |
+| ----------------------------------------------------- | ------------------------------------------------------------------------- |
+| Zod `.refine()` breaks existing data parsing | Test with real data; refinements only reject clearly invalid combinations |
+| CH-2 migration changes break uniqueness enforcement | Test with local Supabase; verify trigger still prevents duplicate directs |
+| getUnreadCounts batch query returns different results | Compare output against current N+1 approach with test data |
+| Event route placeholder is too bare | Minimal is fine -- issue only asks for the route to exist for navigation |
diff --git a/Context/QuickPlans/2026-04-04-gh-issues-32-51.md b/Context/QuickPlans/2026-04-04-gh-issues-32-51.md
new file mode 100644
index 00000000..65bc4504
--- /dev/null
+++ b/Context/QuickPlans/2026-04-04-gh-issues-32-51.md
@@ -0,0 +1,63 @@
+# Quick Plan: GitHub Issues #32 & #51
+
+**Date:** 2026-04-04
+**Issues:** #32 (event_metadata comment), #51 (archive button)
+
+---
+
+## Issue #32 -- event_metadata column comment formatting
+
+**Goal:** Rewrite the `session_templates.event_metadata` column comment in a follow-up migration to eliminate the mixed quoted/unquoted pseudo-schema syntax.
+
+**Current (ambiguous):**
+
+```sql
+'JSONB event metadata for EVENT templates. Expected shape: '
+'{"location": string, "eventDate": ISO-8601, "distance": {"value": number, "unit": "mi"|"km"}, '
+'"cutoffTime": {"seconds": number}, "elevation": {"value": number, "unit": "ft"|"m"}}. '
+'NULL for non-EVENT categories.';
+```
+
+**Approach:**
+
+1. Create `supabase/migrations/_fix_event_metadata_comments.sql`
+2. Use `$$` dollar quoting and a plaintext field list:
+
+```sql
+comment on column session_templates.event_metadata is
+$$JSONB event metadata for EVENT session templates. NULL for non-EVENT categories.
+
+Fields:
+ location text -- event venue or location name
+ eventDate text -- ISO-8601 date string (e.g. "2026-06-15")
+ distance jsonb -- { value: number, unit: "mi" | "km" }
+ cutoffTime jsonb -- { seconds: number }
+ elevation jsonb -- { value: number, unit: "ft" | "m" }
+$$;
+```
+
+3. Close issue #32.
+
+**Verification:** `bunx supabase db push` applies cleanly locally.
+
+**Risks:** Comment-only migration -- no schema changes, zero risk.
+
+---
+
+## Issue #51 -- chat UI archive button
+
+**Finding:** Archive IS already wired up:
+
+- `conversation-header.tsx` calls `onArchive()` in both direct and group menu variants
+- `conversation-detail.tsx:98` -- `handleArchive` calls `toggleArchive.mutateAsync(conversationId)` then navigates to `/comms`
+
+The issue is stale. The archive button was wired when `toggleArchive` mutation was added post-PR #35.
+
+**Approach:** Close #51 with a comment confirming it was resolved.
+
+---
+
+## Execution
+
+- **#32:** Use `/impl` -- single migration file, trivial
+- **#51:** Close manually right now
diff --git a/Context/QuickPlans/2026-04-04-s6-find-direct-conversation-n1.md b/Context/QuickPlans/2026-04-04-s6-find-direct-conversation-n1.md
new file mode 100644
index 00000000..3350e061
--- /dev/null
+++ b/Context/QuickPlans/2026-04-04-s6-find-direct-conversation-n1.md
@@ -0,0 +1,93 @@
+# Quick Plan: S-6 -- Fix findDirectConversation N+1
+
+**Task:** Eliminate N+1 IPC calls in `TauriAdapter.findDirectConversation` and add a dedicated Rust command for optimal single-query lookup.
+
+**Goal:** Replace the current loop of `get_conversation` calls with (A) an immediate TS fix using already-fetched participant data, then (B) a dedicated `find_direct_conversation` Rust command.
+
+## Current Problem
+
+`tauri-adapter.ts:2138` calls `getConversations()` (which already returns `participantUserIds` on each `Conversation`), then loops over direct conversations calling `get_conversation` individually to re-fetch participant data it already has. This is N+1 IPC roundtrips.
+
+## Phase A: TypeScript-Only Fix (1 file)
+
+**File:** `src/lib/tauri-adapter.ts`
+
+Rewrite `findDirectConversation` (line 2138) to filter using the `participantUserIds` already present on the `Conversation` objects returned by `getConversations()`:
+
+```typescript
+async findDirectConversation(otherUserId: string): Promise {
+ const conversations = await this.getConversations()
+ return (
+ conversations.find(
+ (c) =>
+ c.type === 'direct' &&
+ c.participantUserIds.includes(this.userId) &&
+ c.participantUserIds.includes(otherUserId),
+ ) ?? null
+ )
+}
+```
+
+**Test update:** `src/lib/__tests__/tauri-adapter.test.ts:2279` -- remove the second `mockResolvedValueOnce` (the `get_conversation` mock) since only one IPC call (`get_conversations`) is made now.
+
+## Phase B: Dedicated Rust Command (3 files)
+
+### B1. `src-tauri/src/commands/chat.rs` -- add `find_direct_conversation`
+
+New command following the `get_conversation` pattern (line 242). Single SQL query:
+
+```sql
+SELECT c.id, c."type", c.title, c.group_id, c.created_at, c.updated_at
+FROM conversations c
+JOIN conversation_participants cp1 ON cp1.conversation_id = c.id
+JOIN conversation_participants cp2 ON cp2.conversation_id = c.id
+WHERE c."type" = 'direct'
+ AND cp1.user_id = ? AND cp1.left_at IS NULL
+ AND cp2.user_id = ? AND cp2.left_at IS NULL
+LIMIT 1
+```
+
+Then fetch participants for the matched conversation (same pattern as `get_conversation`). Return `Option`.
+
+### B2. `src-tauri/src/lib.rs` -- register command
+
+Add `commands::chat::find_direct_conversation` to `generate_handler!` (after line 138).
+
+### B3. `src/lib/tauri-adapter.ts` -- use new command
+
+Update `findDirectConversation` to call `find_direct_conversation` instead of `getConversations`:
+
+```typescript
+async findDirectConversation(otherUserId: string): Promise {
+ const result = await invokeCommand(
+ 'find_direct_conversation',
+ { user_id: this.userId, other_user_id: otherUserId },
+ )
+ if (!result) return null
+ const userIds = result.participants.filter((p) => p.left_at == null).map((p) => p.user_id)
+ return toConversation(toConversationRowFromTauri(result.conversation), userIds)
+}
+```
+
+### B4. Tests
+
+- Update `tauri-adapter.test.ts` `findDirectConversation` tests to mock `find_direct_conversation` command
+- Add test: returns conversation when both users are active participants
+- Add test: returns null when no matching direct conversation
+- Add test: returns null when other user has left
+
+## Verification
+
+- `bun run test` -- all existing + new tests pass
+- `bun run build` -- TypeScript compiles clean
+- `cd src-tauri && cargo check` -- Rust compiles clean
+- Manual: verify `findDirectConversation` makes exactly 1 IPC call (check devtools network/console)
+
+## Risks
+
+- **Schema mismatch:** The SQL join must produce columns matching `ConversationRow`. Mitigated by using the exact same column list as `get_conversation`.
+- **Edge case:** Two direct conversations between the same users (shouldn't happen per domain rules, but `LIMIT 1` handles it safely).
+
+## Execution
+
+Recommend `/impl` -- this is single-domain (Rust backend + TS adapter), no cross-domain coordination needed.
diff --git a/Context/Reviews/0005-pr59-display-broadcast-review.md b/Context/Reviews/0005-pr59-display-broadcast-review.md
new file mode 100644
index 00000000..c287eee8
--- /dev/null
+++ b/Context/Reviews/0005-pr59-display-broadcast-review.md
@@ -0,0 +1,222 @@
+# PR #59 Review: Display Broadcast Infrastructure
+
+**Date:** 2026-04-04
+**PR:** feat: display broadcast infrastructure (Step 27)
+**Branch:** `feat/remote-display` -> `develop`
+**Reviewers:** Claude Code (automated -- code, tests, types, errors, comments)
+**Decision:** Changes requested
+**Status:** 🟢 Resolved
+
+---
+
+## Scope
+
+8,695 additions / 63 deletions across 75 files covering two feature areas:
+
+1. **Display broadcast infrastructure** -- `DisplaySnapshot` domain type, `display-publisher` service, `useDisplayBroadcast` hook, `PushToDisplayButton`, `display_visible` column
+2. **Chat media** -- Cloudflare Stream video upload/playback, Supabase Storage image/file upload, attachment picker, video player, media upload service, edge functions
+
+---
+
+## Critical (must fix before merge)
+
+**C1 -- Storage upload RLS lacks authorization**
+- `supabase/migrations/20260403000002_media_storage.sql:33-40`
+- `chat_images_insert_authenticated` and `chat_files_insert_authenticated` only check `auth.uid() is not null`. Any authenticated user can upload to any conversation's path.
+- Fix: add `is_conversation_participant((string_to_array(name, '/'))[2]::uuid)` to both insert policies.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `is_conversation_participant` check to both insert RLS policies
+
+**C2 -- Uploaded images never display in chat**
+- `src/lib/media-upload-service.ts` + `src/components/chat/media-message-content.tsx`
+- Image/file uploads set `status: 'ready'` but never populate `playbackUrl` or `thumbnailUrl`. `MediaMessageContent` returns `null` when no URL exists -- uploaded images silently vanish.
+- Fix: generate and store a signed URL after upload, or add URL resolution in the component.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `createSignedUrl` calls after upload in service; added `useStorageSignedUrl` fallback hook in component; updated test mocks
+
+**C3 -- Snapshot builder can crash core workout flow**
+- `src/stores/active-workout-store.ts` -- `_publishCurrentState`
+- No try-catch around `buildDisplaySnapshot()`. Any runtime error propagates into `completeSet`, `startRest`, etc., crashing workout UI for a secondary feature.
+- Fix: wrap in try-catch with `console.error`.
+- **Status:** ✅ Fixed
+- **Resolution:** Wrapped `buildDisplaySnapshot` + `publishDisplaySnapshot` in try-catch in `_publishCurrentState()`
+
+**C4 -- Cloudflare signed URL uses wrong identifier**
+- `supabase/functions/chat-media-signed-url/index.ts:~73`
+- Signed URL constructed as `https://customer-${accountId}.cloudflarestream.com/...`. Cloudflare customer subdomain is separate from account ID. Likely produces invalid playback URLs.
+- Fix: use a dedicated `CLOUDFLARE_CUSTOMER_SUBDOMAIN` env var and update the URL template.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `CLOUDFLARE_CUSTOMER_SUBDOMAIN` env var with validation; updated URL template
+
+---
+
+## Important
+
+**I1 -- Empty `.catch(() => {})` on TUS abort** (`src/lib/media-upload-service.ts`)
+- Cancellation failures silently ignored. Upload may complete in background producing ghost attachments.
+- Fix: log the error.
+- **Status:** ✅ Fixed
+- **Resolution:** Replaced empty catch with `console.error('[media-upload] TUS abort failed:', err)`
+
+**I2 -- Video player bare catch block** (`src/components/chat/video-player.tsx`)
+- `hls.js` import failure caught with no logging, no `setError(true)`. Native fallback also has no `error` event listener. Result: black rectangle with no diagnostic info.
+- Fix: add `console.warn` + `setError(true)` in catch; add video `error` event listener.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `console.warn` in catch, `setError(true)` when native fallback unavailable, video `error` event listeners for both paths
+
+**I3 -- Transcoding timeout never cleared on unmount** (`src/hooks/use-media-upload.ts`)
+- 5-minute `setTimeout` runs after navigation, causing stale query invalidations. `retry` callback also doesn't `void` the upload promise.
+- Fix: store timer ID in ref, clear in `useEffect` cleanup. `void upload(...)` in `retry`.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `transcodingTimerRef` with `useEffect` cleanup on unmount
+
+**I4 -- `isBroadcasting` doesn't reflect publisher readiness** (`src/lib/display-publisher.ts` + `src/hooks/use-display-broadcast.ts`)
+- Hook returns `isBroadcasting: true` based on store state, not whether publisher has a working client. User sees active broadcast UI while nothing publishes.
+- Fix: export `isPublisherReady()` and use in `isBroadcasting` calculation.
+- **Status:** ✅ Fixed
+- **Resolution:** Exported `isPublisherReady()` from publisher; included in `isBroadcasting` calculation in hook
+
+**I5 -- `MediaMessageContent` returns `null` for missing URL / unknown type** (`src/components/chat/media-message-content.tsx`)
+- Produces empty message bubbles with no user-facing explanation.
+- Fix: return a fallback UI element instead of `null`.
+- **Status:** ✅ Fixed
+- **Resolution:** Added "Media unavailable" fallback UI element for missing URL and unknown media types
+
+**I6 -- `retry` callback ignores upload Promise** (`src/hooks/use-media-upload.ts`)
+- Fix: `void upload(file, type)`.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `void` before `upload(...)` call in retry callback
+
+**I7 -- `displayWeightSchema` weaker than source `weightSchema`** (`src/domain/types/display-snapshot.ts`)
+- `unit` is `z.string()` (should be `z.enum(['lb', 'kg'])`), `value` missing `.nonnegative()`, `reps` missing `.int().nonnegative()`.
+- **Status:** ✅ Fixed
+- **Resolution:** Tightened schema -- `unit` to `z.enum(['lb', 'kg'])`, added `.nonnegative()` to value, `.int().nonnegative()` to reps
+
+**I8 -- `z.url()` migration may break consumers** (`src/domain/types/media.ts`, `src/lib/realtime-schemas.ts`)
+- Zod 4's `z.url()` may infer as `URL` object rather than `string`. If consumers pass to JSX `src={}`, this silently breaks.
+- Fix: verify inferred type; revert to `z.string().url()` if needed.
+- **Status:** ✅ Fixed
+- **Resolution:** Reverted `z.url()` to `z.string().url()` in both files (4 occurrences)
+
+**I9 -- Empty `userId` passed to `useDisplayBroadcast` during loading** (`src/routes/_authenticated/log.$workoutId.tsx:~85`)
+- `workoutLog?.userId ?? ''` triggers unnecessary profile query with empty ID.
+- Fix: add `enabled: !!userId` guard in the hook.
+- **Status:** ✅ Fixed (already guarded)
+- **Resolution:** `useUserProfile` already has `enabled: !!userId && userId.length > 0` guard; no additional changes needed
+
+---
+
+## Medium
+
+**M1** -- Edge functions use `Deno.env.get(...)!` non-null assertions for `SUPABASE_URL`/`SUPABASE_ANON_KEY`. Add explicit guards returning 500.
+- **Status:** ✅ Fixed
+- **Resolution:** Replaced all non-null assertions with explicit guards returning 500 "Server configuration error" in all three edge functions
+
+**M2** -- Webhook returns 200 when realtime broadcast fails post-DB-update. Video stays "Processing..." until manual refresh. Add error logging; consider Sentry.
+- **Status:** ✅ Fixed (already handled)
+- **Resolution:** Webhook already catches broadcast failures in try-catch with `console.error` and returns 200; no changes needed
+
+**M3** -- `req.json()` parse failure returns 500 instead of 400 in signed-url and upload-url edge functions.
+- **Status:** ✅ Fixed
+- **Resolution:** Wrapped `req.json()` in try-catch returning 400 "Invalid request body" in both functions
+
+**M4** -- All new modules use raw `console.error`/`console.warn` instead of project logging functions (`logError`, `logForDebugging`). Files: `display-publisher.ts`, `use-media-upload.ts`, `media-upload-service.ts`.
+- **Status:** ✅ Dismissed
+- **Resolution:** No project-wide `logError`/`logForDebugging` functions exist; raw console methods are the current project convention
+
+**M5** -- `useDisplayBroadcast` ignores error states from `useUserProfile` and `useExercises`. On query failure, `exerciseNameMap` silently defaults to `{}` producing "Unknown Exercise" for all exercises on the display.
+- **Status:** ✅ Fixed
+- **Resolution:** Added error destructuring from both queries with `console.warn` logging on error
+
+**M6** -- JSDoc on `configureDisplayPublisher` says "user's ID and visibility preference" but function only accepts `displayVisible`. (`src/lib/display-publisher.ts:53-54`)
+- **Status:** ✅ Fixed
+- **Resolution:** Updated JSDoc to "user's display visibility preference"
+
+---
+
+## Test Coverage Gaps
+
+**T1 -- `media-provider.ts` has zero test coverage** (criticality 8/10)
+- `CloudflareStreamProvider` contains signed URL caching with TTL expiry and Zod response validation -- exactly the code where a field name mismatch broke video in the previous PR.
+- Tests needed: `isExpired` TTL logic, cache hit, cache miss on expiry, null client throws, Zod rejects malformed response.
+- **Status:** ✅ Task created
+- **Resolution:** Added as S024-T in Steps.md (007-video-image-sharing)
+
+**T2 -- Transcoding timeout behavior untested** (criticality 8/10)
+- 5-minute timeout logic has no test. With fake timers: verify processing attachment marked `failed`, ready attachment unaffected, cache invalidated.
+- **Status:** ✅ Task created
+- **Resolution:** Added as S025-T in Steps.md (007-video-image-sharing)
+
+**Other gaps (lower priority):**
+- `publishSessionEnded`/`publishFocusEvent` not tested with `displayVisible = false`
+- `cancelUpload` test is a false positive -- tests no-op path, not active abort
+- `chat-media-webhook` broadcast emission not verified in tests
+
+---
+
+## Comment Issues
+
+- `src/lib/display-snapshot.ts:128` -- ✅ Fixed: Changed "last activity in the last group" to "last entry in the flattened activity list"
+- `src/lib/display-snapshot.ts:108` -- ✅ Fixed: Removed "Pure function" claim
+- `src/lib/media-upload-service.ts:9` -- ✅ Fixed: Removed `(S010)` step reference
+- `src/domain/types/media.ts:27` -- ✅ Fixed: Replaced `(CH-6, TA-12)` with "the external provider"
+- `src/lib/media-constraints.ts:4` -- ✅ Fixed: Removed `(CH-6)` reference
+- `supabase/functions/chat-media-upload-url/index.ts:151-155` -- ✅ Fixed: Removed `stream-media-id` fallback that conflated UID with TUS URL
+- `src/components/chat/media-message-content.tsx:26,59,79` -- ✅ Fixed: Removed `// --- Video ---` noise comments
+- `src/components/chat/video-player.tsx:128,152` -- ✅ Fixed: Removed redundant JSX comments
+- `src/components/chat/image-lightbox.tsx:26` -- ✅ Fixed: Removed redundant comment
+
+---
+
+## Type Design Notes
+
+- `displayWeightSchema` is intentionally separate from `weightSchema` but has looser constraints -- ✅ tightened to match source invariants (see I7)
+- `z.boolean().default(true)` on `user.displayVisible` would centralize the `?? true` default that's currently scattered across consumers
+- `buildDisplaySnapshot()` constructs `DisplaySnapshot` directly without running output through `displaySnapshotSchema.parse()` -- consider dev-mode validation
+
+---
+
+## Strengths
+
+- Display snapshot Zod schema has thorough test coverage (28 cases) including boundary values and discriminated union validation
+- `RestTimerState` discriminated union is well-designed -- makes "running timer with no start time" structurally impossible
+- `is_visible: z.literal(true)` cleverly encodes the business invariant
+- Webhook signature verification uses constant-time comparison
+- Edge function tests cover CORS, auth, validation, happy paths, and DB error paths (25 cases)
+- JSDoc on `useDisplayBroadcast`, `verifyWebhookSignature`, and `SnapshotContext` is clear and accurate
+- RLS infinite recursion migration is well-commented and uses correct `SECURITY DEFINER` approach
+
+---
+
+## Actions
+
+- [x] Fix C1-C4 (merge blockers)
+- [x] Fix I1-I9 (important issues)
+- [x] Fix M1-M5 (medium hardening)
+- [x] Add T1, T2 tests
+- [x] Address comment issues
+- [x] Run `bun run test` and `bun run build` before re-review
+
+---
+
+## Resolution Summary
+**Resolved at:** 2026-04-04
+**Session:** PR #59 review resolution
+
+| Category | Total | Fixed | Tasks Created | Dismissed | Deferred |
+|---|---|---|---|---|---|
+| Critical | 4 | 4 | -- | -- | -- |
+| Important | 9 | 9 | -- | -- | -- |
+| Medium | 6 | 4 | -- | 2 | -- |
+| Test Gaps | 2 | -- | 2 | -- | -- |
+| Comments | 9 | 9 | -- | -- | -- |
+| **Total** | **30** | **26** | **2** | **2** | **0** |
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved inline
+- [x] All [TASK] findings added to Steps.md
+- [x] All comment issues addressed
+- [x] `bun run build` passes
+- [x] `bun run test` passes (1361/1361)
+- [x] Review verified
diff --git a/Context/Reviews/0006-discovery-client-server-url-2026-04-04.md b/Context/Reviews/0006-discovery-client-server-url-2026-04-04.md
new file mode 100644
index 00000000..69bd9049
--- /dev/null
+++ b/Context/Reviews/0006-discovery-client-server-url-2026-04-04.md
@@ -0,0 +1,181 @@
+# PR Review: discovery-client-server-url
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/009-discovery-client-server-url/
+**Branch:** worktree-feat+discovery-client-server-url
+**Reviewers:** code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer, type-design-analyzer
+**Status:** 🟢 Resolved
+
+## Summary
+
+18 findings across 5 review agents. 4 critical (logging, error handling, schema weakness), 6 important (a11y, race condition, error type mismatches, misleading docs), 8 suggestions (type extraction, test gaps, comment refinements). The discovery client logic is well-structured and thoroughly tested; the main concerns are around error observability, defensive error handling in the UI layer, and Zod schema leniency at the system boundary.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P6-001: Zero logging in discovery.ts -- all 5 error paths are silent
+- **File:** src/lib/discovery.ts (entire file)
+- **Severity:** Critical
+- **Detail:** The file has 5 distinct error return paths and zero console.error/console.warn calls. Every other error-handling file in the codebase follows the `console.error('[prefix]', err)` convention. Discovery failures will be invisible in the dev console, making debugging impossible when users report "discovery didn't work."
+- **Status:** ✅ Fixed
+- **Resolution:** Added `console.error('[discovery]', ...)` logging to all 6 error paths (URL parse, protocol check, fetch failure, HTTP error, JSON parse, schema validation).
+
+#### [FIX] P6-002: Bare `catch {}` blocks discard error objects entirely
+- **File:** src/lib/discovery.ts:32, src/lib/discovery.ts:82
+- **Severity:** Critical
+- **Detail:** Both the URL-parsing catch (line 32) and the JSON-parsing catch (line 82) use parameterless `catch {}`. The actual error object is thrown away. Non-obvious failures (body already consumed, memory pressure, browser extension interference) become untraceable. Capture the error parameter and log it.
+- **Status:** ✅ Fixed
+- **Resolution:** Changed both bare `catch {}` to `catch (err)` with `console.error` logging.
+
+#### [FIX] P6-003: Wrong error type `NETWORK_ERROR` for URL parse failure
+- **File:** src/lib/discovery.ts:32-38
+- **Severity:** High
+- **Detail:** When `new URL(normalized)` throws (invalid URL syntax), the code returns `error: 'NETWORK_ERROR'`. This is a validation error, not a network error. If a caller ever branches on error type to decide whether to retry, malformed URLs would be retried as if they were transient network problems.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `INVALID_INPUT` to `DiscoveryError` union. URL parse and protocol check errors now return `INVALID_INPUT`. Updated tests accordingly.
+
+#### [FIX] P6-004: Wrong error type `NOT_FOUND` for JSON parse failure
+- **File:** src/lib/discovery.ts:83-89
+- **Severity:** High
+- **Detail:** When `response.json()` fails on a 200 response (e.g., HTML page from a generic web server), the error type is `NOT_FOUND`. But the endpoint WAS found (HTTP 200); the body is just not JSON. Should be `INVALID_RESPONSE` to match semantic intent and align with the schema validation failure on line 94.
+- **Status:** ✅ Fixed
+- **Resolution:** Changed JSON parse failure error type from `NOT_FOUND` to `INVALID_RESPONSE`. Updated test expectation.
+
+#### [FIX] P6-005: Misleading JSDoc -- "passed directly to createClient"
+- **File:** src/lib/discovery.ts:19-20
+- **Severity:** Medium
+- **Detail:** The JSDoc states credentials "can be passed directly to `createClient`" but the actual consumer pattern is `validateConnection` then `initSupabaseFromConfig`. A future maintainer reading this might bypass the config store and initialization flow, producing a disconnected client instance. Replace with "can be passed to `validateConnection` and then persisted via the config store."
+- **Status:** ✅ Fixed
+- **Resolution:** Updated JSDoc to reference `validateConnection` and config store instead of `createClient`.
+
+#### [FIX] P6-006: Missing `aria-expanded` on manual configuration toggle
+- **File:** src/routes/setup.tsx:196-203
+- **Severity:** High
+- **Detail:** The disclosure button that toggles the advanced/manual section lacks `aria-expanded={advancedOpen}` and `aria-controls`. Screen readers cannot communicate the expanded/collapsed state to users. The `.claude/rules/react-typescript.md` accessibility guideline requires interactive elements to be fully keyboard/screen-reader accessible.
+- **Status:** ✅ Fixed
+- **Resolution:** Added `aria-expanded={advancedOpen}` and `aria-controls="manual-config-section"` to toggle button, plus `id="manual-config-section"` on the controlled panel.
+
+#### [FIX] P6-007: No double-submission guard on handleDiscoverAndConnect
+- **File:** src/routes/setup.tsx:84
+- **Severity:** Medium
+- **Detail:** The Connect button disables during `discovering`/`validating` states (line 161), but `handleDiscoverAndConnect` itself has no early-return guard. Rapid clicks or keyboard events before React re-renders could trigger concurrent discovery+validation flows, causing a race condition on shared state.
+- **Status:** ✅ Fixed
+- **Resolution:** Added early-return guard `if (discoveryStatus === 'discovering' || status === 'validating') return` at the top of `handleDiscoverAndConnect`.
+
+#### [FIX] P6-008: Comment scope mismatch on validation feedback
+- **File:** src/routes/setup.tsx:178
+- **Severity:** Low
+- **Detail:** Comment says "(shown after discovery succeeds)" but the feedback block is shared by both the discovery and manual flows. Change to "(shared by both discovery and manual flows)."
+- **Status:** ✅ Fixed
+- **Resolution:** Updated comment to "(shared by both discovery and manual flows)".
+
+### Missing Tasks
+
+#### [TASK] P6-009: No top-level try-catch in handleDiscoverAndConnect
+- **File:** src/routes/setup.tsx:84-109
+- **Severity:** Critical
+- **Detail:** If `discoverInstance` or `validateAndSave` throws an unexpected runtime error (TypeError, programming bug), the promise rejection propagates unhandled. React does not catch async errors from event handlers. The UI freezes permanently in "discovering" state with no error feedback and no recovery path. Wrap the entire body in a try-catch that resets to an error state.
+- **Relates to:** TA-7, TA-8
+- **Status:** ✅ Fixed inline
+- **Resolution:** Implemented as part of SetupState refactor. `handleDiscoverAndConnect` now wrapped in try-catch that resets to `discovery-failed` phase. Task S005 no longer needed.
+
+#### [TASK] P6-010: No top-level try-catch in handleConnect
+- **File:** src/routes/setup.tsx:74-82
+- **Severity:** High
+- **Detail:** Same pattern as P6-009. If `validateAndSave` throws unexpectedly, the UI stays stuck in "validating" state. Wrap in try-catch with fallback error state.
+- **Relates to:** TA-10
+- **Status:** ✅ Fixed inline
+- **Resolution:** Implemented as part of SetupState refactor. `handleConnect` now wrapped in try-catch that resets to `validation-failed` phase. Task S006 no longer needed.
+
+#### [TASK] P6-011: validateAndSave sets success state before async operations complete
+- **File:** src/routes/setup.tsx:51-66
+- **Severity:** High
+- **Detail:** The try block wraps 4 operations (state update, config persistence, Supabase init, navigation) in one catch. `setStatus('ok')` fires at the top of the block. If `initSupabaseFromConfig` or `router.navigate` throws, the user sees a success flash followed by a misleading "Connected but failed to save" error. Move success state to after all async operations complete.
+- **Relates to:** TA-11
+- **Status:** ✅ Fixed inline
+- **Resolution:** Implemented as part of SetupState refactor. `setState({ phase: 'success' })` now fires after `setConfig` and `initSupabaseFromConfig` complete. Task S007 no longer needed.
+
+#### [TASK] P6-012: Tighten DiscoverySchema Zod validators
+- **File:** src/lib/discovery.ts:9-13
+- **Severity:** Critical
+- **Detail:** `DiscoverySchema` uses bare `z.string()` for all three fields. The downstream `backendConfigSchema` uses `z.string().url()` and `z.string().min(1)`. A discovery response with `supabase_url: ""` or `supabase_url: "not-a-url"` passes discovery validation, then fails later with confusing errors. Validation should be strictest at the system boundary. Use `z.string().url()` for `supabase_url`, `z.string().min(1)` for `supabase_publishable_key` and `version`.
+- **Relates to:** TA-5
+- **Status:** ✅ Task created
+- **Resolution:** Added as S005 in Steps.md
+
+#### [TASK] P6-013: Add test for missing supabase_publishable_key
+- **File:** src/lib/__tests__/discovery.test.ts
+- **Severity:** Medium
+- **Detail:** Tests cover missing `version` and `supabase_url` but not missing `supabase_publishable_key` -- the most security-sensitive field. The spec (Steps.md S001-T, test case 10) explicitly requires it. A regression making this field optional would go undetected.
+- **Relates to:** TA-5
+- **Status:** ✅ Task created
+- **Resolution:** Added as S005-T in Steps.md
+
+#### [TASK] P6-014: Add test for non-404 HTTP errors (e.g., 500)
+- **File:** src/lib/__tests__/discovery.test.ts
+- **Severity:** Low
+- **Detail:** Only 404 is tested. The code handles all non-2xx via `!response.ok`. A 500 test would verify the dynamic status-code message template (`Server returned ${response.status}...`) and prevent regressions if someone narrows the branch to only handle 404.
+- **Relates to:** TA-4
+- **Status:** ✅ Task created
+- **Resolution:** Added as S006-T in Steps.md
+
+#### [TASK] P6-015: Add test for URL with path component
+- **File:** src/lib/__tests__/discovery.test.ts
+- **Severity:** Low
+- **Detail:** `forge.example.com/app` silently strips `/app` via `parsed.origin`. This is correct behavior per the spec, but it is a non-obvious transformation. A test documenting this behavior would prevent accidental regressions and serve as documentation.
+- **Relates to:** TA-1, TA-2
+- **Status:** ✅ Task created
+- **Resolution:** Added as S007-T in Steps.md
+
+### Architectural Concerns
+
+#### [ADR] P6-016: Two parallel state machines with implicit coordination in setup.tsx
+- **File:** src/routes/setup.tsx:38-40
+- **Severity:** Medium
+- **Detail:** `discoveryStatus` + `discoveryMessage` and `status` + `message` are four separate `useState` calls forming a de facto compound state machine. Impossible states are representable (e.g., `discoveryStatus === 'discovery-failed'` with `status === 'ok'`). As the page gains more features (QR scanning, deep links), this will become harder to reason about. Consider unifying into a single discriminated union `SetupState` type. Lower priority -- acceptable to defer to a future refactor if needed.
+- **Relates to:** Tech.md, future Refactor B (QR code)
+- **Status:** ✅ Fixed inline
+- **Resolution:** Unified 4 state variables into a single `SetupState` discriminated union with 7 phases (idle, discovering, discovery-failed, validating, validation-failed, schema-missing, success). Impossible states eliminated. QR scanning (Refactor B) extends by adding `| { phase: 'scanning' } | { phase: 'scan-failed'; error: string }` to the union.
+
+### Convention Gaps
+
+#### [RULE] P6-017: Error type semantics -- NETWORK_ERROR used for non-network failures
+- **Files:** src/lib/discovery.ts:36, src/lib/discovery.ts:44
+- **Severity:** Medium
+- **Detail:** `NETWORK_ERROR` is used for URL validation failures (bad syntax, wrong protocol) which are input errors, not network errors. The `DiscoveryError` union should include `INVALID_INPUT` for input validation failures. This pattern could recur in future client modules that contact external services. Consider adding a convention note to `.claude/rules/` about error type semantics at system boundaries.
+- **Suggested rule:** `.claude/rules/error-handling.md` -- "Error types at system boundaries must distinguish input validation failures from network/transport failures."
+- **Status:** ✅ Rule updated
+- **Resolution:** Added to `.claude/rules/error-handling.md` (new file). Code fix applied in P6-003.
+
+#### [RULE] P6-018: Bare catch blocks without error parameter
+- **Files:** src/lib/discovery.ts:32, src/lib/discovery.ts:82
+- **Severity:** Medium
+- **Detail:** Two catch blocks use `catch {}` with no parameter, discarding the error object entirely. The codebase convention elsewhere is `catch (err)` with `console.error`. This should be codified as a rule so linting or review catches it automatically.
+- **Suggested rule:** `.claude/rules/error-handling.md` -- "Never use bare `catch {}`. Always capture the error parameter and log it with a bracketed module prefix."
+- **Status:** ✅ Rule updated
+- **Resolution:** Added to `.claude/rules/error-handling.md` (new file). Code fix applied in P6-002.
+
+## Resolution Summary
+**Resolved at:** 2026-04-04
+**Session:** Review resolution for discovery-client-server-url
+
+| Category | Total | Fixed | Tasks Created | ADRs | Rules | Dismissed | Deferred |
+|---|---|---|---|---|---|---|---|
+| [FIX] | 8 | 8 | -- | -- | -- | -- | -- |
+| [TASK] | 7 | 3 | 4 | -- | -- | -- | -- |
+| [ADR] | 1 | 1 | -- | -- | -- | -- | -- |
+| [RULE] | 2 | -- | -- | -- | 2 | -- | -- |
+| **Total** | **18** | **12** | **4** | **0** | **2** | **0** | **0** |
+
+Notes:
+- P6-009, P6-010, P6-011 (TASK) were resolved inline as part of the SetupState discriminated union refactor rather than deferred to new tasks
+- P6-016 (ADR) was resolved inline per user decision -- no separate ADR created
+- Remaining tasks (S005, S005-T, S006-T, S007-T) are for schema tightening and test coverage additions
+
+## Resolution Checklist
+- [x] All [FIX] findings resolved (P6-001 through P6-008)
+- [x] All [TASK] findings resolved or tracked (P6-009 through P6-015)
+- [x] All [ADR] findings resolved (P6-016 -- fixed inline)
+- [x] All [RULE] findings applied (P6-017, P6-018)
+- [x] Review verified by review-verify agent
diff --git a/Context/Reviews/0006-idle-mode-review.md b/Context/Reviews/0006-idle-mode-review.md
new file mode 100644
index 00000000..37ff0f4e
--- /dev/null
+++ b/Context/Reviews/0006-idle-mode-review.md
@@ -0,0 +1,165 @@
+# PR Review: feat/idle-mode -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/009-idle-mode/
+**Branch:** worktree-feat+idle-mode
+**Reviewers:** code-reviewer, pr-test-analyzer, silent-failure-hunter, type-design-analyzer, comment-analyzer
+**Status:** :green_circle: Resolved
+
+## Summary
+
+16 findings across 20 changed files (~1700 lines). 3 critical (clock drift bug, silent null client on /display, lying connection status), 6 important (path matching, duplicate channel, missing CLOSED handling, production logging, duplicate type, unsafe cast), 4 task gaps (missing tests, schema edge cases), 3 convention/comment cleanup items. The Edge Function and SQL migration are solid; frontend error handling and observability are the main concerns.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P6-001: Clock re-sync block computes increasingly wrong offset from stale prop
+- **File:** src/components/display/clock-display.tsx:29-37
+- **Severity:** Critical
+- **Detail:** The 60-tick re-sync inside `setInterval` re-parses the same stale `serverTimeCorrection` string against an advancing `Date.now()`, making `offsetRef.current` drift progressively negative. The first `useEffect` (lines 14-20) already correctly resets the offset when the prop changes. The re-sync block is redundant and introduces drift. The accompanying comment ("NTP corrections and DST transitions") is misleading.
+- **Relates to:** A-001 (clock accuracy), A-013 (long-running stability), S-3 (drift correction)
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Removed the 60-tick re-sync block and misleading NTP/DST comment from clock-display.tsx
+
+#### [FIX] P6-002: `/display` route silently non-functional when Supabase client not initialized
+- **File:** src/components/display/use-idle-snapshot.ts:19-20
+- **Severity:** Critical
+- **Detail:** The `/display` route bypasses the auth/config guard in `__root.tsx`, but `getSupabaseClient()` depends on that initialization. On a fresh visit to `/display` (no prior app visit), the client is always `null`. The hook silently returns without logging. Combined with P6-003, the display shows a clock, says "Connected", and never receives data -- with zero signal anything is wrong.
+- **Relates to:** A-004 (sessions render on idle_state), A-009 (Edge Function publishes every 60s)
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added console.error when Supabase client is null in useIdleSnapshot
+
+#### [FIX] P6-003: Connection status defaults to "Connected" when no connection exists
+- **File:** src/routes/display.tsx:30-39
+- **Severity:** Critical
+- **Detail:** Initial state is `'connected'`, but if the Supabase client is null the subscription effect returns early without updating state. The footer permanently displays "Connected" with no actual Realtime connection. This actively misleads the operator.
+- **Relates to:** A-013 (hours of stable operation)
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Default connectionStatus changed to 'reconnecting'; status now driven by useIdleSnapshot callback
+
+#### [FIX] P6-004: Path bypass in `__root.tsx` too broad -- matches `/display*`
+- **File:** src/routes/__root.tsx:11
+- **Severity:** High
+- **Detail:** `startsWith('/display')` matches `/display-anything`, `/displayfoo`, etc. The existing `/s/` bypass uses a trailing slash for precision.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed to exact match `location.pathname === '/display'` in __root.tsx
+
+#### [FIX] P6-005: Duplicate `display-status` channel wastes a subscription
+- **File:** src/routes/display.tsx:42-54
+- **Severity:** High
+- **Detail:** A separate `display-status` channel is created solely for connection monitoring. `useIdleSnapshot` already subscribes to the real `display` channel. Two separate channels; the status one never receives broadcasts, so its status only reflects WebSocket health of a purposeless channel.
+- **Relates to:** A-013 (no resource leaks)
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Removed duplicate display-status channel; useIdleSnapshot now accepts onConnectionStatus callback and reports channel status from the real display channel
+
+#### [FIX] P6-006: `CLOSED` channel status not handled
+- **File:** src/routes/display.tsx:42-48
+- **Severity:** Medium
+- **Detail:** Subscription callback only handles `SUBSCRIBED`, `CHANNEL_ERROR`, `TIMED_OUT`. Supabase also emits `CLOSED` (network drops, server restarts). Over hours of operation, missed `CLOSED` events leave the status indicator stuck.
+- **Relates to:** A-013 (hours of stable operation)
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added CLOSED to the reconnecting branch in useIdleSnapshot subscribe callback
+
+#### [FIX] P6-007: Invalid payloads silently discarded in production
+- **File:** src/components/display/use-idle-snapshot.ts:25-29
+- **Severity:** High
+- **Detail:** `safeParse` failures only log in dev mode (`import.meta.env.DEV`). In production, schema drift between Edge Function and frontend produces zero signal -- the display just shows a bare clock forever.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed from DEV-only console.warn to unconditional console.error for validation failures
+
+#### [FIX] P6-008: Duplicate `DisplayMode` type definition
+- **File:** src/components/display/use-display-mode.ts:4, src/components/display/display-mode-transition.tsx:3
+- **Severity:** Medium
+- **Detail:** `type DisplayMode = 'idle' | 'board' | 'focused'` defined independently in two files. Adding a mode to one but not the other produces a silent mismatch.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Created src/components/display/types.ts with shared DisplayMode type; both files import from there
+
+#### [FIX] P6-009: Unsafe type cast of RPC result in Edge Function
+- **File:** supabase/functions/display-idle-snapshot/index.ts:65
+- **Severity:** Medium
+- **Detail:** `as ScheduledSession[]` cast with no shape validation. Column renames in the SQL function would produce `undefined` fields that the frontend Zod schema rejects silently (see P6-007).
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Replaced unsafe `as` cast with runtime .filter() that validates all four string fields before including a row
+
+#### [FIX] P6-010: Remove redundant "what" comments
+- **File:** src/routes/display.tsx:25,28,30-31,37; src/components/display/idle-view.tsx:23,29,53,64
+- **Severity:** Low
+- **Detail:** ~8 comments that restate what self-documenting code already says (e.g., `// Idle snapshot from Edge Function broadcast` above `const idleSnapshot = useIdleSnapshot()`). Remove or replace with "why" comments where the architectural choice is non-obvious (e.g., why a separate display-status channel exists).
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Removed 8 redundant "what" comments from display.tsx and idle-view.tsx
+
+### Missing Tasks
+
+#### [TASK] P6-011: Add tests for `useIdleSnapshot` hook
+- **File:** src/components/display/use-idle-snapshot.ts
+- **Severity:** High
+- **Detail:** Zero test coverage for the hook with the most real async/channel logic. Needs tests for: valid payload updates state, invalid payload discarded (state stays null), null client returns null without throwing, `removeChannel` called on unmount, subscription status handling.
+- **Relates to:** A-004, A-013 (memory leaks / cleanup)
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S011 in Steps.md Phase 5
+
+#### [TASK] P6-012: Add tests for `DisplayModeTransition`
+- **File:** src/components/display/display-mode-transition.tsx
+- **Severity:** Medium
+- **Detail:** `getTransitionClasses` contains branching logic mapping mode transitions to CSS classes (300ms vs 400ms, zoom-in-95). Pure function that directly implements A-007 and A-017. No tests.
+- **Relates to:** A-007 (300ms idle-to-board), A-017 (400ms board-to-focused)
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S012 in Steps.md Phase 5
+
+#### [TASK] P6-013: Add schema edge case tests
+- **File:** src/domain/types/__tests__/display-snapshot.test.ts
+- **Severity:** Low
+- **Detail:** Missing tests for: invalid session_type value (e.g., "YOGA"), empty scheduled_sessions array, invalid ISO date string for server_time. These catch Zod schema regressions at the parse boundary.
+- **Relates to:** A-010 (payload structure)
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S013 in Steps.md Phase 5
+
+#### [TASK] P6-014: Strengthen ClockDisplay server correction test assertion
+- **File:** src/components/display/__tests__/clock-display.test.tsx:62-76
+- **Severity:** Low
+- **Detail:** Test only asserts time is "different" after correction (`not.toBe(initialTime)`), not that it matches the expected corrected value. Would pass even with wrong-sign correction.
+- **Relates to:** A-001 (clock accuracy), S-3 (drift correction)
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S014 in Steps.md Phase 5
+
+### Architectural Concerns
+
+_None identified. The architecture follows established patterns and the feature is well-scoped._
+
+### Convention Gaps
+
+#### [RULE] P6-015: String fields in display schemas should have `.min(1)`
+- **Files:** src/domain/types/display-snapshot.ts
+- **Severity:** Medium
+- **Detail:** `display_name`, `session_name`, `day_label` accept empty strings via `z.string()`. For display-facing types rendered as visible text on a TV, empty values produce blank cards. Adding `.min(1)` catches this at parse time.
+- **Suggested rule:** Add to `.claude/rules/` domain types conventions: "Display-facing string fields must use `.min(1)` to prevent blank UI elements"
+- **Status:** :white_check_mark: Rule applied
+- **Resolution:** Added .min(1) to all display-facing string fields in idleSnapshotSchema (display_name, session_name, day_label in both scheduled_sessions and next_session)
+
+#### [RULE] P6-016: Tech.md D4 documents wrong option as chosen
+- **Files:** Context/Features/009-idle-mode/Tech.md (D4 section)
+- **Severity:** Low
+- **Detail:** Tech.md D4 documents "Option (a) Raw SQL JOIN traversal in Edge Function" as the chosen approach, but implementation uses `supabase.rpc('get_display_idle_sessions')` -- an RPC function (Option c). Documentation is stale.
+- **Suggested rule:** N/A -- one-off documentation fix
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated Tech.md D4 to document Option (c) RPC as the chosen approach
+
+## Resolution Checklist
+- [x] All [FIX] findings resolved (10 items)
+- [x] All [TASK] findings added to Steps.md (4 items)
+- [x] All [ADR] findings have ADRs created or dismissed (0 items)
+- [x] All [RULE] findings applied or dismissed (2 items)
+- [x] Review verified by review-verify agent
+
+## Resolution Summary
+**Resolved at:** 2026-04-04
+**Session:** Resolve idle mode PR review findings
+
+| Category | Total | Fixed | Tasks Created | ADRs | Rules | Dismissed | Deferred |
+|---|---|---|---|---|---|---|---|
+| [FIX] | 10 | 10 | -- | -- | -- | -- | -- |
+| [TASK] | 4 | -- | 4 | -- | -- | -- | -- |
+| [ADR] | 0 | -- | -- | 0 | -- | -- | -- |
+| [RULE] | 2 | 1 | -- | -- | 1 | -- | -- |
+| **Total** | **16** | **10** | **4** | **0** | **1** | **0** | **0** |
diff --git a/Context/Reviews/0006-pr60-display-route-board-review.md b/Context/Reviews/0006-pr60-display-route-board-review.md
new file mode 100644
index 00000000..2a3a0c01
--- /dev/null
+++ b/Context/Reviews/0006-pr60-display-route-board-review.md
@@ -0,0 +1,205 @@
+# PR Review: worktree-feat+display-route-board -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/009-display-route-board/
+**Branch:** worktree-feat+display-route-board
+**PR:** #60
+**Reviewers:** code-reviewer, pr-test-analyzer, silent-failure-hunter, type-design-analyzer, comment-analyzer
+**Status:** :green_circle: Resolved
+
+## Summary
+
+18 findings across 26 files (2860 additions). 3 critical issues center on the reconnection
+flow: `publishHello()` is dead code, channel objects leak on reconnect, and `boot()` has an
+unhandled promise rejection. 7 important issues cover store robustness, untested reconnection
+logic, and hidden-view timer waste. 8 suggestions for schema hardening, type deduplication,
+and documentation accuracy.
+
+**Breakdown:** 8 FIX, 4 TASK, 1 ADR, 1 RULE (plus 4 suggestions deferred to backlog)
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P6-001: `publishHello()` never called after reconnection
+- **File:** src/lib/display-subscriber.ts:100-118
+- **Severity:** Critical
+- **Detail:** Tech.md TD-5 requires publishing `display_hello` after reconnecting so phones
+ re-broadcast their snapshots. `publishHello()` is implemented and exported but never wired
+ into the reconnection flow. After reconnect, sessions only reappear when phones independently
+ trigger new state changes. Violates requirement M8 and testable assertion TA-10.
+- **Relates to:** Spec.md M8, Tech.md TD-5, TA-10
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `_hasConnectedBefore` flag; `publishHello()` called on SUBSCRIBED when reconnecting (not first connect)
+
+#### [FIX] P6-002: Channel leak on reconnection -- dead channel not removed from Supabase client
+- **File:** src/lib/display-subscriber.ts:109
+- **Severity:** Critical
+- **Detail:** On terminal status (`TIMED_OUT`, `CHANNEL_ERROR`, `CLOSED`), `_channel` is set
+ to `null` without calling `_client.removeChannel(_channel)` first. The guard in
+ `subscribeToDisplay` checks `_channel` which is already null, so the old channel is never
+ cleaned up. Orphaned channel objects accumulate across reconnect cycles -- problematic for a
+ long-running kiosk that may run for days.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Call `_client.removeChannel(_channel)` before nulling `_channel` in terminal status handler
+
+#### [FIX] P6-003: Unhandled promise rejection in `boot()` causes blank screen
+- **File:** src/routes/display.tsx:59-94
+- **Severity:** Critical
+- **Detail:** The `boot()` async function has no try-catch. If `resolveConfig()` throws
+ (localStorage blocked in private browsing, network error in `validateConnection()`), or
+ `createClient()` throws (malformed URL), the promise rejects silently. The display shows a
+ blank page with zero feedback. A gym owner sees a black screen with no diagnostic info.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Wrapped `boot()` body in try-catch; sets connectionStatus to 'disconnected' on failure
+
+#### [FIX] P6-004: `clearAllSessions` does not reset `currentPage`
+- **File:** src/stores/display-store.ts (clearAllSessions action)
+- **Severity:** High
+- **Detail:** `clearAllSessions` resets `sessions`, `lastSeenAt`, and `focusedUserId` but not
+ `currentPage`. If the display is on page 2 when all sessions clear, and new sessions arrive,
+ `getPageSessions` returns an empty array because `currentPage` is still 2 but there is only
+ 1 page of content. Results in a blank board with active sessions.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `currentPage: 0` to `clearAllSessions` reset
+
+#### [FIX] P6-005: `setCurrentPage` has no bounds clamping
+- **File:** src/stores/display-store.ts (setCurrentPage action)
+- **Severity:** Medium
+- **Detail:** Accepts any number without validation. Setting `currentPage` to -1 or 999
+ produces an empty `getPageSessions` result. The auto-paging interval in board-view.tsx
+ could push the page beyond bounds if sessions are removed between ticks.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** `setCurrentPage` now clamps to `[0, totalPages-1]`; updated tests to verify bounds
+
+#### [FIX] P6-006: `subscribeToDisplay` silently returns when client not initialized
+- **File:** src/lib/display-subscriber.ts:53-57
+- **Severity:** Medium
+- **Detail:** When `_client` is null, the function logs `console.warn` and returns without
+ notifying the caller via `onStatusChange`. The connection footer stays stuck on
+ "Disconnected" with no reconnection attempts and no actionable feedback.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `handlers.onStatusChange('disconnected')` call before early return
+
+#### [FIX] P6-007: `useElapsedTime` has no NaN guard for invalid timestamps
+- **File:** src/hooks/use-elapsed-time.ts:11-12
+- **Severity:** Medium
+- **Detail:** `new Date(startedAt).getTime()` produces `NaN` if `startedAt` is an invalid
+ string. While Zod validates upstream, this hook is exported publicly. `Math.floor(NaN / 1000)`
+ produces `NaN`, rendering "NaN:NaN:NaN" on the gym TV.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `Number.isNaN(startMs)` guard returning 0; also clamped elapsed to `Math.max(0, ...)`
+
+#### [FIX] P6-008: Stale comment in `__root.tsx` bypass list
+- **File:** src/routes/__root.tsx:7
+- **Severity:** Low
+- **Detail:** Comment says "Allow /setup and public share routes to load without a backend
+ configuration" but `/display` was just added to the bypass list. Comment no longer reflects
+ the full set of bypassed routes.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated comment to include `/display` in the description
+
+### Missing Tasks
+
+#### [TASK] P6-009: Reconnection retry logic is entirely untested
+- **File:** src/lib/display-subscriber.ts:105-118
+- **Severity:** Critical
+- **Detail:** The exponential backoff retry mechanism (delay computation, cap at 30s, retry
+ attempt reset on success, cancel on destroy) has zero test coverage. This is the most complex
+ untested code path in the PR and is architecturally central to the display's "set it and
+ forget it" reliability requirement.
+- **Relates to:** Spec.md M8, Tech.md TD-5
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S008 in Steps.md (Wave 4)
+
+#### [TASK] P6-010: `use-elapsed-time.ts` has zero tests
+- **File:** src/hooks/use-elapsed-time.ts
+- **Severity:** Medium
+- **Detail:** 19-line hook used by both `SessionCard` and `FocusedViewContent` with date
+ arithmetic that could regress. Trivial to test with `renderHook` following the same pattern
+ as `use-timer-interpolation.test.ts`. Test cases: returns 0 at start, increases over time,
+ resets when `startedAt` changes, handles future timestamps.
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S009 in Steps.md (Wave 4)
+
+#### [TASK] P6-011: Add `.min(1)` to string fields in display snapshot schema
+- **File:** src/domain/types/display-snapshot.ts
+- **Severity:** Medium
+- **Detail:** `user_id`, `display_name`, `session_name`, and `current_exercise` accept empty
+ strings. An empty `user_id` silently creates a session keyed to `""` in the store. Empty
+ `display_name` / `session_name` produce blank cards on the TV.
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S010 in Steps.md (Wave 4)
+
+#### [TASK] P6-012: Invalid payloads for `session_ended` and `focus` events not tested
+- **File:** src/lib/__tests__/display-subscriber.test.ts
+- **Severity:** Low
+- **Detail:** Invalid `workout_snapshot` payload test exists, but `session_ended` and `focus`
+ events have no invalid payload tests. These go through `userIdPayloadSchema.safeParse()` and
+ should be dropped with a warning. A parameterized test for each would provide parity.
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S011 in Steps.md (Wave 4)
+
+### Architectural Concerns
+
+#### [ADR] P6-013: Dual-Map synchronization hazard in display store
+- **File:** src/stores/display-store.ts
+- **Severity:** High
+- **Detail:** `sessions` and `lastSeenAt` are parallel Maps that must always have matching key
+ sets. Every mutation must update both. A single missed spot creates a memory leak (orphaned
+ `lastSeenAt` entries) or a crash (missing `lastSeenAt` for a session). Additionally, Zustand's
+ `setState()` can bypass actions entirely. Merging into a single
+ `Map` eliminates the invariant
+ structurally.
+- **Relates to:** Tech.md display store design
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Merged dual maps into single `Map`. No ADR needed -- straightforward refactor that eliminates the invariant structurally. Added `getSnapshot` and `getSessionCount` selectors for consumers.
+
+### Convention Gaps
+
+#### [RULE] P6-014: `ConnectionStatus` type defined in 3 separate places
+- **Files:** src/stores/display-store.ts, src/lib/display-subscriber.ts (inline literals in
+ DisplayEventHandlers.onStatusChange), subscriber status getter
+- **Severity:** Medium
+- **Detail:** The same `'connected' | 'disconnected' | 'reconnecting'` union is defined
+ independently in three locations. These can drift without compile-time errors. Should be
+ extracted to a single shared definition imported by both the store and subscriber.
+- **Suggested rule:** Add to `.claude/rules/` or domain conventions: shared infrastructure
+ types (connection status, event types) should be defined once and imported.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Extracted `DisplayConnectionStatus` type to `src/domain/types/display-snapshot.ts`; store and subscriber both import from the single source
+
+### Deferred to Backlog (Suggestions)
+
+The following are valid observations but lower priority. They can be captured as backlog
+items rather than blocking merge:
+
+- **S1:** Idle view components remain mounted with CSS opacity -- timers tick when hidden
+ (src/routes/display.tsx:128-159). Consider conditional rendering or `visible` prop.
+- **S2:** Reconnection retry has no max attempt limit -- infinite 30s retries with no "gave up"
+ state (src/lib/display-subscriber.ts:111-118).
+- **S3:** `publishHello` JSDoc says "presence" which is misleading given Supabase Presence API
+ (src/lib/display-subscriber.ts:122).
+- **S4:** `DisplayEventType` enum declared but never consumed programmatically
+ (src/domain/types/display-snapshot.ts). Either consume it or remove it.
+
+## Resolution Summary
+**Resolved at:** 2026-04-04
+**Session:** Review resolution for PR #60
+
+| Category | Total | Fixed | Tasks Created | ADRs | Rules | Dismissed | Deferred |
+|---|---|---|---|---|---|---|---|
+| [FIX] | 8 | 8 | -- | -- | -- | -- | -- |
+| [TASK] | 4 | -- | 4 | -- | -- | -- | -- |
+| [ADR] | 1 | 1 | -- | -- | -- | -- | -- |
+| [RULE] | 1 | 1 | -- | -- | 1 | -- | -- |
+| **Total** | **14** | **10** | **4** | **0** | **1** | **0** | **0** |
+
+Note: P6-013 (ADR) was resolved as a direct refactor (merged dual maps) rather than creating a formal ADR -- the fix was small enough to just do it.
+
+## Resolution Checklist
+- [x] All [FIX] findings resolved (P6-001 through P6-008)
+- [x] All [TASK] findings added to Steps.md (P6-009 through P6-012)
+- [x] All [ADR] findings have ADRs created or dismissed (P6-013)
+- [x] All [RULE] findings applied or dismissed (P6-014)
+- [x] Review verified by review-verify agent
diff --git a/Context/Reviews/0007-pr64-qr-code-gen-scan-review.md b/Context/Reviews/0007-pr64-qr-code-gen-scan-review.md
new file mode 100644
index 00000000..c8ae89d0
--- /dev/null
+++ b/Context/Reviews/0007-pr64-qr-code-gen-scan-review.md
@@ -0,0 +1,162 @@
+# PR Review: worktree-feat+qr-code-gen-scan -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/010-qr-code-gen-scan/
+**Branch:** worktree-feat+qr-code-gen-scan
+**PR:** #64
+**Reviewers:** code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer
+**Status:** :green_circle: Resolved
+
+## Summary
+
+14 findings across 4 review agents. 2 critical (bare catch convention violation, missing try/catch leaving UI stuck), 4 important (unhandled promise, missing user feedback, constant drift risk, accessibility gap), 4 suggestions, and 4 test coverage gaps. New invite-link utility is well-tested (13 unit tests) but UI components have zero component-level tests for the new functionality.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P7-001: Bare `catch {}` in handleCopy violates error-handling convention
+
+- **File:** src/components/profile/backend-settings.tsx:57-59
+- **Severity:** Critical
+- **Detail:** The existing `handleCopy` function uses a bare `catch {}` with comment "Copy is non-critical, swallow silently." This directly violates `.claude/rules/error-handling.md`. Worse, `setCopied(true)` runs unconditionally after the catch, so the user sees "Copied" feedback even when clipboard write fails. Fix: capture error, log with `[backend-settings]` prefix, only set copied on success, show error toast on failure.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Captured error, added `[backend-settings]` log prefix, moved `setCopied(true)` inside try block, added error toast
+
+#### [FIX] P7-002: `processInviteLink` missing try/catch around `validateAndSave`
+
+- **File:** src/routes/setup.tsx:118-129
+- **Severity:** Critical
+- **Detail:** Unlike sibling handlers `handleConnect` and `handleDiscoverAndConnect` which wrap `validateAndSave` in try/catch with state updates, `processInviteLink` does not. If validation throws unexpectedly, the UI gets stuck showing "Connecting..." with no recovery path. Also causes the scan path's outer catch to conflate scan failures with validation failures, showing "QR scan failed" when the scan actually succeeded. Fix: add try/catch matching the pattern in `handleConnect`, setting `validation-failed` state on error.
+- **Relates to:** TA-7
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added try/catch around `validateAndSave` matching `handleConnect` pattern, sets `validation-failed` state on error
+
+#### [FIX] P7-003: Unhandled promise from `handleScan()` in button onClick
+
+- **File:** src/routes/setup.tsx:241
+- **Severity:** High
+- **Detail:** The QR button's `onClick` calls `handleScan()` (async) without `.catch()`. If the dynamic import fails before the internal try block, the rejection is unhandled. The paste field handlers correctly use `.catch()` but the Tauri branch does not. Fix: add `.catch()` matching the pattern used for `processInviteLink` calls.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `.catch()` on `handleScan()` in onClick handler with error log and user-facing toast
+
+#### [FIX] P7-004: "Copy invite link" gives no user feedback on clipboard failure
+
+- **File:** src/components/profile/backend-settings.tsx:286-294
+- **Severity:** High
+- **Detail:** The catch block logs the error with correct module prefix but shows no toast. User clicks button, nothing visible happens on failure. Fix: add `toast('Failed to copy invite link')` in the catch block.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `toast('Failed to copy invite link')` in catch block
+
+#### [FIX] P7-005: `buildInviteLink` hardcodes scheme/host instead of using constants
+
+- **File:** src/lib/invite-link.ts:7
+- **Severity:** High
+- **Detail:** `SCHEME` and `HOST` constants are declared at lines 1-2 and used in `parseInviteLink`, but `buildInviteLink` hardcodes `ardentforge://connect` in the template literal. If the scheme or host changes, the two functions silently diverge. Fix: use `${SCHEME}//${HOST}` in the template.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Replaced hardcoded string with `${SCHEME}//${HOST}` template
+
+#### [FIX] P7-006: Paste field missing `aria-label`
+
+- **File:** src/routes/setup.tsx:253
+- **Severity:** High
+- **Detail:** The `ForgeInput` for the paste field has a `placeholder` but no `aria-label`, violating accessibility requirements. Screen readers cannot identify the input's purpose. Fix: add `aria-label="Paste invite link"`.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `aria-label="Paste invite link"` to ForgeInput
+
+#### [FIX] P7-007: `parseInviteLink` logs `console.error` for expected invalid input
+
+- **File:** src/lib/invite-link.ts:24-26
+- **Severity:** Low
+- **Detail:** Returning `null` for invalid input is by design, but `console.error` treats every parse failure as an error worth logging. Users pasting garbage text will flood the console. Fix: downgrade to `console.debug` or remove the log entirely since `null` return is the expected signal.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Removed console.error from catch block; `null` return is sufficient signal
+
+#### [FIX] P7-008: `.catch()` on processInviteLink in paste/keydown logs but gives no user feedback
+
+- **File:** src/routes/setup.tsx:259-261, 268-270
+- **Severity:** Medium
+- **Detail:** The `.catch()` handlers on `processInviteLink` promise in the onKeyDown and onPaste handlers log the error but show no toast. If an unexpected error occurs, the user sees nothing. Fix: add `toast('Something went wrong. Please try again.')` in the catch blocks. Note: fixing P7-002 (adding try/catch inside `processInviteLink`) reduces the likelihood of these catches firing, but they should still inform the user as a safety net.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `toast('Something went wrong. Please try again.')` in both .catch() handlers
+
+#### [FIX] P7-009: Generic "QR scan failed" toast for all failure types
+
+- **File:** src/routes/setup.tsx:154-158
+- **Severity:** Low
+- **Detail:** The catch block shows "QR scan failed" for dynamic import failures, permission issues, hardware failures, and validation errors alike. At minimum, append "Try pasting the invite link instead." to give the user an actionable alternative. Mostly resolved by P7-002 (which stops validation errors from reaching this catch).
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated toast to "QR scan failed. Try pasting the invite link instead." in both catch locations
+
+#### [FIX] P7-010: Comment on `buildInviteLink` says "encodes the key" (slightly misleading)
+
+- **File:** src/lib/invite-link.ts:4-5
+- **Severity:** Low
+- **Detail:** Comment says the function "Encodes the Supabase publishable (anon) key" but it actually builds an entire invite link URL containing both URL and key. A reader might interpret "encodes" as a transformation of the key itself. Minor rewording suggested: "The publishable (anon) key is not a secret -- safe to embed in QR codes and invite links since it is already bundled into every client build."
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Reworded comment to clarify the key is not a secret and safe to embed
+
+### Missing Tasks
+
+#### [TASK] P7-011: Component tests for processInviteLink valid/invalid flows
+
+- **File:** src/routes/setup.tsx
+- **Severity:** High
+- **Detail:** `processInviteLink` is the central integration point for the paste/scan flow. No component-level tests verify that: (1) a valid invite link populates url/key fields, expands Advanced, triggers validation; (2) an invalid link shows toast and leaves fields unchanged. A regression here makes the entire QR feature non-functional.
+- **Relates to:** TA-6, TA-7, S006
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S006-T1 in Steps.md
+
+#### [TASK] P7-012: Component tests for "Share this server" conditional rendering and copy button
+
+- **File:** src/components/profile/backend-settings.tsx
+- **Severity:** Medium
+- **Detail:** No tests verify: (1) QR section absent when config is null and present when configured (TA-3); (2) copy button writes correct invite link to clipboard and shows toast (TA-2). These are spec requirements M2 and M3.
+- **Relates to:** TA-2, TA-3, S006
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S006-T2 in Steps.md
+
+#### [TASK] P7-013: Unit test for empty-string params in parseInviteLink
+
+- **File:** src/lib/**tests**/invite-link.test.ts
+- **Severity:** Low
+- **Detail:** Tests cover missing params but not explicitly empty-valued params like `ardentforge://connect?url=&key=abc`. The `!url || !key` guard should catch it, but an explicit test documents the behavior and prevents regression if the guard changes.
+- **Relates to:** TA-5
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added two tests for empty-valued url and key params in invite-link.test.ts
+
+### Architectural Concerns
+
+_None identified._
+
+### Convention Gaps
+
+#### [RULE] P7-014: Pre-existing bare `catch {}` in connection-validator.ts
+
+- **Files:** src/lib/connection-validator.ts:25-26
+- **Severity:** Medium
+- **Detail:** The `createClient` call is wrapped in a bare `catch {}` without capturing or logging the error. This predates PR #64 but is now called by new code paths (processInviteLink -> validateAndSave -> validateConnection). Same violation pattern as P7-001. Suggests the error-handling convention should be enforced more broadly, possibly via a lint rule or pre-commit hook.
+- **Suggested rule:** Consider adding an ESLint rule (`no-empty` or custom) to flag bare catch blocks, or add a note in `.claude/rules/error-handling.md` to audit existing code for violations.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Fixed bare catch in connection-validator.ts to capture error and log with `[connection-validator]` prefix
+
+## Resolution Summary
+
+**Resolved at:** 2026-04-04
+**Session:** Resolve PR review findings P7
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+| --------- | ------ | ------ | ----- | ----- | ----- | -------- | --------- |
+| [FIX] | 10 | 10 | -- | -- | -- | -- | -- |
+| [TASK] | 3 | 1 | 2 | -- | -- | -- | -- |
+| [ADR] | 0 | -- | -- | -- | -- | -- | -- |
+| [RULE] | 1 | 1 | -- | -- | -- | -- | -- |
+| **Total** | **14** | **12** | **2** | **0** | **0** | **0** | **0** |
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved (10 items)
+- [x] All [TASK] findings added to Steps.md or tracked (3 items)
+- [x] All [ADR] findings have ADRs created or dismissed (0 items)
+- [x] All [RULE] findings applied or dismissed (1 item)
+- [x] Review verified by review-verify agent
diff --git a/Context/Reviews/0007-pr65-progressive-disclosure-review.md b/Context/Reviews/0007-pr65-progressive-disclosure-review.md
new file mode 100644
index 00000000..3a150f49
--- /dev/null
+++ b/Context/Reviews/0007-pr65-progressive-disclosure-review.md
@@ -0,0 +1,168 @@
+# PR Review: worktree-feat+progressive-disclosure -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/010-Progressive-Disclosure/
+**Branch:** worktree-feat+progressive-disclosure
+**PR:** #65
+**Reviewers:** code-reviewer, silent-failure-hunter, type-design-analyzer, comment-analyzer, code-simplifier
+**Build:** TypeScript PASS, ESLint PASS (no new issues)
+**Status:** 🟡 Partially resolved
+
+## Summary
+
+22 files changed across progressive disclosure and contextual help feature. Architecture is sound with data-driven visibility maps and centralized help content. 15 findings total: 7 fix-now, 4 missing tasks, 1 architectural concern, 3 convention gaps. Two critical items: silent save failure and JSX triplication.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P7-001: `handleSave` silently no-ops when userId is empty
+
+- **File:** src/components/session-builder/session-template-form.tsx:192
+- **Severity:** Critical
+- **Detail:** `if (!userId) return` exits without any user feedback. If auth state is lost or not yet loaded, user taps Save and nothing happens -- no error, no spinner, no indication. Should surface an error message via `setErrors()` and log with `[session-template-form]` prefix.
+- **Status:** ✅ Fixed
+- **Resolution:** Added console.error log with [session-template-form] prefix and setErrors() call for user-facing feedback
+
+#### [FIX] P7-002: Scoring/TimeCap JSX tripled across ternary branches
+
+- **File:** src/components/session-builder/session-template-form.tsx:312-406
+- **Severity:** Critical
+- **Detail:** The Scoring `` and TimeCap `` are copy-pasted three times across three ternary branches. Any change to scoring options or styling must be replicated in three places. Extract to local variables and conditionally wrap in `CollapsedFieldsRow`.
+- **Status:** ✅ Fixed
+- **Resolution:** Extracted scoringField and timeCapField to local variables; single CollapsedFieldsRow wraps hidden fields dynamically
+
+#### [FIX] P7-003: Redundant `satisfies` after explicit type annotation in visibility maps
+
+- **File:** src/components/builders/visibility-maps.ts (all 4 exports)
+- **Severity:** Medium
+- **Detail:** All four constants have both an explicit `Record<...>` type annotation and a trailing `satisfies Record<...>` with the identical type. The `satisfies` is redundant when the annotation matches. Keep only `satisfies` to get narrower literal type inference (e.g., `false` instead of `boolean`), or keep only the annotation -- but not both.
+- **Status:** ✅ Fixed
+- **Resolution:** Removed explicit type annotations, kept only satisfies on all 4 exports
+
+#### [FIX] P7-004: Unstable `availableTypes` in useEffect dependency array
+
+- **File:** src/components/session-builder/set-scheme-editor.tsx:506-512
+- **Severity:** Medium
+- **Detail:** `availableTypes` is created via `.filter()` on every render, making it a new reference each time. Including it in the `useEffect` deps means the effect runs every render (guarded by `!isCurrentAllowed` check, so no infinite loop, but wasteful). Simplify to derive `isCurrentAllowed` directly from `allowedLoads` and `value.type` inside the effect.
+- **Status:** ✅ Fixed
+- **Resolution:** Derived isCurrentAllowed from stable allowedLoads and value.type inside the effect; removed availableTypes from deps
+
+#### [FIX] P7-005: Identical block type help content JSX in two files
+
+- **File:** src/components/program-builder/block-editor.tsx, src/components/program-builder/mobile-block-editor.tsx
+- **Severity:** Medium
+- **Detail:** Both files contain an identical ~25-line block of JSX: "Block type" label + `HelpTrigger` with `BLOCK_TYPE_HELP` content rendering + `ToggleGroup` + one-liner description. Extract the help content JSX into a shared constant or small component in `builders/`.
+- **Status:** ✅ Fixed
+- **Resolution:** Extracted BlockTypeSelector component to program-builder/block-type-selector.tsx; both editors now import it
+
+#### [FIX] P7-006: Add branch comments to Scoring/TimeCap ternary
+
+- **File:** src/components/session-builder/session-template-form.tsx:312,342
+- **Severity:** Low
+- **Detail:** The third branch (line 374) has a helpful comment `{/* showScoring is false, showTimeCap is true (SE category) */}` but the first two branches lack equivalent comments. Add `{/* Both visible: Conditioning, Mixed */}` and `{/* Both hidden: Strength, Event */}` for consistency.
+- **Status:** ❌ Discarded
+- **Resolution:** P7-002 eliminates the ternary -- extraction removes the three-branch structure entirely
+
+#### [FIX] P7-007: Add JSDoc to `useMediaQuery` and `HelpTrigger`
+
+- **File:** src/hooks/use-media-query.ts, src/components/ui/help-trigger.tsx
+- **Severity:** Low
+- **Detail:** `useMediaQuery` has no comments -- worth explaining why `useSyncExternalStore` over `useState+useEffect` (avoids tearing in concurrent mode) and why `getServerSnapshot` returns `false`. `HelpTrigger` should note the Popover-on-desktop/Drawer-on-mobile strategy and that the spec's `placement` prop was intentionally omitted.
+- **Status:** ✅ Fixed
+- **Resolution:** Added JSDoc to both useMediaQuery (useSyncExternalStore rationale) and HelpTrigger (responsive strategy, placement omission)
+
+### Missing Tasks
+
+#### [TASK] P7-008: Extract `HelpEntry` interface in help-content.ts
+
+- **File:** src/components/builders/help-content.ts
+- **Severity:** Medium
+- **Detail:** The `{ label: string; description: string }` shape is duplicated across `GROUP_TYPE_HELP` and `SOURCE_HELP`. Extract into a named `HelpEntry` interface and extend it for `BlockTypeHelpEntry` (which adds `oneLiner`). Reduces duplication and gives a clear name to the concept.
+- **Status:** ✅ Fixed
+- **Resolution:** Extracted HelpEntry and BlockTypeHelpEntry interfaces; both exported and used in satisfies clauses
+
+#### [TASK] P7-009: Use non-empty tuple for CollapsedFieldsRow labels
+
+- **File:** src/components/session-builder/collapsed-fields-row.tsx
+- **Severity:** Medium
+- **Detail:** `labels: string[]` permits an empty array, which would render a collapsed row with no visible label text. Use `labels: [string, ...string[]]` to enforce at least one label at compile time.
+- **Status:** ✅ Fixed
+- **Resolution:** Changed labels type to [string, ...string[]]
+
+#### [TASK] P7-010: Tighten `SCHEME_GROUP_FOR_TYPE` and `SCHEME_TYPE_LABELS` typing
+
+- **File:** src/components/session-builder/set-scheme-editor.tsx:1164,1167
+- **Severity:** Medium
+- **Detail:** Both maps are typed as `Record` with `??` fallbacks. If a new `SetScheme` variant is added to the Zod union, the label silently falls back to `'STRENGTH'` for group name and raw type string for label. Type as `Record` with `satisfies` for compile-time exhaustiveness, matching the pattern in `visibility-maps.ts`.
+- **Status:** ✅ Fixed
+- **Resolution:** Typed as Record via Object.fromEntries + as assertion; removed ?? fallbacks. `satisfies` is not applicable to fromEntries (TypeScript infers `{ [k: string]: ... }` with no key completeness guarantee); as-assertion is the correct approach here. Also fixed 3 pre-existing TS errors introduced by P7 fixes: collapsedLabels cast to [string, ...string[]], allowedLoads.includes cast to LoadSpec['type'][], defaultTypes.includes cast to SetSchemeType[].
+
+#### [TASK] P7-011: Tighten `BLOCK_TYPE_STYLES` and `SESSION_TYPE_BADGE` typing
+
+- **File:** src/components/program-builder/constants.ts:98-116
+- **Severity:** Medium
+- **Detail:** Both use `Record` instead of `Record` and `Record`. New domain variants would silently fall back to generic gray styling. Pre-existing issue but amplified by this PR's heavier reliance on these maps.
+- **Status:** ✅ Fixed
+- **Resolution:** Changed to satisfies Record and satisfies Record
+
+### Architectural Concerns
+
+#### [ADR] P7-012: No ErrorBoundary in the application
+
+- **File:** App-wide (no `ErrorBoundary` component exists in `src/`)
+- **Severity:** High
+- **Detail:** This PR adds new Popover/Drawer subtrees via `HelpTrigger` and complex conditional rendering in builders. If any Radix/vaul component throws during render, the entire form crashes and the user loses all unsaved state. At minimum, `HelpTrigger` internals should be wrapped so a failure degrades to a hidden help icon. Ideally, each `ActivityGroupEditor` and `BlockEditor` also gets a boundary so a single group/block error does not destroy the whole form. This is app-wide scope and warrants a decision on error boundary strategy.
+- **Relates to:** General app resilience, not specific to a spec assertion
+- **Status:** 📋 Deferred
+- **Resolution:** Added to Context/Backlog/error-boundary-strategy.md
+
+### Convention Gaps
+
+#### [RULE] P7-013: Visibility/content maps should use `satisfies` without redundant annotation
+
+- **Files:** src/components/builders/visibility-maps.ts, src/components/builders/help-content.ts
+- **Severity:** Low
+- **Detail:** Pattern appeared 4 times in visibility-maps.ts. When a constant needs compile-time exhaustiveness checking on a Record type, use `satisfies Record` alone (not both annotation and satisfies). The `satisfies` provides exhaustiveness while letting TS infer narrower literal types.
+- **Suggested rule:** Add to `.claude/rules/` a TypeScript conventions rule: "For exhaustive Record constants, use `satisfies Record` without an explicit type annotation to get both safety and narrow inference."
+- **Status:** ✅ Rule updated
+- **Resolution:** Added to .claude/rules/typescript-conventions.md
+
+#### [RULE] P7-014: Domain-keyed style/label maps must use domain union types, not `string`
+
+- **Files:** src/components/program-builder/constants.ts, src/components/session-builder/set-scheme-editor.tsx
+- **Severity:** Medium
+- **Detail:** Four maps use `Record` for domain-keyed lookups (`BLOCK_TYPE_STYLES`, `SESSION_TYPE_BADGE`, `SCHEME_GROUP_FOR_TYPE`, `SCHEME_TYPE_LABELS`). This defeats compile-time exhaustiveness checking. Any `Record` keyed by a domain union (`BlockType`, `SessionType`, `SetSchemeType`) should use the union type as key.
+- **Suggested rule:** Add to `.claude/rules/`: "All Record types keyed by domain union types (SessionType, BlockType, GroupType, SetSchemeType, etc.) must use the union as the key type, not `string`. Use `satisfies` for exhaustiveness."
+- **Status:** ✅ Rule updated
+- **Resolution:** Added to .claude/rules/typescript-conventions.md
+
+#### [RULE] P7-015: Bare `if (!userId) return` guards must surface user feedback
+
+- **Files:** src/components/session-builder/session-template-form.tsx:192
+- **Severity:** Medium
+- **Detail:** Silent auth guards that exit save/submit handlers without feedback are a recurring risk pattern. Any guard clause that prevents a user-initiated action (save, delete, submit) must log with `[module-name]` prefix and surface a user-facing error or toast.
+- **Suggested rule:** Add to `.claude/rules/error-handling.md`: "Guard clauses in user-action handlers (save, delete, submit) must never silently return. Always log with `[module-name]` prefix and set a user-facing error state."
+- **Status:** ✅ Rule updated
+- **Resolution:** Added to .claude/rules/error-handling.md
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved
+- [x] All [TASK] findings resolved (fixed inline -- no Steps.md tasks needed)
+- [x] All [ADR] findings deferred to backlog
+- [x] All [RULE] findings applied
+- [x] Review verified by review-verify agent
+
+## Resolution Summary
+
+**Resolved at:** 2026-04-04
+**Session:** Review resolution for PR #65 progressive disclosure
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+| --------- | ------ | ------ | ----- | ----- | ----- | -------- | --------- |
+| [FIX] | 7 | 6 | -- | -- | -- | -- | 1 |
+| [TASK] | 4 | 4 | -- | -- | -- | -- | -- |
+| [ADR] | 1 | -- | -- | -- | -- | 1 | -- |
+| [RULE] | 3 | -- | -- | -- | 3 | -- | -- |
+| **Total** | **15** | **10** | **0** | **0** | **3** | **1** | **1** |
diff --git a/Context/Reviews/0008-pr66-deep-link-handler-review.md b/Context/Reviews/0008-pr66-deep-link-handler-review.md
new file mode 100644
index 00000000..911812d1
--- /dev/null
+++ b/Context/Reviews/0008-pr66-deep-link-handler-review.md
@@ -0,0 +1,118 @@
+# PR Review: worktree-feat+deep-link-handler -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/011-deep-link-handler/
+**Branch:** worktree-feat+deep-link-handler
+**Reviewers:** code-reviewer, pr-test-analyzer, silent-failure-hunter, type-design-analyzer
+**Status:** :green_circle: Resolved
+
+## Summary
+
+9 findings across 4 review agents. 1 critical error-routing issue where connect-link failures misroute to the OAuth error page. 3 important issues around render-phase setState, unsafe type casts, and missing user feedback. 5 suggestions covering URL validation, store boundary guards, domain type extraction, test gaps, and pre-existing bare catches.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P8-001: `handleConnectLink` has no try-catch; errors propagate to wrong handler
+
+- **File:** src/lib/deep-link-handler.ts:14-40
+- **Severity:** Critical
+- **Detail:** The async function calls `store.hasConfig()` and `store.getConfig()` (Tauri IPC / storage) with no error handling. When called from `auth.tsx:177`, any exception falls through to the outer catch that redirects to `/sign-in?reason=oauth_error` -- actively misleading for a connect-link failure. Wrap the body in try-catch with `[deep-link]` prefix and a user-facing toast.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added try-catch around store operations with `[deep-link]` prefix logging and user-facing toast
+
+#### [FIX] P8-002: Auth.tsx connect-link errors misroute to OAuth error page
+
+- **File:** src/lib/auth.tsx:176-179
+- **Severity:** Critical
+- **Detail:** The `handleConnectLink(urlStr)` call inside the `onOpenUrl` loop has no dedicated catch. If it throws, the outer catch at ~line 197 redirects to `/sign-in?reason=oauth_error`. Wrap the call in its own try-catch with connect-specific error handling before the `return` statement. This is tightly coupled with P8-001 -- fixing both together is most robust.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Wrapped `handleConnectLink` call in dedicated try-catch with `[auth] Connect deep-link failed` logging, isolated from OAuth error path
+
+#### [FIX] P8-003: Render-phase setState in backend-settings.tsx
+
+- **File:** src/components/profile/backend-settings.tsx:52-62
+- **Severity:** High
+- **Detail:** Calling `setEditing()`, `setUrl()`, `setKey()` during the render body (outside an effect) is fragile with React Compiler and Strict Mode. The idiomatic fix is to use `useState` initializer functions to read from `usePendingConnect.getState()` at initialization time, then clear in a `useEffect`.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Replaced render-phase setState with `useState` initializer functions reading from a ref snapshot of `usePendingConnect.getState().pending`. Store cleared in useEffect after mount.
+
+#### [FIX] P8-004: `validateSearch` uses unsafe `as string` casts
+
+- **File:** src/routes/connect.tsx:8-9, src/routes/setup.tsx:26-27
+- **Severity:** High
+- **Detail:** `(search.url as string)` on an `unknown` value bypasses TypeScript safety. If a query param arrives as an array (`?url=a&url=b`), the cast silently lies. Minimum fix: `typeof search.url === 'string' ? search.url || undefined : undefined`. Better: use Zod schema validation.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Replaced `as string` casts with `typeof` guards in both connect.tsx and setup.tsx
+
+#### [FIX] P8-005: Deep-link auto-validate failure has no user feedback
+
+- **File:** src/routes/setup.tsx:221-223
+- **Severity:** Medium
+- **Detail:** The `.catch()` on `validateAndSave()` only logs to console. If auto-connection from a deep link fails unexpectedly, the user sees a pre-filled form with no indication anything went wrong. Add a toast like `'Auto-connection failed. Please try connecting manually.'`.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added toast('Auto-connection failed. Please try connecting manually.') in the catch handler
+
+#### [FIX] P8-006: `parseInviteLink` HTTPS check does not validate URL structure
+
+- **File:** src/lib/invite-link.ts:23
+- **Severity:** Medium
+- **Detail:** `url.startsWith('https://')` passes values like `https://` (no host). Use `new URL(url)` + check `parsed.hostname` for defense-in-depth at this security boundary.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Replaced `startsWith` check with `new URL(url)` validation checking `protocol === 'https:'` and `hostname` is truthy
+
+#### [FIX] P8-007: Pre-existing bare `catch {}` blocks in config-store.ts
+
+- **File:** src/lib/config-store.ts:41,66,91
+- **Severity:** Medium
+- **Detail:** Three bare catches violating `.claude/rules/error-handling.md`. These are the exact methods the new feature calls. Capture error param and log with `[config-store]` prefix.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** All three bare catches now capture `err` and log with `[config-store]` prefix
+
+### Missing Tasks
+
+#### [TASK] P8-008: No tests for /connect route or auth.tsx connect dispatch
+
+- **File:** src/routes/connect.tsx, src/lib/auth.tsx:175-178
+- **Severity:** Low
+- **Detail:** The core `handleConnectLink` has excellent branch coverage (7 test cases). However the `/connect` route's missing-params guard, error catch, and URL reconstruction are untested. The `url.hostname === 'connect'` dispatch in auth.tsx is also untested. A regression could misroute connect URLs into the OAuth flow with no test catching it.
+- **Relates to:** Steps.md S004 (deep-link handler tests)
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S011 in Steps.md (Wave 4: tests for /connect route and auth.tsx connect dispatch)
+
+### Architectural Concerns
+
+_None._
+
+### Convention Gaps
+
+#### [RULE] P8-009: No store-boundary validation on usePendingConnect
+
+- **Files:** src/lib/pending-connect.ts:10
+- **Severity:** Low
+- **Detail:** `setPending("", "")` silently succeeds. The HTTPS invariant is enforced only in `parseInviteLink`, not at the store boundary. A one-line guard would make the store self-protecting. Consider whether Zustand stores that accept domain-constrained values should validate at their own boundary as a project convention.
+- **Suggested rule:** .claude/rules/state-management.md -- "Zustand stores that accept domain values should validate at their own boundary, not rely solely on caller validation"
+- **Status:** :white_check_mark: Rule updated
+- **Resolution:** Created `.claude/rules/state-management.md` with store boundary validation and Zustand+React integration conventions. Applied guard to `usePendingConnect.setPending`.
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved
+- [x] All [TASK] findings added to Steps.md
+- [x] All [ADR] findings have ADRs created or dismissed
+- [x] All [RULE] findings applied or dismissed
+- [x] Review verified by review-verify agent
+
+## Resolution Summary
+
+**Resolved at:** 2026-04-04
+**Session:** PR #66 review resolution
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+| --------- | ----- | ----- | ----- | ----- | ----- | -------- | --------- |
+| [FIX] | 7 | 7 | -- | -- | -- | -- | -- |
+| [TASK] | 1 | -- | 1 | -- | -- | -- | -- |
+| [ADR] | 0 | -- | -- | -- | -- | -- | -- |
+| [RULE] | 1 | -- | -- | -- | 1 | -- | -- |
+| **Total** | **9** | **7** | **1** | **0** | **1** | **0** | **0** |
diff --git a/Context/Reviews/0008-pr67-ci-cd-pipeline-review.md b/Context/Reviews/0008-pr67-ci-cd-pipeline-review.md
new file mode 100644
index 00000000..a2279cf5
--- /dev/null
+++ b/Context/Reviews/0008-pr67-ci-cd-pipeline-review.md
@@ -0,0 +1,180 @@
+# PR Review: feat/ci-cd-pipeline -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/011-ci-cd-pipeline/
+**Branch:** feat/ci-cd-pipeline
+**Reviewers:** code-reviewer, silent-failure-hunter, pr-test-analyzer, comment-analyzer
+**Status:** :large_green_circle: Resolved
+
+## Summary
+
+16 findings across 14 files implementing the Android CI/CD pipeline (Feature 011). 3 critical issues around silent failure modes in signing config and workflow secret handling, 7 important issues covering security anti-patterns and missing validation, and 6 documentation inaccuracies. The pipeline architecture is solid but needs defensive hardening before secrets-dependent workflows run in production.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P8-001: Signing config unconditionally assigned to release builds
+
+- **File:** src-tauri/gen/android/app/build.gradle.kts:53
+- **Severity:** Critical
+- **Detail:** `signingConfig = signingConfigs.getByName("release")` is assigned unconditionally, but the signing config only populates fields when `ANDROID_KEYSTORE_PATH` is set. When env vars are absent (local dev), the signing config has null fields, causing cryptic Gradle errors on release builds. Additionally, if `ANDROID_KEYSTORE_PATH` is set but the other 3 env vars are missing, you get opaque "keystore password was incorrect" errors. Fix: guard the `signingConfig =` assignment inside an `if (keystorePath != null)` block, and validate all 4 env vars together.
+- **Relates to:** S003 acceptance criteria ("Local debug builds still work without env vars set"), TA9
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Guarded `signingConfig` assignment with `if (keystorePath != null)` check in release build type
+
+#### [FIX] P8-002: jq silently returns "null" string for missing Supabase status keys
+
+- **File:** .github/workflows/ci.yml:76-78
+- **Severity:** Critical
+- **Detail:** If `bunx supabase status --output json` output lacks `API_URL` or `ANON_KEY`, `jq -r` returns the literal string "null" with exit code 0. `VITE_SUPABASE_URL=null` gets set, the frontend build succeeds, and E2E tests fail with cryptic network errors. Also calls `supabase status` twice redundantly. Fix: capture status once, validate extracted values are non-empty and not "null".
+- **Relates to:** TA2
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Captured status once into variable, added non-empty and non-"null" validation with `::error::` annotations
+
+#### [FIX] P8-003: Empty KEYSTORE_BASE64 secret silently produces corrupt keystore file
+
+- **File:** .github/workflows/release.yml:62
+- **Severity:** Critical
+- **Detail:** `echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > $RUNNER_TEMP/upload-key.jks` succeeds with exit 0 when secret is empty, producing a near-empty file. Gradle then burns 10-15 min building before failing with "Invalid keystore format." The secret is also inlined in shell rather than passed via `env:`. Fix: pass through env block, validate non-empty before decode, validate decoded file size.
+- **Relates to:** TA9
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Passed KEYSTORE_BASE64 via env block, added empty-check before decode and file size validation (min 100 bytes)
+
+#### [FIX] P8-004: Secrets interpolated directly in shell commands
+
+- **File:** .github/workflows/release.yml:62,106
+- **Severity:** High
+- **Detail:** `${{ secrets.KEYSTORE_BASE64 }}` and `${{ secrets.SUPABASE_PROJECT_REF }}` are expanded inline in `run:` commands. While GitHub masks known secrets, passing via shell args (vs `env:` blocks) risks exposure if commands print error messages with arguments, or if shell tracing is enabled. Fix: pass all secrets through `env:` blocks consistently.
+- **Relates to:** TA9
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Both secrets now passed through `env:` blocks and referenced as shell variables
+
+#### [FIX] P8-005: GitHub Release created before Play Store upload (partial release risk)
+
+- **File:** .github/workflows/release.yml:125-140
+- **Severity:** High
+- **Detail:** If Play Store upload fails, a GitHub Release already exists with an AAB attached but never published to Play Store. This creates a partial release state. Fix: reorder to upload to Play Store first, then create GitHub Release.
+- **Relates to:** TA5, TA6
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Reordered publish job steps: Play Store upload now runs before GitHub Release creation
+
+#### [FIX] P8-006: test:e2e script uses bare playwright instead of bunx
+
+- **File:** package.json (scripts.test:e2e)
+- **Severity:** High
+- **Detail:** `"test:e2e": "playwright test"` violates project convention "Never use npx/npm/yarn; always bun and bunx." Should be `"test:e2e": "bunx playwright test"`.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed to `"test:e2e": "bunx playwright test"`
+
+#### [FIX] P8-007: Missing if-no-files-found on Playwright report upload
+
+- **File:** .github/workflows/ci.yml:89-95
+- **Severity:** Medium
+- **Detail:** If Playwright crashes before generating the report directory, the upload step fails, masking the actual test failure in the job summary. Add `if-no-files-found: ignore`.
+- **Relates to:** TA2
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `if-no-files-found: ignore` to upload-artifact step
+
+#### [FIX] P8-008: Missing newline at end of build.gradle.kts
+
+- **File:** src-tauri/gen/android/app/build.gradle.kts (EOF)
+- **Severity:** Low
+- **Detail:** POSIX convention violation. Causes noisy diffs when next edit appends content.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added trailing newline
+
+#### [FIX] P8-009: Tech.md says "Four workflows" but only two exist
+
+- **File:** Context/Features/011-ci-cd-pipeline/Tech.md:12
+- **Severity:** Medium
+- **Detail:** Architecture Overview opens with "Four GitHub Actions workflows" but only ci.yml and release.yml exist. The table below correctly says two files. Change "Four" to "Two".
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed "Four" to "Two" in Architecture Overview
+
+#### [FIX] P8-010: Tech.md documents armv7-linux-androideabi target not used in workflows
+
+- **File:** Context/Features/011-ci-cd-pipeline/Tech.md (Tool Installation Order table, Step 4)
+- **Severity:** Medium
+- **Detail:** Table lists `armv7-linux-androideabi` as a Rust target but neither workflow installs it. Both use only `aarch64-linux-android` via the action's `targets` input. Remove the extra target and fix the method description.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Removed `armv7-linux-androideabi`, updated method to reference `dtolnay/rust-toolchain` `targets` input
+
+#### [FIX] P8-011: Tech.md E2E base URL and server description wrong
+
+- **File:** Context/Features/011-ci-cd-pipeline/Tech.md (E2E Testing Setup section)
+- **Severity:** Medium
+- **Detail:** States Playwright targets `localhost:5173` (dev server) but actual config uses `localhost:4173` (preview). Also says "Vite dev server is started in preview mode" which is contradictory. Fix both to reference port 4173 and "Vite preview server."
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated to reference port 4173 and "Vite preview server"
+
+#### [FIX] P8-012: Steps.md S004 env var names missing VITE\_ prefix
+
+- **File:** Context/Features/011-ci-cd-pipeline/Steps.md (S004)
+- **Severity:** Medium
+- **Detail:** S004 says `SUPABASE_URL` and `SUPABASE_ANON_KEY` but actual workflow uses `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` (prefix required for Vite).
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated env var names to include `VITE_` prefix
+
+#### [FIX] P8-013: Tech.md signing snippet differs from actual implementation
+
+- **File:** Context/Features/011-ci-cd-pipeline/Tech.md (Signing Architecture section)
+- **Severity:** Low
+- **Detail:** Code snippet shows `?: "/dev/null"` fallback pattern but actual implementation uses null-guard `if` pattern. Update snippet to match.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated code snippet to use `if (keystorePath != null)` guard pattern matching actual implementation
+
+#### [FIX] P8-014: ADR-007 status should be "Accepted"
+
+- **File:** Context/Decisions/ADR-007-github-actions-cicd.md:3
+- **Severity:** Low
+- **Detail:** Status says "Proposed" but implementation is complete. Change to "Accepted" at merge time.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed status from "Proposed" to "Accepted"
+
+### Missing Tasks
+
+#### [TASK] P8-015: Add secret validation steps to release workflow jobs
+
+- **File:** .github/workflows/release.yml (all jobs)
+- **Severity:** High
+- **Detail:** 7 secrets across the release workflow have no upfront validation. Empty secrets silently expand to empty strings, causing failures 10-15 minutes into builds with opaque error messages. Add a "validate secrets" step at the beginning of each job that uses secrets (build-release, migrate, publish). This was not anticipated in Steps.md.
+- **Relates to:** TA9
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added as S010 in Steps.md, then implemented: validation steps added to build-release, migrate, and publish jobs
+
+#### [TASK] P8-016: Add Playwright webServer timeout and output capture
+
+- **File:** playwright.config.ts:21-24
+- **Severity:** High
+- **Detail:** No `timeout` configured (default 60s). If preview server fails to start, error output is swallowed and tests time out with a generic message. Add `timeout: 30_000` and `stdout: 'pipe'` / `stderr: 'pipe'`. Not covered in S002 acceptance criteria.
+- **Relates to:** TA2
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Applied directly to playwright.config.ts -- added `timeout: 30_000`, `stdout: 'pipe'`, `stderr: 'pipe'` to webServer config
+
+### Architectural Concerns
+
+_None identified._
+
+### Convention Gaps
+
+_None identified._
+
+## Resolution Summary
+
+**Resolved at:** 2026-04-04
+**Session:** Review resolution for PR #67 CI/CD pipeline
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+| --------- | ------ | ------ | ------ | ------ | ------ | -------- | --------- |
+| [FIX] | 14 | 14 | -- | -- | -- | -- | -- |
+| [TASK] | 2 | 2 | -- | -- | -- | -- | -- |
+| **Total** | **16** | **16** | **--** | **--** | **--** | **--** | **--** |
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved
+- [x] All [TASK] findings added to Steps.md or fixed directly
+- [x] All [ADR] findings have ADRs created or dismissed
+- [x] All [RULE] findings applied or dismissed
+- [x] Review verified by review-verify agent
diff --git a/Context/Reviews/0009-pr71-enhancement-batch-review.md b/Context/Reviews/0009-pr71-enhancement-batch-review.md
new file mode 100644
index 00000000..c20441cb
--- /dev/null
+++ b/Context/Reviews/0009-pr71-enhancement-batch-review.md
@@ -0,0 +1,140 @@
+# PR Review: worktree-chore+enhancements -> develop
+
+**Date:** 2026-04-04
+**Feature:** N/A (batch enhancement issues -- closes #31, #39, #40, #41, #46, #47, #48)
+**Branch:** worktree-chore+enhancements
+**PR:** #71
+**Reviewers:** code-reviewer, silent-failure-hunter, type-design-analyzer, comment-analyzer, pr-test-analyzer
+**Status:** :yellow_circle: Partially resolved
+
+## Summary
+
+12 findings across 5 parallel review agents. 3 critical (missing RLS, inert refinements, fragile datetime comparison), 4 important (composability loss, missing DELETE trigger, misleading null return, silent partial failure), 5 suggestions (missing tests, comment precision, RETURN NEW in AFTER trigger).
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P9-001: Missing RLS on `direct_conversation_pairs` table
+
+- **File:** supabase/migrations/20260404000004_optimize_direct_conversation_uniqueness.sql:13
+- **Severity:** Critical
+- **Detail:** Every other data table in the project has RLS enabled. Without it, any authenticated user can query/modify the lookup table via the Supabase client, bypassing uniqueness enforcement by deleting rows. Add `ALTER TABLE direct_conversation_pairs ENABLE ROW LEVEL SECURITY;` with no user-facing policies (trigger functions run as table owner).
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added `ALTER TABLE ... ENABLE ROW LEVEL SECURITY` with no user-facing policies after table creation
+
+#### [FIX] P9-002: ISO datetime string comparison is fragile
+
+- **File:** src/domain/types/conversation.ts:42
+- **Severity:** Critical
+- **Detail:** `p.leftAt > p.joinedAt` uses lexicographic string comparison on ISO 8601 strings. This breaks with mixed timezone offsets (e.g., `Z` vs `+05:30`). Fix: `new Date(p.leftAt) > new Date(p.joinedAt)`.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed to `new Date(p.leftAt) > new Date(p.joinedAt)` for proper temporal comparison
+
+#### [FIX] P9-003: `findDirectConversation` all-fail returns misleading `null`
+
+- **File:** src/lib/tauri-adapter.ts:2132-2151
+- **Severity:** High
+- **Detail:** If every `invokeCommand` call fails (e.g., Rust backend down), the function returns `null` -- indistinguishable from "no conversation exists." The UI could offer creating a duplicate conversation. Fix: track failure count; throw if all direct conversation fetches fail.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Added failure counter; throws if all direct conversation fetches fail
+
+#### [FIX] P9-004: `getUnreadCounts` per-failure log lacks conversation ID
+
+- **File:** src/lib/supabase-adapter.ts:2098-2123
+- **Severity:** Medium
+- **Detail:** On rejection, `result.reason` from Supabase may not contain the conversation_id. Enrich the thrown error: `if (error) throw Object.assign(error, { conversationId: p.conversation_id })`. Also consider total-failure detection (if all queries fail, log at error level or throw so TanStack Query can retry).
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Enriched thrown error with `conversationId`; updated warn log to include conversation ID
+
+#### [FIX] P9-005: Comment says "N+1" but queries are parallelized, not eliminated
+
+- **File:** src/lib/supabase-adapter.ts:2096
+- **Severity:** Low
+- **Detail:** `Promise.allSettled` parallelizes N queries but does not eliminate them. Revise to: "Fire all COUNT queries concurrently to minimize wall-clock latency (N queries execute in parallel instead of sequentially)."
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Revised comment to describe parallel execution accurately
+
+#### [FIX] P9-006: Migration header says "single INSERT" but code uses UPSERT
+
+- **File:** supabase/migrations/20260404000004_optimize_direct_conversation_uniqueness.sql:4-6
+- **Severity:** Low
+- **Detail:** The header says "The trigger now does a single INSERT" but the actual code uses `ON CONFLICT (conversation_id) DO UPDATE SET pair_key = excluded.pair_key`. Revise to mention UPSERT and clarify the UNIQUE index on `pair_key` catches cross-conversation duplicates.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Revised header to describe UPSERT with cross-conversation duplicate detection
+
+#### [FIX] P9-007: `RETURN NEW` in AFTER trigger is unnecessary
+
+- **File:** supabase/migrations/20260404000004_optimize_direct_conversation_uniqueness.sql:85-107
+- **Severity:** Low
+- **Detail:** `cleanup_direct_conversation_pair()` is an AFTER UPDATE trigger but returns `NEW`. The return value is ignored in AFTER triggers. While harmless, it could mislead a future maintainer. Change to `RETURN NULL;` or add a comment explaining it is ignored.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Changed to `RETURN NULL` with comment explaining AFTER trigger convention
+
+### Missing Tasks
+
+#### [TASK] P9-008: Zod refinements are never executed (mapper bypass)
+
+- **File:** src/lib/data-mapper.ts (toConversation:701, toConversationParticipant:736, toMessage:775)
+- **Severity:** Critical
+- **Detail:** All mapper functions construct plain object literals and return them typed via structural compatibility. They never call `.parse()`, so all three `.refine()` checks and the `.nonnegative()` constraints are inert/dead code. Either call `schema.parse(result)` in the mappers, add `.safeParse()` at the adapter boundary, or extract assertion functions for write boundaries.
+- **Status:** :blue_square: Deferred
+- **Resolution:** Added to Context/Backlog/Bugs.md
+
+#### [TASK] P9-009: Missing DELETE trigger for participant hard-deletes
+
+- **File:** supabase/migrations/20260404000004_optimize_direct_conversation_uniqueness.sql:85-116
+- **Severity:** High
+- **Detail:** Cleanup trigger only fires on UPDATE (left_at NULL to non-NULL). If a participant row is DELETEd, the pair entry becomes orphaned in `direct_conversation_pairs`, blocking future direct conversations between those users. The `ON DELETE CASCADE` only applies from `conversations`, not from `conversation_participants`. Add an AFTER DELETE trigger on `conversation_participants`, or document hard-deletes never happen.
+- **Status:** :blue_square: Deferred
+- **Resolution:** Added to Context/Backlog/Bugs.md
+
+#### [TASK] P9-010: No tests for new Zod refinements
+
+- **File:** src/domain/types/**tests**/ (conversation.test.ts, message.test.ts, media.test.ts -- all missing)
+- **Severity:** High
+- **Detail:** The project has domain type tests for exercise, program, session, etc. but none for conversation, message, or media. The new refinements encode business rules that, if broken, allow invalid data through the parsing layer silently. Tests should cover: direct conv + groupId (reject), group conv + groupId (pass), leftAt > joinedAt (pass/reject/equal boundary), system msg + senderId (reject), negative duration/fileSize (reject), zero values (pass).
+- **Status:** :blue_square: Deferred
+- **Resolution:** Added to Context/Backlog/Ideas.md
+
+#### [TASK] P9-011: No test for `getUnreadCounts` batching behavior
+
+- **File:** src/lib/**tests**/supabase-adapter.test.ts
+- **Severity:** Medium
+- **Detail:** No existing test for `getUnreadCounts`. The behavioral change from sequential to `Promise.allSettled` changes error semantics. If someone changes `allSettled` back to `all`, all counts would be lost on a single failure. Test cases: all succeed, one fails (partial), empty participations, null last_read_at.
+- **Status:** :blue_square: Deferred
+- **Resolution:** Added to Context/Backlog/Ideas.md
+
+### Architectural Concerns
+
+#### [ADR] P9-012: ZodEffects breaks schema composability
+
+- **File:** src/domain/types/conversation.ts, src/domain/types/message.ts
+- **Severity:** Medium
+- **Detail:** Adding `.refine()` changes schema type from `ZodObject` to `ZodEffects`, losing `.pick()`, `.omit()`, `.extend()`, `.merge()`. Anyone deriving a sub-schema (e.g., for a create form) will hit a runtime error. Recommended mitigation: export both the raw object schema (for composability) and the refined schema (for validation). E.g., `export const conversationObjectSchema = syncableEntitySchema.extend({...})` and `export const conversationSchema = conversationObjectSchema.refine(...)`.
+- **Status:** :white_check_mark: ADR created
+- **Resolution:** ADR-008 (Context/Decisions/ADR-008-dual-export-zod-schemas.md)
+
+### Convention Gaps
+
+_None identified._
+
+## Resolution Summary
+
+**Resolved at:** 2026-04-04
+**Session:** PR #71 enhancement batch review resolution
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+| --------- | ------ | ----- | ----- | ----- | ----- | -------- | --------- |
+| [FIX] | 7 | 7 | -- | -- | -- | -- | -- |
+| [TASK] | 4 | -- | -- | -- | -- | 4 | -- |
+| [ADR] | 1 | -- | -- | 1 | -- | -- | -- |
+| **Total** | **12** | **7** | **0** | **1** | **0** | **4** | **0** |
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved (7 items)
+- [x] All [TASK] findings added to backlog (4 items deferred)
+- [x] All [ADR] findings have ADRs created (1 item -- ADR-008)
+- [x] All [RULE] findings applied or dismissed (0 items)
+- [x] Review verified by review-verify agent
diff --git a/Context/Reviews/0009-pr72-tauri-adapter-tests-review.md b/Context/Reviews/0009-pr72-tauri-adapter-tests-review.md
new file mode 100644
index 00000000..ad3780fa
--- /dev/null
+++ b/Context/Reviews/0009-pr72-tauri-adapter-tests-review.md
@@ -0,0 +1,138 @@
+# PR Review: worktree-fix+testing -> develop
+
+**Date:** 2026-04-04
+**Feature:** Context/Features/012-TauriAdapter-Tests/
+**Branch:** worktree-fix+testing
+**PR:** #72
+**Reviewers:** code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer
+**Status:** :yellow_circle: Partially resolved
+
+## Summary
+
+4 agents reviewed 11 changed files (+5,248/-171). No critical issues were introduced by this PR. 11 findings total: 3 FIX (pre-existing issues surfaced by review), 5 TASK (test coverage gaps and doc updates), 1 ADR (type sync concern), 2 RULE (no new rules needed -- convention gaps are minor). The PR is merge-ready; findings are improvements, not blockers.
+
+## Findings
+
+### Fix-Now
+
+#### [FIX] P9-001: Spec.md and Steps.md still marked Status: Draft
+
+- **File:** Context/Features/012-TauriAdapter-Tests/Spec.md:3, Steps.md:3
+- **Severity:** Low
+- **Detail:** Both documents are marked `Status: Draft` but the feature is fully implemented and in PR. Status should be `Done`.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated Status to Done in Spec.md, Steps.md, and Tech.md
+
+#### [FIX] P9-002: Tech.md KD5 reads as open question but is resolved
+
+- **File:** Context/Features/012-TauriAdapter-Tests/Tech.md (KD5, ~line 105)
+- **Severity:** Low
+- **Detail:** KD5 states "We need to verify whether they are exported" but the implementation has resolved this -- `getMonday` and `formatWeekLabel` are NOT exported and ARE tested indirectly through `getWeeklyVolume`. Update to be definitive.
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Updated KD5 heading and text to be definitive: "Are Not Exported"
+
+#### [FIX] P9-003: Tech.md KD3 overstates invokeCommand coverage
+
+- **File:** Context/Features/012-TauriAdapter-Tests/Tech.md (KD3, ~line 55)
+- **Severity:** Low
+- **Detail:** KD3 claims `invokeCommand` is exercised through "every adapter method" but not-implemented stubs throw errors without going through `invokeCommand`. Should clarify: "every adapter method that delegates to Rust."
+- **Status:** :white_check_mark: Fixed
+- **Resolution:** Clarified KD3 to say "every adapter method that delegates to Rust"
+
+### Missing Tasks
+
+#### [TASK] P9-004: Add malformed JSON handling tests for parseJson
+
+- **File:** src/lib/**tests**/tauri-adapter.test.ts
+- **Severity:** Medium
+- **Detail:** No tests verify what happens when Rust returns malformed JSON in fields like `aliases`, `muscle_groups`, `equipment_required`. A test with `aliases: "not-valid-json{"` would catch regressions in the conversion layer and prevent silent `undefined` propagation.
+- **Relates to:** TauriAdapter conversion layer (tauri-adapter.ts:530)
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S010 in Steps.md
+
+#### [TASK] P9-005: Add DATABASE and INTERNAL error kind tests
+
+- **File:** src/lib/**tests**/tauri-adapter.test.ts (error handling section, ~lines 3510-3567)
+- **Severity:** Medium
+- **Detail:** Error handling tests only cover NOT_FOUND, VALIDATION, non-TauriAppError, and string errors. Missing DATABASE (most common production IPC failure) and INTERNAL error kinds. These are the error shapes most likely to surface in production.
+- **Relates to:** tauri-adapter.ts invokeCommand error wrapping
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S011 in Steps.md
+
+#### [TASK] P9-006: Add Supabase getUnreadCounts happy path test
+
+- **File:** src/lib/**tests**/supabase-adapter.test.ts
+- **Severity:** Low
+- **Detail:** `getUnreadCounts` only tests the empty/error case. The happy path where participations exist with actual unread messages is not tested on the Supabase adapter side (though the Rust side covers it well).
+- **Relates to:** Supabase adapter chat methods
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S012 in Steps.md
+
+#### [TASK] P9-007: Add isoToUnixSeconds edge case tests
+
+- **File:** src/lib/**tests**/tauri-adapter.test.ts
+- **Severity:** Low
+- **Detail:** Timestamp conversion `isoToUnixSeconds` is only tested indirectly via `createWorkoutLog`. No tests for invalid ISO strings, empty strings, or null timestamps. Silent `NaN` propagation could cause data corruption.
+- **Relates to:** tauri-adapter.ts timestamp conversion helpers
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S013 in Steps.md
+
+#### [TASK] P9-008: Restore .bind() position comments in chat.rs INSERT OR REPLACE
+
+- **File:** src-tauri/src/commands/chat.rs (~line 640 area)
+- **Severity:** Low
+- **Detail:** The refactor removed bind-parameter comments that explained which SQL parameter each `.bind()` corresponded to in a complex INSERT OR REPLACE query. Without them, developers must mentally count positions against the SQL string. Consider restoring position-numbered comments.
+- **Status:** :white_check_mark: Task created
+- **Resolution:** Added as S014 in Steps.md
+
+### Architectural Concerns
+
+#### [ADR] P9-009: TypeScript TauriAppError kind union out of sync with Rust ErrorKind
+
+- **File:** src/lib/tauri-adapter.ts:476-480, src-tauri/src/error.rs
+- **Severity:** Medium
+- **Detail:** Rust `ErrorKind` enum includes `Unauthorized`, `Sync`, and `Network` variants. TypeScript `TauriAppError['kind']` only lists `NOT_FOUND | CONFLICT | VALIDATION | DATABASE | INTERNAL`. If Rust returns UNAUTHORIZED or SYNC, TypeScript code doing exhaustive switch/case will miss them. This is pre-existing but relevant since this PR adds tests exercising the error wrapping. Warrants a decision on whether to sync the types or handle unknown kinds with a fallback.
+- **Relates to:** Error handling architecture across Rust/TS boundary
+- **Status:** :white_check_mark: ADR created
+- **Resolution:** ADR-008-tauri-error-kind-sync.md
+
+### Convention Gaps
+
+#### [RULE] P9-010: invokeCommand should log before re-throwing (pre-existing)
+
+- **Files:** src/lib/tauri-adapter.ts:504-512
+- **Severity:** High
+- **Detail:** The `invokeCommand` catch block wraps errors in `AdapterError` and re-throws but never logs. Per `error-handling.md`, catch blocks must log with a bracketed module prefix. No `[tauri-adapter]` log entry appears for IPC failures. This was surfaced because the new tests exercise error paths without verifying any logging occurs.
+- **Suggested rule:** Already covered by `.claude/rules/error-handling.md` -- this is a violation, not a gap. Track as backlog item.
+- **Status:** :yellow_circle: Deferred
+- **Resolution:** Added as B001 in Context/Backlog/Bugs.md
+
+#### [RULE] P9-011: intToBool silent null coercion (pre-existing)
+
+- **Files:** src/lib/tauri-adapter.ts:558-561
+- **Severity:** Medium
+- **Detail:** `intToBool` silently coerces `null` to `false` (or fallback) without logging. If Rust returns null for a non-nullable field like `completed`, all sets appear incomplete with zero diagnostic info. This could be a convention addition: "safety-net coercions at adapter boundaries should log when the fallback triggers."
+- **Suggested rule:** Consider adding to `.claude/rules/error-handling.md`: "Adapter boundary fallbacks (null coercion, default values) should log at warn level when the safety net triggers for fields expected to be non-nullable."
+- **Status:** :white_check_mark: Rule updated
+- **Resolution:** Added "Adapter Boundary Fallbacks" section to .claude/rules/error-handling.md
+
+## Resolution Summary
+
+**Resolved at:** 2026-04-04
+**Session:** PR #72 review resolution
+
+| Category | Total | Fixed | Tasks | ADRs | Rules | Deferred | Discarded |
+| --------- | ------ | ----- | ----- | ----- | ----- | -------- | --------- |
+| [FIX] | 3 | 3 | -- | -- | -- | -- | -- |
+| [TASK] | 5 | -- | 5 | -- | -- | -- | -- |
+| [ADR] | 1 | -- | -- | 1 | -- | -- | -- |
+| [RULE] | 2 | -- | -- | -- | 1 | 1 | -- |
+| **Total** | **11** | **3** | **5** | **1** | **1** | **1** | **0** |
+
+## Resolution Checklist
+
+- [x] All [FIX] findings resolved
+- [x] All [TASK] findings added to Steps.md or backlog
+- [x] All [ADR] findings have ADRs created or dismissed
+- [x] All [RULE] findings applied or dismissed
+- [ ] Review verified by review-verify agent
diff --git a/README.md b/README.md
index d19b37b8..c79e224e 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,8 @@
+
+
diff --git a/bun.lock b/bun.lock
index 471dc2da..734d68c7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -17,27 +17,33 @@
"@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.10.1",
+ "@tauri-apps/plugin-barcode-scanner": "^2.4.4",
"@tauri-apps/plugin-deep-link": "^2.4.7",
+ "@tauri-apps/plugin-opener": "^2.5.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hls.js": "^1.6.15",
"lucide-react": "^1.7.0",
"material-symbols": "^0.43.0",
+ "qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"recharts": "^3.8.1",
"shadcn": "^4.1.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tus-js-client": "^4.3.1",
"tw-animate-css": "^1.4.0",
+ "vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12",
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+ "@playwright/test": "^1.59.1",
"@tanstack/router-plugin": "^1.167.8",
"@tauri-apps/cli": "^2.10.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -287,6 +293,8 @@
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
+ "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
+
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -549,8 +557,12 @@
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
+ "@tauri-apps/plugin-barcode-scanner": ["@tauri-apps/plugin-barcode-scanner@2.4.4", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-uXvyMI8UgQjSrGxzTU5isNoQarMGRxFmTmb4TsgiWZHf/g7LsIyAQCwoFShjax0fXCK5mdVKDOvlkfOr21fo6g=="],
+
"@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.7", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-K0FQlLM6BoV7Ws2xfkh+Tnwi5VZVdkI4Vw/3AGLSf0Xvu2y86AMBzd9w/SpzKhw9ai2B6ES8di/OoGDCExkOzg=="],
+ "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="],
+
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
@@ -1257,6 +1269,10 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+ "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
+
+ "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
+
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
@@ -1283,6 +1299,8 @@
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+ "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
+
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"querystringify": ["querystringify@2.2.0", "", {}, "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="],
@@ -1389,6 +1407,8 @@
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
+ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
+
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -1507,6 +1527,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+ "vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
+
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.0.3", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.12", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ=="],
@@ -1633,6 +1655,8 @@
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
diff --git a/docker-compose.yml b/docker-compose.yml
index b7ddd7ed..3770eed2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -275,6 +275,7 @@ services:
condition: service_healthy
environment:
SITE_URL: ${SITE_URL}
+ ANON_KEY: ${ANON_KEY}
healthcheck:
test:
[
diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile
index 0a36fd21..3e0595e5 100644
--- a/docker/caddy/Caddyfile
+++ b/docker/caddy/Caddyfile
@@ -18,6 +18,13 @@
reverse_proxy studio:3000
}
+ # Instance discovery endpoint
+ handle /.well-known/ardent-forge.json {
+ header Content-Type application/json
+ header Cache-Control "public, max-age=3600"
+ respond `{"version":"1","supabase_url":"{$SITE_URL}","supabase_publishable_key":"{$ANON_KEY}"}`
+ }
+
# Web app -- catch-all for the SPA
handle {
reverse_proxy web:80
diff --git a/docs/03-prd-hosting.md b/docs/03-prd-hosting.md
index 3545d2c6..efec6017 100644
--- a/docs/03-prd-hosting.md
+++ b/docs/03-prd-hosting.md
@@ -14,7 +14,8 @@ This document defines requirements for Ardent Forge's runtime backend configurat
|------|------------------|
| Runtime backend configuration | Users can point the app at any Supabase instance from Settings |
| Smart defaults | The maintainer's Supabase instance is bundled as the default; friends install and it just works |
-| Graceful fallback | If bundled defaults fail on first launch, the app surfaces a configuration screen |
+| Graceful fallback | If bundled defaults fail on first launch, the app surfaces a tiered setup screen: server address (primary), QR scan (parallel), advanced manual entry (expandable) |
+| QR code onboarding | Any authenticated user can generate a QR code or shareable link that pre-configures another device to connect to the same instance |
| Docker turnkey deployment | A single `docker compose up` provisions the full stack for self-hosters |
### Secondary Goals (P1)
@@ -24,6 +25,7 @@ This document defines requirements for Ardent Forge's runtime backend configurat
| Schema detection | App detects a misconfigured or empty database and directs the user to documentation |
| Connection validation | Config changes are validated before being saved |
| Self-hosting documentation | README section and setup guide sufficient for a competent developer to deploy in under an hour |
+| Instance discovery endpoint | Self-hosted deployments serve `/.well-known/ardent-forge.json` so users can connect by entering a single URL instead of two Supabase fields |
### Non-Goals (Explicitly Out of Scope)
@@ -34,6 +36,7 @@ This document defines requirements for Ardent Forge's runtime backend configurat
| Admin dashboard for instance operators | Out of scope — Supabase Dashboard serves this purpose |
| White-labeling or theming per instance | One design system, one app |
| Automatic Supabase project provisioning | Self-hosters bring their own Supabase |
+| Email invites (Resend, SMTP-based) | Requires server-side component and API key management; disproportionate complexity for ~10 user deployments. P2 future enhancement. |
---
@@ -87,8 +90,131 @@ flowchart TB
These are the only two fields needed. Everything else — auth, RLS, schema — is determined by the Supabase instance the app connects to.
+### Instance Discovery
+
+Self-hosted deployments expose a discovery endpoint that allows the app to resolve a single server URL into the two Supabase configuration fields. This removes the need for end users to understand or handle Supabase-specific values.
+
+#### Discovery Endpoint
+
+| Attribute | Value |
+|-----------|-------|
+| Path | `/.well-known/ardent-forge.json` |
+| Method | GET |
+| Auth | None (publicly accessible) |
+| Content-Type | `application/json` |
+
+#### Response Schema
+
+```json
+{
+ "version": 1,
+ "supabase_url": "https://xyzcompany.supabase.co",
+ "supabase_publishable_key": "sb_publishable_abc123..."
+}
+```
+
+The `version` field is an integer that allows the schema to evolve without breaking older clients. Version 1 contains exactly the two fields the app already requires. Future versions may add optional metadata (instance name, branding, feature flags) but must remain backward-compatible: a v1 client ignores unknown fields and extracts the two it needs.
+
+#### Who Serves It
+
+| Deployment Mode | How the Endpoint Is Served |
+|-----------------|---------------------------|
+| Docker (Caddy) | Caddy responds directly with the JSON payload. Values are interpolated from the same `.env` file that configures the rest of the stack. No additional container required. |
+| Supabase Cloud + static host | The self-hoster adds a static `/.well-known/ardent-forge.json` file to their web app deployment (Vercel, Netlify, etc.) containing their Supabase project URL and publishable key. |
+| Supabase Cloud, no web app | Not available. Users connecting via APK-only must use QR code, shareable link, or manual entry. |
+
+#### Discovery Flow
+
+```mermaid
+sequenceDiagram
+ participant UI as Setup Screen
+ participant App as Discovery Client
+ participant Server as Instance Server
+
+ UI->>App: User enters "https://forge.example.com"
+ App->>Server: GET https://forge.example.com/.well-known/ardent-forge.json
+
+ alt Discovery succeeds
+ Server-->>App: 200 OK + JSON payload
+ App->>App: Extract supabase_url + supabase_publishable_key
+ App->>App: Run existing connection validator (19c)
+
+ alt Connection valid + schema present
+ App-->>UI: Success — proceed to auth
+ else Connection valid + no schema
+ App-->>UI: "Connected, but database schema not found."
+ else Connection fails
+ App-->>UI: "Server found, but database is unreachable."
+ end
+ else Discovery fails (404, network error)
+ App-->>UI: "Could not find server configuration at this address."
+ UI->>UI: Suggest QR scan or manual entry
+ end
+```
+
+---
+
+### QR Code and Deep Link Onboarding
+
+Any authenticated user can generate a QR code or shareable deep link that allows another device to connect to the same Ardent Forge instance. This is the primary onboarding path for the friends-and-family use case.
+
+#### Deep Link Format
+
+The `ardentforge://` custom URL scheme is already registered as part of Supabase auth redirect handling. The onboarding flow adds a new path under the existing scheme:
+
+```
+ardentforge://connect?url={encoded_supabase_url}&key={encoded_publishable_key}
+```
+
+| Parameter | Encoding | Example |
+|-----------|----------|---------|
+| `url` | URI-encoded Supabase URL | `https%3A%2F%2Fxyzcompany.supabase.co` |
+| `key` | URI-encoded publishable key | `sb_publishable_abc123...` |
+
+The publishable key is not secret — it is designed to be embedded in client-side code and is safe to encode in QR codes and shareable links.
+
+#### Deep Link Handling
+
+When the app receives an `ardentforge://connect` deep link, behavior depends on the app's current configuration state.
+
+```mermaid
+flowchart TB
+ Receive["Receive ardentforge://connect"] --> Parse["Parse url + key params"]
+ Parse --> Valid{"Params present and well-formed?"}
+
+ Valid -->|No| Ignore["Ignore link, show error toast"]
+ Valid -->|Yes| HasConfig{"App already configured?"}
+
+ HasConfig -->|No| AutoSetup["Pre-populate setup screen with parsed values, auto-validate"]
+ HasConfig -->|Yes| SameInstance{"Same instance as current?"}
+
+ SameInstance -->|Yes| NoOp["Toast: 'Already connected to this server'"]
+ SameInstance -->|No| ChangeFlow["Trigger backend change flow (with data wipe confirmation for Tauri, per CF-3)"]
+```
+
+#### QR Code Generation
+
+Accessible from Settings → Backend → "SHARE INSTANCE" button. Available to any authenticated user — no role or admin gate.
+
+The QR code encodes the `ardentforge://connect` deep link using the connection details from the current device's config store. The same screen also shows a "COPY LINK" button for sharing via text or group chat.
+
+| Element | Design |
+|---------|--------|
+| Section heading | "SHARE THIS SERVER" in `text-industrial` |
+| QR code | Rendered client-side from config store values. Centered on `surface-iron` card. |
+| Copy link button | Secondary button below QR: "COPY INVITE LINK" |
+| Explainer text | `body-small` Inter: "Anyone with this link can connect to this server. They will still need to create an account or sign in." |
+
+#### QR Code Library
+
+The QR code is rendered client-side using a lightweight library (e.g., `qrcode.react` or `react-qr-code`). No server-side generation, no API call, no external service. The payload is constructed from values already in the local config store.
+
+---
+
### Configuration Lifecycle
+On first launch, the app resolves its backend configuration through a tiered process. The tiers are ordered by user-friendliness: the app tries the easiest path first and only falls through to more technical options if earlier tiers fail.
+
```mermaid
flowchart TB
Launch["App Launch"]
@@ -99,7 +225,7 @@ flowchart TB
CheckConfig -->|No| CheckEnv{"Build-time env vars available?"}
CheckEnv -->|Yes| TryDefaults["Attempt connection with bundled defaults"]
- CheckEnv -->|No| ShowSetup["Show backend setup screen"]
+ CheckEnv -->|No| ShowSetup["Show setup screen"]
TryDefaults --> DefaultsWork{"Connection successful?"}
@@ -108,14 +234,22 @@ flowchart TB
PersistDefaults --> AuthFlow["Proceed to auth"]
UseStored --> AuthFlow
- ShowSetup --> UserEnters["User enters URL + key"]
- UserEnters --> Validate["Validate connection"]
+
+ ShowSetup --> Tiered["Tiered setup screen"]
+
+ Tiered --> T1["Tier 1: Server address (discovery endpoint)"]
+ Tiered --> T2["Tier 2: Scan QR code (deep link)"]
+ Tiered --> T3["Tier 3: Advanced (raw Supabase fields)"]
+
+ T1 --> Validate["Validate connection"]
+ T2 --> Validate
+ T3 --> Validate
Validate --> Valid{"Valid?"}
Valid -->|Yes| PersistCustom["Persist to local store"]
Valid -->|No| ShowError["Show error + retry"]
- ShowError --> UserEnters
+ ShowError --> Tiered
PersistCustom --> AuthFlow
```
@@ -278,7 +412,14 @@ Prerequisites: a Supabase account, a static hosting provider (Vercel, Netlify, C
### Mobile App Configuration
-Both paths end with instructions for configuring the Play Store app: open Settings → Backend → enter URL and publishable key. A QR code or deep link containing the configuration is a future P2 enhancement, not in initial scope.
+Both deployment paths end with users connecting the Play Store app to their instance. Three onboarding methods are available, in order of preference:
+
+| Method | User Experience | When to Use |
+|--------|----------------|-------------|
+| QR code scan | Instance operator shows QR from their app; new user scans it | In-person onboarding, group meetups |
+| Shareable link | Operator sends `ardentforge://connect?...` via text or group chat; recipient taps it | Remote onboarding, group chats |
+| Server address | User enters the instance URL (e.g., `https://forge.example.com`); app resolves config via discovery endpoint | User knows the server address but has no QR or link |
+| Manual entry | User opens Advanced section and enters raw Supabase URL + publishable key | Supabase Cloud self-hosters without a web deployment, debugging |
---
@@ -292,3 +433,8 @@ Both paths end with instructions for configuring the Play Store app: open Settin
| H-4 | How does Docker handle migrations? | Init container with `service_role` key | Runs once on first `compose up`, idempotent on restarts |
| H-5 | First-launch behavior for unconfigured users? | Try bundled defaults → show setup screen on failure | Maintainer's friends see zero config; self-hosters get a clear prompt |
| H-6 | Does the web app container need Node.js? | No — multi-stage build, nginx serves static files | Smaller image, better performance, no runtime dependencies |
+| H-7 | How do users connect to an instance? | Tiered: server URL with discovery (primary), QR/deep link (parallel), raw Supabase fields (advanced) | Non-technical users should never see infrastructure terminology; power users retain full control |
+| H-8 | Where does discovery metadata live? | `/.well-known/ardent-forge.json` served by Caddy (Docker) or static hosting (Cloud) | Standard well-known URI convention; no additional containers; values derived from existing env vars |
+| H-9 | Custom URL scheme or HTTPS App Links? | Custom scheme (`ardentforge://connect`) | Scheme is already registered for Supabase auth redirects; avoids dependency on a central domain for all instances; web fallback not needed since QR flow assumes app is installed |
+| H-10 | Who can generate QR codes? | Any authenticated user | Publishable key is not secret; adding an admin role for this single feature would be disproportionate |
+| H-11 | Email invites? | Deferred to P2 | Requires server-side component (Edge Function or additional container), API key, email template; QR + shareable link covers the friends-and-family use case |
diff --git a/docs/10-user-flows.md b/docs/10-user-flows.md
index 1e627816..39c3494c 100644
--- a/docs/10-user-flows.md
+++ b/docs/10-user-flows.md
@@ -418,6 +418,76 @@ flowchart TB
---
+### Builder Progressive Disclosure
+
+Both the template builder and program builder use progressive disclosure to reduce visual complexity. Fields appear based on earlier selections rather than showing all options simultaneously.
+
+```mermaid
+flowchart TB
+ subgraph TemplateDisclosure["Template Builder — Field Visibility"]
+ Cat["Select Category (Strength / Conditioning / SE / Mixed)"]
+ Cat -->|Strength| StrFields["Show: Rest Between Groups Hide: Scoring, Time Cap (collapsed row, expandable)"]
+ Cat -->|Conditioning| CondFields["Show: Scoring, Time Cap, Rest Between Groups"]
+ Cat -->|SE| SEFields["Show: Time Cap, Rest Between Groups Hide: Scoring"]
+ Cat -->|Mixed| MixedFields["Show: All fields"]
+ end
+```
+
+```mermaid
+flowchart TB
+ subgraph GroupDisclosure["Activity Group — Staged Reveal"]
+ Add["+Add group"] --> TypeOnly["Stage 1: Group type selector only (with help icon)"]
+ TypeOnly --> Pick["User picks type"]
+ Pick --> Expand["Stage 2: Group expands Type-relevant rest fields + exercise selector"]
+ Expand --> ExAdd["User adds exercise"]
+ ExAdd --> Scheme["Set scheme editor shows category-filtered types only (Show All toggle available)"]
+ end
+```
+
+#### Template-Level Field Visibility
+
+| Field | Strength | Conditioning | SE | Mixed |
+| ------------------- | --------- | ------------ | --------- | ------- |
+| Scoring | Collapsed | Visible | Collapsed | Visible |
+| Time Cap | Collapsed | Visible | Visible | Visible |
+| Rest Between Groups | Visible | Visible | Visible | Visible |
+
+"Collapsed" fields are accessible via an expandable row but hidden by default to reduce noise.
+
+#### Set Scheme Filtering by Session Category
+
+| Session Category | Default Visible Types | Full List Via Toggle |
+| ---------------- | ------------------------------------------------------------------------------ | -------------------- |
+| Strength | FixedSets, PercentageSets, WorkToMax | All 12 |
+| Conditioning | CardioSteadyState, CardioInterval, RuckMarch, EMOM, AMRAPTimed, DescendingReps | All 12 |
+| SE | ForReps, TimedHold, PercentageOfMaxReps | All 12 |
+| Mixed | All 12 | — |
+
+---
+
+### Contextual Help System
+
+Complex builder screens include a `help_outline` icon (the HelpTrigger component) next to domain-specific labels. Tapping opens a popover (desktop) or bottom sheet (mobile) with a focused explanation.
+
+```mermaid
+flowchart LR
+ subgraph HelpPlacements["HelpTrigger Locations"]
+ H1["Activity group type selector"]
+ H2["Block type selector (Accumulation, Intensification...)"]
+ H3["Source selector (Custom, Template, Imported...)"]
+ end
+```
+
+| Screen | HelpTrigger Location | Content |
+| ---------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------- |
+| Template builder | Activity group type selector | One-line description of each group type (Straight, Superset, Circuit, Complex, EMOM, AMRAP, Couplet) |
+| Program builder | Block type pills | One-line description of each periodization phase, plus inline summary below pills on selection |
+| Program builder | Source badges | One-line description of each program source type |
+
+Help content uses `surface-gunmetal` background, zero border-radius, and industrial vocabulary throughout.
+
+---
+
## Flow 8: Update 1RM
```mermaid
diff --git a/docs/13-prd-remote-display.md b/docs/13-prd-remote-display.md
new file mode 100644
index 00000000..9388ec7f
--- /dev/null
+++ b/docs/13-prd-remote-display.md
@@ -0,0 +1,409 @@
+# PRD: Remote Display
+
+## Overview
+
+This document defines requirements for Ardent Forge's remote display feature. A remote display is a passive, browser-based screen — a gym TV, a wall-mounted monitor, a tablet on a shelf — that shows workout state pushed from a user's phone in real time. The architecture follows a push-based snapshot model: the phone is the control surface, the display is a dumb renderer, and Supabase Realtime Broadcast is the transport layer.
+
+The feature operates in two modes. When one or more users have active workout sessions, the display shows a board of active session cards with the ability to focus on a single user's workout full-screen. When no sessions are active, the display falls back to an idle mode showing a clock, today's scheduled sessions, and a countdown to the next upcoming session.
+
+The design is directly inspired by Nova Clock, a mission operations timeline display system. Nova Clock's core pattern — operator changes state, backend builds a snapshot, WebSocket broadcasts to passive display clients — maps cleanly to the fitness domain. The phone replaces the operator console, workout state replaces mission state, and the gym TV replaces the wall display.
+
+---
+
+## Goals
+
+### Primary Goals (P0)
+
+| Goal | Success Criteria |
+|------|------------------|
+| Real-time workout display | Active workout state visible on a remote screen within 500ms of a state change on the phone |
+| Passive rendering | Display requires no user interaction to show workout state; phone is the only control surface |
+| Board view | All active sessions for the instance visible simultaneously as a card grid |
+| Focus mode | A single user's workout can be displayed full-screen, triggered from the phone |
+| Idle mode | When no sessions are active, display shows clock, today's schedule, and next-session countdown |
+
+### Secondary Goals (P1)
+
+| Goal | Success Criteria |
+|------|------------------|
+| Display opt-out | Users can prevent their active sessions from appearing on the remote display |
+| Multiple displays | Different TVs can show different content (board vs focused on a specific user) |
+
+### Non-Goals (Explicitly Out of Scope)
+
+| Feature | Why Excluded |
+|---------|-------------|
+| TV-side input or interaction | Display is passive; input is a future consideration |
+| Display-side authentication | The display route uses the app's existing publishable key; no login required on the TV |
+| Video or camera feeds | This is workout state data, not a live stream |
+| Historical workout review on display | Display shows only active sessions and idle state; history lives in the main app |
+| Chromecast or AirPlay native casting | The display is a web page; casting is handled by the TV's browser or a generic cast of a browser tab |
+| Custom display layouts or profiles | Unlike Nova Clock, there is no profile editor or widget grid; the display layout is fixed |
+
+---
+
+## Concepts
+
+### Push-Based Snapshot Model
+
+The core pattern borrowed from Nova Clock. When workout state changes on the phone — a set is confirmed, a rest timer starts, an exercise transitions — the phone serializes the current workout state into a snapshot and publishes it to a Supabase Realtime Broadcast channel. The display subscribes to this channel and renders whatever it receives.
+
+```mermaid
+sequenceDiagram
+ participant Phone as Phone (Control)
+ participant Supa as Supabase Broadcast
+ participant TV as Display (Passive)
+
+ Phone->>Phone: User confirms set
+ Phone->>Phone: Build workout snapshot
+ Phone->>Supa: Publish to display channel
+ Supa-->>TV: Broadcast snapshot
+ TV->>TV: Parse snapshot
+ TV->>TV: Render updated state
+
+ Note over TV: Display updates < 500ms
+```
+
+Snapshots are preferred over event streams because they are self-contained and resilient. If the display reconnects after a network drop, a single snapshot brings it fully up to date. There is no event log to replay or reconcile.
+
+### Display Modes
+
+```mermaid
+stateDiagram-v2
+ [*] --> Idle: No active sessions
+
+ Idle --> Board: First session starts
+ Board --> Idle: Last session ends
+ Board --> Focused: Phone sends focus command
+ Focused --> Board: Phone sends unfocus command
+ Focused --> Idle: Focused session ends
+```
+
+**Idle mode** is the default when no workout sessions are active. It renders a full-screen clock with supplementary schedule information — a passive ambient display for the gym.
+
+**Board mode** activates automatically when one or more users begin a workout. Each active session gets a card on a responsive grid. Cards show the user's display name, current exercise, set progress, and rest timer state.
+
+**Focused mode** is triggered exclusively from the phone. When a user taps "Push to Display" in their active workout, the display transitions to a full-screen view of that user's workout. This replaces the board entirely. The phone can unfocus to return to the board. If the focused user's session ends, the display reverts to board mode (or idle, if no other sessions are active).
+
+### What Gets Pushed
+
+A workout snapshot contains the minimum state needed to render the workout on the display. It is not the full workout log — it is a projection of the active session's current visual state.
+
+| Field | Description |
+|-------|-------------|
+| `user_id` | Who is working out |
+| `display_name` | User's display name for the board card |
+| `session_name` | Name of the session or "Ad Hoc Workout" |
+| `workout_started_at` | Timestamp for elapsed time calculation |
+| `current_exercise` | Exercise name currently in progress |
+| `exercise_index` | Position in exercise list (e.g., 2 of 5) |
+| `total_exercises` | Total exercises in the session |
+| `sets` | Array of sets for the current exercise: set number, prescribed weight/reps, actual weight/reps, completion status |
+| `rest_timer` | Timer state: running/paused/idle, remaining seconds, total seconds |
+| `session_type` | Category label (STRENGTH, HIC, ENDURANCE, SE, etc.) |
+| `is_visible` | Whether the user has opted in to display visibility |
+
+The display never queries the database. All data arrives via broadcast snapshots.
+
+---
+
+## Functional Requirements
+
+### FR-1: Display Route
+
+| ID | Requirement | Priority |
+|----|-------------|----------|
+| FR-1.1 | App serves a `/display` route accessible from any browser | P0 |
+| FR-1.2 | Display route requires no authentication | P0 |
+| FR-1.3 | Display route connects to Supabase Broadcast on load | P0 |
+| FR-1.4 | Display route renders full-viewport with no scrolling, no navigation chrome | P0 |
+| FR-1.5 | Display route auto-reconnects on WebSocket disconnection | P0 |
+| FR-1.6 | Display layout assumes landscape orientation only | P0 |
+| FR-1.7 | Connection status indicator visible in display footer | P1 |
+
+### FR-2: Snapshot Publishing
+
+| ID | Requirement | Priority |
+|----|-------------|----------|
+| FR-2.1 | Phone publishes a workout snapshot on: set confirmed, exercise transitioned, rest timer started, rest timer expired, workout started, workout completed | P0 |
+| FR-2.2 | Phone publishes a "session ended" event when workout completes or is abandoned | P0 |
+| FR-2.3 | Snapshot includes all fields defined in the "What Gets Pushed" table | P0 |
+| FR-2.4 | Phone publishes a focus command to put the display in focused mode | P0 |
+| FR-2.5 | Phone publishes an unfocus command to return the display to board mode | P0 |
+| FR-2.6 | Snapshot publishing respects the user's display visibility opt-out | P0 |
+
+### FR-3: Board View
+
+| ID | Requirement | Priority |
+|----|-------------|----------|
+| FR-3.1 | Board displays a card for each active session with `is_visible = true` | P0 |
+| FR-3.2 | Cards show: display name, session name, current exercise, set progress, rest timer | P0 |
+| FR-3.3 | Cards appear when a session starts and disappear when it ends | P0 |
+| FR-3.4 | Board uses a responsive grid that adapts from 1 to 4+ simultaneous sessions | P0 |
+| FR-3.5 | If only one session is active, the card expands to use available space | P1 |
+| FR-3.6 | When more than 4 sessions are active, the board auto-cycles through pages of up to 4 cards on a 10-second interval | P1 |
+
+### FR-4: Focused View
+
+| ID | Requirement | Priority |
+|----|-------------|----------|
+| FR-4.1 | Focused view shows a single user's workout full-screen | P0 |
+| FR-4.2 | Focused view displays: session name, elapsed time, current exercise with full set table, rest timer with large countdown | P0 |
+| FR-4.3 | Focus is triggered by a phone command, not by display interaction | P0 |
+| FR-4.4 | Unfocus returns to board view (or idle if no sessions remain) | P0 |
+
+### FR-5: Idle Mode
+
+| ID | Requirement | Priority |
+|----|-------------|----------|
+| FR-5.1 | Idle mode displays a large clock (local timezone) | P0 |
+| FR-5.2 | Idle mode displays today's scheduled sessions for all users | P1 |
+| FR-5.3 | Idle mode displays a countdown to the next upcoming scheduled session | P1 |
+| FR-5.4 | Idle mode transitions automatically to board view when a session starts | P0 |
+
+### FR-6: Display Opt-Out
+
+| ID | Requirement | Priority |
+|----|-------------|----------|
+| FR-6.1 | Users can toggle display visibility in their profile settings | P0 |
+| FR-6.2 | When opted out, the phone does not publish snapshots to the display channel | P0 |
+| FR-6.3 | Default is opted in (visible) | P0 |
+
+---
+
+## Non-Functional Requirements
+
+| ID | Requirement | Target |
+|----|-------------|--------|
+| NFR-D1 | Snapshot delivery to display | < 500ms from phone publish |
+| NFR-D2 | Display reconnection after network drop | < 5 seconds, automatic |
+| NFR-D3 | Display render after receiving snapshot | < 100ms |
+| NFR-D4 | Rest timer visual accuracy on display | ± 1 second (interpolated locally) |
+| NFR-D5 | Board supports simultaneous sessions | Up to 4 per page; auto-cycles beyond 4 |
+| NFR-D6 | Display page memory footprint | < 50 MB (it runs on cheap hardware) |
+
+---
+
+## Broadcast Channel Design
+
+A single Broadcast channel scoped to the Supabase instance handles all display communication. All users publish to the same channel with their `user_id` as the event discriminator. The display subscribes once and receives all events.
+
+```mermaid
+flowchart TB
+ subgraph Publishers["Phone Clients"]
+ P1["User A — Active Workout"]
+ P2["User B — Active Workout"]
+ P3["User C — Opted Out"]
+ end
+
+ subgraph Channel["Supabase Broadcast Channel: display"]
+ Bus["Event Bus"]
+ end
+
+ subgraph Subscribers["Display Clients"]
+ TV1["Gym TV"]
+ TV2["Garage Monitor"]
+ end
+
+ P1 -->|snapshot event| Bus
+ P2 -->|snapshot event| Bus
+ P3 -.->|blocked by opt-out| Bus
+ Bus -->|all events| TV1
+ Bus -->|all events| TV2
+```
+
+### Event Types
+
+| Event | Payload | Trigger |
+|-------|---------|---------|
+| `workout_snapshot` | Full snapshot object | Set confirmed, exercise transition, timer start/stop, workout start |
+| `session_ended` | `{ user_id }` | Workout completed or abandoned |
+| `focus` | `{ user_id }` | User taps "Push to Display" |
+| `unfocus` | `{}` | User taps "Return to Board" |
+
+### Timer Interpolation
+
+The phone does not push a snapshot every second during a rest timer countdown. Instead, the phone publishes a snapshot when the timer starts (with `rest_timer.remaining_seconds` and `rest_timer.started_at`) and when it expires. The display runs its own local countdown between these two events, using `started_at` and the total duration to calculate the remaining time independently. This keeps broadcast traffic low while maintaining smooth visual countdown on the display.
+
+---
+
+## Idle Mode Data
+
+Idle mode requires today's scheduled sessions. Since the display has no auth context and does not query the database, this data must also arrive via broadcast. A Supabase Edge Function runs on a cron schedule (every 60 seconds), queries today's remaining scheduled sessions for all users, and publishes an `idle_state` event on the display Broadcast channel. The display renders this passively. This decouples idle mode from any phone being open — the display works even when the gym is empty.
+
+The idle state broadcast contains:
+
+| Field | Description |
+|-------|-------------|
+| `server_time` | Current UTC timestamp for clock sync |
+| `scheduled_sessions` | Array of today's remaining sessions: user display name, session name, scheduled time |
+| `next_session` | The soonest upcoming session across all users, for countdown display |
+
+---
+
+## Display UI
+
+### Idle Mode Layout
+
+```
+┌──────────────────────────────────────────────────┐
+│ │
+│ 17:41 CST │ ← Large clock, Space Grotesk
+│ │
+│ Thursday, April 3, 2026 │ ← Date, Inter
+│ │
+├──────────────────────────────────────────────────┤
+│ TODAY'S SESSIONS │
+│ │
+│ Robert OPERATOR 5:30 PM │
+│ Sarah BASE BUILDING 6:00 PM │
+│ Mike HIC 7:00 PM │
+│ │
+│ ┌──────────────────┐ │
+│ │ NEXT UP IN 0:49 │ │ ← Countdown badge, ember accent
+│ │ Robert — OPERATOR │ │
+│ └──────────────────┘ │
+│ │
+├──────────────────────────────────────────────────┤
+│ ● Connected │ ← Status footer
+└──────────────────────────────────────────────────┘
+```
+
+### Board Mode Layout (2 active sessions)
+
+```
+┌──────────────────────────┬───────────────────────┐
+│ ROBERT │ SARAH │
+│ OPERATOR — 32:15 │ BASE BUILDING — 18:40│
+│ │ │
+│ Barbell Back Squat │ Treadmill Run │
+│ Exercise 2 of 5 │ Exercise 1 of 1 │
+│ │ │
+│ SET 1 275×5 ✓ │ │
+│ SET 2 275×5 ✓ │ Duration: 18:40 │
+│ SET 3 275×5 ▸ │ Distance: 2.1 mi │
+│ │ Pace: 8:53/mi │
+│ ┌────────────────┐ │ │
+│ │ REST 1:32 │ │ │
+│ └────────────────┘ │ │
+│ │ │
+└──────────────────────────┴───────────────────────┘
+│ ● Connected | 2 active sessions │
+└──────────────────────────────────────────────────┘
+```
+
+### Focused Mode Layout
+
+```
+┌──────────────────────────────────────────────────┐
+│ ROBERT — OPERATOR 32:15 │ ← Header: name, session, elapsed
+├──────────────────────────────────────────────────┤
+│ │
+│ Barbell Back Squat │ ← Current exercise, large
+│ Exercise 2 of 5 │
+│ │
+│ SET PRESCRIBED ACTUAL STATUS │
+│ 1 275 × 5 275 × 5 ✓ │
+│ 2 275 × 5 275 × 5 ✓ │
+│ 3 275 × 5 — ▸ │ ← Current set highlighted
+│ 4 275 × 5 — │
+│ 5 275 × 5 — │
+│ │
+│ ┌──────────────────┐ │
+│ │ │ │
+│ │ REST 1:32 │ │ ← Large timer, ember accent
+│ │ │ │
+│ └──────────────────┘ │
+│ │
+├──────────────────────────────────────────────────┤
+│ ● Connected | Focused: Robert │
+└──────────────────────────────────────────────────┘
+```
+
+### Iron & Ember Design Callouts
+
+The display follows Iron & Ember with display-specific adaptations for readability at distance:
+
+| Element | Treatment |
+|---------|-----------|
+| Background | `surface-anvil` (#131313) full viewport |
+| Clock (idle) | Space Grotesk, minimum 8rem on 1080p, `text-primary` |
+| Session cards | `surface-iron` (#201F1F) background, zero border-radius |
+| Card headers | Space Grotesk `text-label-large`, ALL-CAPS, `text-primary` |
+| Set table | Inter `body-small`, alternating `surface-charcoal` rows |
+| Rest timer | Space Grotesk `text-readout` scale, `ember` (#FFB59C) text, `surface-steel` background |
+| Active set indicator | `ember` left border accent on current set row |
+| Completed set check | `check_circle` Material Symbol in `arc` (#86CFFF) |
+| Status footer | `surface-pit` (#0E0E0E), Inter `label-small`, `text-secondary` |
+| Countdown badge (idle) | `surface-steel` with `ember` text, zero border-radius |
+| Session type labels | ALL-CAPS, `label-medium`, `text-secondary` |
+| Elapsed time | Space Grotesk monospace numerals, `text-secondary` |
+
+Text sizing must account for viewing distance. The display is designed for 1080p TVs viewed from 3–10 meters. Body text should be no smaller than 1.25rem; timer numerals and clock should be significantly larger.
+
+---
+
+## Resolved Decisions
+
+| ID | Decision | Resolution | Rationale |
+|----|----------|------------|-----------|
+| RD-1 | Transport layer | Supabase Realtime Broadcast | Already in the stack for chat; no new vendor; fits push-based model |
+| RD-2 | Snapshot vs event stream | Full snapshot per meaningful state change | Self-contained, resilient to reconnection, simpler display logic |
+| RD-3 | Display authentication | None; uses publishable key | Display is passive and shows only data that users have opted in to share |
+| RD-4 | Focus trigger | Phone only; display is passive | Keeps TV interaction-free; input can be added later |
+| RD-5 | Timer on display | Local interpolation between start/stop events | Avoids per-second broadcast traffic; smooth visual countdown |
+| RD-6 | Multi-user board | Hybrid: board by default, phone-triggered focus | Covers both overview and single-user deep dive |
+| RD-7 | Display opt-out | Per-user toggle in profile settings, default visible | Respects privacy while keeping opt-in simple for friends-and-family |
+| RD-8 | Idle mode content | Clock + today's schedule + next-session countdown | Useful ambient display; not overly complex |
+| RD-9 | Idle data delivery | Periodic broadcast from Edge Function or active client | Maintains pure-push model; display never queries the database |
+| RD-10 | Display route location | `/display` route in existing web app | No separate app needed; works on any device with a browser |
+| RD-11 | Idle mode data source | Supabase Edge Function on cron | Decouples idle mode from any phone being open; cleaner than phone-based publishing |
+| RD-12 | Display orientation | Landscape only | Gym TVs are landscape; simplifies layout; portrait tablet is not a target use case |
+| RD-13 | Board with 5+ sessions | Auto-cycle through pages of 4 cards | Cards become unreadable at distance beyond 4; cycling maintains readability |
+
+---
+
+## Phase Placement
+
+| Feature | Phase | Rationale |
+|---------|-------|-----------|
+| Display route + Broadcast subscription | Phase 7 | Requires Broadcast infrastructure (shared with Phase 6 chat) |
+| Snapshot publishing from active workout | Phase 7 | Requires active workout logging (Phase 0, Step 6) |
+| Board view | Phase 7 | Core display feature |
+| Focused view | Phase 7 | Core display feature |
+| Idle mode with clock | Phase 7 | Minimal idle state |
+| Idle mode schedule + countdown | Phase 7 | Requires program/schedule data (Phase 2) |
+| Display opt-out | Phase 7 | Privacy control |
+
+The remote display depends on Supabase Realtime Broadcast, which is also used by the chat feature (Phase 6). However, the display feature does not depend on chat being built first — Broadcast channels are independent. The display feature can be built as soon as the active workout logging (Step 6) and program structure (Step 11) are stable, which places it after Phase 2. It is sequenced as Phase 7 to reflect its priority relative to analytics, notifications, hosting, and chat, but it could be started earlier if desired.
+
+---
+
+## Constraints
+
+1. **Display never writes data.** The display route is read-only. It subscribes to Broadcast events and renders them. It does not insert, update, or delete any records.
+2. **Display never authenticates.** No user login, no session token, no RLS context. All data arrives pre-authorized via the Broadcast channel. The publishable key enables the Broadcast subscription only.
+3. **Snapshots are ephemeral.** Workout snapshots are not persisted to the database. They exist only in the Broadcast channel and the display's in-memory state. If the display reloads, it waits for the next snapshot (or requests a current state push from connected phones via a "hello" event).
+4. **One focus at a time.** If User A focuses, then User B focuses, User B's focus replaces User A's. The display shows one focused user or the board, never a partial focus.
+5. **Opted-out users are invisible.** When a user has opted out of display visibility, their phone does not publish any snapshots. The display has no awareness that the user is working out.
+6. **Display is browser-only.** The `/display` route is a standard web page. It is not wrapped in Tauri, not available as a native app, and does not use the Tauri adapter. It uses the Supabase adapter exclusively.
+7. **Landscape only.** The display layout assumes a landscape viewport. Portrait orientation is not supported or tested.
+
+---
+
+## Downstream Document Updates Required
+
+| Document | Changes Needed |
+|----------|----------------|
+| `05-domain-model.md` | Add `display_visible` boolean to UserProfile. Add DisplaySnapshot value object. |
+| `06-invariants.md` | Add invariant: display never writes data. Add invariant: opted-out users produce no display events. |
+| `07-architecture.md` | Add display route to system architecture diagram. Document Broadcast channel for display. |
+| `09-state-machines.md` | Add display mode state machine (Idle → Board → Focused). |
+| `10-user-flows.md` | Add "Push to Display" flow. Add display reconnection flow. |
+| `implementation-plan.md` | Add Steps 27–29 (Phase 7). |
+
+---
+
+## Open Questions
+
+None. All questions resolved — see Resolved Decisions table.
diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md
index 8829ac98..f760b60c 100644
--- a/docs/implementation-plan.md
+++ b/docs/implementation-plan.md
@@ -175,8 +175,33 @@
══════════════════════════╪═══════════════╪════
Phase 6 Complete │ Chat & Media working
══════════════════════════════════════════════
+ │
+ ┌──────────────┐
+ │ STEP 27: │
+ │ Display │
+ │ Broadcast │
+ │ Infra │
+ └──────┬───────┘
+ │
+ ┌──────────────┐
+ │ STEP 28: │
+ │ Display │
+ │ Route + │
+ │ Board/Focus │
+ └──────┬───────┘
+ │
+ ┌──────────────┐
+ │ STEP 29: │
+ │ Idle Mode │
+ └──────────────┘
+
+ ══════════════════════════════════════════════════
+ Phase 7 Complete Remote Display working
+ ══════════════════════════════════════════════════
```
+> **Note on ordering:** Steps 27–29 are shown after Phase 6 for dependency graph clarity, but the remote display has no dependency on chat (Phase 6). The only hard dependencies are Step 6 (active workout logging) and Step 11 (program structure for idle mode schedule). The display could be built as early as Phase 2 completion if desired. Broadcast channel setup is independent between chat and display — they use separate channel names.
+
> **Note on ordering:** Step 19 and Step 20 are shown after Phase 3-4 for dependency graph clarity, but Step 19 can be executed at any point after Phase 1 (Step 9). It modifies the Supabase client initialization and adds a settings screen — both of which exist by Step 9. Step 20 (Docker) can be done at any point after Step 3 (Supabase schema exists). Neither step depends on Steps 10-18. The only hard requirement is that Step 19 must be complete before any Play Store or public distribution.
---
@@ -2355,6 +2380,266 @@ Add a brief explanation in Settings → Data section: "Chat messages are automat
---
+## ═══ PHASE 7: Remote Display ═══
+
+## STEP 27: Display Broadcast Infrastructure (~2 days)
+
+**Dependencies:** Step 6 (active workout logging), Step 1.5 (Iron & Ember design system)
+**Priority:** P2
+**Docs:** `13-prd-remote-display.md` §Broadcast Channel Design + §Snapshot Publishing
+
+### What to build
+
+The data pipeline: workout snapshot construction, Broadcast channel publishing from the phone, and the display route's subscription and state management.
+
+### 27a. Workout snapshot type
+
+Define a `DisplaySnapshot` type (Zod schema) containing all fields from `13-prd-remote-display.md` §What Gets Pushed. This is a projection of the active workout state — not the full workout log. It includes user_id, display_name, session metadata, current exercise, set array for the current exercise, and rest timer state.
+
+Add `is_visible` to the Zod schema. This field is checked before publishing; if false, no snapshot is emitted.
+
+### 27b. Snapshot publisher
+
+Integrate snapshot publishing into the active workout Zustand store. On each state-changing action (set confirmed, exercise transitioned, rest timer started, rest timer expired, workout started, workout completed), build a `DisplaySnapshot` from the current store state and publish it to the `display` Broadcast channel.
+
+Publishing triggers:
+
+| Action | Event Type | Notes |
+|--------|-----------|-------|
+| Workout started | `workout_snapshot` | Initial snapshot with first exercise |
+| Set confirmed | `workout_snapshot` | Updated set completion status |
+| Exercise transitioned | `workout_snapshot` | New current exercise and fresh set array |
+| Rest timer started | `workout_snapshot` | Timer state: running, remaining seconds, started_at |
+| Rest timer expired | `workout_snapshot` | Timer state: idle |
+| Workout completed | `session_ended` | Payload: `{ user_id }` only |
+| Workout abandoned | `session_ended` | Same as completed |
+| Focus requested | `focus` | Payload: `{ user_id }` |
+| Unfocus requested | `unfocus` | Payload: `{}` |
+
+### 27c. Display visibility setting
+
+Add `display_visible` boolean to user profile (default: true). Add a toggle in the Settings screen under a "Remote Display" section. When false, the snapshot publisher skips all Broadcast publishes.
+
+Adapter method:
+
+| Method | Description |
+|--------|-------------|
+| `updateDisplayVisibility(userId, visible)` | Sets the display_visible flag on the user profile |
+
+### 27d. Display Broadcast channel setup
+
+The channel name is `display`. All phone clients publish to this single channel. All display clients subscribe to this single channel. Events are discriminated by `event` type (`workout_snapshot`, `session_ended`, `focus`, `unfocus`) and by `user_id` within the payload.
+
+No RLS applies to Broadcast channels — they are ephemeral pub/sub. The privacy boundary is enforced at the publisher (phone checks `display_visible` before emitting).
+
+### Done when
+
+- [ ] `DisplaySnapshot` Zod schema validates all required fields
+- [ ] Active workout store publishes snapshot on every state-changing action
+- [ ] `session_ended` event fires on workout completion and abandonment
+- [ ] `focus` and `unfocus` events publish from phone
+- [ ] Display visibility toggle exists in Settings
+- [ ] Opted-out users produce zero Broadcast events
+- [ ] Snapshot includes correct timer state (running with started_at, or idle)
+- [ ] Publishing does not degrade active workout UI performance (async, fire-and-forget)
+
+---
+
+## STEP 28: Display Route + Board and Focused Views (~3 days)
+
+**Dependencies:** Step 27 (Broadcast publishing working)
+**Priority:** P2
+**Docs:** `13-prd-remote-display.md` §Display Modes + §Display UI + §Iron & Ember Design Callouts
+
+### What to build
+
+The `/display` route: a full-viewport, no-chrome web page that subscribes to the display Broadcast channel and renders active workout sessions. This is the most visually demanding step — the display must be readable from across a gym on a 1080p TV.
+
+### 28a. Display route shell
+
+Add `/display` route to TanStack Router. This route renders outside the main app layout — no navigation bar, no sidebar, no header. Full viewport, `surface-anvil` background, single status footer.
+
+The route:
+- Subscribes to the `display` Broadcast channel on mount
+- Maintains an in-memory map of active sessions (keyed by `user_id`)
+- Tracks current display mode: idle, board, or focused (and which user_id is focused)
+- Cleans up subscription on unmount
+
+### 28b. Session state management
+
+The display maintains a `Map` in component state (or a dedicated Zustand store). Incoming `workout_snapshot` events upsert into this map. `session_ended` events remove the entry. The display mode is derived from this map:
+
+| Condition | Mode |
+|-----------|------|
+| Map is empty | Idle |
+| Map has entries + no focus command active | Board |
+| Focus command active + focused user in map | Focused |
+| Focus command active + focused user NOT in map | Board (focus cleared) |
+
+### 28c. Board view
+
+Responsive grid of session cards. Grid columns adapt to session count:
+
+| Active Sessions | Grid Layout |
+|----------------|-------------|
+| 1 | Single card, centered, expanded |
+| 2 | 2 columns |
+| 3-4 | 2 columns, 2 rows |
+| 5+ | Pages of up to 4 cards, auto-cycling every 10 seconds |
+
+Each card renders on `surface-iron`, zero border-radius, containing:
+
+| Element | Typography | Color |
+|---------|-----------|-------|
+| Display name | Space Grotesk `text-label-large`, ALL-CAPS | `text-primary` |
+| Session type | Inter `label-medium`, ALL-CAPS | `text-secondary` |
+| Elapsed time | Space Grotesk monospace | `text-secondary` |
+| Current exercise | Inter `body-medium` | `text-primary` |
+| Exercise progress | Inter `label-small` | `text-secondary` |
+| Set progress | Compact: completed/total with `arc` checkmarks | — |
+| Rest timer | Space Grotesk, `ember` text, `surface-steel` background | `ember` |
+
+### 28d. Focused view
+
+Full-screen workout display for a single user. Larger typography than board cards. Full set table with PRESCRIBED / ACTUAL / STATUS columns. Large rest timer countdown.
+
+| Element | Scale | Notes |
+|---------|-------|-------|
+| User name + session | Space Grotesk, 2rem+ | Header bar |
+| Current exercise | Space Grotesk, 2.5rem+ | Centered, prominent |
+| Set table | Inter `body-medium`, generous row height | Alternating `surface-charcoal` rows |
+| Current set row | `ember` left border | Visual indicator of active set |
+| Rest timer | Space Grotesk `text-readout` (3.5rem+) | `ember` text, centered, pulsing when < 10s |
+| Elapsed time | Space Grotesk, 1.5rem | Header, `text-secondary` |
+
+### 28e. Timer interpolation
+
+The display does not receive per-second timer updates. When a snapshot arrives with `rest_timer.state = 'running'`, the display calculates remaining time from `rest_timer.started_at` and `rest_timer.total_seconds`, then runs a local `setInterval` (or `requestAnimationFrame`) countdown. When a snapshot arrives with `rest_timer.state = 'idle'`, the display clears the local timer.
+
+This produces a smooth countdown visual with zero additional Broadcast traffic.
+
+### 28f. Reconnection behavior
+
+If the WebSocket disconnects, the display:
+1. Shows "Reconnecting..." in the status footer
+2. Attempts automatic reconnection (Supabase client handles this)
+3. On reconnection, publishes a `display_hello` event to the channel
+4. Active phones respond by re-publishing their current snapshot
+5. Display rebuilds its session map from incoming snapshots
+
+The `display_hello` → re-publish pattern ensures the display catches up after any disconnection without requiring server-side state persistence.
+
+### 28g. "Push to Display" button
+
+Add a button to the active workout screen (phone). Placement: in the workout header or action menu. Material Symbol: `cast` or `tv`. Label: "PUSH TO DISPLAY" / "RETURN TO BOARD".
+
+The button publishes a `focus` event with the user's `user_id`. Tapping again publishes an `unfocus` event. Button state reflects whether the user is currently focused.
+
+### 28h. Connection status footer
+
+Persistent footer at bottom of display viewport. `surface-pit` background, Inter `label-small`.
+
+| State | Display |
+|-------|---------|
+| Connected | `● Connected` (green dot) |
+| Reconnecting | `○ Reconnecting...` (amber dot, pulsing) |
+| Active sessions count | `N active sessions` or `Focused: [Name]` |
+
+### Done when
+
+- [ ] `/display` route renders full-viewport with no app chrome
+- [ ] Display subscribes to `display` Broadcast channel on mount
+- [ ] Board view shows cards for all active visible sessions
+- [ ] Grid layout adapts from 1 to 4 sessions per page
+- [ ] Board auto-cycles through pages when 5+ sessions active (10-second interval)
+- [ ] Focused view renders full set table and large timer
+- [ ] Focus/unfocus triggered from phone updates display mode
+- [ ] Timer interpolation produces smooth countdown (± 1 second accuracy)
+- [ ] Display auto-reconnects and rebuilds state after network drop
+- [ ] "Push to Display" button exists in active workout UI
+- [ ] Connection status footer shows current state
+- [ ] All typography readable at 3+ meters on 1080p display
+- [ ] Iron & Ember design tokens applied throughout (zero border-radius, tonal surfaces, dual fonts)
+- [ ] No database queries from the display route
+
+---
+
+## STEP 29: Idle Mode + Edge Function (~1.5 days)
+
+**Dependencies:** Step 28 (display route working), Step 11 (program structure for schedule data), Step 3 (Supabase project for Edge Function deployment)
+**Priority:** P2
+**Docs:** `13-prd-remote-display.md` §Idle Mode + §Idle Mode Data
+
+### What to build
+
+The ambient display state when no workouts are active. Clock, today's schedule, and countdown to next session.
+
+### 29a. Idle mode layout
+
+Full-viewport idle display:
+
+| Element | Typography | Position |
+|---------|-----------|----------|
+| Clock | Space Grotesk, minimum 8rem, `text-primary` | Centered, upper half |
+| Date | Inter `body-large`, `text-secondary` | Below clock |
+| Schedule section header | Space Grotesk `text-label-large`, ALL-CAPS, "TODAY'S SESSIONS" | Left-aligned, lower section |
+| Schedule entries | Inter `body-medium`: display name, session type (ALL-CAPS), scheduled time | Rows in `surface-iron` cards |
+| Next-up countdown | Space Grotesk, `ember` text, `surface-steel` badge | Below schedule |
+| Status footer | Same as board/focused modes | Bottom |
+
+Clock updates every second via local `setInterval`. No Broadcast event needed for clock ticking.
+
+### 29b. Idle state Edge Function
+
+A Supabase Edge Function runs on a cron schedule (every 60 seconds). It queries today's remaining scheduled sessions for all users and publishes an `idle_state` event to the `display` Broadcast channel. The Edge Function uses the service_role key (server-side only, never exposed to clients) to query across users.
+
+The Edge Function:
+- Runs via `pg_cron` or Supabase's scheduled function invocation
+- Queries `scheduled_sessions` joined with user profiles for today's date
+- Filters to sessions not yet completed
+- Publishes the `idle_state` event payload to the `display` channel
+- Runs independently of any client being connected
+
+### 29c. Idle state event payload
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `server_time` | ISO timestamp | Current time for clock sync correction |
+| `scheduled_sessions` | Array | `{ display_name, session_name, session_type, scheduled_time }` for each remaining session today |
+| `next_session` | Object or null | The soonest upcoming session: `{ display_name, session_name, starts_in_seconds }` |
+
+### 29d. Transition animations
+
+| Transition | Animation |
+|-----------|-----------|
+| Idle → Board | Fade clock out, fade cards in (300ms) |
+| Board → Idle | Fade cards out, fade clock in (300ms) |
+| Board → Focused | Cards shrink to zero, focused view expands from center (400ms) |
+| Focused → Board | Reverse of above (400ms) |
+
+Use CSS transitions or Framer Motion. Keep animations subtle — the display should feel calm, not flashy.
+
+### Done when
+
+- [ ] Idle mode renders large clock updating every second
+- [ ] Today's scheduled sessions displayed when idle state data available
+- [ ] Next-session countdown badge renders with `ember` accent
+- [ ] Idle mode transitions to board when first session starts
+- [ ] Board transitions to idle when last session ends
+- [ ] Idle state published by Edge Function on 60-second cron interval
+- [ ] Edge Function queries today's remaining sessions across all users
+- [ ] Clock is readable from 5+ meters on 1080p
+- [ ] Transitions between modes are smooth (300-400ms)
+- [ ] Display remains stable for hours without memory leaks or drift
+
+---
+
+## ═══ PHASE 7 COMPLETE ═══
+
+**Checkpoint:** A full-viewport `/display` route runs on any browser-capable screen in the gym. Active workout sessions appear as cards (board mode) or expand to full-screen (focused mode) with live set tracking and smooth rest timer countdowns. The idle screen shows the clock and today's scheduled sessions. The display requires no authentication and no database queries — pure Broadcast subscription.
+
+---
+
## Integration Testing Milestones
### Milestone 1: First Workout in Browser (after Steps 5 + 6)
@@ -2438,7 +2723,11 @@ Add a brief explanation in Settings → Data section: "Chat messages are automat
| 25. Video + Image Sharing | P2 | 4 days | 24 |
| 26. Message Retention + Archiving | P3 | 1 day | 22, 23, 24, 25 |
| **Phase 6 subtotal** | | **~17 days** | |
-| **Total** | | **~63 days** | |
+| 27. Display Broadcast Infrastructure | P2 | 2 days | Step 6 |
+| 28. Display Route + Board/Focused | P2 | 3 days | Step 27 |
+| 29. Idle Mode + Edge Function | P2 | 1.5 days | Step 28, Step 11 |
+| **Phase 7 subtotal** | | **~6.5 days** | |
+| **Total** | | **~69.5 days** | |
> **Critical path to browser MVP:** Steps 1 → 1.5 → 2/3 → 4 → 5 → 6 → 7 = ~13 days
> **Critical path to Tauri GO/NO-GO:** + Steps 8 → 9 = ~18.5 days
@@ -2447,6 +2736,8 @@ Add a brief explanation in Settings → Data section: "Chat messages are automat
> **Critical path to text chat MVP:** + Steps 21 → 22 → 23 = ~36 days
> **Critical path to full chat with video:** + Steps 24 + 25 = ~42 days (24 and 25 can parallel)
> **Retention can ship any time after Step 21.**
+> **Critical path to remote display:** Steps 1–13 + Step 27 → 28 → 29 = ~6 additional days after Phase 2
+> **Can be parallelized with:** Phase 3-4 (analytics, notifications, sharing), Phase 5 (hosting), Phase 6 (chat)
---
diff --git a/docs/onboarding-refactoring-plans.md b/docs/onboarding-refactoring-plans.md
new file mode 100644
index 00000000..7bc534b4
--- /dev/null
+++ b/docs/onboarding-refactoring-plans.md
@@ -0,0 +1,256 @@
+# Onboarding Redesign — Refactoring Plans
+
+**Context:** Steps 19 (Runtime Backend Configuration) and 20 (Docker & Self-Hosting) are complete. These refactoring plans modify the existing codebase to replace the raw Supabase fields setup screen with a tiered onboarding flow: instance URL with discovery (primary), QR code / deep link (parallel), raw Supabase fields (advanced fallback). Each plan is independently shippable but ordered by dependency.
+
+**Docs:** `03-prd-hosting.md` (with onboarding additions applied)
+
+---
+
+## REFACTOR A: Discovery Client + Server URL Input
+
+**Modifies:** Setup screen (`/setup` route), connection validator, Caddyfile
+**Estimated effort:** 1 day
+**Dependencies:** None — this plan touches only the setup screen and adds a new utility module
+
+### What exists today
+
+The setup screen at `/setup` has two underline inputs (Supabase URL, Publishable Key), a "CONNECT" button, inline status feedback, and a help link. The connection validator (`validateConnection`) takes a URL and publishable key, hits the REST API root, and queries a known table. The Caddyfile routes `/rest/v1/*`, `/auth/v1/*`, `/realtime/v1/*` to Kong and everything else to the web app nginx container.
+
+### What changes
+
+#### Aa. Discovery client module
+
+Create `src/lib/discovery.ts`. A single async function that takes a server URL string and returns the Supabase URL and publishable key, or an error.
+
+| Function | Signature | Behavior |
+|----------|-----------|----------|
+| `discoverInstance` | `(serverUrl: string) => Promise` | Normalizes input (prepend `https://` if no protocol, strip trailing slash), fetches `${normalized}/.well-known/ardent-forge.json`, validates response shape (must have `version`, `supabase_url`, `supabase_publishable_key`), returns extracted fields or a typed error |
+
+Error types: `NETWORK_ERROR` (fetch failed), `NOT_FOUND` (404 or non-JSON response), `INVALID_RESPONSE` (JSON present but missing required fields).
+
+The function is pure fetch — no config store interaction, no side effects. It can be unit tested with a mocked fetch.
+
+#### Ab. Setup screen UI refactor
+
+Modify the existing `/setup` route component. The current two-field form becomes the "Advanced" section, collapsed by default. A new primary section takes its place.
+
+**Existing elements to keep:** Heading (change copy from "CONFIGURE BACKEND" to "CONNECT TO SERVER"), status feedback area, help link, overall Iron & Ember styling.
+
+**New layout, top to bottom:**
+
+| Element | Notes |
+|---------|-------|
+| Server URL field | Underline input, placeholder "Server address (e.g. forge.example.com)". Replaces the old Supabase URL field position. |
+| "CONNECT" button | `forge` CTA. Calls `discoverInstance`, then pipes result into existing `validateConnection`. |
+| Status area | Reuses existing inline feedback component. New states added for discovery phase ("Looking up server...", "Could not find server configuration at this address."). |
+| QR scan button | Placeholder for Refactor B — render as disabled or hidden until that plan lands. |
+| "OR" divider | `text-industrial` label with horizontal rules on `surface-charcoal`. |
+| "Manual configuration" toggle | Tappable row with chevron. Expands to reveal the existing two-field form (Supabase URL + Publishable Key + secondary "CONNECT" button). |
+
+**Server URL input behavior:** Accept bare domains (`forge.example.com`), full URLs (`https://forge.example.com`), and URLs with trailing slashes. All normalization happens in `discoverInstance`, not in the UI component.
+
+**State machine change:** The existing setup screen has states: empty → validating → success/error. The refactored screen adds a discovery phase before validation: empty → discovering → discovered (auto-validates) → success/error, with a branch for discovery failure that suggests QR scan or manual entry.
+
+The Advanced section's "CONNECT" button bypasses discovery entirely and calls `validateConnection` directly with the raw fields, exactly as the current screen works today.
+
+#### Ac. Caddy discovery endpoint
+
+Add a `handle` block to the existing Caddyfile that serves `/.well-known/ardent-forge.json` directly from Caddy using a `respond` directive. This block must appear before the catch-all route to the web app container.
+
+The response body is a static JSON string with `{SITE_URL}` and `{ANON_KEY}` placeholder values interpolated from Caddy's environment variables (which already come from the Docker Compose `.env`). Caddy supports environment variable substitution in the Caddyfile natively via `{$ENV_VAR}` syntax.
+
+Response headers: `Content-Type: application/json`, `Cache-Control: public, max-age=3600`.
+
+**Existing Caddyfile routing to preserve:**
+
+| Path | Target |
+|------|--------|
+| `/.well-known/ardent-forge.json` | **NEW** — Caddy responds directly |
+| `/rest/v1/*`, `/auth/v1/*`, `/realtime/v1/*` | Kong (unchanged) |
+| `/studio/*` | Supabase Studio (unchanged) |
+| `/*` | Web app nginx (unchanged) |
+
+#### Ad. Self-hosting docs update
+
+Add a section to `docs/self-hosting.md` for Supabase Cloud + static host deployments explaining how to create the discovery file. For Vercel/Netlify/Cloudflare Pages, this is a static file at `public/.well-known/ardent-forge.json` with hardcoded values. For Docker deployments, note that it's handled automatically by Caddy.
+
+### Done when
+
+- [ ] `discoverInstance("forge.example.com")` returns Supabase URL + key from a valid discovery endpoint
+- [ ] `discoverInstance` returns typed errors for network failure, 404, and malformed JSON
+- [ ] Setup screen shows server URL field as primary input
+- [ ] Entering a server address triggers discovery → validation pipeline
+- [ ] Discovery failure shows error message suggesting QR scan or manual entry
+- [ ] "Manual configuration" toggle reveals existing Supabase URL + key fields
+- [ ] Manual entry bypasses discovery and works exactly as today
+- [ ] Caddy serves `/.well-known/ardent-forge.json` with correct env var values
+- [ ] `Cache-Control` header set on discovery endpoint response
+- [ ] Self-hosting docs updated for static host discovery file
+- [ ] Existing connection validator is unchanged — discovery pipes into it
+- [ ] All existing setup/config tests still pass
+
+---
+
+## REFACTOR B: QR Code Generation + Scan on Setup
+
+**Modifies:** Settings → Backend section, setup screen (`/setup` route)
+**Estimated effort:** 1 day
+**Dependencies:** None strict — can land before or after Refactor A. The setup screen QR scan button placeholder from A is activated here, but B can also land first (scan button just exists from the start).
+
+### What exists today
+
+Settings → Backend shows the current Supabase URL (truncated, with copy button) and a "CHANGE BACKEND" button. The setup screen has the input fields and validation flow.
+
+### What changes
+
+#### Ba. QR code dependency
+
+Add `qrcode.react` to project dependencies via `bun add qrcode.react`. Lightweight, client-side-only, no external service calls.
+
+#### Bb. QR generation in Settings
+
+Add a new section to the existing Settings → Backend area, below the current URL display and change button.
+
+**New elements:**
+
+| Element | Design |
+|---------|--------|
+| Section heading | "SHARE THIS SERVER" in `text-industrial` |
+| QR code | `QRCodeSVG` from `qrcode.react`, rendered on `surface-iron` card, centered. Value is `ardentforge://connect?url=${encodeURIComponent(config.supabaseUrl)}&key=${encodeURIComponent(config.publishableKey)}` read from config store. |
+| Copy button | Secondary button: "COPY INVITE LINK" — copies the same `ardentforge://connect?...` string to clipboard. Show success toast on copy. |
+| Explainer | `body-small` Inter, `text-muted`: "Share this with anyone who wants to connect to this server. They will still need to create an account." |
+
+**Conditional rendering:** The section only renders if `configStore.hasConfig()` is true. On the Settings page this is guaranteed (you can't reach Settings without being configured and authenticated), but the guard makes the contract explicit.
+
+#### Bc. QR scan / paste on setup screen
+
+Add a QR input method to the setup screen alongside the server URL field.
+
+**Tauri mode (Android/iOS):** Add an icon button (`qr_code_scanner` Material Symbol) to the right of the server URL field. Tapping it opens the device camera via the official Tauri barcode scanner plugin (`@tauri-apps/plugin-barcode-scanner`). The plugin is mobile-only (Android + iOS) and accesses the camera natively, bypassing WebView camera APIs entirely.
+
+Setup: `bun tauri add barcode-scanner` handles both the Rust crate and JS bindings. Add `barcode-scanner:allow-scan` and `barcode-scanner:allow-cancel` to the mobile capability file (`src-tauri/capabilities/mobile.json`). For iOS, add `NSCameraUsageDescription` to `src-tauri/Info.ios.plist` (e.g., "Scan a QR code to connect to a server").
+
+Scanning uses `windowed: true` mode, which makes the WebView transparent and renders the camera feed underneath. The app shows a dark semi-transparent overlay with a viewfinder cutout during scanning. On successful scan, call `cancel()` to dismiss the camera and process the result.
+
+```typescript
+import { scan, cancel, Format } from '@tauri-apps/plugin-barcode-scanner';
+
+const result = await scan({ windowed: true, formats: [Format.QRCode] });
+await cancel();
+// result.content contains the decoded string
+```
+
+**Tauri mode (desktop):** The barcode scanner plugin does not support desktop platforms. Hide the QR scan button on desktop builds. Desktop users use the server URL field or manual entry.
+
+**Browser mode:** Replace the camera-based scan with a text input. The icon button opens a small inline field labeled "Paste invite link" that accepts an `ardentforge://connect?...` string. On paste/enter, parse the URL and extract `url` and `key` query parameters.
+
+**Both modes on successful scan/paste:**
+
+1. Parse the `ardentforge://connect` URL, extract `url` and `key` query params.
+2. Pre-populate the Advanced section's Supabase URL and key fields (expand it if collapsed).
+3. Auto-trigger validation using the existing `validateConnection`.
+4. On success, persist to config store and navigate to auth — same as manual entry.
+
+**Error handling:** If the scanned/pasted value is not a valid `ardentforge://connect` URL, show a toast: "Invalid invite link."
+
+### Done when
+
+- [ ] `qrcode.react` added to dependencies
+- [ ] `@tauri-apps/plugin-barcode-scanner` added via `bun tauri add barcode-scanner`
+- [ ] Barcode scanner permissions added to `src-tauri/capabilities/mobile.json`
+- [ ] `NSCameraUsageDescription` added to `src-tauri/Info.ios.plist`
+- [ ] Settings → Backend shows "SHARE THIS SERVER" section with QR code
+- [ ] QR code encodes correct `ardentforge://connect?url=...&key=...` from config store
+- [ ] "COPY INVITE LINK" copies deep link to clipboard with success toast
+- [ ] Explainer text renders in `body-small` / `text-muted`
+- [ ] Setup screen has QR scan button on mobile (opens native camera via barcode scanner plugin)
+- [ ] QR scan button hidden on desktop builds
+- [ ] Browser mode shows "paste invite link" field instead of camera scan
+- [ ] Scanning/pasting a valid invite link pre-populates and auto-validates
+- [ ] Invalid QR/link content shows error toast
+- [ ] Camera overlay renders correctly (transparent WebView with viewfinder cutout)
+- [ ] Section is hidden if config store is empty (defensive guard)
+
+---
+
+## REFACTOR C: Deep Link Handler for `ardentforge://connect`
+
+**Modifies:** Deep link event listener (Tauri), TanStack Router (browser fallback)
+**Estimated effort:** 0.5 days
+**Dependencies:** Refactor B (QR generation must exist to produce links worth handling, though the handler itself is independent)
+
+### What exists today
+
+The `ardentforge://` custom URL scheme is registered via the Tauri deep-link plugin for Supabase auth redirects (OAuth callback flow). The app already has a listener for incoming deep links that routes auth callbacks. In browser mode, TanStack Router handles all routing.
+
+### What changes
+
+#### Ca. Tauri deep link listener extension
+
+Modify the existing deep link event listener to check the incoming URL path. The current listener handles auth callbacks. Add a branch: if the URL path is `/connect` (or the URL starts with `ardentforge://connect`), extract `url` and `key` query parameters and dispatch to the onboarding handler instead of the auth handler.
+
+| Incoming URL pattern | Handler |
+|----------------------|---------|
+| `ardentforge://connect?url=...&key=...` | **NEW** — onboarding handler (Ca) |
+| `ardentforge://...` (all other paths) | Existing auth redirect handler (unchanged) |
+
+**Onboarding handler logic:**
+
+1. Parse `url` and `key` from query params. If either is missing or malformed, show toast "Invalid invite link" and return.
+2. Check `configStore.hasConfig()`:
+ - **Not configured:** Navigate to `/setup`. Pass parsed `url` and `key` as route search params (or via Zustand transient state). The setup screen detects these, pre-populates the Advanced fields, and auto-triggers validation.
+ - **Configured, same instance:** Compare parsed `url` with `configStore.getConfig().supabaseUrl`. If match, show toast "Already connected to this server." Return.
+ - **Configured, different instance:** Navigate to Settings → Backend. Pre-populate the change form with the parsed values. The existing backend change flow runs (including Tauri data wipe confirmation per CF-3).
+
+#### Cb. Browser route for deep link fallback
+
+Add a TanStack Router route at `/connect` that handles the browser-mode equivalent. This route reads `url` and `key` from the URL search params, then runs the same logic as the Tauri handler: redirect to `/setup` with pre-populated values if unconfigured, or trigger the backend change flow if already configured.
+
+This route is primarily useful for development and testing. In production, browser users are more likely to use the setup screen's paste field (Refactor B) than to receive an `ardentforge://connect` URL that the browser can handle. But it completes the contract: the deep link format works everywhere.
+
+#### Cc. Setup screen: accept pre-populated values
+
+Modify the `/setup` route to check for incoming `url` and `key` values (via search params or transient store). If present:
+
+1. Expand the Advanced section.
+2. Pre-populate the Supabase URL and key fields.
+3. Auto-trigger validation.
+4. Show status feedback as normal.
+
+This is a small change to the existing setup component — add a `useEffect` or `onMount` that checks for pre-populated values and triggers the validation pipeline.
+
+### Done when
+
+- [ ] Tapping `ardentforge://connect?url=...&key=...` on a device with the app installed opens the app
+- [ ] Unconfigured app navigates to `/setup` with pre-populated and auto-validating fields
+- [ ] Already-configured app with same instance shows "Already connected" toast
+- [ ] Already-configured app with different instance triggers backend change flow
+- [ ] Malformed deep link shows "Invalid invite link" toast
+- [ ] Existing auth redirect deep links continue to work (no regression)
+- [ ] Browser route `/connect?url=...&key=...` mirrors Tauri behavior
+- [ ] Data wipe confirmation dialog appears for Tauri backend changes (existing behavior preserved)
+
+---
+
+## Dependency & Ordering Summary
+
+```
+REFACTOR A: Discovery + Server URL Input (1 day)
+ ├── Modifies: /setup route, Caddyfile
+ ├── Adds: src/lib/discovery.ts
+ └── No dependencies
+
+REFACTOR B: QR Generation + Scan (1 day)
+ ├── Modifies: Settings → Backend, /setup route
+ ├── Adds: qrcode.react, @tauri-apps/plugin-barcode-scanner
+ └── No strict dependencies (can parallel with A)
+
+REFACTOR C: Deep Link Handler (0.5 day)
+ ├── Modifies: deep link listener, TanStack Router
+ ├── Adds: /connect route
+ └── Soft dependency on B (needs links to exist)
+```
+
+Refactors A and B can be done in parallel. Refactor C should land last since it depends on having both the setup screen changes (A) and QR-generated links (B) in place to be testable end-to-end. Total effort is approximately 2.5 days.
+
+All three refactors leave the existing connection validator, config store, lazy Supabase client initialization, and root route guard completely untouched. The backend change flow (including Tauri data wipe confirmation) is reused as-is. No Supabase schema changes, no sync engine changes. The only Rust-side change is Refactor B registering the barcode scanner plugin in `lib.rs` (one line, handled by `bun tauri add`).
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 5d4bc01a..3eb10db5 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -203,6 +203,28 @@ Then re-run `docker compose up -d` for a clean start.
6. In the Supabase dashboard, go to Authentication > URL Configuration and add your deployed web app URL to the list of allowed redirect URLs.
+### Discovery Endpoint
+
+The discovery endpoint lets users connect to your instance by entering just the site URL in the app, instead of manually providing Supabase credentials. The app fetches `/.well-known/ardent-forge.json` automatically during setup.
+
+**Static host deployments** (Vercel, Netlify, Cloudflare Pages):
+
+Create a file at `public/.well-known/ardent-forge.json` in your project root:
+
+```json
+{
+ "version": "1",
+ "supabase_url": "https://.supabase.co",
+ "supabase_publishable_key": ""
+}
+```
+
+Replace `` and `` with the values from your Supabase dashboard (Settings > API). Your hosting provider will serve this file as a static asset alongside the rest of the build output.
+
+**Docker deployments**:
+
+The discovery endpoint is served automatically by Caddy using environment variables from your `.env` file. No manual setup is needed.
+
### Enabling Google OAuth
**Docker path**: Set `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in your `.env` file. The auth container picks these up automatically and configures the redirect URI as `${SITE_URL}/auth/v1/callback`. In the Google Cloud Console, add that same URL as an authorized redirect URI for your OAuth client.
@@ -214,11 +236,10 @@ Then re-run `docker compose up -d` for a clean start.
After deploying with either path:
1. Open the Ardent Forge app.
-2. Go to Settings > Backend.
-3. Enter your Supabase URL and publishable (anon) key:
+2. On the setup screen, enter your server address (e.g., `forge.example.com`) and tap Connect. The app discovers the backend credentials automatically.
+3. If discovery is unavailable (e.g., the static hosting provider does not serve the discovery file), tap "Manual configuration" and enter the Supabase URL and publishable key directly:
- **Docker path**: The Supabase URL is your `SITE_URL`. The anon key is the `ANON_KEY` value from your `.env` file.
- **Supabase Cloud path**: Both values are in the Supabase dashboard under Settings > API.
-4. Save. The app validates the connection and then connects to your self-hosted instance.
## Architecture Overview
@@ -233,3 +254,40 @@ The Docker path runs a self-contained Supabase stack behind a Caddy reverse prox
7. **Migration runner** is an init container that applies SQL migrations from the repository on startup, then exits. It uses the `SERVICE_ROLE_KEY` (which never leaves the Docker network) for DDL permissions.
8. **Web app** is a multi-stage Docker build: the first stage runs `bun install && bun run build`, the second copies the static output into nginx. No Node.js runtime in production.
9. **Supabase Studio** (optional) provides a web-based admin dashboard for inspecting and managing the database.
+
+## Remote Display
+
+The `/display` route provides a public, no-authentication TV display for gym environments. It shows a clock, today's scheduled sessions, and active workout progress.
+
+### Accessing the Display
+
+Navigate to `https:///display` on any browser (smart TV, tablet, etc.). No login is required.
+
+- **Default clock format:** 24-hour. Override with `?clock=12h` for 12-hour format.
+- **Example:** `https://your-app.example.com/display?clock=12h`
+
+### Edge Function: display-idle-snapshot
+
+The `display-idle-snapshot` Edge Function broadcasts scheduled session data to the display every 60 seconds. Without it, the display shows only a clock (no schedule information).
+
+The cron schedule is pre-configured in `supabase/config.toml`:
+
+```toml
+[functions.display-idle-snapshot]
+verify_jwt = false
+
+[functions.display-idle-snapshot.cron]
+schedule = "*/1 * * * *"
+```
+
+Deploy the function with:
+
+```bash
+bunx supabase functions deploy display-idle-snapshot
+```
+
+### Requirements
+
+- The Edge Function uses the `service_role` key (configured automatically via Supabase environment variables).
+- The `display-idle-snapshot` function must be deployed for schedule data to appear on the display.
+- No additional database migrations are required beyond the standard schema.
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
new file mode 100644
index 00000000..9ae5bf1b
--- /dev/null
+++ b/e2e/smoke.spec.ts
@@ -0,0 +1,20 @@
+import { test, expect } from '@playwright/test'
+
+const FAKE_CONFIG = JSON.stringify({
+ supabaseUrl: 'http://127.0.0.1:54321',
+ supabaseKey: 'fake-anon-key-for-e2e',
+})
+
+test('app loads without crashing', async ({ page }) => {
+ await page.goto('/')
+ await expect(page).toHaveTitle(/Ardent Forge/i)
+})
+
+test('auth page renders', async ({ page }) => {
+ // Seed localStorage so the root route guard allows navigation to /sign-in
+ // instead of redirecting to /setup (no stored config in a fresh browser).
+ await page.goto('/')
+ await page.evaluate((config) => localStorage.setItem('ardentforge:config', config), FAKE_CONFIG)
+ await page.goto('/sign-in')
+ await expect(page.locator('form')).toBeVisible()
+})
diff --git a/eslint.config.js b/eslint.config.js
index 9c9325d2..dcdf331e 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
- globalIgnores(['dist']),
+ globalIgnores(['dist', 'src-tauri/target']),
{
files: ['**/*.{ts,tsx}'],
extends: [
diff --git a/hooks/context-recovery/backup-core.ts b/hooks/context-recovery/backup-core.ts
index f261210a..61680157 100755
--- a/hooks/context-recovery/backup-core.ts
+++ b/hooks/context-recovery/backup-core.ts
@@ -1,11 +1,8 @@
-import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
-import { join, dirname } from "path";
-import { fileURLToPath } from "url";
+import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "fs";
+import { join } from "path";
import { homedir } from "os";
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-const projectDir = join(__dirname, "..", "..");
+const projectDir = process.cwd();
export interface TranscriptSummary {
userRequests: string[];
@@ -23,6 +20,7 @@ interface BackupState {
sessionId: string | null;
currentBackupPath: string | null;
lastUpdated: string | null;
+ lastBackupThreshold?: number | null;
}
function getStatePath(): string {
@@ -35,7 +33,7 @@ export function readState(): BackupState {
if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8"));
} catch { /* ignore */ }
return { sessionId: null, currentBackupPath: null, lastUpdated: null };
-}
+}
export function writeState(state: BackupState): void {
try {
@@ -99,21 +97,21 @@ export function parseTranscript(transcriptPath: string): TranscriptSummary | nul
summary.skillsLoaded = Array.from(seenSkills);
return summary;
} catch { return null; }
-}
+}
export function formatMarkdown(summary: TranscriptSummary, trigger: string, sessionId: string, contextPct?: number): string {
- const lines: string[] = [# Session Backup, "", **Session ID:** , **Trigger:** ];
- if (contextPct !== undefined) lines.push(**Context Remaining:** %);
- lines.push(**Generated:** );
- if (summary.sessionStart) lines.push(**Session Start:** );
+ const lines: string[] = [`# Session Backup`, "", `**Session ID:** ${sessionId}`, `**Trigger:** ${trigger}`];
+ if (contextPct !== undefined) lines.push(`**Context Remaining:** ${contextPct}%`);
+ lines.push(`**Generated:** ${new Date().toISOString()}`);
+ if (summary.sessionStart) lines.push(`**Session Start:** ${summary.sessionStart}`);
lines.push("");
- if (summary.userRequests.length) { lines.push("## User Requests"); summary.userRequests.forEach((r) => lines.push(- )); lines.push(""); }
- if (summary.filesModified.length) { lines.push("## Files Modified"); summary.filesModified.forEach((f) => lines.push(- C:\Users\rhamilton\workspace\cortex\hooks\context-recovery\backup-core.ts)); lines.push(""); }
- if (summary.tasksCreated.length) { lines.push("## Tasks Created"); summary.tasksCreated.forEach((t) => lines.push(- ****)); lines.push(""); }
- if (summary.tasksCompleted.length) { lines.push(## Tasks Completed: ); lines.push(""); }
- if (summary.subAgentCalls.length) { lines.push("## Sub-Agents Invoked"); summary.subAgentCalls.forEach((c) => lines.push(- ****: )); lines.push(""); }
- if (summary.skillsLoaded.length) { lines.push("## Skills Loaded"); summary.skillsLoaded.forEach((s) => lines.push(- )); lines.push(""); }
+ if (summary.userRequests.length) { lines.push("## User Requests"); summary.userRequests.forEach((r) => lines.push(`- ${r}`)); lines.push(""); }
+ if (summary.filesModified.length) { lines.push("## Files Modified"); summary.filesModified.forEach((f) => lines.push(`- \`${f}\``)); lines.push(""); }
+ if (summary.tasksCreated.length) { lines.push("## Tasks Created"); summary.tasksCreated.forEach((t) => lines.push(`- **${t.subject}**: ${t.description}`)); lines.push(""); }
+ if (summary.tasksCompleted.length) { lines.push(`## Tasks Completed: ${summary.tasksCompleted.length}`); lines.push(""); }
+ if (summary.subAgentCalls.length) { lines.push("## Sub-Agents Invoked"); summary.subAgentCalls.forEach((c) => lines.push(`- **${c.agent}**: ${c.description}`)); lines.push(""); }
+ if (summary.skillsLoaded.length) { lines.push("## Skills Loaded"); summary.skillsLoaded.forEach((s) => lines.push(`- ${s}`)); lines.push(""); }
// Include .cortex/session.md content if it exists
const sessionFile = join(projectDir, ".cortex", "session.md");
@@ -133,7 +131,7 @@ export function formatMarkdown(summary: TranscriptSummary, trigger: string, sess
if (existsSync(stepsPath)) {
const content = readFileSync(stepsPath, "utf-8");
if (content.includes("In progress")) {
- lines.push(## Active Steps.md: );
+ lines.push(`## Active Steps.md: ${feat}`);
const progressMatch = content.match(/## Progress[\s\S]*?(?=\n## )/);
if (progressMatch) lines.push(progressMatch[0]);
lines.push("");
@@ -145,7 +143,7 @@ export function formatMarkdown(summary: TranscriptSummary, trigger: string, sess
}
return lines.join("\n");
-}
+}
function getOrdinal(day: number): string {
if (day > 3 && day < 21) return "th";
@@ -157,7 +155,7 @@ function friendlyDate(d: Date): string {
const day = d.getDate();
let h = d.getHours(); const m = d.getMinutes().toString().padStart(2, "0");
const ampm = h >= 12 ? "pm" : "am"; h = h % 12 || 12;
- return ${day}----;
+ return `${months[d.getMonth()]} ${day}${getOrdinal(day)} ${h}:${m}${ampm}`;
}
export function saveBackup(markdown: string, existingPath?: string | null): { fullPath: string; relativePath: string } | null {
@@ -173,9 +171,9 @@ export function saveBackup(markdown: string, existingPath?: string | null): { fu
const existing = existsSync(backupDir) ? readdirSync(backupDir).filter((f) => f.endsWith(".md") && /^\d+-/.test(f)) : [];
const nums = existing.map((f) => parseInt(f.match(/^(\d+)-/)?.[1] ?? "0", 10));
const next = nums.length ? Math.max(...nums) + 1 : 1;
- const name = ${next}-backup-.md;
+ const name = `${next}-backup-${friendlyDate(new Date())}.md`;
const fullPath = join(backupDir, name);
- const relativePath = .cortex/backups/;
+ const relativePath = `.cortex/backups/${name}`;
writeFileSync(fullPath, markdown);
return { fullPath, relativePath };
@@ -187,7 +185,7 @@ export function findTranscript(sessionId: string): string | null {
const claudeDir = join(homedir(), ".claude", "projects");
if (!existsSync(claudeDir)) return null;
for (const dir of readdirSync(claudeDir)) {
- const p = join(claudeDir, dir, ${sessionId}.jsonl);
+ const p = join(claudeDir, dir, `${sessionId}.jsonl`);
if (existsSync(p)) return p;
}
return null;
@@ -209,4 +207,4 @@ export function runBackup(sessionId: string, trigger: string, transcriptPath?: s
writeState({ sessionId, currentBackupPath: result.relativePath, lastUpdated: new Date().toISOString() });
return result.relativePath;
-}
+}
diff --git a/hooks/context-recovery/conv-backup.ts b/hooks/context-recovery/conv-backup.ts
index deacd06d..07dc347a 100755
--- a/hooks/context-recovery/conv-backup.ts
+++ b/hooks/context-recovery/conv-backup.ts
@@ -1,4 +1,4 @@
-/**
+/**
* PreCompact hook: Backup session state before context compaction.
* Triggered by Claude Code before auto-compact or manual /compact.
*/
@@ -16,7 +16,7 @@ try {
const result = runBackup(
data.session_id,
- precompact_,
+ `precompact_${data.trigger}`,
data.transcript_path
);
@@ -25,7 +25,7 @@ try {
const output = {
hookSpecificOutput: {
hookEventName: "PreCompact",
- additionalContext: Session backup saved to . After compaction, read this file to restore context.,
+ additionalContext: `Session backup saved to ${result}. After compaction, read this file to restore context.`,
},
};
console.log(JSON.stringify(output));
@@ -35,4 +35,4 @@ try {
} catch (err) {
console.error("PreCompact backup error:", err);
process.exit(1);
-}
+}
diff --git a/hooks/context-recovery/statusline-monitor.ts b/hooks/context-recovery/statusline-monitor.ts
index d2872ca7..f8f0ca6d 100755
--- a/hooks/context-recovery/statusline-monitor.ts
+++ b/hooks/context-recovery/statusline-monitor.ts
@@ -1,16 +1,10 @@
-/**
+/**
* StatusLine hook: Monitors context usage and triggers backup at thresholds.
* Runs on every status line update from Claude Code.
*/
-import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
-import { join, dirname } from "path";
-import { fileURLToPath } from "url";
-import { homedir } from "os";
+import { readFileSync } from "fs";
import { runBackup, readState, writeState } from "./backup-core.ts";
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
-
interface StatusInput {
session_id: string;
transcript_path: string;
@@ -42,24 +36,24 @@ try {
if (shouldBackup && crossedThreshold !== null) {
const result = runBackup(
data.session_id,
- crossed_pct,
+ `crossed_${crossedThreshold}pct`,
data.transcript_path,
contextPct
);
if (result) {
const updatedState = readState();
- (updatedState as any).lastBackupThreshold = crossedThreshold;
+ updatedState.lastBackupThreshold = crossedThreshold;
writeState(updatedState);
}
}
// Output status line content
const icon = contextPct <= 5 ? "!!" : contextPct <= 15 ? "!" : "";
- console.log(Cortex %);
+ console.log(`Cortex ${icon} ${contextPct}%`);
process.exit(0);
} catch (err) {
console.error("StatusLine error:", err);
process.exit(1);
-}
+}
diff --git a/hooks/formatter/formatter.ts b/hooks/formatter/formatter.ts
index adc88ff7..613cbef6 100755
--- a/hooks/formatter/formatter.ts
+++ b/hooks/formatter/formatter.ts
@@ -1,64 +1,64 @@
-/**
+/**
* PostToolUse hook (Write|Edit): Auto-format files after modification.
* Runs appropriate formatter based on file extension.
*/
-import { readFileSync, existsSync } from "fs";
-import { extname } from "path";
-import { execSync } from "child_process";
+import { readFileSync, existsSync } from 'fs'
+import { extname } from 'path'
+import { execSync } from 'child_process'
interface PostToolInput {
- hook_event_name: string;
- tool_name: string;
- tool_input: { file_path?: string; content?: string };
- tool_response: { filePath?: string; success?: boolean };
+ hook_event_name: string
+ tool_name: string
+ tool_input: { file_path?: string; content?: string }
+ tool_response: { filePath?: string; success?: boolean }
}
try {
- const input = readFileSync(0, "utf-8");
- const data: PostToolInput = JSON.parse(input);
+ const input = readFileSync(0, 'utf-8')
+ const data: PostToolInput = JSON.parse(input)
- const filePath = data.tool_input?.file_path ?? data.tool_response?.filePath;
+ const filePath = data.tool_input?.file_path ?? data.tool_response?.filePath
if (!filePath || !existsSync(filePath)) {
- process.exit(0);
+ process.exit(0)
}
- const ext = extname(filePath).toLowerCase();
+ const ext = extname(filePath).toLowerCase()
// Map extensions to formatters
const formatters: Record = {
- ".ts": "prettier --write",
- ".tsx": "prettier --write",
- ".js": "prettier --write",
- ".jsx": "prettier --write",
- ".json": "prettier --write",
- ".css": "prettier --write",
- ".scss": "prettier --write",
- ".html": "prettier --write",
- ".md": "prettier --write",
- ".yaml": "prettier --write",
- ".yml": "prettier --write",
- ".py": "ruff format",
- ".rs": "rustfmt",
- ".kt": "ktlint -F",
- ".kts": "ktlint -F",
- ".vue": "prettier --write",
- ".svelte": "prettier --write",
- };
+ '.ts': 'prettier --write',
+ '.tsx': 'prettier --write',
+ '.js': 'prettier --write',
+ '.jsx': 'prettier --write',
+ '.json': 'prettier --write',
+ '.css': 'prettier --write',
+ '.scss': 'prettier --write',
+ '.html': 'prettier --write',
+ '.md': 'prettier --write',
+ '.yaml': 'prettier --write',
+ '.yml': 'prettier --write',
+ '.py': 'ruff format',
+ '.rs': 'rustfmt',
+ '.kt': 'ktlint -F',
+ '.kts': 'ktlint -F',
+ '.vue': 'prettier --write',
+ '.svelte': 'prettier --write',
+ }
- const formatter = formatters[ext];
+ const formatter = formatters[ext]
if (formatter) {
try {
- execSync(${formatter} "", {
+ execSync(`${formatter} "${filePath}"`, {
timeout: 10000,
- stdio: "pipe",
- });
+ stdio: 'pipe',
+ })
} catch {
// Formatter not installed or failed -- silently continue
}
}
- process.exit(0);
+ process.exit(0)
} catch (err) {
- // Non-blocking -- don't fail the tool use
- process.exit(0);
-}
+ console.error('[formatter] Hook failed:', err)
+ process.exit(0)
+}
diff --git a/hooks/skill-activation/agent-rules.json b/hooks/skill-activation/agent-rules.json
index 7fa9f058..5aab027d 100755
--- a/hooks/skill-activation/agent-rules.json
+++ b/hooks/skill-activation/agent-rules.json
@@ -1,4 +1,4 @@
-{
+{
"agents": {
"quality-engineer": {
"promptTriggers": {
diff --git a/hooks/skill-activation/skill-activation.ts b/hooks/skill-activation/skill-activation.ts
index 12f7aed6..aff16e25 100755
--- a/hooks/skill-activation/skill-activation.ts
+++ b/hooks/skill-activation/skill-activation.ts
@@ -1,4 +1,4 @@
-import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
+import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -25,13 +25,12 @@ interface SessionState {
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
-const projectDir = join(__dirname, "..", "..");
function main(): void {
const input = readFileSync(0, "utf-8");
const data = JSON.parse(input) as { prompt: string; session_id: string };
const prompt = data.prompt.toLowerCase();
- const sessionId = data.session_id;
+ const sessionId = data.session_id;
// Load rules
const skillRulesPath = join(__dirname, "skill-rules.json");
@@ -70,7 +69,7 @@ function main(): void {
type Match = { name: string; config: SkillConfig };
const matchedSkills: Match[] = [];
- const matchedAgents: Match[] = [];
+ const matchedAgents: Match[] = [];
// Match skills
for (const [name, config] of Object.entries(skillRules.skills ?? {})) {
@@ -114,7 +113,7 @@ function main(): void {
if (g.medium.length) { lines.push("SUGGESTED SKILLS:"); g.medium.forEach((s) => lines.push(" -> " + s.name)); lines.push(""); }
if (g.low.length) { lines.push("OPTIONAL SKILLS:"); g.low.forEach((s) => lines.push(" -> " + s.name)); lines.push(""); }
lines.push("ACTION: Use Skill tool BEFORE responding\n");
- }
+ }
if (matchedAgents.length > 0) {
lines.push("AGENT ACTIVATION CHECK\n");
@@ -145,4 +144,4 @@ try {
} catch (err) {
console.error("Skill activation error:", err);
process.exit(1);
-}
+}
diff --git a/hooks/skill-activation/skill-rules.json b/hooks/skill-activation/skill-rules.json
index d4d5f9a9..3fd53c04 100755
--- a/hooks/skill-activation/skill-rules.json
+++ b/hooks/skill-activation/skill-rules.json
@@ -1,4 +1,4 @@
-{
+{
"skills": {
"plan-spec": {
"promptTriggers": {
diff --git a/hooks/validators/validate-new-file.ts b/hooks/validators/validate-new-file.ts
index 50de550e..5f037500 100755
--- a/hooks/validators/validate-new-file.ts
+++ b/hooks/validators/validate-new-file.ts
@@ -1,4 +1,4 @@
-/**
+/**
* Stop hook validator: Ensures a new file was created in the expected directory.
* Used by /blueprint to verify Spec.md, Tech.md, or Steps.md was written.
*
@@ -20,7 +20,7 @@ if (!directory) {
}
if (!existsSync(directory)) {
- console.error(Directory does not exist: );
+ console.error(`Directory does not exist: ${directory}`);
process.exit(2);
}
@@ -46,8 +46,8 @@ function scanDir(dir: string): void {
scanDir(directory);
if (!found) {
- console.error(No new file was created in . The plan command must produce output files.);
+ console.error(`No new ${extension} file was created in ${directory}. The plan command must produce output files.`);
process.exit(2);
}
-process.exit(0);
+process.exit(0);
diff --git a/mise.toml b/mise.toml
index a94d1edc..a03b5563 100644
--- a/mise.toml
+++ b/mise.toml
@@ -1,2 +1,3 @@
[tools]
bun = "latest"
+java = "21"
diff --git a/package.json b/package.json
index 99be076a..fa8533fb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ardent-forge",
"private": true,
- "version": "0.8.0",
+ "version": "1.0.0-beta.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,7 +11,8 @@
"test": "vitest run",
"test:watch": "vitest",
"test:unit": "vitest run --environment node",
- "test:components": "vitest run src/components"
+ "test:components": "vitest run src/components",
+ "test:e2e": "bunx playwright test"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -26,27 +27,33 @@
"@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2.10.1",
+ "@tauri-apps/plugin-barcode-scanner": "^2.4.4",
"@tauri-apps/plugin-deep-link": "^2.4.7",
+ "@tauri-apps/plugin-opener": "^2.5.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hls.js": "^1.6.15",
"lucide-react": "^1.7.0",
"material-symbols": "^0.43.0",
+ "qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.72.0",
"recharts": "^3.8.1",
"shadcn": "^4.1.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tus-js-client": "^4.3.1",
"tw-animate-css": "^1.4.0",
+ "vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+ "@playwright/test": "^1.59.1",
"@tanstack/router-plugin": "^1.167.8",
"@tauri-apps/cli": "^2.10.1",
"@testing-library/jest-dom": "^6.9.1",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..9ae2fafb
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,28 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: 'html',
+ use: {
+ baseURL: 'http://localhost:4173',
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: {
+ command: 'bun run preview',
+ url: 'http://localhost:4173',
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ stdout: 'pipe',
+ stderr: 'pipe',
+ },
+})
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 2538f140..70e6f9f4 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -82,8 +82,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
-name = "app"
-version = "0.8.0"
+name = "ardent-forge"
+version = "1.0.0-beta.2"
dependencies = [
"chrono",
"futures-util",
@@ -94,9 +94,11 @@ dependencies = [
"sqlx",
"tauri",
"tauri-build",
+ "tauri-plugin-barcode-scanner",
"tauri-plugin-deep-link",
"tauri-plugin-log",
"tauri-plugin-notification",
+ "tauri-plugin-opener",
"tokio",
"tokio-tungstenite",
"url",
@@ -572,9 +574,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.58"
+version = "1.2.59"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
+checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -1235,9 +1237,9 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+checksum = "a043dc74da1e37d6afe657061213aa6f425f855399a11d3463c6ecccc4dfda1f"
[[package]]
name = "fdeflate"
@@ -1938,9 +1940,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
-version = "1.8.1"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
@@ -1951,7 +1953,6 @@ dependencies = [
"httparse",
"itoa",
"pin-project-lite",
- "pin-utils",
"smallvec",
"tokio",
"want",
@@ -2032,12 +2033,13 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
+ "utf8_iter",
"yoke",
"zerofrom",
"zerovec",
@@ -2045,9 +2047,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
@@ -2058,9 +2060,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -2072,15 +2074,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
@@ -2092,15 +2094,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.1.2"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
-version = "2.1.1"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -2157,9 +2159,9 @@ dependencies = [
[[package]]
name = "indexmap"
-version = "2.13.0"
+version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
@@ -2184,14 +2186,33 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
-version = "0.7.11"
+version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
+checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
]
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2277,9 +2298,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.92"
+version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
+checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
dependencies = [
"cfg-if",
"futures-util",
@@ -2328,7 +2349,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
dependencies = [
"cssparser 0.29.6",
"html5ever 0.29.1",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"selectors 0.24.0",
]
@@ -2373,9 +2394,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.183"
+version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libloading"
@@ -2424,9 +2445,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
@@ -2566,9 +2587,9 @@ dependencies = [
[[package]]
name = "muda"
-version = "0.17.1"
+version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a"
+checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
dependencies = [
"crossbeam-channel",
"dpi",
@@ -2629,9 +2650,9 @@ checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "notify-rust"
-version = "4.12.0"
+version = "4.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
+checksum = "7d782ebad628d1a24303e9a0aac4656115ceb482ba56c66c4af3d85530852d9a"
dependencies = [
"futures-lite",
"log",
@@ -2854,6 +2875,18 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "open"
+version = "5.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
+dependencies = [
+ "dunce",
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -2940,6 +2973,12 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -3148,12 +3187,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
[[package]]
name = "piper"
version = "0.2.5"
@@ -3205,7 +3238,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"quick-xml 0.38.4",
"serde",
"time",
@@ -3240,9 +3273,9 @@ dependencies = [
[[package]]
name = "potential_utf"
-version = "0.1.4"
+version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
@@ -3304,7 +3337,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit 0.25.8+spec-1.1.0",
+ "toml_edit 0.25.10+spec-1.1.0",
]
[[package]]
@@ -3802,9 +3835,9 @@ dependencies = [
[[package]]
name = "rustc-hash"
-version = "2.1.1"
+version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
@@ -4058,9 +4091,9 @@ dependencies = [
[[package]]
name = "semver"
-version = "1.0.27"
+version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
@@ -4154,9 +4187,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
-version = "1.1.0"
+version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde_core",
]
@@ -4183,7 +4216,7 @@ dependencies = [
"chrono",
"hex",
"indexmap 1.9.3",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"schemars 0.9.0",
"schemars 1.2.1",
"serde_core",
@@ -4440,7 +4473,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.5",
"hashlink",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"log",
"memchr",
"once_cell",
@@ -4933,11 +4966,25 @@ dependencies = [
"walkdir",
]
+[[package]]
+name = "tauri-plugin-barcode-scanner"
+version = "2.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "485cbcf227f04117e930be748ea71d835900466dcd1d455d5ec284d36107a305"
+dependencies = [
+ "log",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "tauri-plugin-deep-link"
-version = "2.4.7"
+version = "2.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94deb2e2e4641514ac496db2cddcfc850d6fc9d51ea17b82292a0490bd20ba5b"
+checksum = "3db49816aee496a9b200d55b55ab6ae73fd50847c79f2fabc7ee20871fa75c95"
dependencies = [
"dunce",
"plist",
@@ -4995,6 +5042,28 @@ dependencies = [
"url",
]
+[[package]]
+name = "tauri-plugin-opener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f"
+dependencies = [
+ "dunce",
+ "glob",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "open",
+ "schemars 0.8.22",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "url",
+ "windows",
+ "zbus",
+]
+
[[package]]
name = "tauri-runtime"
version = "2.10.1"
@@ -5225,9 +5294,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.2"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
@@ -5250,9 +5319,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.50.0"
+version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
+checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [
"bytes",
"libc",
@@ -5265,9 +5334,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.1"
+version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
@@ -5342,9 +5411,9 @@ version = "0.9.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
dependencies = [
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"serde_core",
- "serde_spanned 1.1.0",
+ "serde_spanned 1.1.1",
"toml_datetime 0.7.5+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -5371,9 +5440,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.1.0+spec-1.1.0"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
dependencies = [
"serde_core",
]
@@ -5384,7 +5453,7 @@ version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
@@ -5395,7 +5464,7 @@ version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.3",
@@ -5404,30 +5473,30 @@ dependencies = [
[[package]]
name = "toml_edit"
-version = "0.25.8+spec-1.1.0"
+version = "0.25.10+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
+checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b"
dependencies = [
- "indexmap 2.13.0",
- "toml_datetime 1.1.0+spec-1.1.0",
+ "indexmap 2.13.1",
+ "toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
- "winnow 1.0.0",
+ "winnow 1.0.1",
]
[[package]]
name = "toml_parser"
-version = "1.1.0+spec-1.1.0"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
dependencies = [
- "winnow 1.0.0",
+ "winnow 1.0.1",
]
[[package]]
name = "toml_writer"
-version = "1.1.0+spec-1.1.0"
+version = "1.1.1+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
+checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
[[package]]
name = "tower"
@@ -5817,9 +5886,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
-version = "0.2.115"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
+checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
dependencies = [
"cfg-if",
"once_cell",
@@ -5830,9 +5899,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
-version = "0.4.65"
+version = "0.4.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0"
+checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -5840,9 +5909,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.115"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
+checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5850,9 +5919,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.115"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
+checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
dependencies = [
"bumpalo",
"proc-macro2",
@@ -5863,9 +5932,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.115"
+version = "0.2.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
+checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
dependencies = [
"unicode-ident",
]
@@ -5887,7 +5956,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"wasm-encoder",
"wasmparser",
]
@@ -5913,15 +5982,15 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.0",
"hashbrown 0.15.5",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"semver",
]
[[package]]
name = "web-sys"
-version = "0.3.92"
+version = "0.3.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
+checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -6579,9 +6648,9 @@ dependencies = [
[[package]]
name = "winnow"
-version = "1.0.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
+checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
@@ -6624,7 +6693,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck 0.5.0",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
@@ -6655,7 +6724,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.0",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"log",
"serde",
"serde_derive",
@@ -6674,7 +6743,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
- "indexmap 2.13.0",
+ "indexmap 2.13.1",
"log",
"semver",
"serde",
@@ -6686,9 +6755,9 @@ dependencies = [
[[package]]
name = "writeable"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "wry"
@@ -6766,9 +6835,9 @@ dependencies = [
[[package]]
name = "yoke"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -6777,9 +6846,9 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.1"
+version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
@@ -6850,18 +6919,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.47"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.47"
+version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
@@ -6870,18 +6939,18 @@ dependencies = [
[[package]]
name = "zerofrom"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
-version = "0.1.6"
+version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
@@ -6897,9 +6966,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
@@ -6908,9 +6977,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.5"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
@@ -6919,9 +6988,9 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.2"
+version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 7675eff2..4fa8214e 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,15 +1,13 @@
[package]
-name = "app"
-version = "0.8.0"
-description = "A Tauri App"
-authors = ["you"]
-license = ""
-repository = ""
+name = "ardent-forge"
+version = "1.0.0-beta.2"
+description = "Fitness and workout tracking application"
+authors = ["Robert Hamilton"]
+license = "MIT"
+repository = "https://github.com/rghsoftware/ardent-forge"
edition = "2021"
rust-version = "1.77.2"
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
-
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@@ -36,6 +34,10 @@ url = "2"
tauri-plugin-notification = "2"
tauri-plugin-deep-link = "2"
tokio = { version = "1", features = ["time", "sync"] }
+tauri-plugin-opener = "2.5.3"
+
+[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
+tauri-plugin-barcode-scanner = "2.4.4"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros"] }
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
index c85f85db..6827aaa6 100644
--- a/src-tauri/capabilities/default.json
+++ b/src-tauri/capabilities/default.json
@@ -3,5 +3,15 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
- "permissions": ["core:default", "notification:default"]
+ "permissions": [
+ "core:default",
+ "notification:default",
+ "deep-link:allow-get-current",
+ "deep-link:allow-is-registered",
+ "deep-link:allow-register",
+ "deep-link:allow-unregister",
+ "opener:allow-open-url",
+ "opener:allow-default-urls",
+ "log:allow-log"
+ ]
}
diff --git a/src-tauri/capabilities/mobile.json b/src-tauri/capabilities/mobile.json
new file mode 100644
index 00000000..ba8c620b
--- /dev/null
+++ b/src-tauri/capabilities/mobile.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "../gen/schemas/mobile-schema.json",
+ "identifier": "mobile-capability",
+ "windows": ["main"],
+ "platforms": ["iOS", "android"],
+ "permissions": [
+ "core:default",
+ "notification:default",
+ "deep-link:allow-get-current",
+ "deep-link:allow-is-registered",
+ "deep-link:allow-register",
+ "deep-link:allow-unregister",
+ "opener:allow-open-url",
+ "opener:allow-default-urls",
+ "log:allow-log",
+ "barcode-scanner:default"
+ ]
+}
diff --git a/src-tauri/gen/android/app/build.gradle.kts b/src-tauri/gen/android/app/build.gradle.kts
index 7ebc7a23..f08d3815 100644
--- a/src-tauri/gen/android/app/build.gradle.kts
+++ b/src-tauri/gen/android/app/build.gradle.kts
@@ -1,70 +1,88 @@
import java.util.Properties
plugins {
- id("com.android.application")
- id("org.jetbrains.kotlin.android")
- id("rust")
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("rust")
}
-val tauriProperties = Properties().apply {
+val tauriProperties =
+ Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
- propFile.inputStream().use { load(it) }
+ propFile.inputStream().use { load(it) }
}
-}
+ }
android {
- compileSdk = 36
- namespace = "com.rghsoftware.ardentforge"
- defaultConfig {
- manifestPlaceholders["usesCleartextTraffic"] = "false"
- applicationId = "com.rghsoftware.ardentforge"
- minSdk = 24
- targetSdk = 36
- versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
- versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
- }
- buildTypes {
- getByName("debug") {
- manifestPlaceholders["usesCleartextTraffic"] = "true"
- isDebuggable = true
- isJniDebuggable = true
- isMinifyEnabled = false
- packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
- jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
- jniLibs.keepDebugSymbols.add("*/x86/*.so")
- jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
- }
- }
- getByName("release") {
- isMinifyEnabled = true
- proguardFiles(
- *fileTree(".") { include("**/*.pro") }
- .plus(getDefaultProguardFile("proguard-android-optimize.txt"))
- .toList().toTypedArray()
- )
- }
+ compileSdk = 36
+ namespace = "com.rghsoftware.ardentforge"
+ defaultConfig {
+ manifestPlaceholders["usesCleartextTraffic"] = "false"
+ applicationId = "com.rghsoftware.ardentforge"
+ minSdk = 24
+ targetSdk = 36
+ versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
+ versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
+ }
+ signingConfigs {
+ create("release") {
+ val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
+ if (keystorePath != null) {
+ storeFile = file(keystorePath)
+ storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
+ keyAlias = System.getenv("ANDROID_KEY_ALIAS")
+ keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
+ }
}
- kotlinOptions {
- jvmTarget = "1.8"
+ }
+ buildTypes {
+ getByName("debug") {
+ manifestPlaceholders["usesCleartextTraffic"] = "true"
+ isDebuggable = true
+ isJniDebuggable = true
+ isMinifyEnabled = false
+ packaging {
+ jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
+ jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
+ jniLibs.keepDebugSymbols.add("*/x86/*.so")
+ jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
+ }
}
- buildFeatures {
- buildConfig = true
+ getByName("release") {
+ val keystorePath = System.getenv("ANDROID_KEYSTORE_PATH")
+ if (keystorePath != null) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ isMinifyEnabled = true
+ proguardFiles(
+ *fileTree(".") { include("**/*.pro") }
+ .plus(getDefaultProguardFile("proguard-android-optimize.txt"))
+ .toList()
+ .toTypedArray(),
+ )
}
+ }
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+ buildFeatures {
+ buildConfig = true
+ }
}
rust {
- rootDirRel = "../../../"
+ rootDirRel = "../../../"
}
dependencies {
- implementation("androidx.webkit:webkit:1.14.0")
- implementation("androidx.appcompat:appcompat:1.7.1")
- implementation("androidx.activity:activity-ktx:1.10.1")
- implementation("com.google.android.material:material:1.12.0")
- testImplementation("junit:junit:4.13.2")
- androidTestImplementation("androidx.test.ext:junit:1.1.4")
- androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
+ implementation("androidx.webkit:webkit:1.14.0")
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("androidx.activity:activity-ktx:1.10.1")
+ implementation("com.google.android.material:material:1.12.0")
+ testImplementation("junit:junit:4.13.2")
+ androidTestImplementation("androidx.test.ext:junit:1.1.4")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
-apply(from = "tauri.build.gradle.kts")
\ No newline at end of file
+apply(from = "tauri.build.gradle.kts")
diff --git a/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
index 2bcf65ce..b909b77d 100644
--- a/src-tauri/gen/android/app/src/main/AndroidManifest.xml
+++ b/src-tauri/gen/android/app/src/main/AndroidManifest.xml
@@ -29,6 +29,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
index 28f1aa119119336781390c2531c381499576ed59..3975b9cacf69fcb447fa4662acd9e8292928f05f 100644
GIT binary patch
delta 721
zcmV;?0xtc;8|Vd)BYy&~NklaAhp~Efw1+0_%AGvuG;{CtOaJyrQFORD_sPhN=s5FquHSJj
z54k+FR-eF$t&e9rAjA_t0`?-EogMVQy6xW2h4(=w)7@h0-QcLJc)jqfp5J8k>}-e2
z8Az6sl1;RvG3U-@dV8J+%-x#lZE3)q)Hnfx<(73xg?|VHr8!qcM9q~}^GfUToqbyl
zE2YZ{R4INRw=e6G2r+dDa57X1!c;qz*DmV#H?4a?v?xmPmC->EU2%J?i}i_HC#LT$
zJ48~=$x{TT7-!oNvC&rgA+6v0wbqMD>S;=({2G0dAiPJ_l4&!KYy1F0Mc)FoX=4K(
z@t2~Ht$%4Y$5Si?$hn>t4~~s^(gUuRo(0z&2&Sh=@WQ(;k&QTm7Mvu_J05F-@3$Ar+8q^v}wK41|0im
z2S3}zcd%L9dTSTQV}P6Vegn1F6JT9;u_pm7l}gp}S^;!#7O<&+{5)u}9^YE`t@sUE
zIgZt-3Yq^217RQxgn=*+2EsrX2m@gt41|F&5DB(FrsE#>Tq>c%00000NkvXXu0mjf
D{MTMe
delta 3523
zcmV;!4LtJb1;iVWBYzExNkl60;tz`}6es*yz$?{TpxAAL;56=YB2}5vw}-Xo
znz@};Cnwtn5DGE4Rf51EAeHzB#c2>Y$ehTV;a96bs`5QW^FGR-yK+a#W+SUS+*7^d
zAUJ(N1qhPP934L4gE<{H5s@Q`lPdT(h?*fl-UL@kynmhngrYDl3yQ)NM56*k$;?Y6
zP#`;Zm2MjS+RqayEJAaEaJN|lS_T$96Y7G;U!gGCE*Q=-(W3XcI(rb=Owcl
zYGCT9uzztY7vA%!QYNba3PGr%O%RF9l+E9ku3Gzb$@{xGByDzC$*`KVktUhet(wJ?$5uzk=5&~|l2Ov%G
z@4olbpA4VGj`v0o_|g`J9ZFFWx(0OhHO?j%wIwuyLZqVy1`nD2kH`g>XfZ$#d1!A(
zPk&VJerVREk(hf4u9Jao{@vIg2^KxvM+GpPC~~G_XKOMAl~fOW&@q&{Rg_qjw+muK
zkQnlxjPkEwB?Q()ZE!8g0^aP*`W}a)BhB5nW|VJC&vG5izT!HUlk2{uG@>dx{TN39
zJboVE`!_XCms~7hdi2BCJ*j;HhtL|Tpnq_{vphYO%0R0neiQg}Y*?wtD+_}t;x*wkQMEJzUsNk!$tdD#Ah<`x5
z=}8`Tq7O@77PHf+i$>`$-Xa1HjRR3BHHC#ag3_LT-uCG5iI!gX#^*hgHi(e3Iq)Q&
zP)ZR1P>K%i8+8Bf<=u`D3BvUN69kT=-0$euy^}e1<_CH6foK8{kNRi_G*fr~RP|j_
zN??D6r)8D&>}=@;Dki$1pI`lw8N|Be%n9zP+YMd!GVmBh{bC+w|`^VM&2ISRw*s=FhwS`tbeRXbyho#
zFqk2SEUt^CbB|7z1m;qSS>nvRUeqE)s^Aq5CEEx2(})@Cn_r@oV5j%ZBJi02kYjsO
zVk9;z13(|nY~iCm`aHwhx8Fdeq*#WwC1`bQ2_U>9IK)p!IX@k3ywNhB@oJr6i2vOx
zR`re_x$NVwtbOkX07YH^Ab-NLaY9I_NxuGbZu$BZuR)P9s!SXl{Idhdzqbd7;br=Cv|I<}SC5))T<9&E4+e3a9la?J+8@d9Rwg-)G2
z&;IbR5s(2dP)foK@PR>G`Nq}ny5Q#4Xw$H_w%L!uYVPD8_@!`n@3;8
zhf8GXrh)~T7hE~|AXTUR8jQr~fMhHP&G>;UhB>3KupB7X7=quV6VQl34Vq))y0>{|
z`dhTD1Y4`9VGFR5!q;D%Q2E8DpK4Er^Gs3dO=PG{7+^+Lb!P8L=X2vHdHcGV$fr
zO4+(yrEAuGQ@Ra5ihtMneMSTG*nL66(5h>Pi6_}t-PvxBO3`;wL&=(}Mw%u=A%7N|
zUBIFbpO@jK#GWl=A(N)eFoHMLZ&{6xto3?~Ur*r3ubbVFzCo$zq3HhqH@lafYB%M#
znw{8mF{{)R-My=Q;3H{jb(KEB1qKN<2h(?zZ&(Gg$%~9qZ-1SnU|>)y3?&x1tTjZ?
zV^#G@C|VJU3I&!MCq92`&fyS*$G@JBc-znfiUyHZ$nNb2Dt7AX-S~jSH(t-YcHTaC
z=#wC&0t74TK`W$m)sp#jea{C#8#dttg$odXCJ0dJ2naHYG{IqsL(>Nx%pU;Cu_3$k
zy(t~$_3j-w2!AF+sx>sbK(_6#*m5P`lP{q0Y{CFGnhAKM7M-As$N4!NVDHC|jgGt0my{~U5SfuxmrpHvVF@I};EPxCINL{+dF|S8IEt!bx
z*uig%OmTIKf}97MgwKBE&CsbY&Zo|5%mOjTB_@EPjmdj{?0xQ&$1?SXN$V9Q0&v&_
z^NJUu7LOklK3%P*P(v`$DhzCm#J{%m#@zGQYp?&_Ru11N2rS5u&~PEp5e_SkK?|4>
z&xLcR4S&8e|Jt!6q$Gy?ecF7v@E68voC@=Rl$4d!a-t-J&snfSwJ4T{XniB|1s!bbn8jh8;`V=;)9E%Nx%|z8M@QwU;Xt
z8B0pW41`OjBZn$?jhuINoR6%x*Rg+9EG=*e_?bpNhZERDW@3>INVvsGqj9xpyP%@G1=?k
zt$#M+&AogYTyCDWsD{JUvS;RPfmR`un0aCfl}u%2^7YB{ua16dQSKDIzmR5cp7D*W
zoQ52zQ$(w)ib4A^CW;`;-Q$xCkH^*7Zcv`uQzy@quV49=)GmVKY<7_|n|Yy4m<52)
z;w6iw8BUnjYeBaosqxknYde!8=YlJ9-ha{?Gor^`==t$CrC;LUqxFs39Ycv>fuq)Z
zwkP&XK8rqd&J%n3CW~6PZJ&QPZ|IC2Y2(Lr+_2+^&R^_19kF=L7ITkYJsV!n8#Qrn
zH(U2Xy@OMU->pRc);BJi#vo`OiL`lc)|~D+#Q-A58*}s`>y~w$SrStcjP5MdbAP-3
z_JiktWnb!#;q?!5u)`6YHtw%NaEQI(h}&W-?&G?1|Lz^2`xF9}fI13*BcW5~ylt?q
zj#WnSGR{yYUC{ufA;Esbl|CJsOng9GLVWOu(PP^!T)e8|x5xACk3abwttfYDpJ%L<
zHvD`BaLL-O^$lPV16SK@16Y>;-G2cStgVilzdjgyI*;Lfk*=6+YwCmjbZr-InNq@U
zZ3^Iu$k(o9J4TJ24lAntmkJb%vpqO?m{L-BwKn^7mJy)jFADYU0YPcT5Ue_O{mA8;
zzO$zGe^{-mQdO7J!<#K)&4Il;%hrDIp3k1N9te#dqNo%X7Q^bv_CZ4*27foJaM<`D
zp1?;KXAzK37pBKsug9w
z(5i~6y1?;&6IPo!An7lCLw~2exG;R~!gWGuSbNw|fQ5kCfE@8b4pK00W*RKS2~A8I
zc&la$QS&wmw4DyF!|JW+w*G@30xH2qfhgeYz*{ULHZ>?pkDPbyyj*be1_1Pc_X?3B
zJH|+nQJo0JS5>Kv7x*t!tPhw?Na2Dx&sJ{R{E0?4Y?3Wi)8-Yq?teg0o!Y;%a&yq|
zwDD?nB~d*d^gK9NXtE4V4(dbFB^MSI{T8@jbkT}2*Ky#<1l8^G7A<;dY}KyqUlE++
z)s_Qj2A%x6xW0A6oO^fePzv&|S`w4{N$n$pkTnpnHV`qdm$F*70xt^LfYC=*wf-(j
zl3)wud8?Io9XYT&e}DGm$6Ut_9n&QFHUTt06ZLI6>NpjG+t`E0K0U?$*vLtydt%~v
z*jv_0!O!jh_q9P!t*mglPaRK(jfLgTj6L7`8Y*r?v`TYsX-D@3fV+a0UP&o1Q>23J
zj)P)t3z17uFHWWCm+Rh(IsbK^I(Zc8CBM$n==)hJ`NIz^20X)_G`D$jEofwSEE5gD
x=<}-98?>2^kvglZ@26?=@m;a>jL-l8002ovPDHLkV1l<&&ldmy
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
index 85d0c88af65bcef008faba29bfc02688883dda8c..d8206271795a5ae118106f584a0ab975ec1c8704 100644
GIT binary patch
literal 3593
zcmb`KS2!CE_s3Hr{Onab_AD_gVv8bH)oRTKMJX{t1wWL=s$DHgjTSX(uZq2@VviJ2
zG1{uVOHi$P{jdKw@5Os@&iOp&InTNJ-h2|w%@7Q&!nI%Iw~Kg=I|7mT0qJJm8IWZ78B++=#27&ny;r}ki1dfUPGwj4rLXyhr>
z^uZd)mu~@5Qxnm8t^z5k6^$@Uq8;STyiqK9ED$6dr1m_Q-5&L*%!jbF&>5Dx9EjN;
zD=lGGs}?@}?cN;R*uI|Ue$?6>_@w(tJvXDMoGHpO9LNrTCy6y=(MuG5_P<4aW+|+p
z1u*^U@Bc%QS9k=ok+j%3boEin#O1(4l5TGJ&-61vNz9mlWD?A^56xPBo+z?m7$2L@>x_|Q#
zIL(8u0Ovn~4E)$%HQy>BHnPkk|9PwHZTNm#_#_YwOa)dI#?pP=Si@PCe)tyHAb&~_
z;$YItx1M#o0R>)X@kjdUG{&z8+O`j^EAW+(3x9Rh*RNpJ7vHT!7v@-_tE)1
z-SCTQ-#~mj-ANv4f8*jkWMx>*L{g=7qQ^v_zK0qA?6OSLUunlGn5`>0%21zQ=b1bZioBmwJL+bK_z_GK?TUHCZgP-Lmi?$DdvYElQ286L6F
z8<7J#q?H5opy!;GSGKK|z>*R1il25{){FWw
z!WBI~Kq}3-OLNYNpv96QK;mwleKnwR{Hg8q8~wAj%m8i}&r%-){C$=0dvyV!0ffa~
zIj|KoAev_WO|-Ug|I5!0=r4*3pSQFqJ)JOVTGJ7AMPNx44(*ksw|hU;0yIk+_(Yzx
zT*jwd=#RW8-~)s(cCNVf10n&Z#f;9tvly{s169w`o9?6RUvpw6u~d^$LyjuyMW$b&
z>uu$&jJjdB7g8bK+jT+uS)Xg`efoW7zBHqm{L#lpnY1pS)TG@F+y0Fd0k_&WR{EDk
zi%=ni<$1!qr>;9$1ZbhS%INWD3{zziuGS!grrZh?m*$$_?Whg~va74q#aT^d);9XI
zG|L>f({}f0CX=(sn#lLe_nL@1f{#)M29X~7kd4v_{a1Nbb`xepDY3B*B@SCQIVV=B
zCu`Mm^WHw;=54O#$!Z7&_GS5bN4LZ+-5CKC2A_BIw5>SrV+O>#<~7U!H(P7{W!mee
z#_R`{Eu`bISW`sc5W2#eMta^3y?E3@WAJNQPvCP%H}R!JI0a%qE2D|hSd9CUR@qjA
z)3tAVa#gEz7SYq9(=4ZLb)hNuFS-S^=vQ3f^`}EKKF8}&c)z8bv?crWPJ-ax?#{v(Ly1{bSQ7k2vLg6^4mV()X>yC9a_t4?o1x3h;hF(72EF(2R
z_e!34_H-0c
zQ)i{d*aN4A?RDH>`&!Th|@yZjQ_|wVi<;mevBAgyyslfI>kZQ@CC=_Xa*mxE~nXSMKY5l8>PGv+YDimc8rX1yew?
zm^bbN+{6KPnr=f|wSB*S0X&-;LuRvM*AUPQNWsEuNxG4WhbA8TKlKE|ZfrS3HQ?zp
zXHEx~mU2ymgq8gw3_VI75UXysGbfS!9>J(hztrD%4Mo||5C!*CMjU2vN<5^QG(>P4
zGx}pvJ{5Td;R${**r%DWccx~ycv@O{G~DKT*ML8Y7{2;e&K=&mefRW#Myh3LHO+gcqiokc$I8hgHccK+EMhVK&my<@JB4i=7|FuC$
z6te`|9m7;!i=f^m)nTvF*UiV@X2H}$xc6%QCOqWT4w$T|E^z<-;s@*VzH102dF;BdBsL*FqX}vqD^@cElUEC^Ja=~gb;;m>Uyg5y+~CpUwxw{=2lS^Q
ziE`63=03fbc`sKuvS0gb%t~B4*WQo31FR>kQc~y8!glj$S|&u*P^k`+F8HV)M3TCgiH~#f``$ts39(-cC^zD
zCPT*+Yj+uMsBRV(5vyA2A6NxTp&Dnd~JNi{%SG^
z<>MEtp7lF<&^>T2=z;AwvBp3jN7EO~v@qJK1SD5lz0bGcm^aTo;lIJHD_g+xw=|v$
zIOOc1*e9bMd>@q&8`ui`Yw+%m69*)D*e5o#!+MoWZ`-fY47Z)|M(;e>M(N!TT@i(;
z@``QWxjm|~oN%wpm&COsuFo9*aif4HF1-C)OgOb!|EDOFG#{7u;XohDuj?`kib@wv
zQ}scP*ugRGIV}e1mbtqM?HG2M4c4LI+HZB!MOF1d*}Vs>xu>4nm^=COto^5sW)-5w
zZaQro9msc)-xMC*@5z>piS@qw=}%={DyISQ2l9ghib~F$a;CNqi*&6Ga&IV@Q$s;R
zQ0Ue?YXt>NjJ)`AIv0Es+5clH$@P{}fG|bGo!+0CF?A!3!EF_n{En(tUt&R1!&>be
z_Dq$<);NE~SV8`ZOj8nB<{Trcng^(ajbMmp9mt%gjrR$+X0;cCzS*{8Gis=M(9**T
zX5080J8%ur%fL=~{MHk;MKVeh+T@&!BaJnm5n=+%+9SeboKT-^{j)k}GZoNacDM%W
z-c&j2k6OB{70vTt&%f4MX%Q>e#f?3jJi;Tg&RF_IJirjwgV!r=#30!$PnDf86P&Mf
z=yVencxW#4`4%$pU~hoMP7&SWVU3b1zWC7^eZ#Pe7mK8WYv|1Q+D~*A_nsn7j#>9=
zRdj9UX7799n~kQtF|RpE588E7wjhS7kIfDH30~y$copHAKEJVUAntm&L
z!JYW$_ra5s^6)u0
zd#L&n+^&l`sYzm-<#-Wf2|SFDMvgWuV@t{CT858y!$ndP(uno%#I*jxJfHCA^vis4
z+Hb;o-ZXm3#`0A@Kd;?cJ7HUY;r@&L5erk2NMKav4pKlYDRgbWqWjD-R2#kL>@HPf
zr~19N*;O)5@UNeqGxk3OpRYy$
literal 14102
zcma)@W3whavxV>3wr$(CZQC|ybGB{Uwr$(CZJXzL|G}y1B;S*Cy4STT;Rc*Hw(lv#FpIm%kw(fKV=2pCMRD*lU-5pKhhYjx}DH
zxeeN_RZ^Qw&&+InZr?sROWF*H{l7wFanDFu0X}i++oyol%d*}h{0}3Jz~PfGKU{9mtYrn%+mJs8@1P$Run{hJF^IsZA^-+lKzX0`
zdDe%WR*`@WT2uhZ|5U=GS%?aELSHZE6mbzCCp%cl?yfW9htao3X-u!DhCyc)Z_=u?
zz#%GQk!fm|l4)z1i8R}Kwz!@{T3!TMr(;!$6UjS11Kl7bQ_v?FY?_dyzgGiPmz)lI
zv(}+IGx#sY{l#|Ya>x6w?R74n$Ua*91NX@tzyQ8mfpLWee2`AzDj4-ZRfqYd4c!Ar
zOEVtWL7KJNS$JJwhikLV%siw+v`_*fAc=AfBI6jcEs#=C@{4N-QYA5^^2>e5?+$r~
z*HBc2%jd9^*-N!s?|D9wp|57+QnlSNt6F2#{5m-JP0&dN3z@)3@WTmEj1_)ShIxx~
z-W(~G$I0M&Dv*1b+%VPlTqGgP_MZV3NW@U^Z(u>?i>%#vFXpob9pd;JR2fVRn^u5=
zL{-2!yiEtBlN~bCHJhCBt@0a>+BRJ*J`Nqx_!u-F7mzg{4JOF0`>zHD8!kD!2~{#ME=8t%|w8Ot5pzP+8Ql`
z?|qtl?p9)^IYX?6x*p#5n@VXFfdaejna2okRnKQj
zOf)-~0G=wE22Y~^3`JmAazGAQL>Axzh_h?W2WJ|6#_K!_JHI=hKWvr}))9`6*bJlKKd3mV{VI>M0nn-h><9xm3J)fX$
zUWE_}=HIm|wH0SFIN8iT%8i;__LU=Ikb4i}?FPO>3`G_ZN41df%-6q_>|EK{{OKwa
zXQ_z*Kcgmo{4irkKk|Y$y@sxbXGnSUE!Vs`X_>n%Jx(XlXG~y`zmtAdrgkxg-AIi+
z9$m*Y{Uf>5%M=lQKv5z+7;p+Y$b^D;L8iEqjY^W-V@sNnT*4*0ea-!ZHeChiheoeGrJfY|c$%ONL
zAiZS!pEch}`b?{qL1%Fs3~UwzZvqiAufv)uYrFJ0lMoe}d}ED2m6{OE93VlI6*-i`
z(o-$ruBijlP9INk~8!7<-dqQ?BWkq!sb#t59!+64SuqzOPY
z+AL!$F}}P>9r)#Ty#~Q;(2!GCC`U7(By0wTAIoXvh>gz>6o?t2lHUlHchXD{MpJ>2XP`(SFbDG(2?B26gzXq2lY@7zFI~A(
z{m-=H_4jrf%t9XtXnZKOs(jhBSXl{;i?eghCns#}_&R4fUvhA@HR;3Thh^}lMil2u
z!*58U7xs!%`!`Vf-}WNG#d)6B@7Lpv`dB0gK6F^v;bgDBZx%Txi-_oZ&{-hjRZUb2
z4FC>jJT;UFu3ud}o+DGmsLsARy3P4zLMuOh>A&n`(@c8l5Wo2IX*ncwH@G(0V!BBz
zUhcO;m5CYYn~fN)%KZ$5Vu4W)OH9TMW#5nI0eJLN6k4)@TSmG)TYlSXlzeP%VRl&zQ+=5
zOs)#%rs>>bGsd(8ObH-A_{0lz?G5%1y6xucU6U;@v67#ulzV@^&ruj#%cpIMm+FN((K=2|tn!gE^enlvsxbaz;ptUV_R
z>rDOj>n@E?+Go2G|j+lfM@Or}HUY8z!{|G*rM3Ld+R+C%8QZs?
zyNaHoz|x8=sGwNslGx;j{Km4+{l|q7F@K|8{~7^?;^LoDZ{FtPP+6?Z8CBkjs7~Iw
zO9pQQ8ao7q(2=dy+X8bw{+=+yxYIf`QX-~YMr~|q+5Pu~6Rtl-xb}nOyR69s@$>oTFRY4MF
zga{P^qHV?l{ePVS6gv@oYzBmyibT|#>D%yEuwA{aCrLJEPS-Cps;UogI}xOn{+}R>
zmlCcNR(j%_ZJ#5o9T?Vqa}hD&X1;ESp`Ga#nwWW$-btOLgo0;)0cHbrSeh!3NQ#tX
zcXpUI>se6jmo}XEta7IbF%&@+>FZ2wrIoJD7xjMyYkH2{
zVXvKsd7W2XnXb=HDifa`$fyEA;755Q9>9a5ayBLxPg_S*lH{}YQHZ}tK^?PovdoPh
zr{=h>eIhLcBm}^qx+G&c2C+`6#5bFp9xo3Q-|;yDALfHJP6gmwjD?@1t?vizS^2WvprnPkFW1@8jw0Zk+5sRaYOM$77U0=?@F}`S@Q7Eju8Qn9&3#7oa@oHbjn#dg}F8N^Ec#X-skziVitIgg7zD
zlN;|z?m66i|1~)FJsvTP2l(~*`PgRcOyQqBQN*>cXg6TB|?faM14Xo
zyY^!l&|7#pn9!5C7~gCD=<|Jw1!Wi4@5WZ!6OQLREkUHLq%NE
z;his+P91E4)Q!8#lngHix!X_-Ye~iSt3Fm0ViH`cG=Dxh1BMmiiw<&Vyp7HIm0djg
ztXipz1N#OLXj)HIQqIM#pO^UaRQ?ymeVQlg5N`A&OPqgoWcna_ws6
z3dWcz%byzdGJ2?HC7)5d)7bXa6Zn^DH<&PT*xXfLiP#|5-R>pN#v>)!f@pKdazKw)
z7^v^}^g={Ce^t$fLy?A(?`+Z9%1D^hr7rT*Ni}TUJT^}ygkOSM&-zR9<7{NFa5>~y
zaiH_y{e1w>)yp>SNp;y)wA_=!hjO7D3pS&wi_GtlBZ(+W_^{G+%5}{8?KE{Zo3rKc
z?5LDH3mOZPv6Z#>Tyegb?;(vZX2|@n>@2>Q)gyWPYQGMNbyZ-s{!t)TJ`6K$)I}l-
zklz~)x|v!OmDm&Iv}HKZ_M>Ph=;ljjE6>xS#^N0=q?!MAs#efJlLb^~+4uwW0JJWFW<}nTH
zj`=ioy$^{}->J1N%;%qPhh|*iP&kFp+wM^bOi7@R+B2E$L089lbrR-)w6PlD8cS!mQgWaS
z&e8wDcQLm=jgq35qnakO}AB_=~!-E(#89d74-P|r{ySZfaR#-W=ZE+}KYI36b
zQ7ng5^zo^i@^L1w(P42AI3BH8PhCII?O#JivuYAmG=eqR2dhly+++G7@Dbi+5P^5E
z6~r_0cDWgquG2T^iKx&nYm*s@!O;IW%HeN*b;Xl`NLNUaCM=LFdi6)JZ`NGC$LgY2
z?cA&(A5?I)Y!VbS8625f!p8xS2-n^r5OOcl%`g1~U{bNkJ^jZvN-qIHxh9kAx=2VN
zF{c1f7cRY|6J01Jz#u0%zGE0z(IPf<)b4mVll_f@Y
z#ANs&1YG5iL_!V1@gU5$T?f%Ey-y#bHO^|bZaDDC$rV_uY0X_ugUqH%gkJi_Rh2J|
z&5+1L?dvHscdzt=#iU}S2>#O`BW8PlSx;T0q|zNGuU~uu?Z7gd!nJf|lOCA7#y;s+3b~U+%O}>WAiIEhQDyi5}z99?0WQ{ove?qGIDMH(SM#9(bxqh8<>l
zy~D;%3Zf(u&1XUv2>S%j;&bjfUFC0p86d9cbLy=xB!L*mRi_Jb~A$xjcM
zznr5JqT0-phIn-8lPSG7!EC&}x8B(Otastw2Zr#Mc<{&`H6Vr6O#rywc%?vdF+>7`
zGH6A(+zleVuF8f)JmSP65a|~2GUrT;tXhtVYL(*yuIWMyo`9JDQ@tO(f2~hk+`kR1
zQmy^V$b}W?G32maJJ|~Ii#~Q|(z0{+;+t$VUll0!35Lu55RpRwrPLpakjS^0Iz(#n
z+e>5c9Ei@nVo@feA}J(A$VmQ-iI>!S;T)`Y{!9E>(oqn@TIO~ipI#8%ADL7tH5k=o
z6k)}iaxYJT)Yw+u`%vMRBX%!bcpcJyX>S2_VtwIs*l)73vP)Q9Fk04RdJ?lc0*-&?
z*#m6~k*(e!G4X!8Zi@1i{q|?p-T(%e;`(*Hsm})Zh{z#?yUoV%UL~-!jgI6jzmaQ<
z2hv!UtTgD6BPX;IU%{oZN6LOeP4z%d;kg
z>d&%x5tb-t(J<#b!gGLE_}gTTA%yCD4R_aAS7=^1=8`z%8j@cPMN-GUnR`R+i!f55
z9PvmEP_o4gp4XQ^OnBZ#41~CH6?JKX63WK3ZymtNo-1d#_4fr4aHtjnfuU@aKv2TU
z^px(L;k2WU#A!UMDG=~}R?7-qI9m=b0Fws%ovA94>d5I}o+ru4pNDA?YY*>WhJwJU
z0J6$Rl(E@{xFxOeDf3O5-dCuaX7tN?7-7(*y&|!&i`qTzOvcHT+FGYktVBlEsFXKY
z1Ka;Lj&*@U)g~i{WC-zhvGpfwIJz6WU|m<0ti8Ms)F}E>N8&`e53>Q$@dlekie;QS
zQ|qXR?bF~yh>*3qQAv?*@N>+s9|J^BLwBxPRQA!UT-Jl7c~N-2bf
z2pIlD47bmQtm3&IrU<5TMee>zQmH{UM
zf{@OULb|@xp?ethnX6TzrdDK`K;plQ1BGB@6bQA-{%2DwpgS$zLkJ$l6E#fbYDf!{
zcE}$LrgvYl?H!!A^A&gS(L-`hJPlLVQZlxy&0V^}A|*Zkn4SC6XYreR#7;uMWE}jA
z*d{)4?P?oS+E5eQ-x_Q-4+1R+D^j?U3N)7(l?hApt%=1tF32LxbX!WVr`Ze=ND;Q)
zZ)6IGr{Cw9ZFKgnu`hJS{xrGyuM&IVAf%h>A_FF(8p_wdh&7f-6WPpX+_NBCLKSo#
z2u4UXRZgs23WZGFhpSD-!n^rSa0$>)SBxa&s+uvr-{)CW=kT3JEeJyCy?tsRjp>D_
zU{hxo7Xp$FT3}tZ5X!~3b^C2)e(79}K2udpIx!w^HD+dL)GY|9!gBiV(Ngm#HL3{#
za+FIrA(Z}#0>w&LAk(fZjh4Ib221;%Gp$Z5`D!_o02(0G+0n!Q33uyRxl{FO=PF}V
zbm~ivCWUnp)y7y+Ik%MQ}&-!z&4@6lkpmCfzyT0wp~%a8MzX!M!%L1Z9ugAKgYc*)FErG1^r**J{mhf}`|L
za+GqK5h$W`Px2;sI4Kq2or_=-U5L1%`abq?nF;2dBCaeDA&(ictjUz*fO=UFI?G<^H
zaF~Wc_V~Rl*hI50TeokLhSQ?K{*{nTf
z7>GU`Jgct7@p0b#xKN)uCo;1>TmU7Jx_|8to13yMsCz4IKqv2ZIMXHBR=|}%0p8}c
zt>PRX7pft9zYQ|~hfedB4SI9^#(YA;20>t?3;C0NUptD2m|^awxJ?JS1EZr3iU;B-
z6P%<{V|MO~Pt(@53o$$M1$E+f#32ez43O6&sY-Ab)M-1V?Yz}$DEh)=)a*NP?<(qZ
zvi?<`^@3VQ0L8Wil5tngknq*gU~B%>{dY60g@z?$-cHYKp;0>o9Tv@w2{`>4f!Lax
z5ze6{RfuJ;j-*={e9&6N(Xb=7qM8ZlFN+8$lIX;r%r3(^V+Sl_V
z#M@CI)K8$;$d~;Z*zUb!Fnyd9Eps6t5LUXT$VVwk=lQZPc#PlM3{ewJLy<_Z_#24q
zE5bhlqfw*TVbN``Qy9JqBhyaCD9jU@$LejB>tirw9Lj@@R#W+Nlzu+CyX(_;ZMbdy
zOrbNDGq`%+tZZ#PG6KJO8$?Zxc%a`3?y8azeh3Zcq1KGq+6`Xk7Bxx$Pq3XvS)BH0$Q^d=(no(j*`@t^%yv)VQd9*m=ThWU2eThV
z4g-fNh9p8l5tAg^Hw~$&czT8h&}%!4Kv)hCpU$+*C3u)c9sr`8HHIV_^EXLLHQcBJ
z4oGwVZHPqa>5JGHA!rDE0OErBRQh_c}||&`lW_s
z!-J{BcOrTyf9d7;F;yX+U6AJ2By9Gs*{YnLZHOOPJO!QP_B!zRkU$+o!EQAV8{tp^-EI*%VKz|)<)2uZFrr0X*
z@}^h`yzDsBYbubjun9jLGS`17?FaN{>T$o>dJsUs
z_jKMa0hR%4$5Z{et%dqWh3WG?&4*lALMEZ>`=w^X=c|TR{hyS&15eV6&fm~~ZxIi<
z=}S+1|BRJ$j!em&-n&<5svwBGPZlC(W0R%u4y%&ur1I*3=G|_>yKnapMG$B+(Bj1L
zk+nd>1S^YgdtdzM55*d;}@cor`|{fgt3GMmN*{c
zj0%S71cAawn3w3^Fm6mAdhWWjqB%VRDl;64;I(Q*Uj0zLup2_;OKE`r2vgE~0!($@|z%Y$8<*Rh|HKTR@i8NiAR6vm&
zetO+qUXL5S4AybktMgK2_c6(LRJIzwTpHeJ78kc_F&X@HT@zTA(v=SIxA`
zST#$cf!EjVJR{@mv@fFge8QChjekB_WE9QTd>P~h(B>HuKS5_
zhxwC3|EG`tIoKArufB%Lv>54Yi{(1z)12k=?+$d =54ZI4k5PL=h@0x#zLv*qh$)WWKvCOGl-7hAA9t!m&~E
zhph1|+q$^iV6F_fA@9zWI{09T--Um)6;1T4QSLHyW~zZg3i6a0WlB0C2{k=G
z$89^_TFjT)@RlJmAjEwDl&d+r_|qlH!3D6JFB-S+vHWuSvE+ulfI&(F{{8U)>1B7L
z;-eRJ_~`ArP1W#Sw~mJ30f5MNf&p!5c)Di!AI&KUjWmzf6kf>Q1YdjZ!{{Z*zD|C}$9X9P{%0b0qdqHtj)x6m08u
z516qr(NXvixG=GxQtey0618f2)r!jhENTvr%`~WrMM0Wtu9%oh$Nj{nQ&{yg-76
zKrivA?B#`ozl)}p>N+dT_aSK4Ro!9Q#7^x+1@`n1XixqMNXQ_L_XQ7bwVqFX)JH7m
z!TY8nfkQ{{9}-w{EXr1)`;&=sC4lRp6lxGr+d=;YK6Rk3^~t@8FB`cKITG
z%1xA)ZnEP$5qw-BU3%~)3W$^VV0W+8IxSZLcj#7P#|9!Kn4z?=Few8nqo~OmyiydQ1w+W1
za!dY52QA$OS%ixbn(N~yE$xnA5kmL4`4=-4jM)r|0tj>$u8T80@8H`OAj{f9gO;F1
zBCsbHu$iOjnjcF>`{k^1Z`&zc-@?OsJP-rdn>frfotC2j03qIVf8jEYq%;Z}!9_@j
zTI)Y3Rtt^`P|Q;(Xoh3^n)|RO5Re^&wY!98?A*!=2`%T0HqlL2VK^u?3?RapvkjTHa!Tku`5vBpxKJ
z(YpM8)i8oIHg`jik^vgJw6$F(4>OH;KX+@ZAlBEEjTyu(AB;x=Pwziloo-3i4XeKo
zfWO{;s*|OrYqTEO3ohYUU6r6U{WqiScF%58*+Gy{`8mSwCa3njKDYm(@40;m3ZQF_3q~ktjH2MVa~@*=e4B<>ep?M
z<9>hTO<1W??0&rl@TEoP)7w73?)ucKB;Byd)j`2B`?<(Zn^EC%ceQ+suJ@k7lvDNV
zD&^L-XrAD>GX2-|0aP@tk#p{hGQxB_PxRSMcQprZc3))-)BhIKE2E(Bh5QNoFs3#o
zCACsDC55b_2AnI#eZ4GY
z4KqZRse$+p$dzEBwFdq&@Wt5I{v-J{hpRIRVI&Ckc1aNJ-&bkvxqFG9e5{%5eOd%H
z<*tLn43vnkfM0<}cy6Md(XN{%MaAVF2q!zR4+|Rlk=!HJkUEI%ASxV}0bOtS%(1v=kRdpD51f%X(jh
z-W%B5Nlv3+dp*FFHXOgZ#mY=!HeO%(_PG3o6EUX1Gl=Q^?{>Z|A=VsFcKdgXMx(lK
zYl@Pcu1YEVh0EML%^oc+0#v3@ihWBvBwCSdl*eFQuko_SSijsvx$M18f=F*b+hEoO
z#}I(j`bj=}y5e`9mfU|JD(f#BUUqvFJIAD4_}E{0P#nH%N&RIbfRHZ-)Sgy#w%Tk3q*UyNP`xOE1d2*bj|Hze
za2xvz8|xTMrK7vQu0)T*@BUo|MZo6+T4{&uk$>S%0PYPHZ}$JOUZ7a_e#%Xx1Nt?L
zj=#ld5ccrQG3`WX)<_J8GWc^C>CBaE`-qmr%LqZUA9Lsi<9Ssm1SZ5H#$YTSfuviP
zgQ=K{G~1(#9VO)A`zMwThX+YLjJx&~a9VN9U4Nzw9q;I$O;jgc{qM5mJJi0bLORiW
z_iJsbW19d}MN`&jx>z-3_u*eR%r!1eO(_QNzVc$2_2P!+=#R&v%Ywf*>)Iqa3hSy2
z^&f_Hnk1OpY!;jzc|NbLMw6e5isD{7$1il6J=Rev>p~wMJe`}%Y25BRZd34(9COX~
z60oY3w)dx7H>-#wfu}(0pP$b=2M22$c%Ly*&_Xa61`Y0_ANs>A^p!?Nmxt|Dk|+#b
zQ3-pml#;TuFEiY+G%-IcP>xDY1DC71o9$k6-C=ZmKHdA}(OnC?+MO6qk>KB+lO;&D
z9WFx$snuj{T<+E@H}w5*(B`wj6Df{HsUX!iaTPeGpT9XO}wGA7D<1IHesf&
z6f3D>+FI@P;IF=RGlF-2fjmoU77Uad5mvQ-1xx=@oq;PDY4v`jic6ot0+)qQ3874!
z-G!NLbL=VpI-T0jzoTnm0dTb0}J->Kg`%9G2jShbcO6#26>F
z!X=PYsU}sMJ0>bs)CeHT6?dmTHyA?y#Xmc1{EVpkfiqlAgRe>IWsMh&z;_n_zezp|
z_qT`rcW6%lMdm7r(FW1VbA!PV;!l-ReC(casCEOBb{GBKq7@|Z$kS9#<5g*sb}3IWmvt8j)Z(WF2ReFW
zgylsjKPrzUlRQL9r-S~*@?3e?$8bzv5==2(+@$6
zGcl%StIQnN*IshdS-pme`$GC3E6Y_wf#a>(9;0xw0D(eT7U=zK>4}-H|JaOy+1Ie}
zEr?6z=~pP9X#F^OQHxBw-2;Ctzw0e>)jxDR%XtLwhU}Wbvv`#4IHkF~hx5@Awsy)F
zKH$$aIvkFLg`cW^oh({SXYOzf_X^&?1efsSjH#)II)
z^5Jaqq;wNNE?dYPPo~1m@@Y@m9$jbt)@a3z5vqEC-&jyz8r6CgPxieisYEDo3J0K*
z)9%vg+}FB}h;z1H#a~nuC{IVNr~$9yP{e#FrRWY~dptF}!|sQhOit(U%(`B}c8#3j
z`!i^Vz=}CED-3CU5A#8&5h%30ReM9{wfB-*AA@m5&>_llL6H%G*1mBSn2xW}`M1{}
zqQ~qRhk6X?T(GVfzl)f;UUyTg-L-W1!Gob3k)iFg*mp(o+mj2FWQ)TaR3bk3_$dTDnYQ$T9E5%`b
z4E%1)z+KF}9TgT{RLOK(L1-KCRO*&~Ji9@#-kw)>u%n3@yMxe@qgzS!mF#l|?In~9fR$n|iya1aK&r~MyQ_
z0@B}U-`aT!8Yvwe^7TdR|<&EpT
zOCTw{Vv*3_x50R&)Ol4XiWOZTTCEAehSU5^w7#!#%GP11bocWNRrZ(JHu&Uuqy8>&
zDSxuc$Ib<+Jcs{Zh4cF?cesy;_!54?`0D3?fxV9ALf1dnpA3$#3&}J))Vh74s6#i>
zS{d4vrMJjX*&ygE$7;>`bQe8=?)$&mOC8PB4QcJXIV_%U@1qGLJ9i~on)#9Q0&t@|
z{lQSEG671OEk9@1hob>KGHY9`fLyC?U`6l<0ts=r6vfW&v6aqWQ!AI`f#u?Bdba%`
z9Mf9yrdMp9iGFsa3w8m6h(-XDQM0
zks*t1Bo2mge7g-Fh2d@8$sbjTv^D=plvsHx#{Imk+prt#*W)uwUCx4-pdLYTB4?q
zEyH8f4OOD33UAFcXsZ5_Dji+IfD)G@8SuP;n95mk(xp9Kn$PYbG{cf9Nhz55w^2A^
zSs}|z$g<&gefK?A4iy_xDlF|1@${ax8x0j|1P_a>09shm$9K5x?Xa=pE^Q^sY
z%C<(61c#bLSuBbggx*Il)OCjm?d985Zfl2xI;7%2WbSvnIMrZO=M&*CucjFYwmk&0FZ2_9dyMHuni!ZJekX
zG_3$46GpJ*K<3AQ{Z-udj3cpHXIU
zMh-G4rD8f;Qs}np?(e*Q&}|L{K`hOZ>#05Er)Xk*TC<*B>&p~MM&hDWK~?7_YwNEV
zh>^BYLJ<&`A-46NX^xZ5bAv_mWeD%GKuYjrkUvKizCJ&Qh=WNS5M?%SBvh>`XqR9)V@wK%^`_wH(#~flX|V;{^=zRKlZSBMMpjAu
z?L7KOCG1Xv5?m2)b%d39a>wJGZ{<7+BRcyi1fAA0P2dSu#%mY>#|#T_r}XQ1yH+%h
zjCYW@=)~An(vjVf8~gBO>CK1sXl8B+ku@zxku&AnWT3ddNzbqN{`^F0^WbP~*!2Np
z+J6RCa694>jORirYqJqWrspwD@h?}Zil=r%K)m
z?I-Q_&^aX^uYj(Rkw6TOK+{)Pc-E*4LAyyx3d83(ukFzjmf5tmN8vy6G!*dHMmnn7
zxNe@T@_Db%KW4G-d)v!lgfkWEhfBo79d6Jc1C1nLjE@qZyJ7ObM-^!FLA0^r#Jf?3
zlJ=gn(8Hz?Xk+Ny-WDC$sGE_*Rr^?uZUTw9@yd~x?#$?Vp}Li_Qx?A7BNA>YCr+|j
zjagbyIqRbty?56Xr%)xTH-^w&nPBT48(l`idQI<~^ijwBV_X|8I0@z(~DMkU`|&
zu3$}~6hbTM!hT@zO?4)mvza45#|HdZuy`!s@;m2%BZ4+o(16-%Z4%i?AgZNw>2nlA
zCj5c*FIR@0`&nt~d0wnPJ(x&u|6mkqibrAo!zjt=IAGz&ASR7yTycg$c?nt9)R>sf
h=>H#uJEOnA6UP3ZI|gE%|NVCZNQ%md)Cd^_{2vweVnP4_
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
index 28f1aa119119336781390c2531c381499576ed59..be974241d1b0c8af14c9c45b01740771c0fc37ad 100644
GIT binary patch
delta 854
zcmV-c1F8JP8|(&cK-1WEZ;#5rrLs4oQRsMP?VfWI_an9X!~fx^xJvgO?>JR7%a(#ny2D`gYwnZ~BdY
zU@zU?!b|4CW0;xmZ)QHf-*4v4GhhcihKA4(8bU*82o0ejG=Ic@4B-L5fAtjsq|@`E
zjzq~5B8Mc&%Lf9yx@co~!#fffpyVJ?kh^RZ0uPg`3mK8!*1IMW_c&sekj6F27TJ5~
z5vIIrNynka
z2<8*%_)OY_oTyTp0=>__U_PfF|$5LlQq(5
zHRnUpmDsr=5(kT$xovW}mrkKNi8AVAxC=aj_Z$m-f}&;kID{mVQPpoU(v{e`0^-CK
zfT4b1_kSnT)8OaLqIg+6bWwvz@OUs`Z5-zuW}58_?k4W
zZ?<5MRVk@%aQO?61(i(cm7QxKk~B(u`wEfHPk+Yq>6UYuer~k1XVh(RM1)?};p}
zQ9Jc3nbId2xbFfuKu+nUl=|2isXXoX-2}t;LNM_hZQh->3SBe#!o!o}D{I?2r{-l&
zLa2q^rF^Nw6%$8eec;W;c$iJasSJf_+|q6
zOHYtiIp$*Jg6LwK0OtVs1u9QbyBG=zrG5E?>5Xb26V
gAvA=B*vSxo0Rn?ch*E;BbN~PV07*qoM6N<$f`!JIjQ{`u
delta 3523
zcmV;!4LtJf2E-eXBYzExNkl60;tz`}6es*yz$?{TpxAAL;56=YB2}5vw}-Xo
znz@};Cnwtn5DGE4Rf51EAeHzB#c2>Y$ehTV;a96bs`5QW^FGR-yK+a#W+SUS+*7^d
zAUJ(N1qhPP934L4gE<{H5s@Q`lPdT(h?*fl-UL@kynmhngrYDl3yQ)NM56*k$;?Y6
zP#`;Zm2MjS+RqayEJAaEaJN|lS_T$96Y7G;U!gGCE*Q=-(W3XcI(rb=Owcl
zYGCT9uzztY7vA%!QYNba3PGr%O%RF9l+E9ku3Gzb$@{xGByDzC$*`KVktUhet(wJ?$5uzk=5&~|l2Ov%G
z@4olbpA4VGj`v0o_|g`J9ZFFWx(0OhHO?j%wIwuyLZqVy1`nD2kH`g>XfZ$#d1!A(
zPk&VJerVREk(hf4u9Jao{@vIg2^KxvM+GpPC~~G_XKOMAl~fOW&@q&{Rg_qjw+muK
zkQnlxjPkEwB?Q()ZE!8g0^aP*`W}a)BhB5nW|VJC&vG5izT!HUlk2{uG@>dx{TN39
zJboVE`!_XCms~7hdi2BCJ*j;HhtL|Tpnq_{vphYO%0R0neiQg}Y*?wtD+_}t;x*wkQMEJzUsNk!$tdD#Ah<`x5
z=}8`Tq7O@77PHf+i$>`$-Xa1HjRR3BHHC#ag3_LT-uCG5iI!gX#^*hgHi(e3Iq)Q&
zP)ZR1P>K%i8+8Bf<=u`D3BvUN69kT=-0$euy^}e1<_CH6foK8{kNRi_G*fr~RP|j_
zN??D6r)8D&>}=@;Dki$1pI`lw8N|Be%n9zP+YMd!GVmBh{bC+w|`^VM&2ISRw*s=FhwS`tbeRXbyho#
zFqk2SEUt^CbB|7z1m;qSS>nvRUeqE)s^Aq5CEEx2(})@Cn_r@oV5j%ZBJi02kYjsO
zVk9;z13(|nY~iCm`aHwhx8Fdeq*#WwC1`bQ2_U>9IK)p!IX@k3ywNhB@oJr6i2vOx
zR`re_x$NVwtbOkX07YH^Ab-NLaY9I_NxuGbZu$BZuR)P9s!SXl{Idhdzqbd7;br=Cv|I<}SC5))T<9&E4+e3a9la?J+8@d9Rwg-)G2
z&;IbR5s(2dP)foK@PR>G`Nq}ny5Q#4Xw$H_w%L!uYVPD8_@!`n@3;8
zhf8GXrh)~T7hE~|AXTUR8jQr~fMhHP&G>;UhB>3KupB7X7=quV6VQl34Vq))y0>{|
z`dhTD1Y4`9VGFR5!q;D%Q2E8DpK4Er^Gs3dO=PG{7+^+Lb!P8L=X2vHdHcGV$fr
zO4+(yrEAuGQ@Ra5ihtMneMSTG*nL66(5h>Pi6_}t-PvxBO3`;wL&=(}Mw%u=A%7N|
zUBIFbpO@jK#GWl=A(N)eFoHMLZ&{6xto3?~Ur*r3ubbVFzCo$zq3HhqH@lafYB%M#
znw{8mF{{)R-My=Q;3H{jb(KEB1qKN<2h(?zZ&(Gg$%~9qZ-1SnU|>)y3?&x1tTjZ?
zV^#G@C|VJU3I&!MCq92`&fyS*$G@JBc-znfiUyHZ$nNb2Dt7AX-S~jSH(t-YcHTaC
z=#wC&0t74TK`W$m)sp#jea{C#8#dttg$odXCJ0dJ2naHYG{IqsL(>Nx%pU;Cu_3$k
zy(t~$_3j-w2!AF+sx>sbK(_6#*m5P`lP{q0Y{CFGnhAKM7M-As$N4!NVDHC|jgGt0my{~U5SfuxmrpHvVF@I};EPxCINL{+dF|S8IEt!bx
z*uig%OmTIKf}97MgwKBE&CsbY&Zo|5%mOjTB_@EPjmdj{?0xQ&$1?SXN$V9Q0&v&_
z^NJUu7LOklK3%P*P(v`$DhzCm#J{%m#@zGQYp?&_Ru11N2rS5u&~PEp5e_SkK?|4>
z&xLcR4S&8e|Jt!6q$Gy?ecF7v@E68voC@=Rl$4d!a-t-J&snfSwJ4T{XniB|1s!bbn8jh8;`V=;)9E%Nx%|z8M@QwU;Xt
z8B0pW41`OjBZn$?jhuINoR6%x*Rg+9EG=*e_?bpNhZERDW@3>INVvsGqj9xpyP%@G1=?k
zt$#M+&AogYTyCDWsD{JUvS;RPfmR`un0aCfl}u%2^7YB{ua16dQSKDIzmR5cp7D*W
zoQ52zQ$(w)ib4A^CW;`;-Q$xCkH^*7Zcv`uQzy@quV49=)GmVKY<7_|n|Yy4m<52)
z;w6iw8BUnjYeBaosqxknYde!8=YlJ9-ha{?Gor^`==t$CrC;LUqxFs39Ycv>fuq)Z
zwkP&XK8rqd&J%n3CW~6PZJ&QPZ|IC2Y2(Lr+_2+^&R^_19kF=L7ITkYJsV!n8#Qrn
zH(U2Xy@OMU->pRc);BJi#vo`OiL`lc)|~D+#Q-A58*}s`>y~w$SrStcjP5MdbAP-3
z_JiktWnb!#;q?!5u)`6YHtw%NaEQI(h}&W-?&G?1|Lz^2`xF9}fI13*BcW5~ylt?q
zj#WnSGR{yYUC{ufA;Esbl|CJsOng9GLVWOu(PP^!T)e8|x5xACk3abwttfYDpJ%L<
zHvD`BaLL-O^$lPV16SK@16Y>;-G2cStgVilzdjgyI*;Lfk*=6+YwCmjbZr-InNq@U
zZ3^Iu$k(o9J4TJ24lAntmkJb%vpqO?m{L-BwKn^7mJy)jFADYU0YPcT5Ue_O{mA8;
zzO$zGe^{-mQdO7J!<#K)&4Il;%hrDIp3k1N9te#dqNo%X7Q^bv_CZ4*27foJaM<`D
zp1?;KXAzK37pBKsug9w
z(5i~6y1?;&6IPo!An7lCLw~2exG;R~!gWGuSbNw|fQ5kCfE@8b4pK00W*RKS2~A8I
zc&la$QS&wmw4DyF!|JW+w*G@30xH2qfhgeYz*{ULHZ>?pkDPbyyj*be1_1Pc_X?3B
zJH|+nQJo0JS5>Kv7x*t!tPhw?Na2Dx&sJ{R{E0?4Y?3Wi)8-Yq?teg0o!Y;%a&yq|
zwDD?nB~d*d^gK9NXtE4V4(dbFB^MSI{T8@jbkT}2*Ky#<1l8^G7A<;dY}KyqUlE++
z)s_Qj2A%x6xW0A6oO^fePzv&|S`w4{N$n$pkTnpnHV`qdm$F*70xt^LfYC=*wf-(j
zl3)wud8?Io9XYT&e}DGm$6Ut_9n&QFHUTt06ZLI6>NpjG+t`E0K0U?$*vLtydt%~v
z*jv_0!O!jh_q9P!t*mglPaRK(jfLgTj6L7`8Y*r?v`TYsX-D@3fV+a0UP&o1Q>23J
zj)P)t3z17uFHWWCm+Rh(IsbK^I(Zc8CBM$n==)hJ`NIz^20X)_G`D$jEofwSEE5gD
x=<}-98?>2^kvglZ@26?=@m;a>jL-l8002ovPDHLkV1oQ0&l~^%
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
index 73e48dbfb7dc31ed1e951b77820af88b93fcc418..cbd269db584fdaeea4502a7ce30f8af0e9af01a4 100644
GIT binary patch
delta 719
zcmV;=0xC;$bZ02F`%2!F4=ZUZ$V+6s`a$vpOEjg%OdEFESc
zM(6sov2I@*BY(3NKv9gk%bu4Kwr)_q7>O0Q7W5MXJ1H1+VC=oKjf^iYoN7;8kdGWo
znbi3<4L*_D#P0B(rnpIu+YE9s4VQ)<{J14UOdo4Kx3LNPECZ%$F28TySuRJagl0sR
zgQ6Ua=1dIi%6`irivYao(TN=w{VDR$Go*||r*8wxzkhrpQ9_xZzdXMr&VFKW^wNm1
z;xVkrM~q7xc^Y)0`!)?ekpMU!^yBZ=ZINUjN764Q-q0vi#Jj9Q5Moj$bccMK2A@cj
z(0ot|JqZ$rO?V|{oQbK)A>v1WqY=`H9H)fNkT&0{;LJlf?*To;!vMb$Bw$h3uzAv{
z@w*9~Zhw~=hw&H5IBgL+$G2(l`Q5-;n5WPP5I*>{^!Uu)RC?ELGszs!6{X{t**IJH
z?tZxb*}2)1U*}I4dSS&36<{l|q`pxYf61Pm?J^H&lrS>(X(FlD3Rj-5xnU;%M=o=h
zOhT`^=-)U`wk0gtPZsJMw^0}A+h|J)$U%#che(BbvPatpeQP5YtKn?izjmF@nDyUZ
zb;D0w{U1;O3P1rU00p1`6o3Ly017|>D1d+6ege>X3468{?xX+!002ovPDHLkV1h_O
BOXC0l
delta 3375
zcmV+~4bbxD1+f~CBYzC|NklQSgd_^dWEm#2zvb6^
z-@HdA8zdoW|L`5&dC6Pv^4AE<4IlEX#%+{Y5hXF{Qz9R+5Q?zxPexTdyo3iFDhbjY=x+8EP-OUKyJ5i?@St06&^D<4@kKdg%Z|c~X
zTMThrjCzBt;(rCq!<%uZ!(VQe#ck!DO^a67z4}@4`7L-W$I_f0grCI${ewJ5Q~&yF
z--WYpOPEg?7>e@Blq3-e>;|Y)ME?-cJ_;FQ;2EAWv+9YjwluB#)5}$}esGG7JiJrv
zq7!s)7@wLb*f%c9_+rqIs1d{^QIHKW3=5iLj8L`$t$$h^_5fTEVGk%2JS*y@z}Yy~
z>lB?g%s(|5vQpaBOwgAB8x{_SK7s@`=LSOGbMIBmKolM(&7z{bFv0DdXg-!TU)`0e=y{$rW)Yd-KVm>5r&U*}3>$8bVQ3y5#yl}<2dpjIEees3rJpAk4(z@JS9hADq9dlwomZON73
zogoa&{JRqWtk+uaF>JVBQ^iZNHj-nb2u9uc>Cl>kMLD}Ct9*2G1K}tWmZbZ<&_(#0#%5))*6#mR@pt_W)+jJoG`%uJWRsBPH)<@2^-r
z_{7cQY?H8cMjEAMidwa~X(bY&+PH!Wl@IL}VTA9xY#7ljtEQ2SiW1@W5#JXklb@C5p!c8)tQWKCA`{T;7tWz%G+r`2>sgw^v&7A|tCiFlI928U{D!`C9*7;e=~k;>e|gm><`X=k&TV(uXvg%f9dY6VHh!t
z#=xpk_f4_SjOlAg2{uXrxI&`=A%CwNL@hM;PRG1RE&|1^1|5W6jlWrPF&ss!`pUp?
z2{F7Qd}I5D+EoWwN>Sw4d+wXn_pJ|iaEZxjQoU0opRqWHYtj(8CmcQDEZj-@UX
zAcqATB2-$kAw$)98cFF~=k_Z8W}o86G!hb4reLCdxt)G30HLYAsuJomhj>M!f6i6f_v}<8(>29l-+LD430m0ZrF2564iC
z96;j{HkPX={VrkNf+cc8J%P93fJL#kSWf(*>S)bC?i8H`G45CG?Hd@W0T|gB#V`eBQinKU?GE~sadWMho`+LVJ-q2
z8Vv%RM?0QexPth7s%;oTFrIaPw(k8i)2_|&Y+Apb1cR*v7m7A4PS7${cRY1^ir*tQ
z^06Gx+c=KmX!6W!)eo=T>e_&9SC+#NVzdS@D#tvMGBc`D1<^q@L4T$y&jzdYF4#^H
zcWS>@wD#GLJj*Bdbw~~MB07bUKfmrj|E_)dkq5#;hvSJPiEW=pY$I#0+2Y!`wcIms
z(#YtECV??-Kj0}o3Xb8U3Tc_X^di2iFm^iDjXM57J3VmP|6OCV?8SE}5m3AY@Wb7N
zPf6{QvGbTOW6w#FQGa|?%qp)T{Z@RsBZ8WlHdGC9hcd;w(fOc*dABVoft*kSSy*5l
zjV%^is45P>8$lku(=qE}Ow4p*zk1#}`S;TSLMA}32*z;;ZRab`gd-1zQn;qjVmx*#
zh+-SPN>Quq;9Z@r`o=8i7R{DUb0y)Q5y$T=HR5(xNx
zySHtU8yj3If50_KZfyXq3AvCIp32t0n7$%&kU39sLjb@7*2+Dv!BSqi62
z3TKC9JDZ}cW|oI>bPWnO5O5N}N758-rOm<`E~P}7P8~=}?$1WY#8R?Epinu*;}(O5
z5A4$lPSG5J@sTLj$v54UF!$+~NFYGTO^p)lB4o_g=YNGaJ`)C{r&5Y6>-9owYyrJq
zK&1)8%<;nUwrP?}LT!X!XDObhW;}Q1ZkI<6BE&lCK9_WWwI{^WynqWeGB`lVuBLja
zzOGJh84(96=H@$R6Q2+7713GjOp4bVWGogQvNl6Y6cahkEqWm3*>@LbtajM>0W!b_
zz6u-z-G9`^QFC6Z`ooud-NhPoF4}pxj)r73i!quENR#1p5I8GIA*yY=)QT9Cr~?XD
zmDG_%jpqQ0DIKx2*}xcKU5E+>l>qPo%LA0~;}0&VxO>UR^{a4ouPeB3mjV4V02vo#(5o
z1BWKeI+m{+51pz@May&``c)sK2In~tYhglzhQr$861b$4l-6T*_|R0eY#}-ekTGDt
z!<~yyG=?fqRk}Z2Q>1k^6$&~e#tw}dhl^S@wc_!AEUuZ8bhGl_`<@0pYJ@8xvzp`l$@6P6@9!-
zNXr}$nJK-S*rPe?(!ZxZfBQuHjV7~TXi=(6FAeWZc`hO+1&Tbb??Ia26OFUaQuA|jp(+u%>6jR>F
zv6gFZxE^38(>%{d6tj%13y>qhqX!R%P8~m`@&g*%imh2z`&|+2_PFRVmxe*OLbY7TZALTu-=0LPJlKM0YQEZ1Cjo#OS#Ku8p4Pq0BI
z#y5ZdQ4uP9=rn>R)yah>Kq7QI{`mrR6trOD;9*xJzfrUWL}Dr!YayY40^gv{JvnEZ
z?5uUF?V1pB*$|5C8jbHSeciu*Uw2gCxIVX$2)>Hewvvx0IG+Si13&znvkh41&*V+{ef#DBplV7+0+;nlgshE{z
zLq>Lbzm@9`(hO@PA`CAOBRVPyG87P9tCzp({$%yrq0=WS+Pxh7Fy@UM6@NS9{<)CF
zTVZxaLXn`&zL$Ec}9g_H=}=P2XrU_DVaxJH8y3*iXG|@@Ar$UUyECr7q+8a7`?11E~tpmJsFa-4zvIZ9z(lT?@XPVuRB`{F}ETEiFX%?bPArV5B
zaM+Uc`(VDHe0#p#`r||QPLa>MyeJ3(Vm)3y?{dr1o!}Dt*d32Qzsxdm@@;Ad^?E2g
zkFAwzCmPkR1YQYGpgEs%-*V>0G^c`=&KbAH4M{a74ugLhB)Hdh9?1ld2OK{t9}(zp3Ci
z6AU>+NAP`820&YlQD=I;uR*M;I2QQ6Y^QJEuI(_Lxi8cdcDNoCZ0~M3%xDs#tJ&*_
z;C}uLbdZtoutLI1BPe=gQICWxI{Y6Be({L^y8SfU{{Y<#TNcpvJ@fzo002ovPDHLk
FV1mu@TEzeW
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
index 13dd21476b5c7f69cdfb82969bce7150244c127f..9608589ce30926a90455da4aa0b50eb6066121e1 100644
GIT binary patch
literal 2074
zcmbW&`6Ck!1IO`^GhZp?99B6aB-C0Wn!Cf13&DHfxpRyo4N%*JLJ
zN+?6v*p@rFaxQayd;Wmuhv)g>^ZNV=?<|L_){^@b_X7X`Nn0DJ)1MFh&%{LjhzfM=
z0RTkDY@z1YV_6HBo5+-|#0oU=>4!J|LY!!w|%34Yv2C7@B<`_7r_Hs}+
zdW#M&OEI)VoYP^457W%F1u2VO9$6%snZ@OLq~cB*sIMbc-Sx!mVt##wLr6R^o_O96
z#VsruaqmmXB8J;jxwjEPL7cdy7}Kr9=Rq9a1)s8pyssV?eUJR(C}j0NyYM>rA^5^c
zh+HDm#Sc*lU0B=9=ej}O5f04ho0P#4hfD0J;TgKSn`Ukm%rhKvPt<5m0oB^K>MwEa
z%GX^!k4GcIl!9sazbSa89_5D4n9Q&$zToqG{bkh!WCLg5v!7yC5gjfo)+(aZe2oPw
z!gkYZS)m@rJrCc~ZGHaA(&atVG&r!zBaJh}J(}iW66?{to15GvdAmEaJCE)$oY;}u
z0l@9x8Zcgx;fm=bz*mf`0$xUM0^3)g4`rJM&E2aBdk*y6SdN8LL7!@}NA5@JL(?dg
z6L7bvdh&1zp%~Y0S)2@W>`B;jStd9O&K_xr-1hWenwjO~bY81jy^rhS-$1_*w;0MlArMZq*x?sfaoKZJGA2Ri|=t(R}>cZ}ev-Uq_mJj2$^oV)ll3
zrDx)|jsf)D2$sp
zw6G>R{wtX9BS^k>czZ)|ym@E0^+pf5N6VzGVyx^2`OZ)+ozM_p7LutHIJb3A?H!`c
zp-;V$9rPVtLMA2%9;D_9WLw`ZO*T-9JQY2{eWi=#O|{|3nLh96E?C-X)O-beGAevO
zYJk|}H!BX*N)T%C+vmhQzP71R(X9+INs6InT85z`RKB`l=^Q<`Vv;APqNLr2>S+Br
zK5QWmu?oo`bZupHzlioVV=(#}ti(=+roL*hJ#gW2y1(=lsrJoY5eec;i#QwEp!mtC
zow8sfTzgi_sZ7GrOo3LEz!+8H(j)Gtm)(LFV<8HYUe8DZUodShT+-3e-bKdazQpcZ
zRY`hqB*}5MGr{Rgu|y;zBS0>Mtz$K+=st-o9)vAB!ou?_tWq9iY6`eVbuL%V*Tnq3WZ$%IHv=mBEXsUr?SU=%vWs
z72Bcco>YQlh*{hMS&Z2Ztjv8C&`nSPh09lykCZNpnJxk>zSl9xt@$qmX&k$9k>Aos
zLAeE>*Ys&ccTZ+v^HF7lTV7gs5)w$Ej1_F}6s<0{0uGPLnjD-t)%bHuOK<%2t~s$O
z!4={eeb#hiLrwGx^yA$h{VB0|$L=mlNP#TPQ-Dc;4~vQ^&&&^W9RppEP=m97v^j!d
z`9(5!n;&@^W!=5hZw_883@+oH&WS>u{N#z6FkX8s{3}figM9$nH#%r0Dm18wrep_z
zL3LVW0sn_UgP%#x(u3EN0dGwnO*i-zw~pF`yf}0<-k|bb3lzN0*bq0|BJep_zz(Q!
z8aB3OXa6`IpaGfi{>D{lQXnlTWQ$i@Xf=b>_h1RaclIGa;HENU>~Sg!!UOsms_Wl;
z%`ARgiJ4nTz5RqS?f?c2t`v4zT@N?Ec5kw3awaMqO5V}`&YX`1!^A`kX%FlZG-Zmr
zVqJ|!G=}&y{Ns3DDh6H6y+}cur#qsT{2Z_77dP0OB&(g1Yl3OFv7}D;cf?@_zVI
zRL^a(!m|K@p7YI?=L&B78A_ENB-xwltm6wpAA6y0Rd_9v>T`C6@`zSGLW3)ia-Eo`
zu*sqT=l6ZR@a~*|K=0NM<*{|13l8L9J51NOw6j%~&J(Jg5lj@Y`(nB>$_i!M>>XnM#-Dmv_O5UHim*{18p^RoSTHiMbN2TkS9mkODivp
za&cvfbF$G9FOjxArqg(y=G%4ON!4AgC47|ROhl(&2V#uH=ZW{KVO9!1X-Tyv8t{m);AHF36q$*rgR?fX){b@eH*77Q}+QK*WUrww6DgXcg
literal 9081
zcmV-SA42T}4*}5d>5a1yqn4
z5Tq)obV5x+2nk6hbtW^fedpZwUI>s$W+oLk_s4@_=GFVZ=bZm3_de3r+FDy{Yi+Hq
zwY9d^*4kQIyVM)=n|&|_{?HBoIrp5ZTK#)TmGXI~=Q)N|6jfGQiR4|ffFV#IQJf^J
zlJOvYyV_Dm^$i(5vTx|fULCD{>|uNwv9eaeY6lSL^=e3s?+^wV{&LE$!V>AkuEWk<
zKkTW@So2d^#?FJzY~y+W91oH!gXF4hfca^gxEesE#hGlkjk4c7VMN5F-*vGKC1%D<
zP)OVg1p-7v(e092({`z9jZ1q1@Xyq!9$BwdsU0L{S>h$LyN}6jGm>98K7Ez#du$p|(
zF*?CPe@&{CrOJ&)hT6yD3#}Ryn2p7vXrIxWdl_lP(m;#RFz3b
zhdy*<#E*xZ*~Y#9&j4mf*2q2~UDm$cX$#0wq*SGd)PDFf`jN>44Am^p#AsX|eJu|<
zV#Mf7ftXNMb^pvme<$cJ=-~Mz#jwA~TUkMLDuq`}$sG98A?GpU8GiGCp&h^u8*zC^
z>bn1Q+68wPDT@jci+aJ*)fB^9A-H4(pekk8p<}L}emd;j{_9cKo|9)hCm^~}%yGnx
z`$O2t+%eG{3DJl4x~)si%JaM&2|?e0wLXuX1nDv_MBj4nfcOw<6)<5Z4yz5T#tFgPmU-r2@xkF5eQ
z^s~^HF$)YiI6r+uyLOi0z{cuTgXouW0@$@6jvPDWT=(jy`O`mMS@gYpu8U|auo_!c
zJ&$x~fqbnGq#ChE6GS`!xT{~dJAPVvui&fDg^)qT0RVixpn*+0bjZ2B$6bd;!vELg&g3_d^NTLKXs&x=4co=$u&+L}HmHR(m@0c~9vn@TjqpcgLNQGq?ui6O#
zg?-3neL{xiXV~P!7iEHKdvMoX*MM7E(Fc_f>
zs;-_LtTD5vrY=^UikZ_00&||0QfVecf$)!His1P#zAk+B&R4Quhu?UxIjH`cJn)KI
z0I@+^yJ~0I=EM+A?KdFoYH;RdZ#Ip7?^c+8=|TOMZ!Z1lbdgvA_sgj2B?=fYWonGy
zc_e9C_wGR>fWk{OUWX(=kAO4izhc6kXIMT;;5Ke>%)R01<4@53U~#|Zi;}C_NTQjS
zZ?=|gyrySx$J7qi%Ovf-YXglz*2sqPwip{HFWykH9tlW8rHpAAur%A%DamZlT$p|o
zdLaT35~;~B!><^jz%GE!C@K`Q&z49hGjrSr?*DhrlaNSs8$MghXa?=t@sR?5ctN*)
zAt7LQrP}Q-28+>liK1H6WzvC;;G2~g&mQRY`4#~TBnc8t((R+|w}1~70Z|FUhw}oC
zy|cOQ{oD3eZrJo|#rMF_emR^|mF*~(JvG5@DX_5NwK4Pr14nw`&78>#UhVLMS}L(L
z#C5SWFf`r>c{|A8O`JOG^!vuMUosM=JCY0-ay4g3_d{`=ZCy%ER2^QqqihLeb-!G{
ztNiB+3SlTtII^*(qP3Kvp?U-Gq;+q0oHJr*_+4_jggzQyi~W;X6T@Yla&PZE@xUOk
z#d=ZJB_d%MSA||zt{o8C2bj~@2d5o>YEeeXw}qvWT(dKDU}>~n4?igl7$l5N4jA?P
zk*e0H`S6>I;9pO^pZmhk{X-{$oV8+G*%pkZ1WG=dUBa;Iam1f46wC1c
zMiml`Y_rI>SRR6eGEudtkuD;BD-k^t&0?X9J%l4;hlGt9)+=~uh>c4@=>@x(m9e{g
z)7M){*TLYSq(Q|bjOy1l=-#te6n!{yc=+9LEaye5Fa)G*Xs_Ur@ZZ;^1_Xp#EYhKO
zD20LHI1aOo@IR^+8MB3$JEyk~8a(`3#p5n^%z5u~EW;r_su7)u(ed%bEiq%K{WW?{71|F8o#>8a@YW^?ixe#F8VQrvI9lVCt!ORE51DvTv|O
zNm2{DOgh-<_-S{*Y>{m#z-Xti9F9m<)g(%`^yu9&`kCp^q<*n>XY$8i9f*1MwfCS0
zgK6im9P4W*8eo0?kF=NHn3lFYJdE!ul}Ivdl2od1C3KTxqJ(ZAywvWS|2~oYykUcU
zuZz=icrHkMZr)Sz9o94|wb)uz?vPnf`4B!TJ#p%JuSkWWY7&?_>ojPQ0M96}FM05exPPttseIM)Ev5T>bZ-mlP>VWrai#
zD2}IvHjK$cJSUFsSKV%uNY)pk2z^I8>8aad|Bf{rSjpjwoo2Lt5b>D5-WoGi6C2eh
z2{7y(L}Je5<$H1&6H}`Vqmp~0rD7!ZYAr5^s$T>KLP*W8T9pl>bX}5U#$siXKU%WP+GpTcsjxtS
z@9{UG?*60CRN>F%@UKpZ<~S6$07tNjLl}IuSbq)zyM-kSB)3qk!kA*6%8ntXR%yeK
zlOYQf+I6s|e7a%_P<8+-Uj&JxQyYBaUPSb(v!QutcDP~TMvxeh_jeu#Qecg_un1li
z#X`p?GpA+UG%UV!@q#b$!88ZEehEXSm+z1~E4P=l_P;$|MOL
zYhOZCMu;uW&f8)mgd{56wSYk@9l*Zj6^r>bj%!(!h&~jkLdky5!;^zYjQI-?3<=br
zP@hz>CNs~N&7^F_lDCWg^UCw$=~JbFs0~$*MLs?A^lLX=89rGsG4ZlChpg^`RLFJ<
zX4WJYi@DS1o%xq8T|O1|FnVA*EzXo;3*!@T7HDnmfaa$PlPod
z7;eHc>HFTu`4b7{(Z9knHBW8o5x>LVIDr+gEaA^DW!(%+ufm^ni(xDKb+cVg%nYLg
z&3MzEhjZMA8f0-A2Q3CA1yWcniJkW9Y!&MpRH}}=8()ZcaG~Ks)uFwsvL{~Gy&_}P
zc92jC6#ORAY*hda(Lu6`yA~w(FA^d@h?atlXFZp`$UbVx(Ly+>J#N1u2&OCtL@O!O~)YK_s09TH-x<)AgUMF8%@18J!m_
zIs1B*Tgl;q0F4Nq!;K5(nTkp&`>Pd2uXVodK-bUKJGS{dfY
z#nBbCg)D@uiV8d@c4xYd)+9-O_jMyk7uDf>bZk)SR~s_H79|iWZMFMaFt$Z5G)w@n
z7M;3p!US62LDi|;=*6HlXZl0X)!eI-wR`(`Qxe|tsRB>#7GQ&COCI|F@Y^Oo%11^gNX3OB
zwL!HT842jV;68SEOYW50CumiE9NQFN8R`*>H=%K-i$phUyW#8IHTTgbD`;xc-;s{1
z?*M4zp>Z(}5tpo+T`y`A_S^Jp#VVa}FoBj-HViP#g8*23OuQ}Zril;86=hUfyxcWL
zV|lg8Rh;+OJvXVMM~{X`O}f^FR&1(Dtg%oSQ|%I=r-wy20hOHb`Yg-I*GGIQ%RbKJ|ibg5qKsm$0|^20CXtA9S?
z%A-|O4X9Xd0AR=rB^t;Lxn}I$ipxoB$-JpTpzoY(=a#Qa7R>$h>jiKs%J$a_|smKaz$^5&v~
zw;;hu^l(?pNEmgLp;<2txK0ISXe$k
ztLoSHmle*{0c`N90)YXB-~fvd5|ZsgYWv=*yNc9GHbCqcvxOD2PwaPY-@I8TA95gI
znc8=bjhyo2^Ff1#{gDj~i)M7~3sOd8M^OPpN*033oi65N6;nx6ltx=}!6Yz(M^r@s
zi~DhAm-R96rXlrBn#cK4EJ)Y$ALl*obStE>mVsac#ETwh?$#rn3y-i9Nm58HIUp5F
z1FSaU+_Cu^1Qk2qpQ>vEtdKE(7>#uxKyK~_Frt;x5=C+p%D5?Ir3z%UW5UM&`LWa`
z>yLzvyLBi9i{l#XV{+P(v^;`!d1qS!fVB(28Yb2Q7S0P3LU?ZHj;Wq$DE-N81PrdjB*t%fq=lM@W7~Nl!`gFFX
zZJyETvp1eeT?V1rFq|dI*S%H;ojOWFcyiwx*^~C4tSUmH(wOtv0+TR3Kr1Arra(v5
zUWSH6f`W-iXY)^rr*jL8?|c+sDjD{BAD+tCg2KTe_tX_kt^qWU`||eac(8YWt9dS8
z!NPfK5XkMcbb7lPKh5o$Ii!E+1c(vR882Kt6~P$!;KO-#-r|C1Km4X>74F~IYfc3Q
zm?0^=5aSgC1PnA1u(uqYlyE-TF9aVX1tKRwwkw8D012(H!s;Tshas|{D~3ef%wGyianeF|jJ1Q}Eyt|CxhjR^W?f?W`{ZY!I2=POy$v2PW%
z@z_|XG-gkUd?LjJ4{!#(Gh$crup3^auwzwI3Mclk(8Lid12o#_C8E}Y7LYxo%T#|5ljQ&Or4^Kb%_2{Enns~8h|0PW0^o&yK
zP^E&rN(PIfYUvS1BM*d&jEbg*`I!1QsDIVLy?Y^`T&CMJ)vr5*o|DT<&w6rBY6UMo
z%q=sX0HPYv-mZqBC6p>5f)~omqyys~KhhV$LdO^rrOmOjb^t0-)e@ID)7#}NU|80x
zZUMr8zFHE4Gl&ofU$_GT3yjip^l$w0Fjm~=U`R=f6E-09|cJKIB%F8X)0><@=
zn4!ixC!13)sNPj^&KVZ%aKSv!1#$8hUl&gAH1R->rJGB4=@E}+d&Ng!HcXEjN@ojl
z(K>tS5Kv1HA&QJiN;?HvajO&*nQMMXv)9)n;5d
zglzy`M9j;{)I(FX?F1@OMEW0X?%b`b4uo&l!%P532Bu>ib7nlrN5x>J6;0LDA(@zN
zVfeTNQZ)0;DXt&49ig#HMXnbxLz;TDx1`2I2`LJA61gy5V_RWQZskznv}e7
zWhRVbYpp5|Bm$!Kocr+p&Dj&K9jZ&T?}k|`p`+?5><<%0+wXhyRZQn-=T;5zpr7Fy
zJBw%k=c)4VmT#ojp@m7l4G)&SB#tN#-yA*GW@c?mHWr|k#z=(^(9wFri_Aae}_
z#CGM?S6b398_dVXCsRUDRFLJ|xplMihm7qsF^Wa4nEm#%KWkYrRD0Q*@y;;1m_5r^
z*P=9N#i-_1iPgoQl2o3xAQsociUtD>`9yeuKFWo+Iw-HcnUeXi9wN&!9+E(?q1%m7J&ET2l(2LX9SrV1)nwB|CI++s2kQc~P5&$hynYxU$VxJ>aCQQYoognW~&7B;Ch+3DWzR>%yX~LfOJfINav(*
z;r18?c?*DnnQm1San&*jlCdVGvf_BwxU0L!jv}e0NJqogOVsND<$^P%<_?_`EWf*~
zpW<@KOl=M_7;{y0V;z2ixl^~e@|8r_5BZmhFmpXI-Bn%dclxR-&tCo
ztVxjq0d7?3S!bp_a;NIV^=;J_TgJBGfEiMDHs1jmXq)x&0aq%Ol@e2X**1MQA(nLq
z558(NjPPRT_D$Pqu_?zjxr(8y^5{Nb+{^F29rpY2Q>5Y|PE&N_GHCbhd*A47u9ccVJKF_RTAm^3p1#
z%jf`;#?X-zC@K*calr*NlM~*VyUN;U;Ee#5R3}*4JPQtNo1FLLeb<7cTtw%-wGc^a
zWWeZXq!D(A=^X*)6=CVscaY+&5RExxUL=jdW%tbOJ9Q7ae%zh#Im^BO*ZstJZ>7{_)x6^^mFmelWjV1z&oSQvj?wlT(Jm|3uJgbs5{`io5tn9jy%6vN!DN5`aDi+^OTwr~JVB)$}cB3Z#qfYr*v
zabV|M9?!ANpWT`JcY017+bf>VIWA_OD1y*f70m>MhS?y_7cIoZwKu2t>s4_tqL#=G|1LUqGajTqf6qf#oM6s
zBa|vvC9~OxGZKil(BCn`Pb2M&Z>v{R
zH5d|R(!sg{z;YN%F|`)3niMb{G@#O=2$I%5dDh|$Y)D9oTwW$3n0{Par6pH%Wi>6k
zt+UVSL?V4B0c4@+7p(5^`f+|F7+|w_PxkTcr|udzno3q{h;`P|nsfG3z$yMowkqKNk&q%p2jPGwdM}h)s6{7Nd
z<+hCrawc9ks5OH{NOQb<1t;}EM}yzvDqFs29^{ftw%+}R;xc`pzQmdn%mdI4foTX7
zNw7<-Er@5W7P)xNyAS3+^YB!)s*1EWo7Mb)(GEh;AR8=6Dloqc&(Xu{AW%F&NKB4X
ztK4)kFvd%0sB!N@0}3MV93Nq4-G}yVIP=Vd*Oq>3!gZ7Di>jo0^5d!L1MJGMzD=|Z#2q*V+NuY9Ij{p3D*;jkKcdRv2gZ6
zbQkEluzuX3dh%7NA#-wvTDeDzH@mW=Na9Ogc60d{m4j-M=-mw`R?&2EK4eC!{jO7w+Ef3hm5{DgGt)fs;
zgyhs%04-IBPfUW0bu73GVX(hdSAV!TX0e!?a|RNTryvrNE#>AEsWn+SW&l`P+NfNr
zK(#)=&X+ox_PsEe>(_HZmO!hz(Cpcz3{vX{?0m`7*`+a2*&Fp&wNQxmZgurivrTQS
rt+lnb*4EluTWf1=t*y1S|C#pxla(O^#1Bof00000NkvXXu0mjfK#F@#
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
index 73e48dbfb7dc31ed1e951b77820af88b93fcc418..8e532cd35931640ecd9860497e6137d1a2a7fefb 100644
GIT binary patch
delta 822
zcmV-61Ihfc8q5ZeBYy)ENkles85JSV6$Z13!XWsMuINf^(X=>|;
zFSuuT+?jLE_uccE`|gjY!GCU=1dsp{Kmter2_OL^fJXrO0DtiMCBVpWAbpcL6hIMh
zfvg}v*(boq!$4gCl&wBdjsUZRI$+cS8fYj8w4ErdgB0uoRVDB{evx3RCO8g44y+GO
zt%lp*tr^=IPXKgPNoygA)of6oY*_YEz;Pp^i-yDFjb#DFaHu>#N2_2tmzis7
zTjL3U_~8P8nSUf~Y?#HM;?w+GWf6SpMDOTYjK?n=%M0nI)4*btA}2$$Q&X}5aP(_b
zv@M516+mwD(3kc3&Q~k3@z%=lcxy$t^X1R@(DcSUkBOWNjVDIn!93W!ZK~F%A?1eS
zThtUY+@&kemT&T9!X^mBP4_&cC@gYb|Iv5?V1IfwV}BD;Rg5j$>^fEC13%umKmO}E
z^HTSjP!E_CGZK~=&~){0%^m~XQV>tNL~r;HpKe3K{AMSeYCGXZyXy+i@-mJ1R#ZbJ
zpOrfYeqBu5tE~8XrhUk?dpZY9)_o59>-qPw*;siXsJ5`3Yss&nWo|k_GvNgNs$y{;
zkMSIy%YQXy7Phk;1N0NoLQSc-&&ZEd6m&I*2pf?=_an^m3yE~@<1vxLjBB{o6LTP8
z#6%&e`3gWhO%wxE+E{%P!jD+bMqrS|ibYO_#uLkEuq3^ppo0qd5D4*r?a*D;M)+kp
zbdURZOyp!}JOQxq7DY9#&zk)F-R#j3Hnm(WNPjLx$c2cW%RbD~BQg`EllfmQ1>Apr(&Z&T>N75kgn^7He*n7Yzd!VvxyZSybcoD_^(Dot?
zvM(DZUuYzN1dsp{Kmter2_OL^fCP{L5@4_FH#z87nLQSgd_^dWEm#2zvb6^
z-@HdA8zdoW|L`5&dC6Pv^4AE<4IlEX#%+{Y5hXF{Qz9R+5Q?zxPexTdyo3iFDhbjY=x+8EP-OUKyJ5i?@St06&^D<4@kKdg%Z|c~X
zTMThrjCzBt;(rCq!<%uZ!(VQe#ck!DO^a67z4}@4`7L-W$I_f0grCI${ewJ5Q~&yF
z--WYpOPEg?7>e@Blq3-e>;|Y)ME?-cJ_;FQ;2EAWv+9YjwluB#)5}$}esGG7JiJrv
zq7!s)7@wLb*f%c9_+rqIs1d{^QIHKW3=5iLj8L`$t$$h^_5fTEVGk%2JS*y@z}Yy~
z>lB?g%s(|5vQpaBOwgAB8x{_SK7s@`=LSOGbMIBmKolM(&7z{bFv0DdXg-!TU)`0e=y{$rW)Yd-KVm>5r&U*}3>$8bVQ3y5#yl}<2dpjIEees3rJpAk4(z@JS9hADq9dlwomZON73
zogoa&{JRqWtk+uaF>JVBQ^iZNHj-nb2u9uc>Cl>kMLD}Ct9*2G1K}tWmZbZ<&_(#0#%5))*6#mR@pt_W)+jJoG`%uJWRsBPH)<@2^-r
z_{7cQY?H8cMjEAMidwa~X(bY&+PH!Wl@IL}VTA9xY#7ljtEQ2SiW1@W5#JXklb@C5p!c8)tQWKCA`{T;7tWz%G+r`2>sgw^v&7A|tCiFlI928U{D!`C9*7;e=~k;>e|gm><`X=k&TV(uXvg%f9dY6VHh!t
z#=xpk_f4_SjOlAg2{uXrxI&`=A%CwNL@hM;PRG1RE&|1^1|5W6jlWrPF&ss!`pUp?
z2{F7Qd}I5D+EoWwN>Sw4d+wXn_pJ|iaEZxjQoU0opRqWHYtj(8CmcQDEZj-@UX
zAcqATB2-$kAw$)98cFF~=k_Z8W}o86G!hb4reLCdxt)G30HLYAsuJomhj>M!f6i6f_v}<8(>29l-+LD430m0ZrF2564iC
z96;j{HkPX={VrkNf+cc8J%P93fJL#kSWf(*>S)bC?i8H`G45CG?Hd@W0T|gB#V`eBQinKU?GE~sadWMho`+LVJ-q2
z8Vv%RM?0QexPth7s%;oTFrIaPw(k8i)2_|&Y+Apb1cR*v7m7A4PS7${cRY1^ir*tQ
z^06Gx+c=KmX!6W!)eo=T>e_&9SC+#NVzdS@D#tvMGBc`D1<^q@L4T$y&jzdYF4#^H
zcWS>@wD#GLJj*Bdbw~~MB07bUKfmrj|E_)dkq5#;hvSJPiEW=pY$I#0+2Y!`wcIms
z(#YtECV??-Kj0}o3Xb8U3Tc_X^di2iFm^iDjXM57J3VmP|6OCV?8SE}5m3AY@Wb7N
zPf6{QvGbTOW6w#FQGa|?%qp)T{Z@RsBZ8WlHdGC9hcd;w(fOc*dABVoft*kSSy*5l
zjV%^is45P>8$lku(=qE}Ow4p*zk1#}`S;TSLMA}32*z;;ZRab`gd-1zQn;qjVmx*#
zh+-SPN>Quq;9Z@r`o=8i7R{DUb0y)Q5y$T=HR5(xNx
zySHtU8yj3If50_KZfyXq3AvCIp32t0n7$%&kU39sLjb@7*2+Dv!BSqi62
z3TKC9JDZ}cW|oI>bPWnO5O5N}N758-rOm<`E~P}7P8~=}?$1WY#8R?Epinu*;}(O5
z5A4$lPSG5J@sTLj$v54UF!$+~NFYGTO^p)lB4o_g=YNGaJ`)C{r&5Y6>-9owYyrJq
zK&1)8%<;nUwrP?}LT!X!XDObhW;}Q1ZkI<6BE&lCK9_WWwI{^WynqWeGB`lVuBLja
zzOGJh84(96=H@$R6Q2+7713GjOp4bVWGogQvNl6Y6cahkEqWm3*>@LbtajM>0W!b_
zz6u-z-G9`^QFC6Z`ooud-NhPoF4}pxj)r73i!quENR#1p5I8GIA*yY=)QT9Cr~?XD
zmDG_%jpqQ0DIKx2*}xcKU5E+>l>qPo%LA0~;}0&VxO>UR^{a4ouPeB3mjV4V02vo#(5o
z1BWKeI+m{+51pz@May&``c)sK2In~tYhglzhQr$861b$4l-6T*_|R0eY#}-ekTGDt
z!<~yyG=?fqRk}Z2Q>1k^6$&~e#tw}dhl^S@wc_!AEUuZ8bhGl_`<@0pYJ@8xvzp`l$@6P6@9!-
zNXr}$nJK-S*rPe?(!ZxZfBQuHjV7~TXi=(6FAeWZc`hO+1&Tbb??Ia26OFUaQuA|jp(+u%>6jR>F
zv6gFZxE^38(>%{d6tj%13y>qhqX!R%P8~m`@&g*%imh2z`&|+2_PFRVmxe*OLbY7TZALTu-=0LPJlKM0YQEZ1Cjo#OS#Ku8p4Pq0BI
z#y5ZdQ4uP9=rn>R)yah>Kq7QI{`mrR6trOD;9*xJzfrUWL}Dr!YayY40^gv{JvnEZ
z?5uUF?V1pB*$|5C8jbHSeciu*Uw2gCxIVX$2)>Hewvvx0IG+Si13&znvkh41&*V+{ef#DBplV7+0+;nlgshE{z
zLq>Lbzm@9`(hO@PA`CAOBRVPyG87P9tCzp({$%yrq0=WS+Pxh7Fy@UM6@NS9{<)CF
zTVZxaLXn`&zL$Ec}9g_H=}=P2XrU_DVaxJH8y3*iXG|@@Ar$UUyECr7q+8a7`?11E~tpmJsFa-4zvIZ9z(lT?@XPVuRB`{F}ETEiFX%?bPArV5B
zaM+Uc`(VDHe0#p#`r||QPLa>MyeJ3(Vm)3y?{dr1o!}Dt*d32Qzsxdm@@;Ad^?E2g
zkFAwzCmPkR1YQYGpgEs%-*V>0G^c`=&KbAH4M{a74ugLhB)Hdh9?1ld2OK{t9}(zp3Ci
z6AU>+NAP`820&YlQD=I;uR*M;I2QQ6Y^QJEuI(_Lxi8cdcDNoCZ0~M3%xDs#tJ&*_
z;C}uLbdZtoutLI1BPe=gQICWxI{Y6Be({L^y8SfU{{Y<#TNcpvJ@fzo002ovPDHLk
FV1k57TC4y7
diff --git a/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/src-tauri/gen/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 1d98044f13bdd3afc7cb426644ae118688c03c14..d715fffff4128b6c3d2273df2c9e88c206274ccf 100644
GIT binary patch
literal 1964
zcmb`I`9Bj51IIVAA$s<8q+Ahl6*&@ZW3HKRbDJ~woExoN6Pg^!eXHD2?i`6~ay-4l
z81}{EYY52^*330GeLa7|^Ljl$yg#4c-#>glNtWg&{Jf&P004mB6lG+6*6#m>8+4XG
zUW97{0N`L#BYj)q6tf@#FEnwvyX&?Fl+l6Tc+}y;-wU3v7UO+T!Cio8@t3u!=8v`2
zL0FDUW1P4QWsI%NexnSOFq(3xK$7;O$ZYFGeTQpg
zSa?|a#2yH3kFklk`-aZk%Rx%gBIl|m_X@)YrGZ-sS3LyF075bUE5}!O6iRuqZ8@2m
zK&c=5p4Gri6QGK`B2%0rnPWXRVGJl*X?3ZoB`5S9=e23d&sNy