diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..ce241d2 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,65 @@ +# Context — ubiquitous language + +The shared vocabulary for `blueprints`. These terms should appear verbatim in +conversation, code, file names, and commits. This file is a glossary only — no +implementation details (those live in `DESIGN.md`). + +## Blueprint + +A named contract for a recurring UI pattern, taken from the `blueprint-ontology` +repo — declarative, categorical, abstract (a _concept_ in Jackson's sense). It +defines what must be true of the pattern: its signature, state machine, behaviors, +invariants, functions, critical-performance thresholds, and failure modes. Examples: +`List`, `Grid`, `Table`, `Async`. + +Blueprints come in two roles (from the ontology): + +- **Feature** — a standalone pattern a user interacts with (`List`, `Grid`, `Form`). +- **Capability** — a composable blueprint that adds state/behavior onto a host + feature (`Async`, `Paginated`, `Selectable`). + +## Illustration + +This app's rendered, interactive presentation of a single blueprint. `blueprints` +is a gallery of illustrations. "Illustration" is the noun for the whole per-blueprint +screen; do not use "demo" (implies a live working widget — these are static) or +"page" (routing concept, not domain). + +## Viewer + +The one reusable component that renders any blueprint's illustration. It draws the +generic parts from a blueprint's typed data and slots in that blueprint's bespoke +Specimen. Every illustration is the Viewer applied to one blueprint. + +## Specimen + +The interactive-looking but **static** visual mock of the blueprint's actual UI — +the left pane of the Viewer. Bespoke per blueprint (a Grid specimen looks nothing +like a List's). Selecting a Function highlights the region of the specimen that +function owns. Not "mockup", not "demo". + +## Readout + +The right pane of the Viewer: the blueprint's formal contract for the selected +Function — source capability, responsibility, state touched, behaviors (pre → +effect), invariants, failure modes, critical-performance thresholds. + +## Companion + +A supporting schematic shown below the Viewer's two panes. Two kinds exist so far: +the **composition map** (how a feature extends/composes with capabilities) and the +**state machine** (the blueprint's lifecycle). A blueprint may have neither, one, or +both. + +## Function + +One row of a blueprint's "Functional analysis" — a named responsibility of the +pattern (e.g. List's `Display`, `Scroll`, `Load`). Functions are the unit the Viewer +lets you explore, tab by tab. Distinct from **Behavior** (a concrete state +transition like `loadMore()`); behaviors are the mechanics underneath a function. + +## Gallery + +The app's index (landing screen): every blueprint in the ontology, each as a card +linking to its illustration — including blueprints not yet illustrated, shown as +**planned**. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..343a296 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,70 @@ +# Design — `blueprints` + +Decisions for building `blueprints` out from a single List illustration into a +gallery of many. Terminology: see [CONTEXT.md](CONTEXT.md). + +## D1 · Content sourcing: hand-authored typed data + bespoke specimen + +Each blueprint is a **typed TS data module** (`src/data/Blueprint.ts`), +transcribed by hand from its ontology README, paired with a **bespoke Specimen** +component. The generic **Viewer** renders everything else (Readout, composition map, +state machine) from that data. + +- **Why not parse the ontology at build time?** The Specimen must be hand-built for + every blueprint regardless (the ontology only provides an ASCII sketch), so parsing + would automate only the cheap, structured text — at the cost of fragile + markdown/mermaid parsing and a build-time coupling to the ontology repo's exact + format. Not worth it. +- **Source of truth** stays the `blueprint-ontology` repo; the data modules are a + faithful transcription, cited in each module's header. + +Structural split this implies: + +| Part | Generic (data-driven) | Bespoke (per blueprint) | +| ---------------------------------- | ------------------------------------ | ----------------------------------- | +| Chrome (title block, tabs, legend) | ✓ | | +| Readout (dossier per function) | ✓ | | +| Composition map | ✓ (from `extends` + `composes` data) | | +| State machine | ✓ (from nodes/edges data) | | +| Specimen | | ✓ component slotted into the Viewer | + +## D2 · Routing: vue-router, URL per blueprint + +Add `vue-router`. Routes: `/` → Gallery, `/b/:slug` → the blueprint's illustration +(e.g. `/b/list`). Deep-linkable and shareable; browser back/forward works. The nginx +SPA fallback (`try_files … /index.html`) already serves client routes, so no server +change is needed. A blueprint registry (`src/data/blueprints.ts`) maps `slug → +{ data module, specimen component, meta }` and is the single source both the router +and the Gallery read from. + +## D3 · Gallery: illustrated-only, grows over time + +The Gallery lists **only** blueprints that have an illustration; it grows as we add +them. No greyed "planned" cards. Rationale: keeps the landing clean and truthful, and +avoids maintaining metadata for 45 not-yet-built entries. A blueprint appears in the +Gallery precisely when it is registered in `src/data/blueprints.ts`. + +## D4 · Build-out slice #1: foundation + Grid + +**Foundation (refactor, no UX change to List):** + +- `pnpm add vue-router`; `App.vue` → thin shell with a header (link home) + ``. +- `src/views/GalleryView.vue` (`/`) — cards from the registry. +- `src/views/BlueprintView.vue` (`/b/:slug`) — resolves the registry entry, renders the Viewer. +- `src/components/BlueprintViewer.vue` — the **generic Viewer**, generalized from the + current List-specific `App.vue`: title block, legend, function tabs + full view, + two panes, optional composition map + state machine, footer. Props: a `Blueprint` + object + the blueprint's Specimen component (injected via ``). +- Generic `Blueprint` type in `src/data/blueprint.ts` (signature, functions, + coreInvariants, optional composition, optional stateMachine, meta). `listBlueprint.ts` + conforms to it. Specimens move to `src/specimens/`. +- `src/data/blueprints.ts` — registry: `slug → { meta, blueprint, specimen }`. + +**Content:** `gridBlueprint.ts` + `src/specimens/GridSpecimen.vue` (2D tile grid). + +**Ships:** Gallery listing List + Grid; `/b/list` (same UX, refactored) and `/b/grid`. + +**Gates before push:** `pnpm build` clean · `pnpm lint` · `pnpm fmt`; then commit → push +(auto-deploys) → verify `https://blueprints.apoena.dev`. + +Subsequent slices add one blueprint each (Table, Feed, …). diff --git a/README.md b/README.md index 9e84cd5..0ff137f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ The first screen illustrates the **List** blueprint and its seven functions — Display · Scroll · Load · Filter · Sort · Select · Act — as a specimen-under-glass with a full contract readout, an anatomy/composition map, and the `LoadState` machine. + + +## Documentation + +- [CONTEXT.md](CONTEXT.md) — ubiquitous language (Blueprint, Viewer, Specimen, Readout, …) +- [DESIGN.md](DESIGN.md) — architecture & build-out decisions + + + ## Develop ```bash diff --git a/package.json b/package.json index 5a6087a..d9b9e45 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ }, "dependencies": { "daisyui": "^5.6.6", - "vue": "^3.5.39" + "vue": "^3.5.39", + "vue-router": "^5.1.0" }, "devDependencies": { "@tailwindcss/vite": "^4.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49f8746..e253811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,16 +14,19 @@ importers: vue: specifier: ^3.5.39 version: 3.5.39(typescript@6.0.3) + vue-router: + specifier: ^5.1.0 + version: 5.1.0(@vue/compiler-sfc@3.5.39)(rolldown@1.1.4)(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) devDependencies: '@tailwindcss/vite': specifier: ^4.3.2 - version: 4.3.2(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)) + version: 4.3.2(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)) '@types/node': specifier: ^24.13.2 version: 24.13.2 '@vitejs/plugin-vue': specifier: ^6.0.7 - version: 6.0.7(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.39(typescript@6.0.3)) + version: 6.0.7(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)) '@vue/tsconfig': specifier: ^0.9.1 version: 0.9.1(typescript@6.0.3)(vue@3.5.39(typescript@6.0.3)) @@ -41,30 +44,51 @@ importers: version: 6.0.3 vite: specifier: ^8.1.1 - version: 8.1.2(@types/node@24.13.2)(jiti@2.7.0) + version: 8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) vue-tsc: specifier: ^3.3.5 version: 3.3.6(typescript@6.0.3) packages: + '@babel/generator@8.0.0': + resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.0': + resolution: {integrity: sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.2': + resolution: {integrity: sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.7': resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@8.0.0': + resolution: {integrity: sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.0': + resolution: {integrity: sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} @@ -538,6 +562,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/node@24.13.2': resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} @@ -557,6 +584,15 @@ packages: '@volar/typescript@2.4.28': resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + '@vue-macros/common@3.1.2': + resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + '@vue/compiler-core@3.5.39': resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} @@ -569,6 +605,15 @@ packages: '@vue/compiler-ssr@3.5.39': resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + '@vue/devtools-api@8.1.5': + resolution: {integrity: sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==} + + '@vue/devtools-kit@8.1.5': + resolution: {integrity: sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==} + + '@vue/devtools-shared@8.1.5': + resolution: {integrity: sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==} + '@vue/language-core@3.3.6': resolution: {integrity: sha512-LgBMZAy2sR3cQWknpyaxnI6yBkqDfLBPkbdhwRhQCvzfNJRQXPilgQIrdI/v4ytJ0sAq9bWhaPsjqBqneomJ3Q==} @@ -600,9 +645,35 @@ packages: vue: optional: true + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-walker-scope@0.9.0: + resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} + engines: {node: '>=20.19.0'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -624,6 +695,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -641,10 +715,23 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -719,9 +806,20 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -759,6 +857,12 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -766,15 +870,31 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + rolldown@1.1.4: resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -802,9 +922,49 @@ packages: engines: {node: '>=14.17'} hasBin: true + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + vite@8.1.2: resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -851,6 +1011,24 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-router@5.1.0: + resolution: {integrity: sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 + pinia: ^3.0.4 + vite: ^7.0.0 || ^8.0.0 + vue: ^3.5.34 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + vue-tsc@3.3.6: resolution: {integrity: sha512-ERXGgbKSBGFUkavrJ1Iwj0ZVxKqB/5UOx65IXy7fPf2UsoI21n3ssQLwdvx8xyUGgJe9PvSZTM/FYzIdwUDPFA==} hasBin: true @@ -865,21 +1043,51 @@ packages: typescript: optional: true + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + snapshots: + '@babel/generator@8.0.0': + dependencies: + '@babel/parser': 8.0.0 + '@babel/types': 8.0.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.0': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.2': {} + '@babel/parser@7.29.7': dependencies: '@babel/types': 7.29.7 + '@babel/parser@8.0.0': + dependencies: + '@babel/types': 8.0.0 + '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.0': + dependencies: + '@babel/helper-string-parser': 8.0.0 + '@babel/helper-validator-identifier': 8.0.2 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -1150,26 +1358,28 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 - '@tailwindcss/vite@4.3.2(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0))': + '@tailwindcss/vite@4.3.2(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@tailwindcss/node': 4.3.2 '@tailwindcss/oxide': 4.3.2 tailwindcss: 4.3.2 - vite: 8.1.2(@types/node@24.13.2)(jiti@2.7.0) + vite: 8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/jsesc@2.5.1': {} + '@types/node@24.13.2': dependencies: undici-types: 7.18.2 - '@vitejs/plugin-vue@6.0.7(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0))(vue@3.5.39(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.7(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.2(@types/node@24.13.2)(jiti@2.7.0) + vite: 8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) vue: 3.5.39(typescript@6.0.3) '@volar/language-core@2.4.28': @@ -1184,6 +1394,16 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 + '@vue-macros/common@3.1.2(vue@3.5.39(typescript@6.0.3))': + dependencies: + '@vue/compiler-sfc': 3.5.39 + ast-kit: 2.2.0 + local-pkg: 1.2.1 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.39(typescript@6.0.3) + '@vue/compiler-core@3.5.39': dependencies: '@babel/parser': 7.29.7 @@ -1214,6 +1434,19 @@ snapshots: '@vue/compiler-dom': 3.5.39 '@vue/shared': 3.5.39 + '@vue/devtools-api@8.1.5': + dependencies: + '@vue/devtools-kit': 8.1.5 + + '@vue/devtools-kit@8.1.5': + dependencies: + '@vue/devtools-shared': 8.1.5 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.1.5': {} + '@vue/language-core@3.3.6': dependencies: '@volar/language-core': 2.4.28 @@ -1253,8 +1486,31 @@ snapshots: typescript: 6.0.3 vue: 3.5.39(typescript@6.0.3) + acorn@8.17.0: {} + alien-signals@3.2.1: {} + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.7 + pathe: 2.0.3 + + ast-walker-scope@0.9.0: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + ast-kit: 2.2.0 + + birpc@2.9.0: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + csstype@3.2.3: {} daisyui@5.6.6: {} @@ -1270,6 +1526,8 @@ snapshots: estree-walker@2.0.2: {} + exsolve@1.1.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1279,8 +1537,14 @@ snapshots: graceful-fs@4.2.11: {} + hookable@5.5.3: {} + jiti@2.7.0: {} + jsesc@3.1.0: {} + + json5@2.2.3: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -1330,10 +1594,27 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + muggle-string@0.4.1: {} nanoid@3.3.15: {} @@ -1386,16 +1667,36 @@ snapshots: path-browserify@1.0.1: {} + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + postcss@8.5.16: dependencies: nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 + quansync@0.2.11: {} + + readdirp@5.0.0: {} + rolldown@1.1.4: dependencies: '@oxc-project/types': 0.138.0 @@ -1417,6 +1718,8 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.4 '@rolldown/binding-win32-x64-msvc': 1.1.4 + scule@1.3.0: {} + source-map-js@1.2.1: {} tailwindcss@4.3.2: {} @@ -1435,9 +1738,25 @@ snapshots: typescript@6.0.3: {} + ufo@1.6.4: {} + undici-types@7.18.2: {} - vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0): + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.4 + + unplugin@3.3.0(rolldown@1.1.4)(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.1.4 + vite: 8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) + + vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -1448,9 +1767,43 @@ snapshots: '@types/node': 24.13.2 fsevents: 2.3.3 jiti: 2.7.0 + yaml: 2.9.0 vscode-uri@3.1.0: {} + vue-router@5.1.0(@vue/compiler-sfc@3.5.39)(rolldown@1.1.4)(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.39(typescript@6.0.3)): + dependencies: + '@babel/generator': 8.0.0 + '@vue-macros/common': 3.1.2(vue@3.5.39(typescript@6.0.3)) + '@vue/devtools-api': 8.1.5 + ast-walker-scope: 0.9.0 + chokidar: 5.0.0 + json5: 2.2.3 + local-pkg: 1.2.1 + magic-string: 0.30.21 + mlly: 1.8.2 + muggle-string: 0.4.1 + pathe: 2.0.3 + picomatch: 4.0.4 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.3.0(rolldown@1.1.4)(vite@8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0)) + unplugin-utils: 0.3.2 + vue: 3.5.39(typescript@6.0.3) + yaml: 2.9.0 + optionalDependencies: + '@vue/compiler-sfc': 3.5.39 + vite: 8.1.2(@types/node@24.13.2)(jiti@2.7.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - webpack + vue-tsc@3.3.6(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 @@ -1466,3 +1819,7 @@ snapshots: '@vue/shared': 3.5.39 optionalDependencies: typescript: 6.0.3 + + webpack-virtual-modules@0.6.2: {} + + yaml@2.9.0: {} diff --git a/src/App.vue b/src/App.vue index da19907..34047d7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,446 +1,38 @@ - + diff --git a/src/components/BlueprintViewer.vue b/src/components/BlueprintViewer.vue new file mode 100644 index 0000000..d7961b4 --- /dev/null +++ b/src/components/BlueprintViewer.vue @@ -0,0 +1,469 @@ + + + + + diff --git a/src/components/CompositionMap.vue b/src/components/CompositionMap.vue index f12660f..3fb89c6 100644 --- a/src/components/CompositionMap.vue +++ b/src/components/CompositionMap.vue @@ -1,6 +1,8 @@ diff --git a/src/components/StateMachine.vue b/src/components/StateMachine.vue index 9334669..e3d05d4 100644 --- a/src/components/StateMachine.vue +++ b/src/components/StateMachine.vue @@ -1,6 +1,8 @@ - + = { + succeed: "#7fdca0", + fail: "#ff8f8f", + refresh: "#ffb84d", + loadMore: "#8fd0ff", + init: "#4f7099", +} + +// ── Blueprint ────────────────────────────────────────────────────────────── +export type BlueprintRole = "feature" | "capability" + +export interface Blueprint { + slug: string + name: string + tagline: string + role: BlueprintRole + extendsName?: string + composesCount?: number + sig: ComposedSig + functions: BlueprintFunction[] + coreInvariants: string[] + composition?: Composition + stateMachine?: StateMachine +} diff --git a/src/data/blueprints.ts b/src/data/blueprints.ts new file mode 100644 index 0000000..6cde111 --- /dev/null +++ b/src/data/blueprints.ts @@ -0,0 +1,26 @@ +// Registry: the single source the router and Gallery both read from. A blueprint +// appears in the app precisely when it is registered here — pairing its typed data +// with its bespoke Specimen component. + +import type { Component } from "vue" +import type { Blueprint } from "./blueprint" +import { list } from "./listBlueprint" +import { grid } from "./gridBlueprint" +import ListSpecimen from "@/specimens/ListSpecimen.vue" +import GridSpecimen from "@/specimens/GridSpecimen.vue" + +export interface RegistryEntry { + blueprint: Blueprint + specimen: Component +} + +export const REGISTRY: Record = { + list: { blueprint: list, specimen: ListSpecimen }, + grid: { blueprint: grid, specimen: GridSpecimen }, +} + +export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint) + +export function entryFor(slug: string): RegistryEntry | undefined { + return REGISTRY[slug] +} diff --git a/src/data/gridBlueprint.ts b/src/data/gridBlueprint.ts new file mode 100644 index 0000000..56a9c29 --- /dev/null +++ b/src/data/gridBlueprint.ts @@ -0,0 +1,206 @@ +// The Grid blueprint, as data. Lifted verbatim from the blueprint-ontology +// `grid` README. Note: Grid has 6 functions (incl. the Grid-specific `Position`, +// no Sort/Act) and shares the composed LoadState machine with List. + +import type { Blueprint } from "./blueprint" +import { LOADSTATE_MACHINE } from "./loadStateMachine" + +export const grid: Blueprint = { + slug: "grid", + name: "Grid", + tagline: "A parameterized, scrollable set of items in a 2D cell layout.", + role: "feature", + extendsName: "Set", + composesCount: 6, + + sig: { + header: "Grid + Async + Paginated + Pullable + Selectable + Filterable", + fields: [ + { name: "items", type: "seq Item", note: "row-major; loaded subset of total" }, + { name: "columns", type: "Int", note: "number of columns (≥ 1)" }, + { name: "totalCount", type: "Int", note: "Paginated" }, + { name: "loadState", type: "LoadState", note: "Async · Paginated · Pullable" }, + { name: "selectionMode", type: "SelectionMode", note: "Selectable" }, + { name: "selected", type: "set Item", note: "Selectable" }, + { name: "visible", type: "set Item", note: "Filterable" }, + ], + types: [ + "LoadState = Idle | Loading | Refreshing | LoadingMore | Error", + "SelectionMode = None | Single | Multi", + "row(i) = i / columns col(i) = i mod columns (derived, not stored)", + ], + }, + + functions: [ + { + id: "display", + name: "Display", + fig: "01", + caps: ["Grid", "Async"], + verb: "Render items in a 2D cell layout; handle empty, loading, and error states distinctly.", + state: [ + ["items", "seq Item"], + ["loadState", "LoadState"], + ], + behaviors: [], + invariants: [ + "#4 items = ∅ is a valid state (empty grid, not an error)", + "#8 all cells have equal dimensions", + ], + failures: [ + "empty vs error conflation — server returns 0 items with 4xx; empty state treated as error, user sees wrong UI", + "incomplete trailing row — #items mod columns ≠ 0; trailing cells must render as empty placeholders, not crash", + "variable-height cell break — items with differing heights; row alignment lost, grid degenerates to a non-uniform layout", + ], + perf: ["visible rows appear within 300 ms of navigation"], + notes: [], + }, + { + id: "scroll", + name: "Scroll", + fig: "02", + caps: ["Grid"], + verb: "Allow the user to navigate the full item sequence along one axis; virtualize off-screen rows.", + state: [["items", "seq Item"]], + behaviors: [], + invariants: [], + failures: [], + perf: [ + "60 fps minimum — off-screen rows must not be rendered (row virtualization required)", + "rows outside the render window must release their cell view references (memory)", + ], + notes: [ + "Grid scrolls one axis; the row (not the individual cell) is the unit of virtualization.", + ], + }, + { + id: "load", + name: "Load", + fig: "03", + caps: ["Async", "Paginated", "Pullable"], + verb: "Fetch the initial page; support refresh (restart) and pagination (append).", + state: [ + ["loadState", "LoadState"], + ["totalCount", "Int"], + ], + behaviors: [ + { + sig: "refresh()", + pre: "—", + eff: "loadState ← refreshing, reload items from page 1, selected ← ∅", + }, + { + sig: "loadMore()", + pre: "#items < totalCount, loadState = idle", + eff: "loadState ← loadingMore, append next page to items", + }, + ], + invariants: ["#6 #items ≤ totalCount — loaded count never exceeds the server total"], + failures: [ + "duplicate load — loadMore() called while loadState = loadingMore; duplicate items appended, identity-uniqueness lost", + ], + perf: ["loadMore appends new rows without layout shift or scroll-position jump"], + notes: [], + }, + { + id: "position", + name: "Position", + fig: "04", + caps: ["Grid"], + verb: "Derive each item's (row, col) from its index and columns; recompute on resize.", + state: [["columns", "Int"]], + behaviors: [ + { + sig: "resize(n)", + pre: "n ≥ 1", + eff: "columns ← n, reflow layout; scroll position preserved by item", + }, + ], + invariants: [ + "#1 columns ≥ 1 — at least one column at all times", + "#7 row(i) = i / columns, col(i) = i mod columns — position deterministically derived from index", + ], + failures: [ + "layout thrash on resize — columns changes while items are mid-scroll; visible rows reflow, scroll position jumps to a different item", + ], + perf: ["column reflow completes within one frame (≤ 16 ms) for in-memory data"], + notes: [ + "Derived: row(i) = i / columns (integer division), col(i) = i mod columns, rows = ⌈#items / columns⌉.", + "Open question: adaptive column count (floor(width / minCellWidth)) is treated as a consumer concern; the model uses a fixed integer.", + ], + }, + { + id: "filter", + name: "Filter", + fig: "05", + caps: ["Filterable"], + verb: "Restrict the visible set without mutating the underlying sequence.", + state: [["visible", "set Item"]], + behaviors: [{ sig: "filter(pred)", pre: "—", eff: "visible ← { x ∈ items | pred(x) }" }], + invariants: ["#5 visible ⊆ items — filter is purely restrictive"], + failures: [], + perf: [], + notes: ["Ephemeral: derives visible from items without mutating the sequence."], + }, + { + id: "select", + name: "Select", + fig: "06", + caps: ["Selectable"], + verb: "Track user intent over a subset of items (none / single / multi).", + state: [ + ["selectionMode", "SelectionMode"], + ["selected", "set Item"], + ], + behaviors: [ + { + sig: "select(x)", + pre: "x ∈ visible, selectionMode ≠ none", + eff: "selected ← selected ∪ {x}", + }, + { sig: "deselect(x)", pre: "x ∈ selected", eff: "selected ← selected \\ {x}" }, + { sig: "selectAll()", pre: "selectionMode = multi", eff: "selected ← visible" }, + { sig: "clearSelection()", pre: "—", eff: "selected ← ∅" }, + ], + invariants: ["selected ⊆ items — selection is always over loaded items"], + failures: [ + "stale selection after refresh — refresh() clears items but not selected; selected ⊄ items, invariant violated", + ], + perf: [], + notes: [], + }, + ], + + coreInvariants: [ + "columns ≥ 1 — at least one column at all times", + "members = elems(items) — set and sequence are always in sync", + "items are identity-unique within items", + "items = ∅ is a valid state (empty grid, not an error)", + "all cells have equal dimensions", + ], + + composition: { + caps: [ + { id: "core", label: "Grid : Set", sub: "items, columns", core: true }, + { id: "async", label: "Async", sub: "loadState" }, + { id: "paginated", label: "Paginated", sub: "totalCount" }, + { id: "pullable", label: "Pullable", sub: "refresh" }, + { id: "filterable", label: "Filterable", sub: "visible" }, + { id: "selectable", label: "Selectable", sub: "selected" }, + ], + funcs: ["Display", "Scroll", "Load", "Position", "Filter", "Select"], + links: [ + ["core", "Display"], + ["core", "Scroll"], + ["core", "Position"], + ["async", "Display"], + ["async", "Load"], + ["paginated", "Load"], + ["pullable", "Load"], + ["filterable", "Filter"], + ["selectable", "Select"], + ], + }, + + stateMachine: LOADSTATE_MACHINE, +} diff --git a/src/data/listBlueprint.ts b/src/data/listBlueprint.ts index 90922cd..6277a68 100644 --- a/src/data/listBlueprint.ts +++ b/src/data/listBlueprint.ts @@ -1,318 +1,230 @@ // The List blueprint, as data. Lifted verbatim from the blueprint-ontology // `list` README: signature, behaviors, invariants, failure modes, performance. -// Rendered by the components under src/components as an interactive schematic. +// The generic Viewer renders this; ListSpecimen supplies the bespoke visual. -export interface SampleItem { - n: string - s: string -} +import type { Blueprint } from "./blueprint" +import { LOADSTATE_MACHINE } from "./loadStateMachine" -export const SAMPLE: SampleItem[] = [ - { n: "Item A", s: "#a1f3 · ready" }, - { n: "Item B", s: "#b2e8 · ready" }, - { n: "Item C", s: "#c7d1 · ready" }, - { n: "Item D", s: "#d0a4 · ready" }, - { n: "Item E", s: "#e5b9 · ready" }, - { n: "Item F", s: "#f3c2 · ready" }, - { n: "Item G", s: "#01d7 · ready" }, - { n: "Item H", s: "#12e0 · ready" }, -] +export const list: Blueprint = { + slug: "list", + name: "List", + tagline: "A scrollable, ordered set of items.", + role: "feature", + extendsName: "Set", + composesCount: 5, -export interface SigField { - name: string - type: string - note: string -} + sig: { + header: "List + Async + Paginated + Pullable + Selectable + Filterable", + fields: [ + { name: "items", type: "seq Item", note: "List: ordered, loaded subset" }, + { name: "loadState", type: "LoadState", note: "Async · Paginated · Pullable" }, + { name: "totalCount", type: "Int", note: "Paginated" }, + { name: "selectionMode", type: "SelectionMode", note: "Selectable" }, + { name: "selected", type: "set Item", note: "Selectable" }, + { name: "visible", type: "set Item", note: "Filterable" }, + ], + types: [ + "LoadState = Idle | Loading | Refreshing | LoadingMore | Error", + "SelectionMode = None | Single | Multi", + ], + }, -export const COMPOSED_SIG: { - header: string - fields: SigField[] - types: string[] -} = { - header: "List + Async + Paginated + Pullable + Selectable + Filterable", - fields: [ - { name: "items", type: "seq Item", note: "List: ordered, loaded subset" }, - { name: "loadState", type: "LoadState", note: "Async · Paginated · Pullable" }, - { name: "totalCount", type: "Int", note: "Paginated" }, - { name: "selectionMode", type: "SelectionMode", note: "Selectable" }, - { name: "selected", type: "set Item", note: "Selectable" }, - { name: "visible", type: "set Item", note: "Filterable" }, + functions: [ + { + id: "display", + name: "Display", + fig: "01", + caps: ["List", "Async"], + verb: "Render items in order; render skeleton placeholders during initial load; handle empty, loading, and error states distinctly.", + state: [ + ["items", "seq Item"], + ["loadState", "LoadState"], + ], + behaviors: [], + invariants: ["#1 items = ∅ is a valid state (empty list, not an error)"], + failures: [ + "empty vs error conflation — network returns 0 items with 4xx; empty state treated as error, user sees wrong UI", + "skeleton / content mismatch — skeleton row dimensions differ from real item; layout shift on Loading → Idle", + ], + perf: [ + "skeleton placeholders render within 100 ms of navigation", + "real items replace the skeleton within 300 ms", + "skeleton dimensions must match real items to prevent layout shift", + ], + notes: [], + }, + { + id: "scroll", + name: "Scroll", + fig: "02", + caps: ["List"], + verb: "Allow the user to navigate the full item sequence; virtualize off-screen items.", + state: [["items", "seq Item"]], + behaviors: [], + invariants: ["items form a well-formed sequence (no duplicate indices) — l.items.isSeq"], + failures: [], + perf: [ + "60 fps minimum — off-screen items must not be rendered (virtualization required)", + "items outside the render window must release their view references (memory)", + ], + notes: [ + "Scroll is navigation, not a mutating behavior — it moves the render window over items, it does not change them.", + ], + }, + { + id: "load", + name: "Load", + fig: "03", + caps: ["Async", "Paginated", "Pullable"], + verb: "Fetch the initial page; support refresh (restart) and pagination (append).", + state: [ + ["loadState", "LoadState"], + ["totalCount", "Int"], + ], + behaviors: [ + { + sig: "refresh()", + pre: "loadState = idle", + eff: "loadState ← refreshing, reload items from page 1, selected ← ∅", + }, + { + sig: "loadMore()", + pre: "#items < totalCount, loadState = idle", + eff: "loadState ← loadingMore, append next page to items", + }, + ], + invariants: ["#6 #items ≤ totalCount", "#7 loadState = loadingMore → #items < totalCount"], + failures: [ + "duplicate load — loadMore() called while loadState = loadingMore; duplicate items appended, items loses uniqueness", + "unknown total — streaming source, totalCount never known; use CursorPaginated with a hasMore sentinel instead", + ], + perf: ["loadMore appends new items without layout shift or scroll-position jump"], + notes: [], + }, + { + id: "filter", + name: "Filter", + fig: "04", + caps: ["Filterable"], + verb: "Restrict the visible set without mutating the underlying sequence.", + state: [["visible", "set Item"]], + behaviors: [{ sig: "filter(pred)", pre: "—", eff: "visible ← { x ∈ items | pred(x) }" }], + invariants: ["#8 visible ⊆ items — filter never introduces new items"], + failures: [ + "filter after reload — predicate references old item shape; visible silently empties without error", + ], + perf: ["result updates within one frame (≤ 16 ms) for in-memory data"], + notes: [ + "Ephemeral: derives visible from items but does not mutate the sequence. Persisting a filter is a consumer concern.", + ], + }, + { + id: "sort", + name: "Sort", + fig: "05", + caps: ["Sortable"], + verb: "Reorder the sequence by a comparator without changing identity.", + state: [["items", "seq Item"]], + behaviors: [{ sig: "sort(cmp)", pre: "—", eff: "reorder items by comparator" }], + invariants: ["identity is preserved — sorting changes order, never membership"], + failures: [ + "sort invalidates cursor (CursorPaginated) — cursor is stale; must reset to NoCursor and reload from page 1", + ], + perf: [], + notes: [ + "Ephemeral alongside filter: reorders the displayed sequence without persisting the change.", + ], + }, + { + id: "select", + name: "Select", + fig: "06", + caps: ["Selectable"], + verb: "Track user intent over a subset of items (none / single / multi).", + state: [ + ["selectionMode", "SelectionMode"], + ["selected", "set Item"], + ], + behaviors: [ + { + sig: "select(x)", + pre: "x ∈ visible, selectionMode ≠ none", + eff: "selected ← selected ∪ {x}", + }, + { sig: "deselect(x)", pre: "x ∈ selected", eff: "selected ← selected \\ {x}" }, + { sig: "selectAll()", pre: "selectionMode = multi", eff: "selected ← visible" }, + { sig: "clearSelection()", pre: "—", eff: "selected ← ∅" }, + ], + invariants: [ + "#3 selected ⊆ items", + "#4 selectionMode = none → selected = ∅", + "#5 selectionMode = single → #selected ≤ 1", + ], + failures: [ + "stale selection — refresh() clears items but not selected; selected ⊄ items, invariant #3 violated", + ], + perf: [], + notes: [], + }, + { + id: "act", + name: "Act", + fig: "07", + caps: ["Swipeable", "Reorderable"], + verb: "Expose per-item contextual actions (swipe, long-press) to the consumer.", + state: [], + behaviors: [ + { + sig: "swipeAction(x, dir)", + pre: "x ∈ visible", + eff: "trigger consumer-defined contextual action", + }, + { + sig: "reorder(from, to)", + pre: "capability: reorderable = true", + eff: "swap positions in items", + }, + ], + invariants: [], + failures: [ + "reorder on filtered view — reorder operates on visible, not items; position mismatch between displayed and stored order", + ], + perf: [], + notes: ["Open question: swipe direction — left | right only, or also up | down?"], + }, ], - types: [ - "LoadState = Idle | Loading | Refreshing | LoadingMore | Error", - "SelectionMode = None | Single | Multi", + + coreInvariants: [ + "items = ∅ is a valid state (empty list, not an error)", + "items are identity-unique — no duplicates by key", + "elems[items] = members — ordered form stays in sync with the set", ], -} -export interface Behavior { - sig: string - pre: string - eff: string -} - -export interface BlueprintFunction { - id: string - name: string - fig: string - caps: string[] - verb: string - state: [string, string][] - behaviors: Behavior[] - invariants: string[] - failures: string[] - perf: string[] - notes: string[] -} - -export const FUNCTIONS: BlueprintFunction[] = [ - { - id: "display", - name: "Display", - fig: "01", - caps: ["List", "Async"], - verb: "Render items in order; render skeleton placeholders during initial load; handle empty, loading, and error states distinctly.", - state: [ - ["items", "seq Item"], - ["loadState", "LoadState"], + composition: { + caps: [ + { id: "core", label: "List : Set", sub: "items : seq Item", core: true }, + { id: "async", label: "Async", sub: "loadState" }, + { id: "paginated", label: "Paginated", sub: "totalCount" }, + { id: "pullable", label: "Pullable", sub: "refresh" }, + { id: "filterable", label: "Filterable", sub: "visible" }, + { id: "selectable", label: "Selectable", sub: "selected" }, + { id: "sortable", label: "Sortable", sub: "cmp" }, + { id: "swipeable", label: "Swipeable", sub: "+ Reorderable" }, ], - behaviors: [], - invariants: ["#1 items = ∅ is a valid state (empty list, not an error)"], - failures: [ - "empty vs error conflation — network returns 0 items with 4xx; empty state treated as error, user sees wrong UI", - "skeleton / content mismatch — skeleton row dimensions differ from real item; layout shift on Loading → Idle", - ], - perf: [ - "skeleton placeholders render within 100 ms of navigation", - "real items replace the skeleton within 300 ms", - "skeleton dimensions must match real items to prevent layout shift", - ], - notes: [], - }, - { - id: "scroll", - name: "Scroll", - fig: "02", - caps: ["List"], - verb: "Allow the user to navigate the full item sequence; virtualize off-screen items.", - state: [["items", "seq Item"]], - behaviors: [], - invariants: ["items form a well-formed sequence (no duplicate indices) — l.items.isSeq"], - failures: [], - perf: [ - "60 fps minimum — off-screen items must not be rendered (virtualization required)", - "items outside the render window must release their view references (memory)", - ], - notes: [ - "Scroll is navigation, not a mutating behavior — it moves the render window over items, it does not change them.", + funcs: ["Display", "Scroll", "Load", "Filter", "Sort", "Select", "Act"], + links: [ + ["core", "Display"], + ["core", "Scroll"], + ["async", "Display"], + ["async", "Load"], + ["paginated", "Load"], + ["pullable", "Load"], + ["filterable", "Filter"], + ["selectable", "Select"], + ["sortable", "Sort"], + ["swipeable", "Act"], ], }, - { - id: "load", - name: "Load", - fig: "03", - caps: ["Async", "Paginated", "Pullable"], - verb: "Fetch the initial page; support refresh (restart) and pagination (append).", - state: [ - ["loadState", "LoadState"], - ["totalCount", "Int"], - ], - behaviors: [ - { - sig: "refresh()", - pre: "loadState = idle", - eff: "loadState ← refreshing, reload items from page 1, selected ← ∅", - }, - { - sig: "loadMore()", - pre: "#items < totalCount, loadState = idle", - eff: "loadState ← loadingMore, append next page to items", - }, - ], - invariants: ["#6 #items ≤ totalCount", "#7 loadState = loadingMore → #items < totalCount"], - failures: [ - "duplicate load — loadMore() called while loadState = loadingMore; duplicate items appended, items loses uniqueness", - "unknown total — streaming source, totalCount never known; use CursorPaginated with a hasMore sentinel instead", - ], - perf: ["loadMore appends new items without layout shift or scroll-position jump"], - notes: [], - }, - { - id: "filter", - name: "Filter", - fig: "04", - caps: ["Filterable"], - verb: "Restrict the visible set without mutating the underlying sequence.", - state: [["visible", "set Item"]], - behaviors: [{ sig: "filter(pred)", pre: "—", eff: "visible ← { x ∈ items | pred(x) }" }], - invariants: ["#8 visible ⊆ items — filter never introduces new items"], - failures: [ - "filter after reload — predicate references old item shape; visible silently empties without error", - ], - perf: ["result updates within one frame (≤ 16 ms) for in-memory data"], - notes: [ - "Ephemeral: derives visible from items but does not mutate the sequence. Persisting a filter is a consumer concern.", - ], - }, - { - id: "sort", - name: "Sort", - fig: "05", - caps: ["Sortable"], - verb: "Reorder the sequence by a comparator without changing identity.", - state: [["items", "seq Item"]], - behaviors: [{ sig: "sort(cmp)", pre: "—", eff: "reorder items by comparator" }], - invariants: ["identity is preserved — sorting changes order, never membership"], - failures: [ - "sort invalidates cursor (CursorPaginated) — cursor is stale; must reset to NoCursor and reload from page 1", - ], - perf: [], - notes: [ - "Ephemeral alongside filter: reorders the displayed sequence without persisting the change.", - ], - }, - { - id: "select", - name: "Select", - fig: "06", - caps: ["Selectable"], - verb: "Track user intent over a subset of items (none / single / multi).", - state: [ - ["selectionMode", "SelectionMode"], - ["selected", "set Item"], - ], - behaviors: [ - { - sig: "select(x)", - pre: "x ∈ visible, selectionMode ≠ none", - eff: "selected ← selected ∪ {x}", - }, - { sig: "deselect(x)", pre: "x ∈ selected", eff: "selected ← selected \\ {x}" }, - { sig: "selectAll()", pre: "selectionMode = multi", eff: "selected ← visible" }, - { sig: "clearSelection()", pre: "—", eff: "selected ← ∅" }, - ], - invariants: [ - "#3 selected ⊆ items", - "#4 selectionMode = none → selected = ∅", - "#5 selectionMode = single → #selected ≤ 1", - ], - failures: [ - "stale selection — refresh() clears items but not selected; selected ⊄ items, invariant #3 violated", - ], - perf: [], - notes: [], - }, - { - id: "act", - name: "Act", - fig: "07", - caps: ["Swipeable", "Reorderable"], - verb: "Expose per-item contextual actions (swipe, long-press) to the consumer.", - state: [], - behaviors: [ - { - sig: "swipeAction(x, dir)", - pre: "x ∈ visible", - eff: "trigger consumer-defined contextual action", - }, - { - sig: "reorder(from, to)", - pre: "capability: reorderable = true", - eff: "swap positions in items", - }, - ], - invariants: [], - failures: [ - "reorder on filtered view — reorder operates on visible, not items; position mismatch between displayed and stored order", - ], - perf: [], - notes: ["Open question: swipe direction — left | right only, or also up | down?"], - }, -] -export const CORE_INVARIANTS: string[] = [ - "items = ∅ is a valid state (empty list, not an error)", - "items are identity-unique — no duplicates by key", - "elems[items] = members — ordered form stays in sync with the set", -] - -// ── Composition map model ────────────────────────────────────────────────── -export interface CapNode { - id: string - label: string - sub: string - core?: boolean -} - -export const COMP_CAPS: CapNode[] = [ - { id: "core", label: "List : Set", sub: "items : seq Item", core: true }, - { id: "async", label: "Async", sub: "loadState" }, - { id: "paginated", label: "Paginated", sub: "totalCount" }, - { id: "pullable", label: "Pullable", sub: "refresh" }, - { id: "filterable", label: "Filterable", sub: "visible" }, - { id: "selectable", label: "Selectable", sub: "selected" }, - { id: "sortable", label: "Sortable", sub: "cmp" }, - { id: "swipeable", label: "Swipeable", sub: "+ Reorderable" }, -] - -export const COMP_FUNCS: string[] = ["Display", "Scroll", "Load", "Filter", "Sort", "Select", "Act"] - -export const COMP_LINKS: [string, string][] = [ - ["core", "Display"], - ["core", "Scroll"], - ["async", "Display"], - ["async", "Load"], - ["paginated", "Load"], - ["pullable", "Load"], - ["filterable", "Filter"], - ["selectable", "Select"], - ["sortable", "Sort"], - ["swipeable", "Act"], -] - -// ── LoadState machine model ──────────────────────────────────────────────── -export interface SmNode { - id: string - x: number - y: number - w: number - h: number - label: string -} - -export type SmKind = "succeed" | "fail" | "refresh" | "loadMore" - -export interface SmEdge { - from: string - to: string - label: string - kind: SmKind - off: number -} - -export const SM_NODES: SmNode[] = [ - { id: "loading", x: 70, y: 150, w: 110, h: 40, label: "Loading" }, - { id: "idle", x: 300, y: 150, w: 96, h: 40, label: "Idle" }, - { id: "refreshing", x: 520, y: 56, w: 130, h: 40, label: "Refreshing" }, - { id: "loadingmore", x: 520, y: 250, w: 140, h: 40, label: "LoadingMore" }, - { id: "error", x: 770, y: 150, w: 96, h: 40, label: "Error" }, -] - -export const SM_EDGES: SmEdge[] = [ - { from: "loading", to: "idle", label: "succeed()", kind: "succeed", off: 0 }, - { from: "loading", to: "error", label: "fail()", kind: "fail", off: 150 }, - { from: "idle", to: "refreshing", label: "refresh()", kind: "refresh", off: -16 }, - { from: "refreshing", to: "idle", label: "succeed()", kind: "succeed", off: -16 }, - { from: "idle", to: "loadingmore", label: "loadMore()", kind: "loadMore", off: 16 }, - { from: "loadingmore", to: "idle", label: "succeed()", kind: "succeed", off: 16 }, - { from: "refreshing", to: "error", label: "fail()", kind: "fail", off: 14 }, - { from: "loadingmore", to: "error", label: "fail()", kind: "fail", off: -14 }, - { from: "error", to: "refreshing", label: "refresh()", kind: "refresh", off: 70 }, -] - -export const SM_COLORS: Record = { - succeed: "#7fdca0", - fail: "#ff8f8f", - refresh: "#ffb84d", - loadMore: "#8fd0ff", - init: "#4f7099", + stateMachine: LOADSTATE_MACHINE, } diff --git a/src/data/loadStateMachine.ts b/src/data/loadStateMachine.ts new file mode 100644 index 0000000..28637cb --- /dev/null +++ b/src/data/loadStateMachine.ts @@ -0,0 +1,25 @@ +import type { StateMachine } from "./blueprint" + +// The composed LoadState lifecycle: Idle / Loading / Refreshing / LoadingMore / Error. +// Shared verbatim by every blueprint that composes Async + Paginated + Pullable +// (List, Grid, Feed, Table, …). Node coordinates are hand-placed for a clean layout. +export const LOADSTATE_MACHINE: StateMachine = { + nodes: [ + { id: "loading", x: 70, y: 150, w: 110, h: 40, label: "Loading" }, + { id: "idle", x: 300, y: 150, w: 96, h: 40, label: "Idle" }, + { id: "refreshing", x: 520, y: 56, w: 130, h: 40, label: "Refreshing" }, + { id: "loadingmore", x: 520, y: 250, w: 140, h: 40, label: "LoadingMore" }, + { id: "error", x: 770, y: 150, w: 96, h: 40, label: "Error" }, + ], + edges: [ + { from: "loading", to: "idle", label: "succeed()", kind: "succeed", off: 0 }, + { from: "loading", to: "error", label: "fail()", kind: "fail", off: 150 }, + { from: "idle", to: "refreshing", label: "refresh()", kind: "refresh", off: -16 }, + { from: "refreshing", to: "idle", label: "succeed()", kind: "succeed", off: -16 }, + { from: "idle", to: "loadingmore", label: "loadMore()", kind: "loadMore", off: 16 }, + { from: "loadingmore", to: "idle", label: "succeed()", kind: "succeed", off: 16 }, + { from: "refreshing", to: "error", label: "fail()", kind: "fail", off: 14 }, + { from: "loadingmore", to: "error", label: "fail()", kind: "fail", off: -14 }, + { from: "error", to: "refreshing", label: "refresh()", kind: "refresh", off: 70 }, + ], +} diff --git a/src/main.ts b/src/main.ts index 8a1724e..8b65e21 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from "vue" import "./style.css" import App from "./App.vue" +import { router } from "./router" -createApp(App).mount("#app") +createApp(App).use(router).mount("#app") diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..41d8523 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,17 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router" +import GalleryView from "@/views/GalleryView.vue" +import BlueprintView from "@/views/BlueprintView.vue" + +const routes: RouteRecordRaw[] = [ + { path: "/", name: "gallery", component: GalleryView }, + { path: "/b/:slug", name: "blueprint", component: BlueprintView, props: true }, + { path: "/:pathMatch(.*)*", redirect: "/" }, +] + +export const router = createRouter({ + history: createWebHistory(), + routes, + scrollBehavior() { + return { top: 0 } + }, +}) diff --git a/src/specimens/GridSpecimen.vue b/src/specimens/GridSpecimen.vue new file mode 100644 index 0000000..875e016 --- /dev/null +++ b/src/specimens/GridSpecimen.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/src/components/ListSpecimen.vue b/src/specimens/ListSpecimen.vue similarity index 96% rename from src/components/ListSpecimen.vue rename to src/specimens/ListSpecimen.vue index 1846e97..8b957a1 100644 --- a/src/components/ListSpecimen.vue +++ b/src/specimens/ListSpecimen.vue @@ -1,6 +1,21 @@ + + + + diff --git a/src/views/GalleryView.vue b/src/views/GalleryView.vue new file mode 100644 index 0000000..326a74b --- /dev/null +++ b/src/views/GalleryView.vue @@ -0,0 +1,124 @@ + + + + +