From cb6f37b686e852f7bac70612031d5a7dbe68df93 Mon Sep 17 00:00:00 2001 From: Tony Ketcham Date: Thu, 20 Aug 2026 08:54:54 +0000 Subject: [PATCH] docs: redraw site as a technical service manual MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Describe Redraw the docs from PR #247 as a readable service manual while keeping its content and tools intact. - Replace the terminal grid with a numbered manual index, source-backed plate coordinates, manifests, rules, and restrained light and dark themes. - Add a semantic exploded content diagram whose native inspection control reveals the real guide, section, and package records. - Keep search, theme persistence, code copy, local contents, keyboard focus, reduced motion, responsive navigation, and static export behavior. ## Test Plan - `pnpm --filter @flatbread/docs test` — 19 files and 243 tests pass. - `pnpm --filter @flatbread/docs typecheck` and `pnpm lint` pass. - `pnpm docs:build` exports both the root and `/flatbread` base-path sites. - Checked home, guide, table, code, search, theme, and inspection flows at 1280px and 375px in both themes, including reduced motion and keyboard focus. Change-Id: Ib30718ec3432bd27862b339e6ff357f721984159 --- apps/docs/app/components/ascii/Frame.tsx | 26 +- apps/docs/app/components/ascii/Rule.tsx | 16 +- .../components/chrome/ThemeToggle.test.tsx | 8 +- .../app/components/chrome/ThemeToggle.tsx | 2 +- .../manual/ManualContentDiagram.tsx | 202 ++ .../components/manual/content-system.test.ts | 15 + .../app/components/manual/content-system.ts | 42 + .../docs/app/components/nav/NavDisclosure.tsx | 5 +- apps/docs/app/components/nav/Sidebar.test.tsx | 57 +- apps/docs/app/components/nav/Sidebar.tsx | 72 +- apps/docs/app/components/nav/Toc.test.tsx | 6 + apps/docs/app/components/nav/Toc.tsx | 12 +- .../app/components/prose/CodeCopy.test.tsx | 28 +- apps/docs/app/components/prose/CodeCopy.tsx | 8 +- .../app/components/search/SearchDialog.tsx | 4 +- apps/docs/app/docs/[slug]/page.tsx | 64 +- apps/docs/app/globals.css | 2018 ++++++++++++----- apps/docs/app/layout.tsx | 20 +- apps/docs/app/not-found.tsx | 23 +- apps/docs/app/page.tsx | 197 +- apps/docs/app/reference/[slug]/page.tsx | 52 +- apps/docs/generated/graphql.ts | 4 +- apps/docs/lib/content.test.ts | 41 +- apps/docs/lib/content.ts | 16 + apps/docs/queries/docs.graphql | 2 + apps/docs/vitest.config.ts | 1 + 26 files changed, 2207 insertions(+), 734 deletions(-) create mode 100644 apps/docs/app/components/manual/ManualContentDiagram.tsx create mode 100644 apps/docs/app/components/manual/content-system.test.ts create mode 100644 apps/docs/app/components/manual/content-system.ts diff --git a/apps/docs/app/components/ascii/Frame.tsx b/apps/docs/app/components/ascii/Frame.tsx index 89797031..3dbf2381 100644 --- a/apps/docs/app/components/ascii/Frame.tsx +++ b/apps/docs/app/components/ascii/Frame.tsx @@ -1,38 +1,18 @@ import type { ReactNode } from 'react'; interface FrameProps { - /** Sits in the top rule, the way a filename sits in a box-drawn panel. */ + /** Names the section. */ label?: ReactNode; - /** Sits at the right of the top rule. Use it for counts and short notes. */ + /** Adds a count or short note to the section heading. */ note?: ReactNode; className?: string; children: ReactNode; } -/** - * A panel drawn as a box. - * - * The four sides are ordinary one-pixel borders so they stay crisp at any zoom - * and any width. Only the corners are real box-drawing characters, sitting on - * top of the border. A panel built entirely from characters comes apart the - * moment the container is resized; this does not. - */ +/** Groups a named section of a page. */ export function Frame({ label, note, className, children }: FrameProps) { return (
- - ┌ - - - ┐ - - - └ - - - ┘ - - {label ?

{label}

: null} {note ?

{note}

: null} diff --git a/apps/docs/app/components/ascii/Rule.tsx b/apps/docs/app/components/ascii/Rule.tsx index 5f555f6f..819dce37 100644 --- a/apps/docs/app/components/ascii/Rule.tsx +++ b/apps/docs/app/components/ascii/Rule.tsx @@ -1,21 +1,13 @@ interface RuleProps { - char?: string; className?: string; } -/** - * A horizontal rule made of real characters. - * - * The string is longer than any sensible screen and the container clips it, so - * the rule fills its width without any measuring. - */ -export function Rule({ char = '─', className }: RuleProps) { +/** Separates the main regions of the page. */ +export function Rule({ className }: RuleProps) { return ( -
- {char.repeat(400)} -
+ /> ); } diff --git a/apps/docs/app/components/chrome/ThemeToggle.test.tsx b/apps/docs/app/components/chrome/ThemeToggle.test.tsx index 8f17958b..3a737d1b 100644 --- a/apps/docs/app/components/chrome/ThemeToggle.test.tsx +++ b/apps/docs/app/components/chrome/ThemeToggle.test.tsx @@ -34,7 +34,7 @@ describe('ThemeToggle', () => { document.documentElement.dataset.theme = 'light'; await renderToggle(); - expect(toggleButton().textContent).toBe('[◑ light]'); + expect(toggleButton().textContent).toBe('Theme: light'); expect(toggleButton().getAttribute('aria-label')).toBe( 'Switch to the dark theme' ); @@ -43,7 +43,7 @@ describe('ThemeToggle', () => { expect(document.documentElement.dataset.theme).toBe('dark'); expect(localStorage.getItem(THEME_KEY)).toBe('dark'); - expect(toggleButton().textContent).toBe('[◐ dark]'); + expect(toggleButton().textContent).toBe('Theme: dark'); expect(toggleButton().getAttribute('aria-label')).toBe( 'Switch to the light theme' ); @@ -52,7 +52,7 @@ describe('ThemeToggle', () => { expect(document.documentElement.dataset.theme).toBe('light'); expect(localStorage.getItem(THEME_KEY)).toBe('light'); - expect(toggleButton().textContent).toBe('[◑ light]'); + expect(toggleButton().textContent).toBe('Theme: light'); }); it('still updates the document and control when storage rejects the write', async () => { @@ -68,7 +68,7 @@ describe('ThemeToggle', () => { expect(setItem).toHaveBeenCalledWith(THEME_KEY, 'dark'); expect(document.documentElement.dataset.theme).toBe('dark'); - expect(toggleButton().textContent).toBe('[◐ dark]'); + expect(toggleButton().textContent).toBe('Theme: dark'); }); }); diff --git a/apps/docs/app/components/chrome/ThemeToggle.tsx b/apps/docs/app/components/chrome/ThemeToggle.tsx index 99d45ac2..6c0946ac 100644 --- a/apps/docs/app/components/chrome/ThemeToggle.tsx +++ b/apps/docs/app/components/chrome/ThemeToggle.tsx @@ -47,7 +47,7 @@ export function ThemeToggle() { className="fb-button" aria-label={`Switch to the ${theme === 'dark' ? 'light' : 'dark'} theme`} > - {theme === 'dark' ? '[◐ dark]' : '[◑ light]'} + Theme: {theme} ); } diff --git a/apps/docs/app/components/manual/ManualContentDiagram.tsx b/apps/docs/app/components/manual/ManualContentDiagram.tsx new file mode 100644 index 00000000..7b5e22bd --- /dev/null +++ b/apps/docs/app/components/manual/ManualContentDiagram.tsx @@ -0,0 +1,202 @@ +import Link from 'next/link'; + +import type { DocSummary, PackageSummary, Section } from '../../../lib/content'; +import { CONTENT_PARTS } from './content-system'; +import type { ContentPartKey } from './content-system'; + +interface ManualContentDiagramProps { + sections: Section[]; + docs: DocSummary[]; + packages: PackageSummary[]; +} + +interface AssemblyPart { + ref: string; + name: string; + count: number; + source: string; + purpose: string; + items: Array<{ id: string; label: string; href?: string }>; +} + +/** + * A semantic, CSS-drawn exploded view of the content that builds this site. + * + * The native details element deliberately supplies the one interaction: it is + * keyboard and touch accessible without JavaScript, and CSS may animate its + * `[open]` state only when reduced motion is not requested. + */ +export function ManualContentDiagram({ + sections, + docs, + packages, +}: ManualContentDiagramProps) { + const counts: Record = { + sections: sections.length, + docs: docs.length, + packages: packages.length, + }; + const items: Record = { + sections: sections.map((section) => ({ + id: section.id, + label: `${number(section.order)} ${section.title}`, + })), + docs: docs.map((doc) => ({ + id: doc.id, + label: doc.title, + href: `/docs/${doc.id}/`, + })), + packages: packages.map((entry) => ({ + id: entry.id, + label: entry.id, + href: `/reference/${entry.id}/`, + })), + }; + const parts: AssemblyPart[] = CONTENT_PARTS.map((part) => ({ + ref: part.ref, + name: part.name, + count: counts[part.key], + source: part.sourcePattern, + purpose: part.purpose, + items: items[part.key], + })); + const total = parts.reduce((sum, part) => sum + part.count, 0); + + return ( +
+
+

FIG. 00–1

+

Content system, exploded

+

+ {total} source records assembled into this documentation site. +

+
+ +
+
+
+ + Rendered documentation + + flatbread docs + + {total} source records + +
+ +
    + {parts.map((part) => ( +
  1. + {part.ref} + + {part.name} + + + {part.count} records + +
  2. + ))} +
+ +
    +
  1. + 01 → 02 + Navigation sections group guide records. +
  2. +
  3. + 02 → 00 + Guide records render as documentation pages. +
  4. +
  5. + 03 → 00 + Package references render alongside guides. +
  6. +
+
+ +
+ CONTENT ASSEMBLY + + Direct labels identify the source collection and its role. + +
+
+ +
+ + Inspect assembled records + + {total} records + + + +
+

+ Each part remains a repository file or README; the site reads it at + build time. +

+ +
    + {parts.map((part) => ( +
  1. +
    + + REF. {part.ref} + +

    {part.name}

    + {part.count} records +
    +

    {part.purpose}

    + {part.source} +
      + {part.items.map((item) => ( +
    • + {item.href ? ( + {item.label} + ) : ( + item.label + )} +
    • + ))} +
    +
  2. + ))} +
+
+
+
+ ); +} + +function number(value: number): string { + return String(value).padStart(2, '0'); +} diff --git a/apps/docs/app/components/manual/content-system.test.ts b/apps/docs/app/components/manual/content-system.test.ts new file mode 100644 index 00000000..588658b2 --- /dev/null +++ b/apps/docs/app/components/manual/content-system.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { CONTENT_PARTS } from './content-system'; + +describe('content-system references', () => { + it('keeps one ordered reference for each source collection', () => { + expect( + CONTENT_PARTS.map(({ key, ref, name }) => ({ key, ref, name })) + ).toEqual([ + { key: 'sections', ref: '01', name: 'Navigation sections' }, + { key: 'docs', ref: '02', name: 'Guide records' }, + { key: 'packages', ref: '03', name: 'Package references' }, + ]); + }); +}); diff --git a/apps/docs/app/components/manual/content-system.ts b/apps/docs/app/components/manual/content-system.ts new file mode 100644 index 00000000..8e3ca699 --- /dev/null +++ b/apps/docs/app/components/manual/content-system.ts @@ -0,0 +1,42 @@ +export type ContentPartKey = 'sections' | 'docs' | 'packages'; + +interface ContentPartDefinition { + key: ContentPartKey; + ref: string; + name: string; + sourcePattern: string; + manifestPath: string; + manifestNote: string; + purpose: string; +} + +/** One reference system for both the exploded view and its parts manifest. */ +export const CONTENT_PARTS: readonly ContentPartDefinition[] = [ + { + key: 'sections', + ref: '01', + name: 'Navigation sections', + sourcePattern: 'apps/docs/content/nav/*.yaml', + manifestPath: 'apps/docs/content/nav/[id].yaml', + manifestNote: 'Names and orders the guide index.', + purpose: 'Groups the guide index.', + }, + { + key: 'docs', + ref: '02', + name: 'Guide records', + sourcePattern: 'apps/docs/content/docs/*.md', + manifestPath: 'apps/docs/content/docs/[id].md', + manifestNote: 'Rendered as guide pages and grouped by section.', + purpose: 'Supplies the guide pages and local contents.', + }, + { + key: 'packages', + ref: '03', + name: 'Package references', + sourcePattern: 'apps/docs/content/reference/*.md', + manifestPath: 'apps/docs/content/reference/[id].md', + manifestNote: 'Published README content presented as reference pages.', + purpose: 'Supplies the package README reference pages.', + }, +]; diff --git a/apps/docs/app/components/nav/NavDisclosure.tsx b/apps/docs/app/components/nav/NavDisclosure.tsx index 739aa6db..e54c7925 100644 --- a/apps/docs/app/components/nav/NavDisclosure.tsx +++ b/apps/docs/app/components/nav/NavDisclosure.tsx @@ -53,10 +53,7 @@ export function NavDisclosure({ open={wide || open} onToggle={(event) => setOpen(event.currentTarget.open)} > - - {open ? '▾' : '▸'} - {label} - + {label} {children} ); diff --git a/apps/docs/app/components/nav/Sidebar.test.tsx b/apps/docs/app/components/nav/Sidebar.test.tsx index a9cd11a8..729480ab 100644 --- a/apps/docs/app/components/nav/Sidebar.test.tsx +++ b/apps/docs/app/components/nav/Sidebar.test.tsx @@ -30,7 +30,7 @@ vi.mock('next/link', async () => { }); const sections: Section[] = [ - { id: 'start', title: 'Start', order: 1, blurb: 'Start here' }, + { id: 'start', title: 'Start', order: 2, blurb: 'Start here' }, { id: 'empty', title: 'Empty section', order: 2, blurb: 'No pages' }, ]; @@ -39,7 +39,7 @@ const docs: DocSummary[] = [ id: 'alpha', title: 'Alpha guide', summary: 'Read alpha', - order: 1, + order: 3, sectionId: 'start', }, ]; @@ -68,6 +68,7 @@ describe('Sidebar', () => { await renderSidebar(); expect(alphaLink().getAttribute('aria-current')).toBe('page'); + expect(alphaLink().textContent).toBe('02.03 Current page: Alpha guide'); navigation.pathname = '/docs/alpha'; await renderSidebar(); @@ -79,12 +80,56 @@ describe('Sidebar', () => { it('omits empty documentation sections and package groups', async () => { await renderSidebar([{ id: 'core' }]); + expect(container.querySelector('nav')?.getAttribute('aria-label')).toBe( + 'Manual index' + ); expect(container.textContent).toContain('Start'); - expect(container.textContent).toContain('Reference · Build'); + expect(container.textContent).toContain('02 Start'); + expect(container.textContent).toContain('02.03 Alpha guide'); + expect(container.textContent).toContain('Package reference / Build'); expect(container.textContent).not.toContain('Empty section'); - expect(container.textContent).not.toContain('Reference · Content'); - expect(container.textContent).not.toContain('Reference · Tools'); - expect(container.textContent).not.toContain('Reference · Other'); + expect(container.textContent).not.toContain('Package reference / Content'); + expect(container.textContent).not.toContain('Package reference / Tools'); + expect(container.textContent).not.toContain('Package reference / Other'); + }); + + it('sorts manual sections and pages by their published order', async () => { + const laterSection: Section = { + id: 'later', + title: 'Later', + order: 4, + blurb: 'Later section', + }; + const earlierDoc: DocSummary = { + id: 'before-alpha', + title: 'Before alpha', + summary: 'Read this first', + order: 1, + sectionId: 'start', + }; + const laterDoc: DocSummary = { + id: 'later-page', + title: 'Later page', + summary: 'Read this later', + order: 1, + sectionId: 'later', + }; + + await act(async () => { + root.render( + createElement(Sidebar, { + sections: [laterSection, ...sections], + docs: [laterDoc, docs[0], earlierDoc], + packages: [], + }) + ); + }); + + const text = container.textContent ?? ''; + expect(text.indexOf('02 Start')).toBeLessThan(text.indexOf('04 Later')); + expect(text.indexOf('02.01 Before alpha')).toBeLessThan( + text.indexOf('02.03 Alpha guide') + ); }); }); diff --git a/apps/docs/app/components/nav/Sidebar.tsx b/apps/docs/app/components/nav/Sidebar.tsx index deae5270..d6908e22 100644 --- a/apps/docs/app/components/nav/Sidebar.tsx +++ b/apps/docs/app/components/nav/Sidebar.tsx @@ -15,22 +15,16 @@ interface SidebarProps { interface Branch { id: string; title: string; - items: Array<{ href: string; label: string }>; + number?: number; + items: Array<{ href: string; label: string; number?: number }>; } -/** - * The navigation, drawn as a directory tree. - * - * A vertical list is the one place where box-drawing characters are completely - * safe: `├─` and `└─` never have to stretch, so they line up whatever the - * window is doing. The current page gets a plain text marker inside its link; - * it does not need an animation library or a shared moving element. - */ +/** Lists each manual section and page in its published order. */ export function Sidebar({ sections, docs, packages }: SidebarProps) { const pathname = usePathname(); const referenceBranches: Branch[] = groupPackages(packages).map((group) => ({ id: `reference-${group.name.toLowerCase()}`, - title: `Reference · ${group.name}`, + title: `Package reference / ${group.name}`, items: group.packages.map((entry) => ({ href: `/reference/${entry.id}/`, label: entry.id, @@ -38,52 +32,62 @@ export function Sidebar({ sections, docs, packages }: SidebarProps) { })); const branches: Branch[] = [ - ...sections.map((section) => ({ - id: section.id, - title: section.title, - items: docs - .filter((doc) => doc.sectionId === section.id) - .map((doc) => ({ href: `/docs/${doc.id}/`, label: doc.title })), - })), + ...[...sections] + .sort((a, b) => a.order - b.order) + .map((section) => ({ + id: section.id, + title: section.title, + number: section.order, + items: docs + .filter((doc) => doc.sectionId === section.id) + .sort((a, b) => a.order - b.order) + .map((doc) => ({ + href: `/docs/${doc.id}/`, + label: doc.title, + number: doc.order, + })), + })), ...referenceBranches, ].filter((branch) => branch.items.length > 0); return ( -