diff --git a/apps/ageorgedev-e2e/tests/talks.spec.ts b/apps/ageorgedev-e2e/tests/talks.spec.ts new file mode 100644 index 00000000..df2ba97b --- /dev/null +++ b/apps/ageorgedev-e2e/tests/talks.spec.ts @@ -0,0 +1,16 @@ +import { expect, test } from '@playwright/test'; + +test('talks page lists the Tailwind talk', async ({ page }) => { + await page.goto('/talks'); + await expect( + page.getByRole('heading', { name: 'Tailwind beyond Production' }) + ).toBeVisible(); +}); + +test('Tailwind talk links to its detail page', async ({ page }) => { + await page.goto('/talks'); + const link = page.getByRole('link', { name: /Tailwind beyond Production/i }); + await expect(link).toHaveAttribute('href', '/talks/tailwind'); + await link.click(); + await expect(page).toHaveURL(/\/talks\/tailwind\/?$/); +}); diff --git a/apps/ageorgedev-e2e/tsconfig.json b/apps/ageorgedev-e2e/tsconfig.json new file mode 100644 index 00000000..41b204e9 --- /dev/null +++ b/apps/ageorgedev-e2e/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@ageorgedev/ts-config/base.json", + "include": ["tests", "playwright.config.ts"], + "exclude": ["node_modules", "test-results"], + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + } +} diff --git a/apps/game-tools/src/components/DndHeaderActions.tsx b/apps/game-tools/src/components/DndHeaderActions.tsx new file mode 100644 index 00000000..ba1e5b13 --- /dev/null +++ b/apps/game-tools/src/components/DndHeaderActions.tsx @@ -0,0 +1,43 @@ +import { BookOpenTextIcon, PrinterIcon } from '@phosphor-icons/react'; +import { useMatches } from '@tanstack/react-router'; + +export function DndHeaderActions() { + const matches = useMatches(); + const spellBookUrl = matches + .map((m) => m.staticData?.spellBookUrl) + .find(Boolean); + const isCharacterSheet = matches.some((m) => m.routeId.includes('_sheet')); + + if (!spellBookUrl && !isCharacterSheet) { + return null; + } + + return ( + <> + {spellBookUrl && ( + + + Download Spellbook + + )} + {isCharacterSheet && ( + + )} + + ); +} diff --git a/apps/game-tools/src/components/HeaderBreadcrumbs.tsx b/apps/game-tools/src/components/HeaderBreadcrumbs.tsx new file mode 100644 index 00000000..9d90011e --- /dev/null +++ b/apps/game-tools/src/components/HeaderBreadcrumbs.tsx @@ -0,0 +1,67 @@ +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@ageorgedev/design-system/ui/breadcrumb'; +import { Link, useMatches } from '@tanstack/react-router'; +import { Fragment } from 'react'; + +type Crumb = { label: string; to?: string }; +type Matches = ReturnType; + +function deriveCrumbs(matches: Matches): Crumb[] { + const crumbs: Crumb[] = []; + + for (const match of matches) { + if (/\/dnd\/characters\/?$/.test(match.pathname)) { + crumbs.push({ label: 'DnD Characters', to: '/dnd/characters' }); + continue; + } + + if (/\/dnd\/characters\/.+$/.test(match.pathname)) { + const characterName = match.staticData?.character?.name ?? 'Character'; + const level = match.staticData?.character?.level; + const label = `${characterName}${level ? ` (Level ${level})` : ''}`; + crumbs.push({ label, to: match.pathname }); + } + } + + if (crumbs.length > 0) { + crumbs[crumbs.length - 1] = { label: crumbs[crumbs.length - 1].label }; + } + + return crumbs; +} + +export function HeaderBreadcrumbs() { + const matches = useMatches(); + const crumbs = deriveCrumbs(matches); + + if (crumbs.length === 0) { + return null; + } + + return ( + + + {crumbs.map((crumb, index) => ( + + {index > 0 && } + + {crumb.to ? ( + + {crumb.label} + + ) : ( + {crumb.label} + )} + + + ))} + + + ); +} diff --git a/apps/game-tools/src/data/dnd-characters/zoynari/zoynari-spellbook-2.pdf b/apps/game-tools/src/data/dnd-characters/zoynari/zoynari-spellbook-2.pdf new file mode 100644 index 00000000..dd99fb35 Binary files /dev/null and b/apps/game-tools/src/data/dnd-characters/zoynari/zoynari-spellbook-2.pdf differ diff --git a/apps/game-tools/src/routes/_public.tsx b/apps/game-tools/src/routes/_public.tsx index 4ae3d46f..f887ef77 100644 --- a/apps/game-tools/src/routes/_public.tsx +++ b/apps/game-tools/src/routes/_public.tsx @@ -1,46 +1,23 @@ import { ThemeSwitcher } from '@ageorgedev/design-system/theming/ThemeSwitcher'; -import { PrinterIcon } from '@phosphor-icons/react'; -import { - createFileRoute, - Link, - Outlet, - useChildMatches, -} from '@tanstack/react-router'; +import { createFileRoute, Link, Outlet } from '@tanstack/react-router'; +import { DndHeaderActions } from '../components/DndHeaderActions'; +import { HeaderBreadcrumbs } from '../components/HeaderBreadcrumbs'; export const Route = createFileRoute('/_public')({ component: RouteComponent, }); function RouteComponent() { - const childMatches = useChildMatches(); - const isCharacterSheet = childMatches.some((m) => - m.routeId.includes('_sheet') - ); - return ( <>
- - Game Tools - - - {isCharacterSheet && ( - - )} + + +
diff --git a/apps/game-tools/src/routes/_public/dnd/characters/_sheet/zoynari.2.tsx b/apps/game-tools/src/routes/_public/dnd/characters/_sheet/zoynari.2.tsx index 8ef1bd84..27f5850a 100644 --- a/apps/game-tools/src/routes/_public/dnd/characters/_sheet/zoynari.2.tsx +++ b/apps/game-tools/src/routes/_public/dnd/characters/_sheet/zoynari.2.tsx @@ -4,6 +4,12 @@ import { } from '@ageorgedev/dnd-character-sheet'; import { createFileRoute } from '@tanstack/react-router'; import { Zoynari2Data } from '../../../../../data/dnd-characters/zoynari/zoynari-2'; +import SpellSheet from '../../../../../data/dnd-characters/zoynari/zoynari-spellbook-2.pdf?url'; + +// const SpellSheet = new URL( +// '../../../../../data/dnd-characters/zoynari/zoynari-spellbook-2.pdf', +// import.meta.url +// ).href; export const Route = createFileRoute( '/_public/dnd/characters/_sheet/zoynari/2' @@ -11,6 +17,7 @@ export const Route = createFileRoute( component: RouteComponent, staticData: { character: getCharacterBrief(Zoynari2Data), + spellBookUrl: SpellSheet, }, }); diff --git a/apps/game-tools/src/styles.css b/apps/game-tools/src/styles.css index 5da8015f..8279c8c0 100644 --- a/apps/game-tools/src/styles.css +++ b/apps/game-tools/src/styles.css @@ -8,4 +8,7 @@ html { font-size: 9pt; } + body { + background-color: transparent; + } } diff --git a/apps/game-tools/src/type-enhancements.d.ts b/apps/game-tools/src/type-enhancements.d.ts index 323d656d..db2a2520 100644 --- a/apps/game-tools/src/type-enhancements.d.ts +++ b/apps/game-tools/src/type-enhancements.d.ts @@ -1,3 +1,5 @@ +/// + declare module '@tanstack/react-router' { interface StaticDataRouteOption { character?: { @@ -5,8 +7,14 @@ declare module '@tanstack/react-router' { description: string; level: number; }; + spellBookUrl?: string; } } +declare module '*.pdf?url' { + const url: string; + export default url; +} + // important, makes this file a module for ts, not an ambient script which will replace the above module entirely export {}; diff --git a/apps/game-tools/vite.config.ts b/apps/game-tools/vite.config.ts index ad2eac1b..cc308c07 100644 --- a/apps/game-tools/vite.config.ts +++ b/apps/game-tools/vite.config.ts @@ -20,6 +20,7 @@ const config = defineConfig({ allow: [searchForWorkspaceRoot(process.cwd())], }, }, + assetsInclude: ['**/*.pdf'], }); export default config; diff --git a/components.json b/components.json deleted file mode 100644 index 44726767..00000000 --- a/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "apps/ageorgedev/src/app/tailwind.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@ageorgedev/design-system/lib", - "utils": "@ageorgedev/design-system", - "ui": "@ageorgedev/design-system/lib/ui", - "lib": "@ageorgedev/design-system/lib", - "hooks": "@ageorgedev/design-system/lib" - }, - "iconLibrary": "lucide" -} diff --git a/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/.openspec.yaml b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/.openspec.yaml new file mode 100644 index 00000000..7a579632 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: my-workflow +created: 2026-07-02 diff --git a/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/design.md b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/design.md new file mode 100644 index 00000000..bd81ebb1 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/design.md @@ -0,0 +1,68 @@ +## Context + +`apps/game-tools/src/routes/_public.tsx` currently renders a flat header (home link, nav links, optional print button, theme switcher). As the DnD character list grows there is no in-app indicator of "where am I in the tree." The design system already ships a `Breadcrumb` primitive at `packages/design-system/src/ui/breadcrumb.tsx` (added on `feat/pdfs`, commit `a8ade94`) exporting `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator`, `BreadcrumbEllipsis`. It's headless-ish (data-slot styling, Tailwind classes) and integrates cleanly with TanStack Router via the `asChild` prop on `BreadcrumbLink`. + +The route tree we need to reflect: + +- `/` (home) +- `/dnd/characters` (character index) +- `/dnd/characters/` (character sheet, under `_sheet` sub-layout) + +Character sheet routes carry `staticData: { character: { name, level, description } }` — the same source already consumed by `_public/dnd/characters/index.tsx`. This is the label source of truth. + +## Goals / Non-Goals + +**Goals:** + +- Show a breadcrumb trail inside the existing header row of `_public.tsx`. +- Trail derives from active router matches at render time — no hard-coded per-route tables. +- Character sheet leaf uses `staticData.character.name`. +- Reuse the design-system `Breadcrumb` primitives without modification. + +**Non-Goals:** + +- No changes to the design-system `Breadcrumb` component. +- No global route-metadata refactor. We introspect matches ad-hoc in `_public.tsx`. +- No breadcrumbs on the home route (`/`) — nothing meaningful to show. +- No separate print handling: breadcrumbs live inside the `print:hidden` header. + +## Decisions + +### Decision 1: Placement — inline in the header row (not a second row) + +Breadcrumbs replace the current standalone `Game Tools` home link as the app's location indicator on the left of the header. The home link becomes the root of the breadcrumb trail (either as `BreadcrumbLink`s implicit root, or dropped entirely per user answer: "DnD Characters / " — no explicit Home crumb). + +**Rationale**: user explicitly chose "Inside the header row". Keeps vertical space tight, no CLS on nav transitions. + +**Alternative considered**: second row under header — rejected by the user. + +### Decision 2: Trail derivation from `useChildMatches()` / `useMatches()` + +Read the current match tree via TanStack Router hooks (already used in the file for `useChildMatches`). Filter to matches whose `routeId` corresponds to a user-visible segment (`_public/dnd/characters/`, `_public/dnd/characters/_sheet/$name`) and map each to `{ label, to }`: + +- `_public/dnd/characters/` → `{ label: 'DnD Characters', to: '/dnd/characters' }` +- `_public/dnd/characters/_sheet/$name` → `{ label: match.staticData.character.name, to: match.pathname }` + +The last entry renders as `BreadcrumbPage` (non-link, current page); earlier entries render as `BreadcrumbLink` with `asChild` wrapping `` from `@tanstack/react-router`. + +**Rationale**: keeps the mapping in one place and driven by the router — new nested routes just plug in. + +**Alternative considered**: a static per-route lookup table. Rejected: duplicates knowledge already in the route tree, drifts easily. + +### Decision 3: Home route shows no breadcrumbs + +On `/`, the derived list is empty and the whole `` block renders nothing (or renders a bare "Game Tools" wordmark as today). This avoids a single-item crumb which reads as noise. + +### Decision 4: Header wordmark + +Retain "Game Tools" as a plain link on the left, then render breadcrumbs to its right when non-empty (visually: `Game Tools │ DnD Characters / Zoynari`). This keeps a stable brand anchor. Separator between wordmark and breadcrumbs is a subtle vertical divider or a normal `BreadcrumbSeparator`. + +## Risks / Trade-offs + +- **Character label depends on `staticData.character`** → if a future character route omits it, the leaf crumb falls back to a humanised route param (e.g. `zoynari` → `Zoynari`). Mitigation: derivation function has an explicit fallback path; add a test scenario. +- **Header horizontal space on small viewports** → deep trails could wrap awkwardly. Mitigation: `BreadcrumbList` already uses `flex-wrap` and `text-sm`; acceptable for game-tools (desktop-oriented). Revisit if we add more nesting. +- **Coupling `_public.tsx` to specific route ids** → the derivation switch references `_public/dnd/characters/*` strings. Mitigation: acceptable at current scale (one section); revisit when a second top-level section is added. + +## Migration Plan + +Pure additive change to one file. No data migration, no feature flag. Ship on `feat/pdfs` alongside other in-flight work. diff --git a/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/proposal.md b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/proposal.md new file mode 100644 index 00000000..d49f21c6 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/proposal.md @@ -0,0 +1,30 @@ +## Why + +The game-tools app currently offers no in-context wayfinding once a user drills into a nested route (e.g. an individual character sheet). Adding breadcrumbs to the header gives users a clear sense of location within the app and a one-click path back to parent sections. + +## What Changes + +- Add a breadcrumb trail to the header inside `apps/game-tools/src/routes/_public.tsx`, positioned inline with the existing header row (alongside the current "Game Tools" home link). +- Consume the `Breadcrumb*` primitives from `@ageorgedev/design-system/ui/breadcrumb`. +- Derive breadcrumb items from the active TanStack Router matches: + - Home route (`/`): no breadcrumbs (or just root marker). + - `/dnd/characters`: `DnD Characters` (current page). + - `/dnd/characters/`: `DnD Characters` (link) → `` (current page, from route `staticData.character.name`). +- Breadcrumbs live inside the existing header, so they inherit the `print:hidden` behavior automatically — no additional print handling needed. + +## Capabilities + +### New Capabilities + +_(none — this extends existing nav shell behavior)_ + +### Modified Capabilities + +- `game-tools-nav-shell`: header now also renders a breadcrumb trail reflecting the active route hierarchy. + +## Impact + +- **Code**: `apps/game-tools/src/routes/_public.tsx` (add breadcrumb rendering + route-match derivation). +- **Dependencies**: uses existing `@ageorgedev/design-system` `Breadcrumb` components (already available on `feat/pdfs`); no new package deps. +- **Routes touched (read only)**: `_public/dnd/characters/index.tsx` and `_public/dnd/characters/_sheet.tsx` (their `staticData.character.name` is read for labels — no changes to their contract). +- **Tests**: existing `game-tools-nav-shell` scenarios still hold; new scenarios added for breadcrumb presence and labels. diff --git a/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/specs/game-tools-nav-shell/spec.md b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/specs/game-tools-nav-shell/spec.md new file mode 100644 index 00000000..0c0ef723 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/specs/game-tools-nav-shell/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Navigation header renders a breadcrumb trail for the active route +The `_public` layout header SHALL render a breadcrumb trail (built from `@ageorgedev/design-system/ui/breadcrumb`) that reflects the active route hierarchy. The trail SHALL be derived from the current TanStack Router matches — not hard-coded — so that nested routes automatically contribute crumbs. The final crumb SHALL render as `BreadcrumbPage` (non-link, marked `aria-current="page"`); all preceding crumbs SHALL render as `BreadcrumbLink` that navigate to the corresponding route. + +#### Scenario: Character index shows current-page crumb +- **WHEN** a user navigates to `/dnd/characters` +- **THEN** the header renders a breadcrumb trail containing exactly one crumb, `DnD Characters`, rendered as the current page (non-link, `aria-current="page"`) + +#### Scenario: Character sheet shows link + leaf crumb +- **WHEN** a user navigates to `/dnd/characters/` for a character whose `staticData.character.name` is `Zoynari` +- **THEN** the header renders a breadcrumb trail with `DnD Characters` as a link to `/dnd/characters` followed by `Zoynari` as the current page (non-link) + +#### Scenario: Character-sheet crumb label comes from route staticData +- **WHEN** a character route defines `staticData.character.name` as `Claw` +- **THEN** the leaf breadcrumb on that route displays `Claw` + +#### Scenario: Home route shows no breadcrumb trail +- **WHEN** a user navigates to `/` +- **THEN** no breadcrumb list items are rendered in the header + +### Requirement: Breadcrumb link on non-leaf crumb navigates +The `BreadcrumbLink` used for non-leaf crumbs SHALL be wired to TanStack Router `Link` (via the component's `asChild` prop) so that activating the crumb performs client-side navigation to the target route. + +#### Scenario: Clicking the DnD Characters crumb navigates back to the index +- **WHEN** a user is on `/dnd/characters/zoynari` and clicks the `DnD Characters` breadcrumb link +- **THEN** the app navigates to `/dnd/characters` without a full page reload diff --git a/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/tasks.md b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/tasks.md new file mode 100644 index 00000000..b3cc40d2 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-game-tools-breadcrumbs/tasks.md @@ -0,0 +1,13 @@ +## 1. Wire breadcrumbs into the header + +- [x] 1.1 In `apps/game-tools/src/routes/_public.tsx`, import `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` from `@ageorgedev/design-system/ui/breadcrumb` +- [x] 1.2 Add a `useMatches()` (or extend the existing `useChildMatches()`) call to obtain the active router match tree +- [x] 1.3 Implement a `deriveCrumbs(matches)` helper (local to the file) that returns `Array<{ label: string; to?: string }>`. Skip the home match. Map `_public/dnd/characters/` → `{ label: 'DnD Characters', to: '/dnd/characters' }`. Map `_public/dnd/characters/_sheet/$name` → `{ label: match.staticData?.character?.name ?? humanise(params.name), to: match.pathname }`. The last item MUST be returned without `to` (rendered as `BreadcrumbPage`) +- [x] 1.4 Render `` inside the header, to the right of the "Game Tools" wordmark, only when the derived list is non-empty. Interleave `` between items. Non-leaf items use `{label}`; leaf uses `{label}` + +## 2. Verify + +- [ ] 2.1 Run `yarn turbo dev --filter=@ageorgedev/game-tools`, load `/`, `/dnd/characters`, and a character sheet route, and confirm breadcrumbs render as specified in `specs/game-tools-nav-shell/spec.md` +- [ ] 2.2 Trigger print preview on a character sheet route and confirm no breadcrumbs appear (header, and therefore breadcrumbs, are `print:hidden`) +- [x] 2.3 Run `yarn format-and-lint:fix` at the repo root +- [x] 2.4 Run `yarn turbo build --filter=@ageorgedev/game-tools` to confirm the app still type-checks and builds diff --git a/openspec/changes/archive/2026-07-02-add-spellbook-download/.openspec.yaml b/openspec/changes/archive/2026-07-02-add-spellbook-download/.openspec.yaml new file mode 100644 index 00000000..7a579632 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-spellbook-download/.openspec.yaml @@ -0,0 +1,2 @@ +schema: my-workflow +created: 2026-07-02 diff --git a/openspec/changes/archive/2026-07-02-add-spellbook-download/design.md b/openspec/changes/archive/2026-07-02-add-spellbook-download/design.md new file mode 100644 index 00000000..8e6adde1 --- /dev/null +++ b/openspec/changes/archive/2026-07-02-add-spellbook-download/design.md @@ -0,0 +1,43 @@ +## Context + +The game-tools app's `_public.tsx` layout already reads `useMatches()` to build the breadcrumb trail and toggle the Print button (`isCharacterSheet = matches.some((m) => m.routeId.includes('_sheet'))`). Character routes declare metadata through TanStack Router's `staticData`, which is typed in `apps/game-tools/src/type-enhancements.d.ts`. `spellBookUrl?: string` is already declared there, and `zoynari.2.tsx` already populates it via `new URL('...pdf', import.meta.url).href` (Vite handles bundling the asset). + +The Print button lives inside the header element that carries `print:hidden`, and uses a Phosphor icon at `size={30}`. The new control should be visually and behaviourally consistent. + +## Goals / Non-Goals + +**Goals:** +- Surface `staticData.spellBookUrl` as a downloadable action beside the Print button. +- Reuse the same match-derivation pattern already in `_public.tsx` so behaviour follows route static data automatically. +- Trigger a file download (not inline navigation) so the PDF is saved to disk. +- Zero changes to existing character data files — the button appears automatically for routes that already declare `spellBookUrl`. + +**Non-Goals:** +- Rendering the spellbook PDF inline in the character sheet. +- Introducing a general "downloadable route asset" abstraction — this is scoped to spellbooks. +- Changing how `spellBookUrl` is declared or typed (already in place). +- Adding tests beyond what the existing app conventions require; the button is a thin presentational addition. + +## Decisions + +### Derive the URL from `useMatches()`, matching the existing breadcrumb/print pattern +Read the URL by scanning `matches` for a `staticData.spellBookUrl` value: `const spellBookUrl = matches.map((m) => m.staticData?.spellBookUrl).find(Boolean);`. This mirrors how `isCharacterSheet` and the breadcrumb data are already derived in `_public.tsx`, keeping the layout self-contained. + +**Alternative considered:** Reading via `Route.useMatch()` on the specific character route. Rejected because `_public.tsx` sits above the character routes and shouldn't hard-code knowledge of them; the `useMatches` scan generalises to any future route that declares `spellBookUrl`. + +### Render an `` rather than a `