Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions apps/ageorgedev-e2e/tests/talks.spec.ts
Original file line number Diff line number Diff line change
@@ -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\/?$/);
});
9 changes: 9 additions & 0 deletions apps/ageorgedev-e2e/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
43 changes: 43 additions & 0 deletions apps/game-tools/src/components/DndHeaderActions.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<a
href={spellBookUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-neutral-subdued hover:text-primary-foreground transition-colors inline-flex gap-1 items-center"
aria-label="Download spellbook PDF"
title="Download spellbook PDF"
>
<BookOpenTextIcon size={30} />
<span>Download Spellbook</span>
</a>
)}
{isCharacterSheet && (
<button
type="button"
onClick={() => window.print()}
className="text-xs text-neutral-subdued hover:text-primary-foreground transition-colors inline-flex gap-1 items-center"
aria-label="Print character sheet"
>
<PrinterIcon size={30} />
<span>Print Character Sheet</span>
</button>
)}
</>
);
}
67 changes: 67 additions & 0 deletions apps/game-tools/src/components/HeaderBreadcrumbs.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useMatches>;

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 (
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, index) => (
<Fragment key={crumb.to ?? `page:${crumb.label}`}>
{index > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{crumb.to ? (
<BreadcrumbLink asChild>
<Link to={crumb.to}>{crumb.label}</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
)}
</BreadcrumbItem>
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}
Binary file not shown.
41 changes: 9 additions & 32 deletions apps/game-tools/src/routes/_public.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<header className="print:hidden flex items-center justify-between gap-4 px-4 py-3 border-b border-border">
<Link to="/" className="font-bold text-lg">
Game Tools
</Link>
<nav className="flex grow justify-end gap-4">
<Link
to="/dnd/characters"
className="text-sm hover:text-primary-foreground transition-colors"
>
DnD Characters
<div className="flex items-baseline gap-4 flex-1">
<Link to="/" className="font-bold text-lg">
Game Tools
</Link>
</nav>
{isCharacterSheet && (
<button
type="button"
onClick={() => window.print()}
className="hover:text-primary-foreground transition-colors"
aria-label="Print character sheet"
>
<PrinterIcon size={30} />
</button>
)}
<HeaderBreadcrumbs />
</div>
<DndHeaderActions />
<ThemeSwitcher />
</header>
<main>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ 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'
)({
component: RouteComponent,
staticData: {
character: getCharacterBrief(Zoynari2Data),
spellBookUrl: SpellSheet,
},
});

Expand Down
3 changes: 3 additions & 0 deletions apps/game-tools/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@
html {
font-size: 9pt;
}
body {
background-color: transparent;
}
}
8 changes: 8 additions & 0 deletions apps/game-tools/src/type-enhancements.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
/// <reference types="vite/client" />

declare module '@tanstack/react-router' {
interface StaticDataRouteOption {
character?: {
name: string;
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 {};
1 change: 1 addition & 0 deletions apps/game-tools/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const config = defineConfig({
allow: [searchForWorkspaceRoot(process.cwd())],
},
},
assetsInclude: ['**/*.pdf'],
});

export default config;
21 changes: 0 additions & 21 deletions components.json

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: my-workflow
created: 2026-07-02
Original file line number Diff line number Diff line change
@@ -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/<name>` (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 / <Name>" — 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 `<Link>` 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 `<Breadcrumb>` 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.
Original file line number Diff line number Diff line change
@@ -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/<name>`: `DnD Characters` (link) → `<Character Name>` (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.
Loading