feat: scaffold blueprints SPA with List functional-blueprint illustration

This commit is contained in:
Julien Calixte
2026-07-02 18:12:20 +02:00
commit 4405497658
27 changed files with 5108 additions and 0 deletions

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.git
.zed
.vscode
archive

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": false
}

11
.oxlintrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "unicorn", "oxc"],
"categories": {
"correctness": "error"
},
"rules": {},
"env": {
"builtin": true
}
}

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

31
.zed/settings.json Normal file
View File

@@ -0,0 +1,31 @@
{
"languages": {
"TypeScript": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"JavaScript": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
},
"Vue.js": {
"format_on_save": "on",
"formatter": {
"external": {
"command": "./node_modules/.bin/oxfmt",
"arguments": ["--stdin-filepath", "{buffer_path}"]
}
}
}
}
}

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# blueprints
Deployed at https://blueprints.apoena.dev
Interactive illustrations of UI-pattern **blueprints** — the formal, framework-agnostic
contracts from [`blueprint-ontology`](https://git.apoena.dev) (signature, state machine,
behaviors, invariants, critical performance, failure modes), rendered in a cyanotype
"blueprint" style: monospace, blueprint-blue paper, faint grid, amber annotations.
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.
## Develop
```bash
pnpm dev # frontend on :5173
pnpm build # type-check (vue-tsc) + production build → dist/
pnpm lint # oxlint (pnpm lint:fix to autofix)
pnpm fmt # oxfmt (pnpm fmt:check to verify only)
```
## Stack
- **Vite + Vue 3 + TypeScript** SPA (no backend)
- **Tailwind v4 + DaisyUI** with a custom `blueprint` dark theme (`src/style.css`)
- **oxlint + oxfmt** for lint/format (no semicolons, double quotes)
- Served in production by **nginx** (see `Dockerfile` + `nginx.conf`)
Blueprint content lives as typed data in `src/data/`; the viewer components render it.
Source of truth: the `list` blueprint in `blueprint-ontology`
(`blueprints/list/README.md`, `list.als`, `list.test.als`).
The original single-file static illustration is archived at
[`archive/list-illustration.html`](archive/list-illustration.html).
## Deploy
Pushes to `main` are picked up by Coolify at https://platform.apoena.dev and deployed to
https://blueprints.apoena.dev.

File diff suppressed because it is too large Load Diff

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" data-theme="blueprint">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Blueprints · UI pattern contracts, illustrated</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

15
nginx.conf Normal file
View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "blueprints",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "oxlint",
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check"
},
"dependencies": {
"daisyui": "^5.6.6",
"vue": "^3.5.39"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"oxfmt": "^0.57.0",
"oxlint": "^1.72.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}

1468
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
public/favicon.svg Normal file
View File

@@ -0,0 +1,23 @@
<!--
tags: [maths, dimensions, size, width, length, geometry, measure, technical, ruler, creative]
category: Design
version: "1.39"
unicode: "eed2"
-->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#1b4f91"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M17 3l4 4l-14 14l-4 -4l14 -14" />
<path d="M16 7l-1.5 -1.5" />
<path d="M13 10l-1.5 -1.5" />
<path d="M10 13l-1.5 -1.5" />
<path d="M7 16l-1.5 -1.5" />
</svg>

After

Width:  |  Height:  |  Size: 526 B

446
src/App.vue Normal file
View File

@@ -0,0 +1,446 @@
<script setup lang="ts">
import { computed, ref } from "vue"
import { FUNCTIONS, COMPOSED_SIG, CORE_INVARIANTS } from "@/data/listBlueprint"
import ListSpecimen from "@/components/ListSpecimen.vue"
import FunctionReadout from "@/components/FunctionReadout.vue"
import CompositionMap from "@/components/CompositionMap.vue"
import StateMachine from "@/components/StateMachine.vue"
const TABS = [
{ id: "full", label: "▣ FULL VIEW", full: true },
...FUNCTIONS.map((f) => ({ id: f.id, label: f.name, full: false })),
]
const activeId = ref("full")
const activeFn = computed(() => FUNCTIONS.find((f) => f.id === activeId.value) ?? null)
const specId = computed(() => (activeFn.value ? `FIG.${activeFn.value.fig}` : "FIG.00"))
</script>
<template>
<div class="sheet">
<!-- TITLE BLOCK -->
<div class="titleblock">
<div class="title-main">
<h1>
List<span class="brk">&lt;</span>Item<span class="brk">&gt;</span> Functional Blueprint
</h1>
<div class="sub">
A scrollable, ordered set of items · an interactive schematic of its 7 functions
</div>
</div>
<div class="tb-cell">
<div class="tb-k">Extends</div>
<div class="tb-v">Set&lt;Item&gt;</div>
</div>
<div class="tb-cell">
<div class="tb-k">Sheet</div>
<div class="tb-v">1 / 1</div>
</div>
<div class="tb-cell">
<div class="tb-k">Composition</div>
<div class="tb-v">+5 caps</div>
</div>
</div>
<!-- LEGEND -->
<div class="legend">
<span
><span class="sw"></span> <b>highlighted region</b> the part of the list this function
owns</span
>
<span><b></b> skeleton placeholder</span>
<span><b></b> subset</span>
<span><b></b> assignment / effect</span>
<span><span class="warn"></span> failure mode</span>
<span><span class="perf"></span> critical-performance threshold</span>
</div>
<!-- VIEWER -->
<h2 class="section">Functional viewer select a function</h2>
<div class="tabs">
<button
v-for="t in TABS"
:key="t.id"
class="tab"
:class="{ active: activeId === t.id, full: t.full }"
@click="activeId = t.id"
>
{{ t.label }}
</button>
</div>
<div class="viewer">
<div class="panel">
<div class="cap">
<span>Specimen · List&lt;Item&gt;</span><span class="id">{{ specId }}</span>
</div>
<div class="body"><ListSpecimen :active-id="activeId" /></div>
</div>
<div class="panel">
<div class="cap"><span>Readout · Contract</span><span class="id">§ list.als</span></div>
<div class="body">
<FunctionReadout v-if="activeFn" :fn="activeFn" />
<div v-else>
<div class="flex flex-wrap items-baseline gap-2">
<span class="text-lg font-bold tracking-wide text-secondary">FULL VIEW</span>
</div>
<p class="mb-4 mt-1 text-xs text-base-content/50">
The standard composition and its seven functions. Select a function above to focus it.
</p>
<div class="rblock">
<div class="rk"><span>Composed signature</span><span class="rule" /></div>
<div class="sig-block">
<div class="sig-head">{{ COMPOSED_SIG.header }}</div>
<div v-for="f in COMPOSED_SIG.fields" :key="f.name" class="sig-field">
<span class="sig-name">{{ f.name }}</span>
<span class="sig-type">{{ f.type }}</span>
<span class="sig-note">-- {{ f.note }}</span>
</div>
<div class="sig-types">
<div v-for="t in COMPOSED_SIG.types" :key="t">{{ t }}</div>
</div>
</div>
</div>
<div class="rblock">
<div class="rk"><span>Core invariants</span><span class="rule" /></div>
<ul class="tight">
<li v-for="i in CORE_INVARIANTS" :key="i">{{ i }}</li>
</ul>
</div>
<div class="rblock">
<div class="rk"><span>Function index</span><span class="rule" /></div>
<div class="fn-index">
<template v-for="f in FUNCTIONS" :key="f.id">
<button class="fi-n" @click="activeId = f.id">{{ f.name }}</button>
<div class="fi-d">{{ f.verb }}</div>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- COMPANION 1 -->
<h2 class="section">Anatomy where the 7 functions come from</h2>
<div class="panel">
<div class="cap">
<span>Composition map</span><span class="id">Set List capabilities</span>
</div>
<div class="body svg-wrap"><CompositionMap /></div>
</div>
<!-- COMPANION 2 -->
<h2 class="section">Lifecycle LoadState machine</h2>
<div class="panel">
<div class="cap">
<span>Composed machine · List + Async + Paginated + Pullable</span
><span class="id">loadState : LoadState</span>
</div>
<div class="body svg-wrap"><StateMachine /></div>
</div>
<footer class="credit">
<div>
<span class="ck">SOURCE</span> blueprint-ontology / blueprints / list · formal model:
<code>list.als</code>, <code>list.test.als</code>
</div>
<div>
<span class="ck">READS AS</span> a <i>concept</i> (Jackson) / <i>contract</i> declarative,
categorical, abstract
</div>
<div>
<span class="ck">SCOPE</span> standard composition:
<code>List + Async + Paginated + Pullable + Selectable + Filterable</code>
</div>
</footer>
</div>
</template>
<style scoped>
.sheet {
max-width: 1180px;
margin: 0 auto;
padding: 22px 20px 60px;
color: var(--color-base-content);
font-size: 13px;
line-height: 1.5;
}
code {
color: var(--color-secondary);
}
/* title block */
.titleblock {
border: 1.5px solid rgba(200, 226, 255, 0.3);
display: grid;
grid-template-columns: 1fr 130px 90px 120px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent);
}
.tb-cell {
border-left: 1px solid rgba(200, 226, 255, 0.14);
padding: 8px 12px;
}
.tb-k {
color: #4f7099;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.tb-v {
margin-top: 2px;
}
.title-main {
padding: 12px 14px 10px;
}
.title-main h1 {
margin: 0;
font-size: 20px;
letter-spacing: 0.02em;
font-weight: 600;
}
.title-main h1 .brk {
color: var(--color-accent);
}
.title-main .sub {
color: #7ea6cd;
font-size: 12px;
margin-top: 4px;
}
/* legend */
.legend {
display: flex;
flex-wrap: wrap;
gap: 6px 20px;
margin: 12px 0 18px;
padding: 8px 12px;
border: 1px solid rgba(200, 226, 255, 0.14);
color: #7ea6cd;
font-size: 11px;
}
.legend b {
color: var(--color-base-content);
}
.legend .sw {
color: var(--color-accent);
}
.legend .warn {
color: var(--color-error);
}
.legend .perf {
color: var(--color-warning);
}
.section {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #7ea6cd;
font-weight: 600;
margin: 28px 0 10px;
display: flex;
align-items: center;
gap: 10px;
}
.section::after {
content: "";
flex: 1;
height: 1px;
background: rgba(200, 226, 255, 0.14);
}
/* tabs */
.tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.tab {
font-family: var(--font-mono);
font-size: 12px;
color: #7ea6cd;
background: transparent;
border: 1px solid rgba(200, 226, 255, 0.14);
padding: 5px 12px;
cursor: pointer;
letter-spacing: 0.06em;
transition: all 0.12s;
}
.tab:hover {
color: var(--color-base-content);
border-color: rgba(200, 226, 255, 0.3);
}
.tab.active {
color: var(--color-accent-content);
background: var(--color-accent);
border-color: var(--color-accent);
font-weight: 700;
}
.tab.full {
color: var(--color-secondary);
}
.tab.full.active {
color: var(--color-secondary-content);
background: var(--color-secondary);
border-color: var(--color-secondary);
}
/* viewer */
.viewer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
align-items: start;
}
@media (max-width: 820px) {
.viewer {
grid-template-columns: 1fr;
}
}
.panel {
border: 1.5px solid rgba(200, 226, 255, 0.3);
background: var(--color-base-200);
position: relative;
}
.panel::before,
.panel::after {
content: "";
position: absolute;
width: 8px;
height: 8px;
pointer-events: none;
}
.panel::before {
top: -1px;
left: -1px;
border-top: 1.5px solid var(--color-accent);
border-left: 1.5px solid var(--color-accent);
}
.panel::after {
bottom: -1px;
right: -1px;
border-bottom: 1.5px solid var(--color-accent);
border-right: 1.5px solid var(--color-accent);
}
.panel .cap {
display: flex;
justify-content: space-between;
align-items: center;
padding: 7px 12px;
border-bottom: 1px solid rgba(200, 226, 255, 0.14);
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #7ea6cd;
background: rgba(255, 255, 255, 0.02);
}
.panel .cap .id {
color: #4f7099;
}
.panel .body {
padding: 16px;
}
.svg-wrap {
overflow-x: auto;
}
/* full-view readout */
.rblock {
margin-bottom: 0.875rem;
}
.rk {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-base-content) 50%, transparent);
margin-bottom: 0.35rem;
}
.rk .rule {
flex: 1;
height: 1px;
background: rgba(200, 226, 255, 0.14);
}
.tight {
margin: 0;
padding-left: 1rem;
list-style: disc;
}
.tight li {
margin: 0.125rem 0;
}
.sig-block {
background: var(--color-base-100);
border: 1px solid rgba(200, 226, 255, 0.14);
padding: 10px 12px;
font-size: 12px;
line-height: 1.7;
overflow-x: auto;
}
.sig-head {
color: var(--color-base-content);
margin-bottom: 4px;
}
.sig-field {
display: grid;
grid-template-columns: 8.5rem 8.5rem 1fr;
white-space: nowrap;
}
.sig-name {
padding-left: 1rem;
color: var(--color-base-content);
}
.sig-type {
color: var(--color-secondary);
}
.sig-note {
color: #4f7099;
}
.sig-types {
margin-top: 8px;
color: #7ea6cd;
}
.fn-index {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 12px;
font-size: 12px;
align-items: baseline;
}
.fi-n {
color: var(--color-accent);
background: none;
border: none;
padding: 0;
text-align: left;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
}
.fi-n:hover {
text-decoration: underline;
}
.fi-d {
color: #7ea6cd;
}
.credit {
margin-top: 34px;
padding-top: 14px;
border-top: 1px solid rgba(200, 226, 255, 0.14);
color: #4f7099;
font-size: 11px;
line-height: 1.8;
}
.credit .ck {
color: #7ea6cd;
}
</style>

View File

@@ -0,0 +1,37 @@
# Icons
Reusable in-app icons. Source: https://tabler.io/icons (outline variant by default).
## Add an icon
1. Find the icon on https://tabler.io/icons, click it, copy the SVG (or download).
2. Save it here as `<slug>.svg` — same slug Tabler uses (`bolt.svg`, `qrcode.svg`).
3. Keep `stroke="currentColor"` in the SVG so colour follows Tailwind classes.
## Use an icon
Static colour (cheapest, no extra component):
```vue
<img src="@/assets/icons/bolt.svg" alt="" class="size-5" />
```
Dynamic colour (needs `currentColor` to flow through — paste the SVG inline as a component):
```vue
<template>
<svg
class="size-5 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<!-- paste paths from the Tabler SVG here -->
</svg>
</template>
```
The favicon at `public/favicon.svg` was generated from this same icon set with `currentColor` replaced by the app's primary hex at scaffold time.

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed } from "vue"
import { COMP_CAPS, COMP_FUNCS, COMP_LINKS } from "@/data/listBlueprint"
const PAL = {
boxFill: "#103763",
boxStroke: "#3d5f85",
ink: "#dbe9f7",
inkFaint: "#4f7099",
cyan: "#8fd0ff",
amber: "#ffb84d",
}
const W = 640
const lx = 24
const lw = 170
const rx = 446
const rw = 170
const bh = 34
const top = 16
const lstep = 40
const rstep = 52
const H = top + COMP_CAPS.length * lstep + 16
const capY = (i: number) => top + i * lstep + bh / 2
const funcY = (i: number) => top + i * rstep + bh / 2
const capIndex = computed(
() => Object.fromEntries(COMP_CAPS.map((c, i) => [c.id, i])) as Record<string, number>,
)
const funcIndex = computed(
() => Object.fromEntries(COMP_FUNCS.map((f, i) => [f, i])) as Record<string, number>,
)
const leftBoxes = computed(() =>
COMP_CAPS.map((c, i) => ({ ...c, x: lx, y: capY(i) - bh / 2, w: lw, h: bh })),
)
const rightBoxes = computed(() =>
COMP_FUNCS.map((f, i) => ({ label: f, x: rx, y: funcY(i) - bh / 2, w: rw, h: bh })),
)
const edges = computed(() =>
COMP_LINKS.map(([a, b]) => {
const y1 = capY(capIndex.value[a])
const y2 = funcY(funcIndex.value[b])
const x1 = lx + lw
const x2 = rx
const mx = (x1 + x2) / 2
const color = a === "core" ? PAL.inkFaint : PAL.cyan
return {
key: `${a}-${b}`,
d: `M${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`,
color,
x2,
y2,
}
}),
)
</script>
<template>
<svg :viewBox="`0 0 ${W} ${H}`" role="img" aria-label="Composition map" class="schematic">
<path
v-for="e in edges"
:key="e.key"
:d="e.d"
fill="none"
stroke-width="1.4"
:stroke="e.color"
opacity="0.55"
/>
<circle v-for="e in edges" :key="`c-${e.key}`" :cx="e.x2" :cy="e.y2" r="2.4" :fill="e.color" />
<g v-for="b in leftBoxes" :key="b.id">
<rect
:x="b.x"
:y="b.y"
:width="b.w"
:height="b.h"
rx="2"
:fill="PAL.boxFill"
:stroke="b.core ? PAL.amber : PAL.boxStroke"
stroke-width="1.5"
/>
<text :x="b.x + 12" :y="b.y + 15" :fill="PAL.ink" font-size="12">{{ b.label }}</text>
<text :x="b.x + 12" :y="b.y + 27" :fill="PAL.inkFaint" font-size="9.5">{{ b.sub }}</text>
</g>
<g v-for="b in rightBoxes" :key="b.label">
<rect
:x="b.x"
:y="b.y"
:width="b.w"
:height="b.h"
rx="2"
:fill="PAL.boxFill"
:stroke="PAL.amber"
stroke-width="1.5"
/>
<text :x="b.x + 12" :y="b.y + 21" :fill="PAL.amber" font-size="12">{{ b.label }}</text>
</g>
<text :x="lx" :y="H - 4" :fill="PAL.inkFaint" font-size="10" letter-spacing="1.5">
CAPABILITIES (compose onto host)
</text>
<text :x="rx" :y="H - 4" :fill="PAL.inkFaint" font-size="10" letter-spacing="1.5">
FUNCTIONS
</text>
</svg>
</template>
<style scoped>
.schematic {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
font-family: var(--font-mono);
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import type { BlueprintFunction } from "@/data/listBlueprint"
defineProps<{ fn: BlueprintFunction }>()
</script>
<template>
<div>
<div class="flex flex-wrap items-baseline gap-2">
<span class="text-lg font-bold tracking-wide text-accent">{{ fn.name }}</span>
<span
v-for="c in fn.caps"
:key="c"
class="chip"
:class="c === 'List' ? 'chip-core' : 'chip-cap'"
>{{ c }}</span
>
</div>
<p class="mb-4 mt-1 text-xs text-base-content/50">{{ fn.verb }}</p>
<section v-if="fn.state.length" class="block-sec">
<div class="k"><span>State touched</span><span class="rule" /></div>
<div v-for="[name, type] in fn.state" :key="name" class="flex gap-2">
<span>{{ name }}</span
><span class="text-base-content/40">:</span><span class="text-secondary">{{ type }}</span>
</div>
</section>
<section v-if="fn.behaviors.length" class="block-sec">
<div class="k">
<span>Behavior{{ fn.behaviors.length > 1 ? "s" : "" }}</span
><span class="rule" />
</div>
<div v-for="b in fn.behaviors" :key="b.sig" class="beh">
<div class="text-accent">{{ b.sig }}</div>
<div class="flex gap-2 text-xs text-base-content/70">
<span class="w-7 shrink-0 text-base-content/40">pre</span
><span class="text-base-content">{{ b.pre }}</span>
</div>
<div class="flex gap-2 text-xs text-base-content/70">
<span class="w-7 shrink-0 text-base-content/40">eff</span
><span class="text-base-content">{{ b.eff }}</span>
</div>
</div>
</section>
<section v-if="fn.invariants.length" class="block-sec">
<div class="k">
<span>Invariant{{ fn.invariants.length > 1 ? "s" : "" }}</span
><span class="rule" />
</div>
<ul class="tight">
<li v-for="i in fn.invariants" :key="i">{{ i }}</li>
</ul>
</section>
<section v-if="fn.failures.length" class="block-sec">
<div class="k text-error/80">
<span> Failure mode{{ fn.failures.length > 1 ? "s" : "" }}</span
><span class="rule" />
</div>
<ul class="tight text-error">
<li v-for="x in fn.failures" :key="x">{{ x }}</li>
</ul>
</section>
<section v-if="fn.perf.length" class="block-sec">
<div class="k text-warning/80"><span> Critical performance</span><span class="rule" /></div>
<ul class="tight text-warning">
<li v-for="x in fn.perf" :key="x">{{ x }}</li>
</ul>
</section>
<p v-for="n in fn.notes" :key="n" class="text-[11px] italic text-base-content/40"> {{ n }}</p>
</div>
</template>
<style scoped>
.chip {
border: 1px solid;
font-size: 10px;
padding: 1px 6px;
letter-spacing: 0.06em;
}
.chip-cap {
border-color: var(--color-secondary);
color: var(--color-secondary);
}
.chip-core {
border-color: color-mix(in srgb, var(--color-base-content) 40%, transparent);
color: color-mix(in srgb, var(--color-base-content) 60%, transparent);
}
.block-sec {
margin-bottom: 0.875rem;
}
.k {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: color-mix(in srgb, var(--color-base-content) 50%, transparent);
margin-bottom: 0.35rem;
}
.k .rule {
flex: 1;
height: 1px;
background: color-mix(in srgb, var(--color-base-content) 15%, transparent);
}
.beh {
border-left: 2px solid color-mix(in srgb, var(--color-base-content) 30%, transparent);
padding-left: 0.625rem;
margin-bottom: 0.5rem;
}
.tight {
margin: 0;
padding-left: 1rem;
list-style: disc;
}
.tight li {
margin: 0.125rem 0;
}
</style>

View File

@@ -0,0 +1,579 @@
<script setup lang="ts">
import { computed } from "vue"
import { SAMPLE, type SampleItem } from "@/data/listBlueprint"
const props = defineProps<{ activeId: string }>()
interface FrameToolbar {
search?: { q?: string; hl?: boolean }
sort?: { label: string; hl?: boolean }
select?: { meta: string; hl?: boolean }
badge?: string
badgeNote?: string
}
interface FrameRow {
key: string
item?: SampleItem
skeleton?: boolean
check?: boolean
dim?: boolean
sel?: boolean
win?: boolean
ghost?: boolean
hlTag?: string
hlChev?: boolean
swiped?: boolean
}
interface FrameScrollbar {
hl?: boolean
top: number
h: number
}
interface Frame {
kind: "list" | "display"
note?: string
toolbar?: FrameToolbar
rows: FrameRow[]
scrollbar?: FrameScrollbar
footer?: { text: string; hl?: boolean }
actWrap?: boolean
}
function row(i: number, extra: Partial<FrameRow> = {}): FrameRow {
return { key: `r${i}`, item: SAMPLE[i], ...extra }
}
function skel(i: number): FrameRow {
return { key: `s${i}`, skeleton: true }
}
const frame = computed<Frame>(() => {
switch (props.activeId) {
case "full":
return {
kind: "list",
toolbar: {
search: { hl: true },
sort: { label: "name ↑", hl: true },
select: { meta: "multi", hl: true },
},
rows: [
row(0, { check: false, hlTag: "◈ DISPLAY" }),
row(1, { check: true, sel: true }),
row(2, { check: false, hlChev: true }),
row(3, { check: false }),
row(4, { check: false }),
],
scrollbar: { hl: true, top: 12, h: 44 },
footer: { text: "↻ loadMore ▾ · items 5 / 42", hl: true },
}
case "display":
return {
kind: "display",
note: "Four states, rendered distinctly. The skeleton mirrors item shape so the transition to Idle causes no layout shift.",
rows: [],
}
case "scroll":
return {
kind: "list",
note: "▓ render window — only these rows exist in the DOM. Faded rows are virtualized: not rendered, view references released.",
rows: [
row(0, { ghost: true }),
row(1, { ghost: true }),
row(2, { win: true }),
row(3, { win: true }),
row(4, { win: true }),
row(5, { win: true }),
row(6, { ghost: true }),
row(7, { ghost: true }),
],
scrollbar: { hl: true, top: 30, h: 40 },
}
case "load":
return {
kind: "list",
toolbar: { badge: "loadState = LoadingMore", badgeNote: "appending page 2…" },
rows: [row(0), row(1), row(2), skel(3), skel(4)],
footer: { text: "↻ loadMore ▾ · items 3 / 42", hl: true },
}
case "filter":
return {
kind: "list",
toolbar: { search: { q: '"b"', hl: true } },
note: 'visible = { x ∈ items | name matches "b" } · the underlying items sequence is untouched.',
rows: [
row(0, { dim: true }),
row(1, { hlTag: "◈ visible" }),
row(2, { dim: true }),
row(3, { dim: true }),
row(4, { dim: true }),
],
}
case "sort":
return {
kind: "list",
toolbar: { sort: { label: "name ↓", hl: true } },
note: "Same members, new order (name ↓). Identity is unchanged — only the sequence is reordered.",
rows: [row(7), row(6), row(5), row(4), row(3)],
}
case "select":
return {
kind: "list",
toolbar: { select: { meta: "MULTI · 2 selected", hl: true } },
note: "selectionMode = Multi · selected = { Item B, Item D } ⊆ visible",
rows: [
row(0, { check: false }),
row(1, { check: true, sel: true }),
row(2, { check: false }),
row(3, { check: true, sel: true }),
row(4, { check: false }),
],
}
case "act":
return {
kind: "list",
note: "swipeAction(Item C, left) → reveals consumer-defined contextual actions. The list does not define them; it exposes the hook.",
rows: [row(0), row(1), row(2, { swiped: true }), row(3)],
actWrap: true,
}
default:
return { kind: "list", rows: [] }
}
})
</script>
<template>
<div class="specimen">
<!-- Display: distinct states -->
<template v-if="frame.kind === 'display'">
<p class="note">{{ frame.note }}</p>
<div class="display-grid">
<div class="mini">
<div class="mh">Empty · items = </div>
<div class="mb">
<div class="empty-state">
<div class="big"></div>
no items valid state, not an error
</div>
</div>
</div>
<div class="mini">
<div class="mh">Loading</div>
<div class="mb">
<div class="row">
<div class="txt">
<div class="skel w1" />
<div class="skel w2" />
</div>
</div>
<div class="row">
<div class="txt">
<div class="skel w1" />
<div class="skel w2" />
</div>
</div>
<div class="row">
<div class="txt">
<div class="skel w1" />
<div class="skel w2" />
</div>
</div>
</div>
</div>
<div class="mini">
<div class="mh">Error</div>
<div class="mb">
<div class="error-state"> failed to load<br /><span class="retry"> retry</span></div>
</div>
</div>
<div class="mini">
<div class="mh">Idle</div>
<div class="mb">
<div v-for="i in 3" :key="i" class="mrow">
<span class="n">{{ SAMPLE[i - 1].n }}</span
><span class="mchev">&gt;</span>
</div>
</div>
</div>
</div>
</template>
<!-- List-shaped frames -->
<template v-else>
<p v-if="frame.note" class="note">{{ frame.note }}</p>
<div v-if="frame.toolbar" class="spec-toolbar">
<div v-if="frame.toolbar.badge" class="badge">{{ frame.toolbar.badge }}</div>
<span v-if="frame.toolbar.badgeNote" class="badge-note">{{ frame.toolbar.badgeNote }}</span>
<div
v-if="frame.toolbar.search"
class="search"
:class="{ hl: frame.toolbar.search.hl }"
:data-tag="frame.toolbar.search.hl ? ' FILTER' : undefined"
>
<span v-if="frame.toolbar.search.q" class="q">{{ frame.toolbar.search.q }}</span>
<span v-else class="ph">search</span>
</div>
<div
v-if="frame.toolbar.sort"
class="ctl"
:class="frame.toolbar.sort.hl ? ['hl', 'tag-r'] : []"
:data-tag="frame.toolbar.sort.hl ? ' SORT' : undefined"
>
sort {{ frame.toolbar.sort.label }}
</div>
<div
v-if="frame.toolbar.select"
class="ctl"
:class="frame.toolbar.select.hl ? ['hl', 'tag-r'] : []"
:data-tag="frame.toolbar.select.hl ? ' SELECT' : undefined"
>
{{ frame.toolbar.select.meta }}
</div>
</div>
<div
class="listbox"
:class="frame.actWrap ? ['hl', 'tag-b'] : []"
:data-tag="frame.actWrap ? ' ACT' : undefined"
>
<div class="listwrap">
<div class="rows">
<div
v-for="r in frame.rows"
:key="r.key"
class="row"
:class="{ dim: r.dim, sel: r.sel, win: r.win, ghost: r.ghost, swiped: r.swiped }"
>
<span v-if="r.check !== undefined" class="chk" :class="{ on: r.check }">{{
r.check ? "" : ""
}}</span>
<div v-if="r.skeleton" class="txt">
<div class="skel w1" />
<div class="skel w2" />
</div>
<div v-else class="txt" :class="{ hl: r.hlTag }" :data-tag="r.hlTag">
<div class="n">{{ r.item?.n }}</div>
<div class="s">{{ r.item?.s }}</div>
</div>
<template v-if="r.swiped">
<span class="swipehint"> swiped</span>
<div class="actions">
<span class="act archive">Archive</span><span class="act delete">Delete</span>
</div>
</template>
<span
v-else
class="chev"
:class="r.hlChev ? ['hl', 'tag-r'] : []"
:data-tag="r.hlChev ? ' ACT' : undefined"
>&gt;</span
>
</div>
</div>
<div
v-if="frame.scrollbar"
class="scrollbar"
:class="frame.scrollbar.hl ? ['hl', 'tag-r'] : []"
:data-tag="frame.scrollbar.hl ? ' SCROLL' : undefined"
>
<div
class="thumb"
:style="{ top: frame.scrollbar.top + '%', height: frame.scrollbar.h + '%' }"
/>
</div>
</div>
<div
v-if="frame.footer"
class="footer-load"
:class="frame.footer.hl ? ['hl', 'tag-b'] : []"
:data-tag="frame.footer.hl ? ' LOAD' : undefined"
>
{{ frame.footer.text }}
</div>
</div>
</template>
</div>
</template>
<style scoped>
.specimen {
--ink: var(--color-base-content);
--ink-faint: #4f7099;
--amber: var(--color-accent);
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
--cyan: var(--color-secondary);
--red: var(--color-error);
--paper: var(--color-base-100);
--line: rgba(200, 226, 255, 0.14);
--line-strong: rgba(200, 226, 255, 0.3);
font-size: 13px;
}
.note {
color: var(--ink-faint);
font-size: 11px;
font-style: italic;
margin: 0 0 10px;
}
.spec-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
color: color-mix(in srgb, var(--ink) 60%, transparent);
font-size: 12px;
}
.badge-note {
color: var(--ink-faint);
}
.search {
flex: 1;
border: 1px solid var(--line);
background: var(--paper);
padding: 5px 9px;
color: var(--ink);
}
.ph {
color: var(--ink-faint);
}
.q {
color: var(--amber);
}
.ctl {
border: 1px solid var(--line);
padding: 5px 9px;
white-space: nowrap;
}
.badge {
border: 1px solid var(--line-strong);
color: var(--cyan);
padding: 1px 7px;
font-size: 10px;
letter-spacing: 0.08em;
}
.listbox {
border: 1px solid var(--line);
background: var(--paper);
position: relative;
}
.listwrap {
display: flex;
}
.rows {
flex: 1;
min-width: 0;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 11px;
border-bottom: 1px dashed var(--line);
position: relative;
}
.row:last-child {
border-bottom: none;
}
.chk {
width: 14px;
height: 14px;
border: 1px solid var(--ink-faint);
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--amber);
}
.chk.on {
border-color: var(--amber);
background: var(--amber-dim);
}
.txt {
flex: 1;
min-width: 0;
}
.txt .n {
color: var(--ink);
}
.txt .s {
color: var(--ink-faint);
font-size: 11px;
}
.chev {
color: color-mix(in srgb, var(--ink) 60%, transparent);
}
.row.dim {
opacity: 0.32;
}
.row.dim .n {
text-decoration: line-through;
}
.row.sel {
background: var(--amber-dim);
}
.row.win {
background: rgba(143, 208, 255, 0.07);
}
.row.ghost {
opacity: 0.4;
}
.row.ghost .n,
.row.ghost .s {
color: transparent;
}
.skel {
height: 10px;
background: repeating-linear-gradient(
90deg,
var(--ink-faint),
var(--ink-faint) 6px,
transparent 6px,
transparent 12px
);
opacity: 0.5;
border-radius: 2px;
}
.skel.w1 {
width: 60%;
}
.skel.w2 {
width: 40%;
margin-top: 5px;
}
.actions {
display: flex;
flex: none;
}
.actions .act {
padding: 9px 12px;
font-size: 11px;
border-left: 1px solid var(--line);
}
.actions .archive {
background: rgba(143, 208, 255, 0.12);
color: var(--cyan);
}
.actions .delete {
background: rgba(255, 143, 143, 0.14);
color: var(--red);
}
.swipehint {
color: var(--ink-faint);
font-size: 10px;
margin-left: auto;
padding-right: 8px;
}
.row.swiped {
padding-right: 0;
}
.scrollbar {
width: 8px;
flex: none;
background: rgba(255, 255, 255, 0.03);
border-left: 1px solid var(--line);
position: relative;
}
.scrollbar .thumb {
position: absolute;
left: 1px;
right: 1px;
background: var(--ink-faint);
opacity: 0.6;
}
.footer-load {
padding: 8px 11px;
border-top: 1px solid var(--line);
text-align: center;
color: var(--cyan);
font-size: 11px;
letter-spacing: 0.05em;
}
.display-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mini {
border: 1px solid var(--line);
background: var(--paper);
}
.mini .mh {
padding: 4px 8px;
border-bottom: 1px solid var(--line);
font-size: 10px;
letter-spacing: 0.12em;
color: color-mix(in srgb, var(--ink) 60%, transparent);
text-transform: uppercase;
}
.mini .mb {
padding: 8px;
}
.mini .mrow {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 4px;
border-bottom: 1px dashed var(--line);
font-size: 11px;
}
.mini .mrow:last-child {
border-bottom: none;
}
.mini .mrow .n {
color: var(--ink);
}
.mini .mchev {
margin-left: auto;
color: color-mix(in srgb, var(--ink) 60%, transparent);
}
.empty-state {
padding: 20px 12px;
text-align: center;
color: var(--ink-faint);
}
.empty-state .big {
font-size: 22px;
color: color-mix(in srgb, var(--ink) 60%, transparent);
}
.error-state {
padding: 18px 12px;
text-align: center;
color: var(--red);
line-height: 1.9;
}
.error-state .retry {
color: var(--cyan);
}
/* callouts */
.hl {
outline: 1.5px dashed var(--amber);
outline-offset: 3px;
position: relative;
z-index: 2;
}
.hl[data-tag]::after {
content: attr(data-tag);
position: absolute;
top: -10px;
left: 8px;
background: var(--paper);
color: var(--amber);
font-size: 9px;
padding: 0 5px;
border: 1px solid var(--amber);
white-space: nowrap;
letter-spacing: 0.1em;
line-height: 1.5;
z-index: 3;
}
.hl.tag-r[data-tag]::after {
left: auto;
right: 8px;
}
.hl.tag-b[data-tag]::after {
top: auto;
bottom: -10px;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from "vue"
import { SM_NODES, SM_EDGES, SM_COLORS, type SmKind, type SmNode } from "@/data/listBlueprint"
const paper = "#0d2f54"
const nodeFill = "#103763"
const nodeStroke = "#3d5f85"
const ink = "#dbe9f7"
const inkDim = "#7ea6cd"
const byId: Record<string, SmNode> = Object.fromEntries(SM_NODES.map((n) => [n.id, n]))
function center(n: SmNode): [number, number] {
return [n.x + n.w / 2, n.y + n.h / 2]
}
function edgePoint(n: SmNode, tx: number, ty: number): [number, number] {
const cx = n.x + n.w / 2
const cy = n.y + n.h / 2
const dx = tx - cx
const dy = ty - cy
if (dx === 0 && dy === 0) return [cx, cy]
const sx = dx !== 0 ? n.w / 2 / Math.abs(dx) : Infinity
const sy = dy !== 0 ? n.h / 2 / Math.abs(dy) : Infinity
const s = Math.min(sx, sy)
return [cx + dx * s, cy + dy * s]
}
const markers = (Object.keys(SM_COLORS) as (keyof typeof SM_COLORS)[]).map((k) => ({
id: `ar-${k}`,
color: SM_COLORS[k],
}))
const loading = byId.loading
const loadingCy = loading.y + loading.h / 2
const initEnd = edgePoint(loading, 26, loadingCy)
const initPath = `M31 ${loadingCy} L ${initEnd[0]} ${initEnd[1]}`
const edges = computed(() =>
SM_EDGES.map((e) => {
const a = byId[e.from]
const b = byId[e.to]
const [acx, acy] = center(a)
const [bcx, bcy] = center(b)
const [sx, sy] = edgePoint(a, bcx, bcy)
const [tx, ty] = edgePoint(b, acx, acy)
const mx = (sx + tx) / 2
const my = (sy + ty) / 2
const dx = tx - sx
const dy = ty - sy
const len = Math.hypot(dx, dy) || 1
const nx = -dy / len
const ny = dx / len
const cx = mx + nx * e.off
const cy = my + ny * e.off
return {
key: `${e.from}-${e.to}-${e.label}`,
d: `M${sx} ${sy} Q ${cx} ${cy} ${tx} ${ty}`,
color: SM_COLORS[e.kind],
marker: `url(#ar-${e.kind})`,
label: e.label,
lx: 0.25 * sx + 0.5 * cx + 0.25 * tx,
ly: 0.25 * sy + 0.5 * cy + 0.25 * ty - 3,
}
}),
)
const legendItems = (() => {
const items: [string, SmKind][] = [
["succeed()", "succeed"],
["fail()", "fail"],
["refresh()", "refresh"],
["loadMore()", "loadMore"],
]
let lxp = 70
return items.map(([t, k]) => {
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 }
lxp += 24 + t.length * 6.6 + 26
return item
})
})()
</script>
<template>
<svg viewBox="0 -6 900 330" role="img" aria-label="LoadState machine" class="schematic">
<defs>
<marker
v-for="m in markers"
:id="m.id"
:key="m.id"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="7"
markerHeight="7"
orient="auto-start-reverse"
>
<path d="M0 0 L10 5 L0 10 z" :fill="m.color" />
</marker>
</defs>
<!-- initial transition -->
<circle cx="26" :cy="loadingCy" r="5" :fill="SM_COLORS.init" />
<path
:d="initPath"
fill="none"
stroke-width="1.4"
:stroke="SM_COLORS.init"
marker-end="url(#ar-init)"
/>
<!-- transitions -->
<template v-for="e in edges" :key="e.key">
<path :d="e.d" fill="none" stroke-width="1.4" :stroke="e.color" :marker-end="e.marker" />
<text :x="e.lx" :y="e.ly" :fill="e.color" :stroke="paper" text-anchor="middle" class="elabel">
{{ e.label }}
</text>
</template>
<!-- states -->
<g v-for="n in SM_NODES" :key="n.id">
<rect
:x="n.x"
:y="n.y"
:width="n.w"
:height="n.h"
rx="3"
:fill="nodeFill"
:stroke="nodeStroke"
stroke-width="1.5"
/>
<text
:x="n.x + n.w / 2"
:y="n.y + n.h / 2 + 4"
text-anchor="middle"
:fill="ink"
font-size="12"
>
{{ n.label }}
</text>
</g>
<!-- legend -->
<g v-for="l in legendItems" :key="l.t">
<line :x1="l.x1" :y1="l.y" :x2="l.x2" :y2="l.y" :stroke="l.color" stroke-width="2" />
<text :x="l.tx" :y="l.ty" :fill="inkDim" font-size="10">{{ l.t }}</text>
</g>
</svg>
</template>
<style scoped>
.schematic {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
font-family: var(--font-mono);
}
.elabel {
font-size: 10px;
paint-order: stroke;
stroke-width: 3.5px;
stroke-linejoin: round;
}
</style>

318
src/data/listBlueprint.ts Normal file
View File

@@ -0,0 +1,318 @@
// 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.
export interface SampleItem {
n: string
s: string
}
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 interface SigField {
name: string
type: string
note: string
}
export const COMPOSED_SIG: {
header: string
fields: SigField[]
types: string[]
} = {
header: "List<Item> + 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 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"],
],
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?"],
},
]
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<SmKind | "init", string> = {
succeed: "#7fdca0",
fail: "#ff8f8f",
refresh: "#ffb84d",
loadMore: "#8fd0ff",
init: "#4f7099",
}

5
src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
import { createApp } from "vue"
import "./style.css"
import App from "./App.vue"
createApp(App).mount("#app")

76
src/style.css Normal file
View File

@@ -0,0 +1,76 @@
/* The font @import MUST come first — before @import "tailwindcss" — because
Tailwind inlines its import into real CSS rules, and the CSS spec requires
@import to precede all other rules. If it comes second, the production build
warns ("@import must precede all rules…") and browsers silently DROP the
font import, so the custom font never loads. */
@import url("https://api.fonts.coollabs.io/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap");
@import "tailwindcss";
@plugin "daisyui";
/* Custom cyanotype "blueprint" theme — deep blueprint-navy paper, light ink,
classic blueprint-blue primary, amber highlight accent. */
@plugin "daisyui/theme" {
name: "blueprint";
default: true;
prefersdark: true;
color-scheme: dark;
--color-base-100: #0a2645; /* blueprint-navy paper */
--color-base-200: #0d2f54; /* panel */
--color-base-300: #123f6d; /* raised / hairlines */
--color-base-content: #dbe9f7;
--color-primary: #1b4f91; /* classic blueprint blue */
--color-primary-content: #eaf3ff;
--color-secondary: #8fd0ff; /* cyan ink */
--color-secondary-content: #061a30;
--color-accent: #ffb84d; /* amber highlight */
--color-accent-content: #241500;
--color-neutral: #123f6d;
--color-neutral-content: #dbe9f7;
--color-info: #8fd0ff;
--color-info-content: #061a30;
--color-success: #7fdca0;
--color-success-content: #06210f;
--color-warning: #ffb84d;
--color-warning-content: #241500;
--color-error: #ff8f8f;
--color-error-content: #2a0606;
/* crisp, technical geometry */
--radius-selector: 0.125rem;
--radius-field: 0.125rem;
--radius-box: 0.25rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@theme {
--font-sans: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
/* Blueprint grid paper, applied globally. */
@layer base {
body {
min-height: 100svh;
background-color: var(--color-base-100);
background-image:
linear-gradient(rgba(200, 226, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(200, 226, 255, 0.14) 1px, transparent 1px),
linear-gradient(rgba(200, 226, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(200, 226, 255, 0.05) 1px, transparent 1px);
background-size:
120px 120px,
120px 120px,
24px 24px,
24px 24px;
background-position: -1px -1px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}

16
tsconfig.app.json Normal file
View File

@@ -0,0 +1,16 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"paths": { "@/*": ["./src/*"] },
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

4
tsconfig.json Normal file
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

23
tsconfig.node.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { fileURLToPath, URL } from "node:url"
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import tailwindcss from "@tailwindcss/vite"
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
})