@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,132 @@
|
|
|
1
|
+
// Where a comment points: the prototype's state (URL and every dial) and an
|
|
2
|
+
// element on the page, and the way back to both. The selector is a path of
|
|
3
|
+
// tag:nth-of-type steps, stopping at a stable id, because prototype markup
|
|
4
|
+
// rarely carries test ids and a pin must survive a reload.
|
|
5
|
+
import { restoreDials, snapshotDials } from '@kit/dials';
|
|
6
|
+
import type { Anchor } from '../../core/review/model';
|
|
7
|
+
|
|
8
|
+
const RESTORE_KEY = 'hub-review-restore';
|
|
9
|
+
const RESTORE_WAIT_MS = 3000;
|
|
10
|
+
// React's useId values (:r1:, «r1») change between renders of different trees; never anchor on them.
|
|
11
|
+
const STABLE_ID = /^[A-Za-z][\w-]*$/;
|
|
12
|
+
|
|
13
|
+
export const isHarness = (el: Element | null) =>
|
|
14
|
+
!!el?.closest('[data-harness], .dialkit-root, .proto-picker');
|
|
15
|
+
|
|
16
|
+
/** The element's own id when it is stable and unique on the page, so a path can stop there. */
|
|
17
|
+
function stableId(node: Element): string | null {
|
|
18
|
+
if (!node.id || !STABLE_ID.test(node.id)) return null;
|
|
19
|
+
const id = `#${CSS.escape(node.id)}`;
|
|
20
|
+
return document.querySelectorAll(id).length === 1 ? id : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** tag, or tag:nth-of-type(n) when siblings share the tag. */
|
|
24
|
+
function step(node: Element): string {
|
|
25
|
+
const same = node.parentElement
|
|
26
|
+
? [...node.parentElement.children].filter((c) => c.localName === node.localName)
|
|
27
|
+
: [];
|
|
28
|
+
return same.length > 1 ? `${node.localName}:nth-of-type(${same.indexOf(node) + 1})` : node.localName;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function selectorFor(el: Element): string | null {
|
|
32
|
+
if (isHarness(el) || el === document.body || el === document.documentElement) return null;
|
|
33
|
+
const steps: string[] = [];
|
|
34
|
+
for (let node: Element | null = el; node && node !== document.body; node = node.parentElement) {
|
|
35
|
+
const id = stableId(node);
|
|
36
|
+
if (id) return [id, ...steps].join(' > ');
|
|
37
|
+
steps.unshift(step(node));
|
|
38
|
+
}
|
|
39
|
+
return `body > ${steps.join(' > ')}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveTarget(selector: string | null): Element | null {
|
|
43
|
+
if (!selector) return null;
|
|
44
|
+
try {
|
|
45
|
+
return document.querySelector(selector);
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const currentUrl = () => location.pathname + location.search + location.hash;
|
|
52
|
+
|
|
53
|
+
export function captureAnchor(
|
|
54
|
+
where: { prototype: string; feature: string },
|
|
55
|
+
target: Element | null,
|
|
56
|
+
point: { x: number; y: number } | null,
|
|
57
|
+
): Anchor {
|
|
58
|
+
const selector = target ? selectorFor(target) : null;
|
|
59
|
+
let offset: Anchor['offset'] = null;
|
|
60
|
+
if (target && selector && point) {
|
|
61
|
+
const r = target.getBoundingClientRect();
|
|
62
|
+
const clamp = (v: number) => Math.min(1, Math.max(0, v));
|
|
63
|
+
offset = {
|
|
64
|
+
x: clamp(r.width ? (point.x - r.left) / r.width : 0.5),
|
|
65
|
+
y: clamp(r.height ? (point.y - r.top) / r.height : 0.5),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...where,
|
|
70
|
+
url: currentUrl(),
|
|
71
|
+
dials: snapshotDials(),
|
|
72
|
+
target: selector,
|
|
73
|
+
offset,
|
|
74
|
+
viewport: { width: innerWidth, height: innerHeight },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where a pin sits now, in viewport pixels, or null when its element is not on the page. */
|
|
79
|
+
export function pinPoint(anchor: Anchor): { x: number; y: number } | null {
|
|
80
|
+
const el = resolveTarget(anchor.target);
|
|
81
|
+
if (!el) return null;
|
|
82
|
+
const r = el.getBoundingClientRect();
|
|
83
|
+
if (!r.width && !r.height) return null;
|
|
84
|
+
const o = anchor.offset ?? { x: 0.5, y: 0.5 };
|
|
85
|
+
return { x: r.left + r.width * o.x, y: r.top + r.height * o.y };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function settleDials(dials: Anchor['dials'], then: (missing: string[]) => void) {
|
|
89
|
+
// Panels register as the prototype renders, so retry until every named one exists or time runs out.
|
|
90
|
+
const started = performance.now();
|
|
91
|
+
const tick = () => {
|
|
92
|
+
const missing = restoreDials(dials);
|
|
93
|
+
if (!missing.length || performance.now() - started > RESTORE_WAIT_MS) then(missing);
|
|
94
|
+
else requestAnimationFrame(tick);
|
|
95
|
+
};
|
|
96
|
+
tick();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Reopens a comment's state. A different path or query reloads the page
|
|
101
|
+
* (the variant picker and the shell read them on load), carrying the dials
|
|
102
|
+
* through sessionStorage; a different hash only changes the route.
|
|
103
|
+
*/
|
|
104
|
+
export function openAnchor(anchor: Anchor, commentId: string, done: (missing: string[]) => void): void {
|
|
105
|
+
const url = new URL(anchor.url, location.origin);
|
|
106
|
+
if (url.pathname !== location.pathname || url.search !== location.search) {
|
|
107
|
+
try {
|
|
108
|
+
sessionStorage.setItem(RESTORE_KEY, JSON.stringify({ id: commentId, dials: anchor.dials }));
|
|
109
|
+
} catch {
|
|
110
|
+
// Storage off (a private window): the page still opens at the URL, without the dials.
|
|
111
|
+
}
|
|
112
|
+
location.assign(anchor.url);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (url.hash !== location.hash) location.hash = url.hash;
|
|
116
|
+
settleDials(anchor.dials, done);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** After a reload from openAnchor: the comment to focus, once its dials are set. */
|
|
120
|
+
export function resumeAnchor(done: (commentId: string, missing: string[]) => void): void {
|
|
121
|
+
let pending: { id: string; dials: Anchor['dials'] } | null = null;
|
|
122
|
+
try {
|
|
123
|
+
pending = JSON.parse(sessionStorage.getItem(RESTORE_KEY) ?? 'null');
|
|
124
|
+
sessionStorage.removeItem(RESTORE_KEY);
|
|
125
|
+
} catch {
|
|
126
|
+
// Storage off, or a value this page did not write: there is nothing to resume.
|
|
127
|
+
}
|
|
128
|
+
if (pending) {
|
|
129
|
+
const { id, dials } = pending;
|
|
130
|
+
settleDials(dials, (missing) => done(id, missing));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The review UI's one store: who is signed in, this prototype's comments and,
|
|
2
|
+
// for editors, the people. Components read it with useSyncExternalStore and
|
|
3
|
+
// call its actions; every call goes to /api/review (server/api.ts), and each
|
|
4
|
+
// answer is merged in place, so a write never refetches the list.
|
|
5
|
+
import { useSyncExternalStore } from 'react';
|
|
6
|
+
import type { Anchor, Comment, Person, Role } from '../../core/review/model';
|
|
7
|
+
|
|
8
|
+
export type Me = Omit<Person, 'epoch'>;
|
|
9
|
+
/** off: this site has no review store, so the UI stays out of the way. */
|
|
10
|
+
type Status = 'loading' | 'ready' | 'guest' | 'off';
|
|
11
|
+
|
|
12
|
+
type State = {
|
|
13
|
+
status: Status;
|
|
14
|
+
me: Me | null;
|
|
15
|
+
comments: Comment[];
|
|
16
|
+
people: Me[] | null;
|
|
17
|
+
error: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
class ApiError extends Error {
|
|
21
|
+
status: number;
|
|
22
|
+
problems: string[];
|
|
23
|
+
constructor(status: number, message: string, problems: string[] = []) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.problems = problems;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const API = '/api/review';
|
|
31
|
+
|
|
32
|
+
async function call<T>(
|
|
33
|
+
op: string,
|
|
34
|
+
body?: Record<string, unknown>,
|
|
35
|
+
params: Record<string, string> = {},
|
|
36
|
+
): Promise<T> {
|
|
37
|
+
const response = body
|
|
38
|
+
? await fetch(API, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'content-type': 'application/json' },
|
|
41
|
+
body: JSON.stringify({ op, ...body }),
|
|
42
|
+
credentials: 'same-origin',
|
|
43
|
+
})
|
|
44
|
+
: await fetch(`${API}?${new URLSearchParams({ op, ...params })}`, { credentials: 'same-origin' });
|
|
45
|
+
const json = (await response.json().catch(() => null)) as
|
|
46
|
+
| ({ error?: string; problems?: string[] } & T)
|
|
47
|
+
| null;
|
|
48
|
+
if (!json)
|
|
49
|
+
throw new ApiError(response.status, `The review API did not answer with JSON (${response.status}).`);
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
throw new ApiError(response.status, json.error ?? `Request failed (${response.status}).`, json.problems);
|
|
52
|
+
return json;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createReviewStore(prototype: string) {
|
|
56
|
+
let state: State = { status: 'loading', me: null, comments: [], people: null, error: null };
|
|
57
|
+
const listeners = new Set<() => void>();
|
|
58
|
+
const set = (patch: Partial<State>) => {
|
|
59
|
+
state = { ...state, ...patch };
|
|
60
|
+
for (const l of listeners) l();
|
|
61
|
+
};
|
|
62
|
+
const merge = (c: Comment) => {
|
|
63
|
+
const at = state.comments.findIndex((x) => x.id === c.id);
|
|
64
|
+
set({
|
|
65
|
+
comments: at < 0 ? [...state.comments, c] : state.comments.map((x) => (x.id === c.id ? c : x)),
|
|
66
|
+
error: null,
|
|
67
|
+
});
|
|
68
|
+
return c;
|
|
69
|
+
};
|
|
70
|
+
const mergePerson = (p: Me) => {
|
|
71
|
+
if (state.people)
|
|
72
|
+
set({
|
|
73
|
+
people: state.people.some((x) => x.id === p.id)
|
|
74
|
+
? state.people.map((x) => (x.id === p.id ? p : x))
|
|
75
|
+
: [...state.people, p],
|
|
76
|
+
});
|
|
77
|
+
return p;
|
|
78
|
+
};
|
|
79
|
+
// Every action reports its failure in the store, and rethrows so the caller keeps the user's input.
|
|
80
|
+
const attempt = async <T>(run: () => Promise<T>): Promise<T> => {
|
|
81
|
+
try {
|
|
82
|
+
return await run();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
set({
|
|
85
|
+
error:
|
|
86
|
+
error instanceof ApiError && error.problems.length
|
|
87
|
+
? error.problems.join('. ')
|
|
88
|
+
: String((error as Error).message),
|
|
89
|
+
});
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
subscribe(listener: () => void) {
|
|
96
|
+
listeners.add(listener);
|
|
97
|
+
return () => listeners.delete(listener);
|
|
98
|
+
},
|
|
99
|
+
get: () => state,
|
|
100
|
+
|
|
101
|
+
async load() {
|
|
102
|
+
try {
|
|
103
|
+
const { person } = await call<{ person: Me | null }>('me');
|
|
104
|
+
if (!person) return set({ status: 'guest', me: null });
|
|
105
|
+
const { comments } = await call<{ comments: Comment[] }>('comments', undefined, { prototype });
|
|
106
|
+
set({ status: 'ready', me: person, comments, error: null });
|
|
107
|
+
} catch (error) {
|
|
108
|
+
// 503 is a site without a review store; 404 is a static preview with no API at all.
|
|
109
|
+
if (error instanceof ApiError && (error.status === 503 || error.status === 404))
|
|
110
|
+
set({ status: 'off' });
|
|
111
|
+
else set({ status: 'ready', error: String((error as Error).message) });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
add: (body: string, anchor: Anchor, states: string[]) =>
|
|
116
|
+
attempt(async () =>
|
|
117
|
+
merge((await call<{ comment: Comment }>('comment.add', { body, anchor, states })).comment),
|
|
118
|
+
),
|
|
119
|
+
reply: (thread: string, body: string) =>
|
|
120
|
+
attempt(async () =>
|
|
121
|
+
merge((await call<{ comment: Comment }>('comment.reply', { prototype, thread, body })).comment),
|
|
122
|
+
),
|
|
123
|
+
edit: (id: string, body: string) =>
|
|
124
|
+
attempt(async () =>
|
|
125
|
+
merge((await call<{ comment: Comment }>('comment.edit', { prototype, id, body })).comment),
|
|
126
|
+
),
|
|
127
|
+
resolve: (id: string, resolved: boolean) =>
|
|
128
|
+
attempt(async () =>
|
|
129
|
+
merge((await call<{ comment: Comment }>('comment.resolve', { prototype, id, resolved })).comment),
|
|
130
|
+
),
|
|
131
|
+
triage: (id: string, triage: { kind: string | null; state: string | null }, states: string[]) =>
|
|
132
|
+
attempt(async () =>
|
|
133
|
+
merge(
|
|
134
|
+
(await call<{ comment: Comment }>('comment.triage', { prototype, id, triage, states })).comment,
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
remove: (id: string) =>
|
|
138
|
+
attempt(async () => {
|
|
139
|
+
const { removed } = await call<{ removed: string[] }>('comment.remove', { prototype, id });
|
|
140
|
+
set({ comments: state.comments.filter((c) => !removed.includes(c.id)) });
|
|
141
|
+
}),
|
|
142
|
+
|
|
143
|
+
loadPeople: () => attempt(async () => set({ people: (await call<{ people: Me[] }>('people')).people })),
|
|
144
|
+
invite: (name: string, team: string, role: Role) =>
|
|
145
|
+
attempt(async () => {
|
|
146
|
+
const r = await call<{ person: Me; link: string }>('people.invite', { name, team, role });
|
|
147
|
+
mergePerson(r.person);
|
|
148
|
+
return new URL(r.link, location.origin).href;
|
|
149
|
+
}),
|
|
150
|
+
reissue: (id: string) =>
|
|
151
|
+
attempt(async () => {
|
|
152
|
+
const r = await call<{ person: Me; link: string }>('people.reissue', { id });
|
|
153
|
+
mergePerson(r.person);
|
|
154
|
+
return new URL(r.link, location.origin).href;
|
|
155
|
+
}),
|
|
156
|
+
revoke: (id: string) =>
|
|
157
|
+
attempt(async () => mergePerson((await call<{ person: Me }>('people.revoke', { id })).person)),
|
|
158
|
+
clearError: () => set({ error: null }),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type ReviewStore = ReturnType<typeof createReviewStore>;
|
|
163
|
+
|
|
164
|
+
export const useReviewState = (store: ReviewStore) =>
|
|
165
|
+
useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Mounts the review layer in its own root beside the prototype, so a
|
|
2
|
+
// prototype that throws never takes the comments down with it, and the
|
|
3
|
+
// review code loads only on prototype pages.
|
|
4
|
+
import { createRoot } from 'react-dom/client';
|
|
5
|
+
import { Review } from './Review';
|
|
6
|
+
|
|
7
|
+
export function mountReview(props: { prototype: string; feature: string; states: string[] }) {
|
|
8
|
+
const host = document.createElement('div');
|
|
9
|
+
host.dataset.harness = 'review-root';
|
|
10
|
+
document.body.append(host);
|
|
11
|
+
createRoot(host).render(<Review {...props} />);
|
|
12
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/* The review layer. Harness material in both themes (--harness-*), so it
|
|
2
|
+
never reads as part of the design under review. The panel and launcher sit
|
|
3
|
+
above everything but the variant picker. Pins and the picking surface sit
|
|
4
|
+
below the dials panel (DialKit's 9999), because they belong to the page. */
|
|
5
|
+
|
|
6
|
+
.rv {
|
|
7
|
+
--rv-z: 2147483600;
|
|
8
|
+
--rv-z-page: 9000;
|
|
9
|
+
--rv-gap: 16px;
|
|
10
|
+
--rv-fg-strong: oklch(1 0 0);
|
|
11
|
+
--rv-fill: oklch(1 0 0 / 0.06);
|
|
12
|
+
--rv-fill-hover: oklch(1 0 0 / 0.1);
|
|
13
|
+
--rv-accent: oklch(0.92 0 0); /* light neutral on the dark panel */
|
|
14
|
+
--rv-on-accent: oklch(0.205 0 0);
|
|
15
|
+
--rv-mark: oklch(0.55 0 0); /* on the page: mid grey holds on light and dark */
|
|
16
|
+
--rv-pin: oklch(0.205 0 0);
|
|
17
|
+
--rv-danger: oklch(0.779 0.131 23.4);
|
|
18
|
+
font: 13px/18px var(--sys-font);
|
|
19
|
+
-webkit-font-smoothing: antialiased;
|
|
20
|
+
color: var(--harness-fg);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.rv button,
|
|
24
|
+
.rv input,
|
|
25
|
+
.rv select,
|
|
26
|
+
.rv textarea { font: inherit; color: inherit; }
|
|
27
|
+
|
|
28
|
+
.rv :focus-visible { outline: 2px solid var(--rv-accent); outline-offset: 2px; }
|
|
29
|
+
|
|
30
|
+
.rv-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
|
31
|
+
.rv-muted { color: var(--harness-fg-muted); }
|
|
32
|
+
|
|
33
|
+
/* ---------- launcher ---------- */
|
|
34
|
+
.rv-launch {
|
|
35
|
+
position: fixed;
|
|
36
|
+
right: max(var(--rv-gap), env(safe-area-inset-right));
|
|
37
|
+
bottom: max(var(--rv-gap), env(safe-area-inset-bottom));
|
|
38
|
+
z-index: var(--rv-z);
|
|
39
|
+
display: inline-flex;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: 8px;
|
|
42
|
+
height: 40px;
|
|
43
|
+
padding: 0 14px;
|
|
44
|
+
border: 1px solid var(--harness-border);
|
|
45
|
+
border-radius: 999px;
|
|
46
|
+
background: var(--harness-bg);
|
|
47
|
+
box-shadow: var(--harness-shadow);
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
touch-action: manipulation;
|
|
50
|
+
transition: transform var(--dur-press) var(--ease-press), background-color var(--dur-hover) var(--ease-hover);
|
|
51
|
+
}
|
|
52
|
+
.rv-launch:active { transform: scale(0.97); }
|
|
53
|
+
.rv-count {
|
|
54
|
+
min-width: 20px;
|
|
55
|
+
height: 20px;
|
|
56
|
+
padding: 0 6px;
|
|
57
|
+
border-radius: 999px;
|
|
58
|
+
background: var(--rv-accent);
|
|
59
|
+
color: var(--rv-on-accent);
|
|
60
|
+
font-weight: 600;
|
|
61
|
+
font-variant-numeric: tabular-nums;
|
|
62
|
+
line-height: 20px;
|
|
63
|
+
text-align: center;
|
|
64
|
+
}
|
|
65
|
+
@media (hover: hover) and (pointer: fine) {
|
|
66
|
+
.rv-launch:hover { background: color-mix(in srgb, var(--harness-bg), white 8%); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------- panel ---------- */
|
|
70
|
+
.rv-panel {
|
|
71
|
+
position: fixed;
|
|
72
|
+
top: var(--rv-gap);
|
|
73
|
+
right: var(--rv-gap);
|
|
74
|
+
bottom: calc(var(--rv-gap) + 48px);
|
|
75
|
+
z-index: var(--rv-z);
|
|
76
|
+
display: flex;
|
|
77
|
+
flex-direction: column;
|
|
78
|
+
width: min(380px, calc(100vw - 2 * var(--rv-gap)));
|
|
79
|
+
border: 1px solid var(--harness-border);
|
|
80
|
+
border-radius: 12px;
|
|
81
|
+
background: var(--harness-bg);
|
|
82
|
+
box-shadow: var(--harness-shadow);
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
transition: opacity var(--dur-dialog) var(--ease-out), transform var(--dur-dialog) var(--ease-out);
|
|
85
|
+
}
|
|
86
|
+
@starting-style { .rv-panel { opacity: 0; transform: translateX(12px); } }
|
|
87
|
+
@media (prefers-reduced-motion: reduce) {
|
|
88
|
+
@starting-style { .rv-panel { transform: none; } }
|
|
89
|
+
.rv-launch:active { transform: none; }
|
|
90
|
+
}
|
|
91
|
+
[data-motion="reduce"] .rv-panel { transform: none; }
|
|
92
|
+
|
|
93
|
+
.rv-head {
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
justify-content: space-between;
|
|
97
|
+
gap: 8px;
|
|
98
|
+
padding: 10px 10px 10px 16px;
|
|
99
|
+
border-bottom: 1px solid var(--harness-border);
|
|
100
|
+
}
|
|
101
|
+
.rv-title { margin: 0; font-size: 14px; font-weight: 600; color: var(--rv-fg-strong); }
|
|
102
|
+
.rv-tabs { display: flex; gap: 2px; padding: 2px; border-radius: 8px; background: var(--rv-fill); }
|
|
103
|
+
.rv-tabs button {
|
|
104
|
+
height: 28px;
|
|
105
|
+
padding: 0 12px;
|
|
106
|
+
border: 0;
|
|
107
|
+
border-radius: 6px;
|
|
108
|
+
background: transparent;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
}
|
|
111
|
+
.rv-tabs [aria-selected="true"] { background: var(--rv-fill-hover); color: var(--rv-fg-strong); font-weight: 600; }
|
|
112
|
+
|
|
113
|
+
.rv-icon {
|
|
114
|
+
display: grid;
|
|
115
|
+
place-items: center;
|
|
116
|
+
width: 32px;
|
|
117
|
+
height: 32px;
|
|
118
|
+
border: 0;
|
|
119
|
+
border-radius: 6px;
|
|
120
|
+
background: transparent;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
}
|
|
123
|
+
@media (hover: hover) and (pointer: fine) {
|
|
124
|
+
.rv-icon:hover, .rv-tabs button:hover { background: var(--rv-fill-hover); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.rv-body { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
128
|
+
.rv-empty { margin: 16px; color: var(--harness-fg-muted); text-wrap: pretty; }
|
|
129
|
+
.rv-error { display: flex; gap: 8px; align-items: baseline; justify-content: space-between; margin: 12px 16px 0; padding: 8px 10px; border-radius: 8px; background: oklch(0.779 0.131 23.4 / 0.12); color: var(--rv-danger); }
|
|
130
|
+
|
|
131
|
+
/* ---------- controls ---------- */
|
|
132
|
+
.rv-button {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
gap: 6px;
|
|
137
|
+
height: 32px;
|
|
138
|
+
padding: 0 12px;
|
|
139
|
+
border: 1px solid var(--harness-border);
|
|
140
|
+
border-radius: 8px;
|
|
141
|
+
background: var(--rv-fill);
|
|
142
|
+
color: var(--rv-fg-strong);
|
|
143
|
+
font-weight: 500;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
transition: background-color var(--dur-hover) var(--ease-hover), transform var(--dur-press) var(--ease-press);
|
|
146
|
+
}
|
|
147
|
+
.rv-button:active { transform: scale(0.97); }
|
|
148
|
+
.rv-button:disabled { opacity: 0.5; cursor: default; transform: none; }
|
|
149
|
+
.rv-new { align-self: stretch; height: 36px; }
|
|
150
|
+
.rv-link {
|
|
151
|
+
min-height: 24px;
|
|
152
|
+
padding: 0 2px;
|
|
153
|
+
border: 0;
|
|
154
|
+
background: none;
|
|
155
|
+
color: var(--rv-accent);
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
.rv-danger { color: var(--rv-danger); }
|
|
159
|
+
@media (hover: hover) and (pointer: fine) {
|
|
160
|
+
.rv-button:not(:disabled):hover { background: var(--rv-fill-hover); }
|
|
161
|
+
.rv-link:hover { text-decoration: underline; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.rv-label { display: flex; flex-direction: column; gap: 6px; color: var(--harness-fg-muted); }
|
|
165
|
+
.rv input,
|
|
166
|
+
.rv textarea,
|
|
167
|
+
.rv select {
|
|
168
|
+
width: 100%;
|
|
169
|
+
border: 1px solid var(--harness-border);
|
|
170
|
+
border-radius: 8px;
|
|
171
|
+
background: oklch(0 0 0 / 0.25);
|
|
172
|
+
color: var(--rv-fg-strong);
|
|
173
|
+
}
|
|
174
|
+
.rv input, .rv select { height: 32px; padding: 0 10px; }
|
|
175
|
+
.rv textarea { padding: 8px 10px; resize: vertical; min-height: 72px; line-height: 18px; }
|
|
176
|
+
.rv input::placeholder { color: var(--harness-fg-muted); }
|
|
177
|
+
|
|
178
|
+
.rv-filter { display: flex; gap: 4px; min-width: 0; margin: 0; padding: 0; border: 0; }
|
|
179
|
+
.rv-filter button {
|
|
180
|
+
height: 28px;
|
|
181
|
+
padding: 0 10px;
|
|
182
|
+
border: 1px solid transparent;
|
|
183
|
+
border-radius: 999px;
|
|
184
|
+
background: none;
|
|
185
|
+
cursor: pointer;
|
|
186
|
+
}
|
|
187
|
+
.rv-filter [aria-pressed="true"] { border-color: var(--harness-border); background: var(--rv-fill); color: var(--rv-fg-strong); }
|
|
188
|
+
|
|
189
|
+
/* ---------- threads ---------- */
|
|
190
|
+
.rv-list, .rv-replies, .rv-people { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; }
|
|
191
|
+
.rv-list { gap: 8px; }
|
|
192
|
+
.rv-thread {
|
|
193
|
+
display: flex;
|
|
194
|
+
flex-direction: column;
|
|
195
|
+
gap: 8px;
|
|
196
|
+
padding: 12px;
|
|
197
|
+
border: 1px solid var(--harness-border);
|
|
198
|
+
border-radius: 10px;
|
|
199
|
+
background: var(--rv-fill);
|
|
200
|
+
scroll-margin: 12px;
|
|
201
|
+
transition: border-color var(--dur-hover) var(--ease-hover);
|
|
202
|
+
}
|
|
203
|
+
.rv-thread[data-focused] { border-color: var(--rv-accent); }
|
|
204
|
+
.rv-thread[data-resolved] .rv-text { color: var(--harness-fg-muted); }
|
|
205
|
+
.rv-entry header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
|
|
206
|
+
.rv-entry strong { color: var(--rv-fg-strong); font-weight: 600; }
|
|
207
|
+
.rv-edit { margin-left: auto; }
|
|
208
|
+
.rv-no {
|
|
209
|
+
display: inline-grid;
|
|
210
|
+
place-items: center;
|
|
211
|
+
min-width: 18px;
|
|
212
|
+
height: 18px;
|
|
213
|
+
padding: 0 4px;
|
|
214
|
+
border-radius: 999px;
|
|
215
|
+
background: var(--rv-accent);
|
|
216
|
+
color: var(--rv-on-accent);
|
|
217
|
+
font-size: 11px;
|
|
218
|
+
font-weight: 700;
|
|
219
|
+
font-variant-numeric: tabular-nums;
|
|
220
|
+
}
|
|
221
|
+
.rv-text { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--harness-fg); }
|
|
222
|
+
.rv-replies { gap: 10px; padding-left: 12px; border-left: 2px solid var(--harness-border); }
|
|
223
|
+
.rv-tags { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
224
|
+
.rv-tag { padding: 0 8px; height: 22px; line-height: 22px; border-radius: 999px; background: var(--rv-fill-hover); color: var(--rv-fg-strong); font-size: 12px; }
|
|
225
|
+
.rv-select select { height: 26px; padding: 0 6px; font-size: 12px; width: auto; }
|
|
226
|
+
.rv-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
|
227
|
+
.rv-hint { margin-left: auto; font-size: 12px; }
|
|
228
|
+
.rv-composer { display: flex; flex-direction: column; gap: 8px; }
|
|
229
|
+
|
|
230
|
+
/* ---------- people ---------- */
|
|
231
|
+
.rv-invite { display: flex; flex-direction: column; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid var(--harness-border); }
|
|
232
|
+
.rv-share { display: flex; flex-direction: column; gap: 8px; padding: 10px; border-radius: 8px; background: var(--rv-fill); }
|
|
233
|
+
.rv-share p { margin: 0; text-wrap: pretty; }
|
|
234
|
+
.rv-share-row { display: flex; gap: 6px; }
|
|
235
|
+
.rv-people { gap: 2px; }
|
|
236
|
+
.rv-people li { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; border-bottom: 1px solid var(--harness-border); }
|
|
237
|
+
.rv-people li > div:first-child { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; }
|
|
238
|
+
.rv-people strong { color: var(--rv-fg-strong); }
|
|
239
|
+
.rv-people [data-revoked] { opacity: 0.55; }
|
|
240
|
+
|
|
241
|
+
/* ---------- pins and picking ---------- */
|
|
242
|
+
.rv-pins { position: fixed; inset: 0; z-index: var(--rv-z-page); pointer-events: none; }
|
|
243
|
+
.rv-pin {
|
|
244
|
+
position: absolute;
|
|
245
|
+
top: 0;
|
|
246
|
+
left: 0;
|
|
247
|
+
display: grid;
|
|
248
|
+
place-items: center;
|
|
249
|
+
width: 24px;
|
|
250
|
+
height: 24px;
|
|
251
|
+
margin: -24px 0 0 0;
|
|
252
|
+
border: 2px solid oklch(1 0 0);
|
|
253
|
+
border-radius: 12px 12px 12px 2px;
|
|
254
|
+
background: var(--rv-pin);
|
|
255
|
+
color: oklch(1 0 0);
|
|
256
|
+
font: 700 11px/1 var(--sys-font);
|
|
257
|
+
font-variant-numeric: tabular-nums;
|
|
258
|
+
box-shadow: 0 2px 8px oklch(0 0 0 / 0.35);
|
|
259
|
+
pointer-events: auto;
|
|
260
|
+
cursor: pointer;
|
|
261
|
+
}
|
|
262
|
+
.rv-pin[hidden] { display: none; }
|
|
263
|
+
.rv-pin[data-new] { background: oklch(1 0 0); color: var(--rv-pin); pointer-events: none; }
|
|
264
|
+
|
|
265
|
+
.rv-pick { position: fixed; inset: 0; z-index: calc(var(--rv-z-page) + 1); cursor: crosshair; }
|
|
266
|
+
.rv-pick-box {
|
|
267
|
+
position: fixed;
|
|
268
|
+
top: 0;
|
|
269
|
+
left: 0;
|
|
270
|
+
z-index: calc(var(--rv-z-page) + 2);
|
|
271
|
+
pointer-events: none;
|
|
272
|
+
border: 2px solid var(--rv-mark);
|
|
273
|
+
border-radius: 4px;
|
|
274
|
+
background: oklch(0.55 0 0 / 0.12);
|
|
275
|
+
}
|
|
276
|
+
.rv-pick-bar {
|
|
277
|
+
position: fixed;
|
|
278
|
+
left: 50%;
|
|
279
|
+
bottom: max(var(--rv-gap), env(safe-area-inset-bottom));
|
|
280
|
+
z-index: var(--rv-z);
|
|
281
|
+
display: flex;
|
|
282
|
+
align-items: center;
|
|
283
|
+
gap: 12px;
|
|
284
|
+
padding: 6px 6px 6px 16px;
|
|
285
|
+
transform: translateX(-50%);
|
|
286
|
+
border: 1px solid var(--harness-border);
|
|
287
|
+
border-radius: 999px;
|
|
288
|
+
background: var(--harness-bg);
|
|
289
|
+
box-shadow: var(--harness-shadow);
|
|
290
|
+
white-space: nowrap;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
@media (max-width: 520px) {
|
|
294
|
+
.rv-panel { top: auto; left: var(--rv-gap); height: min(70vh, 560px); }
|
|
295
|
+
.rv-pick-bar { white-space: normal; left: var(--rv-gap); right: var(--rv-gap); transform: none; border-radius: 12px; }
|
|
296
|
+
}
|