@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
package/kit/dials.tsx
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ActionConfig,
|
|
3
|
+
type DialConfig,
|
|
4
|
+
DialRoot,
|
|
5
|
+
DialStore,
|
|
6
|
+
type DialValue,
|
|
7
|
+
type UseDialOptions,
|
|
8
|
+
useDialKit,
|
|
9
|
+
useDialKitController,
|
|
10
|
+
} from 'dialkit';
|
|
11
|
+
import 'dialkit/styles.css';
|
|
12
|
+
import './dials.css';
|
|
13
|
+
import { type RefObject, useLayoutEffect } from 'react';
|
|
14
|
+
import { createRoot } from 'react-dom/client';
|
|
15
|
+
import { iconMarkup } from './Icon';
|
|
16
|
+
|
|
17
|
+
// What the panel calls itself. DialKit hard-codes its own name as the root
|
|
18
|
+
// title; the prototypes say what the thing is instead.
|
|
19
|
+
export const DIALS_TITLE = 'Controls';
|
|
20
|
+
|
|
21
|
+
let mounted = false;
|
|
22
|
+
let parked = false;
|
|
23
|
+
let guide: string | null = null;
|
|
24
|
+
|
|
25
|
+
// The app shell parks its dials closed in the sidebar's empty corner, so the product stays in view.
|
|
26
|
+
export function parkDials() {
|
|
27
|
+
parked = true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The shell names the walkthrough for the open prototype's feature. It must
|
|
31
|
+
// run before the root mounts; the panel then opens with the link as its last row.
|
|
32
|
+
export const setGuide = (href: string) => {
|
|
33
|
+
guide = href;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// One DialRoot per page, in its own React root, however many panels register.
|
|
37
|
+
export function ensureDialRoot() {
|
|
38
|
+
if (mounted) return;
|
|
39
|
+
mounted = true;
|
|
40
|
+
const host = document.createElement('div');
|
|
41
|
+
host.dataset.harness = 'dials';
|
|
42
|
+
document.body.append(host);
|
|
43
|
+
const open = !parked && matchMedia('(min-width: 1180px)').matches;
|
|
44
|
+
// DialKit hides its panel in production builds unless told otherwise; the
|
|
45
|
+
// deployed prototypes are for tuning, so the dials ship with them.
|
|
46
|
+
createRoot(host).render(
|
|
47
|
+
<DialRoot
|
|
48
|
+
position={parked ? 'top-left' : 'top-right'}
|
|
49
|
+
theme="dark"
|
|
50
|
+
defaultOpen={open}
|
|
51
|
+
productionEnabled
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
// DialKit portals the panel to <body>; it appears once the first panel registers.
|
|
55
|
+
const settle = (root: HTMLElement) => {
|
|
56
|
+
retitle(root);
|
|
57
|
+
placeGuide(root);
|
|
58
|
+
new MutationObserver((records) => {
|
|
59
|
+
for (const { type, target } of records) {
|
|
60
|
+
// DialKit keeps the panel's scroll offset when it collapses, which pushes the trigger icon off-centre.
|
|
61
|
+
if (type === 'attributes' && target instanceof HTMLElement && target.dataset.collapsed === 'true') {
|
|
62
|
+
target.scrollTop = 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
retitle(root);
|
|
66
|
+
placeGuide(root);
|
|
67
|
+
}).observe(root, { subtree: true, childList: true, attributeFilter: ['data-collapsed'] });
|
|
68
|
+
};
|
|
69
|
+
const found = document.body.querySelector<HTMLElement>('.dialkit-root');
|
|
70
|
+
if (found) settle(found);
|
|
71
|
+
else {
|
|
72
|
+
const watch = new MutationObserver(() => {
|
|
73
|
+
const root = document.body.querySelector<HTMLElement>('.dialkit-root');
|
|
74
|
+
if (!root) return;
|
|
75
|
+
watch.disconnect();
|
|
76
|
+
settle(root);
|
|
77
|
+
});
|
|
78
|
+
watch.observe(document.body, { childList: true });
|
|
79
|
+
}
|
|
80
|
+
installHints();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The root header renders the library's name as its title and its accessible
|
|
84
|
+
// name, and mounts the title again on every open.
|
|
85
|
+
function retitle(root: HTMLElement) {
|
|
86
|
+
const head = root.querySelector<HTMLElement>(
|
|
87
|
+
'.dialkit-folder-root > .dialkit-folder-header .dialkit-folder-header-top',
|
|
88
|
+
);
|
|
89
|
+
if (head && head.getAttribute('aria-label') !== DIALS_TITLE) head.setAttribute('aria-label', DIALS_TITLE);
|
|
90
|
+
const title = root.querySelector<HTMLElement>('.dialkit-folder-title-root');
|
|
91
|
+
if (title && title.textContent !== DIALS_TITLE) title.textContent = DIALS_TITLE;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The walkthrough is the panel's last row, under every group. DialKit remounts
|
|
95
|
+
// the content on open and adds groups as panels register, so the link is put
|
|
96
|
+
// back whenever it is missing and moved down whenever a group lands below it.
|
|
97
|
+
function placeGuide(root: HTMLElement) {
|
|
98
|
+
if (!guide) return;
|
|
99
|
+
const content = root.querySelector('.dialkit-folder-root > .dialkit-folder-content');
|
|
100
|
+
if (!content) return;
|
|
101
|
+
const placed = content.querySelector(':scope > .dial-guide');
|
|
102
|
+
if (placed) {
|
|
103
|
+
if (content.lastElementChild !== placed) content.append(placed);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const link = document.createElement('a');
|
|
107
|
+
link.className = 'dial-guide';
|
|
108
|
+
link.href = guide;
|
|
109
|
+
link.target = '_blank';
|
|
110
|
+
link.rel = 'noopener';
|
|
111
|
+
link.innerHTML = `<span>Open the walkthrough</span>${iconMarkup('arrow-up-right', 16)}`;
|
|
112
|
+
content.append(link);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Hints: what each dial does, shown on hover or focus.
|
|
116
|
+
// DialKit has no per-control description, so the kit keeps one per panel and
|
|
117
|
+
// label and draws its own tooltip under the hovered row. A nested folder's
|
|
118
|
+
// controls take a nested object, keyed the same way as the config.
|
|
119
|
+
|
|
120
|
+
export type DialHints<T> = {
|
|
121
|
+
[K in keyof T & string]?: T[K] extends boolean | number | string | readonly unknown[] | { type: string }
|
|
122
|
+
? string
|
|
123
|
+
: DialHints<T[K]>;
|
|
124
|
+
};
|
|
125
|
+
type DialOptions<T> = UseDialOptions & { hints?: DialHints<T> };
|
|
126
|
+
|
|
127
|
+
const hints = new Map<string, Record<string, string>>();
|
|
128
|
+
// DialKit's own key-to-label rule, so a hint keyed by `hingeAngle` meets the row labelled "Hinge Angle".
|
|
129
|
+
const labelOf = (key: string) =>
|
|
130
|
+
key
|
|
131
|
+
.replace(/([A-Z])/g, ' $1')
|
|
132
|
+
.replace(/^./, (s) => s.toUpperCase())
|
|
133
|
+
.trim();
|
|
134
|
+
const PATH_SEP = ' / ';
|
|
135
|
+
|
|
136
|
+
// An action's row is its button, labelled by the config's `label` when it has one.
|
|
137
|
+
const actionLabel = (c: unknown) =>
|
|
138
|
+
c && typeof c === 'object' && (c as ActionConfig).type === 'action' ? (c as ActionConfig).label : undefined;
|
|
139
|
+
|
|
140
|
+
function flattenHints(
|
|
141
|
+
h: Record<string, unknown>,
|
|
142
|
+
cfg: DialConfig,
|
|
143
|
+
into: Record<string, string>,
|
|
144
|
+
prefix = '',
|
|
145
|
+
) {
|
|
146
|
+
for (const [k, v] of Object.entries(h)) {
|
|
147
|
+
const label = prefix + (actionLabel(cfg[k]) ?? labelOf(k));
|
|
148
|
+
if (typeof v === 'string') into[label] = v;
|
|
149
|
+
else if (v && typeof v === 'object') {
|
|
150
|
+
flattenHints(v as Record<string, unknown>, (cfg[k] ?? {}) as DialConfig, into, label + PATH_SEP);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function registerHints(panel: string, config: DialConfig, h?: Record<string, unknown>) {
|
|
156
|
+
if (!h) return;
|
|
157
|
+
const byLabel: Record<string, string> = { ...hints.get(panel) };
|
|
158
|
+
flattenHints(h, config, byLabel);
|
|
159
|
+
hints.set(panel, byLabel);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const rowLabel = (row: HTMLElement) =>
|
|
163
|
+
row.getAttribute('aria-label') ??
|
|
164
|
+
(
|
|
165
|
+
row.querySelector('.dialkit-labeled-control-label')?.firstChild ??
|
|
166
|
+
row.querySelector('.dialkit-select-label') ??
|
|
167
|
+
row
|
|
168
|
+
).textContent?.trim() ??
|
|
169
|
+
'';
|
|
170
|
+
|
|
171
|
+
/** The row's folders, innermost first, up to its panel (the section), and the panel's title. */
|
|
172
|
+
function rowPlace(row: HTMLElement): { path: string[]; panel: string | undefined } {
|
|
173
|
+
const path: string[] = [];
|
|
174
|
+
for (
|
|
175
|
+
let f = row.closest<HTMLElement>('.dialkit-folder');
|
|
176
|
+
f;
|
|
177
|
+
f = f.parentElement?.closest('.dialkit-folder') ?? null
|
|
178
|
+
) {
|
|
179
|
+
const title = f
|
|
180
|
+
.querySelector(':scope > .dialkit-folder-header .dialkit-folder-title')
|
|
181
|
+
?.textContent?.trim();
|
|
182
|
+
if (f.classList.contains('dialkit-folder-section')) return { path, panel: title };
|
|
183
|
+
if (title) path.unshift(title);
|
|
184
|
+
}
|
|
185
|
+
return { path, panel: undefined };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function hintFor(el: Element) {
|
|
189
|
+
const row = el.closest<HTMLElement>(
|
|
190
|
+
'.dialkit-slider, .dialkit-labeled-control, .dialkit-select-row, .dialkit-button',
|
|
191
|
+
);
|
|
192
|
+
if (!row) return null;
|
|
193
|
+
const { path, panel } = rowPlace(row);
|
|
194
|
+
const text = panel ? hints.get(panel)?.[[...path, rowLabel(row)].join(PATH_SEP)] : undefined;
|
|
195
|
+
return text ? { row, text } : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The first hint waits; once one is up, moving to the next row shows it at once (the Tooltip Rule).
|
|
199
|
+
// DialKit portals its panel to <body>, so the listeners sit on the document and keep to .dialkit-root.
|
|
200
|
+
function installHints() {
|
|
201
|
+
const tip = document.createElement('div');
|
|
202
|
+
tip.className = 'dial-hint';
|
|
203
|
+
tip.setAttribute('role', 'tooltip');
|
|
204
|
+
tip.setAttribute('aria-hidden', 'true');
|
|
205
|
+
document.body.append(tip);
|
|
206
|
+
let shownFor: HTMLElement | null = null;
|
|
207
|
+
let timer = 0;
|
|
208
|
+
let lastHidden = 0;
|
|
209
|
+
|
|
210
|
+
const show = (row: HTMLElement, text: string) => {
|
|
211
|
+
const r = row.getBoundingClientRect();
|
|
212
|
+
const onLeft = r.left < innerWidth / 2;
|
|
213
|
+
tip.textContent = text;
|
|
214
|
+
tip.style.top = `${r.bottom + 6}px`;
|
|
215
|
+
tip.style.left = onLeft ? `${r.left}px` : '';
|
|
216
|
+
tip.style.right = onLeft ? '' : `${innerWidth - r.right}px`;
|
|
217
|
+
tip.style.transformOrigin = onLeft ? 'top left' : 'top right';
|
|
218
|
+
tip.classList.add('is-on');
|
|
219
|
+
tip.removeAttribute('aria-hidden');
|
|
220
|
+
shownFor = row;
|
|
221
|
+
};
|
|
222
|
+
const hide = () => {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
if (shownFor) lastHidden = performance.now();
|
|
225
|
+
shownFor = null;
|
|
226
|
+
tip.classList.remove('is-on');
|
|
227
|
+
tip.setAttribute('aria-hidden', 'true');
|
|
228
|
+
};
|
|
229
|
+
const aim = (target: Element | null) => {
|
|
230
|
+
const hit = target?.closest('.dialkit-root') ? hintFor(target) : null;
|
|
231
|
+
if (!hit) return hide();
|
|
232
|
+
if (hit.row === shownFor) return;
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
const instant = shownFor || performance.now() - lastHidden < 500;
|
|
235
|
+
if (instant) show(hit.row, hit.text);
|
|
236
|
+
else timer = window.setTimeout(() => show(hit.row, hit.text), 400);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
document.addEventListener('pointerover', (e) => aim(e.target as Element));
|
|
240
|
+
document.addEventListener('pointerdown', hide);
|
|
241
|
+
document.addEventListener('focusin', (e) => aim(e.target as Element));
|
|
242
|
+
document.addEventListener('focusout', hide);
|
|
243
|
+
document.addEventListener('scroll', hide, true);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function useDials<T extends DialConfig>(name: string, config: T, options?: DialOptions<T>) {
|
|
247
|
+
useLayoutEffect(ensureDialRoot, []);
|
|
248
|
+
const { hints: h, ...rest } = options ?? {};
|
|
249
|
+
registerHints(name, config, h as Record<string, unknown> | undefined);
|
|
250
|
+
return useDialKit(name, config, rest);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The same panel with its controller, for dials that code also sets (every toggle at once).
|
|
254
|
+
export function useDialsController<T extends DialConfig>(name: string, config: T, options?: DialOptions<T>) {
|
|
255
|
+
useLayoutEffect(ensureDialRoot, []);
|
|
256
|
+
const { hints: h, ...rest } = options ?? {};
|
|
257
|
+
registerHints(name, config, h as Record<string, unknown> | undefined);
|
|
258
|
+
return useDialKitController(name, config, rest);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Dial values as CSS variables on an element: `--key`, a folder's as `--folder-key`,
|
|
262
|
+
// a toggle as 1 or 0, an easing as the curve plus `--key-duration` in ms.
|
|
263
|
+
/** A number or string as written, a toggle as 1 or 0; null for anything else. */
|
|
264
|
+
const plainValue = (value: unknown): string | null =>
|
|
265
|
+
typeof value === 'boolean'
|
|
266
|
+
? value
|
|
267
|
+
? '1'
|
|
268
|
+
: '0'
|
|
269
|
+
: typeof value === 'number' || typeof value === 'string'
|
|
270
|
+
? String(value)
|
|
271
|
+
: null;
|
|
272
|
+
|
|
273
|
+
type Easing = { ease: number[]; duration: number };
|
|
274
|
+
const isEasing = (v: object): v is Easing => 'ease' in v && 'duration' in v;
|
|
275
|
+
|
|
276
|
+
export function writeDialVars(el: HTMLElement, values: Record<string, unknown>, prefix = '') {
|
|
277
|
+
for (const [key, value] of Object.entries(values)) {
|
|
278
|
+
const prop = `--${prefix}${key}`;
|
|
279
|
+
const plain = plainValue(value);
|
|
280
|
+
if (plain !== null) el.style.setProperty(prop, plain);
|
|
281
|
+
else if (!value || typeof value !== 'object') continue;
|
|
282
|
+
else if (isEasing(value)) {
|
|
283
|
+
el.style.setProperty(prop, `cubic-bezier(${value.ease.join(', ')})`);
|
|
284
|
+
el.style.setProperty(`${prop}-duration`, String(Math.round(value.duration * 1000)));
|
|
285
|
+
} else if (!('type' in value)) writeDialVars(el, value as Record<string, unknown>, `${prefix}${key}-`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
type VarsProps<T extends DialConfig> = {
|
|
290
|
+
name: string;
|
|
291
|
+
config: T;
|
|
292
|
+
target?: RefObject<HTMLElement | null>;
|
|
293
|
+
options?: DialOptions<T>;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// Values land in CSS variables, not state, so a slider re-renders nothing.
|
|
297
|
+
export function DialVars<T extends DialConfig>({ name, config, target, options }: VarsProps<T>) {
|
|
298
|
+
const values = useDials(name, config, options);
|
|
299
|
+
useLayoutEffect(() => {
|
|
300
|
+
writeDialVars(target?.current ?? document.documentElement, values as Record<string, unknown>);
|
|
301
|
+
}, [values, target]);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// The dial state a review comment carries, and the way back to it.
|
|
306
|
+
|
|
307
|
+
/** Every registered panel's values, by panel name. */
|
|
308
|
+
export function snapshotDials(): Record<string, Record<string, unknown>> {
|
|
309
|
+
return Object.fromEntries(DialStore.getPanels('panel').map((p) => [p.name, DialStore.getValues(p.id)]));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Sets each named panel's values; returns the names of panels this page does not have. */
|
|
313
|
+
export function restoreDials(snapshot: Record<string, Record<string, unknown>>): string[] {
|
|
314
|
+
const panels = new Map(DialStore.getPanels('panel').map((p) => [p.name, p.id]));
|
|
315
|
+
const missing: string[] = [];
|
|
316
|
+
for (const [name, values] of Object.entries(snapshot)) {
|
|
317
|
+
const id = panels.get(name);
|
|
318
|
+
if (id) DialStore.updateValues(id, values as Record<string, DialValue>);
|
|
319
|
+
else missing.push(name);
|
|
320
|
+
}
|
|
321
|
+
return missing;
|
|
322
|
+
}
|
package/kit/fixtures.ts
ADDED
package/kit/format.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Words for numbers and dates, the same in the shell and every prototype.
|
|
2
|
+
|
|
3
|
+
/** An ISO date (2026-10-28) as "28 Oct 2026"; empty for an empty string. Noon, so no timezone moves the day. */
|
|
4
|
+
export const fmtDate = (iso: string) =>
|
|
5
|
+
iso
|
|
6
|
+
? new Date(`${iso}T12:00:00`).toLocaleDateString('en-GB', {
|
|
7
|
+
day: 'numeric',
|
|
8
|
+
month: 'short',
|
|
9
|
+
year: 'numeric',
|
|
10
|
+
})
|
|
11
|
+
: '';
|
|
12
|
+
|
|
13
|
+
export const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
package/kit/harness.css
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/* Harness chrome, not part of any design under review. The picker is
|
|
2
|
+
emil-prototype's PICKER.md, verbatim except the face, which is the hub's
|
|
3
|
+
Geist (_shell/system.css). Never restyle it otherwise. */
|
|
4
|
+
|
|
5
|
+
.proto-picker {
|
|
6
|
+
position: fixed;
|
|
7
|
+
bottom: 24px;
|
|
8
|
+
left: 50%;
|
|
9
|
+
transform: translateX(-50%);
|
|
10
|
+
z-index: 2147483647;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 2px;
|
|
14
|
+
padding: 4px;
|
|
15
|
+
border-radius: 999px;
|
|
16
|
+
background: oklch(0.145 0 0 / 0.82);
|
|
17
|
+
-webkit-backdrop-filter: blur(12px) saturate(1.4);
|
|
18
|
+
backdrop-filter: blur(12px) saturate(1.4);
|
|
19
|
+
box-shadow:
|
|
20
|
+
0 0 0 1px oklch(1 0 0 / 0.08) inset,
|
|
21
|
+
0 8px 24px oklch(0 0 0 / 0.24),
|
|
22
|
+
0 2px 6px oklch(0 0 0 / 0.12);
|
|
23
|
+
font-family: var(--sys-font);
|
|
24
|
+
font-size: 13px;
|
|
25
|
+
line-height: 1;
|
|
26
|
+
-webkit-font-smoothing: antialiased;
|
|
27
|
+
user-select: none;
|
|
28
|
+
-webkit-user-select: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.proto-picker-highlight {
|
|
32
|
+
position: absolute;
|
|
33
|
+
top: 4px;
|
|
34
|
+
left: 0;
|
|
35
|
+
height: 28px;
|
|
36
|
+
border-radius: 999px;
|
|
37
|
+
background: oklch(1 0 0 / 0.12);
|
|
38
|
+
will-change: transform;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.proto-picker[data-ready] .proto-picker-highlight {
|
|
42
|
+
transition:
|
|
43
|
+
transform 250ms cubic-bezier(0.23, 1, 0.32, 1),
|
|
44
|
+
width 250ms cubic-bezier(0.23, 1, 0.32, 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
@media (prefers-reduced-motion: reduce) {
|
|
48
|
+
.proto-picker[data-ready] .proto-picker-highlight { transition: none; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.proto-picker-item {
|
|
52
|
+
position: relative;
|
|
53
|
+
display: flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
height: 28px;
|
|
56
|
+
padding: 0 12px;
|
|
57
|
+
border: 0;
|
|
58
|
+
border-radius: 999px;
|
|
59
|
+
background: transparent;
|
|
60
|
+
color: oklch(1 0 0 / 0.55);
|
|
61
|
+
font: inherit;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
transition: color 150ms ease-out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.proto-picker-item:hover {
|
|
67
|
+
color: oklch(1 0 0 / 0.85);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.proto-picker-item:active {
|
|
71
|
+
transform: scale(0.97);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.proto-picker-item:focus-visible {
|
|
75
|
+
outline: 2px solid oklch(1 0 0 / 0.4);
|
|
76
|
+
outline-offset: 2px;
|
|
77
|
+
box-shadow: none;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.proto-picker-item[data-active] {
|
|
81
|
+
color: oklch(1 0 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.proto-picker-divider {
|
|
85
|
+
width: 1px;
|
|
86
|
+
height: 16px;
|
|
87
|
+
margin: 0 4px;
|
|
88
|
+
background: oklch(1 0 0 / 0.12);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.proto-picker-replay {
|
|
92
|
+
padding: 0 10px;
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.proto-picker[data-position="top"] {
|
|
97
|
+
bottom: auto;
|
|
98
|
+
top: 24px;
|
|
99
|
+
}
|
package/kit/hooks.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useStoredState<T>(key: string, initial: T) {
|
|
4
|
+
const [value, setValue] = useState<T>(() => {
|
|
5
|
+
try {
|
|
6
|
+
const raw = localStorage.getItem(key);
|
|
7
|
+
return raw == null ? initial : (JSON.parse(raw) as T);
|
|
8
|
+
} catch {
|
|
9
|
+
return initial;
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
try {
|
|
14
|
+
localStorage.setItem(key, JSON.stringify(value));
|
|
15
|
+
} catch {}
|
|
16
|
+
}, [key, value]);
|
|
17
|
+
return [value, setValue] as const;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const onHash = (fn: () => void) => {
|
|
21
|
+
addEventListener('hashchange', fn);
|
|
22
|
+
return () => removeEventListener('hashchange', fn);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Hash routes keep a prototype's own pages out of the shell's path:
|
|
26
|
+
// /sample-list/#/items/42?tab=overview
|
|
27
|
+
export function useHashRoute(fallback = '#/') {
|
|
28
|
+
const hash = useSyncExternalStore(onHash, () => location.hash || fallback);
|
|
29
|
+
const [path, search = ''] = hash.split('?');
|
|
30
|
+
const query = Object.fromEntries(new URLSearchParams(search));
|
|
31
|
+
const go = useCallback((to: string) => {
|
|
32
|
+
location.hash = to;
|
|
33
|
+
}, []);
|
|
34
|
+
return { hash, path, query, go };
|
|
35
|
+
}
|