@milesroxas/design-hub 0.0.0-stage → 0.1.1

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.
Files changed (217) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/client.d.ts +6 -0
  4. package/configs/biome/biome.json +64 -0
  5. package/configs/tsconfig.project.json +17 -0
  6. package/core/bytes.test.ts +14 -0
  7. package/core/bytes.ts +14 -0
  8. package/core/catalog.test.ts +44 -0
  9. package/core/catalog.ts +43 -0
  10. package/core/checks.test.ts +23 -0
  11. package/core/checks.ts +72 -0
  12. package/core/contacts.test.ts +31 -0
  13. package/core/contacts.ts +38 -0
  14. package/core/doc.ts +4 -0
  15. package/core/feature.ts +6 -0
  16. package/core/fixtures.test.ts +33 -0
  17. package/core/fixtures.ts +69 -0
  18. package/core/frontmatter.ts +15 -0
  19. package/core/html.ts +3 -0
  20. package/core/image.test.ts +22 -0
  21. package/core/image.ts +37 -0
  22. package/core/jev.test.ts +66 -0
  23. package/core/jev.ts +77 -0
  24. package/core/review/model.ts +189 -0
  25. package/core/review/policy.ts +26 -0
  26. package/core/review/service.test.ts +163 -0
  27. package/core/review/service.ts +309 -0
  28. package/core/review/store.ts +51 -0
  29. package/core/review/triage.test.ts +41 -0
  30. package/core/review/triage.ts +58 -0
  31. package/core/states.test.ts +36 -0
  32. package/core/states.ts +34 -0
  33. package/dist/core/bytes.d.ts +5 -0
  34. package/dist/core/bytes.js +9 -0
  35. package/dist/core/catalog.d.ts +17 -0
  36. package/dist/core/catalog.js +24 -0
  37. package/dist/core/checks.d.ts +41 -0
  38. package/dist/core/checks.js +53 -0
  39. package/dist/core/contacts.d.ts +9 -0
  40. package/dist/core/contacts.js +34 -0
  41. package/dist/core/doc.d.ts +11 -0
  42. package/dist/core/doc.js +1 -0
  43. package/dist/core/feature.d.ts +3 -0
  44. package/dist/core/feature.js +4 -0
  45. package/dist/core/fixtures.d.ts +19 -0
  46. package/dist/core/fixtures.js +52 -0
  47. package/dist/core/frontmatter.d.ts +4 -0
  48. package/dist/core/frontmatter.js +15 -0
  49. package/dist/core/html.d.ts +2 -0
  50. package/dist/core/html.js +2 -0
  51. package/dist/core/image.d.ts +6 -0
  52. package/dist/core/image.js +34 -0
  53. package/dist/core/jev.d.ts +54 -0
  54. package/dist/core/jev.js +52 -0
  55. package/dist/core/review/model.d.ts +85 -0
  56. package/dist/core/review/model.js +112 -0
  57. package/dist/core/review/policy.d.ts +3 -0
  58. package/dist/core/review/policy.js +19 -0
  59. package/dist/core/review/service.d.ts +79 -0
  60. package/dist/core/review/service.js +245 -0
  61. package/dist/core/review/store.d.ts +20 -0
  62. package/dist/core/review/store.js +37 -0
  63. package/dist/core/review/triage.d.ts +6 -0
  64. package/dist/core/review/triage.js +45 -0
  65. package/dist/core/states.d.ts +6 -0
  66. package/dist/core/states.js +29 -0
  67. package/dist/scripts/bootstrap.d.ts +51 -0
  68. package/dist/scripts/bootstrap.js +278 -0
  69. package/dist/scripts/calibrate.d.ts +127 -0
  70. package/dist/scripts/calibrate.js +164 -0
  71. package/dist/scripts/check.d.ts +25 -0
  72. package/dist/scripts/check.js +30 -0
  73. package/dist/scripts/cli.d.ts +2 -0
  74. package/dist/scripts/cli.js +73 -0
  75. package/dist/scripts/commands.d.ts +25 -0
  76. package/dist/scripts/commands.js +49 -0
  77. package/dist/scripts/comments.d.ts +63 -0
  78. package/dist/scripts/comments.js +152 -0
  79. package/dist/scripts/dev.d.ts +46 -0
  80. package/dist/scripts/dev.js +84 -0
  81. package/dist/scripts/elyx.d.ts +61 -0
  82. package/dist/scripts/elyx.js +195 -0
  83. package/dist/scripts/fixtures-pull.d.ts +60 -0
  84. package/dist/scripts/fixtures-pull.js +121 -0
  85. package/dist/scripts/guard.d.ts +58 -0
  86. package/dist/scripts/guard.js +201 -0
  87. package/dist/scripts/hook.d.ts +18 -0
  88. package/dist/scripts/hook.js +46 -0
  89. package/dist/scripts/lib/bin.d.ts +4 -0
  90. package/dist/scripts/lib/bin.js +20 -0
  91. package/dist/scripts/lib/catalog.d.ts +7 -0
  92. package/dist/scripts/lib/catalog.js +18 -0
  93. package/dist/scripts/lib/cli.d.ts +75 -0
  94. package/dist/scripts/lib/cli.js +88 -0
  95. package/dist/scripts/lib/css-tokens.d.ts +13 -0
  96. package/dist/scripts/lib/css-tokens.js +67 -0
  97. package/dist/scripts/lib/judge.d.ts +24 -0
  98. package/dist/scripts/lib/judge.js +60 -0
  99. package/dist/scripts/lib/ports.d.ts +35 -0
  100. package/dist/scripts/lib/ports.js +113 -0
  101. package/dist/scripts/lib/review.d.ts +7 -0
  102. package/dist/scripts/lib/review.js +22 -0
  103. package/dist/scripts/lib/rules.d.ts +12 -0
  104. package/dist/scripts/lib/rules.js +20 -0
  105. package/dist/scripts/lib/vercel.d.ts +17 -0
  106. package/dist/scripts/lib/vercel.js +53 -0
  107. package/dist/scripts/new.d.ts +47 -0
  108. package/dist/scripts/new.js +113 -0
  109. package/dist/scripts/people.d.ts +59 -0
  110. package/dist/scripts/people.js +73 -0
  111. package/dist/scripts/preflight.d.ts +35 -0
  112. package/dist/scripts/preflight.js +251 -0
  113. package/dist/scripts/release-check.d.ts +19 -0
  114. package/dist/scripts/release-check.js +39 -0
  115. package/dist/scripts/skill-hint.d.ts +32 -0
  116. package/dist/scripts/skill-hint.js +103 -0
  117. package/dist/scripts/skills.d.ts +13 -0
  118. package/dist/scripts/skills.js +34 -0
  119. package/dist/scripts/visual-match.d.ts +46 -0
  120. package/dist/scripts/visual-match.js +93 -0
  121. package/dist/server/api.d.ts +22 -0
  122. package/dist/server/api.js +143 -0
  123. package/dist/server/checks.d.ts +4 -0
  124. package/dist/server/checks.js +22 -0
  125. package/dist/server/dev.d.ts +2 -0
  126. package/dist/server/dev.js +54 -0
  127. package/dist/server/env.d.ts +8 -0
  128. package/dist/server/env.js +57 -0
  129. package/dist/server/fake-upstash.d.ts +1 -0
  130. package/dist/server/fake-upstash.js +37 -0
  131. package/dist/server/file-kv.d.ts +2 -0
  132. package/dist/server/file-kv.js +24 -0
  133. package/dist/server/local.d.ts +4 -0
  134. package/dist/server/local.js +25 -0
  135. package/dist/server/paths.d.ts +22 -0
  136. package/dist/server/paths.js +81 -0
  137. package/dist/server/session.d.ts +22 -0
  138. package/dist/server/session.js +110 -0
  139. package/dist/server/upstash.d.ts +7 -0
  140. package/dist/server/upstash.js +41 -0
  141. package/dist/vercel/index.d.ts +5 -0
  142. package/dist/vercel/index.js +12 -0
  143. package/dist/vercel/proxy.d.ts +11 -0
  144. package/dist/vercel/proxy.js +238 -0
  145. package/dist/vite/kit.d.ts +4 -0
  146. package/dist/vite/kit.js +35 -0
  147. package/dist/vite/markdown.d.ts +2 -0
  148. package/dist/vite/markdown.js +212 -0
  149. package/dist/vite/plugin.d.ts +15 -0
  150. package/dist/vite/plugin.js +84 -0
  151. package/dist/vite/registry.d.ts +7 -0
  152. package/dist/vite/registry.js +70 -0
  153. package/kit/Icon.tsx +39 -0
  154. package/kit/Space.tsx +25 -0
  155. package/kit/Variants.tsx +134 -0
  156. package/kit/ViewTools.tsx +34 -0
  157. package/kit/base.css +27 -0
  158. package/kit/dials.css +98 -0
  159. package/kit/dials.tsx +322 -0
  160. package/kit/fixtures.ts +3 -0
  161. package/kit/format.ts +13 -0
  162. package/kit/harness.css +99 -0
  163. package/kit/hooks.ts +35 -0
  164. package/kit/icons.ts +329 -0
  165. package/kit/motion.ts +18 -0
  166. package/kit/prefs.ts +111 -0
  167. package/kit/space.css +12 -0
  168. package/kit/ui/Button.tsx +135 -0
  169. package/kit/ui/Menu.tsx +386 -0
  170. package/kit/ui/Overlay.tsx +300 -0
  171. package/kit/ui/Toast.tsx +187 -0
  172. package/kit/ui/index.ts +13 -0
  173. package/kit/ui/layer.ts +51 -0
  174. package/kit/ui/ui.css +238 -0
  175. package/package.json +87 -3
  176. package/shell/Guide.tsx +160 -0
  177. package/shell/Index.tsx +301 -0
  178. package/shell/Notice.tsx +27 -0
  179. package/shell/Prototype.tsx +31 -0
  180. package/shell/a11y.ts +50 -0
  181. package/shell/doc.d.ts +7 -0
  182. package/shell/doc.ts +3 -0
  183. package/shell/guide.css +133 -0
  184. package/shell/index.html +17 -0
  185. package/shell/main.tsx +53 -0
  186. package/shell/public/favicon.svg +1 -0
  187. package/shell/registry.ts +21 -0
  188. package/shell/registry.virtual.d.ts +10 -0
  189. package/shell/review/Review.tsx +989 -0
  190. package/shell/review/anchor.ts +132 -0
  191. package/shell/review/client.ts +165 -0
  192. package/shell/review/mount.tsx +12 -0
  193. package/shell/review/review.css +296 -0
  194. package/shell/shell.css +160 -0
  195. package/shell/system.css +238 -0
  196. package/skills/miles-conductor/SKILL.md +37 -0
  197. package/skills/miles-deploy/SKILL.md +67 -0
  198. package/skills/miles-design-hub/SKILL.md +62 -0
  199. package/skills/miles-get-hostile/SKILL.md +61 -0
  200. package/skills/miles-new-feature/SKILL.md +40 -0
  201. package/skills/miles-paper-to-prototype/SKILL.md +46 -0
  202. package/skills/miles-port/SKILL.md +59 -0
  203. package/skills/miles-port/references/astro.md +35 -0
  204. package/skills/miles-port/references/next.md +36 -0
  205. package/skills/miles-prototype/SKILL.md +83 -0
  206. package/skills/miles-prototype/references/accessibility.md +48 -0
  207. package/skills/miles-prototype/references/motion.md +59 -0
  208. package/skills/miles-prototype/references/performance.md +45 -0
  209. package/skills/miles-review/SKILL.md +39 -0
  210. package/templates/app/README.md +41 -0
  211. package/templates/app/index.tsx +32 -0
  212. package/templates/app/styles.css +1 -0
  213. package/templates/conductor/settings.toml +29 -0
  214. package/templates/guide.md +38 -0
  215. package/templates/space/README.md +37 -0
  216. package/templates/space/index.tsx +43 -0
  217. package/templates/space/styles.css +13 -0
@@ -0,0 +1,160 @@
1
+ import { Icon } from '@kit/Icon';
2
+ import '@kit/ui/ui.css';
3
+ import { Suspense, use, useEffect, useState } from 'react';
4
+ import { featureTitle, guideHref } from '../core/feature';
5
+ import type { Doc } from './doc';
6
+ import { Boundary, Notice } from './Notice';
7
+ import { featureEntry, guides } from './registry';
8
+ import './guide.css';
9
+
10
+ const fmtDate = (iso: string) =>
11
+ iso
12
+ ? new Date(`${iso}T12:00:00`).toLocaleDateString('en-GB', {
13
+ day: 'numeric',
14
+ month: 'long',
15
+ year: 'numeric',
16
+ })
17
+ : '';
18
+
19
+ // One promise per guide, so a suspended render retries against the same load;
20
+ // a failed load leaves the cache, so the next render asks again.
21
+ const docs = new Map<string, Promise<Doc>>();
22
+ function docOf(feature: string) {
23
+ let doc = docs.get(feature);
24
+ if (!doc) {
25
+ doc = guides.get(feature)!();
26
+ docs.set(feature, doc);
27
+ doc.catch(() => docs.delete(feature));
28
+ }
29
+ return doc;
30
+ }
31
+
32
+ // /guide/<feature>/ renders docs/sprints/<feature>/guide.md. /guide/ alone
33
+ // opens the only guide when there is one, and lists them otherwise.
34
+ export default function Guide({ feature }: { feature?: string }) {
35
+ const key = feature ?? (guides.size === 1 ? guides.keys().next().value : undefined);
36
+ if (!key || !guides.has(key))
37
+ return feature ? (
38
+ <Notice title={`No walkthrough for “${feature}”`} body="Add one at docs/sprints/<feature>/guide.md." />
39
+ ) : (
40
+ <GuideList />
41
+ );
42
+ return (
43
+ <Boundary name={`The ${featureTitle(key)} walkthrough`}>
44
+ <Suspense fallback={null}>
45
+ <GuidePage feature={key} />
46
+ </Suspense>
47
+ </Boundary>
48
+ );
49
+ }
50
+
51
+ function GuideList() {
52
+ return (
53
+ <main className="shell-notice">
54
+ <h1>Walkthroughs</h1>
55
+ {guides.size ? (
56
+ <ul>
57
+ {[...guides.keys()].map((f) => (
58
+ <li key={f}>
59
+ <a href={guideHref(f)}>{featureTitle(f)}</a>
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ ) : (
64
+ <p>No walkthrough yet. Start one with pnpm new:guide &lt;feature&gt;.</p>
65
+ )}
66
+ <a href="/">All prototypes</a>
67
+ </main>
68
+ );
69
+ }
70
+
71
+ // The Markdown is the source; this page is how a reviewer without the repo reads it.
72
+ function GuidePage({ feature }: { feature: string }) {
73
+ const { html, meta, title, toc } = use(docOf(feature));
74
+ const prototype = featureEntry(feature);
75
+ const [current, setCurrent] = useState<string | null>(null);
76
+
77
+ useEffect(() => {
78
+ document.title = `${title} · Prototypes`;
79
+ if (location.hash)
80
+ document.getElementById(location.hash.slice(1))?.scrollIntoView({ behavior: 'instant' });
81
+ }, [title]);
82
+
83
+ // The contents follow the reader: the last heading above the top third of
84
+ // the viewport is the current one.
85
+ useEffect(() => {
86
+ const heads = Array.from(document.querySelectorAll<HTMLElement>('.doc-body :is(h2, h3)'));
87
+ if (!heads.length) return;
88
+ let frame = 0;
89
+ const update = () => {
90
+ frame = 0;
91
+ const line = innerHeight / 3;
92
+ let found: string | null = null;
93
+ for (const h of heads) {
94
+ if (h.getBoundingClientRect().top <= line) found = h.id;
95
+ else break;
96
+ }
97
+ setCurrent(found);
98
+ };
99
+ const onScroll = () => {
100
+ if (!frame) frame = requestAnimationFrame(update);
101
+ };
102
+ update();
103
+ addEventListener('scroll', onScroll, { passive: true });
104
+ addEventListener('resize', onScroll);
105
+ return () => {
106
+ removeEventListener('scroll', onScroll);
107
+ removeEventListener('resize', onScroll);
108
+ if (frame) cancelAnimationFrame(frame);
109
+ };
110
+ }, []);
111
+
112
+ return (
113
+ <main className="doc">
114
+ <div className="doc-layout">
115
+ <header className="doc-head">
116
+ <a className="doc-back" href="/">
117
+ <Icon name="arrow-left" size={16} />
118
+ Prototypes
119
+ </a>
120
+ <div className="doc-head-row">
121
+ <div className="doc-head-text">
122
+ <p className="doc-kicker">
123
+ {featureTitle(feature)} · Walkthrough · <time dateTime={meta.date}>{fmtDate(meta.date)}</time>
124
+ </p>
125
+ <h1 className="doc-title">{title}</h1>
126
+ </div>
127
+ {prototype ? (
128
+ <a
129
+ className="btn btn-primary btn-md"
130
+ href={`/${prototype.slug}/`}
131
+ target="_blank"
132
+ rel="noreferrer"
133
+ >
134
+ <span className="btn-label">Open the prototype</span>
135
+ <Icon name="arrow-up-right" size={16} />
136
+ </a>
137
+ ) : null}
138
+ </div>
139
+ </header>
140
+ <nav className="doc-toc" aria-label="Contents">
141
+ <p className="doc-toc-label">Contents</p>
142
+ <ol>
143
+ {toc.map((h) => (
144
+ <li key={h.id} className={h.depth === 3 ? 'is-sub' : undefined}>
145
+ <a href={`#${h.id}`} aria-current={current === h.id ? 'location' : undefined}>
146
+ {h.text}
147
+ </a>
148
+ </li>
149
+ ))}
150
+ </ol>
151
+ </nav>
152
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: compiled at build time from a Markdown file in this repo */}
153
+ <article className="doc-body" dangerouslySetInnerHTML={{ __html: html }} />
154
+ <footer className="doc-foot">
155
+ <a href="/">All prototypes</a>
156
+ </footer>
157
+ </div>
158
+ </main>
159
+ );
160
+ }
@@ -0,0 +1,301 @@
1
+ import { fmtDate, plural } from '@kit/format';
2
+ import { useStoredState } from '@kit/hooks';
3
+ import { Icon } from '@kit/Icon';
4
+ import {
5
+ type CSSProperties,
6
+ type KeyboardEvent,
7
+ type MouseEvent,
8
+ useDeferredValue,
9
+ useEffect,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import { featureTitle, guideHref } from '../core/feature';
14
+ import { type Entry, entries, guides } from './registry';
15
+ import './shell.css';
16
+
17
+ // One group per feature: its one primary prototype, its alternates on a rail
18
+ // under it, then its internal explorations folded away. A second sprint
19
+ // becomes an alternate, since a feature has one primary. Prototypes with no
20
+ // feature fold into their own group. Archived ones leave their group.
21
+ type Group = {
22
+ id: string;
23
+ title: string;
24
+ guide?: string;
25
+ items: Entry[];
26
+ alternates: Entry[];
27
+ explorations: Entry[];
28
+ };
29
+ const groupsOf = (list: Entry[]): Group[] => {
30
+ const live = list.filter((e) => e.status !== 'archived');
31
+ const features = [...new Set(live.map((e) => e.feature).filter(Boolean))];
32
+ return [
33
+ ...features.map((f) => {
34
+ const own = live.filter((e) => e.feature === f);
35
+ const primary = own.find((e) => e.kind === 'sprint');
36
+ const alternates = own.filter((e) => e.kind !== 'exploration' && e !== primary);
37
+ return {
38
+ id: `feature-${f}`,
39
+ title: featureTitle(f),
40
+ guide: guides.has(f) ? guideHref(f) : undefined,
41
+ items: primary ? [primary] : alternates,
42
+ alternates: primary ? alternates : [],
43
+ explorations: own.filter((e) => e.kind === 'exploration'),
44
+ };
45
+ }),
46
+ {
47
+ id: 'unassigned',
48
+ title: 'Without a feature',
49
+ items: [],
50
+ alternates: [],
51
+ explorations: live.filter((e) => !e.feature),
52
+ },
53
+ {
54
+ id: 'archived',
55
+ title: 'Archived',
56
+ items: list.filter((e) => e.status === 'archived'),
57
+ alternates: [],
58
+ explorations: [],
59
+ },
60
+ ].filter((g) => g.items.length || g.alternates.length || g.explorations.length);
61
+ };
62
+
63
+ const RISE_CAP = 8;
64
+ const rise = (i: number) => ({ '--i': Math.min(i, RISE_CAP) }) as CSSProperties;
65
+
66
+ export function Index() {
67
+ const [query, setQuery] = useState('');
68
+ const [leaving, setLeaving] = useState<string | null>(null);
69
+ const [opened, setOpened] = useStoredState<Record<string, boolean>>('index-explorations-open', {});
70
+ const [instant, setInstant] = useState(false);
71
+ const q = useDeferredValue(query.trim().toLowerCase());
72
+ const main = useRef<HTMLElement>(null);
73
+ const shown = entries.filter((e) => !q || `${e.title} ${e.summary} ${e.slug}`.toLowerCase().includes(q));
74
+ const groups = groupsOf(shown);
75
+ const archivedTotal = entries.filter((e) => e.status === 'archived').length;
76
+
77
+ // A back-forward cache restore brings the faded page back; undo the exit.
78
+ useEffect(() => {
79
+ const restore = (e: PageTransitionEvent) => {
80
+ if (e.persisted) setLeaving(null);
81
+ };
82
+ addEventListener('pageshow', restore);
83
+ return () => removeEventListener('pageshow', restore);
84
+ }, []);
85
+
86
+ // Arrows walk the rows; keyboard moves never animate.
87
+ const onKeyDown = (e: KeyboardEvent) => {
88
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
89
+ const rows = Array.from(
90
+ main.current?.querySelectorAll<HTMLAnchorElement>('.index-row, .index-fold:not([inert]) .index-card') ??
91
+ [],
92
+ );
93
+ if (!rows.length) return;
94
+ const at = rows.indexOf(document.activeElement as HTMLAnchorElement);
95
+ const next = e.key === 'ArrowDown' ? Math.min(at + 1, rows.length - 1) : Math.max(at - 1, 0);
96
+ rows[next]?.focus();
97
+ e.preventDefault();
98
+ };
99
+
100
+ // Pointer opens fade the page while the prototype loads. A keyboard open
101
+ // (detail 0) is instant, and a modified click opens elsewhere, so neither fades.
102
+ const onOpen = (e: MouseEvent<HTMLAnchorElement>, slug: string) => {
103
+ if (e.detail === 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
104
+ setLeaving(slug);
105
+ };
106
+
107
+ // A keyboard toggle (detail 0) opens and closes without motion.
108
+ const onToggle = (e: MouseEvent<HTMLButtonElement>, id: string) => {
109
+ setInstant(e.detail === 0);
110
+ setOpened((o) => ({ ...o, [id]: !o[id] }));
111
+ };
112
+
113
+ const rows = (items: Entry[], from: number) =>
114
+ items.map((e, i) => (
115
+ <li key={e.slug} className="index-rise" style={rise(from + i)}>
116
+ <a
117
+ className="index-row"
118
+ href={`/${e.slug}/`}
119
+ data-kind={e.kind}
120
+ data-chosen={leaving === e.slug || undefined}
121
+ onClick={(ev) => onOpen(ev, e.slug)}
122
+ >
123
+ <span className="index-title">{e.title}</span>
124
+ <span className="index-summary">{e.summary}</span>
125
+ <EntryMeta entry={e} className="index-meta" />
126
+ </a>
127
+ </li>
128
+ ));
129
+
130
+ let slot = 0;
131
+ return (
132
+ <main className={leaving ? 'index is-leaving' : 'index'} ref={main} onKeyDown={onKeyDown}>
133
+ <header className="index-head">
134
+ <h1>Prototypes</h1>
135
+ <p className="index-voice">
136
+ High-fidelity sketches in code, made as we sprint on features and improve the product.
137
+ </p>
138
+ </header>
139
+ {entries.length > 6 ? (
140
+ <label className="index-filter">
141
+ <span className="sr-only">Filter prototypes</span>
142
+ <input
143
+ type="search"
144
+ placeholder="Filter"
145
+ autoComplete="off"
146
+ value={query}
147
+ onChange={(e) => setQuery(e.target.value)}
148
+ />
149
+ </label>
150
+ ) : null}
151
+ {entries.length === 0 ? (
152
+ <section className="index-empty index-rise" style={rise(0)}>
153
+ <h2>No prototypes yet</h2>
154
+ <p>
155
+ Write the problem and the one question in a README, then start one with{' '}
156
+ <code>pnpm new &lt;slug&gt;</code>.
157
+ </p>
158
+ </section>
159
+ ) : null}
160
+ {groups.map((g) => {
161
+ const from = slot;
162
+ const alt = from + 1 + g.items.length;
163
+ const sub = g.alternates.length ? alt + 1 + g.alternates.length : alt;
164
+ const open = Boolean(q || opened[g.id]);
165
+ slot = g.explorations.length ? sub + 1 + (open ? g.explorations.length : 0) : sub;
166
+ return (
167
+ <section key={g.id} aria-labelledby={`group-${g.id}`} className="index-group">
168
+ <div className="index-section index-rise" style={rise(from)}>
169
+ <h2 id={`group-${g.id}`}>{g.title}</h2>
170
+ {g.guide ? (
171
+ <a className="index-guide" href={g.guide}>
172
+ Walkthrough
173
+ <span className="sr-only"> for {g.title}</span>
174
+ <Icon name="arrow-right" size={16} />
175
+ </a>
176
+ ) : null}
177
+ </div>
178
+ {g.items.length ? <ul className="index-list">{rows(g.items, from + 1)}</ul> : null}
179
+ {g.alternates.length ? (
180
+ <div className="index-alternates">
181
+ <h3
182
+ id={`group-${g.id}-alternates`}
183
+ className="index-alternates-label index-rise"
184
+ style={rise(alt)}
185
+ >
186
+ Alternates
187
+ </h3>
188
+ <ul className="index-list" aria-labelledby={`group-${g.id}-alternates`}>
189
+ {rows(g.alternates, alt + 1)}
190
+ </ul>
191
+ </div>
192
+ ) : null}
193
+ {g.explorations.length ? (
194
+ <Explorations
195
+ group={g}
196
+ at={sub}
197
+ open={open}
198
+ instant={instant}
199
+ leaving={leaving}
200
+ onToggle={(ev) => onToggle(ev, g.id)}
201
+ onOpen={onOpen}
202
+ />
203
+ ) : null}
204
+ </section>
205
+ );
206
+ })}
207
+ {entries.length > 0 && shown.length === 0 ? (
208
+ <p className="index-none" role="status">
209
+ Nothing matches “{query}”.
210
+ </p>
211
+ ) : null}
212
+ {entries.length > 0 ? (
213
+ <footer className="index-foot index-rise" style={rise(slot)}>
214
+ <p className="tabular">
215
+ {plural(entries.length - archivedTotal, 'prototype')}
216
+ {archivedTotal ? `, ${plural(archivedTotal, 'archived')}` : ''}
217
+ </p>
218
+ </footer>
219
+ ) : null}
220
+ </main>
221
+ );
222
+ }
223
+
224
+ /** A group's internal explorations, folded away under a toggle until asked for or a search matches them. */
225
+ function Explorations({
226
+ group,
227
+ at,
228
+ open,
229
+ instant,
230
+ leaving,
231
+ onToggle,
232
+ onOpen,
233
+ }: {
234
+ group: Group;
235
+ at: number;
236
+ open: boolean;
237
+ instant: boolean;
238
+ leaving: string | null;
239
+ onToggle: (e: MouseEvent<HTMLButtonElement>) => void;
240
+ onOpen: (e: MouseEvent<HTMLAnchorElement>, slug: string) => void;
241
+ }) {
242
+ const foldId = `group-${group.id}-explorations`;
243
+ return (
244
+ <>
245
+ <h3 id={`${foldId}-title`} className="index-subsection index-rise" style={rise(at)}>
246
+ <button
247
+ type="button"
248
+ className="index-fold-toggle"
249
+ aria-expanded={open}
250
+ aria-controls={foldId}
251
+ onClick={onToggle}
252
+ >
253
+ Internal explorations
254
+ <span className="index-fold-count tabular">{group.explorations.length}</span>
255
+ <Icon name="chevron-down" size={16} />
256
+ </button>
257
+ </h3>
258
+ <div
259
+ id={foldId}
260
+ className="index-fold"
261
+ data-open={open || undefined}
262
+ data-instant={instant || undefined}
263
+ inert={!open}
264
+ >
265
+ <ul className="index-cards" aria-labelledby={`${foldId}-title`}>
266
+ {group.explorations.map((e, i) => (
267
+ <li key={e.slug} className="index-rise" style={rise(at + 1 + i)}>
268
+ <a
269
+ className="index-card"
270
+ href={`/${e.slug}/`}
271
+ data-chosen={leaving === e.slug || undefined}
272
+ onClick={(ev) => onOpen(ev, e.slug)}
273
+ >
274
+ <span className="index-card-head">
275
+ <span className="index-title">{e.title}</span>
276
+ <Icon name="arrow-right" size={16} />
277
+ </span>
278
+ <span className="index-summary">{e.summary}</span>
279
+ <EntryMeta entry={e} className="index-card-meta" />
280
+ </a>
281
+ </li>
282
+ ))}
283
+ </ul>
284
+ </div>
285
+ </>
286
+ );
287
+ }
288
+
289
+ function EntryMeta({ entry, className }: { entry: Entry; className: string }) {
290
+ return (
291
+ <span className={className}>
292
+ <span>{entry.context === 'app' ? 'App shell' : 'Space'}</span>
293
+ <span className="index-status" data-status={entry.status}>
294
+ {entry.status}
295
+ </span>
296
+ <time dateTime={entry.created} className="tabular">
297
+ {fmtDate(entry.created)}
298
+ </time>
299
+ </span>
300
+ );
301
+ }
@@ -0,0 +1,27 @@
1
+ import { Component, type ReactNode } from 'react';
2
+ import './shell.css';
3
+
4
+ export function Notice({ title, body, detail }: { title: string; body: string; detail?: string }) {
5
+ return (
6
+ <main className="shell-notice sys">
7
+ <h1>{title}</h1>
8
+ <p>{body}</p>
9
+ {detail ? <pre>{detail}</pre> : null}
10
+ <a href="/">All prototypes</a>
11
+ </main>
12
+ );
13
+ }
14
+
15
+ export class Boundary extends Component<{ name: string; children: ReactNode }, { error: Error | null }> {
16
+ state = { error: null as Error | null };
17
+
18
+ static getDerivedStateFromError(error: Error) {
19
+ return { error };
20
+ }
21
+
22
+ render() {
23
+ const { error } = this.state;
24
+ if (!error) return this.props.children;
25
+ return <Notice title={`${this.props.name} crashed`} body={error.message} detail={error.stack} />;
26
+ }
27
+ }
@@ -0,0 +1,31 @@
1
+ import { lazy, Suspense, useEffect, useMemo } from 'react';
2
+ import { Boundary, Notice } from './Notice';
3
+ import type { Entry } from './registry';
4
+
5
+ export function Prototype({ slug, entry }: { slug: string; entry?: Entry }) {
6
+ const View = useMemo(() => (entry?.load ? lazy(entry.load) : null), [entry]);
7
+
8
+ useEffect(() => {
9
+ document.title = `${entry?.title ?? slug} · Prototypes`;
10
+ }, [entry, slug]);
11
+
12
+ if (!entry)
13
+ return (
14
+ <Notice
15
+ title={`No prototype called “${slug}”`}
16
+ body="Check the folder name, or start one with pnpm new."
17
+ />
18
+ );
19
+ if (!View)
20
+ return (
21
+ <Notice title={entry.title} body={`${slug}/index.tsx is missing. It default-exports the prototype.`} />
22
+ );
23
+
24
+ return (
25
+ <Boundary name={slug}>
26
+ <Suspense fallback={null}>
27
+ <View />
28
+ </Suspense>
29
+ </Boundary>
30
+ );
31
+ }
package/shell/a11y.ts ADDED
@@ -0,0 +1,50 @@
1
+ import axe from 'axe-core';
2
+
3
+ // Dev only: axe runs once the page has been quiet for a moment and logs
4
+ // new violations as [a11y] warnings. ?a11y=0 turns it off.
5
+ const HARNESS = [['.proto-picker'], ['.dialkit-root'], ['[data-harness]'], ['[data-lapse-panel]']];
6
+
7
+ export function watchA11y() {
8
+ let timer = 0;
9
+ let running = false;
10
+ let last: string | null = null;
11
+
12
+ const run = async () => {
13
+ if (running) return;
14
+ running = true;
15
+ try {
16
+ const { violations } = await axe.run(
17
+ { exclude: HARNESS },
18
+ { resultTypes: ['violations'], rules: { region: { enabled: false } } },
19
+ );
20
+ const key = violations.map((v) => `${v.id}:${v.nodes.length}`).join('|');
21
+ if (key && key !== last) {
22
+ console.groupCollapsed(`[a11y] ${violations.length} rule(s) failing`);
23
+ for (const v of violations) {
24
+ console.warn(
25
+ `[a11y] ${v.impact} ${v.id}: ${v.help}`,
26
+ v.nodes.map((n) => n.target.join(' ')),
27
+ v.helpUrl,
28
+ );
29
+ }
30
+ console.groupEnd();
31
+ } else if (!key && last !== '') {
32
+ console.info('[a11y] clean');
33
+ }
34
+ last = key;
35
+ } finally {
36
+ running = false;
37
+ }
38
+ };
39
+
40
+ const schedule = () => {
41
+ clearTimeout(timer);
42
+ // Safari has no requestIdleCallback; without the fallback the watcher throws and never runs there.
43
+ const idle = window.requestIdleCallback ?? ((fn: () => void) => window.setTimeout(fn, 200));
44
+ timer = window.setTimeout(() => idle(run, { timeout: 2000 }), 1200);
45
+ };
46
+
47
+ new MutationObserver(schedule).observe(document.body, { subtree: true, childList: true });
48
+ addEventListener('hashchange', schedule);
49
+ schedule();
50
+ }
package/shell/doc.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ declare module '*.md?doc' {
2
+ type Doc = import('./doc').Doc;
3
+ export const meta: Doc['meta'];
4
+ export const title: Doc['title'];
5
+ export const toc: Doc['toc'];
6
+ export const html: Doc['html'];
7
+ }
package/shell/doc.ts ADDED
@@ -0,0 +1,3 @@
1
+ // The compiled walkthrough's shape, for the page. The compiler runs only at
2
+ // build time, so the page imports this shape, never the compiler.
3
+ export type { Doc } from '../core/doc';