@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,340 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { Manifest, ManifestComponent } from '../core/manifest'
|
|
3
|
+
import {
|
|
4
|
+
buildAddressUrl,
|
|
5
|
+
buildRenderSrc,
|
|
6
|
+
buildUrl,
|
|
7
|
+
gridPad,
|
|
8
|
+
groupByLevel,
|
|
9
|
+
groupPrimerSections,
|
|
10
|
+
initialSelectionFor,
|
|
11
|
+
MAX_PAD,
|
|
12
|
+
MIN_PAD,
|
|
13
|
+
type PrimerSection,
|
|
14
|
+
parseRoute,
|
|
15
|
+
primerForLocation,
|
|
16
|
+
resolveMode,
|
|
17
|
+
type Selection,
|
|
18
|
+
selSignature,
|
|
19
|
+
} from './shell-core'
|
|
20
|
+
|
|
21
|
+
// `primerForLocation` reads `window.location.pathname`; stub a minimal window
|
|
22
|
+
// for the duration of each case and restore it afterwards.
|
|
23
|
+
const realWindow = (globalThis as { window?: unknown }).window
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
;(globalThis as { window?: unknown }).window = realWindow
|
|
26
|
+
})
|
|
27
|
+
function atPath(pathname: string): void {
|
|
28
|
+
;(globalThis as { window?: unknown }).window = { location: { pathname } }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function manifest(over: Partial<Manifest>): Manifest {
|
|
32
|
+
return {
|
|
33
|
+
title: 'T',
|
|
34
|
+
components: [],
|
|
35
|
+
primer: true,
|
|
36
|
+
landing: 'primer',
|
|
37
|
+
...over,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('primerForLocation', () => {
|
|
42
|
+
test('the /primer route is the Primer whenever one is configured', () => {
|
|
43
|
+
atPath('/primer')
|
|
44
|
+
// Even when the landing default was overridden to the library, an explicit
|
|
45
|
+
// /primer link still resolves to the Primer.
|
|
46
|
+
expect(primerForLocation(manifest({ landing: 'library' }))).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('the /primer route is the library when no Primer is configured', () => {
|
|
50
|
+
atPath('/primer')
|
|
51
|
+
expect(
|
|
52
|
+
primerForLocation(manifest({ primer: false, landing: 'library' })),
|
|
53
|
+
).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('the bare / landing honors the resolved landing', () => {
|
|
57
|
+
atPath('/')
|
|
58
|
+
expect(primerForLocation(manifest({ landing: 'primer' }))).toBe(true)
|
|
59
|
+
expect(primerForLocation(manifest({ landing: 'library' }))).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('a /c/... deep link is always a library address', () => {
|
|
63
|
+
atPath('/c/button/default')
|
|
64
|
+
expect(primerForLocation(manifest({ landing: 'primer' }))).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
function comp(over: Partial<ManifestComponent>): ManifestComponent {
|
|
69
|
+
return {
|
|
70
|
+
id: 'button',
|
|
71
|
+
name: 'Button',
|
|
72
|
+
level: 'atom',
|
|
73
|
+
isFlow: false,
|
|
74
|
+
caseFile: 'src/Button.case.tsx',
|
|
75
|
+
placardDoc: null,
|
|
76
|
+
cases: [],
|
|
77
|
+
...over,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe('parseRoute', () => {
|
|
82
|
+
test('extracts the component and case ids from a /c/ path', () => {
|
|
83
|
+
const r = parseRoute('/c/button/default', '')
|
|
84
|
+
expect(r.componentId).toBe('button')
|
|
85
|
+
expect(r.caseId).toBe('default')
|
|
86
|
+
expect(r.path).toBe('/c/button/default')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('collects only t.* query params into tweaks, stripping the prefix', () => {
|
|
90
|
+
const r = parseRoute('/c/button/default', '?t.size=lg&t.disabled=1&other=x')
|
|
91
|
+
expect(r.tweaks).toEqual({ size: 'lg', disabled: '1' })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('reads docs=1 as the open docs flag', () => {
|
|
95
|
+
expect(parseRoute('/c/x/y', '?docs=1').docs).toBe(true)
|
|
96
|
+
expect(parseRoute('/c/x/y', '').docs).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('yields empty ids for a path with no component segment', () => {
|
|
100
|
+
const r = parseRoute('/', '')
|
|
101
|
+
expect(r.componentId).toBe('')
|
|
102
|
+
expect(r.caseId).toBe('')
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('resolveMode', () => {
|
|
107
|
+
const route = (path: string) => parseRoute(path, '')
|
|
108
|
+
|
|
109
|
+
test('/primer resolves to the primer only when one is configured', () => {
|
|
110
|
+
expect(resolveMode(route('/primer'), manifest({ primer: true }))).toBe(
|
|
111
|
+
'primer',
|
|
112
|
+
)
|
|
113
|
+
expect(resolveMode(route('/primer'), manifest({ primer: false }))).toBe(
|
|
114
|
+
'library',
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('the bare / landing honors the resolved landing', () => {
|
|
119
|
+
expect(resolveMode(route('/'), manifest({ landing: 'primer' }))).toBe(
|
|
120
|
+
'primer',
|
|
121
|
+
)
|
|
122
|
+
expect(resolveMode(route('/'), manifest({ landing: 'library' }))).toBe(
|
|
123
|
+
'library',
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test('any deep link is a library address', () => {
|
|
128
|
+
expect(
|
|
129
|
+
resolveMode(route('/c/button/default'), manifest({ landing: 'primer' })),
|
|
130
|
+
).toBe('library')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('initialSelectionFor', () => {
|
|
135
|
+
test('uses the route selection when it names a component', () => {
|
|
136
|
+
const route = parseRoute('/c/button/primary', '?t.size=lg')
|
|
137
|
+
expect(initialSelectionFor(manifest({}), route)).toEqual({
|
|
138
|
+
componentId: 'button',
|
|
139
|
+
caseId: 'primary',
|
|
140
|
+
tweaks: { size: 'lg' },
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('falls back to the first component and its first case', () => {
|
|
145
|
+
const route = parseRoute('/', '')
|
|
146
|
+
const m = manifest({
|
|
147
|
+
components: [
|
|
148
|
+
comp({
|
|
149
|
+
id: 'card',
|
|
150
|
+
cases: [
|
|
151
|
+
{
|
|
152
|
+
id: 'a',
|
|
153
|
+
name: 'A',
|
|
154
|
+
browseUrl: '',
|
|
155
|
+
renderUrl: '',
|
|
156
|
+
tweaks: null,
|
|
157
|
+
transitions: [],
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
id: 'b',
|
|
161
|
+
name: 'B',
|
|
162
|
+
browseUrl: '',
|
|
163
|
+
renderUrl: '',
|
|
164
|
+
tweaks: null,
|
|
165
|
+
transitions: [],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
}),
|
|
169
|
+
],
|
|
170
|
+
})
|
|
171
|
+
expect(initialSelectionFor(m, route)).toEqual({
|
|
172
|
+
componentId: 'card',
|
|
173
|
+
caseId: 'a',
|
|
174
|
+
tweaks: {},
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('returns an empty selection when the manifest has no components', () => {
|
|
179
|
+
expect(
|
|
180
|
+
initialSelectionFor(manifest({ components: [] }), parseRoute('/', '')),
|
|
181
|
+
).toEqual({ componentId: '', caseId: '', tweaks: {} })
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('selSignature', () => {
|
|
186
|
+
const sel = (over: Partial<Selection>): Selection => ({
|
|
187
|
+
componentId: 'button',
|
|
188
|
+
caseId: 'default',
|
|
189
|
+
tweaks: {},
|
|
190
|
+
...over,
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('is stable for equal selections', () => {
|
|
194
|
+
expect(selSignature(sel({}))).toBe(selSignature(sel({})))
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('changes when the case or a tweak changes', () => {
|
|
198
|
+
const base = selSignature(sel({}))
|
|
199
|
+
expect(selSignature(sel({ caseId: 'primary' }))).not.toBe(base)
|
|
200
|
+
expect(selSignature(sel({ tweaks: { size: 'lg' } }))).not.toBe(base)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('buildUrl', () => {
|
|
205
|
+
test('builds a bare /c/ path when there are no tweaks or docs', () => {
|
|
206
|
+
expect(buildUrl('button', 'default', {}, false)).toBe('/c/button/default')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('encodes tweaks under the t. prefix and the docs flag', () => {
|
|
210
|
+
expect(buildUrl('button', 'default', { size: 'lg' }, true)).toBe(
|
|
211
|
+
'/c/button/default?t.size=lg&docs=1',
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test('round-trips through parseRoute', () => {
|
|
216
|
+
const url = buildUrl('button', 'primary', { size: 'lg', on: '1' }, true)
|
|
217
|
+
const [path, search] = url.split('?')
|
|
218
|
+
const r = parseRoute(path, `?${search}`)
|
|
219
|
+
expect(r.componentId).toBe('button')
|
|
220
|
+
expect(r.caseId).toBe('primary')
|
|
221
|
+
expect(r.tweaks).toEqual({ size: 'lg', on: '1' })
|
|
222
|
+
expect(r.docs).toBe(true)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('buildRenderSrc', () => {
|
|
227
|
+
test('always sets the theme and appends tweaks', () => {
|
|
228
|
+
const src = buildRenderSrc(
|
|
229
|
+
'/render/button/default',
|
|
230
|
+
'dark',
|
|
231
|
+
{ size: 'lg' },
|
|
232
|
+
false,
|
|
233
|
+
false,
|
|
234
|
+
)
|
|
235
|
+
expect(src.startsWith('/render/button/default?')).toBe(true)
|
|
236
|
+
expect(src).toContain('theme=dark')
|
|
237
|
+
expect(src).toContain('t.size=lg')
|
|
238
|
+
expect(src).not.toContain('fit=1')
|
|
239
|
+
expect(src).not.toContain('transparent=1')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test('adds the fit and transparent stage hints when requested', () => {
|
|
243
|
+
const src = buildRenderSrc('/render/x/y', 'light', {}, true, true)
|
|
244
|
+
expect(src).toContain('fit=1')
|
|
245
|
+
expect(src).toContain('transparent=1')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('buildAddressUrl', () => {
|
|
250
|
+
test('prefixes the origin and carries theme + tweaks but no stage hints', () => {
|
|
251
|
+
const url = buildAddressUrl(
|
|
252
|
+
'/render/button/default',
|
|
253
|
+
'dark',
|
|
254
|
+
{ size: 'lg' },
|
|
255
|
+
'https://x.dev',
|
|
256
|
+
)
|
|
257
|
+
expect(url.startsWith('https://x.dev/render/button/default?')).toBe(true)
|
|
258
|
+
expect(url).toContain('theme=dark')
|
|
259
|
+
expect(url).toContain('t.size=lg')
|
|
260
|
+
expect(url).not.toContain('fit=1')
|
|
261
|
+
expect(url).not.toContain('transparent=1')
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('stays relative with an empty origin (server / first render)', () => {
|
|
265
|
+
expect(
|
|
266
|
+
buildAddressUrl('/render/x/y', 'light', {}, '').startsWith(
|
|
267
|
+
'/render/x/y?',
|
|
268
|
+
),
|
|
269
|
+
).toBe(true)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('gridPad', () => {
|
|
274
|
+
test('clamps to at least one grid cell when there is no slack', () => {
|
|
275
|
+
expect(gridPad(100, 100)).toBe(MIN_PAD)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('clamps to at most three grid cells when there is ample slack', () => {
|
|
279
|
+
expect(gridPad(2000, 100)).toBe(MAX_PAD)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
test('snaps the centered margin to a whole number of grid cells', () => {
|
|
283
|
+
const pad = gridPad(400, 200)
|
|
284
|
+
expect(pad % 16).toBe(0)
|
|
285
|
+
expect(pad).toBeGreaterThanOrEqual(MIN_PAD)
|
|
286
|
+
expect(pad).toBeLessThanOrEqual(MAX_PAD)
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
describe('groupByLevel', () => {
|
|
291
|
+
test('orders groups atoms-first and drops empty levels', () => {
|
|
292
|
+
const groups = groupByLevel([
|
|
293
|
+
comp({ id: 'page', level: 'page' }),
|
|
294
|
+
comp({ id: 'atom', level: 'atom' }),
|
|
295
|
+
comp({ id: 'mystery', level: null }),
|
|
296
|
+
])
|
|
297
|
+
expect(groups.map((g) => g.key)).toEqual(['atom', 'page', 'unclassified'])
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('files a null level under the unclassified group', () => {
|
|
301
|
+
const groups = groupByLevel([comp({ id: 'x', level: null })])
|
|
302
|
+
expect(groups).toHaveLength(1)
|
|
303
|
+
expect(groups[0].key).toBe('unclassified')
|
|
304
|
+
expect(groups[0].components).toHaveLength(1)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('groupPrimerSections', () => {
|
|
309
|
+
const section = (over: Partial<PrimerSection>): PrimerSection => ({
|
|
310
|
+
id: 's',
|
|
311
|
+
title: 'S',
|
|
312
|
+
kind: 'display',
|
|
313
|
+
...over,
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('folds displays under the heading that precedes them', () => {
|
|
317
|
+
const groups = groupPrimerSections([
|
|
318
|
+
section({ id: 'colors', kind: 'heading', title: 'Colors' }),
|
|
319
|
+
section({ id: 'ramp' }),
|
|
320
|
+
section({ id: 'swatches' }),
|
|
321
|
+
section({ id: 'type', kind: 'heading', title: 'Type' }),
|
|
322
|
+
section({ id: 'scale' }),
|
|
323
|
+
])
|
|
324
|
+
expect(groups).toHaveLength(2)
|
|
325
|
+
expect(groups[0].heading?.id).toBe('colors')
|
|
326
|
+
expect(groups[0].items.map((i) => i.id)).toEqual(['ramp', 'swatches'])
|
|
327
|
+
expect(groups[1].heading?.id).toBe('type')
|
|
328
|
+
expect(groups[1].items.map((i) => i.id)).toEqual(['scale'])
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('puts displays before the first heading into a leading headless group', () => {
|
|
332
|
+
const groups = groupPrimerSections([
|
|
333
|
+
section({ id: 'wordmark' }),
|
|
334
|
+
section({ id: 'intro', kind: 'heading', title: 'Intro' }),
|
|
335
|
+
])
|
|
336
|
+
expect(groups[0].heading).toBeNull()
|
|
337
|
+
expect(groups[0].items.map((i) => i.id)).toEqual(['wordmark'])
|
|
338
|
+
expect(groups[1].heading?.id).toBe('intro')
|
|
339
|
+
})
|
|
340
|
+
})
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import type { Manifest, ManifestComponent } from '../core/manifest'
|
|
2
|
+
import type { HierarchyLevel } from '../index'
|
|
3
|
+
import { HIERARCHY_LEVELS } from '../index'
|
|
4
|
+
|
|
5
|
+
export type Theme = 'light' | 'dark'
|
|
6
|
+
export type Mode = 'primer' | 'library'
|
|
7
|
+
|
|
8
|
+
// Two ways to size the preview, à la Chrome DevTools' device toolbar:
|
|
9
|
+
// - Responsive: a width (or "full"); height fills the panel; manual zoom applies.
|
|
10
|
+
// - Fixed: an exact W×H (a device preset or custom); the iframe renders at that
|
|
11
|
+
// size and is auto-scaled to fit the panel (manual zoom is overridden).
|
|
12
|
+
export interface ResponsivePreset {
|
|
13
|
+
id: string
|
|
14
|
+
label: string
|
|
15
|
+
width: 'full' | number
|
|
16
|
+
}
|
|
17
|
+
export const RESPONSIVE: ResponsivePreset[] = [
|
|
18
|
+
{ id: 'full', label: 'Full', width: 'full' },
|
|
19
|
+
{ id: 'desktop', label: 'Desktop', width: 1280 },
|
|
20
|
+
{ id: 'tablet', label: 'Tablet', width: 768 },
|
|
21
|
+
{ id: 'mobile', label: 'Mobile', width: 375 },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
export interface DevicePreset {
|
|
25
|
+
id: string
|
|
26
|
+
label: string
|
|
27
|
+
w: number
|
|
28
|
+
h: number
|
|
29
|
+
}
|
|
30
|
+
export const DEVICES: DevicePreset[] = [
|
|
31
|
+
{ id: 'tv-1080p', label: '1080p', w: 1920, h: 1080 },
|
|
32
|
+
{ id: 'tv-4k', label: '4K', w: 3840, h: 2160 },
|
|
33
|
+
{ id: 'laptop', label: 'Laptop', w: 1366, h: 768 },
|
|
34
|
+
{ id: 'laptop-hidpi', label: 'Laptop HiDPI', w: 1440, h: 900 },
|
|
35
|
+
{ id: 'ipad-pro-11', label: 'iPad Pro', w: 834, h: 1194 },
|
|
36
|
+
{ id: 'ipad', label: 'iPad', w: 820, h: 1180 },
|
|
37
|
+
{ id: 'iphone-14', label: 'iPhone 15', w: 390, h: 844 },
|
|
38
|
+
{ id: 'iphone-promax', label: 'iPhone Pro Max', w: 430, h: 932 },
|
|
39
|
+
{ id: 'iphone-se', label: 'iPhone SE', w: 375, h: 667 },
|
|
40
|
+
{ id: 'pixel-7', label: 'Pixel 7', w: 412, h: 915 },
|
|
41
|
+
{ id: 'galaxy-s20', label: 'Galaxy S20', w: 360, h: 800 },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
export const LEVEL_LABEL: Record<HierarchyLevel | 'unclassified', string> = {
|
|
45
|
+
atom: 'Atoms',
|
|
46
|
+
molecule: 'Molecules',
|
|
47
|
+
organism: 'Organisms',
|
|
48
|
+
template: 'Templates',
|
|
49
|
+
page: 'Pages',
|
|
50
|
+
flow: 'Flows',
|
|
51
|
+
unclassified: 'Unclassified',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const GROUP_ORDER: (HierarchyLevel | 'unclassified')[] = [
|
|
55
|
+
...HIERARCHY_LEVELS,
|
|
56
|
+
'unclassified',
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
// At or below this chrome width the nav starts collapsed (tablet and down).
|
|
60
|
+
export const NAV_COLLAPSE_MAX = 1024
|
|
61
|
+
|
|
62
|
+
export const ZOOM_MIN = 0.5
|
|
63
|
+
export const ZOOM_MAX = 2
|
|
64
|
+
export const ZOOM_STEP = 0.1
|
|
65
|
+
|
|
66
|
+
// Documentation panel width (px), adjustable by dragging its left edge.
|
|
67
|
+
export const DOC_MIN_W = 256
|
|
68
|
+
export const DOC_MAX_W = 640
|
|
69
|
+
export const DOC_DEFAULT_W = 352 // 22rem
|
|
70
|
+
|
|
71
|
+
// Stage crossfade duration (ms): the exhibit fades out when the selection
|
|
72
|
+
// changes, swaps while hidden, then fades back in once measured. Mirrors the
|
|
73
|
+
// CSS opacity transition on the stage.
|
|
74
|
+
export const STAGE_FADE_MS = 150
|
|
75
|
+
|
|
76
|
+
// Primer ↔ Cases crossfade duration (ms): on a mode switch the nav, the screen
|
|
77
|
+
// content, and the mode-specific header controls all fade out together, the view
|
|
78
|
+
// swaps while hidden, then everything fades back in. The mode-switch highlight
|
|
79
|
+
// box lerps across this same span (see `.dc-modeswitch-thumb`). Mirrors the CSS
|
|
80
|
+
// opacity transitions applied to those regions.
|
|
81
|
+
export const MODE_FADE_MS = 200
|
|
82
|
+
|
|
83
|
+
// The stage's dotted-grid margin around a decorated component, in px. It scales
|
|
84
|
+
// with the spare room — from 1 grid cell when the component is near max width up
|
|
85
|
+
// to 3 cells when there's plenty — and is snapped to the grid so the component's
|
|
86
|
+
// edges land on dot columns/rows. `GRID` is the dot spacing (matches the 16px
|
|
87
|
+
// `background-size` of `.dc-stage-frame`'s grid in chrome.css).
|
|
88
|
+
export const GRID = 16
|
|
89
|
+
export const MIN_PAD = GRID // at least 1 dot
|
|
90
|
+
export const MAX_PAD = GRID * 3 // at most 3 dots
|
|
91
|
+
|
|
92
|
+
// Center-fit, grid-snapped padding for one axis: half the spare space (after the
|
|
93
|
+
// 1px borders), floored to a whole number of dots, clamped to [MIN_PAD, MAX_PAD].
|
|
94
|
+
export function gridPad(available: number, box: number): number {
|
|
95
|
+
const slack = Math.floor((available - box - 2) / 2 / GRID) * GRID
|
|
96
|
+
return Math.max(MIN_PAD, Math.min(MAX_PAD, slack))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface Selection {
|
|
100
|
+
componentId: string
|
|
101
|
+
caseId: string
|
|
102
|
+
tweaks: Record<string, string>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ParsedRoute {
|
|
106
|
+
componentId: string
|
|
107
|
+
caseId: string
|
|
108
|
+
tweaks: Record<string, string>
|
|
109
|
+
docs: boolean
|
|
110
|
+
/** The pathname this route was parsed from, for mode resolution. */
|
|
111
|
+
path: string
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Pure route parse from an explicit path + query string. Usable on the server —
|
|
116
|
+
* which has only the request, not `window` — and on the client, so the shell's
|
|
117
|
+
* server render and its client hydration derive the same initial route and agree.
|
|
118
|
+
*/
|
|
119
|
+
export function parseRoute(pathname: string, search: string): ParsedRoute {
|
|
120
|
+
const parts = pathname.split('/').filter(Boolean)
|
|
121
|
+
const tweaks: Record<string, string> = {}
|
|
122
|
+
const params = new URLSearchParams(search)
|
|
123
|
+
for (const [k, v] of params) {
|
|
124
|
+
if (k.startsWith('t.')) tweaks[k.slice(2)] = v
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
componentId: parts[1] ?? '',
|
|
128
|
+
caseId: parts[2] ?? '',
|
|
129
|
+
tweaks,
|
|
130
|
+
docs: params.get('docs') === '1',
|
|
131
|
+
path: pathname,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Client convenience: parse the live address. Reads `window`, so client-only. */
|
|
136
|
+
export function parseLocation(): ParsedRoute {
|
|
137
|
+
return parseRoute(window.location.pathname, window.location.search)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a route resolves to the Primer rather than the library. The canonical
|
|
142
|
+
* `/primer` route does (when a Primer exists); the bare `/` landing does unless
|
|
143
|
+
* the consumer opted out with `landing: 'cases'` (resolved server-side into
|
|
144
|
+
* `manifest.landing`). Every `/c/...` deep link — and any other path — is a
|
|
145
|
+
* library address. Pure (takes the route), so it runs on the server too.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveMode(route: ParsedRoute, m: Manifest): Mode {
|
|
148
|
+
if (route.path === '/primer') return m.primer ? 'primer' : 'library'
|
|
149
|
+
if (route.path === '/') return m.landing === 'primer' ? 'primer' : 'library'
|
|
150
|
+
return 'library'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Client convenience over {@link resolveMode}. Reads `window`; client-only. */
|
|
154
|
+
export function primerForLocation(m: Manifest): boolean {
|
|
155
|
+
return resolveMode(parseLocation(), m) === 'primer'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Pure initial selection from a parsed route + manifest (server + client). */
|
|
159
|
+
export function initialSelectionFor(
|
|
160
|
+
m: Manifest,
|
|
161
|
+
route: ParsedRoute,
|
|
162
|
+
): Selection {
|
|
163
|
+
if (route.componentId)
|
|
164
|
+
return {
|
|
165
|
+
componentId: route.componentId,
|
|
166
|
+
caseId: route.caseId,
|
|
167
|
+
tweaks: route.tweaks,
|
|
168
|
+
}
|
|
169
|
+
const first = m.components[0]
|
|
170
|
+
if (first) {
|
|
171
|
+
return {
|
|
172
|
+
componentId: first.id,
|
|
173
|
+
caseId: first.cases[0]?.id ?? '',
|
|
174
|
+
tweaks: {},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { componentId: '', caseId: '', tweaks: {} }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Client convenience over {@link initialSelectionFor}. Reads `window`. */
|
|
181
|
+
export function initialSelection(m: Manifest): Selection {
|
|
182
|
+
return initialSelectionFor(m, parseLocation())
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Identity of a selection: component + case + tweak overrides. Drives the
|
|
186
|
+
// reveal gate (a size report only counts once it matches the shown selection,
|
|
187
|
+
// tweaks included) and detects when a selection change is a no-op. Note: a
|
|
188
|
+
// tweak-only change alters this signature but is NOT a crossfade trigger — the
|
|
189
|
+
// crossfade controller compares component+case, so the frame retweaks in place.
|
|
190
|
+
export function selSignature(s: Selection): string {
|
|
191
|
+
return `${s.componentId}\0${s.caseId}\0${JSON.stringify(s.tweaks ?? {})}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Encode the shareable app address: which case is on the stage, its tweak
|
|
195
|
+
// overrides (`t.*`), and whether the docs panel is open (`docs=1`). Parsed back
|
|
196
|
+
// on load by `parseLocation` so any of these survive a copied/shared link.
|
|
197
|
+
export function buildUrl(
|
|
198
|
+
componentId: string,
|
|
199
|
+
caseId: string,
|
|
200
|
+
tweaks: Record<string, string>,
|
|
201
|
+
docsOpen: boolean,
|
|
202
|
+
): string {
|
|
203
|
+
const params = new URLSearchParams()
|
|
204
|
+
for (const [k, v] of Object.entries(tweaks)) params.set(`t.${k}`, v)
|
|
205
|
+
if (docsOpen) params.set('docs', '1')
|
|
206
|
+
const qs = params.toString()
|
|
207
|
+
return `/c/${componentId}/${caseId}${qs ? `?${qs}` : ''}`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function buildRenderSrc(
|
|
211
|
+
renderUrl: string,
|
|
212
|
+
theme: Theme,
|
|
213
|
+
tweaks: Record<string, string>,
|
|
214
|
+
fit: boolean,
|
|
215
|
+
transparent: boolean,
|
|
216
|
+
): string {
|
|
217
|
+
const params = new URLSearchParams()
|
|
218
|
+
params.set('theme', theme)
|
|
219
|
+
// The preview's pixel size is controlled by the iframe element, not an inner
|
|
220
|
+
// max-width, so the frame always renders "full".
|
|
221
|
+
for (const [k, v] of Object.entries(tweaks)) params.set(`t.${k}`, v)
|
|
222
|
+
// `fit` asks the render frame to shrink-wrap the case to its natural width so
|
|
223
|
+
// a small component (e.g. a square button) doesn't stretch to fill the frame.
|
|
224
|
+
if (fit) params.set('fit', '1')
|
|
225
|
+
// `transparent` drops the render doc's background so the component sits on the
|
|
226
|
+
// stage's dotted grid (decorated components only — not pages/flows, and not
|
|
227
|
+
// the standalone /render endpoint, which keeps its opaque snapshot bg).
|
|
228
|
+
if (transparent) params.set('transparent', '1')
|
|
229
|
+
return `${renderUrl}?${params.toString()}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// The shareable standalone-snapshot address shown above the stage: the render
|
|
233
|
+
// URL with the visible theme and any tweak overrides — none of the
|
|
234
|
+
// stage-internal `fit`/`transparent` hints `buildRenderSrc` adds, since those
|
|
235
|
+
// describe how the vitrine embeds the frame, not the case a reader would open.
|
|
236
|
+
// Absolute (origin-prefixed) so the address a reader copies pastes straight into
|
|
237
|
+
// a browser; `renderUrl` is a server-relative path like `/render/...`. `origin`
|
|
238
|
+
// is passed in (not read from `window`) so this stays pure: the server and the
|
|
239
|
+
// client's first render both use an empty origin (a relative URL), and the
|
|
240
|
+
// client fills the real origin in after hydration — no hydration mismatch.
|
|
241
|
+
export function buildAddressUrl(
|
|
242
|
+
renderUrl: string,
|
|
243
|
+
theme: Theme,
|
|
244
|
+
tweaks: Record<string, string>,
|
|
245
|
+
origin: string,
|
|
246
|
+
): string {
|
|
247
|
+
const params = new URLSearchParams()
|
|
248
|
+
params.set('theme', theme)
|
|
249
|
+
for (const [k, v] of Object.entries(tweaks)) params.set(`t.${k}`, v)
|
|
250
|
+
return `${origin}${renderUrl}?${params.toString()}`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function groupByLevel(components: ManifestComponent[]) {
|
|
254
|
+
return GROUP_ORDER.map((key) => ({
|
|
255
|
+
key,
|
|
256
|
+
components: components.filter((c) => (c.level ?? 'unclassified') === key),
|
|
257
|
+
})).filter((g) => g.components.length > 0)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A `##`-heading group in the primer table of contents: the heading plus the
|
|
261
|
+
// Displays that follow it (a leading headless group holds Displays before the
|
|
262
|
+
// first heading).
|
|
263
|
+
export interface PrimerSection {
|
|
264
|
+
id: string
|
|
265
|
+
title: string
|
|
266
|
+
kind: 'heading' | 'display'
|
|
267
|
+
}
|
|
268
|
+
export interface PrimerGroup {
|
|
269
|
+
heading: PrimerSection | null
|
|
270
|
+
items: PrimerSection[]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Fold the primer's flat, document-ordered section list into `##`-heading
|
|
274
|
+
// groups: each heading owns the Displays that follow it. Displays before the
|
|
275
|
+
// first heading (e.g. the wordmark under the H1) sit in a leading headless
|
|
276
|
+
// group so they still appear in the table of contents.
|
|
277
|
+
export function groupPrimerSections(
|
|
278
|
+
primerSections: PrimerSection[],
|
|
279
|
+
): PrimerGroup[] {
|
|
280
|
+
const out: PrimerGroup[] = []
|
|
281
|
+
let current: PrimerGroup | null = null
|
|
282
|
+
for (const s of primerSections) {
|
|
283
|
+
if (s.kind === 'heading') {
|
|
284
|
+
current = { heading: s, items: [] }
|
|
285
|
+
out.push(current)
|
|
286
|
+
} else {
|
|
287
|
+
if (!current) {
|
|
288
|
+
current = { heading: null, items: [] }
|
|
289
|
+
out.push(current)
|
|
290
|
+
}
|
|
291
|
+
current.items.push(s)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out
|
|
295
|
+
}
|
package/src/ui/shell.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ShellView } from './design-system/components/shell/ShellView'
|
|
2
|
+
import { DcTestIds } from './test-ids'
|
|
3
|
+
import { type ShellSeed, useShell } from './use-shell'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The browse chrome's container. It runs the {@link useShell} state machine and
|
|
7
|
+
* hands the resulting view model to the pure {@link ShellView}, supplying the
|
|
8
|
+
* one thing a view model can't carry across the app/exhibit boundary: the live
|
|
9
|
+
* `<iframe>` elements (the render stage and the Primer reading page). In a
|
|
10
|
+
* page/flow exhibit those slots are static stand-ins instead — same view, no
|
|
11
|
+
* server. Until the manifest loads (and when it's empty) the container shows the
|
|
12
|
+
* loading/empty screens rather than the chrome.
|
|
13
|
+
*/
|
|
14
|
+
export function Shell({ seed }: { seed: ShellSeed }) {
|
|
15
|
+
const vm = useShell(seed)
|
|
16
|
+
if (!vm.manifest) return <div className="dc-loading">Loading…</div>
|
|
17
|
+
if (vm.manifest.components.length === 0) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="dc-empty">
|
|
20
|
+
<p>No cases found.</p>
|
|
21
|
+
<p className="dc-empty-hint">
|
|
22
|
+
Add a <code>*.case.tsx</code> file to get started.
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The live render stage: one iframe, loaded once at a fixed src; the hook
|
|
29
|
+
// pushes every later change in via postMessage so it never reloads/flickers.
|
|
30
|
+
const renderFrame = (
|
|
31
|
+
<iframe
|
|
32
|
+
ref={vm.frameRef}
|
|
33
|
+
title="preview"
|
|
34
|
+
data-testid={DcTestIds.stageFrame}
|
|
35
|
+
className="dc-frame"
|
|
36
|
+
style={{
|
|
37
|
+
width: `${vm.targetW}px`,
|
|
38
|
+
height: `${vm.renderH}px`,
|
|
39
|
+
transform: vm.scale === 1 ? undefined : `scale(${vm.scale})`,
|
|
40
|
+
transformOrigin: 'top left',
|
|
41
|
+
}}
|
|
42
|
+
src={vm.frameSrc ?? undefined}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// The Primer reading page: its own isolated iframe, created lazily the first
|
|
47
|
+
// time the Primer view is opened (so `primerSrc` is null until then).
|
|
48
|
+
const primerFrame = vm.primerSrc ? (
|
|
49
|
+
<iframe
|
|
50
|
+
ref={vm.primerRef}
|
|
51
|
+
title="Primer"
|
|
52
|
+
className="dc-primer-frame"
|
|
53
|
+
src={vm.primerSrc}
|
|
54
|
+
/>
|
|
55
|
+
) : null
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<ShellView {...vm} renderFrame={renderFrame} primerFrame={primerFrame} />
|
|
59
|
+
)
|
|
60
|
+
}
|