@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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/client.d.ts +6 -0
- package/configs/biome/biome.json +64 -0
- package/configs/tsconfig.project.json +17 -0
- package/core/bytes.test.ts +14 -0
- package/core/bytes.ts +14 -0
- package/core/catalog.test.ts +44 -0
- package/core/catalog.ts +43 -0
- package/core/checks.test.ts +23 -0
- package/core/checks.ts +72 -0
- package/core/contacts.test.ts +31 -0
- package/core/contacts.ts +38 -0
- package/core/doc.ts +4 -0
- package/core/feature.ts +6 -0
- package/core/fixtures.test.ts +33 -0
- package/core/fixtures.ts +69 -0
- package/core/frontmatter.ts +15 -0
- package/core/html.ts +3 -0
- package/core/image.test.ts +22 -0
- package/core/image.ts +37 -0
- package/core/jev.test.ts +66 -0
- package/core/jev.ts +77 -0
- package/core/review/model.ts +189 -0
- package/core/review/policy.ts +26 -0
- package/core/review/service.test.ts +163 -0
- package/core/review/service.ts +309 -0
- package/core/review/store.ts +51 -0
- package/core/review/triage.test.ts +41 -0
- package/core/review/triage.ts +58 -0
- package/core/states.test.ts +36 -0
- package/core/states.ts +34 -0
- package/dist/core/bytes.d.ts +5 -0
- package/dist/core/bytes.js +9 -0
- package/dist/core/catalog.d.ts +17 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/checks.d.ts +41 -0
- package/dist/core/checks.js +53 -0
- package/dist/core/contacts.d.ts +9 -0
- package/dist/core/contacts.js +34 -0
- package/dist/core/doc.d.ts +11 -0
- package/dist/core/doc.js +1 -0
- package/dist/core/feature.d.ts +3 -0
- package/dist/core/feature.js +4 -0
- package/dist/core/fixtures.d.ts +19 -0
- package/dist/core/fixtures.js +52 -0
- package/dist/core/frontmatter.d.ts +4 -0
- package/dist/core/frontmatter.js +15 -0
- package/dist/core/html.d.ts +2 -0
- package/dist/core/html.js +2 -0
- package/dist/core/image.d.ts +6 -0
- package/dist/core/image.js +34 -0
- package/dist/core/jev.d.ts +54 -0
- package/dist/core/jev.js +52 -0
- package/dist/core/review/model.d.ts +85 -0
- package/dist/core/review/model.js +112 -0
- package/dist/core/review/policy.d.ts +3 -0
- package/dist/core/review/policy.js +19 -0
- package/dist/core/review/service.d.ts +79 -0
- package/dist/core/review/service.js +245 -0
- package/dist/core/review/store.d.ts +20 -0
- package/dist/core/review/store.js +37 -0
- package/dist/core/review/triage.d.ts +6 -0
- package/dist/core/review/triage.js +45 -0
- package/dist/core/states.d.ts +6 -0
- package/dist/core/states.js +29 -0
- package/dist/scripts/bootstrap.d.ts +51 -0
- package/dist/scripts/bootstrap.js +278 -0
- package/dist/scripts/calibrate.d.ts +127 -0
- package/dist/scripts/calibrate.js +164 -0
- package/dist/scripts/check.d.ts +25 -0
- package/dist/scripts/check.js +30 -0
- package/dist/scripts/cli.d.ts +2 -0
- package/dist/scripts/cli.js +73 -0
- package/dist/scripts/commands.d.ts +25 -0
- package/dist/scripts/commands.js +49 -0
- package/dist/scripts/comments.d.ts +63 -0
- package/dist/scripts/comments.js +152 -0
- package/dist/scripts/dev.d.ts +46 -0
- package/dist/scripts/dev.js +84 -0
- package/dist/scripts/elyx.d.ts +61 -0
- package/dist/scripts/elyx.js +195 -0
- package/dist/scripts/fixtures-pull.d.ts +60 -0
- package/dist/scripts/fixtures-pull.js +121 -0
- package/dist/scripts/guard.d.ts +58 -0
- package/dist/scripts/guard.js +201 -0
- package/dist/scripts/hook.d.ts +18 -0
- package/dist/scripts/hook.js +46 -0
- package/dist/scripts/lib/bin.d.ts +4 -0
- package/dist/scripts/lib/bin.js +20 -0
- package/dist/scripts/lib/catalog.d.ts +7 -0
- package/dist/scripts/lib/catalog.js +18 -0
- package/dist/scripts/lib/cli.d.ts +75 -0
- package/dist/scripts/lib/cli.js +88 -0
- package/dist/scripts/lib/css-tokens.d.ts +13 -0
- package/dist/scripts/lib/css-tokens.js +67 -0
- package/dist/scripts/lib/judge.d.ts +24 -0
- package/dist/scripts/lib/judge.js +60 -0
- package/dist/scripts/lib/ports.d.ts +35 -0
- package/dist/scripts/lib/ports.js +113 -0
- package/dist/scripts/lib/review.d.ts +7 -0
- package/dist/scripts/lib/review.js +22 -0
- package/dist/scripts/lib/rules.d.ts +12 -0
- package/dist/scripts/lib/rules.js +20 -0
- package/dist/scripts/lib/vercel.d.ts +17 -0
- package/dist/scripts/lib/vercel.js +53 -0
- package/dist/scripts/new.d.ts +47 -0
- package/dist/scripts/new.js +113 -0
- package/dist/scripts/people.d.ts +59 -0
- package/dist/scripts/people.js +73 -0
- package/dist/scripts/preflight.d.ts +35 -0
- package/dist/scripts/preflight.js +251 -0
- package/dist/scripts/release-check.d.ts +19 -0
- package/dist/scripts/release-check.js +39 -0
- package/dist/scripts/skill-hint.d.ts +32 -0
- package/dist/scripts/skill-hint.js +103 -0
- package/dist/scripts/skills.d.ts +13 -0
- package/dist/scripts/skills.js +34 -0
- package/dist/scripts/visual-match.d.ts +46 -0
- package/dist/scripts/visual-match.js +93 -0
- package/dist/server/api.d.ts +22 -0
- package/dist/server/api.js +143 -0
- package/dist/server/checks.d.ts +4 -0
- package/dist/server/checks.js +22 -0
- package/dist/server/dev.d.ts +2 -0
- package/dist/server/dev.js +54 -0
- package/dist/server/env.d.ts +8 -0
- package/dist/server/env.js +57 -0
- package/dist/server/fake-upstash.d.ts +1 -0
- package/dist/server/fake-upstash.js +37 -0
- package/dist/server/file-kv.d.ts +2 -0
- package/dist/server/file-kv.js +24 -0
- package/dist/server/local.d.ts +4 -0
- package/dist/server/local.js +25 -0
- package/dist/server/paths.d.ts +22 -0
- package/dist/server/paths.js +81 -0
- package/dist/server/session.d.ts +22 -0
- package/dist/server/session.js +110 -0
- package/dist/server/upstash.d.ts +7 -0
- package/dist/server/upstash.js +41 -0
- package/dist/vercel/index.d.ts +5 -0
- package/dist/vercel/index.js +12 -0
- package/dist/vercel/proxy.d.ts +11 -0
- package/dist/vercel/proxy.js +238 -0
- package/dist/vite/kit.d.ts +4 -0
- package/dist/vite/kit.js +35 -0
- package/dist/vite/markdown.d.ts +2 -0
- package/dist/vite/markdown.js +212 -0
- package/dist/vite/plugin.d.ts +15 -0
- package/dist/vite/plugin.js +84 -0
- package/dist/vite/registry.d.ts +7 -0
- package/dist/vite/registry.js +70 -0
- package/kit/Icon.tsx +39 -0
- package/kit/Space.tsx +25 -0
- package/kit/Variants.tsx +134 -0
- package/kit/ViewTools.tsx +34 -0
- package/kit/base.css +27 -0
- package/kit/dials.css +98 -0
- package/kit/dials.tsx +322 -0
- package/kit/fixtures.ts +3 -0
- package/kit/format.ts +13 -0
- package/kit/harness.css +99 -0
- package/kit/hooks.ts +35 -0
- package/kit/icons.ts +329 -0
- package/kit/motion.ts +18 -0
- package/kit/prefs.ts +111 -0
- package/kit/space.css +12 -0
- package/kit/ui/Button.tsx +135 -0
- package/kit/ui/Menu.tsx +386 -0
- package/kit/ui/Overlay.tsx +300 -0
- package/kit/ui/Toast.tsx +187 -0
- package/kit/ui/index.ts +13 -0
- package/kit/ui/layer.ts +51 -0
- package/kit/ui/ui.css +238 -0
- package/package.json +87 -3
- package/shell/Guide.tsx +160 -0
- package/shell/Index.tsx +301 -0
- package/shell/Notice.tsx +27 -0
- package/shell/Prototype.tsx +31 -0
- package/shell/a11y.ts +50 -0
- package/shell/doc.d.ts +7 -0
- package/shell/doc.ts +3 -0
- package/shell/guide.css +133 -0
- package/shell/index.html +17 -0
- package/shell/main.tsx +53 -0
- package/shell/public/favicon.svg +1 -0
- package/shell/registry.ts +21 -0
- package/shell/registry.virtual.d.ts +10 -0
- package/shell/review/Review.tsx +989 -0
- package/shell/review/anchor.ts +132 -0
- package/shell/review/client.ts +165 -0
- package/shell/review/mount.tsx +12 -0
- package/shell/review/review.css +296 -0
- package/shell/shell.css +160 -0
- package/shell/system.css +238 -0
- package/skills/miles-conductor/SKILL.md +37 -0
- package/skills/miles-deploy/SKILL.md +67 -0
- package/skills/miles-design-hub/SKILL.md +62 -0
- package/skills/miles-get-hostile/SKILL.md +61 -0
- package/skills/miles-new-feature/SKILL.md +40 -0
- package/skills/miles-paper-to-prototype/SKILL.md +46 -0
- package/skills/miles-port/SKILL.md +59 -0
- package/skills/miles-port/references/astro.md +35 -0
- package/skills/miles-port/references/next.md +36 -0
- package/skills/miles-prototype/SKILL.md +83 -0
- package/skills/miles-prototype/references/accessibility.md +48 -0
- package/skills/miles-prototype/references/motion.md +59 -0
- package/skills/miles-prototype/references/performance.md +45 -0
- package/skills/miles-review/SKILL.md +39 -0
- package/templates/app/README.md +41 -0
- package/templates/app/index.tsx +32 -0
- package/templates/app/styles.css +1 -0
- package/templates/conductor/settings.toml +29 -0
- package/templates/guide.md +38 -0
- package/templates/space/README.md +37 -0
- package/templates/space/index.tsx +43 -0
- package/templates/space/styles.css +13 -0
package/shell/Guide.tsx
ADDED
|
@@ -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 <feature>.</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
|
+
}
|
package/shell/Index.tsx
ADDED
|
@@ -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 <slug></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
|
+
}
|
package/shell/Notice.tsx
ADDED
|
@@ -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
package/shell/doc.ts
ADDED