From 21f62cd28191b0e47b9b2f9838272dbd84280910 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Fri, 10 Jul 2026 19:21:07 -0600 Subject: [PATCH 01/13] fix(start): correct organizer panel semantics --- .../-components/check-in-instructions.tsx | 54 +++++++++++++++++++ .../-components/results-load-error.tsx | 32 +++++++++++ .../organizer/$competitionId/check-in.tsx | 48 ++++++----------- .../organizer/$competitionId/results.tsx | 7 +-- .../organizer-semantic-panels.test.tsx | 47 ++++++++++++++++ lat.md/organizer-dashboard.md | 14 ++++- 6 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/check-in-instructions.tsx create mode 100644 apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/results-load-error.tsx create mode 100644 apps/wodsmith-start/test/routes/compete/organizer-semantic-panels.test.tsx diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/check-in-instructions.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/check-in-instructions.tsx new file mode 100644 index 000000000..d38af13da --- /dev/null +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/check-in-instructions.tsx @@ -0,0 +1,54 @@ +import { ClipboardCheck, ExternalLink } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +interface CheckInInstructionsProps { + competitionSlug: string +} + +export function CheckInInstructions({ + competitionSlug, +}: CheckInInstructionsProps) { + const titleId = "check-in-instructions-title" + + return ( +
+ + +
+ +
+ +

+ Day-of check-in +

+
+ + Run check-in from a shared device at the door. Search for an athlete + and tap check in to mark their whole team as arrived. Athletes can + sign any missing waivers right on the device. Volunteers on this + competition can also run the kiosk from their volunteer dashboard. + +
+ + + +
+
+ ) +} diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/results-load-error.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/results-load-error.tsx new file mode 100644 index 000000000..ee023df71 --- /dev/null +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/-components/results-load-error.tsx @@ -0,0 +1,32 @@ +import { AlertTriangle } from "lucide-react" +import { Alert } from "@/components/ui/alert" +import { Button } from "@/components/ui/button" + +interface ResultsLoadErrorProps { + onRetry: () => void +} + +export function ResultsLoadError({ onRetry }: ResultsLoadErrorProps) { + return ( + + +
+

+ Unable to load results +

+

+ Unable to load score entry data. Please try again. +

+ +
+
+ ) +} diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/check-in.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/check-in.tsx index da4519cf6..8688e040a 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/check-in.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/check-in.tsx @@ -8,46 +8,30 @@ // @lat: [[organizer-dashboard#Check-In Kiosk]] import { createFileRoute, getRouteApi, redirect } from "@tanstack/react-router" -import { ClipboardCheck, ExternalLink } from "lucide-react" -import { OrganizerEmptyState } from "@/components/organizer/empty-state" import { canUseDayOfCheckIn } from "@/lib/competitions/scheduling-check-in-gates" +import { CheckInInstructions } from "./-components/check-in-instructions" const parentRoute = getRouteApi("/compete/organizer/$competitionId") -export const Route = createFileRoute("/compete/organizer/$competitionId/check-in")( - { - loader: async ({ params, parentMatchPromise }) => { - const parentMatch = await parentMatchPromise - const competition = parentMatch.loaderData?.competition +export const Route = createFileRoute( + "/compete/organizer/$competitionId/check-in", +)({ + loader: async ({ params, parentMatchPromise }) => { + const parentMatch = await parentMatchPromise + const competition = parentMatch.loaderData?.competition - if (!competition || !canUseDayOfCheckIn(competition.competitionType)) { - throw redirect({ - to: "/compete/organizer/$competitionId", - params: { competitionId: params.competitionId }, - }) - } - }, - component: CheckInLandingPage, + if (!competition || !canUseDayOfCheckIn(competition.competitionType)) { + throw redirect({ + to: "/compete/organizer/$competitionId", + params: { competitionId: params.competitionId }, + }) + } }, -) + component: CheckInLandingPage, +}) function CheckInLandingPage() { const { competition } = parentRoute.useLoaderData() - return ( - } - onAction={() => - window.open( - `/compete/${competition.slug}/check-in`, - "_blank", - "noopener,noreferrer", - ) - } - /> - ) + return } diff --git a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/results.tsx b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/results.tsx index 1b9653f88..0ac518438 100644 --- a/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/results.tsx +++ b/apps/wodsmith-start/src/routes/compete/organizer/$competitionId/results.tsx @@ -51,6 +51,7 @@ import { import { getSubmissionCountsByEventFn } from "@/server-fns/video-submission-fns" import { cn } from "@/utils/cn" import { formatTrackOrder } from "@/utils/format-track-order" +import { ResultsLoadError } from "./-components/results-load-error" // Get parent route API to access competition data const parentRoute = getRouteApi("/compete/organizer/$competitionId") @@ -766,11 +767,7 @@ function InPersonResultsEntry({ Enter scores for competition events

- + router.invalidate()} /> ) } diff --git a/apps/wodsmith-start/test/routes/compete/organizer-semantic-panels.test.tsx b/apps/wodsmith-start/test/routes/compete/organizer-semantic-panels.test.tsx new file mode 100644 index 000000000..c1c802407 --- /dev/null +++ b/apps/wodsmith-start/test/routes/compete/organizer-semantic-panels.test.tsx @@ -0,0 +1,47 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" +import { CheckInInstructions } from "@/routes/compete/organizer/$competitionId/-components/check-in-instructions" +import { ResultsLoadError } from "@/routes/compete/organizer/$competitionId/-components/results-load-error" + +describe("organizer semantic panels", () => { + // @lat: [[organizer-dashboard#Semantic Organizer Panels Tests#Check-In Instructions Use Link Semantics]] + it("presents check-in instructions with a new-tab kiosk link", () => { + render() + + const section = screen.getByRole("region", { name: "Day-of check-in" }) + expect(section).toBeInTheDocument() + expect( + screen.getByRole("heading", { level: 2, name: "Day-of check-in" }), + ).toBeInTheDocument() + expect( + screen.getByText(/tap check in to mark their whole team as arrived/i), + ).toBeInTheDocument() + + const kioskLink = screen.getByRole("link", { + name: /open check-in kiosk/i, + }) + expect(kioskLink).toHaveAttribute( + "href", + "/compete/test-throwdown/check-in", + ) + expect(kioskLink).toHaveAttribute("target", "_blank") + expect(kioskLink).toHaveAttribute("rel", "noopener noreferrer") + }) + + // @lat: [[organizer-dashboard#Semantic Organizer Panels Tests#Results Load Failure Exposes Retry]] + it("announces a results load failure and retries through the supplied action", () => { + const onRetry = vi.fn() + + render() + + const alert = screen.getByRole("alert") + expect(alert).toHaveTextContent("Unable to load results") + expect(alert).toHaveTextContent( + "Unable to load score entry data. Please try again.", + ) + + fireEvent.click(screen.getByRole("button", { name: "Try again" })) + expect(onRetry).toHaveBeenCalledOnce() + }) +}) diff --git a/lat.md/organizer-dashboard.md b/lat.md/organizer-dashboard.md index f95db2097..60e5bcd94 100644 --- a/lat.md/organizer-dashboard.md +++ b/lat.md/organizer-dashboard.md @@ -141,7 +141,19 @@ Defaults: when `divisionResults` is absent entirely, online competitions treat e For in-person competitions only, the "Run Competition" sidebar exposes a "Check-in" link to an organizer landing page that explains the flow and opens the volunteer-facing kiosk in a new tab. -The sidebar link ([[apps/wodsmith-start/src/components/competition-sidebar.tsx]]) is a normal internal `` to `/compete/organizer/{competitionId}/check-in`. That landing page ([[apps/wodsmith-start/src/routes/compete/organizer/$competitionId/check-in.tsx]]) renders an [[apps/wodsmith-start/src/components/organizer/empty-state.tsx#OrganizerEmptyState]] describing how day-of check-in works (one tap checks in the whole team, athletes sign missing waivers on the device, volunteers can run the kiosk from their own accounts) with an "Open check-in kiosk" action that `window.open`s `/compete/{slug}/check-in` in a new tab so organizers keep their dashboard. The overview's Registrations card "Go to Check-In" button points at the same landing page. Online competitions hide the sidebar link, and the landing route's loader redirects them back to the organizer overview. Instructions live only on this organizer page — the public kiosk route stays instruction-free for volunteers. Permission gating (`MANAGE_COMPETITIONS` or volunteer role on the competition team) lives on the kiosk route's loader and on every check-in server function. See [[registration#Day-of Check-In]] for the kiosk's behavior and data model. +The sidebar link ([[apps/wodsmith-start/src/components/competition-sidebar.tsx]]) is a normal internal `` to `/compete/organizer/{competitionId}/check-in`. That landing page ([[apps/wodsmith-start/src/routes/compete/organizer/$competitionId/check-in.tsx]]) renders a route-owned instructional card describing how day-of check-in works (one tap checks in the whole team, athletes sign missing waivers on the device, volunteers can run the kiosk from their own accounts). Its "Open check-in kiosk" action is a real anchor to `/compete/{slug}/check-in` with `target="_blank"` and `rel="noopener noreferrer"`, so organizers can keep their dashboard and browsers expose normal link behavior. The overview's Registrations card "Go to Check-In" button points at the same landing page. Online competitions hide the sidebar link, and the landing route's loader redirects them back to the organizer overview. Instructions live only on this organizer page — the public kiosk route stays instruction-free for volunteers. Permission gating (`MANAGE_COMPETITIONS` or volunteer role on the competition team) lives on the kiosk route's loader and on every check-in server function. See [[registration#Day-of Check-In]] for the kiosk's behavior and data model. + +## Semantic Organizer Panels Tests + +These tests keep instructional and error panels semantically distinct from true empty states while preserving their existing route-owned behavior. + +### Check-In Instructions Use Link Semantics + +This test verifies the instructional landing panel uses the route's heading hierarchy and exposes the kiosk launcher as a safe new-tab link without changing its destination or guidance. + +### Results Load Failure Exposes Retry + +This test verifies missing score-entry data is announced as an error and offers a retry action that the organizer results route wires to its existing router invalidation flow. ## Leaderboard Preview From 59151a93ef308925bb968600b284cb46355563d1 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Fri, 10 Jul 2026 19:29:11 -0600 Subject: [PATCH 02/13] refactor(ui): adapt organizer empty states --- .../src/components/organizer/empty-state.tsx | 38 +++-- .../components/organizer-empty-state.test.tsx | 131 ++++++++++++++++++ .../docs/ui-library-inventory.md | 10 +- .../src/components/organizer/empty-state.tsx | 38 +++-- .../components/organizer-empty-state.test.tsx | 128 +++++++++++++++++ .../organizer-empty-state-adapter.md | 28 ++++ lat.md/ui-library.md | 56 +++++++- 7 files changed, 395 insertions(+), 34 deletions(-) create mode 100644 apps/crew/test/components/organizer-empty-state.test.tsx create mode 100644 apps/wodsmith-start/test/components/organizer-empty-state.test.tsx create mode 100644 docs/ui-library/organizer-empty-state-adapter.md diff --git a/apps/crew/src/components/organizer/empty-state.tsx b/apps/crew/src/components/organizer/empty-state.tsx index 4cc7fe491..560b5729e 100644 --- a/apps/crew/src/components/organizer/empty-state.tsx +++ b/apps/crew/src/components/organizer/empty-state.tsx @@ -1,7 +1,7 @@ import type { LucideIcon } from "lucide-react" import type { ReactNode } from "react" import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" +import { EmptyState } from "@/components/ui/empty-state" interface OrganizerEmptyStateProps { variant?: "card" | "plain" @@ -32,16 +32,18 @@ export function OrganizerEmptyState({ (actionLabel && onAction) || (secondaryActionLabel && onSecondaryAction) const content = ( -
-
+ <> + -
-

{title}

-

+ + +

{title}

+ + {description} -

+
{hasActions ? ( -
+ {actionLabel && onAction ? ( ) : null} -
+ ) : null} -
+ ) - if (variant === "plain") return content + if (variant === "plain") { + return ( + {content} + ) + } return ( - - {content} - + +
+ + {content} + +
+
) } diff --git a/apps/crew/test/components/organizer-empty-state.test.tsx b/apps/crew/test/components/organizer-empty-state.test.tsx new file mode 100644 index 000000000..ae74697f3 --- /dev/null +++ b/apps/crew/test/components/organizer-empty-state.test.tsx @@ -0,0 +1,131 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest" + +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { fireEvent, render, screen } from "@testing-library/react" +import { Inbox } from "lucide-react" +import { describe, expect, it, vi } from "vitest" +import { OrganizerEmptyState } from "@/components/organizer/empty-state" + +describe("OrganizerEmptyState", () => { + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Crew adapter#Plain presentation]] + it("preserves the plain presentation with an explicit h3 and decorative icon", () => { + const { container } = render( + , + ) + + const root = container.firstElementChild + expect(root).toHaveClass("px-6", "py-12", "text-center") + expect(root).not.toHaveClass("border") + expect( + screen.getByRole("heading", { level: 3, name: "No heats yet" }), + ).toHaveClass("text-lg", "font-semibold") + expect(screen.getByText("Create heats before assigning judges.")).toHaveClass( + "mt-2", + "max-w-md", + ) + expect(container.querySelector("svg")?.parentElement).toHaveAttribute( + "aria-hidden", + "true", + ) + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Crew adapter#Card presentation]] + it("preserves the bounded card and compatibility content spacing", () => { + const { container } = render( + , + ) + + const card = container.firstElementChild + expect(card).toHaveClass( + "rounded-lg", + "border", + "bg-card", + "text-card-foreground", + "shadow-sm", + ) + expect(card).not.toHaveClass("max-w-xl", "p-8") + expect(card?.firstElementChild).toHaveClass("p-6", "pt-0") + expect(card?.firstElementChild?.firstElementChild).toHaveClass( + "px-6", + "py-12", + ) + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Crew adapter#Incomplete actions]] + it("omits incomplete action pairs", () => { + render( + , + ) + + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Crew adapter#Action behavior]] + it("preserves primary and secondary actions, icons, order, and callbacks", () => { + const onPrimary = vi.fn() + const onSecondary = vi.fn() + + render( + +} + secondaryActionLabel="Import" + onSecondaryAction={onSecondary} + secondaryActionIcon={+} + />, + ) + + const buttons = screen.getAllByRole("button") + expect(buttons.map((button) => button.textContent)).toEqual([ + "+Add division", + "+Import", + ]) + expect(screen.getByTestId("primary-icon")).toBeInTheDocument() + expect(screen.getByTestId("secondary-icon")).toBeInTheDocument() + expect(buttons[1]).toHaveClass("border") + + fireEvent.click(buttons[0]) + fireEvent.click(buttons[1]) + expect(onPrimary).toHaveBeenCalledOnce() + expect(onSecondary).toHaveBeenCalledOnce() + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Crew adapter#Mirrored source parity]] + it("stays byte-identical to the Start compatibility adapter", () => { + const crewSource = readFileSync( + resolve(process.cwd(), "src/components/organizer/empty-state.tsx"), + "utf8", + ) + const startSource = readFileSync( + resolve( + process.cwd(), + "../wodsmith-start/src/components/organizer/empty-state.tsx", + ), + "utf8", + ) + + expect(crewSource).toBe(startSource) + }) +}) diff --git a/apps/wodsmith-start/docs/ui-library-inventory.md b/apps/wodsmith-start/docs/ui-library-inventory.md index ef541ac59..9e3028f38 100644 --- a/apps/wodsmith-start/docs/ui-library-inventory.md +++ b/apps/wodsmith-start/docs/ui-library-inventory.md @@ -6,7 +6,7 @@ Regenerate with `pnpm --filter wodsmith-start ui:inventory` and verify it with ` ## Scope summary -The Start app exposes 38 primitive import paths in `src/components/ui`. They are consumed directly by 181 route-owned files and 149 shared component files. +The Start app exposes 38 primitive import paths in `src/components/ui`. They are consumed directly by 183 route-owned files and 149 shared component files. - `@repo/ui` owns 30 extracted primitive implementations under `packages/ui/src/components`. - 18 primitive modules have representative Storybook stories. @@ -28,19 +28,19 @@ Counts are unique importing files. Route-owned components under `src/routes` cou | Primitive module | Classification | Shared package | Route files | Component files | UI dependencies | Story | | --- | --- | --- | ---: | ---: | ---: | --- | -| `alert` | foundation | yes | 24 | 10 | 0 | yes | +| `alert` | foundation | yes | 25 | 10 | 0 | yes | | `alert-dialog` | composition | — | 18 | 4 | 0 | — | | `avatar` | foundation | yes | 18 | 2 | 0 | yes | | `badge` | foundation | yes | 79 | 64 | 0 | yes | | `breadcrumb` | composition | yes | 1 | 2 | 0 | yes | -| `button` | foundation | yes | 151 | 108 | 4 | yes | +| `button` | foundation | yes | 153 | 108 | 4 | yes | | `calendar` | composition | — | 3 | 1 | 0 | — | -| `card` | foundation | yes | 113 | 50 | 0 | yes | +| `card` | foundation | yes | 114 | 49 | 0 | yes | | `checkbox` | foundation | yes | 23 | 12 | 0 | — | | `collapsible` | composition | yes | 11 | 15 | 0 | yes | | `dialog` | composition | yes | 17 | 23 | 0 | yes | | `dropdown-menu` | composition | yes | 4 | 4 | 0 | yes | -| `empty-state` | foundation | yes | 0 | 0 | 0 | yes | +| `empty-state` | foundation | yes | 0 | 1 | 0 | yes | | `field` | foundation | yes | 3 | 2 | 0 | yes | | `file-upload` | app adapter | — | 0 | 1 | 0 | — | | `form` | composition | yes | 19 | 13 | 0 | yes | diff --git a/apps/wodsmith-start/src/components/organizer/empty-state.tsx b/apps/wodsmith-start/src/components/organizer/empty-state.tsx index 4cc7fe491..560b5729e 100644 --- a/apps/wodsmith-start/src/components/organizer/empty-state.tsx +++ b/apps/wodsmith-start/src/components/organizer/empty-state.tsx @@ -1,7 +1,7 @@ import type { LucideIcon } from "lucide-react" import type { ReactNode } from "react" import { Button } from "@/components/ui/button" -import { Card, CardContent } from "@/components/ui/card" +import { EmptyState } from "@/components/ui/empty-state" interface OrganizerEmptyStateProps { variant?: "card" | "plain" @@ -32,16 +32,18 @@ export function OrganizerEmptyState({ (actionLabel && onAction) || (secondaryActionLabel && onSecondaryAction) const content = ( -
-
+ <> + -
-

{title}

-

+ + +

{title}

+ + {description} -

+
{hasActions ? ( -
+ {actionLabel && onAction ? ( ) : null} -
+ ) : null} -
+ ) - if (variant === "plain") return content + if (variant === "plain") { + return ( + {content} + ) + } return ( - - {content} - + +
+ + {content} + +
+
) } diff --git a/apps/wodsmith-start/test/components/organizer-empty-state.test.tsx b/apps/wodsmith-start/test/components/organizer-empty-state.test.tsx new file mode 100644 index 000000000..24ca795ca --- /dev/null +++ b/apps/wodsmith-start/test/components/organizer-empty-state.test.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom + +import "@testing-library/jest-dom/vitest" + +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { fireEvent, render, screen } from "@testing-library/react" +import { Inbox } from "lucide-react" +import { describe, expect, it, vi } from "vitest" +import { OrganizerEmptyState } from "@/components/organizer/empty-state" + +describe("OrganizerEmptyState", () => { + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Start adapter#Plain presentation]] + it("preserves the plain presentation with an explicit h3 and decorative icon", () => { + const { container } = render( + , + ) + + const root = container.firstElementChild + expect(root).toHaveClass("px-6", "py-12", "text-center") + expect(root).not.toHaveClass("border") + expect( + screen.getByRole("heading", { level: 3, name: "No heats yet" }), + ).toHaveClass("text-lg", "font-semibold") + expect(screen.getByText("Create heats before assigning judges.")).toHaveClass( + "mt-2", + "max-w-md", + ) + expect(container.querySelector("svg")?.parentElement).toHaveAttribute( + "aria-hidden", + "true", + ) + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Start adapter#Card presentation]] + it("preserves the bounded card and compatibility content spacing", () => { + const { container } = render( + , + ) + + const card = container.firstElementChild + expect(card).toHaveClass( + "rounded-lg", + "border", + "bg-card", + "text-card-foreground", + "shadow-sm", + ) + expect(card).not.toHaveClass("max-w-xl", "p-8") + expect(card?.firstElementChild).toHaveClass("p-6", "pt-0") + expect(card?.firstElementChild?.firstElementChild).toHaveClass( + "px-6", + "py-12", + ) + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Start adapter#Incomplete actions]] + it("omits incomplete action pairs", () => { + render( + , + ) + + expect(screen.queryByRole("button")).not.toBeInTheDocument() + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Start adapter#Action behavior]] + it("preserves primary and secondary actions, icons, order, and callbacks", () => { + const onPrimary = vi.fn() + const onSecondary = vi.fn() + + render( + +} + secondaryActionLabel="Import" + onSecondaryAction={onSecondary} + secondaryActionIcon={+} + />, + ) + + const buttons = screen.getAllByRole("button") + expect(buttons.map((button) => button.textContent)).toEqual([ + "+Add division", + "+Import", + ]) + expect(screen.getByTestId("primary-icon")).toBeInTheDocument() + expect(screen.getByTestId("secondary-icon")).toBeInTheDocument() + expect(buttons[1]).toHaveClass("border") + + fireEvent.click(buttons[0]) + fireEvent.click(buttons[1]) + expect(onPrimary).toHaveBeenCalledOnce() + expect(onSecondary).toHaveBeenCalledOnce() + }) + + // @lat: [[lat.md/ui-library#UI Library#Current boundary#Empty state composition#Organizer compatibility adapter#Start adapter#Mirrored source parity]] + it("stays byte-identical to the Crew compatibility adapter", () => { + const startSource = readFileSync( + resolve(process.cwd(), "src/components/organizer/empty-state.tsx"), + "utf8", + ) + const crewSource = readFileSync( + resolve(process.cwd(), "../crew/src/components/organizer/empty-state.tsx"), + "utf8", + ) + + expect(startSource).toBe(crewSource) + }) +}) diff --git a/docs/ui-library/organizer-empty-state-adapter.md b/docs/ui-library/organizer-empty-state-adapter.md new file mode 100644 index 000000000..5241e382e --- /dev/null +++ b/docs/ui-library/organizer-empty-state-adapter.md @@ -0,0 +1,28 @@ +# Organizer empty-state adapter inventory + +This inventory records the audited Start and Crew organizer adapters that now compose the shared `@repo/ui` EmptyState without changing their feature-facing prop API. + +## Audited surface + +The two adapter definitions were byte-identical before migration and remain byte-identical after migration. No feature consumer changes are part of this slice. + +| App | Adapter definition | Consumer files | Render sites | Plain | Default card | +| --- | --- | ---: | ---: | ---: | ---: | +| WODsmith Start | `apps/wodsmith-start/src/components/organizer/empty-state.tsx` | 11 | 16 | 3 | 13 | +| Crew | `apps/crew/src/components/organizer/empty-state.tsx` | 4 | 5 | 3 | 2 | +| Total | 2 mirrored definitions | 15 | 21 | 6 | 15 | + +## Compatibility mapping + +The adapter maps `variant="plain"` to `EmptyState.Root` and the default `variant="card"` to `EmptyState.Card`. Both surfaces compose `Icon`, `Title`, `Description`, and optional `Actions` children. + +- The required icon, title, and description props are unchanged. +- The caller-owned title remains an `h3` at every existing render site. +- Primary and secondary actions still require both their label and callback. +- Buttons retain their order, variants, supplied icons, and callbacks. +- The legacy card's outer presentation and nested `CardContent` spacing are preserved; this slice intentionally does not normalize the audited double padding without page-level visual evidence. +- The icon wrapper is now explicitly decorative with `aria-hidden="true"`; the icon was already visual-only and no accessible label or interactive behavior is removed. + +## Verification contract + +Mirrored Start and Crew tests cover plain and card presentations, incomplete action omission, action ordering and callbacks, icon rendering, heading level, and byte-identical source parity. Shared primitive behavior remains covered by the package EmptyState tests and Storybook stories. diff --git a/lat.md/ui-library.md b/lat.md/ui-library.md index 6e79a3402..deac589a5 100644 --- a/lat.md/ui-library.md +++ b/lat.md/ui-library.md @@ -16,7 +16,7 @@ The shared package now includes a dependency-closed feedback, identity, navigati [[packages/ui/src/components/empty-state.tsx#EmptyState|EmptyState]] owns portable plain and bounded presentation without owning route state, copy, heading level, actions, or live-region policy. -Root and Card are explicit surfaces instead of modes. Icon, Title, Description, and Actions compose caller content inside either surface; Start and Crew expose identity-only adapters without migrating feature consumers in this slice. +Root and Card are explicit surfaces instead of modes. Icon, Title, Description, and Actions compose caller content inside either surface; Start and Crew expose identity-only UI adapters while organizer compatibility adapters preserve the legacy feature API. #### Plain empty state @@ -34,6 +34,60 @@ Title slots exactly one concrete h1 through h6 child so the caller owns document Icon, Title, Description, and Actions fail with a specific error outside Root or Card, preventing detached compound parts from silently losing their composition contract. +#### Organizer compatibility adapter + +The mirrored [[apps/wodsmith-start/src/components/organizer/empty-state.tsx#OrganizerEmptyState|Start]] and [[apps/crew/src/components/organizer/empty-state.tsx#OrganizerEmptyState|Crew]] adapters map the legacy prop API to explicit EmptyState surfaces. + +The [adapter inventory](../docs/ui-library/organizer-empty-state-adapter.md) records all 21 render sites. Plain and card layout, h3 hierarchy, action order, callbacks, icons, and legacy card spacing remain stable; the decorative icon wrapper now has `aria-hidden` semantics. + +##### Start adapter + +Start keeps its organizer feature API stable while the adapter composes the shared primitive. + +###### Plain presentation + +The plain adapter retains centered spacing, h3 hierarchy, description width, and decorative icon treatment without adding a bounded surface. + +###### Card presentation + +The default adapter retains the prior card border, foreground, shadow, and nested content spacing without applying the shared card's default maximum width or padding. + +###### Incomplete actions + +An action renders only when its label and callback are both present, preserving the legacy optional-pair contract. + +###### Action behavior + +Primary and outlined secondary buttons retain their order, supplied icons, and callback behavior. + +###### Mirrored source parity + +The Start source stays byte-identical to the Crew source so both application surfaces keep one compatibility contract. + +##### Crew adapter + +Crew mirrors the Start organizer feature API and shared primitive composition exactly. + +###### Plain presentation + +The plain adapter retains centered spacing, h3 hierarchy, description width, and decorative icon treatment without adding a bounded surface. + +###### Card presentation + +The default adapter retains the prior card border, foreground, shadow, and nested content spacing without applying the shared card's default maximum width or padding. + +###### Incomplete actions + +An action renders only when its label and callback are both present, preserving the legacy optional-pair contract. + +###### Action behavior + +Primary and outlined secondary buttons retain their order, supplied icons, and callback behavior. + +###### Mirrored source parity + +The Crew source stays byte-identical to the Start source so both application surfaces keep one compatibility contract. + ### Field composition [[packages/ui/src/components/field.tsx#Field|Field]] and FieldGroup own portable label, control, description, error, fieldset, and legend semantics without owning validation state or form controllers. From ca6933e417e0e398875c4e5b40a5ef26ccc1e391 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Fri, 10 Jul 2026 20:25:40 -0600 Subject: [PATCH 03/13] fix(ui): preserve organizer empty state actions --- apps/crew/src/components/organizer/empty-state.tsx | 2 +- apps/crew/test/components/organizer-empty-state.test.tsx | 5 +++++ .../src/components/organizer/empty-state.tsx | 2 +- .../test/components/organizer-empty-state.test.tsx | 5 +++++ docs/ui-library/organizer-empty-state-adapter.md | 8 ++++---- lat.md/ui-library.md | 6 +++--- 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/crew/src/components/organizer/empty-state.tsx b/apps/crew/src/components/organizer/empty-state.tsx index 560b5729e..9dcca32f0 100644 --- a/apps/crew/src/components/organizer/empty-state.tsx +++ b/apps/crew/src/components/organizer/empty-state.tsx @@ -43,7 +43,7 @@ export function OrganizerEmptyState({ {description} {hasActions ? ( - + {actionLabel && onAction ? ( , +})) + +vi.mock("@/components/nav/logout-button", () => ({ + default: () => , +})) + +vi.mock("@/db/schema", () => ({ + ROLES_ENUM: { ADMIN: "admin" }, +})) + +vi.mock("@/utils/auth", () => ({ + getSessionFromCookie: vi.fn(), +})) + +const { Route } = await import("@/routes/admin") +const AdminLayout = Route.options.component as ComponentType + +let root: ReturnType | undefined + +afterEach(async () => { + if (root) { + await act(async () => root?.unmount()) + root = undefined + } + document.body.innerHTML = "" +}) + +async function expectHydrationStable(pathname: string, activeLabel: string) { + routerPath = pathname + window.history.replaceState({}, "", "/") + const serverMarkup = renderToString() + window.history.replaceState({}, "", pathname) + const container = document.createElement("div") + container.innerHTML = serverMarkup + document.body.appendChild(container) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await act(async () => { + root = hydrateRoot(container, ) + }) + + expect(consoleError).not.toHaveBeenCalled() + const activeLink = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === activeLabel, + ) + expect(activeLink).toHaveAttribute("data-status", "active") + expect(activeLink).toHaveAttribute("aria-current", "page") + expect(activeLink).toHaveClass("bg-primary", "text-primary-foreground") +} + +describe("AdminLayout hydration", () => { + // @lat: [[admin-navigation#Admin Navigation Hydration Tests#Dashboard Direct Load Hydrates Stably]] + it("hydrates /admin with only Dashboard active", async () => { + await expectHydrationStable("/admin", "Dashboard") + }) + + // @lat: [[admin-navigation#Admin Navigation Hydration Tests#Teams Direct Load Hydrates Stably]] + it("hydrates /admin/teams with Teams active", async () => { + await expectHydrationStable("/admin/teams", "Teams") + }) + + // @lat: [[admin-navigation#Admin Navigation Hydration Tests#Nested Team Route Preserves Parent Active State]] + it("hydrates nested team routes with Teams active", async () => { + await expectHydrationStable("/admin/teams/team_123", "Teams") + }) +}) diff --git a/lat.md/admin-navigation.md b/lat.md/admin-navigation.md new file mode 100644 index 000000000..e829212b6 --- /dev/null +++ b/lat.md/admin-navigation.md @@ -0,0 +1,26 @@ +--- +lat: + require-code-mention: true +--- + +# Admin Navigation + +The Start admin shell delegates active-link matching to TanStack Router so direct SSR loads and client hydration produce identical navigation markup. + +The dashboard link matches only `/admin`, while each remaining platform link stays active for its nested routes. Navigation destinations, authorization, and layout remain route-owned. + +## Admin Navigation Hydration Tests + +These tests reproduce direct server-rendered loads and verify hydration keeps the router-selected platform link stable without React console errors. + +### Dashboard Direct Load Hydrates Stably + +This test verifies `/admin` hydrates without an attribute mismatch and marks only the dashboard link active. + +### Teams Direct Load Hydrates Stably + +This test verifies `/admin/teams` hydrates without an attribute mismatch and marks the teams link active. + +### Nested Team Route Preserves Parent Active State + +This test verifies a nested team route hydrates without an attribute mismatch while retaining the teams parent link's active styling and semantics. diff --git a/lat.md/architecture.md b/lat.md/architecture.md index cca333b00..ef5ae31cb 100644 --- a/lat.md/architecture.md +++ b/lat.md/architecture.md @@ -128,7 +128,7 @@ Co-host dashboard for users invited to help manage a competition. Accessible at ### admin -Platform-level admin routes for WODsmith operators. Manages teams, competitions, entitlements, and demo data. +Platform-level admin routes for WODsmith operators. Their shared [[admin-navigation#Admin Navigation|navigation shell]] manages SSR-safe active states across teams, competitions, entitlements, and demo data. ### api diff --git a/lat.md/lat.md b/lat.md/lat.md index 09595d0a6..51b58cbaa 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -1,6 +1,7 @@ This directory defines the high-level concepts, business logic, and architecture of this project using markdown. It is managed by [lat.md](https://www.npmjs.com/package/lat.md) — a tool that anchors source code to these definitions. Install the `lat` command with `npm i -g lat.md` and run `lat --help`. - [[architecture]] — Monorepo structure, tech stack, route groups, and deployment +- [[admin-navigation]] — SSR-stable Start admin navigation and active-route hydration tests - [[ui-library]] — Shared UI boundary, Storybook contract, and migration inventory - [[domain]] — Core domain model: teams, competitions, workouts, scoring, volunteers - [[auth]] — Authentication, sessions, authorization, and placeholder users From c2a0cc4368271ea03826fabd0d51db511db62052 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Fri, 10 Jul 2026 20:53:13 -0600 Subject: [PATCH 05/13] fix(start): separate admin link state styles --- apps/wodsmith-start/src/routes/admin.tsx | 6 +++--- .../test/routes/admin-layout-hydration.test.tsx | 15 ++++++++++++++- lat.md/admin-navigation.md | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/wodsmith-start/src/routes/admin.tsx b/apps/wodsmith-start/src/routes/admin.tsx index 01eb147a9..8f9afcd0c 100644 --- a/apps/wodsmith-start/src/routes/admin.tsx +++ b/apps/wodsmith-start/src/routes/admin.tsx @@ -104,10 +104,10 @@ function AdminSidebar() { key={item.href} to={item.href} activeOptions={{ exact: item.href === "/admin" }} - className="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors hover:bg-accent" + className="flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors" + inactiveProps={{ className: "hover:bg-accent" }} activeProps={{ - className: - "flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors bg-primary text-primary-foreground", + className: "bg-primary text-primary-foreground", }} > diff --git a/apps/wodsmith-start/test/routes/admin-layout-hydration.test.tsx b/apps/wodsmith-start/test/routes/admin-layout-hydration.test.tsx index 357ce4ca6..bcfa17e54 100644 --- a/apps/wodsmith-start/test/routes/admin-layout-hydration.test.tsx +++ b/apps/wodsmith-start/test/routes/admin-layout-hydration.test.tsx @@ -17,22 +17,27 @@ vi.mock("@tanstack/react-router", () => ({ activeProps, children, className, + inactiveProps, to, }: { activeOptions?: { exact?: boolean } activeProps?: { className?: string } children: ReactNode className?: string + inactiveProps?: { className?: string } to: string }) => { const active = activeOptions?.exact ? routerPath === to : routerPath === to || routerPath.startsWith(`${to}/`) + const stateClassName = active + ? activeProps?.className + : inactiveProps?.className return ( @@ -100,6 +105,14 @@ async function expectHydrationStable(pathname: string, activeLabel: string) { expect(activeLink).toHaveAttribute("data-status", "active") expect(activeLink).toHaveAttribute("aria-current", "page") expect(activeLink).toHaveClass("bg-primary", "text-primary-foreground") + expect(activeLink).not.toHaveClass("hover:bg-accent") + + const inactiveLabel = activeLabel === "Dashboard" ? "Teams" : "Dashboard" + const inactiveLink = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === inactiveLabel, + ) + expect(inactiveLink).toHaveClass("hover:bg-accent") + expect(inactiveLink).not.toHaveClass("bg-primary") } describe("AdminLayout hydration", () => { diff --git a/lat.md/admin-navigation.md b/lat.md/admin-navigation.md index e829212b6..0e9d6cfe4 100644 --- a/lat.md/admin-navigation.md +++ b/lat.md/admin-navigation.md @@ -7,7 +7,7 @@ lat: The Start admin shell delegates active-link matching to TanStack Router so direct SSR loads and client hydration produce identical navigation markup. -The dashboard link matches only `/admin`, while each remaining platform link stays active for its nested routes. Navigation destinations, authorization, and layout remain route-owned. +The dashboard link matches only `/admin`, while each remaining platform link stays active for its nested routes. Router-owned inactive props keep hover styling off active links. Navigation destinations, authorization, and layout remain route-owned. ## Admin Navigation Hydration Tests From 6317e9c1ee0e3f3e566ae323676b987fc0df8666 Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 11 Jul 2026 08:59:43 -0600 Subject: [PATCH 06/13] fix(start): add admin dashboard heading hierarchy --- .../wodsmith-start/src/routes/admin/index.tsx | 12 ++++-- .../routes/admin-dashboard-heading.test.tsx | 38 +++++++++++++++++++ lat.md/admin-navigation.md | 12 ++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 apps/wodsmith-start/test/routes/admin-dashboard-heading.test.tsx diff --git a/apps/wodsmith-start/src/routes/admin/index.tsx b/apps/wodsmith-start/src/routes/admin/index.tsx index f374366c0..f78d68705 100644 --- a/apps/wodsmith-start/src/routes/admin/index.tsx +++ b/apps/wodsmith-start/src/routes/admin/index.tsx @@ -25,7 +25,9 @@ function AdminDashboardPage() { {/* Welcome Section */} - Admin Dashboard +

+ Admin Dashboard +

Welcome to the admin panel. Manage your platform from here. @@ -57,7 +59,9 @@ function AdminDashboardPage() { {/* Quick Actions */} - Quick Actions +

+ Quick Actions +

@@ -92,7 +96,9 @@ function AdminDashboardPage() { {/* Recent Activity Placeholder */} - Recent Activity +

+ Recent Activity +

diff --git a/apps/wodsmith-start/test/routes/admin-dashboard-heading.test.tsx b/apps/wodsmith-start/test/routes/admin-dashboard-heading.test.tsx new file mode 100644 index 000000000..06a11b06f --- /dev/null +++ b/apps/wodsmith-start/test/routes/admin-dashboard-heading.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +import type { ComponentType } from "react" +import { render, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +vi.mock("@tanstack/react-router", () => ({ + createFileRoute: + () => + (config: Record) => + ({ ...config, options: config }), +})) + +const { Route } = await import("@/routes/admin/index") +const AdminDashboardPage = Route.options.component as ComponentType + +describe("AdminDashboardPage headings", () => { + // @lat: [[admin-navigation#Admin Dashboard Heading Tests#Direct Dashboard Exposes One Page Heading]] + it("uses the visible dashboard title as the only h1", () => { + render() + + expect( + screen.getByRole("heading", { level: 1, name: "Admin Dashboard" }), + ).toBeVisible() + expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1) + }) + + // @lat: [[admin-navigation#Admin Dashboard Heading Tests#Dashboard Sections Follow the Page Heading]] + it("uses h2 headings for dashboard sections", () => { + render() + + expect( + screen.getByRole("heading", { level: 2, name: "Quick Actions" }), + ).toBeVisible() + expect( + screen.getByRole("heading", { level: 2, name: "Recent Activity" }), + ).toBeVisible() + }) +}) diff --git a/lat.md/admin-navigation.md b/lat.md/admin-navigation.md index 0e9d6cfe4..aa4e5585b 100644 --- a/lat.md/admin-navigation.md +++ b/lat.md/admin-navigation.md @@ -24,3 +24,15 @@ This test verifies `/admin/teams` hydrates without an attribute mismatch and mar ### Nested Team Route Preserves Parent Active State This test verifies a nested team route hydrates without an attribute mismatch while retaining the teams parent link's active styling and semantics. + +## Admin Dashboard Heading Tests + +These tests keep the direct `/admin` page title and section hierarchy available to heading navigation without changing the dashboard actions or layout. + +### Direct Dashboard Exposes One Page Heading + +This test verifies the visible Admin Dashboard title is the direct page's only level-one heading. + +### Dashboard Sections Follow the Page Heading + +This test verifies Quick Actions and Recent Activity are level-two headings beneath the dashboard title. From 739ca5370f292aac9f20a1ae7389c6e6e07e9e4c Mon Sep 17 00:00:00 2001 From: Zac Jones Date: Sat, 11 Jul 2026 09:25:41 -0600 Subject: [PATCH 07/13] fix(start): correct demo competition semantics --- .../routes/admin/demo-competitions/index.tsx | 29 ++++--- ...admin-demo-competitions-semantics.test.tsx | 86 +++++++++++++++++++ lat.md/admin-navigation.md | 12 +++ 3 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 apps/wodsmith-start/test/routes/admin-demo-competitions-semantics.test.tsx diff --git a/apps/wodsmith-start/src/routes/admin/demo-competitions/index.tsx b/apps/wodsmith-start/src/routes/admin/demo-competitions/index.tsx index 060ef57bc..7c9f8c30b 100644 --- a/apps/wodsmith-start/src/routes/admin/demo-competitions/index.tsx +++ b/apps/wodsmith-start/src/routes/admin/demo-competitions/index.tsx @@ -3,6 +3,7 @@ import { useServerFn } from "@tanstack/react-start" import { format } from "date-fns" import { Loader2, Plus, Trash2 } from "lucide-react" import { useState } from "react" +import { toast } from "sonner" import { AlertDialog, AlertDialogAction, @@ -19,7 +20,6 @@ import { CardContent, CardDescription, CardHeader, - CardTitle, } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -38,10 +38,9 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { toast } from "sonner" import { - deleteDemoCompetitionFn, type DemoCompetitionSummary, + deleteDemoCompetitionFn, generateDemoCompetitionFn, getOrganizingTeamsFn, listDemoCompetitionsFn, @@ -170,7 +169,9 @@ function DemoCompetitionsPage() { {/* Existing Demo Competitions */} - Existing Demo Competitions +

+ Existing Demo Competitions +

Demo competitions that have been generated. Delete when no longer needed. @@ -231,10 +232,10 @@ function DemoCompetitionsPage() { {/* Generate New Demo Competition */} - +

Generate New Demo Competition - +

Creates a complete competition with 4 divisions, 3 workouts, 40 athletes, heats, scores, and volunteers. @@ -322,18 +323,20 @@ function DemoCompetitionsPage() { {/* What gets created */}
-

What gets created:

+

What gets created: