@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miles Roxas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,81 @@
|
|
|
1
|
-
#
|
|
1
|
+
# design-hub
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Design Hub product as an npm package: the hub that lists and serves a project's prototypes, the review panel and its API, the password gate, the kit, the `design-hub` command and the `miles-*` skills. A project installs it and keeps only its prototypes, kit materials and docs; `npm create @milesroxas/design-hub` makes one.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
design-hub dev # the hub on http://localhost:<PORT>
|
|
7
|
+
design-hub new <slug> --feature <feature> # a prototype in the low-gravity space
|
|
8
|
+
design-hub new <slug> --app --sprint --feature <feature> # a feature's own prototype, in the shell
|
|
9
|
+
design-hub guide <feature> # its walkthrough, docs/sprints/<feature>/guide.md
|
|
10
|
+
design-hub commands # every command, with its flags, as JSON
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Inside the package, from the inside out: `core/` is the pure kernel (README states, the catalog, fixture sets, checks, the Jev client, the review domain); `kit/` the materials prototypes import; `shell/` the browser hub; `server/` the Node side (the review API, signed sessions, the stores, env and paths); `vite/` the plugin that turns a project into a Vite app; `vercel/` the deployed entries; `scripts/` the CLI; `templates/` what `new` copies; `skills/` what `skills` installs. `AGENTS.md` here holds the layers and their rules.
|
|
14
|
+
|
|
15
|
+
How a project becomes an app: `design-hub dev` runs Vite with the package's `shell/` as the root and the project as the content. `vite/registry.ts` writes `virtual:design-hub/registry` from the project's `prototypes/` and `docs/sprints/`; `vite/kit.ts` resolves `@kit/<name>` to the project's `kit/<name>` first and the package's second. The sections below describe the hub's own interface and the deployed site; they are the reference for changing the package, not for using it.
|
|
16
|
+
|
|
17
|
+
## The index
|
|
18
|
+
|
|
19
|
+
`/` lists every prototype from its README frontmatter (`shell/Index.tsx`, `shell/shell.css`). The page answers one question for a reviewer: which prototype is the feature, and what else was tried around it.
|
|
20
|
+
|
|
21
|
+
### Which kind
|
|
22
|
+
|
|
23
|
+
Walk this for every new prototype; the first match wins.
|
|
24
|
+
|
|
25
|
+
1. The feature's main prototype, the one its walkthrough opens → `sprint`. Exactly one per feature, so a reviewer never has to guess which build is current. `design-hub new --sprint` refuses a second while the first is not archived.
|
|
26
|
+
2. Another full take on that same flow, built to compare against the primary → `alternate` (`--alternate`). Refused until the primary exists, because an alternate is only legible next to what it replaces.
|
|
27
|
+
3. Anything else: a side question about one part (a menu's motion, a loading state) → `exploration`, the default.
|
|
28
|
+
|
|
29
|
+
### Layout
|
|
30
|
+
|
|
31
|
+
Groups, in order: one per feature, then `Without a feature` (explorations with an empty `feature:`), then `Archived` (`status: archived`, any kind, plain rows).
|
|
32
|
+
|
|
33
|
+
A feature group, top to bottom:
|
|
34
|
+
|
|
35
|
+
1. **Heading.** The feature's name. `Walkthrough →` ends the line when `docs/sprints/<feature>/guide.md` exists.
|
|
36
|
+
2. **Primary.** The one `sprint` row, its title 16px against 14px for every other row. Size alone marks it; no badge, no new shape.
|
|
37
|
+
3. **Alternates.** Under an `Alternates` caption (12px, 500, quaternary text), indented 20px and joined to the primary by a 1px rail in `--border-secondary` on the primary's text edge. The rail makes them children of that one row; a heading of their own would make them peers of the feature. A stray second `sprint` renders here too.
|
|
38
|
+
4. **Internal explorations.** A toggle (`Internal explorations`, the count, a chevron), collapsed by default because explorations are working material, not the feature. The open state persists per group in `localStorage` (`index-explorations-open`). Typing in the filter opens every fold, so a match is never hidden. Open, the explorations are cards on a grid (`minmax(208px, 1fr)`, 8px gap). Every exploration lives here, never in the row list.
|
|
39
|
+
|
|
40
|
+
Within any list: exploring above decided, then newest first.
|
|
41
|
+
|
|
42
|
+
Rhythm: inside a group, each block's text sits 20px under the text above it (primary under heading, each caption under the last summary), and a caption sits 8px over what it labels, so it reads as the start of what follows. Groups sit 40px apart, double the inner step, so a group reads as one unit.
|
|
43
|
+
|
|
44
|
+
Surfaces:
|
|
45
|
+
|
|
46
|
+
- **Rows** draw no dividers. The hover surface is the only drawn shape and bleeds 12px into the gutter, so every text edge on the page lines up. One page grid reaches every row through subgrid, so the context, status and date columns align across groups without fixed widths; the alternates' indent reaches the first column only.
|
|
47
|
+
- **Cards** take `--radius-lg` and 12px padding, so card text keeps the page's text edge. The title line ends in an arrow (quaternary), because the whole card opens the prototype. The summary drops to caption size (12px/18px, tertiary) so three cards scan as three titles. Context, status and date sit pinned to the bottom so the meta lines up across a row of cards. The edge is an inset ring (`oklch(0 0 0 / 0.08)`) plus two soft layers in light, and a white 9% ring alone in dark, where the tokens drop every shadow. The ring is inset because the fold clips its content while it animates and would cut an outer one. Focus stacks the focus ring on the edge, since both are box-shadows and the edge alone would erase it.
|
|
48
|
+
- **Status** is a 6px dot before the word: ink for exploring, success for decided, quinary for archived. The word stays, so colour is never the only signal.
|
|
49
|
+
- **Foot.** The count alone, in body type over a hairline (the Ledger Foot Rule).
|
|
50
|
+
- **Lede.** 18px/28px Geist at 400, secondary text. `--font-voice` resolves to Geist inside the hub's `.sys` scope (`shell/system.css`), so the index never borrows the project's faces.
|
|
51
|
+
|
|
52
|
+
### Motion
|
|
53
|
+
|
|
54
|
+
Nothing animates on a keyboard action.
|
|
55
|
+
|
|
56
|
+
- **Arrival**, one shot: the head fades over 200ms, then each heading, caption, row and open card rises 4px and fades over 220ms, 30ms apart (stagger capped at eight), then the foot. Keyframes fill backwards only, so a settled row's opacity stays free to transition.
|
|
57
|
+
- **Departure**, pointer only: a clicked row or card holds while the rest of the page fades over 150ms on the Layer Exit curve, filling the wait while the browser fetches the prototype. A modified click or Enter opens instantly; a back-forward restore clears the fade.
|
|
58
|
+
- **Fold:** opens over 220ms (grid row `0fr` to `1fr`, plus the fade) and closes over 150ms, both `--ease-out`; the chevron turns 180°. A keyboard toggle is instant.
|
|
59
|
+
- **Hover:** a row takes the Hover Rule fill. A card stays white and lifts instead (ring and shadow deepen; in dark, the ring brightens and the surface steps up to `--bg-primary_hover`), and its arrow moves 2px right and darkens to secondary. Both lift the summary to secondary. **Press:** `scale(0.97)`.
|
|
60
|
+
- **Reduced motion** keeps the fades and drops the rise, the press, the fold's growth, the chevron's turn and the arrow's nudge.
|
|
61
|
+
- Up and Down arrows walk the rows and the cards of open folds.
|
|
62
|
+
|
|
63
|
+
A speculation rule in `shell/index.html` prefetches a prototype's page when its row is hovered, so the document is in cache by the click (Chromium; other browsers ignore the rule). Prerendering was rejected: it would play a prototype's entrance motion before anyone saw it.
|
|
64
|
+
|
|
65
|
+
## The walkthrough
|
|
66
|
+
|
|
67
|
+
`/guide/<feature>/` is `docs/sprints/<feature>/guide.md`, rendered for reviewers who have the password but not the repo; `/guide/` alone opens the only guide, or lists them. The registry plugin lists the guides, so a new feature's walkthrough appears the moment the file exists (`design-hub guide <feature>` starts one from `templates/guide.md`). The Markdown stays the one source: a Vite plugin (`vite/markdown.ts`, the `?doc` import) compiles it at build time with `marked`, so nothing is parsed in the browser. It turns the first H1 into the title, every H2 and H3 into the contents rail, each image into a figure with its pixel size (no layout shift, lazy below the first), and `> [!NOTE]` quotes into callouts. A paragraph that starts `Controls → …` renders as a click path. Links to other Markdown files lose their href, since nothing serves them. The page reads at 16px on a 680px measure; screenshots and tables take the full 960px column, because a 1440px shot at 680px is unreadable. Two raw values: a screenshot wears a 1px inset outline at 10% (black on light, white on dark) and a callout a 6% ring (8% on dark), alphas rather than tokens because they composite on any surface the page is set on. The slug `guide` is the hub's (`design-hub new guide` refuses it). The page's **Open the prototype** button goes to the feature's sprint prototype, and every prototype of the feature answers with **Open the walkthrough** as the last row of Controls, under every group, in a new tab, so a reviewer reads and clicks side by side. A sheet inside the product was rejected: it would be modal over the thing under review, and testing aids never sit in the product chrome.
|
|
68
|
+
|
|
69
|
+
## Deploying, and the password
|
|
70
|
+
|
|
71
|
+
`miles-deploy` owns the steps; this is what they rest on. Pushing `main` deploys the project to the linked Vercel project (`design-hub bootstrap --vercel <project>` links it and sets the env). Every path on the deployed site sits behind one password, enforced on the server by Vercel Routing Middleware (`vercel/proxy.ts`, exported as `createProxy` and default-exported by the project's one-line `proxy.ts`, which `vercel.json` names as `proxy.entrypoint`): a visitor who opens a deep link, `/sample-list/#/items` included, sees the password page first and lands on that link after. A client-side gate was rejected because the bundle and the state behind it would still download.
|
|
72
|
+
|
|
73
|
+
The middleware reads two environment variables, set in the Vercel project for Production and Preview and never committed (`.env.example` names them): `GATE_PASSWORD`, the word a reviewer types, and `GATE_SECRET`, at least 32 random characters (`openssl rand -base64 32`) that sign the session cookie. With either missing the gate answers 503 on every path, so a misconfigured deploy never serves the prototypes open. The cookie is `__Host-`-prefixed, `HttpOnly`, `Secure`, `SameSite=Lax`, lasts 30 days, and its signature covers the password, so changing `GATE_PASSWORD` signs every visitor out. A wrong password waits 800ms before answering; add a Vercel Firewall rate-limit rule on `POST` for anything stronger. The password page ships a hash-based Content Security Policy with no `unsafe-inline`, plus `font-src 'self'` and `img-src 'self'`. It wears the hub's neutral look in Geist, so two files pass the gate without a session: `/_gate/geist.woff2` (the latin subset, emitted by `vite/plugin.ts` at that fixed path) and `/favicon.svg`. Nothing else does. Every response carries `X-Robots-Tag: noindex`, so no search engine lists the site. The page's title is the `siteName` the project's `proxy.ts` passes to `createProxy`.
|
|
74
|
+
|
|
75
|
+
## Comments and people
|
|
76
|
+
|
|
77
|
+
The password opens the site to view. To comment, a person needs their own invite link, made by an editor in the review panel's People tab or with `design-hub people invite`. Opening it shows who it is for and asks them to continue; the continue is a POST, so a mail scanner that opens every link never uses the invite. It sets a second signed cookie naming that person and their epoch, a counter a new link or a revoke bumps, which ends every session they hold on their next page load. The store keeps only a hash of each invite token, so a read of the store signs nobody in. A link lasts 14 days.
|
|
78
|
+
|
|
79
|
+
The panel (`shell/review/`) mounts on every prototype page in its own React root, so a prototype that throws never takes it down. A comment carries the prototype, the URL, every dial panel's values (`snapshotDials`), and the element it was pinned to as a selector path with an offset; Show state reopens the URL and sets the dials back. Pins draw over their elements and below the Controls panel. The API is one endpoint, `/api/review` (`server/api.ts`, reached on Vercel through the project's one-line `api/review.ts`, which also bundles the project's `checks.json` so triage runs on its thresholds): queries by GET, commands by POST with a JSON body, same-origin only. The rules (who may resolve, what a valid comment is) live once, in `core/review/`, behind a small storage port: Upstash Redis over REST when deployed, a file in `.design-hub/review.json` under `design-hub dev`, where whoever runs the server is a local editor.
|
|
80
|
+
|
|
81
|
+
`design-hub dev` serves the prototypes open, with the review panel on the local store. The gate is covered by `pnpm test` (`vercel/proxy.test.ts`, against an in-memory Upstash); `vercel dev` in a project runs it for real.
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Ambient types for a project: Vite's import.meta and asset modules, and the
|
|
2
|
+
// kit's own module shapes. A project's tsconfig lists `@milesroxas/design-hub/client` in
|
|
3
|
+
// `types`; it never needs vite on its own.
|
|
4
|
+
/// <reference types="vite/client" />
|
|
5
|
+
/// <reference path="./shell/doc.d.ts" />
|
|
6
|
+
/// <reference path="./shell/registry.virtual.d.ts" />
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"files": {
|
|
3
|
+
"includes": [
|
|
4
|
+
"**",
|
|
5
|
+
"!**/node_modules",
|
|
6
|
+
"!**/dist",
|
|
7
|
+
"!**/.vercel",
|
|
8
|
+
"!**/.design-hub",
|
|
9
|
+
"!**/design-system",
|
|
10
|
+
"!**/docs",
|
|
11
|
+
"!**/.impeccable",
|
|
12
|
+
"!**/.claude",
|
|
13
|
+
"!**/*.md",
|
|
14
|
+
"!**/pnpm-lock.yaml"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"formatter": {
|
|
18
|
+
"indentStyle": "space",
|
|
19
|
+
"indentWidth": 2,
|
|
20
|
+
"lineWidth": 110
|
|
21
|
+
},
|
|
22
|
+
"javascript": {
|
|
23
|
+
"formatter": {
|
|
24
|
+
"quoteStyle": "single"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"css": {
|
|
28
|
+
"formatter": {
|
|
29
|
+
"enabled": false
|
|
30
|
+
},
|
|
31
|
+
"linter": {
|
|
32
|
+
"enabled": false
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"linter": {
|
|
36
|
+
"rules": {
|
|
37
|
+
"preset": "recommended",
|
|
38
|
+
"suspicious": {
|
|
39
|
+
"noExplicitAny": "off"
|
|
40
|
+
},
|
|
41
|
+
"style": {
|
|
42
|
+
"noNonNullAssertion": "off"
|
|
43
|
+
},
|
|
44
|
+
"a11y": {
|
|
45
|
+
"noLabelWithoutControl": {
|
|
46
|
+
"level": "error",
|
|
47
|
+
"options": {
|
|
48
|
+
"inputComponents": [
|
|
49
|
+
"Checkbox"
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"assist": {
|
|
57
|
+
"actions": {
|
|
58
|
+
"source": {
|
|
59
|
+
"organizeImports": "on"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"types": ["@milesroxas/design-hub/client"]
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { base64, base64url, hex, sha256 } from './bytes.ts';
|
|
4
|
+
|
|
5
|
+
test('sha256 of a known string', async () => {
|
|
6
|
+
assert.equal(hex(await sha256('abc')), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('base64url swaps the two unsafe characters and drops padding', () => {
|
|
10
|
+
const bytes = new Uint8Array([251, 255, 254]);
|
|
11
|
+
assert.equal(base64(bytes), '+//+');
|
|
12
|
+
assert.equal(base64url(bytes), '-__-');
|
|
13
|
+
assert.equal(base64url(new Uint8Array([1])), 'AQ');
|
|
14
|
+
});
|
package/core/bytes.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Bytes to text, and SHA-256, on Web Crypto only, so the same helpers serve
|
|
2
|
+
// the browser, Node and Vercel's runtime.
|
|
3
|
+
|
|
4
|
+
export async function sha256(text: string): Promise<Uint8Array> {
|
|
5
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const hex = (bytes: Uint8Array) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
9
|
+
|
|
10
|
+
export const base64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes));
|
|
11
|
+
|
|
12
|
+
/** URL-safe base64 without padding: cookies, tokens and links carry it unescaped. */
|
|
13
|
+
export const base64url = (bytes: Uint8Array) =>
|
|
14
|
+
base64(bytes).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { byIndexOrder, isLive, readMeta } from './catalog.ts';
|
|
4
|
+
import { frontmatter } from './frontmatter.ts';
|
|
5
|
+
|
|
6
|
+
const readme = (fm: string, body = '') => `---\n${fm}\n---\n\n# X\n${body}`;
|
|
7
|
+
|
|
8
|
+
test('frontmatter reads flat pairs, strips quotes, and keeps colons in values', () => {
|
|
9
|
+
const { data, body } = frontmatter(readme('title: "A: B"\nsummary: \'One line\'\nempty:'));
|
|
10
|
+
assert.deepEqual(data, { title: 'A: B', summary: 'One line', empty: '' });
|
|
11
|
+
assert.equal(body.trim(), '# X');
|
|
12
|
+
assert.deepEqual(frontmatter('# no frontmatter').data, {});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('readMeta fills defaults and keeps only the known kinds', () => {
|
|
16
|
+
const m = readMeta('my-slug', readme('kind: sprint'));
|
|
17
|
+
assert.deepEqual(
|
|
18
|
+
{ title: m.title, context: m.context, status: m.status, kind: m.kind, states: m.states },
|
|
19
|
+
{ title: 'my-slug', context: 'space', status: 'exploring', kind: 'sprint', states: null },
|
|
20
|
+
);
|
|
21
|
+
assert.equal(readMeta('x', readme('kind: final')).kind, 'exploration');
|
|
22
|
+
assert.equal(
|
|
23
|
+
readMeta(
|
|
24
|
+
'x',
|
|
25
|
+
readme('kind: alternate', '\n## States\n\n| State | How |\n| --- | --- |\n| Open | click |\n'),
|
|
26
|
+
).states?.length,
|
|
27
|
+
1,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('open questions sort above answered ones, newest first, then by title', () => {
|
|
32
|
+
const m = (slug: string, status: string, created: string) =>
|
|
33
|
+
readMeta(slug, readme(`title: ${slug}\nstatus: ${status}\ncreated: ${created}`));
|
|
34
|
+
const sorted = [
|
|
35
|
+
m('b', 'decided', '2026-01-02'),
|
|
36
|
+
m('c', 'exploring', '2026-01-01'),
|
|
37
|
+
m('a', 'exploring', '2026-01-03'),
|
|
38
|
+
].sort(byIndexOrder);
|
|
39
|
+
assert.deepEqual(
|
|
40
|
+
sorted.map((x) => x.slug),
|
|
41
|
+
['a', 'c', 'b'],
|
|
42
|
+
);
|
|
43
|
+
assert.equal(isLive({ status: 'archived' }), false);
|
|
44
|
+
});
|
package/core/catalog.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// What a prototype's README says about it: the frontmatter the index lists
|
|
2
|
+
// and the States table the checks read. One reader for the browser index
|
|
3
|
+
// (shell/registry.ts) and the Node scripts (scripts/lib/catalog.ts), so
|
|
4
|
+
// they never disagree about what a prototype is.
|
|
5
|
+
import { frontmatter } from './frontmatter.ts';
|
|
6
|
+
import { parseStates, type State } from './states.ts';
|
|
7
|
+
|
|
8
|
+
export type Kind = 'sprint' | 'alternate' | 'exploration';
|
|
9
|
+
|
|
10
|
+
export type Meta = {
|
|
11
|
+
slug: string;
|
|
12
|
+
title: string;
|
|
13
|
+
summary: string;
|
|
14
|
+
context: string;
|
|
15
|
+
status: string;
|
|
16
|
+
kind: Kind;
|
|
17
|
+
feature: string;
|
|
18
|
+
created: string;
|
|
19
|
+
/** null when the README has no States section. */
|
|
20
|
+
states: State[] | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function readMeta(slug: string, readme: string): Meta {
|
|
24
|
+
const { data } = frontmatter(readme);
|
|
25
|
+
return {
|
|
26
|
+
slug,
|
|
27
|
+
title: data.title || slug,
|
|
28
|
+
summary: data.summary || '',
|
|
29
|
+
context: data.context || 'space',
|
|
30
|
+
status: data.status || 'exploring',
|
|
31
|
+
kind: data.kind === 'sprint' || data.kind === 'alternate' ? data.kind : 'exploration',
|
|
32
|
+
feature: data.feature || '',
|
|
33
|
+
created: data.created || '',
|
|
34
|
+
states: parseStates(readme),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const isLive = (m: Pick<Meta, 'status'>) => m.status !== 'archived';
|
|
39
|
+
|
|
40
|
+
// Open questions sit above answered ones; within a status, newest first.
|
|
41
|
+
const rank = (status: string) => (status === 'exploring' ? 0 : 1);
|
|
42
|
+
export const byIndexOrder = (a: Meta, b: Meta) =>
|
|
43
|
+
rank(a.status) - rank(b.status) || b.created.localeCompare(a.created) || a.title.localeCompare(b.title);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { DEFAULT_CHECKS, loadChecks, reaches, validateChecks } from './checks.ts';
|
|
4
|
+
|
|
5
|
+
test('the default checks are valid, and loadChecks keeps a valid value', () => {
|
|
6
|
+
assert.deepEqual(validateChecks(DEFAULT_CHECKS), []);
|
|
7
|
+
assert.equal(loadChecks(DEFAULT_CHECKS).checks, DEFAULT_CHECKS);
|
|
8
|
+
assert.equal(loadChecks({ jev: {} }).checks, null);
|
|
9
|
+
assert.ok(loadChecks({ jev: {} }).problems.length > 0);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('names each invalid field by path', () => {
|
|
13
|
+
const problems = validateChecks({ jev: { model: '', timeoutMs: 0 }, fidelity: { warnAt: 2 } });
|
|
14
|
+
assert.ok(problems.includes('jev.model: a model name'));
|
|
15
|
+
assert.ok(problems.some((p) => p.startsWith('fidelity.warnAt')));
|
|
16
|
+
assert.ok(problems.some((p) => p.startsWith('contacts.allow')));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('a null threshold never fires', () => {
|
|
20
|
+
assert.equal(reaches(1, null), false);
|
|
21
|
+
assert.equal(reaches(0.7, 0.7), true);
|
|
22
|
+
assert.equal(reaches(0.69, 0.7), false);
|
|
23
|
+
});
|
package/core/checks.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The model and thresholds every Jev check reads, from one file in the
|
|
2
|
+
// project: .design-hub/checks.json. `design-hub calibrate --write` rewrites it
|
|
3
|
+
// from labelled judgments, so thresholds come from data, never from a
|
|
4
|
+
// constant in a script. The model is pinned, not an alias: an alias moves on
|
|
5
|
+
// a release, and the thresholds were set against one model. A file that does
|
|
6
|
+
// not validate turns every Jev check off (and `design-hub preflight` reports
|
|
7
|
+
// it), because a check must never block on a config typo. A project without
|
|
8
|
+
// the file runs on DEFAULT_CHECKS.
|
|
9
|
+
|
|
10
|
+
/** A probability threshold, or null for "never". */
|
|
11
|
+
type At = number | null;
|
|
12
|
+
|
|
13
|
+
export type Checks = {
|
|
14
|
+
jev: { model: string; timeoutMs: number };
|
|
15
|
+
fidelity: { warnAt: At; blockAt: At };
|
|
16
|
+
states: { blockAt: At };
|
|
17
|
+
contacts: { privateAt: At; allow: string[] };
|
|
18
|
+
skillHint: { gateAt: At; fitsAt: At };
|
|
19
|
+
triage: { at: At };
|
|
20
|
+
visual: { warnAt: At };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_CHECKS: Checks = {
|
|
24
|
+
jev: { model: 'jev-1.14.0', timeoutMs: 2000 },
|
|
25
|
+
fidelity: { warnAt: 0.7, blockAt: 0.9 },
|
|
26
|
+
states: { blockAt: 0.8 },
|
|
27
|
+
contacts: { privateAt: 0.5, allow: [] },
|
|
28
|
+
skillHint: { gateAt: 0.3, fitsAt: 0.3 },
|
|
29
|
+
triage: { at: 0.5 },
|
|
30
|
+
visual: { warnAt: 0.7 },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const isAt = (v: unknown): v is At => v === null || (typeof v === 'number' && v >= 0 && v <= 1);
|
|
34
|
+
const obj = (v: unknown): Record<string, unknown> =>
|
|
35
|
+
v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
|
36
|
+
|
|
37
|
+
/** Every problem with a checks object, by path; empty when it is valid. */
|
|
38
|
+
export function validateChecks(value: unknown): string[] {
|
|
39
|
+
const c = obj(value);
|
|
40
|
+
const problems: string[] = [];
|
|
41
|
+
const jev = obj(c.jev);
|
|
42
|
+
if (typeof jev.model !== 'string' || !jev.model) problems.push('jev.model: a model name');
|
|
43
|
+
if (typeof jev.timeoutMs !== 'number' || jev.timeoutMs <= 0)
|
|
44
|
+
problems.push('jev.timeoutMs: milliseconds > 0');
|
|
45
|
+
const thresholds: [string, string][] = [
|
|
46
|
+
['fidelity', 'warnAt'],
|
|
47
|
+
['fidelity', 'blockAt'],
|
|
48
|
+
['states', 'blockAt'],
|
|
49
|
+
['contacts', 'privateAt'],
|
|
50
|
+
['skillHint', 'gateAt'],
|
|
51
|
+
['skillHint', 'fitsAt'],
|
|
52
|
+
['triage', 'at'],
|
|
53
|
+
['visual', 'warnAt'],
|
|
54
|
+
];
|
|
55
|
+
for (const [group, key] of thresholds)
|
|
56
|
+
if (!isAt(obj(c[group])[key])) problems.push(`${group}.${key}: a probability from 0 to 1, or null`);
|
|
57
|
+
const allow = obj(c.contacts).allow;
|
|
58
|
+
if (!Array.isArray(allow) || !allow.every((a) => typeof a === 'string'))
|
|
59
|
+
problems.push('contacts.allow: a list of strings');
|
|
60
|
+
return problems;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type Loaded = { checks: Checks | null; problems: string[] };
|
|
64
|
+
|
|
65
|
+
/** The checks a raw value yields: the value when it validates, else null with the problems. */
|
|
66
|
+
export function loadChecks(raw: unknown): Loaded {
|
|
67
|
+
const problems = validateChecks(raw);
|
|
68
|
+
return { checks: problems.length ? null : (raw as Checks), problems };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when p reaches the threshold; a null threshold never does. */
|
|
72
|
+
export const reaches = (p: number, at: At) => at !== null && p >= at;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { contactsIn, contactsInJson } from './contacts.ts';
|
|
4
|
+
|
|
5
|
+
test('finds emails and phone numbers', () => {
|
|
6
|
+
const found = contactsIn('Call +1 (415) 555-0134 or write ana.ruiz@example.com');
|
|
7
|
+
assert.deepEqual(
|
|
8
|
+
found.map((c) => [c.kind, c.value]),
|
|
9
|
+
[
|
|
10
|
+
['email', 'ana.ruiz@example.com'],
|
|
11
|
+
['phone', '+1 (415) 555-0134'],
|
|
12
|
+
],
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('ignores dates, short numbers, prices and ids', () => {
|
|
17
|
+
for (const text of [
|
|
18
|
+
'2026-10-28',
|
|
19
|
+
'updated 2026-10-28T10:00:00Z',
|
|
20
|
+
'42 items',
|
|
21
|
+
'$1,299.00',
|
|
22
|
+
'v1.2.3',
|
|
23
|
+
'id 64f1a2b3c4d5e6f7a8b9c0d1',
|
|
24
|
+
])
|
|
25
|
+
assert.deepEqual(contactsIn(text), [], text);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('reports the JSON path of each match', () => {
|
|
29
|
+
const found = contactsInJson({ real: [{ name: 'A' }, { owner: { email: 'x@y.io' } }] });
|
|
30
|
+
assert.deepEqual(found, [{ kind: 'email', value: 'x@y.io', path: 'real[1].owner.email' }]);
|
|
31
|
+
});
|
package/core/contacts.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Emails and phone numbers in fixture data. A CMS pull can put a real
|
|
2
|
+
// person's details behind a share link a client forwards, so the pre-flight
|
|
3
|
+
// stops on every match until it is confirmed as a business contact. This is
|
|
4
|
+
// the code half of the contact check; Jev, when on, only judges whether a
|
|
5
|
+
// match is a private person or a public business.
|
|
6
|
+
|
|
7
|
+
export type Contact = { kind: 'email' | 'phone'; value: string; path: string };
|
|
8
|
+
|
|
9
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
10
|
+
// A run that starts and ends with a digit, may hold spaces, dots, dashes and
|
|
11
|
+
// brackets, and is not glued to letters or digits on either side.
|
|
12
|
+
const PHONE = /(?<![\w.+-])\+?\d[\d\s().-]{6,}\d(?![\w])/g;
|
|
13
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}/;
|
|
14
|
+
|
|
15
|
+
function phones(text: string): string[] {
|
|
16
|
+
return [...text.matchAll(PHONE)]
|
|
17
|
+
.map((m) => m[0].trim())
|
|
18
|
+
.filter((v) => {
|
|
19
|
+
const digits = v.replace(/\D/g, '').length;
|
|
20
|
+
return digits >= 9 && digits <= 15 && !ISO_DATE.test(v);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every contact in a string, in order of appearance. */
|
|
25
|
+
export function contactsIn(text: string, path = ''): Contact[] {
|
|
26
|
+
const emails = [...text.matchAll(EMAIL)].map((m) => ({ kind: 'email' as const, value: m[0], path }));
|
|
27
|
+
const withoutEmails = text.replace(EMAIL, ' ');
|
|
28
|
+
return [...emails, ...phones(withoutEmails).map((value) => ({ kind: 'phone' as const, value, path }))];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every contact in any string of a JSON value, with the path to it (`real[2].owner`). */
|
|
32
|
+
export function contactsInJson(value: unknown, path = ''): Contact[] {
|
|
33
|
+
if (typeof value === 'string') return contactsIn(value, path);
|
|
34
|
+
if (Array.isArray(value)) return value.flatMap((v, i) => contactsInJson(v, `${path}[${i}]`));
|
|
35
|
+
if (value && typeof value === 'object')
|
|
36
|
+
return Object.entries(value).flatMap(([k, v]) => contactsInJson(v, path ? `${path}.${k}` : k));
|
|
37
|
+
return [];
|
|
38
|
+
}
|
package/core/doc.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// What a compiled walkthrough looks like: the shape the Vite plugin writes at
|
|
2
|
+
// build time (vite/markdown.ts) and the page reads (shell/Guide.tsx).
|
|
3
|
+
export type TocEntry = { id: string; text: string; depth: number };
|
|
4
|
+
export type Doc = { meta: Record<string, string>; title: string; toc: TocEntry[]; html: string };
|
package/core/feature.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The feature slug names docs/sprints/<feature>/ and a prototype's `feature:`. One rule for its shape and its display name.
|
|
2
|
+
export const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
3
|
+
|
|
4
|
+
export const featureTitle = (slug: string) => slug.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
|
5
|
+
|
|
6
|
+
export const guideHref = (feature: string) => `/guide/${feature}/`;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { fixtureSets } from './fixtures.ts';
|
|
4
|
+
|
|
5
|
+
type Item = { id: string; name: string; status: string; updated: string };
|
|
6
|
+
const real: Item[] = [
|
|
7
|
+
{ id: '1', name: 'Short', status: 'active', updated: '2026-10-28' },
|
|
8
|
+
{ id: '2', name: 'A much longer name', status: 'done', updated: '2026-10-21' },
|
|
9
|
+
];
|
|
10
|
+
const sets = fixtureSets({ real, missing: [] }, { max: 5, text: ['name'] });
|
|
11
|
+
|
|
12
|
+
test('longest stretches only the named text fields, on the first row', () => {
|
|
13
|
+
assert.deepEqual(sets.longest[0], {
|
|
14
|
+
id: '1',
|
|
15
|
+
name: 'A much longer name A much longer name',
|
|
16
|
+
status: 'active',
|
|
17
|
+
updated: '2026-10-28',
|
|
18
|
+
});
|
|
19
|
+
assert.deepEqual(sets.longest[1], real[1]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('max repeats to the count with unique ids; empty is empty', () => {
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
sets.max.map((i) => i.id),
|
|
25
|
+
['1', '2', '1-1', '2-1', '1-2'],
|
|
26
|
+
);
|
|
27
|
+
assert.deepEqual(sets.empty, []);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a set written by hand wins', () => {
|
|
31
|
+
const hand = fixtureSets({ real, missing: [], longest: [real[1]] }, { max: 1, text: ['name'] });
|
|
32
|
+
assert.deepEqual(hand.longest, [real[1]]);
|
|
33
|
+
});
|
package/core/fixtures.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Hostile fixture sets, derived from the real ones so Claude never writes
|
|
2
|
+
// them by hand. A feature's fixtures.json holds `real` (pulled from the CMS
|
|
3
|
+
// or copied from the comp) and `missing` (the item with no image, no link,
|
|
4
|
+
// hand-written because no rule can derive it). This file fills in the rest:
|
|
5
|
+
//
|
|
6
|
+
// longest the first item carries, in each free-text field the component
|
|
7
|
+
// names (`text`), the longest real value for that field doubled,
|
|
8
|
+
// because the CMS will hold a longer title than anything in it
|
|
9
|
+
// today; the other items stay real, so the monster row is judged
|
|
10
|
+
// beside normal ones. Only named fields stretch: a doubled date,
|
|
11
|
+
// status or URL is not a longer value, it is a broken one
|
|
12
|
+
// empty nothing
|
|
13
|
+
// max the real items repeated up to the design's maximum count, ids
|
|
14
|
+
// made unique so React keys hold
|
|
15
|
+
//
|
|
16
|
+
// A set written in the JSON by hand wins over the derived one, so a feature
|
|
17
|
+
// can replace any of them when its component needs something the rule
|
|
18
|
+
// cannot know.
|
|
19
|
+
|
|
20
|
+
export const SET_NAMES = ['real', 'longest', 'empty', 'max', 'missing'] as const;
|
|
21
|
+
export type SetName = (typeof SET_NAMES)[number];
|
|
22
|
+
|
|
23
|
+
export type FixtureSets<T> = Record<SetName, T[]>;
|
|
24
|
+
type Source<T> = { real: T[]; missing: T[] } & Partial<Record<SetName, T[]>>;
|
|
25
|
+
/** The keys of T whose values are strings: the only fields `longest` can stretch. */
|
|
26
|
+
type TextKey<T> = { [K in keyof T & string]: T[K] extends string | null | undefined ? K : never }[keyof T &
|
|
27
|
+
string];
|
|
28
|
+
type Options<T> = {
|
|
29
|
+
/** The design's maximum count, for `max`. */
|
|
30
|
+
max: number;
|
|
31
|
+
/** The free-text fields a reviewer reads (a name, a title), for `longest`. */
|
|
32
|
+
text: readonly TextKey<T>[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function fixtureSets<T extends Record<string, unknown>>(
|
|
36
|
+
source: Source<T>,
|
|
37
|
+
{ max, text }: Options<T>,
|
|
38
|
+
): FixtureSets<T> {
|
|
39
|
+
return {
|
|
40
|
+
real: source.real,
|
|
41
|
+
longest: source.longest ?? longest(source.real, text),
|
|
42
|
+
empty: source.empty ?? [],
|
|
43
|
+
max: source.max ?? repeat(source.real, max),
|
|
44
|
+
missing: source.missing,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function longest<T extends Record<string, unknown>>(real: T[], text: readonly string[]): T[] {
|
|
49
|
+
const [first, ...rest] = real;
|
|
50
|
+
if (!first) return [];
|
|
51
|
+
const hostile = { ...first } as Record<string, unknown>;
|
|
52
|
+
for (const key of text) {
|
|
53
|
+
const widest = real
|
|
54
|
+
.map((item) => item[key])
|
|
55
|
+
.filter((v): v is string => typeof v === 'string')
|
|
56
|
+
.reduce((a, b) => (b.length > a.length ? b : a), '');
|
|
57
|
+
if (widest) hostile[key] = `${widest} ${widest}`;
|
|
58
|
+
}
|
|
59
|
+
return [hostile as T, ...rest];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function repeat<T extends Record<string, unknown>>(real: T[], count: number): T[] {
|
|
63
|
+
if (!real.length) return [];
|
|
64
|
+
return Array.from({ length: count }, (_, i) => {
|
|
65
|
+
const item = real[i % real.length];
|
|
66
|
+
const round = Math.floor(i / real.length);
|
|
67
|
+
return round === 0 || !('id' in item) ? item : { ...item, id: `${String(item.id)}-${round}` };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The YAML block at the top of a Markdown file, read as flat `key: value`
|
|
2
|
+
// pairs. Shared by the index (in the browser) and the ?doc plugin (in Node).
|
|
3
|
+
export function frontmatter(md: string) {
|
|
4
|
+
const match = md.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
5
|
+
const data: Record<string, string> = {};
|
|
6
|
+
for (const line of (match?.[1] ?? '').split('\n')) {
|
|
7
|
+
const at = line.indexOf(':');
|
|
8
|
+
if (at > 0)
|
|
9
|
+
data[line.slice(0, at).trim()] = line
|
|
10
|
+
.slice(at + 1)
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(/^["']|["']$/g, '');
|
|
13
|
+
}
|
|
14
|
+
return { data, body: match ? md.slice(match[0].length) : md };
|
|
15
|
+
}
|
package/core/html.ts
ADDED