diff --git a/.github/labeler.yml b/.github/labeler.yml index 843e5b6c..83b47e1f 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -13,6 +13,14 @@ web: - changed-files: - any-glob-to-any-file: web/** +panel: + - changed-files: + - any-glob-to-any-file: panel/** + +shared-ui: + - changed-files: + - any-glob-to-any-file: packages/** + desktop: - changed-files: - any-glob-to-any-file: desktop/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5be99b9a..594048cb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,7 @@ jobs: web: ${{ steps.filter.outputs.web }} mobile: ${{ steps.filter.outputs.mobile }} desktop: ${{ steps.filter.outputs.desktop }} + shared-ui: ${{ steps.filter.outputs.shared-ui }} steps: - uses: actions/checkout@v5 - uses: dorny/paths-filter@v4 @@ -43,6 +44,11 @@ jobs: - 'mobile/**' desktop: - 'desktop/**' + shared-ui: + - 'packages/**' + - 'package.json' + - 'package-lock.json' + - '.npmrc' # Backend + web validation (lint, security scan, test, build) validate: @@ -51,6 +57,7 @@ jobs: with: run-backend: ${{ needs.changes.outputs.backend == 'true' }} run-web: ${{ needs.changes.outputs.web == 'true' }} + run-shared-ui: ${{ needs.changes.outputs.shared-ui == 'true' }} # Build + lint Android (conditional, only for mobile changes) build-android: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb85ab8a..8cf82745 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ jobs: with: run-backend: true run-web: true + run-shared-ui: true backend-image: needs: validate diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 81774aee..d2fec425 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -9,6 +9,9 @@ on: run-web: type: boolean default: true + run-shared-ui: + type: boolean + default: true permissions: {} @@ -259,3 +262,85 @@ jobs: echo "## Web Build Results" >> $GITHUB_STEP_SUMMARY echo "✅ Web built successfully" >> $GITHUB_STEP_SUMMARY du -sh web/dist >> $GITHUB_STEP_SUMMARY + + # TypeScript type checking (shared UI package — packages/ui) + typecheck-shared-ui: + name: TypeScript Type Check (Shared UI) + runs-on: ubuntu-latest + if: inputs.run-shared-ui + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck -w @idento/ui + + - name: Summary + if: always() + run: | + echo "## Shared UI Type Check Results" >> $GITHUB_STEP_SUMMARY + echo "✅ Type checking completed" >> $GITHUB_STEP_SUMMARY + + # Lint shared UI (ESLint) + lint-shared-ui: + name: Lint Shared UI (ESLint) + runs-on: ubuntu-latest + if: inputs.run-shared-ui + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint with annotations + run: npm run lint -w @idento/ui + + - name: Summary + if: always() + run: | + echo "## Shared UI ESLint Results" >> $GITHUB_STEP_SUMMARY + echo "✅ Shared UI linting completed" >> $GITHUB_STEP_SUMMARY + + # Test shared UI (tokens, primitives, verdict vocabulary) + test-shared-ui: + name: Test Shared UI + runs-on: ubuntu-latest + needs: [typecheck-shared-ui, lint-shared-ui] + if: inputs.run-shared-ui + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm test -w @idento/ui + + - name: Summary + if: always() + run: | + echo "## Shared UI Test Results" >> $GITHUB_STEP_SUMMARY + echo "✅ Shared UI tests completed" >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index 685d46ca..9f8e38d5 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ mobile/iosApp/build/ .idea/ .vscode/* !.vscode/extensions.json +# Claude Code local state (launch.json config, worktree scratch — can be +# hundreds of MB per worktree, never meant to be committed) +.claude/ *.iml *.suo *.ntvs* diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index f568e47e..f46257cf 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -294,3 +294,18 @@ Re-review (fable): ALL FIXED, Ready to merge YES. 206 subtests green at e548f65. Branch pushed, PR #58 opened (https://github.com/thevladbog/idento/pull/58). PR review round: GetTenantByID missing archived_at (fixed), PurgeExpiredTenants missing retentionDays<=0 guard (fixed, defense-in-depth test added), plan doc snippet desync (synced) — commit 35b33a5. PR #58 MERGED to main 2026-07-13T18:13:48Z (merge commit 079e1e9). 207 subtests green post-merge. INITIATIVE COMPLETE. Worktree hungry-chatterjee-1efdd5 and branch claude/charming-curie-9b5640 cleaned up post-merge. + +=== PANEL P0.1 (workspace + @idento/ui foundation) — branch docs/panel-rewrite-spec === +Base: af155877 (spec+plan commits). Plan: docs/superpowers/plans/2026-07-13-panel-p0.1-workspace-ui-foundation.md +Task 1: complete (commits af15587..aabcd4a, review Approved, no fixes). Root package.json workspaces=[packages/*], root .npmrc pre-existing (registry pinned), packages/ui scaffold (package.json exports "."→src/index.ts, "./theme.css"→src/theme.css, peer react>=18), tsconfig, vitest.config, cn() lib + 3 tests. +Task 2: complete (commits aabcd4a..34f3886, review round1 Important plan-mandated fix applied, round2 Approved). theme.css tokens light+dark (board 1a exact hex values), verdict mapping (already_checked_in->info/blue), Inter type ramp utilities. Guard tests: theme.test.ts, no-hardcoded-colors.test.ts (EXEMPT=[theme.css,theme.test.ts] only). +Task 3: complete (commits 34f3886..8e8cfe6, review Approved, no fixes). Button (6 variants x 4 sizes, asChild via Radix Slot), Label, Input, Card+subcomponents. All token-backed, no hex literals. +Task 4: complete (commits 8e8cfe6..0431c19, review Approved, no fixes). Dialog/Sheet/DropdownMenu/Tooltip/Separator/Switch/Avatar on Radix, closeLabel prop (i18n-ready), native focus-trap+Escape preserved, 36 tests total. Minor notes only (Sheet overlay lacks fade classes, no default side specified, no type=button on Close — all inherited from brief, not implementer defects). +Task 5: complete (commits 0431c19..e28556a, review Approved, no fixes). StatusPill: 6 board-1a statuses, always icon+text (WCAG 1.4.1), data-status attribute, token-backed classes. +Task 6: complete (commits e28556a..502418c, review Approved, no fixes). Skeleton (token-based, animate-pulse+bg-muted), EmptyState (icon+title+description?+actions?, teaches the pipeline). +Task 7: complete (commits 502418c..c54ef82, review Approved, no fixes). ConfirmDialog: tier1 plain, tier2 typed-confirm (strict === match, reset-on-close via useEffect keyed on open), destructive variant independent of disabled gating. Minor: no test for reopen-reset or destructive-independence (coverage only, not a defect). +Task 8: complete (commits c54ef82..3dfe9b1, review Approved, no fixes). AgentStatus (3 states, always icon+title), verdict.ts (4 verdicts, already_checked_in correctly -> text-verdict-repeat/info-blue, verified against theme.css). NOTE surfaced by reviewer: mobile's actual VerdictBand.AlreadyChecked uses Amber, not blue -- plan text and mobile's own doc comment disagree with mobile's real Kotlin code. Not a Task 8 defect (web correctly follows the plan); flag to user for cross-platform reconciliation decision, out of P0.1 scope. +Task 9: complete (commits 3dfe9b1..ae773fa, review Approved, no fixes). Barrel index.ts (51 exports, all verified real), eslint.config.js (blocks i18n/router/api/app imports), packages/ui/AGENTS.md + root AGENTS.md addition (append-only, no .cursor/rules added). Also fixed pre-existing gaps surfaced by first typecheck/lint run: tsconfig.json +node types, cn.test.ts no-constant-binary-expression. Minor: node types scoped project-wide not just to the 2 consuming files (not a defect today). +=== PANEL P0.1 COMPLETE — all 9 tasks approved, HEAD=ae773fa === +Final whole-branch review: Ready to merge = Yes (opus). Strengths: 1:1 plan fidelity, all Global Constraints verified independently (npmrc, peer react>=18, no React-19-only APIs, verdict vocabulary consistent everywhere incl. already_checked_in->info/blue, uniform forwardRef pattern, sound color-guard walk logic incl. self-reference edge case, gap-free 55-name barrel, content boundary enforced 2 ways). No Critical/Important. Minor: (1) StatusPill in_progress Loader2 didn't spin -- FIXED commit 1f1c8a1, verified 120/120 tests incl new spin-only-on-in_progress assertion, typecheck+lint clean (bypassed RTK proxy JSON-parse quirk via direct npx). (2) tsconfig node types project-wide not scoped -- left as backlog, low risk. (3) Dialog/Sheet close-button markup duplicated ~6 lines -- left, not worth abstracting yet. (4) Sheet overlay lacks fade-in classes Dialog has -- cosmetic, left. Cross-platform flag for user: mobile VerdictBand.AlreadyChecked renders Amber but plan/package maps already_checked_in to blue/info -- pre-existing plan-vs-mobile discrepancy, needs a deliberate reconciliation decision, out of P0.1 scope. +=== PANEL P0.1 DONE — HEAD=1f1c8a1, ready for finishing-a-development-branch === diff --git a/AGENTS.md b/AGENTS.md index 23c43e4b..34837107 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,13 @@ Code style and conventions are defined in **.cursor/rules/**: - **Web (React/TypeScript)**: [.cursor/rules/web-react.mdc](.cursor/rules/web-react.mdc) When making changes, run the relevant lint and test steps (see copilot-instructions.md) before considering the task complete. + +## Web frontend — panel rewrite & shared UI + +Rules for the customer-panel rewrite (spec: [docs/superpowers/specs/2026-07-13-panel-rewrite-design.md](docs/superpowers/specs/2026-07-13-panel-rewrite-design.md)): + +- UI primitives (Button, StatusPill, ConfirmDialog, EmptyState, Skeleton, AgentStatus, …) come **only** from `@idento/ui` (`packages/ui`). Never re-implement one inside an app; a missing primitive is added to the package, not to the app. +- Colors only via semantic tokens (`bg-success`, `text-muted-foreground`, …) — hardcoded hex/rgb values are a review-blocker (test-enforced in `packages/ui`). +- Every user-facing string ships in **EN and RU** in the same change; every screen works in **light and dark** themes. +- `web/` is frozen for feature work while the rewrite runs (critical fixes only). Console work targets `web/`; panel work targets `panel/`. +- Scoped rules: [packages/ui/AGENTS.md](packages/ui/AGENTS.md). diff --git a/docs/design-briefs/customer-web-panel.md b/docs/design-briefs/customer-web-panel.md new file mode 100644 index 00000000..c7fe5701 --- /dev/null +++ b/docs/design-briefs/customer-web-panel.md @@ -0,0 +1,108 @@ +# Design Brief: Idento Customer Web Panel (Tenant Admin) + +**Date:** 2026-07-11 +**Scope:** the customer-facing web app in `web/` — everything outside `/super-admin`. This is the "main panel brief" anticipated by [saas-tenant-admin.md](saas-tenant-admin.md) (§7, §10). +**Inputs:** UI inventory of `web/src`, audit findings ([web-qual](../audit/findings/web-qual.md), [web-bug](../audit/findings/web-bug.md), [web-sec](../audit/findings/web-sec.md)), [DUAL_DISTRIBUTION_REWORK.md](../DUAL_DISTRIBUTION_REWORK.md), console-redesign batches 1–3, mobile redesign M1–M2. + +## 1. Project Overview + +**Product:** the tenant's own admin panel of Idento — where an organizer's team runs the full event lifecycle: create an event → import attendees (CSV) → design and print badges → configure zones, staff, and equipment → run check-in on event day → monitor results. Today it spans auth (login / register / QR staff login), Dashboard, Events + a six-section event workspace (Attendees, Zones, Badge Template, Staff, Check-in, Settings), Users, Equipment, Organization Settings, and a fullscreen check-in station. + +This brief covers its redesign. Unlike the platform console, this panel ships in **both editions** — SaaS and on-prem — and must become edition-aware (the P2.2 "frontend mode awareness" work lands with this redesign; today the app never reads `GET /api/instance`). + +**Relationship to sibling surfaces:** the platform console (dark-chrome, `/super-admin`) has its own brief and shipped design system pieces this panel should inherit; the mobile app (KMP) and desktop kiosk (Tauri) are the dedicated event-day devices with their own redesign tracks. The web check-in station remains the browser-based registration-desk surface. + +## 2. Users & Context of Use + +- **Two tempos, one product.** Preparation happens at a desk, weeks ahead: dense forms, tables, an editor — calm and information-rich. Event day happens standing at a venue entrance: glanceable screens, huge targets, seconds matter, and mistakes are public (a wrong badge prints in front of the guest). The redesign must serve both without compromising either. +- **Roles:** tenant `Admin` and `Manager` do preparation; `Staff` log in via QR tokens with minimal training and touch only event-day surfaces. Users may belong to several organizations (org switcher in the shell). +- **First-run is a business moment.** In SaaS, a self-signed-up trial organizer must get from an empty account to a check-in-ready event without reading docs — trial conversion depends on it. Today the six event sections are flat, disconnected, and give no signal of what's done or what's next. +- **Hardware is part of the UX.** Printing and scanning go through a local Go agent (`localhost:3000`); its connectivity, printer state, and scanner state leak into almost every flow and today fail silently (empty lists, vanished printers). + +## 3. Core Objects & Information Architecture + +Objects: **Event** (center of gravity), Attendee, Badge template, Zone (+ access rules, staff assignments), Team member, Equipment (printers/scanners via agent), Organization (+ subscription/usage in SaaS). + +Current IA problems, in order of pain: + +- **Dashboard is a dead end** — five static navigation tiles, zero data ([Dashboard.tsx](../../web/src/pages/Dashboard.tsx)). An organizer's real questions — "is my event ready?", "how is check-in going right now?" — have no home. +- **The event workspace has no spine.** Six sidebar links with no readiness state, no suggested order, and empty sections that don't teach. The badge editor and check-in link live at the same visual rank as Settings. +- **Org-level vs event-level is muddled.** Equipment is global but consumed per-event; Users (team) is global while Staff is per-event; the same person configures both with no map. +- **Check-in entry is an afterthought.** A buried "Launch fullscreen" card that today binds to `events[0]` regardless of which event you came from (WEB-QUAL-02) and reads station settings from `localStorage` that silently diverge from Event Settings (WEB-QUAL-03). + +Target IA: + +- **Home = operational.** Next/running event with readiness state and a live check-in pulse when an event is active; recent events; quick actions. (Whether Home is a page or Events-list-as-home is a design decision — see Open Questions.) +- **Event workspace with a readiness model.** The six sections become a visible pipeline — Attendees → Badge → Zones → Staff → Equipment check → Check-in — each with a readiness chip (empty / in progress / ready), aggregating to an "event is ready" state. Zones stay optional (single-zone events must not pay a complexity tax). +- **Org level:** Team, Equipment, Organization settings (+ usage vs plan limits in SaaS). +- **Check-in launch is a ceremony:** explicit event confirmation, station settings fetched from the server (one source of truth), printer check, then fullscreen. + +## 4. Key Design Problems to Solve + +- **One status language.** Today there are four bespoke role/status badge treatments, emoji-as-status (`🚫`, `✓`), and color-only verdicts (whole-screen green/yellow/red on check-in). Define a single token-based status/verdict vocabulary — shared semantics with the mobile app's `VerdictBand` (`allowed` / `no_access` / `not_registered` / already-checked-in) — always icon + text + color, WCAG 1.4.1 clean, with `aria-live` on the kiosk verdict swap. +- **Scale honestly.** Attendee lists fetch everything and filter in memory; there is no pagination anywhere. Design every list (attendees first) for server-side search/filter/pagination and thousands of rows: sticky bulk actions, virtualized tables where needed, CSV import with progress and per-row error reporting (large files currently freeze the tab — WEB-BUG-07). +- **Destructive actions deserve ceremony.** Four surfaces still use native `confirm()` while a superior typed-confirm ceremony (`useTypedConfirmGate`, `ConfirmActionDialog`) already exists but is console-only. Promote it: one confirm component, severity-tiered (plain confirm → typed confirm for data loss: delete attendees, regenerate codes, delete zone, delete event — currently a permanently disabled placeholder button). +- **The badge editor must become a real tool.** 1,135 lines with three hand-rolled `fixed inset-0` modals (no focus trap/Escape), hardcoded Russian labels, hardcoded `#009246` canvas chrome, and no unsaved-changes guard (WEB-BUG-03: navigation silently destroys a layout). Redesign as a proper editor shell: canvas + panels (elements, properties, label setup), design-system dialogs, dirty-state guard, autosave or explicit save model, i18n'd copy, printer-font flow integrated with agent state. +- **Check-in correctness is a UX property.** Duplicate scans double-print (WEB-BUG-01/02), failed scans vanish after a 3s toast, and there is no offline/degraded handling (an orphaned `offlineMode` i18n key ships with no feature). Design: debounced single-flight scanning, "already checked in" as a first-class verdict (with timestamp + reprint action, never an auto-reprint), a persistent recent-scans rail so nothing is lost, and an explicit degraded-connection banner with retry semantics. Full offline queueing stays with mobile/desktop kiosks. +- **Make the agent a citizen of the UI.** One `AgentStatus` model (connected / disconnected / stale) surfaced consistently: in Equipment, in the badge editor's print controls, in the check-in ceremony. Guided setup flows per device class (network printer, USB/COM scanner, camera) instead of one 1,002-line wall; saved printers must survive reloads (WEB-QUAL-04) and the "default printer" rule must be single-sourced (WEB-QUAL-05). +- **Edition & subscription awareness.** On-prem: hide self-signup, plans, and platform links entirely (shell reads `/api/instance`). SaaS: show usage vs limits (reuse the console's meter utilities/`BarRow`), trial state, and a human-readable **tenant-suspended** screen for `403 tenant_suspended` (today: raw failure); keep the impersonation banner exactly as specced in the console brief — it belongs to this app's design system. +- **States are a contract, not an accident.** One shared loading (token-based skeletons — the only skeleton today is hardcoded `bg-gray-200`, invisible in dark mode), one `EmptyState` pattern (icon + explanation + CTA that teaches the pipeline), one error pattern (inline + retry; errors currently go to `console.error` and the UI shows nothing), plus a top-level ErrorBoundary so one bad date never white-screens check-in (WEB-BUG-08). +- **Responsive stance.** Primary nav is `hidden md:flex` with no hamburger — on a phone the app is chromeless; the event sidebar is a fixed 256px column. Decide and design: desktop-first for prep, but nav collapses to a drawer, and check-in + monitoring are fully usable on a tablet. + +## 5. Check-in Station (highest-stakes surface) + +The fullscreen station gets its own spec: launch ceremony (event + station settings from server + printer confirm) → scan loop (hardware scanner via `useScanner`, camera, or search-first fallback) → verdict screen (XXL, icon+text+color, auto-dismiss with countdown, manual dismiss) → recent-scans rail with per-entry reprint/undo affordances → header with live counters (checked-in / total, per-zone when zones exist) → degraded-mode banner. Kiosk chrome: no app nav, explicit exit, wake-lock awareness. Settings changes made at the station write back to the server, not `localStorage`. + +## 6. Visual Identity + +- **Same brand, calmer expert surface.** Keep the token system: green `--primary: hsl(152 100% 29%)` (dark: `152 50% 42%`), shadcn variables, `--radius: 0.5rem`, light + dark. The customer panel keeps **light chrome** — the dark top-chrome is the console's signature; the two contexts must stay unmistakably distinct. +- **Typography is currently nothing.** No font is loaded at all (system default) and heading sizes drift per page. Adopt **Inter** (mobile already standardized on it) with a defined scale (page title / section / card title / body / caption) — one shared type ramp for the panel. +- **Add the missing semantic tokens.** Only `--destructive` exists today, which is *why* 17 files hardcode raw greens/ambers/blues (50 hits in CSV import alone). Introduce `success` / `warning` / `info` token families (light+dark) plus the shared verdict set, then purge hardcoded hexes including `#009246` in the Konva canvas. +- **Density:** comfortable for prep surfaces (organizers are occasional users, not daily operators), compact for data tables, XXL for kiosk verdicts. +- **Iconography:** lucide only; no emoji as UI. Status chips never rely on color alone. +- **Language:** full EN/RU parity via i18n keys — including zod validation messages (now hardcoded EN) and the badge editor (now hardcoded RU). + +## 7. Technical & UX Constraints + +- Stack: React 18 + Vite + Tailwind v4 (CSS-first tokens; migrate the hand-rolled `@layer utilities` re-declarations to proper `@theme` mapping as part of foundation), vendored shadcn/ui + Radix, lucide, sonner toasts, RHF + zod, react-i18next (flat keys, EN/RU), Konva (badge canvas), PapaParse (CSV), local agent API for devices. +- **This brief owns the shared primitives** the console brief borrows: Tabs and DataTable (sort/filter/server-side pagination), plus PageHeader, StatusPill, EmptyState, Skeleton, ConfirmDialog, Stepper/Wizard, Meter, AgentStatus. Currently absent primitives: tabs, data-table, skeleton, pagination, breadcrumb, form wrapper, radio-group, progress. +- Reuse from the console redesign where semantics match: `useTypedConfirmGate`, meter utilities (`lib/meters.ts`, `BarRow`), `useScrollSpy` (long-scroll + anchor rail pattern for Event Settings), sheet, command palette infra. +- Server-side list endpoints (attendees search/pagination) require backend coordination — design must not assume in-memory data. +- Session model: JWT in `localStorage` today, flagged to move (WEB-SEC-04/05); don't couple designs to synchronous client-side auth state; route guards may re-verify server-side. QR codes are always rendered locally (never third-party chart APIs). +- Concurrency: save flows must stop full-PUTting stale snapshots (WEB-BUG-04) — design saves as scoped updates with conflict awareness where relevant (badge editor, event settings). +- CSV import must handle Windows-1251 gracefully (encoding detection or explicit choice — WEB-BUG-05) and parse off the main thread with progress. +- Accessibility: WCAG AA; all dialogs from the Dialog primitive (focus trap, Escape); keyboard-complete tables and editor panels; `aria-live` for verdicts and toasts. +- Tests: vitest + Testing Library infra exists (added by console batches); redesigned components ship with tests — the audit flagged zero web coverage as the main refactor risk. + +## 8. Success Metrics + +- A new organizer reaches a check-in-ready event (attendees imported, badge designed, station tested) in **≤ 30 minutes without documentation**; readiness state visible at every step. +- Event day: scan → verdict **< 1s perceived**; **zero wrong-event check-ins** (explicit binding); a repeated scan **never double-prints**; no scan outcome is lost silently (recent-scans rail). +- Attendee management stays fluid at **5,000+ attendees** (server-side operations, no full-list fetches). +- WCAG AA on all redesigned surfaces; every status readable without color; zero native `confirm()` left. +- 100% of UI strings through i18n keys in both EN and RU; one StatusPill, one ConfirmDialog, one EmptyState implementation panel-wide. + +## 9. Deliverables + +1. **Foundation:** semantic token extension (success/warning/info + verdict set), Inter type ramp, dark/light parity, `@theme` migration — documented in both themes. +2. **Component kit:** DataTable, Tabs, PageHeader, StatusPill, EmptyState, Skeleton, ConfirmDialog (severity-tiered), Stepper/Wizard, Meter, AgentStatus indicator, mobile nav drawer. +3. **App shell:** global nav (desktop + drawer), org switcher, edition-aware menu, impersonation banner integration, suspended-tenant screen. +4. **Home:** operational dashboard (or Events-as-home per decision) with readiness + live pulse. +5. **Event workspace:** readiness-driven navigation + section designs — Attendees at scale (list, filters, bulk ops, import wizard with progress/error report, attendee card with movement timeline), Zones (CRUD, access rules, staff assignments), Staff, Settings (decomposed long-scroll with anchor rail, real danger zone). +6. **Badge editor:** full redesign — editor shell, element/property panels, fonts & printer flows, save model with dirty guard, ZPL preview. +7. **Check-in station:** launch ceremony, scan loop, verdict screens, recent-scans rail, degraded mode, station settings (server-sourced). +8. **Equipment hub:** agent state model, guided per-device setup and test flows. +9. **Auth set:** login / register (SaaS-only) / QR staff login, password strength signal, edition awareness. +10. **Interaction spec:** loading/empty/error contracts, toast usage, confirmation tiers, keyboard patterns. + +## 10. Out of Scope + +The platform console (own brief; batches 1–3 shipped), the marketing landing, mobile apps and the desktop/Tauri kiosk (own redesign tracks), customer-facing billing/upgrade and payment-provider UI (post-v1; billing is operator-side in the console), license-key activation (deferred in the dual-distribution plan), full offline-first web check-in (offline ownership sits with mobile/desktop kiosks; web gets graceful degradation only), and backend feature work beyond the contracts listed in §7. + +## 11. Open Questions (for product owner) + +1. **Home:** dedicated operational dashboard, or Events list as the landing surface with a live strip for the running event? +2. **Web check-in ambition:** long-term first-class station (invest in browser scanner/camera UX) or registration-desk fallback while mobile/desktop kiosks are primary? +3. **Tablet:** target for prep surfaces too, or only for check-in + monitoring? +4. **Team model:** keep global Users + per-event Staff as two surfaces, or unify into one Team area with per-event assignment? +5. **Attendee card:** does v1 need a full attendee detail view (timeline, per-zone access editing), or do row-level dialogs suffice? diff --git a/docs/superpowers/plans/2026-07-13-panel-p0.1-workspace-ui-foundation.md b/docs/superpowers/plans/2026-07-13-panel-p0.1-workspace-ui-foundation.md new file mode 100644 index 00000000..1c2f5c19 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-panel-p0.1-workspace-ui-foundation.md @@ -0,0 +1,1880 @@ +# Panel P0.1 — Workspace & @idento/ui Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create the npm workspace and the shared `@idento/ui` package — design tokens (light+dark), Inter type ramp, and the board-1b component kit — fully tested, with agent rules in the AGENTS.md chain. + +**Architecture:** `packages/ui` is an internal workspace package consumed as TypeScript source (no build/publish step; Vite in consumer apps transpiles it). It contains only tokens (`theme.css`), presentational primitives, and the verdict vocabulary. Tokens follow the shadcn Tailwind-v4 pattern: raw CSS custom properties in `:root`/`.dark`, mapped once inside `@theme inline`. + +**Tech Stack:** TypeScript ~5.6 (strict), React 19 (devDep for tests; peer `>=18`), Radix UI, class-variance-authority, tailwind-merge, lucide-react, Tailwind v4 CSS-first tokens, vitest 4 + Testing Library + jsdom. + +**Spec:** [docs/superpowers/specs/2026-07-13-panel-rewrite-design.md](../specs/2026-07-13-panel-rewrite-design.md). This is plan 1 of 3 for phase P0 (P0.2 = panel scaffold/shell/auth, P0.3 = openapi truth-up + CI/Docker). + +## Global Constraints + +- Package name: `@idento/ui`, path `packages/ui`, private, `"type": "module"`, exports source directly (`"." → ./src/index.ts`, `"./theme.css" → ./src/theme.css`). +- `peerDependencies`: `react >=18`, `react-dom >=18`. **No React-19-only APIs** (the desktop kiosk is React 18). +- **No hardcoded colors** anywhere in `src/` except `src/theme.css` (guarded by a test written in Task 2). +- **No i18n / data-fetching / router dependencies** in the package — every user-facing string arrives via props (guarded by ESLint `no-restricted-imports`, Task 9). +- Icons: `lucide-react` only. Status/verdict UI always renders **icon + text + color** (WCAG 1.4.1). +- Token values come verbatim from board 1a: `--success #00935E/#2FD598`, `--warning #D97706/#FBBF24`, `--info #2563EB/#7BA6F7`, `--destructive #DC2626/#F87171`, primary `#00935E` hover `#00714A`, radius `0.5rem`. +- Verdict vocabulary (shared with mobile `VerdictBand`): `allowed | no_access | not_registered | already_checked_in`; repeat scan maps to the **info/blue** family. +- Tests colocated as `src/**/*.test.{ts,tsx}`; run with `npm test -w @idento/ui` from the repo root. All commands in this plan run from the repo root unless stated. +- Commit style: conventional commits (`feat:`, `test:`, `docs:`, `chore:`). +- Node >= 24, npm workspaces (no pnpm/yarn/turbo). +- **Mandatory:** repo-root `.npmrc` pinning the standard registry (`registry=https://registry.npmjs.com/`) — same convention as the existing `web/.npmrc` and `desktop/.npmrc`; the workspace root file covers all workspace packages. +- **Explicitly deferred, do not build here:** DataTable, Stepper/Wizard, Meter, PageHeader, Tabs, XXL verdict banner, readiness-aggregate component — each lands in `packages/ui` with its first consumer phase (P0.2/P1/P2/P4). Panel scaffold, i18n, theming switcher = P0.2. CI = P0.3. + +--- + +### Task 1: npm workspace root + `packages/ui` scaffold + +**Files:** +- Create: `package.json` (repo root) +- Create: `.npmrc` (repo root) +- Create: `packages/ui/package.json` +- Create: `packages/ui/tsconfig.json` +- Create: `packages/ui/vitest.config.ts` +- Create: `packages/ui/src/test/setup.ts` +- Create: `packages/ui/src/lib/cn.ts` +- Test: `packages/ui/src/lib/cn.test.ts` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: `cn(...inputs: ClassValue[]): string` from `src/lib/cn.ts` — every later component imports it as `import { cn } from "../lib/cn"`. Workspace command `npm test -w @idento/ui` works. + +- [ ] **Step 1: Create the workspace root files** + +`package.json` (repo root — new file; `panel` joins the workspaces array in P0.2): + +```json +{ + "name": "idento", + "private": true, + "workspaces": ["packages/*"] +} +``` + +`.npmrc` (repo root, same registry as web/): + +``` +registry=https://registry.npmjs.com/ +``` + +- [ ] **Step 2: Scaffold the package** + +`packages/ui/package.json`: + +```json +{ + "name": "@idento/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./theme.css": "./src/theme.css" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "eslint ." + }, + "dependencies": { + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.556.0", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.3.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@eslint/js": "^9.15.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.15.0", + "jsdom": "^25.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "~5.6.3", + "typescript-eslint": "^8.15.0", + "vitest": "^4.1.10" + } +} +``` + +`packages/ui/tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["src"] +} +``` + +`packages/ui/vitest.config.ts`: + +```ts +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "jsdom", + globals: true, + setupFiles: "./src/test/setup.ts", + }, +}); +``` + +`packages/ui/src/test/setup.ts`: + +```ts +import "@testing-library/jest-dom/vitest"; +``` + +- [ ] **Step 3: Install and write the failing `cn` test** + +Run: `npm install` +Expected: lockfile `package-lock.json` created at the repo root, `node_modules` hoisted at root. + +`packages/ui/src/lib/cn.test.ts`: + +```ts +import { cn } from "./cn"; + +describe("cn", () => { + it("merges class strings", () => { + expect(cn("a", "b")).toBe("a b"); + }); + + it("drops falsy values", () => { + expect(cn("a", false && "b", undefined, "c")).toBe("a c"); + }); + + it("resolves tailwind conflicts (last wins)", () => { + expect(cn("p-2", "p-4")).toBe("p-4"); + expect(cn("bg-success", "bg-destructive")).toBe("bg-destructive"); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `npm test -w @idento/ui` +Expected: FAIL — `Cannot find module './cn'`. + +- [ ] **Step 5: Implement `cn`** + +`packages/ui/src/lib/cn.ts`: + +```ts +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `npm test -w @idento/ui` +Expected: PASS (3 tests). + +- [ ] **Step 7: Commit** + +```bash +git add package.json .npmrc package-lock.json packages/ui +git commit -m "feat(ui): npm workspace root + @idento/ui package scaffold with cn()" +``` + +--- + +### Task 2: Design tokens (`theme.css`) + guard tests + +**Files:** +- Create: `packages/ui/src/theme.css` +- Test: `packages/ui/src/theme.test.ts` +- Test: `packages/ui/src/no-hardcoded-colors.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `@idento/ui/theme.css` — the single CSS entry apps import (it itself imports Tailwind). Tailwind utility names available to all components: `bg-background bg-card bg-muted bg-primary bg-primary-hover bg-secondary bg-destructive bg-success bg-warning bg-info bg-verdict-allowed bg-verdict-no-access bg-verdict-not-registered bg-verdict-repeat` (+ matching `text-*`, `border-*`, `ring-*`, each with `*-foreground` companions), radii `rounded-sm|md|lg`, type-ramp utilities `text-page-title text-section-title text-card-title text-body text-caption text-code`. + +- [ ] **Step 1: Write the failing token tests** + +`packages/ui/src/theme.test.ts`: + +```ts +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const css = readFileSync(join(HERE, "theme.css"), "utf8"); + +const SEMANTIC_TOKENS = [ + "--background", "--foreground", "--card", "--popover", "--primary", + "--primary-hover", "--secondary", "--muted", "--muted-foreground", + "--accent", "--destructive", "--success", "--warning", "--info", + "--border", "--input", "--ring", "--radius", +]; + +function block(selector: string): string { + const start = css.indexOf(selector); + expect(start, `${selector} block missing`).toBeGreaterThan(-1); + return css.slice(start, css.indexOf("}", css.indexOf("{", start))); +} + +describe("theme.css tokens", () => { + it("defines every semantic token in :root (light)", () => { + const root = block(":root"); + for (const t of SEMANTIC_TOKENS) expect(root, `${t} missing in :root`).toContain(`${t}:`); + }); + + it("defines every semantic token in .dark", () => { + const dark = block(".dark"); + for (const t of SEMANTIC_TOKENS.filter((t) => t !== "--radius")) { + expect(dark, `${t} missing in .dark`).toContain(`${t}:`); + } + }); + + it("uses the board-1a palette values", () => { + const root = block(":root"); + expect(root).toContain("--success: #00935e"); + expect(root).toContain("--warning: #d97706"); + expect(root).toContain("--info: #2563eb"); + expect(root).toContain("--destructive: #dc2626"); + expect(root).toContain("--primary-hover: #00714a"); + const dark = block(".dark"); + expect(dark).toContain("--success: #2fd598"); + expect(dark).toContain("--warning: #fbbf24"); + expect(dark).toContain("--info: #7ba6f7"); + expect(dark).toContain("--destructive: #f87171"); + }); + + it("maps verdict tokens onto semantic families in @theme", () => { + expect(css).toContain("--color-verdict-allowed: var(--success)"); + expect(css).toContain("--color-verdict-no-access: var(--destructive)"); + expect(css).toContain("--color-verdict-not-registered: var(--warning)"); + expect(css).toContain("--color-verdict-repeat: var(--info)"); + }); + + it("defines the Inter type ramp utilities", () => { + for (const u of ["text-page-title", "text-section-title", "text-card-title", "text-body", "text-caption", "text-code"]) { + expect(css).toContain(`@utility ${u}`); + } + expect(css).toContain('"Inter Variable"'); + }); +}); +``` + +`packages/ui/src/no-hardcoded-colors.test.ts`: + +```ts +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const EXEMPT = new Set(["theme.css"]); +const HEX = /#[0-9a-fA-F]{3,8}\b/; +const RGB = /\brgba?\(/; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else out.push(p); + } + return out; +} + +describe("no hardcoded colors outside theme.css", () => { + const files = walk(HERE).filter( + (f) => !EXEMPT.has(f.split("/").pop()!) && /\.(ts|tsx|css)$/.test(f), + ); + + it.each(files)("%s has no hex/rgb literals", (file) => { + const content = readFileSync(file, "utf8"); + expect(content).not.toMatch(HEX); + expect(content).not.toMatch(RGB); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -w @idento/ui` +Expected: `theme.test.ts` FAILS (`ENOENT ... theme.css`); `no-hardcoded-colors.test.ts` PASSES (only existing files scanned). + +- [ ] **Step 3: Write `theme.css`** + +`packages/ui/src/theme.css` (complete file): + +```css +@import "tailwindcss"; +@import "tw-animate-css"; /* animate-in / slide-in-* utilities used by Dialog & Sheet */ +@source "./"; + +:root { + --background: #fafafa; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --popover: #ffffff; + --popover-foreground: #18181b; + --primary: #00935e; + --primary-hover: #00714a; + --primary-foreground: #ffffff; + --secondary: #f4f4f5; + --secondary-foreground: #18181b; + --muted: #f4f4f5; + --muted-foreground: #71717a; + --accent: #f0faf5; + --accent-foreground: #00714a; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --success: #00935e; + --success-foreground: #ffffff; + --warning: #d97706; + --warning-foreground: #ffffff; + --info: #2563eb; + --info-foreground: #ffffff; + --border: #e4e4e7; + --input: #e4e4e7; + --ring: #00935e; + --radius: 0.5rem; +} + +.dark { + --background: #101012; + --foreground: #f4f4f5; + --card: #18181b; + --card-foreground: #f4f4f5; + --popover: #18181b; + --popover-foreground: #f4f4f5; + --primary: #36a16f; + --primary-hover: #2c8a5e; + --primary-foreground: #ffffff; + --secondary: #27272a; + --secondary-foreground: #f4f4f5; + --muted: #27272a; + --muted-foreground: #a1a1aa; + --accent: #16281f; + --accent-foreground: #2fd598; + --destructive: #f87171; + --destructive-foreground: #18181b; + --success: #2fd598; + --success-foreground: #052e1c; + --warning: #fbbf24; + --warning-foreground: #2b1c02; + --info: #7ba6f7; + --info-foreground: #0c1c3d; + --border: #27272a; + --input: #27272a; + --ring: #2fd598; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-verdict-allowed: var(--success); + --color-verdict-no-access: var(--destructive); + --color-verdict-not-registered: var(--warning); + --color-verdict-repeat: var(--info); + --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; + --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +/* Type ramp — board 1a (px values expressed in rem @16px base) */ +@utility text-page-title { + font-size: 1.25rem; + line-height: 1.3; + font-weight: 700; + letter-spacing: -0.01em; +} +@utility text-section-title { + font-size: 1rem; + line-height: 1.4; + font-weight: 700; +} +@utility text-card-title { + font-size: 0.84375rem; + line-height: 1.4; + font-weight: 600; +} +@utility text-body { + font-size: 0.8125rem; + line-height: 1.55; + font-weight: 400; +} +@utility text-caption { + font-size: 0.71875rem; + line-height: 1.45; + font-weight: 400; +} +@utility text-code { + font-family: var(--font-mono); + font-size: 0.65625rem; + line-height: 1.4; +} +``` + +Note: the Inter font files themselves ship with the consumer app (`@fontsource-variable/inter`, added in P0.2 — self-hosted, no CDN for on-prem); this file only declares the family. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -w @idento/ui` +Expected: PASS — all `theme.test.ts` assertions green; `no-hardcoded-colors` still green (theme.css exempt). + +- [ ] **Step 5: Commit** + +```bash +git add packages/ui/src +git commit -m "feat(ui): design tokens light+dark, verdict mapping, Inter type ramp (board 1a)" +``` + +--- + +### Task 3: Form primitives — Button, Label, Input, Card + +**Files:** +- Create: `packages/ui/src/components/button.tsx` +- Create: `packages/ui/src/components/label.tsx` +- Create: `packages/ui/src/components/input.tsx` +- Create: `packages/ui/src/components/card.tsx` +- Test: `packages/ui/src/components/button.test.tsx` +- Test: `packages/ui/src/components/input.test.tsx` + +**Interfaces:** +- Consumes: `cn` (Task 1), token classes (Task 2). +- Produces: `Button` (`variant: default|destructive|outline|secondary|ghost|link`, `size: default|sm|lg|icon`, `asChild`), `buttonVariants`, `Label`, `Input`, `Card CardHeader CardTitle CardDescription CardContent CardFooter`. All forwardRef, all accept `className`. + +- [ ] **Step 1: Write the failing tests** + +`packages/ui/src/components/button.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { Button } from "./button"; + +describe("Button", () => { + it("renders a button with its label", () => { + render(); + expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + + it("applies variant classes from tokens", () => { + render(); + expect(screen.getByRole("button")).toHaveClass("bg-destructive"); + }); + + it("renders as child element with asChild", () => { + render( + , + ); + expect(screen.getByRole("link", { name: "Link" })).toHaveClass("bg-primary"); + }); + + it("is disabled when disabled", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); +}); +``` + +`packages/ui/src/components/input.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { Input } from "./input"; +import { Label } from "./label"; + +describe("Input", () => { + it("is reachable by its label", () => { + render( + <> + + + , + ); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + }); + + it("forwards native props", () => { + render(); + expect(screen.getByPlaceholderText("you@example.com")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -w @idento/ui` +Expected: FAIL — `Cannot find module './button'` (and `./input`). + +- [ ] **Step 3: Implement the components** + +`packages/ui/src/components/button.tsx`: + +```tsx +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; +import { cn } from "../lib/cn"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-body font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary-hover", + destructive: "bg-destructive text-destructive-foreground hover:opacity-90", + outline: "border border-border bg-card text-foreground hover:bg-muted", + secondary: "bg-secondary text-secondary-foreground hover:opacity-90", + ghost: "text-foreground hover:bg-muted", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3", + lg: "h-10 rounded-md px-6", + icon: "size-9", + }, + }, + defaultVariants: { variant: "default", size: "default" }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ; + }, +); +Button.displayName = "Button"; + +export { buttonVariants }; +``` + +`packages/ui/src/components/label.tsx`: + +```tsx +import * as React from "react"; +import { cn } from "../lib/cn"; + +export const Label = React.forwardRef>( + ({ className, ...props }, ref) => ( +