@awarebydefault/display-case 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +309 -0
- package/display-case.prompt.md +64 -0
- package/docs/ai-agents.md +126 -0
- package/docs/cli.md +99 -0
- package/docs/configuration.md +410 -0
- package/docs/documentation-panel.md +50 -0
- package/docs/examples/README.md +14 -0
- package/docs/examples/multi-variant.case.tsx +30 -0
- package/docs/examples/plain.case.tsx +22 -0
- package/docs/examples/tweak-control.placard.md +80 -0
- package/docs/examples/tweaks.case.tsx +39 -0
- package/docs/hierarchy.md +59 -0
- package/docs/quick-start.md +78 -0
- package/docs/style-engines.md +180 -0
- package/docs/testing.md +245 -0
- package/docs/theming.md +97 -0
- package/docs/tweaks.md +75 -0
- package/docs/writing-cases.md +144 -0
- package/docs/writing-placard-docs.md +194 -0
- package/package.json +113 -0
- package/skills/display-case-author-case/README.md +20 -0
- package/skills/display-case-author-case/SKILL.md +40 -0
- package/skills/display-case-author-placard-doc/README.md +24 -0
- package/skills/display-case-author-placard-doc/SKILL.md +65 -0
- package/skills/display-case-review/README.md +19 -0
- package/skills/display-case-review/SKILL.md +30 -0
- package/skills/display-case-snapshot/README.md +20 -0
- package/skills/display-case-snapshot/SKILL.md +29 -0
- package/src/checks/a11y-scanner.test.ts +240 -0
- package/src/checks/a11y-scanner.ts +410 -0
- package/src/checks/check-text.test.ts +53 -0
- package/src/checks/check-text.ts +78 -0
- package/src/checks/check.test.ts +194 -0
- package/src/checks/check.ts +473 -0
- package/src/checks/providers/pixelmatch-diff.test.ts +79 -0
- package/src/checks/providers/pixelmatch-diff.ts +30 -0
- package/src/checks/providers/playwright-driver.ts +104 -0
- package/src/checks/ssr-check.test.ts +73 -0
- package/src/checks/ssr-check.ts +96 -0
- package/src/checks/structure-check.cross-package.test.ts +165 -0
- package/src/checks/structure-check.test.ts +651 -0
- package/src/checks/structure-check.ts +988 -0
- package/src/checks/tokens-check.test.ts +159 -0
- package/src/checks/tokens-check.ts +162 -0
- package/src/cli.ts +218 -0
- package/src/commands/agents.test.ts +24 -0
- package/src/commands/agents.ts +28 -0
- package/src/commands/init-run.test.ts +123 -0
- package/src/commands/init.test.ts +63 -0
- package/src/commands/init.ts +412 -0
- package/src/commands/publish.test.ts +210 -0
- package/src/commands/publish.ts +292 -0
- package/src/core/affected.test.ts +99 -0
- package/src/core/affected.ts +144 -0
- package/src/core/catalog.test.ts +152 -0
- package/src/core/catalog.ts +92 -0
- package/src/core/discovery.test.ts +184 -0
- package/src/core/discovery.ts +250 -0
- package/src/core/manifest.ts +41 -0
- package/src/core/mdx-lite/__fixtures__/box-stub.tsx +7 -0
- package/src/core/mdx-lite/index.ts +393 -0
- package/src/core/mdx-lite/mdx-lite.test.ts +345 -0
- package/src/core/mdx-plugin.test.ts +60 -0
- package/src/core/mdx-plugin.ts +30 -0
- package/src/flow-step.test-d.ts +39 -0
- package/src/index.test.ts +100 -0
- package/src/index.ts +564 -0
- package/src/render/collect-styles.emotion.test.tsx +114 -0
- package/src/render/collect-styles.test.tsx +72 -0
- package/src/render/collect-styles.ts +33 -0
- package/src/render/documents.test.ts +184 -0
- package/src/render/documents.ts +88 -0
- package/src/render/render-node.test.tsx +160 -0
- package/src/render/render-node.tsx +133 -0
- package/src/render/ssr-primer.test.tsx +25 -0
- package/src/render/ssr-primer.tsx +54 -0
- package/src/render/ssr-render.test.tsx +142 -0
- package/src/render/ssr-render.tsx +63 -0
- package/src/render/ssr-shell.test.tsx +57 -0
- package/src/render/ssr-shell.tsx +54 -0
- package/src/server/prod-server.ts +237 -0
- package/src/server/server.test.ts +117 -0
- package/src/server/server.ts +1039 -0
- package/src/style-engine.test-d.ts +37 -0
- package/src/testing/test-helpers.ts +27 -0
- package/src/types/pixelmatch.d.ts +12 -0
- package/src/ui/browser-entry.tsx +51 -0
- package/src/ui/chrome.css +485 -0
- package/src/ui/design-system/README.md +88 -0
- package/src/ui/design-system/components/controls/Button.case.tsx +52 -0
- package/src/ui/design-system/components/controls/Button.css +89 -0
- package/src/ui/design-system/components/controls/Button.placard.md +14 -0
- package/src/ui/design-system/components/controls/Button.test.tsx +45 -0
- package/src/ui/design-system/components/controls/Button.tsx +41 -0
- package/src/ui/design-system/components/controls/IconButton.case.tsx +52 -0
- package/src/ui/design-system/components/controls/IconButton.css +67 -0
- package/src/ui/design-system/components/controls/IconButton.placard.md +13 -0
- package/src/ui/design-system/components/controls/IconButton.test.tsx +39 -0
- package/src/ui/design-system/components/controls/IconButton.tsx +47 -0
- package/src/ui/design-system/components/controls/Input.case.tsx +50 -0
- package/src/ui/design-system/components/controls/Input.css +52 -0
- package/src/ui/design-system/components/controls/Input.placard.md +12 -0
- package/src/ui/design-system/components/controls/Input.test.tsx +43 -0
- package/src/ui/design-system/components/controls/Input.tsx +45 -0
- package/src/ui/design-system/components/controls/Select.case.tsx +48 -0
- package/src/ui/design-system/components/controls/Select.css +44 -0
- package/src/ui/design-system/components/controls/Select.placard.md +15 -0
- package/src/ui/design-system/components/controls/Select.test.tsx +57 -0
- package/src/ui/design-system/components/controls/Select.tsx +58 -0
- package/src/ui/design-system/components/controls/SelectMenu.case.tsx +100 -0
- package/src/ui/design-system/components/controls/SelectMenu.css +72 -0
- package/src/ui/design-system/components/controls/SelectMenu.placard.md +18 -0
- package/src/ui/design-system/components/controls/SelectMenu.test.tsx +66 -0
- package/src/ui/design-system/components/controls/SelectMenu.tsx +377 -0
- package/src/ui/design-system/components/index.ts +66 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.case.tsx +44 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/ColorRamp.tsx +51 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.case.tsx +38 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/DefinitionList.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.case.tsx +24 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/FontFamilies.tsx +41 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.case.tsx +27 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.placard.md +13 -0
- package/src/ui/design-system/components/primer-specimen/GlyphGrid.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.case.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.placard.md +7 -0
- package/src/ui/design-system/components/primer-specimen/LayoutMock.tsx +36 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.case.tsx +20 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.placard.md +12 -0
- package/src/ui/design-system/components/primer-specimen/SpacingScale.tsx +33 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.case.tsx +56 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.placard.md +17 -0
- package/src/ui/design-system/components/primer-specimen/SpecimenBoxRow.tsx +45 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.case.tsx +17 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.placard.md +16 -0
- package/src/ui/design-system/components/primer-specimen/StatusList.tsx +39 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.case.tsx +26 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/SwatchGrid.tsx +42 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.case.tsx +23 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.placard.md +14 -0
- package/src/ui/design-system/components/primer-specimen/TypeScale.tsx +34 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.case.tsx +28 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.placard.md +15 -0
- package/src/ui/design-system/components/primer-specimen/WeightSpecimen.tsx +46 -0
- package/src/ui/design-system/components/primer-specimen/index.ts +31 -0
- package/src/ui/design-system/components/primer-specimen/styles.css +476 -0
- package/src/ui/design-system/components/shell/A11yPage.case.tsx +237 -0
- package/src/ui/design-system/components/shell/A11yPage.placard.md +15 -0
- package/src/ui/design-system/components/shell/CaseTemplate.case.tsx +32 -0
- package/src/ui/design-system/components/shell/CaseTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/CasesPage.case.tsx +141 -0
- package/src/ui/design-system/components/shell/CasesPage.placard.md +12 -0
- package/src/ui/design-system/components/shell/PrimerPage.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerPage.placard.md +3 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.case.tsx +22 -0
- package/src/ui/design-system/components/shell/PrimerTemplate.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.case.tsx +57 -0
- package/src/ui/design-system/components/shell/ShellView.placard.md +5 -0
- package/src/ui/design-system/components/shell/ShellView.tsx +678 -0
- package/src/ui/design-system/components/shell/shell-fixtures.tsx +727 -0
- package/src/ui/design-system/components/showcase/A11yBadge.case.tsx +46 -0
- package/src/ui/design-system/components/showcase/A11yBadge.css +27 -0
- package/src/ui/design-system/components/showcase/A11yBadge.placard.md +11 -0
- package/src/ui/design-system/components/showcase/A11yBadge.test.tsx +31 -0
- package/src/ui/design-system/components/showcase/A11yBadge.tsx +41 -0
- package/src/ui/design-system/components/showcase/A11yPanel.case.tsx +121 -0
- package/src/ui/design-system/components/showcase/A11yPanel.css +198 -0
- package/src/ui/design-system/components/showcase/A11yPanel.placard.md +19 -0
- package/src/ui/design-system/components/showcase/A11yPanel.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/A11yPanel.tsx +144 -0
- package/src/ui/design-system/components/showcase/Chip.case.tsx +48 -0
- package/src/ui/design-system/components/showcase/Chip.css +51 -0
- package/src/ui/design-system/components/showcase/Chip.placard.md +13 -0
- package/src/ui/design-system/components/showcase/Chip.test.tsx +46 -0
- package/src/ui/design-system/components/showcase/Chip.tsx +54 -0
- package/src/ui/design-system/components/showcase/Eyebrow.case.tsx +30 -0
- package/src/ui/design-system/components/showcase/Eyebrow.css +16 -0
- package/src/ui/design-system/components/showcase/Eyebrow.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Eyebrow.test.tsx +38 -0
- package/src/ui/design-system/components/showcase/Eyebrow.tsx +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.case.tsx +35 -0
- package/src/ui/design-system/components/showcase/FlowNav.css +29 -0
- package/src/ui/design-system/components/showcase/FlowNav.placard.md +13 -0
- package/src/ui/design-system/components/showcase/FlowNav.test.tsx +48 -0
- package/src/ui/design-system/components/showcase/FlowNav.tsx +58 -0
- package/src/ui/design-system/components/showcase/ImpactTag.case.tsx +19 -0
- package/src/ui/design-system/components/showcase/ImpactTag.css +36 -0
- package/src/ui/design-system/components/showcase/ImpactTag.placard.md +14 -0
- package/src/ui/design-system/components/showcase/ImpactTag.test.tsx +40 -0
- package/src/ui/design-system/components/showcase/ImpactTag.tsx +35 -0
- package/src/ui/design-system/components/showcase/NavItem.case.tsx +86 -0
- package/src/ui/design-system/components/showcase/NavItem.css +111 -0
- package/src/ui/design-system/components/showcase/NavItem.placard.md +13 -0
- package/src/ui/design-system/components/showcase/NavItem.test.tsx +65 -0
- package/src/ui/design-system/components/showcase/NavItem.tsx +95 -0
- package/src/ui/design-system/components/showcase/RenderAddress.case.tsx +21 -0
- package/src/ui/design-system/components/showcase/RenderAddress.css +35 -0
- package/src/ui/design-system/components/showcase/RenderAddress.placard.md +7 -0
- package/src/ui/design-system/components/showcase/RenderAddress.test.tsx +26 -0
- package/src/ui/design-system/components/showcase/RenderAddress.tsx +43 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.case.tsx +84 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.css +61 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.placard.md +21 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.test.tsx +81 -0
- package/src/ui/design-system/components/showcase/SegmentedToggle.tsx +75 -0
- package/src/ui/design-system/components/showcase/Sidebar.case.tsx +67 -0
- package/src/ui/design-system/components/showcase/Sidebar.css +6 -0
- package/src/ui/design-system/components/showcase/Sidebar.placard.md +14 -0
- package/src/ui/design-system/components/showcase/Sidebar.test.tsx +32 -0
- package/src/ui/design-system/components/showcase/Sidebar.tsx +30 -0
- package/src/ui/design-system/components/showcase/Stage.case.tsx +51 -0
- package/src/ui/design-system/components/showcase/Stage.css +91 -0
- package/src/ui/design-system/components/showcase/Stage.placard.md +15 -0
- package/src/ui/design-system/components/showcase/Stage.test.tsx +84 -0
- package/src/ui/design-system/components/showcase/Stage.tsx +97 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.case.tsx +81 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.css +169 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.placard.md +20 -0
- package/src/ui/design-system/components/showcase/TweaksPanel.tsx +230 -0
- package/src/ui/design-system/components/showcase/Wordmark.case.tsx +42 -0
- package/src/ui/design-system/components/showcase/Wordmark.css +31 -0
- package/src/ui/design-system/components/showcase/Wordmark.placard.md +10 -0
- package/src/ui/design-system/components/showcase/Wordmark.test.tsx +22 -0
- package/src/ui/design-system/components/showcase/Wordmark.tsx +22 -0
- package/src/ui/design-system/primer-specimens/brand.tsx +26 -0
- package/src/ui/design-system/primer-specimens/colors.tsx +83 -0
- package/src/ui/design-system/primer-specimens/components.tsx +308 -0
- package/src/ui/design-system/primer-specimens/foundations.tsx +71 -0
- package/src/ui/design-system/primer-specimens/index.ts +25 -0
- package/src/ui/design-system/primer-specimens/showcase.tsx +68 -0
- package/src/ui/design-system/primer-specimens/spacing.tsx +101 -0
- package/src/ui/design-system/primer-specimens/type.tsx +75 -0
- package/src/ui/design-system/primer.mdx +236 -0
- package/src/ui/design-system/styles.css +14 -0
- package/src/ui/design-system/tokens/colors.css +172 -0
- package/src/ui/design-system/tokens/fonts.css +18 -0
- package/src/ui/design-system/tokens/spacing.css +48 -0
- package/src/ui/design-system/tokens/typography.css +49 -0
- package/src/ui/markdown.test.tsx +54 -0
- package/src/ui/markdown.tsx +19 -0
- package/src/ui/primer-mount.tsx +76 -0
- package/src/ui/primer.css +175 -0
- package/src/ui/primer.tsx +277 -0
- package/src/ui/render-mount.tsx +284 -0
- package/src/ui/shell-core.test.ts +340 -0
- package/src/ui/shell-core.ts +295 -0
- package/src/ui/shell.tsx +60 -0
- package/src/ui/test-ids.ts +53 -0
- package/src/ui/use-shell.ts +1230 -0
|
@@ -0,0 +1,1230 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CSSProperties,
|
|
3
|
+
KeyboardEvent as ReactKeyboardEvent,
|
|
4
|
+
PointerEvent as ReactPointerEvent,
|
|
5
|
+
RefCallback,
|
|
6
|
+
} from 'react'
|
|
7
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
+
import type { Manifest, ManifestComponent } from '../core/manifest'
|
|
9
|
+
import type { A11yViolation } from '../index'
|
|
10
|
+
import {
|
|
11
|
+
buildAddressUrl,
|
|
12
|
+
buildRenderSrc,
|
|
13
|
+
buildUrl,
|
|
14
|
+
DEVICES,
|
|
15
|
+
DOC_DEFAULT_W,
|
|
16
|
+
DOC_MAX_W,
|
|
17
|
+
DOC_MIN_W,
|
|
18
|
+
GRID,
|
|
19
|
+
gridPad,
|
|
20
|
+
groupByLevel,
|
|
21
|
+
groupPrimerSections,
|
|
22
|
+
initialSelectionFor,
|
|
23
|
+
MIN_PAD,
|
|
24
|
+
MODE_FADE_MS,
|
|
25
|
+
NAV_COLLAPSE_MAX,
|
|
26
|
+
type ParsedRoute,
|
|
27
|
+
type PrimerGroup,
|
|
28
|
+
type PrimerSection,
|
|
29
|
+
parseLocation,
|
|
30
|
+
primerForLocation,
|
|
31
|
+
RESPONSIVE,
|
|
32
|
+
resolveMode,
|
|
33
|
+
type Selection,
|
|
34
|
+
STAGE_FADE_MS,
|
|
35
|
+
selSignature,
|
|
36
|
+
type Theme,
|
|
37
|
+
} from './shell-core'
|
|
38
|
+
import { DcTestIds } from './test-ids'
|
|
39
|
+
|
|
40
|
+
/** The accessibility audit results the chrome surfaces — per-variant nav markers
|
|
41
|
+
* across the library plus the active variant's verdict. The whole field is only
|
|
42
|
+
* present when a11y scanning is configured; when it's absent the chrome shows no
|
|
43
|
+
* markers and no panel at all (see {@link ShellViewModel.a11y}). */
|
|
44
|
+
export interface A11ySurface {
|
|
45
|
+
/** componentId → (caseId → that variant's WCAG violation count). Violations
|
|
46
|
+
* belong to a *variant* (each `/render/<component>/<case>` is audited on its
|
|
47
|
+
* own), so this is keyed per case. The nav rail folds it: a collapsed
|
|
48
|
+
* component shows the sum across its variants; expanded, the per-variant
|
|
49
|
+
* counts move onto the case rows and the parent shows a plain marker.
|
|
50
|
+
* Absent components / all-zero variants render no marker. A still-scanning
|
|
51
|
+
* case simply has no entry yet (its marker appears once the scan lands). */
|
|
52
|
+
byVariant: Record<string, Record<string, number>>
|
|
53
|
+
/** The active variant's verdict for the stage panel: `'pending'` while its
|
|
54
|
+
* scan is in flight, `'unavailable'` when the scan prerequisite can't run, an
|
|
55
|
+
* empty array for a clean pass, or the violations.
|
|
56
|
+
* (There is no "not audited" value — the panel only renders when a11y is
|
|
57
|
+
* configured, and a configured, viewed variant is always pending-or-resolved.) */
|
|
58
|
+
current: A11yViolation[] | 'pending' | 'unavailable'
|
|
59
|
+
/** How the resolved verdict should animate in: `'cascade'` when it just
|
|
60
|
+
* resolved from a live scan (the user watched "Scanning…"), `'all'` when it
|
|
61
|
+
* came straight from cache (already scanned — fade in at once). */
|
|
62
|
+
reveal?: 'cascade' | 'all'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The view model the chrome's pure {@link ShellView} renders. Everything the
|
|
66
|
+
// chrome needs to draw a frame is here — state, derived layout numbers, event
|
|
67
|
+
// handlers and the imperative refs the live iframes attach to. `useShell`
|
|
68
|
+
// produces the live one (state + effects + the render-frame handshake); a case
|
|
69
|
+
// can hand-build a static one to exhibit the chrome as a page/flow.
|
|
70
|
+
export interface ShellViewModel {
|
|
71
|
+
manifest: Manifest
|
|
72
|
+
theme: Theme
|
|
73
|
+
setTheme: (update: (t: Theme) => Theme) => void
|
|
74
|
+
navCollapsed: boolean
|
|
75
|
+
setNavCollapsed: (update: (c: boolean) => boolean) => void
|
|
76
|
+
|
|
77
|
+
// Primer ↔ Cases mode.
|
|
78
|
+
mode: 'primer' | 'library'
|
|
79
|
+
setMode: (m: 'primer' | 'library') => void
|
|
80
|
+
shownMode: 'primer' | 'library'
|
|
81
|
+
modeFadeStyle: CSSProperties
|
|
82
|
+
|
|
83
|
+
// Device toolbar / zoom / grid.
|
|
84
|
+
sizeId: string
|
|
85
|
+
setSizeId: (id: string) => void
|
|
86
|
+
manualZoom: number
|
|
87
|
+
setManualZoom: (update: (z: number) => number) => void
|
|
88
|
+
showGrid: boolean
|
|
89
|
+
setShowGrid: (update: (g: boolean) => boolean) => void
|
|
90
|
+
widthInputValue: number | ''
|
|
91
|
+
fixed: { w: number; h: number } | null
|
|
92
|
+
fitted: boolean
|
|
93
|
+
scale: number
|
|
94
|
+
sizeMeta: string
|
|
95
|
+
editDim: (axis: 'w' | 'h', raw: string) => void
|
|
96
|
+
rotateDims: () => void
|
|
97
|
+
|
|
98
|
+
// The exhibit on the stage.
|
|
99
|
+
stageDecor: boolean
|
|
100
|
+
component: ManifestComponent | null
|
|
101
|
+
activeCase: ManifestComponent['cases'][number] | null
|
|
102
|
+
shownSel: Selection | null
|
|
103
|
+
sel: Selection | null
|
|
104
|
+
/** The exhibit's shareable standalone-snapshot address (origin-prefixed). Held
|
|
105
|
+
* on the model — not derived in the view — so the pure view never reads
|
|
106
|
+
* `window`, and an exhibit can supply a deterministic address. */
|
|
107
|
+
addressUrl: string
|
|
108
|
+
|
|
109
|
+
// Docs panel.
|
|
110
|
+
docOpen: boolean
|
|
111
|
+
changeDocsOpen: (open: boolean) => void
|
|
112
|
+
docText: string | null
|
|
113
|
+
docWidth: number
|
|
114
|
+
startDocResize: (e: ReactPointerEvent<HTMLDivElement>) => void
|
|
115
|
+
onDocResizeKey: (e: ReactKeyboardEvent<HTMLDivElement>) => void
|
|
116
|
+
|
|
117
|
+
// Tweaks panel.
|
|
118
|
+
tweaksFloating: boolean
|
|
119
|
+
setTweaksFloating: (update: (f: boolean) => boolean) => void
|
|
120
|
+
|
|
121
|
+
// Accessibility audit surface. Optional — absent until live audits are wired
|
|
122
|
+
// into the running chrome; a page/template exhibit supplies it to demonstrate
|
|
123
|
+
// the surfacing. `byVariant` drives the nav-rail markers (a component is
|
|
124
|
+
// discoverable as having issues without selecting it — summed when collapsed
|
|
125
|
+
// or a single-case leaf, per-variant when expanded); `current` lists the
|
|
126
|
+
// active variant's violations in the stage's a11y panel.
|
|
127
|
+
a11y?: A11ySurface
|
|
128
|
+
/** Force a fresh audit of the viewed variant (the panel's "re-scan" control).
|
|
129
|
+
* Absent in a static exhibit unless it wires its own. */
|
|
130
|
+
rescanA11y?: () => void
|
|
131
|
+
|
|
132
|
+
// Nav rail (scroll-fade refs + library tree).
|
|
133
|
+
navScrollRef: RefCallback<HTMLDivElement> | { current: HTMLDivElement | null }
|
|
134
|
+
navBodyRef: RefCallback<HTMLDivElement> | { current: HTMLDivElement | null }
|
|
135
|
+
groups: ReturnType<typeof groupByLevel>
|
|
136
|
+
expanded: Set<string>
|
|
137
|
+
toggleExpanded: (id: string) => void
|
|
138
|
+
selectComponent: (c: ManifestComponent) => void
|
|
139
|
+
select: (next: Selection) => void
|
|
140
|
+
|
|
141
|
+
// Nav rail (primer table of contents).
|
|
142
|
+
primerGroups: PrimerGroup[]
|
|
143
|
+
primerActive: string
|
|
144
|
+
primerExpanded: Set<string>
|
|
145
|
+
togglePrimerGroup: (id: string) => void
|
|
146
|
+
scrollToSection: (id: string) => void
|
|
147
|
+
|
|
148
|
+
// Stage geometry.
|
|
149
|
+
attachPreview: RefCallback<HTMLDivElement>
|
|
150
|
+
padX: number
|
|
151
|
+
padY: number
|
|
152
|
+
stageShown: boolean
|
|
153
|
+
/** True for the duration of a navigation crossfade (fade-out through fade-in).
|
|
154
|
+
* The a11y panel uses it to hard-switch its verdict colour while faded rather
|
|
155
|
+
* than easing the previous exhibit's colour across the fade. */
|
|
156
|
+
colorSnap?: boolean
|
|
157
|
+
boxW: number
|
|
158
|
+
boxH: number
|
|
159
|
+
|
|
160
|
+
// Live-frame plumbing (the container builds the iframes from these; a case
|
|
161
|
+
// ignores them and supplies a static stage/primer slot instead).
|
|
162
|
+
frameRef: { current: HTMLIFrameElement | null }
|
|
163
|
+
frameSrc: string | null
|
|
164
|
+
targetW: number
|
|
165
|
+
renderH: number
|
|
166
|
+
primerRef: { current: HTMLIFrameElement | null }
|
|
167
|
+
primerSrc: string | null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The browse chrome's state machine: manifest loading, the address ↔ selection
|
|
172
|
+
* sync, the stage crossfade + sizing math, the docs panel, and the render/
|
|
173
|
+
* primer frame handshakes. It owns every hook so {@link ShellView} can stay a
|
|
174
|
+
* pure function of the {@link ShellViewModel} it returns — which is also what
|
|
175
|
+
* lets the chrome be exhibited as a page/flow from a static, hand-built model.
|
|
176
|
+
*
|
|
177
|
+
* The shell is seeded: the server renders it from the in-memory manifest + the
|
|
178
|
+
* request route + theme, and the client hydrates from the same seed (the inlined
|
|
179
|
+
* manifest + the live address), so the render-affecting initial state is
|
|
180
|
+
* deterministic on both sides. Measured values (panel/content size, frame src)
|
|
181
|
+
* start at constants and update in effects after hydration.
|
|
182
|
+
*/
|
|
183
|
+
export interface ShellSeed {
|
|
184
|
+
manifest: Manifest
|
|
185
|
+
route: ParsedRoute
|
|
186
|
+
theme: Theme
|
|
187
|
+
/** Whether live a11y surfacing is configured (drives nav markers + panel). */
|
|
188
|
+
a11y: boolean
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function useShell(seed: ShellSeed): ShellViewModel | { manifest: null } {
|
|
192
|
+
// Initial selection + mode are derived from the seed (route + manifest), not
|
|
193
|
+
// from `window`, so the server render and the client hydration agree.
|
|
194
|
+
const seedSel = initialSelectionFor(seed.manifest, seed.route)
|
|
195
|
+
const seedMode = resolveMode(seed.route, seed.manifest)
|
|
196
|
+
const [manifest, setManifest] = useState<Manifest | null>(seed.manifest)
|
|
197
|
+
// Read in the popstate listener (a `[]`-deps effect) to decide whether `/`
|
|
198
|
+
// means the Primer — without resubscribing the listener on every manifest change.
|
|
199
|
+
const manifestRef = useRef(manifest)
|
|
200
|
+
manifestRef.current = manifest
|
|
201
|
+
const [sel, setSel] = useState<Selection | null>(seedSel)
|
|
202
|
+
const [theme, setTheme] = useState<Theme>(seed.theme)
|
|
203
|
+
// Page origin for absolute shareable addresses. Empty during the server render
|
|
204
|
+
// and the client's first render (so they match); filled in after hydration.
|
|
205
|
+
const [origin, setOrigin] = useState('')
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
setOrigin(window.location.origin)
|
|
208
|
+
}, [])
|
|
209
|
+
|
|
210
|
+
// Live accessibility surfacing. Whether scanning is configured is part of the
|
|
211
|
+
// seed (the server knows; the client gets the same value inlined), so the
|
|
212
|
+
// markers/panel render identically on both sides. Results come from `/a11y`
|
|
213
|
+
// (per viewed variant) and are pushed in over the SSE stream as scans complete.
|
|
214
|
+
const dcConfig = (
|
|
215
|
+
globalThis as {
|
|
216
|
+
__displayCase?: { reload?: boolean; a11y?: boolean; dev?: boolean }
|
|
217
|
+
}
|
|
218
|
+
).__displayCase
|
|
219
|
+
const a11yEnabled = seed.a11y
|
|
220
|
+
const [a11y, setA11y] = useState<A11ySurface | undefined>(
|
|
221
|
+
a11yEnabled ? { byVariant: {}, current: 'pending' } : undefined,
|
|
222
|
+
)
|
|
223
|
+
// The variant the panel currently reflects, so a late-arriving scan for a
|
|
224
|
+
// variant the viewer has since left updates only the nav markers, not the panel.
|
|
225
|
+
const a11yCurRef = useRef<{ c?: string; cs?: string; th?: string }>({})
|
|
226
|
+
|
|
227
|
+
const applyA11yResult = useCallback(
|
|
228
|
+
(
|
|
229
|
+
c: string,
|
|
230
|
+
cs: string,
|
|
231
|
+
th: string,
|
|
232
|
+
res: {
|
|
233
|
+
status: 'ok' | 'pending' | 'unavailable'
|
|
234
|
+
violations?: A11yViolation[]
|
|
235
|
+
reason?: string
|
|
236
|
+
},
|
|
237
|
+
// True when this result came from a live scan completing (the SSE push),
|
|
238
|
+
// false when it came straight from the cache (the fetch response).
|
|
239
|
+
fromScan: boolean,
|
|
240
|
+
) => {
|
|
241
|
+
setA11y((prev) => {
|
|
242
|
+
const byVariant = { ...(prev?.byVariant ?? {}) }
|
|
243
|
+
if (res.status === 'ok') {
|
|
244
|
+
byVariant[c] = {
|
|
245
|
+
...(byVariant[c] ?? {}),
|
|
246
|
+
[cs]: res.violations?.length ?? 0,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const cur = a11yCurRef.current
|
|
250
|
+
const isCurrent = c === cur.c && cs === cur.cs && th === cur.th
|
|
251
|
+
let current = prev?.current ?? 'pending'
|
|
252
|
+
let reveal = prev?.reveal ?? 'all'
|
|
253
|
+
if (isCurrent) {
|
|
254
|
+
if (res.status === 'ok') {
|
|
255
|
+
current = res.violations ?? []
|
|
256
|
+
// Cascade only when the user watched it scan; a cache hit fades in.
|
|
257
|
+
reveal = fromScan ? 'cascade' : 'all'
|
|
258
|
+
} else current = res.status
|
|
259
|
+
}
|
|
260
|
+
return { byVariant, current, reveal }
|
|
261
|
+
})
|
|
262
|
+
},
|
|
263
|
+
[],
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
const requestA11y = useCallback(
|
|
267
|
+
(c: string, cs: string, th: string, force?: boolean) => {
|
|
268
|
+
a11yCurRef.current = { c, cs, th }
|
|
269
|
+
// A forced re-scan always runs a real scan, so show "Scanning…" at once.
|
|
270
|
+
// A plain view lets the response decide: a cache hit resolves straight to
|
|
271
|
+
// the result (faded in), only a real scan shows "Scanning…" then cascades.
|
|
272
|
+
if (force) {
|
|
273
|
+
setA11y((prev) => ({
|
|
274
|
+
byVariant: prev?.byVariant ?? {},
|
|
275
|
+
current: 'pending',
|
|
276
|
+
reveal: prev?.reveal ?? 'all',
|
|
277
|
+
}))
|
|
278
|
+
}
|
|
279
|
+
const rescan = force ? '&rescan=1' : ''
|
|
280
|
+
fetch(
|
|
281
|
+
`/a11y?component=${encodeURIComponent(c)}&case=${encodeURIComponent(cs)}&theme=${th}${rescan}`,
|
|
282
|
+
)
|
|
283
|
+
.then((r) => r.json())
|
|
284
|
+
.then((res) => applyA11yResult(c, cs, th, res, false))
|
|
285
|
+
.catch(() => {})
|
|
286
|
+
},
|
|
287
|
+
[applyA11yResult],
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
// The panel's "re-scan" affordance: force a fresh audit of the viewed variant.
|
|
291
|
+
const rescanA11y = useCallback(() => {
|
|
292
|
+
const cur = a11yCurRef.current
|
|
293
|
+
if (cur.c && cur.cs && cur.th) requestA11y(cur.c, cur.cs, cur.th, true)
|
|
294
|
+
}, [requestA11y])
|
|
295
|
+
// Selected size: a responsive/device preset id, or 'custom' (uses `custom`).
|
|
296
|
+
const [sizeId, setSizeId] = useState<string>('full')
|
|
297
|
+
const [custom, setCustom] = useState<{ w: number; h: number }>({
|
|
298
|
+
w: 1280,
|
|
299
|
+
h: 800,
|
|
300
|
+
})
|
|
301
|
+
const [manualZoom, setManualZoom] = useState(1)
|
|
302
|
+
// Stage backdrop for decorated components: the dotted grid (default) or the
|
|
303
|
+
// consumer app's own background colour (`--color-bg`, the same the iframe uses).
|
|
304
|
+
const [showGrid, setShowGrid] = useState(true)
|
|
305
|
+
// Measured inner size of the preview panel, for fit-to-panel scaling.
|
|
306
|
+
const [panel, setPanel] = useState<{ w: number; h: number }>({
|
|
307
|
+
w: 0,
|
|
308
|
+
h: 0,
|
|
309
|
+
})
|
|
310
|
+
const previewObserver = useRef<ResizeObserver | null>(null)
|
|
311
|
+
// Natural size of the rendered component, reported by the render frame. In
|
|
312
|
+
// Responsive mode a decorated component is sized to its own height (and
|
|
313
|
+
// centered on the grid) instead of stretching to fill the panel.
|
|
314
|
+
const [content, setContent] = useState<{ w: number; h: number } | null>(null)
|
|
315
|
+
// The selection the stage currently *displays*. It trails `sel` by one
|
|
316
|
+
// fade-out so the iframe content, size, and mode all swap while the stage is
|
|
317
|
+
// hidden — a clean crossfade rather than a mid-flight jump. The sidebar tracks
|
|
318
|
+
// `sel` directly (instant highlight); only the exhibit waits.
|
|
319
|
+
const [shownSel, setShownSel] = useState<Selection | null>(seedSel)
|
|
320
|
+
const shownSelRef = useRef(shownSel)
|
|
321
|
+
shownSelRef.current = shownSel
|
|
322
|
+
const selRef = useRef(sel)
|
|
323
|
+
selRef.current = sel
|
|
324
|
+
const selSig = sel ? selSignature(sel) : ''
|
|
325
|
+
// Drives the stage opacity. Starts hidden so the first exhibit fades in once
|
|
326
|
+
// measured; flipped false on navigation (fade out) and true once the shown
|
|
327
|
+
// exhibit has caught up and reported its size (fade in).
|
|
328
|
+
const [stageShown, setStageShown] = useState(false)
|
|
329
|
+
// True while a navigation crossfade is in flight (fade-out start → fade-in
|
|
330
|
+
// end). The a11y panel snaps its verdict colour during this window so the new
|
|
331
|
+
// exhibit's colour is in place before it fades in, instead of easing the old
|
|
332
|
+
// colour across the fade.
|
|
333
|
+
const [colorSnap, setColorSnap] = useState(true)
|
|
334
|
+
// Signature of the exhibit the render frame last reported a size for. The
|
|
335
|
+
// stage only fades in once this matches what's shown, so a swap never reveals
|
|
336
|
+
// at the wrong size.
|
|
337
|
+
const [measuredSig, setMeasuredSig] = useState('')
|
|
338
|
+
const [docOpen, setDocOpen] = useState(seed.route.docs)
|
|
339
|
+
// Kept current so the stable `select` callback can preserve the docs flag in
|
|
340
|
+
// the address across navigations without taking `docOpen` as a dependency.
|
|
341
|
+
const docOpenRef = useRef(docOpen)
|
|
342
|
+
docOpenRef.current = docOpen
|
|
343
|
+
const [docText, setDocText] = useState<string | null>(null)
|
|
344
|
+
const [docWidth, setDocWidth] = useState(DOC_DEFAULT_W)
|
|
345
|
+
// Tweaks panel can be undocked into a free-floating, draggable overlay.
|
|
346
|
+
const [tweaksFloating, setTweaksFloating] = useState(false)
|
|
347
|
+
// Starts expanded deterministically (the server has no viewport width); an
|
|
348
|
+
// effect collapses it on a narrow viewport after mount, so hydration matches.
|
|
349
|
+
const [navCollapsed, setNavCollapsed] = useState(false)
|
|
350
|
+
// Which components are expanded in the nav. Collapsed by default; the
|
|
351
|
+
// initially-selected component is seeded open so its active case is visible.
|
|
352
|
+
const [expanded, setExpanded] = useState<Set<string>>(
|
|
353
|
+
() => new Set(seedSel.componentId ? [seedSel.componentId] : []),
|
|
354
|
+
)
|
|
355
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null)
|
|
356
|
+
// The iframe loads once at a fixed src; every later change (case, theme,
|
|
357
|
+
// width, tweaks) is pushed in via postMessage so it never reloads/flickers.
|
|
358
|
+
const [frameSrc, setFrameSrc] = useState<string | null>(null)
|
|
359
|
+
const [frameReady, setFrameReady] = useState(false)
|
|
360
|
+
|
|
361
|
+
// ── Primer (the optional .mdx reading page) ──────────────────────────────
|
|
362
|
+
// Which sidebar view is active. The Primer, when configured, is the default
|
|
363
|
+
// landing view (it orients you before you browse) — but a deep link to a case
|
|
364
|
+
// opens straight into the library.
|
|
365
|
+
const [mode, setMode] = useState<'primer' | 'library'>(seedMode)
|
|
366
|
+
// The mode actually on screen. `mode` is the target (drives the mode-switch
|
|
367
|
+
// highlight box, which lerps to it instantly); `shownMode` lags by one fade so
|
|
368
|
+
// the nav, screen content, and header controls swap while hidden — a crossfade
|
|
369
|
+
// rather than a hard cut. Mirrors the `sel`/`shownSel` stage pattern above.
|
|
370
|
+
const [shownMode, setShownMode] = useState<'primer' | 'library'>(seedMode)
|
|
371
|
+
const shownModeRef = useRef(shownMode)
|
|
372
|
+
shownModeRef.current = shownMode
|
|
373
|
+
// Drives the opacity of the faded regions: true = shown, false = mid-swap.
|
|
374
|
+
const [modeContentShown, setModeContentShown] = useState(true)
|
|
375
|
+
// The nav scroll region — an inner wrapper, so the rail's right border (on the
|
|
376
|
+
// outer nav) and the pinned mode switch above it are never faded. Its native
|
|
377
|
+
// scrollbar is hidden (see chrome.css); a soft gradient fade at whichever edge
|
|
378
|
+
// has more content off-screen is the affordance instead. `data-fade-top` /
|
|
379
|
+
// `data-fade-bottom` toggle the mask; this recomputes them from scroll position.
|
|
380
|
+
const navScrollRef = useRef<HTMLDivElement | null>(null)
|
|
381
|
+
const navBodyRef = useRef<HTMLDivElement | null>(null)
|
|
382
|
+
const updateNavFade = useCallback(() => {
|
|
383
|
+
const el = navScrollRef.current
|
|
384
|
+
if (!el) return
|
|
385
|
+
const max = el.scrollHeight - el.clientHeight
|
|
386
|
+
const T = 4 // px slack so a resting top/bottom reads as fully docked
|
|
387
|
+
el.setAttribute('data-fade-top', el.scrollTop > T ? 'true' : 'false')
|
|
388
|
+
el.setAttribute(
|
|
389
|
+
'data-fade-bottom',
|
|
390
|
+
el.scrollTop < max - T ? 'true' : 'false',
|
|
391
|
+
)
|
|
392
|
+
}, [])
|
|
393
|
+
// The Primer renders in its own isolated iframe (like /render), created lazily
|
|
394
|
+
// the first time the Primer view is opened.
|
|
395
|
+
const primerRef = useRef<HTMLIFrameElement | null>(null)
|
|
396
|
+
const [primerSrc, setPrimerSrc] = useState<string | null>(null)
|
|
397
|
+
const [primerReady, setPrimerReady] = useState(false)
|
|
398
|
+
// Section table-of-contents + active section, reported by the Primer frame.
|
|
399
|
+
const [primerSections, setPrimerSections] = useState<PrimerSection[]>([])
|
|
400
|
+
const [primerActive, setPrimerActive] = useState('')
|
|
401
|
+
// Heading ids the reader has expanded in the table of contents. Tracking the
|
|
402
|
+
// open set (not the closed one) makes groups collapsed by default — the ids
|
|
403
|
+
// arrive asynchronously, so there's nothing to pre-seed a "collapsed" set with.
|
|
404
|
+
const [primerExpanded, setPrimerExpanded] = useState<Set<string>>(
|
|
405
|
+
() => new Set(),
|
|
406
|
+
)
|
|
407
|
+
// Accordion: at most one TOC group is open. Expanding a group collapses any
|
|
408
|
+
// other; clicking the open group's chevron closes it (leaving all closed).
|
|
409
|
+
const togglePrimerGroup = useCallback((id: string) => {
|
|
410
|
+
setPrimerExpanded((prev) => (prev.has(id) ? new Set() : new Set([id])))
|
|
411
|
+
}, [])
|
|
412
|
+
|
|
413
|
+
// The manifest, selection, and mode are seeded at init (the server renders from
|
|
414
|
+
// them and the client hydrates from the same seed), so there is no initial
|
|
415
|
+
// fetch here. The live-reload refresh below still refetches `/manifest.json` to
|
|
416
|
+
// pick up catalog changes without a full reload.
|
|
417
|
+
//
|
|
418
|
+
// Collapse the nav on a narrow viewport once mounted. It starts expanded (the
|
|
419
|
+
// server has no viewport width); collapsing in an effect runs only on the
|
|
420
|
+
// client, after hydration, so the first render matches on both sides.
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
if (window.innerWidth <= NAV_COLLAPSE_MAX) setNavCollapsed(true)
|
|
423
|
+
}, [])
|
|
424
|
+
|
|
425
|
+
// Crossfade the chrome when the view mode changes. The highlight box lerps to
|
|
426
|
+
// `mode` immediately (CSS); everything that swaps content — nav, screen, the
|
|
427
|
+
// mode-specific header controls — fades out first, swaps `shownMode` while
|
|
428
|
+
// hidden, then fades back in (two rAFs so the swapped-in view paints at opacity
|
|
429
|
+
// 0 before the transition to 1). Keyed on `mode` only; `shownMode` is read
|
|
430
|
+
// through a ref so a rapid toggle-back mid-fade just fades the current view
|
|
431
|
+
// back in (the pending swap is cancelled) instead of restarting.
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
if (mode === shownModeRef.current) {
|
|
434
|
+
setModeContentShown(true)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
setModeContentShown(false) // fade out
|
|
438
|
+
let raf1 = 0
|
|
439
|
+
let raf2 = 0
|
|
440
|
+
const swap = setTimeout(() => {
|
|
441
|
+
setShownMode(mode) // swap while hidden
|
|
442
|
+
raf1 = requestAnimationFrame(() => {
|
|
443
|
+
raf2 = requestAnimationFrame(() => setModeContentShown(true)) // fade in
|
|
444
|
+
})
|
|
445
|
+
}, MODE_FADE_MS)
|
|
446
|
+
return () => {
|
|
447
|
+
clearTimeout(swap)
|
|
448
|
+
cancelAnimationFrame(raf1)
|
|
449
|
+
cancelAnimationFrame(raf2)
|
|
450
|
+
}
|
|
451
|
+
}, [mode])
|
|
452
|
+
|
|
453
|
+
// Keep the nav's scroll-fade in sync: on scroll, when the container is resized
|
|
454
|
+
// (the sidebar's height tracks the window), and when its content height changes
|
|
455
|
+
// — expand/collapse, a mode swap, the primer TOC loading — which a
|
|
456
|
+
// ResizeObserver on the content body catches without listing state deps.
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
// Gate on `manifest`: until it loads, the chrome renders a loading screen and
|
|
459
|
+
// the nav isn't mounted, so re-run once it is (the ref is null before then).
|
|
460
|
+
if (!manifest) return
|
|
461
|
+
const el = navScrollRef.current
|
|
462
|
+
if (!el) return
|
|
463
|
+
updateNavFade()
|
|
464
|
+
el.addEventListener('scroll', updateNavFade, { passive: true })
|
|
465
|
+
const ro = new ResizeObserver(updateNavFade)
|
|
466
|
+
ro.observe(el)
|
|
467
|
+
if (navBodyRef.current) ro.observe(navBodyRef.current)
|
|
468
|
+
return () => {
|
|
469
|
+
el.removeEventListener('scroll', updateNavFade)
|
|
470
|
+
ro.disconnect()
|
|
471
|
+
}
|
|
472
|
+
}, [manifest, updateNavFade])
|
|
473
|
+
|
|
474
|
+
// Lazily mount the Primer frame the first time its view is opened, seeding the
|
|
475
|
+
// theme into the URL so the initial paint is correct; later theme changes are
|
|
476
|
+
// pushed in via postMessage (below) so it never reloads.
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (mode === 'primer' && !primerSrc) {
|
|
479
|
+
setPrimerSrc(`/render/primer?theme=${theme}`)
|
|
480
|
+
}
|
|
481
|
+
}, [mode, primerSrc, theme])
|
|
482
|
+
|
|
483
|
+
// Receive the Primer frame's section list, active section, and readiness.
|
|
484
|
+
useEffect(() => {
|
|
485
|
+
function onMessage(e: MessageEvent) {
|
|
486
|
+
if (e.source !== primerRef.current?.contentWindow) return
|
|
487
|
+
const data = e.data as {
|
|
488
|
+
type?: string
|
|
489
|
+
sections?: PrimerSection[]
|
|
490
|
+
id?: string
|
|
491
|
+
}
|
|
492
|
+
if (data?.type === 'dc-primer-ready') setPrimerReady(true)
|
|
493
|
+
else if (data?.type === 'dc-primer-sections' && data.sections)
|
|
494
|
+
setPrimerSections(data.sections)
|
|
495
|
+
else if (data?.type === 'dc-primer-active' && data.id)
|
|
496
|
+
setPrimerActive(data.id)
|
|
497
|
+
}
|
|
498
|
+
window.addEventListener('message', onMessage)
|
|
499
|
+
return () => window.removeEventListener('message', onMessage)
|
|
500
|
+
}, [])
|
|
501
|
+
|
|
502
|
+
// Keep the Primer frame's theme in step with the chrome's.
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
if (!primerReady) return
|
|
505
|
+
primerRef.current?.contentWindow?.postMessage(
|
|
506
|
+
{ type: 'dc-primer-theme', theme },
|
|
507
|
+
'*',
|
|
508
|
+
)
|
|
509
|
+
}, [theme, primerReady])
|
|
510
|
+
|
|
511
|
+
// Scroll the Primer to a section when its TOC entry is clicked. Setting the
|
|
512
|
+
// active section immediately drives the accordion (see the effect below) so
|
|
513
|
+
// the clicked group opens without waiting for the scroll to settle.
|
|
514
|
+
const scrollToSection = useCallback((id: string) => {
|
|
515
|
+
setPrimerActive(id)
|
|
516
|
+
primerRef.current?.contentWindow?.postMessage(
|
|
517
|
+
{ type: 'dc-primer-scroll', id },
|
|
518
|
+
'*',
|
|
519
|
+
)
|
|
520
|
+
}, [])
|
|
521
|
+
|
|
522
|
+
const select = useCallback((next: Selection) => {
|
|
523
|
+
setSel(next)
|
|
524
|
+
window.history.pushState(
|
|
525
|
+
null,
|
|
526
|
+
'',
|
|
527
|
+
buildUrl(next.componentId, next.caseId, next.tweaks, docOpenRef.current),
|
|
528
|
+
)
|
|
529
|
+
}, [])
|
|
530
|
+
|
|
531
|
+
// Switch between the Primer and the library, reflecting the mode in the
|
|
532
|
+
// address so the boundary is a real navigation step: back/forward cross it and
|
|
533
|
+
// a copied link reopens the same view. The Primer lives at `/primer`; the
|
|
534
|
+
// library at the *remembered* case address — so toggling back to Cases returns
|
|
535
|
+
// you to the exhibit you were last on (held in `sel`, never unmounted) rather
|
|
536
|
+
// than a reset. `setMode` then runs the crossfade as before.
|
|
537
|
+
const changeMode = useCallback((m: 'primer' | 'library') => {
|
|
538
|
+
setMode(m)
|
|
539
|
+
const s = selRef.current
|
|
540
|
+
const url =
|
|
541
|
+
m === 'primer' || !s
|
|
542
|
+
? '/primer'
|
|
543
|
+
: buildUrl(s.componentId, s.caseId, s.tweaks, docOpenRef.current)
|
|
544
|
+
window.history.pushState(null, '', url)
|
|
545
|
+
}, [])
|
|
546
|
+
|
|
547
|
+
// Open/close the docs panel and reflect it in the address (replaceState, so a
|
|
548
|
+
// toggle isn't a back-button step) so the open panel is deep-linkable.
|
|
549
|
+
const changeDocsOpen = useCallback(
|
|
550
|
+
(open: boolean) => {
|
|
551
|
+
setDocOpen(open)
|
|
552
|
+
if (!sel) return
|
|
553
|
+
window.history.replaceState(
|
|
554
|
+
null,
|
|
555
|
+
'',
|
|
556
|
+
buildUrl(sel.componentId, sel.caseId, sel.tweaks, open),
|
|
557
|
+
)
|
|
558
|
+
},
|
|
559
|
+
[sel],
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
// Browser back/forward only changes the address; this is the read side that
|
|
563
|
+
// applies it back to state. `select`/`changeDocsOpen` are the write side
|
|
564
|
+
// (they pushState/replaceState), so here we set state directly — never push a
|
|
565
|
+
// new entry, or back/forward would fight history.
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
const onPop = () => {
|
|
568
|
+
const loc = parseLocation()
|
|
569
|
+
if (loc.componentId) {
|
|
570
|
+
setSel({
|
|
571
|
+
componentId: loc.componentId,
|
|
572
|
+
caseId: loc.caseId,
|
|
573
|
+
tweaks: loc.tweaks,
|
|
574
|
+
})
|
|
575
|
+
setMode('library')
|
|
576
|
+
} else {
|
|
577
|
+
// Non-case address: `/primer` is the Primer; the bare `/` honors the
|
|
578
|
+
// configured landing (Primer unless `landing: 'cases'`). The library
|
|
579
|
+
// `sel` is left untouched so a later toggle back to Cases still restores
|
|
580
|
+
// it.
|
|
581
|
+
const m = manifestRef.current
|
|
582
|
+
setMode(m && primerForLocation(m) ? 'primer' : 'library')
|
|
583
|
+
}
|
|
584
|
+
setDocOpen(loc.docs)
|
|
585
|
+
}
|
|
586
|
+
window.addEventListener('popstate', onPop)
|
|
587
|
+
return () => window.removeEventListener('popstate', onPop)
|
|
588
|
+
}, [])
|
|
589
|
+
|
|
590
|
+
// Toggle a component's case list open/closed without navigating.
|
|
591
|
+
const toggleExpanded = useCallback((id: string) => {
|
|
592
|
+
setExpanded((prev) => {
|
|
593
|
+
const next = new Set(prev)
|
|
594
|
+
if (next.has(id)) next.delete(id)
|
|
595
|
+
else next.add(id)
|
|
596
|
+
return next
|
|
597
|
+
})
|
|
598
|
+
}, [])
|
|
599
|
+
|
|
600
|
+
// Navigate to a component's first case. Collapse every other accordion so only
|
|
601
|
+
// this component stays open (and reopen it if it was manually collapsed).
|
|
602
|
+
const selectComponent = useCallback(
|
|
603
|
+
(c: ManifestComponent) => {
|
|
604
|
+
const first = c.cases[0]
|
|
605
|
+
if (!first) return
|
|
606
|
+
select({ componentId: c.id, caseId: first.id, tweaks: {} })
|
|
607
|
+
setExpanded(new Set([c.id]))
|
|
608
|
+
},
|
|
609
|
+
[select],
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
// Whenever the selected component changes — by any path: a component click, a
|
|
613
|
+
// case click in another component, a flow `goto`, or a deep-link — collapse
|
|
614
|
+
// every other accordion so only the active component stays open. Keyed on the
|
|
615
|
+
// component id, so manual chevron toggles between navigations are preserved
|
|
616
|
+
// (they don't change the id and so don't re-fire this).
|
|
617
|
+
const selectedComponentId = sel?.componentId
|
|
618
|
+
useEffect(() => {
|
|
619
|
+
if (!selectedComponentId) return
|
|
620
|
+
setExpanded(new Set([selectedComponentId]))
|
|
621
|
+
}, [selectedComponentId])
|
|
622
|
+
|
|
623
|
+
// Keep the active nav row on screen. A deep link can land straight on a
|
|
624
|
+
// component far down the rail — taller than the viewport — leaving its
|
|
625
|
+
// highlighted row scrolled out of view. Re-run on the selection (the row's
|
|
626
|
+
// `data-current` follows `sel`), on `expanded` (a case row only mounts once
|
|
627
|
+
// its component is open, so the first pass may not find it yet), and on
|
|
628
|
+
// `shownMode` (the library nav isn't mounted in Primer view). The first
|
|
629
|
+
// reveal centers the row so it clears the rail's edge fade mask; afterwards
|
|
630
|
+
// `block: 'nearest'` keeps an off-screen selection in view without yanking
|
|
631
|
+
// rows that are already visible, so an ordinary click never jumps the rail.
|
|
632
|
+
const didInitialNavScrollRef = useRef(false)
|
|
633
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `expanded` isn't read here but a case row only mounts once its component is expanded, so re-run to find it
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
if (shownMode !== 'library') return
|
|
636
|
+
const container = navScrollRef.current
|
|
637
|
+
if (!container || !sel?.componentId) return
|
|
638
|
+
const id = sel.caseId
|
|
639
|
+
? DcTestIds.navCase(sel.componentId, sel.caseId)
|
|
640
|
+
: DcTestIds.navComponent(sel.componentId)
|
|
641
|
+
const raf = requestAnimationFrame(() => {
|
|
642
|
+
const el = container.querySelector<HTMLElement>(`[data-testid="${id}"]`)
|
|
643
|
+
if (!el) return
|
|
644
|
+
el.scrollIntoView({
|
|
645
|
+
block: didInitialNavScrollRef.current ? 'nearest' : 'center',
|
|
646
|
+
})
|
|
647
|
+
didInitialNavScrollRef.current = true
|
|
648
|
+
})
|
|
649
|
+
return () => cancelAnimationFrame(raf)
|
|
650
|
+
}, [sel?.componentId, sel?.caseId, expanded, shownMode])
|
|
651
|
+
|
|
652
|
+
// Drop the previous exhibit's measured size when the *shown* case changes, so
|
|
653
|
+
// the new one is sized from its own report. Keyed on `shownSel` (not `sel`) so
|
|
654
|
+
// it fires at the swap point — while the stage is hidden — not when the user
|
|
655
|
+
// first clicks.
|
|
656
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: reset keyed on shown selection
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
setContent(null)
|
|
659
|
+
}, [shownSel?.componentId, shownSel?.caseId])
|
|
660
|
+
|
|
661
|
+
// Crossfade controller. When the *exhibit* (component or case) changes, fade
|
|
662
|
+
// the stage out, then — once it's hidden — swap `shownSel` to the new
|
|
663
|
+
// selection so the iframe content, size, and mode all change behind the fade.
|
|
664
|
+
// The reveal effect (below) fades it back in once measured.
|
|
665
|
+
//
|
|
666
|
+
// A tweak-only change keeps the same exhibit: the frame retweaks in place via
|
|
667
|
+
// postMessage (see the push effect below), so we swap `shownSel` immediately
|
|
668
|
+
// with no fade — adjusting a knob shouldn't blink the stage.
|
|
669
|
+
useEffect(() => {
|
|
670
|
+
const next = selRef.current
|
|
671
|
+
const shown = shownSelRef.current
|
|
672
|
+
if (!next || !shown) return
|
|
673
|
+
// Already in lock-step (initial mount, a flow's in-place goto, or a theme
|
|
674
|
+
// toggle that left the selection untouched): nothing to do.
|
|
675
|
+
if (selSignature(shown) === selSig) return
|
|
676
|
+
const sameExhibit =
|
|
677
|
+
shown.componentId === next.componentId && shown.caseId === next.caseId
|
|
678
|
+
if (sameExhibit) {
|
|
679
|
+
// Tweak-only change: swap in place, no crossfade.
|
|
680
|
+
setShownSel(next)
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
setStageShown(false) // fade out; `shownSel` holds so the box keeps its size
|
|
684
|
+
setColorSnap(true) // snap the a11y colour while faded — see the fade-in effect
|
|
685
|
+
const id = setTimeout(() => setShownSel(next), STAGE_FADE_MS)
|
|
686
|
+
return () => clearTimeout(id)
|
|
687
|
+
}, [selSig])
|
|
688
|
+
|
|
689
|
+
const groups = useMemo(
|
|
690
|
+
() => groupByLevel(manifest?.components ?? []),
|
|
691
|
+
[manifest],
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
const primerGroups = useMemo(
|
|
695
|
+
() => groupPrimerSections(primerSections),
|
|
696
|
+
[primerSections],
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
// Keep the TOC accordion in step with the reading position. Whenever the
|
|
700
|
+
// active section changes — the first section reports active on load, then the
|
|
701
|
+
// scrollspy tracks it as the reader scrolls — open the group that owns it
|
|
702
|
+
// (heading or one of its Displays) and collapse the rest. A section in the
|
|
703
|
+
// leading headless group collapses every heading group. The functional update
|
|
704
|
+
// is a no-op while the active section stays within the open group, so a manual
|
|
705
|
+
// chevron toggle survives until the reader scrolls into a different group.
|
|
706
|
+
useEffect(() => {
|
|
707
|
+
if (!primerActive) return
|
|
708
|
+
const owner = primerGroups.find(
|
|
709
|
+
(g) =>
|
|
710
|
+
g.heading?.id === primerActive ||
|
|
711
|
+
g.items.some((it) => it.id === primerActive),
|
|
712
|
+
)
|
|
713
|
+
const headingId = owner?.heading?.id
|
|
714
|
+
setPrimerExpanded((prev) => {
|
|
715
|
+
const already = headingId
|
|
716
|
+
? prev.size === 1 && prev.has(headingId)
|
|
717
|
+
: prev.size === 0
|
|
718
|
+
if (already) return prev
|
|
719
|
+
return headingId ? new Set([headingId]) : new Set()
|
|
720
|
+
})
|
|
721
|
+
}, [primerActive, primerGroups])
|
|
722
|
+
|
|
723
|
+
// The stage renders the *shown* selection (which trails `sel` across a fade),
|
|
724
|
+
// not `sel` itself — so the exhibit, its size, and its mode change together
|
|
725
|
+
// while hidden.
|
|
726
|
+
const component =
|
|
727
|
+
manifest?.components.find((c) => c.id === shownSel?.componentId) ?? null
|
|
728
|
+
const activeCase =
|
|
729
|
+
component?.cases.find((c) => c.id === shownSel?.caseId) ?? null
|
|
730
|
+
|
|
731
|
+
// The exhibit's shareable /render address (origin-prefixed). Computed here so
|
|
732
|
+
// the pure view doesn't reach for `window.location`. `origin` starts empty
|
|
733
|
+
// (matching the server render) and is filled in after hydration, so the copied
|
|
734
|
+
// address becomes absolute without a hydration mismatch.
|
|
735
|
+
const addressUrl = activeCase
|
|
736
|
+
? buildAddressUrl(
|
|
737
|
+
activeCase.renderUrl,
|
|
738
|
+
theme,
|
|
739
|
+
shownSel?.tweaks ?? {},
|
|
740
|
+
origin,
|
|
741
|
+
)
|
|
742
|
+
: ''
|
|
743
|
+
|
|
744
|
+
// Full pages and flows are exhibited on a clean, framed stage; smaller
|
|
745
|
+
// component levels (atoms…templates, and unclassified) keep the vitrine
|
|
746
|
+
// dotted grid + corner ticks that help judge a component's edges.
|
|
747
|
+
const stageDecor = component?.level !== 'page' && !component?.isFlow
|
|
748
|
+
|
|
749
|
+
// Callback ref: (re)attach a ResizeObserver to the preview panel to track the
|
|
750
|
+
// space available for fit-to-panel scaling.
|
|
751
|
+
const attachPreview = useCallback((el: HTMLDivElement | null) => {
|
|
752
|
+
previewObserver.current?.disconnect()
|
|
753
|
+
if (!el) return
|
|
754
|
+
// Measure the *content* box (clientWidth/Height include padding); the frame
|
|
755
|
+
// must fit inside that, or it overflows by the padding and shows a scrollbar.
|
|
756
|
+
const measure = () => {
|
|
757
|
+
const cs = getComputedStyle(el)
|
|
758
|
+
const padX = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight)
|
|
759
|
+
const padY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom)
|
|
760
|
+
// The Stage's caption strip sits inside the preview but above the body the
|
|
761
|
+
// frame-box lives in, so it steals vertical room the fit math must not
|
|
762
|
+
// count. Without reserving it, a fitted size (device W×H, or a responsive
|
|
763
|
+
// width whose content fills the height) scales to the full preview height,
|
|
764
|
+
// then the caption pushes the stage past the bottom — and `.dc-preview`'s
|
|
765
|
+
// `align-items: safe center` snaps the overflow to the top, jamming the
|
|
766
|
+
// exhibit above the corner ticks. Subtract its measured height (0 before it
|
|
767
|
+
// mounts) so the panel height reflects the space the body actually gets.
|
|
768
|
+
const caption = el.querySelector('.dcui-stage-caption')
|
|
769
|
+
const captionH = caption ? caption.getBoundingClientRect().height : 0
|
|
770
|
+
setPanel({
|
|
771
|
+
w: Math.max(0, Math.floor(el.clientWidth - padX)),
|
|
772
|
+
h: Math.max(0, Math.floor(el.clientHeight - padY - captionH)),
|
|
773
|
+
})
|
|
774
|
+
}
|
|
775
|
+
measure()
|
|
776
|
+
// Observe the preview for available-space changes, and the caption too so a
|
|
777
|
+
// font swap or wrap that changes its height re-runs the fit.
|
|
778
|
+
const ro = new ResizeObserver(measure)
|
|
779
|
+
ro.observe(el)
|
|
780
|
+
const caption = el.querySelector('.dcui-stage-caption')
|
|
781
|
+
if (caption) ro.observe(caption)
|
|
782
|
+
previewObserver.current = ro
|
|
783
|
+
}, [])
|
|
784
|
+
|
|
785
|
+
// Resolve the active sizing mode into concrete iframe dimensions + scale.
|
|
786
|
+
const responsive = RESPONSIVE.find((r) => r.id === sizeId)
|
|
787
|
+
const device = DEVICES.find((d) => d.id === sizeId)
|
|
788
|
+
let fixed: { w: number; h: number } | null = null
|
|
789
|
+
if (sizeId === 'custom') fixed = custom
|
|
790
|
+
else if (device) fixed = { w: device.w, h: device.h }
|
|
791
|
+
|
|
792
|
+
const responsiveWidth =
|
|
793
|
+
responsive && responsive.width !== 'full' ? responsive.width : null
|
|
794
|
+
// A "fitted" mode has fixed pixel dimensions that must auto-scale to stay
|
|
795
|
+
// fully on-screen: device presets (W×H — both axes) and the numbered
|
|
796
|
+
// responsive widths (Desktop/Tablet/Mobile — width only). Only "Responsive
|
|
797
|
+
// (full)" zooms manually.
|
|
798
|
+
const fitted = fixed !== null || responsiveWidth !== null
|
|
799
|
+
|
|
800
|
+
// In the default Responsive (full) view a decorated component shrink-wraps to
|
|
801
|
+
// its natural width — the render frame is told to `fit` — so a small component
|
|
802
|
+
// (a square button, a chip) doesn't stretch to fill the frame. Picking a
|
|
803
|
+
// preset/device width opts back into full-width layout for responsive testing.
|
|
804
|
+
const fitWidth = stageDecor && !fixed && responsiveWidth === null
|
|
805
|
+
|
|
806
|
+
// Fade the stage in once the shown exhibit has caught up to the selection and
|
|
807
|
+
// the render frame has reported *its* size (matched by signature, so an
|
|
808
|
+
// in-flight size report for the previous exhibit can't reveal early). This
|
|
809
|
+
// gate is what keeps a swap from flashing at the wrong size.
|
|
810
|
+
const stageMeasured =
|
|
811
|
+
shownSel != null && measuredSig === selSignature(shownSel)
|
|
812
|
+
useEffect(() => {
|
|
813
|
+
if (!shownSel) return
|
|
814
|
+
if (selSignature(shownSel) !== selSig) return // still mid-swap
|
|
815
|
+
if (stageMeasured) setStageShown(true)
|
|
816
|
+
}, [shownSel, selSig, stageMeasured])
|
|
817
|
+
|
|
818
|
+
// Release the a11y colour snap once the fade-in has finished, so in-place state
|
|
819
|
+
// changes (a scan resolving while the panel is visible) ease again. Keyed on
|
|
820
|
+
// the fade-in starting (stageShown → true); a new navigation re-arms the snap.
|
|
821
|
+
useEffect(() => {
|
|
822
|
+
if (!stageShown) return
|
|
823
|
+
const id = setTimeout(() => setColorSnap(false), STAGE_FADE_MS)
|
|
824
|
+
return () => clearTimeout(id)
|
|
825
|
+
}, [stageShown])
|
|
826
|
+
|
|
827
|
+
// Area available for the (scaled) component. A decorated component reserves at
|
|
828
|
+
// least a 1-dot margin (+1px border) on each side so a near-max component can
|
|
829
|
+
// give back padding for width; a page/flow uses the whole panel (edge-to-edge).
|
|
830
|
+
const reserve = stageDecor ? MIN_PAD + 1 : 0
|
|
831
|
+
const availW = Math.max(1, panel.w - 2 * reserve)
|
|
832
|
+
const availH = Math.max(1, panel.h - 2 * reserve)
|
|
833
|
+
|
|
834
|
+
// `renderH` is the iframe element's height — the viewport the component lays
|
|
835
|
+
// out against (kept = panel height in Responsive mode so `vh`/media queries
|
|
836
|
+
// stay stable and never feed back as the visible box shrinks). `visibleH` is
|
|
837
|
+
// how much of it the stage actually shows: a decorated component (atom…
|
|
838
|
+
// template) is clipped to its own measured height and centered on the grid,
|
|
839
|
+
// while pages/flows — and anything not yet measured — fill the panel.
|
|
840
|
+
let targetW: number
|
|
841
|
+
let renderH: number
|
|
842
|
+
let visibleH: number
|
|
843
|
+
if (fixed) {
|
|
844
|
+
targetW = fixed.w
|
|
845
|
+
renderH = fixed.h
|
|
846
|
+
visibleH = fixed.h
|
|
847
|
+
} else if (stageDecor) {
|
|
848
|
+
// Decorated: render at the preset width (or the available width), and clip
|
|
849
|
+
// to the component's own height so the frame hugs it (centered on the grid).
|
|
850
|
+
targetW = responsiveWidth ?? availW
|
|
851
|
+
renderH = panel.h
|
|
852
|
+
// Until the new case reports its size, collapse to nothing so the vitrine
|
|
853
|
+
// rests at its CSS min size (centered) rather than ballooning to fill the
|
|
854
|
+
// panel — a full-height box overflows by the caption strip and `safe center`
|
|
855
|
+
// snaps it to the top-left for a frame. It grows once `dc-size` lands.
|
|
856
|
+
visibleH = content && content.h > 0 ? Math.min(content.h, availH) : 0
|
|
857
|
+
} else {
|
|
858
|
+
// Page/flow: fill the frame edge-to-edge.
|
|
859
|
+
targetW = responsiveWidth ?? panel.w
|
|
860
|
+
renderH = panel.h
|
|
861
|
+
visibleH = panel.h
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Scale a fitted mode down to fit the available area (never up past 100%): a
|
|
865
|
+
// device fits both axes; a numbered responsive width fits horizontally (its
|
|
866
|
+
// height fills/conforms). Full responsive uses the manual zoom.
|
|
867
|
+
let scale = manualZoom
|
|
868
|
+
if (fixed) {
|
|
869
|
+
scale =
|
|
870
|
+
panel.w > 0 && panel.h > 0
|
|
871
|
+
? Math.min(availW / fixed.w, availH / fixed.h, 1)
|
|
872
|
+
: 1
|
|
873
|
+
} else if (responsiveWidth !== null) {
|
|
874
|
+
scale = panel.w > 0 ? Math.min(availW / responsiveWidth, 1) : 1
|
|
875
|
+
}
|
|
876
|
+
// When fitting width, the iframe still renders at `targetW` (a stable viewport
|
|
877
|
+
// so `vw`/media queries don't shift), but the box clips to the component's own
|
|
878
|
+
// measured width so the frame hugs it horizontally — symmetric with `visibleH`.
|
|
879
|
+
let visibleW = targetW
|
|
880
|
+
if (fitWidth) {
|
|
881
|
+
// Measured: hug the content's width. Unmeasured: collapse to the vitrine's
|
|
882
|
+
// min width (centered) rather than full width — symmetric with `visibleH`.
|
|
883
|
+
visibleW = content && content.w > 0 ? Math.min(content.w, targetW) : 0
|
|
884
|
+
}
|
|
885
|
+
// Value for the width input: a fixed width, the responsive preset width, or
|
|
886
|
+
// blank (Full / auto).
|
|
887
|
+
const widthInputValue = fixed ? fixed.w : (responsiveWidth ?? '')
|
|
888
|
+
// The currently set size, for the stage caption meta: a named responsive
|
|
889
|
+
// preset (Responsive / Desktop / Tablet / Mobile), else a fixed W×H
|
|
890
|
+
// (device or custom).
|
|
891
|
+
let sizeMeta = fixed ? `${fixed.w} × ${fixed.h}` : 'Responsive'
|
|
892
|
+
if (responsive) sizeMeta = responsive.label
|
|
893
|
+
// The frame box occupies the *scaled* size so the panel can center it and
|
|
894
|
+
// scroll to every edge (no left-side cutoff when zoomed in). Ceil (not floor)
|
|
895
|
+
// so a fractional scaled dimension never crops the component's right/bottom
|
|
896
|
+
// edge — flooring shaved a sub-pixel row/column, cutting the last border.
|
|
897
|
+
//
|
|
898
|
+
// A decorated exhibit hugs its own measured size, so when the rightmost (or
|
|
899
|
+
// bottom) element's border sits *exactly* on the measured edge — e.g. a
|
|
900
|
+
// full-width `<input>` whose box-sizing border lands flush at `fit-content` —
|
|
901
|
+
// the clip seam coincides with that border and `overflow: hidden` eats it
|
|
902
|
+
// (ceil can't help: the value is already integral). A 1px guard on the hugged
|
|
903
|
+
// axes keeps the seam off the border without a visible gap (it falls inside
|
|
904
|
+
// the grid margin). Skipped for pages/flows, which fill the panel edge-to-edge
|
|
905
|
+
// and would overflow it by the guard.
|
|
906
|
+
const edgeGuard = stageDecor ? 1 : 0
|
|
907
|
+
const boxW = Math.max(1, Math.ceil(visibleW * scale) + edgeGuard)
|
|
908
|
+
// The box clips the iframe (which is `renderH` tall) to the *visible* height,
|
|
909
|
+
// hiding everything below the component so the grid shows through. Ceil (not
|
|
910
|
+
// floor) so a fractional scaled height never crops the component's bottom edge
|
|
911
|
+
// — flooring it shaved a sub-pixel row, cutting the last border.
|
|
912
|
+
const boxH = Math.max(1, Math.ceil(visibleH * scale) + edgeGuard)
|
|
913
|
+
// Grid margin around the exhibit, scaled to the spare room (1–3 dots) on each
|
|
914
|
+
// axis. Driven inline since it's dynamic; only decorated stages get it.
|
|
915
|
+
const padX = stageDecor ? gridPad(panel.w, boxW) : 0
|
|
916
|
+
const padY = stageDecor ? gridPad(panel.h, boxH) : 0
|
|
917
|
+
|
|
918
|
+
// Editing a dimension or rotating switches to a custom fixed size, seeded from
|
|
919
|
+
// the current dimensions so the untouched axis is preserved.
|
|
920
|
+
const editDim = (axis: 'w' | 'h', raw: string) => {
|
|
921
|
+
const value = Math.max(1, Math.round(Number(raw) || 0))
|
|
922
|
+
const base = fixed ?? { w: targetW || 1280, h: renderH || 800 }
|
|
923
|
+
setCustom({ ...base, [axis]: value })
|
|
924
|
+
setSizeId('custom')
|
|
925
|
+
}
|
|
926
|
+
const rotateDims = () => {
|
|
927
|
+
if (!fixed) return
|
|
928
|
+
setCustom({ w: fixed.h, h: fixed.w })
|
|
929
|
+
setSizeId('custom')
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Drag the doc panel's left edge to resize it. Dragging left (toward the
|
|
933
|
+
// content) widens it; the width is clamped and applied via a CSS variable so
|
|
934
|
+
// the narrow-screen stacking rule still wins.
|
|
935
|
+
const startDocResize = useCallback(
|
|
936
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
937
|
+
e.preventDefault()
|
|
938
|
+
const startX = e.clientX
|
|
939
|
+
const startW = docWidth
|
|
940
|
+
const onMove = (ev: PointerEvent) => {
|
|
941
|
+
const next = startW + (startX - ev.clientX)
|
|
942
|
+
setDocWidth(Math.max(DOC_MIN_W, Math.min(DOC_MAX_W, next)))
|
|
943
|
+
}
|
|
944
|
+
const onUp = () => {
|
|
945
|
+
window.removeEventListener('pointermove', onMove)
|
|
946
|
+
window.removeEventListener('pointerup', onUp)
|
|
947
|
+
document.body.style.cursor = ''
|
|
948
|
+
document.body.style.userSelect = ''
|
|
949
|
+
}
|
|
950
|
+
document.body.style.cursor = 'col-resize'
|
|
951
|
+
document.body.style.userSelect = 'none'
|
|
952
|
+
window.addEventListener('pointermove', onMove)
|
|
953
|
+
window.addEventListener('pointerup', onUp)
|
|
954
|
+
},
|
|
955
|
+
[docWidth],
|
|
956
|
+
)
|
|
957
|
+
// Keyboard resize: arrows nudge by one grid step (left widens, like the drag).
|
|
958
|
+
const onDocResizeKey = useCallback(
|
|
959
|
+
(e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
960
|
+
let step = 0
|
|
961
|
+
if (e.key === 'ArrowLeft') step = GRID
|
|
962
|
+
else if (e.key === 'ArrowRight') step = -GRID
|
|
963
|
+
if (!step) return
|
|
964
|
+
e.preventDefault()
|
|
965
|
+
setDocWidth((w) => Math.max(DOC_MIN_W, Math.min(DOC_MAX_W, w + step)))
|
|
966
|
+
},
|
|
967
|
+
[],
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
// Drive the token theme from the document root so html/body (not just the
|
|
971
|
+
// app chrome) pick up the themed background — no white bars around the app.
|
|
972
|
+
useEffect(() => {
|
|
973
|
+
document.documentElement.dataset.theme = theme
|
|
974
|
+
}, [theme])
|
|
975
|
+
|
|
976
|
+
// Capture a fixed initial src once a case is available; never change it after.
|
|
977
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: initial-only (frameSrc gate); fit is the initial mode's value
|
|
978
|
+
useEffect(() => {
|
|
979
|
+
if (frameSrc || !activeCase) return
|
|
980
|
+
setFrameSrc(
|
|
981
|
+
buildRenderSrc(
|
|
982
|
+
activeCase.renderUrl,
|
|
983
|
+
theme,
|
|
984
|
+
shownSel?.tweaks ?? {},
|
|
985
|
+
fitWidth,
|
|
986
|
+
stageDecor,
|
|
987
|
+
),
|
|
988
|
+
)
|
|
989
|
+
}, [frameSrc, activeCase, theme, shownSel?.tweaks])
|
|
990
|
+
|
|
991
|
+
// Listen for the render frame's readiness handshake and in-flow transitions.
|
|
992
|
+
useEffect(() => {
|
|
993
|
+
function onMessage(e: MessageEvent) {
|
|
994
|
+
if (e.source !== frameRef.current?.contentWindow) return
|
|
995
|
+
const data = e.data as {
|
|
996
|
+
type?: string
|
|
997
|
+
state?: {
|
|
998
|
+
componentId: string
|
|
999
|
+
caseId: string
|
|
1000
|
+
tweaks: Record<string, string>
|
|
1001
|
+
}
|
|
1002
|
+
size?: { width: number; height: number }
|
|
1003
|
+
}
|
|
1004
|
+
if (data?.type === 'dc-ready') {
|
|
1005
|
+
setFrameReady(true)
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
// The render frame reported its content's natural size — used to shrink
|
|
1009
|
+
// the iframe to the component in Responsive mode. Tag the measurement with
|
|
1010
|
+
// the exhibit it belongs to (what the iframe is currently showing) so the
|
|
1011
|
+
// reveal gate only trusts a size that matches the shown selection.
|
|
1012
|
+
if (data?.type === 'dc-size' && data.size) {
|
|
1013
|
+
setContent({ w: data.size.width, h: data.size.height })
|
|
1014
|
+
if (shownSelRef.current) {
|
|
1015
|
+
setMeasuredSig(selSignature(shownSelRef.current))
|
|
1016
|
+
}
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
// A flow step advanced itself via `goto` — follow it so the sidebar's
|
|
1020
|
+
// active step and the address stay in sync with the preview. The iframe
|
|
1021
|
+
// already transitioned in place, so move `shownSel` in lock-step (no
|
|
1022
|
+
// crossfade — the controller sees the stage already matches `sel`).
|
|
1023
|
+
if (data?.type === 'dc-step-changed' && data.state) {
|
|
1024
|
+
const next = {
|
|
1025
|
+
componentId: data.state.componentId,
|
|
1026
|
+
caseId: data.state.caseId,
|
|
1027
|
+
tweaks: data.state.tweaks ?? {},
|
|
1028
|
+
}
|
|
1029
|
+
select(next)
|
|
1030
|
+
setShownSel(next)
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
window.addEventListener('message', onMessage)
|
|
1034
|
+
return () => window.removeEventListener('message', onMessage)
|
|
1035
|
+
}, [select])
|
|
1036
|
+
|
|
1037
|
+
// Push the current selection into the frame in place (no reload).
|
|
1038
|
+
useEffect(() => {
|
|
1039
|
+
if (!frameReady || !component || !activeCase) return
|
|
1040
|
+
frameRef.current?.contentWindow?.postMessage(
|
|
1041
|
+
{
|
|
1042
|
+
type: 'dc-render',
|
|
1043
|
+
state: {
|
|
1044
|
+
componentId: component.id,
|
|
1045
|
+
caseId: activeCase.id,
|
|
1046
|
+
theme,
|
|
1047
|
+
width: null,
|
|
1048
|
+
tweaks: shownSel?.tweaks ?? {},
|
|
1049
|
+
fit: fitWidth,
|
|
1050
|
+
transparent: stageDecor,
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
'*',
|
|
1054
|
+
)
|
|
1055
|
+
}, [
|
|
1056
|
+
frameReady,
|
|
1057
|
+
component,
|
|
1058
|
+
activeCase,
|
|
1059
|
+
theme,
|
|
1060
|
+
shownSel?.tweaks,
|
|
1061
|
+
fitWidth,
|
|
1062
|
+
stageDecor,
|
|
1063
|
+
])
|
|
1064
|
+
|
|
1065
|
+
// Load the doc when the active component changes.
|
|
1066
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by component id
|
|
1067
|
+
useEffect(() => {
|
|
1068
|
+
setDocText(null)
|
|
1069
|
+
if (!component?.placardDoc) return
|
|
1070
|
+
fetch(`/doc/${component.id}`)
|
|
1071
|
+
.then((r) => (r.ok ? r.text() : null))
|
|
1072
|
+
.then(setDocText)
|
|
1073
|
+
}, [component?.id])
|
|
1074
|
+
|
|
1075
|
+
// Request the viewed variant's a11y result whenever the selection or theme
|
|
1076
|
+
// changes (per-theme, since contrast differs by theme). The result arrives
|
|
1077
|
+
// here (cached) or later over the SSE stream.
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (!a11yEnabled || !component || !activeCase) return
|
|
1080
|
+
requestA11y(component.id, activeCase.id, theme)
|
|
1081
|
+
}, [a11yEnabled, component, activeCase, theme, requestA11y])
|
|
1082
|
+
|
|
1083
|
+
// One SSE subscription drives live a11y pushes and (in non-dev) the rebuild
|
|
1084
|
+
// refresh. The render iframe reloads itself via its own document script; here
|
|
1085
|
+
// we refetch the manifest (so added/removed cases appear) and re-request the
|
|
1086
|
+
// current variant's a11y (its cache may have been invalidated by the edit).
|
|
1087
|
+
// In `--dev` the shell does a full reload instead (chrome may have changed).
|
|
1088
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: subscribe once for the session
|
|
1089
|
+
useEffect(() => {
|
|
1090
|
+
if (!a11yEnabled && !dcConfig?.reload) return
|
|
1091
|
+
const es = new EventSource('/__livereload')
|
|
1092
|
+
if (a11yEnabled) {
|
|
1093
|
+
es.addEventListener('a11y', (e) => {
|
|
1094
|
+
try {
|
|
1095
|
+
const d = JSON.parse((e as MessageEvent).data)
|
|
1096
|
+
// From the live scan completing → cascade the reveal.
|
|
1097
|
+
applyA11yResult(d.component, d.case, d.theme, d, true)
|
|
1098
|
+
} catch {
|
|
1099
|
+
// ignore malformed event
|
|
1100
|
+
}
|
|
1101
|
+
})
|
|
1102
|
+
}
|
|
1103
|
+
if (dcConfig?.reload && !dcConfig?.dev) {
|
|
1104
|
+
es.addEventListener('reload', (e) => {
|
|
1105
|
+
// A shell-bundle change (the chrome itself) needs a full reload — the
|
|
1106
|
+
// injected styles + chrome code only re-run on a fresh document. A
|
|
1107
|
+
// content-only change refreshes the manifest + re-requests a11y while the
|
|
1108
|
+
// stage iframe reloads itself, preserving nav state.
|
|
1109
|
+
if ((e as MessageEvent).data === 'shell') {
|
|
1110
|
+
location.reload()
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
fetch('/manifest.json')
|
|
1114
|
+
.then((r) => r.json())
|
|
1115
|
+
.then((m: Manifest) => setManifest(m))
|
|
1116
|
+
.catch(() => {})
|
|
1117
|
+
const cur = a11yCurRef.current
|
|
1118
|
+
if (a11yEnabled && cur.c && cur.cs && cur.th) {
|
|
1119
|
+
requestA11y(cur.c, cur.cs, cur.th)
|
|
1120
|
+
}
|
|
1121
|
+
})
|
|
1122
|
+
}
|
|
1123
|
+
return () => es.close()
|
|
1124
|
+
}, [])
|
|
1125
|
+
|
|
1126
|
+
// Seed the nav markers from every verdict the server already knows at connect
|
|
1127
|
+
// time — start-up population (the `cached`/`refresh` modes) and any scan that
|
|
1128
|
+
// completed before this tab opened. SSE only carries events emitted *after* we
|
|
1129
|
+
// subscribe, so without this one-shot replay a freshly opened tab would show
|
|
1130
|
+
// an empty nav until each variant is viewed. Results land via `applyA11yResult`
|
|
1131
|
+
// with `fromScan: false`, so only the markers fill in — the panel (keyed to
|
|
1132
|
+
// the viewed variant) is untouched.
|
|
1133
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: seed once on mount
|
|
1134
|
+
useEffect(() => {
|
|
1135
|
+
if (!a11yEnabled) return
|
|
1136
|
+
fetch('/a11y/known')
|
|
1137
|
+
.then((r) => r.json())
|
|
1138
|
+
.then(
|
|
1139
|
+
(
|
|
1140
|
+
rows: Array<{
|
|
1141
|
+
component: string
|
|
1142
|
+
case: string
|
|
1143
|
+
theme: string
|
|
1144
|
+
status: 'ok' | 'pending' | 'unavailable'
|
|
1145
|
+
violations?: A11yViolation[]
|
|
1146
|
+
reason?: string
|
|
1147
|
+
}>,
|
|
1148
|
+
) => {
|
|
1149
|
+
for (const d of rows)
|
|
1150
|
+
applyA11yResult(d.component, d.case, d.theme, d, false)
|
|
1151
|
+
},
|
|
1152
|
+
)
|
|
1153
|
+
.catch(() => {})
|
|
1154
|
+
}, [])
|
|
1155
|
+
|
|
1156
|
+
if (!manifest) return { manifest: null }
|
|
1157
|
+
|
|
1158
|
+
// Shared opacity crossfade for every region that swaps on a mode change (nav
|
|
1159
|
+
// body, screen content, mode-specific header controls). They fade as one.
|
|
1160
|
+
const modeFadeStyle: CSSProperties = {
|
|
1161
|
+
opacity: modeContentShown ? 1 : 0,
|
|
1162
|
+
transition: `opacity ${MODE_FADE_MS}ms var(--dc-ease)`,
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return {
|
|
1166
|
+
manifest,
|
|
1167
|
+
theme,
|
|
1168
|
+
setTheme,
|
|
1169
|
+
navCollapsed,
|
|
1170
|
+
setNavCollapsed,
|
|
1171
|
+
mode,
|
|
1172
|
+
setMode: changeMode,
|
|
1173
|
+
shownMode,
|
|
1174
|
+
modeFadeStyle,
|
|
1175
|
+
sizeId,
|
|
1176
|
+
setSizeId,
|
|
1177
|
+
manualZoom,
|
|
1178
|
+
setManualZoom,
|
|
1179
|
+
showGrid,
|
|
1180
|
+
setShowGrid,
|
|
1181
|
+
widthInputValue,
|
|
1182
|
+
fixed,
|
|
1183
|
+
fitted,
|
|
1184
|
+
scale,
|
|
1185
|
+
sizeMeta,
|
|
1186
|
+
editDim,
|
|
1187
|
+
rotateDims,
|
|
1188
|
+
stageDecor,
|
|
1189
|
+
component,
|
|
1190
|
+
activeCase,
|
|
1191
|
+
addressUrl,
|
|
1192
|
+
shownSel,
|
|
1193
|
+
sel,
|
|
1194
|
+
docOpen,
|
|
1195
|
+
changeDocsOpen,
|
|
1196
|
+
docText,
|
|
1197
|
+
docWidth,
|
|
1198
|
+
startDocResize,
|
|
1199
|
+
onDocResizeKey,
|
|
1200
|
+
tweaksFloating,
|
|
1201
|
+
setTweaksFloating,
|
|
1202
|
+
a11y,
|
|
1203
|
+
rescanA11y,
|
|
1204
|
+
navScrollRef,
|
|
1205
|
+
navBodyRef,
|
|
1206
|
+
groups,
|
|
1207
|
+
expanded,
|
|
1208
|
+
toggleExpanded,
|
|
1209
|
+
selectComponent,
|
|
1210
|
+
select,
|
|
1211
|
+
primerGroups,
|
|
1212
|
+
primerActive,
|
|
1213
|
+
primerExpanded,
|
|
1214
|
+
togglePrimerGroup,
|
|
1215
|
+
scrollToSection,
|
|
1216
|
+
attachPreview,
|
|
1217
|
+
padX,
|
|
1218
|
+
padY,
|
|
1219
|
+
stageShown,
|
|
1220
|
+
colorSnap,
|
|
1221
|
+
boxW,
|
|
1222
|
+
boxH,
|
|
1223
|
+
frameRef,
|
|
1224
|
+
frameSrc,
|
|
1225
|
+
targetW,
|
|
1226
|
+
renderH,
|
|
1227
|
+
primerRef,
|
|
1228
|
+
primerSrc,
|
|
1229
|
+
}
|
|
1230
|
+
}
|