@milesroxas/design-hub 0.0.0-stage → 0.1.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 +80 -2
- package/client.d.ts +6 -0
- package/configs/biome/biome.json +63 -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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// `virtual:design-hub/registry`: the project's prototypes and walkthroughs as
|
|
3
|
+
// a module the hub imports. The hub's own code lives in this package, so it
|
|
4
|
+
// cannot glob the project; this plugin scans the project's folders instead
|
|
5
|
+
// and writes the imports the browser index and the loader need, each on an
|
|
6
|
+
// absolute path. A folder that appears or disappears invalidates the module
|
|
7
|
+
// and reloads the page, so a new prototype shows without a restart.
|
|
8
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { SLUG } from '../core/feature.js';
|
|
11
|
+
const REGISTRY_ID = 'virtual:design-hub/registry';
|
|
12
|
+
const RESOLVED = `\0${REGISTRY_ID}`;
|
|
13
|
+
const dirs = (parent) => existsSync(parent)
|
|
14
|
+
? readdirSync(parent, { withFileTypes: true })
|
|
15
|
+
.filter((d) => d.isDirectory() && SLUG.test(d.name))
|
|
16
|
+
.map((d) => d.name)
|
|
17
|
+
.sort()
|
|
18
|
+
: [];
|
|
19
|
+
/** The prototype folders: kebab-case, with a README. `guide` is the walkthrough route, never a prototype. */
|
|
20
|
+
export const prototypeSlugs = (prototypes) => dirs(prototypes).filter((slug) => slug !== 'guide' && existsSync(join(prototypes, slug, 'README.md')));
|
|
21
|
+
/** The features with a walkthrough at docs/sprints/<feature>/guide.md. */
|
|
22
|
+
export const guideFeatures = (sprints) => dirs(sprints).filter((feature) => existsSync(join(sprints, feature, 'guide.md')));
|
|
23
|
+
const entry = (key, path, query = '') => ` ${JSON.stringify(key)}: () => import(${JSON.stringify(path + query)}),`;
|
|
24
|
+
export function registrySource(root) {
|
|
25
|
+
const prototypes = join(root, 'prototypes');
|
|
26
|
+
const sprints = join(root, 'docs', 'sprints');
|
|
27
|
+
const slugs = prototypeSlugs(prototypes);
|
|
28
|
+
const readmes = slugs.map((slug, i) => `import readme${i} from ${JSON.stringify(`${join(prototypes, slug, 'README.md')}?raw`)};`);
|
|
29
|
+
const modules = slugs
|
|
30
|
+
.filter((slug) => existsSync(join(prototypes, slug, 'index.tsx')))
|
|
31
|
+
.map((slug) => entry(slug, join(prototypes, slug, 'index.tsx')));
|
|
32
|
+
const guides = guideFeatures(sprints).map((feature) => entry(feature, join(sprints, feature, 'guide.md'), '?doc'));
|
|
33
|
+
return [
|
|
34
|
+
...readmes,
|
|
35
|
+
`export const readmes = {\n${slugs.map((slug, i) => ` ${JSON.stringify(slug)}: readme${i},`).join('\n')}\n};`,
|
|
36
|
+
`export const modules = {\n${modules.join('\n')}\n};`,
|
|
37
|
+
`export const guideDocs = {\n${guides.join('\n')}\n};`,
|
|
38
|
+
'',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
const REGISTRY_FILE = /\/(README\.md|index\.tsx|guide\.md)$/;
|
|
42
|
+
export function registry(root) {
|
|
43
|
+
const prototypes = join(root, 'prototypes');
|
|
44
|
+
const sprints = join(root, 'docs', 'sprints');
|
|
45
|
+
return {
|
|
46
|
+
name: 'design-hub-registry',
|
|
47
|
+
resolveId(id) {
|
|
48
|
+
return id === REGISTRY_ID ? RESOLVED : null;
|
|
49
|
+
},
|
|
50
|
+
load(id) {
|
|
51
|
+
return id === RESOLVED ? registrySource(root) : null;
|
|
52
|
+
},
|
|
53
|
+
configureServer(server) {
|
|
54
|
+
server.watcher.add([prototypes, sprints]);
|
|
55
|
+
const refresh = (path) => {
|
|
56
|
+
if (!path.startsWith(prototypes) && !path.startsWith(sprints))
|
|
57
|
+
return;
|
|
58
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED);
|
|
59
|
+
if (mod)
|
|
60
|
+
server.moduleGraph.invalidateModule(mod);
|
|
61
|
+
server.ws.send({ type: 'full-reload' });
|
|
62
|
+
};
|
|
63
|
+
// A folder coming or going, or one of the three files the registry lists, changes what the index shows.
|
|
64
|
+
server.watcher.on('addDir', refresh);
|
|
65
|
+
server.watcher.on('unlinkDir', refresh);
|
|
66
|
+
server.watcher.on('add', (path) => REGISTRY_FILE.test(path) && refresh(path));
|
|
67
|
+
server.watcher.on('unlink', (path) => REGISTRY_FILE.test(path) && refresh(path));
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/kit/Icon.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type IconName, icons } from './icons';
|
|
2
|
+
|
|
3
|
+
export type { IconName };
|
|
4
|
+
|
|
5
|
+
type Props = { name: IconName; size?: number; className?: string; label?: string };
|
|
6
|
+
|
|
7
|
+
function geometry(name: IconName, size: number) {
|
|
8
|
+
const icon = icons[name];
|
|
9
|
+
const [, , w, h] = icon.vb.split(' ').map(Number);
|
|
10
|
+
return { icon, width: size, height: Math.round((size * h) / w), stroked: Boolean(icon.rootStroke) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Icon({ name, size = 20, className, label }: Props) {
|
|
14
|
+
const { icon, width, height, stroked } = geometry(name, size);
|
|
15
|
+
const stroke = stroked ? { stroke: 'currentColor', strokeWidth: 2 } : {};
|
|
16
|
+
return (
|
|
17
|
+
<svg
|
|
18
|
+
className={className ? `icon ${className}` : 'icon'}
|
|
19
|
+
width={width}
|
|
20
|
+
height={height}
|
|
21
|
+
viewBox={icon.vb}
|
|
22
|
+
fill="none"
|
|
23
|
+
{...stroke}
|
|
24
|
+
role={label ? 'img' : undefined}
|
|
25
|
+
aria-label={label}
|
|
26
|
+
aria-hidden={label ? undefined : true}
|
|
27
|
+
focusable="false"
|
|
28
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: trusted, inlined from @kit/icons
|
|
29
|
+
dangerouslySetInnerHTML={{ __html: icon.inner }}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The same decorative icon as markup, for the few places the kit writes DOM outside React.
|
|
35
|
+
export function iconMarkup(name: IconName, size = 20) {
|
|
36
|
+
const { icon, width, height, stroked } = geometry(name, size);
|
|
37
|
+
const stroke = stroked ? ' stroke="currentColor" stroke-width="2"' : '';
|
|
38
|
+
return `<svg class="icon" width="${width}" height="${height}" viewBox="${icon.vb}" fill="none"${stroke} aria-hidden="true" focusable="false">${icon.inner}</svg>`;
|
|
39
|
+
}
|
package/kit/Space.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import './space.css';
|
|
3
|
+
import { ViewTools } from './ViewTools';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
title: string;
|
|
7
|
+
lede?: ReactNode;
|
|
8
|
+
width?: 'narrow' | 'wide' | 'full';
|
|
9
|
+
tools?: boolean;
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// The low-gravity space: one thing on a quiet canvas, no product around it.
|
|
14
|
+
export function Space({ title, lede, width = 'wide', tools = true, children }: Props) {
|
|
15
|
+
return (
|
|
16
|
+
<main className="space" data-width={width}>
|
|
17
|
+
<header className="space-head">
|
|
18
|
+
<h1 className="space-title">{title}</h1>
|
|
19
|
+
{lede ? <p className="space-lede">{lede}</p> : null}
|
|
20
|
+
</header>
|
|
21
|
+
{children}
|
|
22
|
+
{tools ? <ViewTools /> : null}
|
|
23
|
+
</main>
|
|
24
|
+
);
|
|
25
|
+
}
|
package/kit/Variants.tsx
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type ComponentType, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import './harness.css';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
options: Record<string, ComponentType>;
|
|
7
|
+
replay?: boolean;
|
|
8
|
+
onReplay?: () => void;
|
|
9
|
+
position?: 'bottom' | 'top';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const POPUPS = '[role="menu"], [role="listbox"], [role="dialog"]';
|
|
13
|
+
const OWNS_KEYS = `input, textarea, select, [contenteditable=""], [contenteditable="true"], ${POPUPS}`;
|
|
14
|
+
const HARNESS = '.dialkit-root, [data-harness], [data-lapse-panel]';
|
|
15
|
+
|
|
16
|
+
const popupOpen = () =>
|
|
17
|
+
[...document.querySelectorAll(POPUPS)].some((el) => !el.closest(HARNESS) && el.getClientRects().length > 0);
|
|
18
|
+
|
|
19
|
+
function initialIndex(count: number) {
|
|
20
|
+
const v = Number(new URLSearchParams(location.search).get('v'));
|
|
21
|
+
return Number.isInteger(v) && v >= 1 && v <= count ? v - 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const modified = (e: KeyboardEvent) => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
|
|
25
|
+
|
|
26
|
+
/** A bare key the page has not handled, outside any control or popup that owns keys. */
|
|
27
|
+
const pickerOwns = (e: KeyboardEvent) =>
|
|
28
|
+
!modified(e) && !e.defaultPrevented && !(e.target as Element | null)?.closest?.(OWNS_KEYS) && !popupOpen();
|
|
29
|
+
|
|
30
|
+
/** What a key does: 1 to N picks a variant, arrows step and wrap, R replays. */
|
|
31
|
+
function keyTarget(key: string, index: number, count: number): number | 'again' | null {
|
|
32
|
+
const n = Number.parseInt(key, 10);
|
|
33
|
+
if (n >= 1 && n <= count) return n - 1;
|
|
34
|
+
if (key === 'ArrowRight') return (index + 1) % count;
|
|
35
|
+
if (key === 'ArrowLeft') return (index - 1 + count) % count;
|
|
36
|
+
return key.toLowerCase() === 'r' ? 'again' : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Variants({ options, replay = true, onReplay, position = 'bottom' }: Props) {
|
|
40
|
+
const names = Object.keys(options);
|
|
41
|
+
const [index, setIndex] = useState(() => initialIndex(names.length));
|
|
42
|
+
const [take, setTake] = useState(0);
|
|
43
|
+
const [ready, setReady] = useState(false);
|
|
44
|
+
const items = useRef<(HTMLButtonElement | null)[]>([]);
|
|
45
|
+
const highlight = useRef<HTMLSpanElement>(null);
|
|
46
|
+
|
|
47
|
+
const select = useCallback(
|
|
48
|
+
(i: number) => {
|
|
49
|
+
if (i < 0 || i >= names.length) return;
|
|
50
|
+
setIndex(i);
|
|
51
|
+
const url = new URL(location.href);
|
|
52
|
+
url.searchParams.set('v', String(i + 1));
|
|
53
|
+
history.replaceState(history.state, '', url);
|
|
54
|
+
},
|
|
55
|
+
[names.length],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const again = useCallback(() => (onReplay ? onReplay() : setTake((t) => t + 1)), [onReplay]);
|
|
59
|
+
|
|
60
|
+
useLayoutEffect(() => {
|
|
61
|
+
const move = () => {
|
|
62
|
+
const el = items.current[index];
|
|
63
|
+
if (!el || !highlight.current) return;
|
|
64
|
+
highlight.current.style.width = `${el.offsetWidth}px`;
|
|
65
|
+
highlight.current.style.transform = `translateX(${el.offsetLeft}px)`;
|
|
66
|
+
};
|
|
67
|
+
move();
|
|
68
|
+
addEventListener('resize', move);
|
|
69
|
+
return () => removeEventListener('resize', move);
|
|
70
|
+
}, [index]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const id = requestAnimationFrame(() => requestAnimationFrame(() => setReady(true)));
|
|
74
|
+
return () => cancelAnimationFrame(id);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const onKey = (e: KeyboardEvent) => {
|
|
79
|
+
if (!pickerOwns(e)) return;
|
|
80
|
+
const next = keyTarget(e.key, index, names.length);
|
|
81
|
+
if (next === 'again') again();
|
|
82
|
+
else if (next !== null) select(next);
|
|
83
|
+
};
|
|
84
|
+
document.addEventListener('keydown', onKey);
|
|
85
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
86
|
+
}, [index, names.length, select, again]);
|
|
87
|
+
|
|
88
|
+
const Current = options[names[index]];
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<Current key={`${index}:${take}`} />
|
|
93
|
+
{createPortal(
|
|
94
|
+
<nav
|
|
95
|
+
className="proto-picker"
|
|
96
|
+
aria-label="Prototype variants"
|
|
97
|
+
data-ready={ready ? '' : undefined}
|
|
98
|
+
data-position={position === 'top' ? 'top' : undefined}
|
|
99
|
+
>
|
|
100
|
+
<span ref={highlight} className="proto-picker-highlight" aria-hidden="true" />
|
|
101
|
+
{names.map((name, i) => (
|
|
102
|
+
<button
|
|
103
|
+
key={name}
|
|
104
|
+
ref={(el) => {
|
|
105
|
+
items.current[i] = el;
|
|
106
|
+
}}
|
|
107
|
+
type="button"
|
|
108
|
+
className="proto-picker-item"
|
|
109
|
+
data-active={i === index ? '' : undefined}
|
|
110
|
+
aria-current={i === index ? 'true' : undefined}
|
|
111
|
+
onClick={() => select(i)}
|
|
112
|
+
>
|
|
113
|
+
{name}
|
|
114
|
+
</button>
|
|
115
|
+
))}
|
|
116
|
+
{replay && (
|
|
117
|
+
<>
|
|
118
|
+
<span className="proto-picker-divider" aria-hidden="true" />
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
className="proto-picker-item proto-picker-replay"
|
|
122
|
+
aria-label="Replay animation (R)"
|
|
123
|
+
onClick={again}
|
|
124
|
+
>
|
|
125
|
+
↻
|
|
126
|
+
</button>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</nav>,
|
|
130
|
+
document.body,
|
|
131
|
+
)}
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useDialKitController } from 'dialkit';
|
|
2
|
+
import { useEffect, useLayoutEffect, useState } from 'react';
|
|
3
|
+
import { ensureDialRoot } from './dials';
|
|
4
|
+
import {
|
|
5
|
+
type Appearance,
|
|
6
|
+
prefersReducedMotion,
|
|
7
|
+
setAppearance,
|
|
8
|
+
setForceReduce,
|
|
9
|
+
setSpeed,
|
|
10
|
+
useAppearance,
|
|
11
|
+
} from './prefs';
|
|
12
|
+
|
|
13
|
+
const SPEEDS: Record<string, number> = { '1×': 1, '0.5×': 0.5, '0.25×': 0.25, '0.1×': 0.1 };
|
|
14
|
+
|
|
15
|
+
// The View dials every prototype gets, in the Space and the app shell alike.
|
|
16
|
+
// Theme mirrors @kit/prefs both ways, so a product setting or ?theme= moves the dial too.
|
|
17
|
+
export function ViewTools() {
|
|
18
|
+
const appearance = useAppearance();
|
|
19
|
+
const [config] = useState(() => ({
|
|
20
|
+
theme: { type: 'select' as const, options: ['system', 'light', 'dark'], default: appearance },
|
|
21
|
+
reduceMotion: prefersReducedMotion(),
|
|
22
|
+
speed: { type: 'select' as const, options: Object.keys(SPEEDS), default: '1×' },
|
|
23
|
+
}));
|
|
24
|
+
useLayoutEffect(ensureDialRoot, []);
|
|
25
|
+
const { values: v, setValue } = useDialKitController('View', config, {
|
|
26
|
+
id: 'view',
|
|
27
|
+
defaultCollapsed: true,
|
|
28
|
+
});
|
|
29
|
+
useEffect(() => setAppearance(v.theme as Appearance), [v.theme]);
|
|
30
|
+
useEffect(() => setValue('theme', appearance), [appearance, setValue]);
|
|
31
|
+
useEffect(() => setForceReduce(v.reduceMotion), [v.reduceMotion]);
|
|
32
|
+
useEffect(() => setSpeed(SPEEDS[v.speed] ?? 1), [v.speed]);
|
|
33
|
+
return null;
|
|
34
|
+
}
|
package/kit/base.css
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/* The floor every prototype stands on. Reduced motion and theme are
|
|
2
|
+
attributes on <html> (see prefs.ts), so CSS and JS read one source. */
|
|
3
|
+
|
|
4
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
5
|
+
/* The platform layer on a phone: no tap flash (every control has its own
|
|
6
|
+
press state), no pull-to-refresh or rubber band on an app shell. */
|
|
7
|
+
html { height: 100%; -webkit-text-size-adjust: 100%; -webkit-tap-highlight-color: transparent; overscroll-behavior: none; }
|
|
8
|
+
body { margin: 0; min-height: 100%; font-family: var(--font); font-size: 14px; line-height: 20px; color: var(--text-primary); background: var(--bg-primary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overscroll-behavior: none; }
|
|
9
|
+
h1, h2, h3, h4, h5, h6, p, dl, dd, ul, ol, figure, blockquote { margin: 0; }
|
|
10
|
+
button, input, select, textarea { font: inherit; color: inherit; }
|
|
11
|
+
a { color: inherit; }
|
|
12
|
+
/* No 300ms tap wait on a control; a long press never selects a control's label. */
|
|
13
|
+
button, a, input, select, textarea, [role="button"], [role="tab"], [role="menuitem"] { touch-action: manipulation; }
|
|
14
|
+
button, [role="button"], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="option"] { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
|
15
|
+
/* iOS zooms into any field under 16px and never zooms back: the type steps up where the pointer is a finger. */
|
|
16
|
+
@media (pointer: coarse), (max-width: 767px) { input:not([type="checkbox"]):not([type="radio"]), select, textarea { font-size: 16px; } }
|
|
17
|
+
.icon { display: inline-block; flex: none; vertical-align: middle; }
|
|
18
|
+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
19
|
+
.num, .tabular { font-variant-numeric: tabular-nums; }
|
|
20
|
+
|
|
21
|
+
/* A transparent outline still paints in forced-colors mode, where box-shadow does not. */
|
|
22
|
+
:focus-visible { outline: 2px solid transparent; outline-offset: 2px; box-shadow: var(--focus-ring); }
|
|
23
|
+
|
|
24
|
+
/* Theme swaps land in one frame instead of every fill easing at once. */
|
|
25
|
+
.no-tx, .no-tx *, .no-tx *::before, .no-tx *::after { transition: none !important; }
|
|
26
|
+
|
|
27
|
+
:root[data-motion="reduce"] { scroll-behavior: auto; }
|
package/kit/dials.css
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* DialKit is harness chrome: the hub's Geist and Geist Mono, not the
|
|
2
|
+
library's system-ui and not the project's face. */
|
|
3
|
+
.dialkit-root, .dialkit-shortcut-pill, .dialkit-version-overlay { font-family: var(--sys-font); }
|
|
4
|
+
.dialkit-image-control, .dialkit-image-popover { font-family: var(--sys-font); }
|
|
5
|
+
:is(.dialkit-slider-value, .dialkit-slider-input, .dialkit-pad-value, .dialkit-color-value, .dialkit-color-css-input, .dialkit-shortcuts-row-key, .dialkit-timeline-time, .dialkit-timeline-tick-label, .dialkit-timeline-loop-infinity, .dialkit-timeline-clip-duration, .dialkit-timeline-playhead-flag) { font-family: var(--sys-font-mono); }
|
|
6
|
+
|
|
7
|
+
/* The dial hint: one tooltip for every DialKit row that has one, drawn in the
|
|
8
|
+
panel's own dark material. Fixed under the row, fades and settles 2px. */
|
|
9
|
+
.dial-hint {
|
|
10
|
+
position: fixed;
|
|
11
|
+
z-index: 2147483647;
|
|
12
|
+
max-width: 260px;
|
|
13
|
+
padding: 6px 10px;
|
|
14
|
+
border-radius: 6px;
|
|
15
|
+
background: var(--harness-bg);
|
|
16
|
+
color: var(--harness-fg);
|
|
17
|
+
border: 1px solid var(--harness-border);
|
|
18
|
+
box-shadow: var(--harness-shadow);
|
|
19
|
+
font: 500 12px/16px var(--sys-font);
|
|
20
|
+
letter-spacing: 0;
|
|
21
|
+
text-wrap: pretty;
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
opacity: 0;
|
|
24
|
+
transform: translateY(-2px) scale(0.98);
|
|
25
|
+
transition: opacity 125ms var(--ease-out), transform 125ms var(--ease-out);
|
|
26
|
+
}
|
|
27
|
+
.dial-hint.is-on { opacity: 1; transform: none; }
|
|
28
|
+
.dial-hint:not(.is-on) { transition-duration: 90ms; }
|
|
29
|
+
:root[data-motion="reduce"] .dial-hint { transform: none; transition: opacity 120ms var(--ease-hover); }
|
|
30
|
+
|
|
31
|
+
/* The walkthrough link: the panel's last row, drawn as one of DialKit's own
|
|
32
|
+
buttons so it reads as part of the panel, with the arrow that says new tab. */
|
|
33
|
+
.dial-guide {
|
|
34
|
+
display: flex;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: space-between;
|
|
37
|
+
gap: 8px;
|
|
38
|
+
margin: 12px 0 0;
|
|
39
|
+
padding: 10px 16px;
|
|
40
|
+
border-radius: var(--dial-radius);
|
|
41
|
+
background: var(--dial-surface);
|
|
42
|
+
color: var(--dial-text-secondary);
|
|
43
|
+
font-size: 13px;
|
|
44
|
+
line-height: 20px;
|
|
45
|
+
font-weight: 500;
|
|
46
|
+
font-family: inherit;
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
transition: background-color var(--dur-hover) var(--ease-hover), color var(--dur-hover) var(--ease-hover), transform var(--dur-press-out) var(--ease-press);
|
|
49
|
+
}
|
|
50
|
+
.dial-guide:hover { background: var(--dial-surface-hover); color: var(--dial-text-primary); }
|
|
51
|
+
.dial-guide:active { transform: scale(0.97); }
|
|
52
|
+
.dial-guide:focus-visible { outline: 2px solid var(--dial-focus-ring); outline-offset: -2px; }
|
|
53
|
+
:root[data-motion="reduce"] .dial-guide:active { transform: none; }
|
|
54
|
+
/* The panel has no bottom padding, so the last row brings its own. */
|
|
55
|
+
.dialkit-folder-root > .dialkit-folder-content:has(> .dial-guide) { padding-bottom: 12px; }
|
|
56
|
+
|
|
57
|
+
/* DialKit draws Copy parameters and Save version at the top of every group,
|
|
58
|
+
inside its sticky header. Here they close the group instead: the header
|
|
59
|
+
gives up its box so its title and the toolbar can be ordered around the
|
|
60
|
+
controls, the title keeps the sticky job, and DialKit's own fold still
|
|
61
|
+
finds the toolbar where it left it. */
|
|
62
|
+
.dialkit-panel .dialkit-folder.dialkit-folder-section { display: flex; flex-direction: column; }
|
|
63
|
+
.dialkit-panel .dialkit-folder.dialkit-folder-section > .dialkit-folder-header { display: contents; }
|
|
64
|
+
.dialkit-panel .dialkit-folder.dialkit-folder-section > .dialkit-folder-header > .dialkit-folder-header-top {
|
|
65
|
+
order: 0;
|
|
66
|
+
position: sticky;
|
|
67
|
+
top: var(--dial-panel-header-height, 0px);
|
|
68
|
+
z-index: 2;
|
|
69
|
+
background: var(--dial-glass-bg);
|
|
70
|
+
cursor: pointer;
|
|
71
|
+
user-select: none;
|
|
72
|
+
}
|
|
73
|
+
.dialkit-panel .dialkit-folder.dialkit-folder-section > .dialkit-folder-header > .dialkit-folder-header-top::before {
|
|
74
|
+
content: '';
|
|
75
|
+
position: absolute;
|
|
76
|
+
inset: 0 -12px;
|
|
77
|
+
z-index: -1;
|
|
78
|
+
background: var(--dial-glass-bg);
|
|
79
|
+
pointer-events: none;
|
|
80
|
+
}
|
|
81
|
+
.dialkit-panel .dialkit-folder.dialkit-folder-section > .dialkit-folder-content { order: 1; }
|
|
82
|
+
.dialkit-panel .dialkit-folder.dialkit-folder-section > .dialkit-folder-header > .dialkit-panel-section-toolbar-clip { order: 2; }
|
|
83
|
+
.dialkit-panel .dialkit-folder-section > .dialkit-folder-header > .dialkit-panel-section-toolbar-clip > .dialkit-panel-section-toolbar {
|
|
84
|
+
padding-top: 0;
|
|
85
|
+
padding-bottom: 4px;
|
|
86
|
+
}
|
|
87
|
+
.dialkit-panel .dialkit-folder-section { --dial-section-header-height: 44px; }
|
|
88
|
+
/* Save version (the version gallery) is the group's first control; it goes
|
|
89
|
+
last, with its divider turned over to sit above it. */
|
|
90
|
+
.dialkit-panel .dialkit-folder-section .dialkit-folder-inner > .dialkit-versions-host { order: 1; }
|
|
91
|
+
.dialkit-panel .dialkit-folder-section .dialkit-versions {
|
|
92
|
+
padding-bottom: 0;
|
|
93
|
+
margin-bottom: 0;
|
|
94
|
+
border-bottom: 0;
|
|
95
|
+
padding-top: 12px;
|
|
96
|
+
margin-top: 4px;
|
|
97
|
+
border-top: 1px solid var(--dial-border);
|
|
98
|
+
}
|