feat: gallery + generic blueprint viewer, add Grid blueprint

Refactor the List-specific screen into a data-driven, reusable viewer:
- generic Blueprint type + shared LoadState machine + registry
- vue-router: / (gallery) and /b/:slug (illustration)
- BlueprintViewer renders any blueprint; specimens stay bespoke
- add Grid blueprint (6 functions incl. Position) + GridSpecimen
This commit is contained in:
Julien Calixte
2026-07-02 18:34:49 +02:00
parent 4405497658
commit 03804b10dc
21 changed files with 2304 additions and 765 deletions

206
src/data/gridBlueprint.ts Normal file
View File

@@ -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<Item>",
tagline: "A parameterized, scrollable set of items in a 2D cell layout.",
role: "feature",
extendsName: "Set<Item>",
composesCount: 6,
sig: {
header: "Grid<Item> + 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,
}