nodality 1.0.221 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +227 -0
- package/bin/nodality.js +64 -0
- package/dist/animator.cjs.js +1 -1
- package/dist/animator.cjs.js.LICENSE.txt +1 -1
- package/dist/animator.esm.js +1 -1
- package/dist/animator.esm.js.LICENSE.txt +1 -1
- package/dist/audionew.cjs.js +1 -1
- package/dist/audionew.cjs.js.LICENSE.txt +1 -1
- package/dist/audionew.esm.js +1 -1
- package/dist/audionew.esm.js.LICENSE.txt +1 -1
- package/dist/base.cjs.js +1 -1
- package/dist/base.cjs.js.LICENSE.txt +1 -1
- package/dist/base.esm.js +1 -1
- package/dist/base.esm.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.cjs.js +1 -1
- package/dist/beta-desktop-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-desktop-bar.esm.js +1 -1
- package/dist/beta-desktop-bar.esm.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.cjs.js +1 -1
- package/dist/beta-mobile-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/beta-mobile-bar.esm.js +1 -1
- package/dist/beta-mobile-bar.esm.js.LICENSE.txt +1 -1
- package/dist/bundle.umd.js +1 -1
- package/dist/bundle.umd.js.LICENSE.txt +131 -1
- package/dist/button.cjs.js +1 -1
- package/dist/button.cjs.js.LICENSE.txt +1 -1
- package/dist/button.esm.js +1 -1
- package/dist/button.esm.js.LICENSE.txt +1 -1
- package/dist/card-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/card-getter.esm.js.LICENSE.txt +1 -1
- package/dist/center.cjs.js +1 -1
- package/dist/center.cjs.js.LICENSE.txt +1 -1
- package/dist/center.esm.js +1 -1
- package/dist/center.esm.js.LICENSE.txt +1 -1
- package/dist/checkbox.cjs.js +1 -1
- package/dist/checkbox.cjs.js.LICENSE.txt +1 -1
- package/dist/checkbox.esm.js +1 -1
- package/dist/checkbox.esm.js.LICENSE.txt +1 -1
- package/dist/code.cjs.js +1 -1
- package/dist/code.cjs.js.LICENSE.txt +1 -1
- package/dist/code.esm.js +1 -1
- package/dist/code.esm.js.LICENSE.txt +1 -1
- package/dist/container.cjs.js +1 -1
- package/dist/container.cjs.js.LICENSE.txt +1 -1
- package/dist/container.esm.js +1 -1
- package/dist/container.esm.js.LICENSE.txt +1 -1
- package/dist/data-list.cjs.js +1 -1
- package/dist/data-list.cjs.js.LICENSE.txt +1 -1
- package/dist/data-list.esm.js +1 -1
- package/dist/data-list.esm.js.LICENSE.txt +1 -1
- package/dist/designer.cjs.js +1 -1
- package/dist/designer.cjs.js.LICENSE.txt +97 -1
- package/dist/designer.esm.js +1 -1
- package/dist/designer.esm.js.LICENSE.txt +97 -1
- package/dist/element-mapper.cjs.js +1 -1
- package/dist/element-mapper.cjs.js.LICENSE.txt +17 -1
- package/dist/element-mapper.esm.js +1 -1
- package/dist/element-mapper.esm.js.LICENSE.txt +17 -1
- package/dist/finalresult.esm.js +1 -1
- package/dist/finalresult.esm.js.LICENSE.txt +158 -1
- package/dist/flex-card.cjs.js +1 -1
- package/dist/flex-card.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-card.esm.js +1 -1
- package/dist/flex-card.esm.js.LICENSE.txt +1 -1
- package/dist/flex-grid.cjs.js +1 -1
- package/dist/flex-grid.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-grid.esm.js +1 -1
- package/dist/flex-grid.esm.js.LICENSE.txt +1 -1
- package/dist/flex-row.cjs.js +1 -1
- package/dist/flex-row.cjs.js.LICENSE.txt +1 -1
- package/dist/flex-row.esm.js +1 -1
- package/dist/flex-row.esm.js.LICENSE.txt +1 -1
- package/dist/floating-input.cjs.js +1 -1
- package/dist/floating-input.cjs.js.LICENSE.txt +1 -1
- package/dist/floating-input.esm.js +1 -1
- package/dist/floating-input.esm.js.LICENSE.txt +1 -1
- package/dist/free.cjs.js +1 -1
- package/dist/free.cjs.js.LICENSE.txt +1 -1
- package/dist/free.esm.js +1 -1
- package/dist/free.esm.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.cjs.js.LICENSE.txt +1 -1
- package/dist/horizontal-scroller.esm.js.LICENSE.txt +1 -1
- package/dist/image-picker.cjs.js +1 -1
- package/dist/image-picker.cjs.js.LICENSE.txt +1 -1
- package/dist/image-picker.esm.js +1 -1
- package/dist/image-picker.esm.js.LICENSE.txt +1 -1
- package/dist/image.cjs.js +1 -1
- package/dist/image.cjs.js.LICENSE.txt +1 -1
- package/dist/image.esm.js +1 -1
- package/dist/image.esm.js.LICENSE.txt +1 -1
- package/dist/index.cjs.js +1 -1
- package/dist/index.cjs.js.LICENSE.txt +158 -1
- package/dist/index.d.ts +962 -0
- package/dist/index.esm.js +1 -1
- package/dist/index.esm.js.LICENSE.txt +158 -1
- package/dist/keyframe-animation.cjs.js.LICENSE.txt +1 -1
- package/dist/keyframe-animation.esm.js.LICENSE.txt +1 -1
- package/dist/link-getter.cjs.js +1 -1
- package/dist/link-getter.cjs.js.LICENSE.txt +1 -1
- package/dist/link-getter.esm.js +1 -1
- package/dist/link-getter.esm.js.LICENSE.txt +1 -1
- package/dist/link.cjs.js +1 -1
- package/dist/link.cjs.js.LICENSE.txt +1 -1
- package/dist/link.esm.js +1 -1
- package/dist/link.esm.js.LICENSE.txt +1 -1
- package/dist/meta-adder.cjs.js +1 -1
- package/dist/meta-adder.cjs.js.LICENSE.txt +1 -1
- package/dist/meta-adder.esm.js +1 -1
- package/dist/meta-adder.esm.js.LICENSE.txt +1 -1
- package/dist/modal-2025.cjs.js +1 -1
- package/dist/modal-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/modal-2025.esm.js +1 -1
- package/dist/modal-2025.esm.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.cjs.js +1 -1
- package/dist/multiswitcher.cjs.js.LICENSE.txt +1 -1
- package/dist/multiswitcher.esm.js +1 -1
- package/dist/multiswitcher.esm.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.cjs.js +1 -1
- package/dist/new-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/new-nav-bar.esm.js +1 -1
- package/dist/new-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/picker.cjs.js +1 -1
- package/dist/picker.cjs.js.LICENSE.txt +1 -1
- package/dist/picker.esm.js +1 -1
- package/dist/picker.esm.js.LICENSE.txt +1 -1
- package/dist/progress.cjs.js +1 -1
- package/dist/progress.cjs.js.LICENSE.txt +1 -1
- package/dist/progress.esm.js +1 -1
- package/dist/progress.esm.js.LICENSE.txt +1 -1
- package/dist/radio.cjs.js +1 -1
- package/dist/radio.cjs.js.LICENSE.txt +1 -1
- package/dist/radio.esm.js +1 -1
- package/dist/radio.esm.js.LICENSE.txt +1 -1
- package/dist/range.cjs.js +1 -1
- package/dist/range.cjs.js.LICENSE.txt +1 -1
- package/dist/range.esm.js +1 -1
- package/dist/range.esm.js.LICENSE.txt +1 -1
- package/dist/scroll-video.cjs.js.LICENSE.txt +1 -1
- package/dist/scroll-video.esm.js.LICENSE.txt +1 -1
- package/dist/side-bar.cjs.js +1 -1
- package/dist/side-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-bar.esm.js +1 -1
- package/dist/side-bar.esm.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.cjs.js +1 -1
- package/dist/side-nav-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/side-nav-bar.esm.js +1 -1
- package/dist/side-nav-bar.esm.js.LICENSE.txt +1 -1
- package/dist/simple-bar.cjs.js +1 -1
- package/dist/simple-bar.cjs.js.LICENSE.txt +1 -1
- package/dist/simple-bar.esm.js +1 -1
- package/dist/simple-bar.esm.js.LICENSE.txt +1 -1
- package/dist/slider-2025.cjs.js +1 -1
- package/dist/slider-2025.cjs.js.LICENSE.txt +1 -1
- package/dist/slider-2025.esm.js +1 -1
- package/dist/slider-2025.esm.js.LICENSE.txt +1 -1
- package/dist/spacer.cjs.js +1 -1
- package/dist/spacer.cjs.js.LICENSE.txt +1 -1
- package/dist/spacer.esm.js +1 -1
- package/dist/spacer.esm.js.LICENSE.txt +1 -1
- package/dist/stack.cjs.js +1 -1
- package/dist/stack.cjs.js.LICENSE.txt +1 -1
- package/dist/stack.esm.js +1 -1
- package/dist/stack.esm.js.LICENSE.txt +1 -1
- package/dist/stacker.cjs.js.LICENSE.txt +1 -1
- package/dist/stacker.esm.js.LICENSE.txt +1 -1
- package/dist/table.cjs.js +1 -1
- package/dist/table.cjs.js.LICENSE.txt +1 -1
- package/dist/table.esm.js +1 -1
- package/dist/table.esm.js.LICENSE.txt +1 -1
- package/dist/text-field.cjs.js +1 -1
- package/dist/text-field.cjs.js.LICENSE.txt +1 -1
- package/dist/text-field.esm.js +1 -1
- package/dist/text-field.esm.js.LICENSE.txt +1 -1
- package/dist/text.cjs.js +1 -1
- package/dist/text.cjs.js.LICENSE.txt +1 -1
- package/dist/text.esm.js +1 -1
- package/dist/text.esm.js.LICENSE.txt +1 -1
- package/dist/theme.cjs.js.LICENSE.txt +1 -1
- package/dist/theme.esm.js.LICENSE.txt +1 -1
- package/dist/transform-anim.cjs.js.LICENSE.txt +1 -1
- package/dist/transform-anim.esm.js.LICENSE.txt +1 -1
- package/dist/ulist.cjs.js +1 -1
- package/dist/ulist.cjs.js.LICENSE.txt +1 -1
- package/dist/ulist.esm.js +1 -1
- package/dist/ulist.esm.js.LICENSE.txt +1 -1
- package/dist/video.cjs.js +1 -1
- package/dist/video.cjs.js.LICENSE.txt +1 -1
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.LICENSE.txt +1 -1
- package/dist/wrap.cjs.js +1 -1
- package/dist/wrap.cjs.js.LICENSE.txt +1 -1
- package/dist/wrap.esm.js +1 -1
- package/dist/wrap.esm.js.LICENSE.txt +1 -1
- package/dist/zoom-card.cjs.js +1 -1
- package/dist/zoom-card.cjs.js.LICENSE.txt +1 -1
- package/dist/zoom-card.esm.js +1 -1
- package/dist/zoom-card.esm.js.LICENSE.txt +1 -1
- package/examples/custom-raster-op.js +174 -0
- package/layout/animator.js +30 -2
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +3 -2
- package/layout/button.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +2 -2
- package/layout/circle.js +3 -2
- package/layout/code.js +3 -2
- package/layout/container.js +3 -4
- package/layout/dropdown-2025.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +3 -2
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +2 -2
- package/layout/form-components/floating-input.js +2 -2
- package/layout/form-components/form-all.js +2 -2
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +2 -2
- package/layout/form-components/radio.js +2 -2
- package/layout/form-components/radiogroup.js +2 -2
- package/layout/form-components/range.js +2 -2
- package/layout/free.js +3 -4
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/link.js +5 -5
- package/layout/list.js +3 -4
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/morph.js +1003 -0
- package/layout/multiswitcher.js +11 -3
- package/layout/nav-bar.js +2 -3
- package/layout/nav-factor/custom-div.js +2 -2
- package/layout/new-nav-bar.js +11 -4
- package/layout/polygon.js +3 -4
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +1 -1
- package/layout/side-nav-bar.js +1 -1
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/svg.js +3 -4
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +5 -5
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/codegen.js +62 -0
- package/lib/data.js +37 -1
- package/lib/designer.js +18 -1
- package/lib/element-mapper.js +351 -247
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/morph-node.js +424 -0
- package/lib/raster-inspect.js +409 -0
- package/lib/raster-ops.js +1980 -81
- package/lib/raster-presets.js +329 -0
- package/lib/scroll-video.js +1 -1
- package/lib/seo.js +28 -1
- package/lib/stacker.js +1 -1
- package/lib/suggest.js +66 -0
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/lib/transition.js +205 -0
- package/package.json +20 -7
package/layout/morph.js
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* nodality v1.1.0
|
|
3
|
+
* (c) 2026 Filip Vabrousek
|
|
4
|
+
* License: MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Levenshtein "did you mean" matching, shared with ElementMapper since
|
|
8
|
+
// phase P3. Pure, no DOM — importing it does not weaken the purity gate
|
|
9
|
+
// below, which is asserted by test rather than trusted.
|
|
10
|
+
import { suggest } from "../lib/suggest.js";
|
|
11
|
+
|
|
12
|
+
// morph.js — the pure core of the `morph` op.
|
|
13
|
+
//
|
|
14
|
+
// One spec in, ordinary Nodality out:
|
|
15
|
+
//
|
|
16
|
+
// import { expand } from "nodality/morph";
|
|
17
|
+
// const { elements, nodes, slots, meta } = expand(spec);
|
|
18
|
+
// new Des().nodes(nodes).add(elements).set({ mount: "#mount" });
|
|
19
|
+
//
|
|
20
|
+
// Three properties this file exists to hold, all of them testable:
|
|
21
|
+
//
|
|
22
|
+
// PURE — nothing here touches the DOM at import time and nothing
|
|
23
|
+
// imports layout/index.js (which reads `window` at module
|
|
24
|
+
// scope). `node -e "await import('layout/morph.js')"` must
|
|
25
|
+
// exit 0. That purity IS the agent contract and the SSG
|
|
26
|
+
// story: expand() runs in a subprocess, in jsdom, in an MCP
|
|
27
|
+
// server, with no browser anywhere.
|
|
28
|
+
// DETERMINISTIC — no Date.now(), no Math.random(). Variation comes only
|
|
29
|
+
// from mulberry32(seed), the default seed is the constant 1,
|
|
30
|
+
// and every emission walks AXES / REGISTRY / bones in
|
|
31
|
+
// DECLARATION order. Two expand() calls are deep-equal.
|
|
32
|
+
// SEMANTIC — the expansion emits element OPTIONS, never `keySet` and
|
|
33
|
+
// never a <style> tag. If a style needs an option the
|
|
34
|
+
// element classes do not have, the option gets added to the
|
|
35
|
+
// class (with a `//@` annotation); the generator gets no
|
|
36
|
+
// escape hatch. Users keep `keySet` for themselves.
|
|
37
|
+
//
|
|
38
|
+
// morphController() at the bottom is the one DOM-touching export. It is a
|
|
39
|
+
// function body, so importing this module still executes no DOM code.
|
|
40
|
+
|
|
41
|
+
// ── Design source ────────────────────────────────────────────────────
|
|
42
|
+
// Every mapping is lerp(min, max, t) and MUST remain affine — the closed
|
|
43
|
+
// -form inverse ((v - min) / (max - min)) is load-bearing for phase M7
|
|
44
|
+
// (lift). If you feel the need for a curve, put the curve in a morph
|
|
45
|
+
// PATH, never in the mapping.
|
|
46
|
+
const AXES = {
|
|
47
|
+
split: { css: "--nod-split", map: { unit: "%", min: 14, max: 40 }, default: 0.2 },
|
|
48
|
+
density: { css: "--nod-density", map: { unit: "px", min: 8, max: 48 }, default: 0.5 }, // gap
|
|
49
|
+
radius: { css: "--nod-radius", map: { unit: "px", min: 0, max: 24 }, default: 0.6 },
|
|
50
|
+
motion: { css: "--nod-motion", map: { unit: "", min: 0, max: 1 }, default: 0.4 },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The rest of the design source: the magnitudes the registry interpolates.
|
|
54
|
+
// They live here so no expand() body carries a literal — a px number typed
|
|
55
|
+
// inside an expand() is the smell this block removes.
|
|
56
|
+
const SCALE = {
|
|
57
|
+
inset: "12px", // how far a `floating` nav sits off its edges
|
|
58
|
+
layer: 10, // nav stacking order, above page content
|
|
59
|
+
speed: "0.4s", // the motion axis multiplies this into transitions
|
|
60
|
+
dense: "24px", // a `dense` region's padding ceiling — tighter than the
|
|
61
|
+
// full density ramp, which is how a sidebar earns its width
|
|
62
|
+
// Height cap for the DESKTOP bar only. A mobile bar must be free to
|
|
63
|
+
// grow: it stacks in a column and the open hamburger adds the whole
|
|
64
|
+
// link list below the brand, so a cap there clips the menu instead of
|
|
65
|
+
// sizing it. (Matching the production Gesos nav, which caps DesktopBar
|
|
66
|
+
// at 100px and passes no height to MobileBar at all.)
|
|
67
|
+
bar: "100px",
|
|
68
|
+
navBreak: "1200px", // where the Switcher swaps MobileBar → DesktopBar
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Role → fluid type scale step, and role → semantic tag. Two tables, one
|
|
72
|
+
// row per role, so a role's size and its element type are each stated once.
|
|
73
|
+
//
|
|
74
|
+
// The mapping is GLOBAL, so a role means one size everywhere: `side` uses
|
|
75
|
+
// `heading` rather than reusing `title`, which would drag the hero's S1 into
|
|
76
|
+
// a 240px column. M4 should move this onto the kind, where it belongs.
|
|
77
|
+
const TYPE = { brand: "S5", title: "S1", sub: "S4", button: "S6", heading: "S4", body: "S6", label: "S6" };
|
|
78
|
+
const TAG = { brand: "h3", title: "h1", sub: "p", button: "a", heading: "h4", body: "p", label: "a" };
|
|
79
|
+
|
|
80
|
+
// The box the allocator solves against at expand time — the poster values
|
|
81
|
+
// baked into prerendered HTML. M6 replaces the width with the measured
|
|
82
|
+
// container; until then it is a constant, because a time- or environment-
|
|
83
|
+
// derived default would break byte-determinism.
|
|
84
|
+
const CONTAINER = { width: 1200, height: 800 };
|
|
85
|
+
|
|
86
|
+
// Closed set. `required` never yields; the rest yield lowest-first.
|
|
87
|
+
const PRIORITY = { required: Infinity, high: 1, low: 0 };
|
|
88
|
+
// A track that declares a `min` but no priority yields first: an author who
|
|
89
|
+
// did not think about priority should not be the one who blocks the layout.
|
|
90
|
+
const DEFAULT_PRIORITY = "low";
|
|
91
|
+
|
|
92
|
+
const DEFAULT_SEED = 1;
|
|
93
|
+
|
|
94
|
+
const RESERVED = ["op", "target", "seed", "bones", "axes", "paths", "content"];
|
|
95
|
+
|
|
96
|
+
// ── PRNG ─────────────────────────────────────────────────────────────
|
|
97
|
+
function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;
|
|
98
|
+
let t=Math.imul(a^a>>>15,1|a);t=t+Math.imul(t^t>>>7,61|t)^t;
|
|
99
|
+
return((t^t>>>14)>>>0)/4294967296}}
|
|
100
|
+
|
|
101
|
+
// ── Axis → CSS ───────────────────────────────────────────────────────
|
|
102
|
+
// The ONLY place an axis becomes CSS. Affine by construction: the emitted
|
|
103
|
+
// expression is lerp(min, max, t) with t = var(--nod-<axis>). `scale`
|
|
104
|
+
// re-bases the same lerp onto a design-source magnitude (a duration, an
|
|
105
|
+
// inset) — still lerp(0, scale, t), so M7 can still invert it.
|
|
106
|
+
function axisCalc(name, scale){
|
|
107
|
+
const a = AXES[name];
|
|
108
|
+
if (scale != null) return `calc(${scale} * var(${a.css}))`;
|
|
109
|
+
const span = a.map.max - a.map.min;
|
|
110
|
+
return `calc(${a.map.min}${a.map.unit} + ${span}${a.map.unit} * var(${a.css}))`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The numeric value of an axis at t, in its own unit.
|
|
114
|
+
function axisValue(name, t){
|
|
115
|
+
const m = AXES[name].map;
|
|
116
|
+
return m.min + (m.max - m.min) * t;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Registry ─────────────────────────────────────────────────────────
|
|
120
|
+
// Data, and the single source of truth: the region vocabulary, the token
|
|
121
|
+
// vocabulary, the slot roles, and — in M4 — the .d.ts unions and the docs
|
|
122
|
+
// token table are all read from here.
|
|
123
|
+
//
|
|
124
|
+
// Every expand(ctx) returns { options?, children? }. ctx is
|
|
125
|
+
// { axes, seed, rng, slotId(role), content }.
|
|
126
|
+
const REGISTRY = {
|
|
127
|
+
nav: {
|
|
128
|
+
region: true,
|
|
129
|
+
// `type: "nav"` is a Switcher over a MobileBar / DesktopBar pair —
|
|
130
|
+
// the shape ElementMapper.protoNav already builds and the shape the
|
|
131
|
+
// production Gesos nav uses. The hamburger lives on MobileBar and
|
|
132
|
+
// the breakpoint swap is the Switcher's whole job, so responsive
|
|
133
|
+
// behaviour is inherited rather than reimplemented. (UINavBar was
|
|
134
|
+
// the wrong target: one bar that tries to be both, with a hardcoded
|
|
135
|
+
// 600px query and a one-way show/hide.)
|
|
136
|
+
element: "nav",
|
|
137
|
+
slots: ["brand"],
|
|
138
|
+
expand: () => ({ options: {
|
|
139
|
+
// The width at which the Switcher swaps mobile → desktop.
|
|
140
|
+
breakpoint: SCALE.navBreak,
|
|
141
|
+
maxHeight: SCALE.bar,
|
|
142
|
+
} }),
|
|
143
|
+
// A region's array holds tokens (strings) AND kinds (objects), the
|
|
144
|
+
// same shape `main` uses for its sections. Three links is three
|
|
145
|
+
// `{ kind: "link" }` entries, and §2.5's repeated-kind indexing
|
|
146
|
+
// gives them ids without a `count` concept having to exist.
|
|
147
|
+
kinds: {
|
|
148
|
+
link: {
|
|
149
|
+
slots: ["label"],
|
|
150
|
+
bare: true,
|
|
151
|
+
expand: (ctx) => ({ children: [linkEl(ctx, "label", SCALE.dense)] }),
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
tokens: {
|
|
155
|
+
// Detached from the edges — the bars' own `mar` inset.
|
|
156
|
+
floating: {
|
|
157
|
+
conflictsWith: ["pinned"],
|
|
158
|
+
expand: () => ({ options: { mar: [{ a: SCALE.inset }] } }),
|
|
159
|
+
},
|
|
160
|
+
// Flush: no inset at all.
|
|
161
|
+
pinned: {
|
|
162
|
+
conflictsWith: ["floating"],
|
|
163
|
+
expand: () => ({ options: { mar: [{ a: 0 }] } }),
|
|
164
|
+
},
|
|
165
|
+
// MobileBar and DesktopBar both honour `radius`'s VALUE
|
|
166
|
+
// (`obj.radius && (style.borderRadius = obj.radius)`), so the
|
|
167
|
+
// corner really does ride the axis here.
|
|
168
|
+
rounded: {
|
|
169
|
+
expand: () => ({ options: { radius: axisCalc("radius") } }),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
// The column `@split` is actually about. MORPH-PLAN's canonical bones are
|
|
175
|
+
// `areas: ["nav nav", "side main"]` with `cols: ["@split", "1fr"]` — the
|
|
176
|
+
// split sizes the SIDE, not the nav. MORPH-MVP dropped this region while
|
|
177
|
+
// keeping `@split`, which is what left that track with no owner.
|
|
178
|
+
side: {
|
|
179
|
+
region: true,
|
|
180
|
+
tag: "aside",
|
|
181
|
+
slots: ["heading", "body"],
|
|
182
|
+
expand: () => ({ options: { pad: [{ a: axisCalc("density") }] } }),
|
|
183
|
+
tokens: {
|
|
184
|
+
// Rides with the scroll rather than leaving a dead column.
|
|
185
|
+
sticky: { expand: () => ({ options: { sticky: true } }) },
|
|
186
|
+
// Tighter than the content well beside it.
|
|
187
|
+
dense: { expand: () => ({ options: { pad: [{ a: axisCalc("density", SCALE.dense) }] } }) },
|
|
188
|
+
rounded: { expand: () => ({ options: { radius: axisCalc("radius") } }) },
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
main: {
|
|
193
|
+
region: true,
|
|
194
|
+
tag: "main",
|
|
195
|
+
sections: true,
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
section: {
|
|
199
|
+
kinds: {
|
|
200
|
+
hero: {
|
|
201
|
+
slots: ["title", "sub"],
|
|
202
|
+
expand: (ctx) => ({
|
|
203
|
+
options: { pad: [{ a: axisCalc("density") }] },
|
|
204
|
+
children: [
|
|
205
|
+
textEl(ctx, "title"),
|
|
206
|
+
textEl(ctx, "sub"),
|
|
207
|
+
],
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
cta: {
|
|
211
|
+
slots: ["button"],
|
|
212
|
+
expand: (ctx) => ({
|
|
213
|
+
options: { pad: [{ a: axisCalc("density") }], center: true },
|
|
214
|
+
children: [linkEl(ctx, "button")],
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
effect: {
|
|
221
|
+
// M2 implements the op inside lib/raster-ops.js; the token exists
|
|
222
|
+
// here so a spec can be validated without a GPU anywhere near it.
|
|
223
|
+
tokens: { flow: { drivers: ["mouse", "time"] } },
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const REGION_NAMES = Object.keys(REGISTRY).filter((k) => REGISTRY[k].region);
|
|
228
|
+
|
|
229
|
+
// ── Slot elements ────────────────────────────────────────────────────
|
|
230
|
+
// Slot ids are `<region>.<kind>.<role>` and are independent of `seed` —
|
|
231
|
+
// M7's diffing keys on them, so they are test-pinned. The DOM id is the
|
|
232
|
+
// same string with dots swapped for dashes, which keeps it usable as a
|
|
233
|
+
// plain CSS selector.
|
|
234
|
+
function slotElementId(slotId){
|
|
235
|
+
return "nod-" + slotId.replace(/\./g, "-");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Missing content renders its own id, visibly — never lorem, which hides
|
|
239
|
+
// the gap instead of showing it.
|
|
240
|
+
function slotText(ctx, role){
|
|
241
|
+
const id = ctx.slotId(role);
|
|
242
|
+
const v = ctx.content[id];
|
|
243
|
+
if (v == null) return `[${id}]`;
|
|
244
|
+
return typeof v === "object" ? String(v.text ?? `[${id}]`) : String(v);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function textEl(ctx, role){
|
|
248
|
+
const id = ctx.slotId(role);
|
|
249
|
+
return {
|
|
250
|
+
type: TAG[role],
|
|
251
|
+
id: slotElementId(id),
|
|
252
|
+
size: TYPE[role],
|
|
253
|
+
text: slotText(ctx, role),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// `padScale` re-bases the padding onto a smaller design-source magnitude:
|
|
258
|
+
// a nav link wants the tight ramp, a page CTA the full one.
|
|
259
|
+
function linkEl(ctx, role, padScale){
|
|
260
|
+
const id = ctx.slotId(role);
|
|
261
|
+
const v = ctx.content[id];
|
|
262
|
+
return {
|
|
263
|
+
type: TAG[role],
|
|
264
|
+
id: slotElementId(id),
|
|
265
|
+
size: TYPE[role],
|
|
266
|
+
text: slotText(ctx, role),
|
|
267
|
+
url: (v && typeof v === "object" && v.url != null) ? String(v.url) : "#",
|
|
268
|
+
radius: axisCalc("radius"),
|
|
269
|
+
pad: [{ a: padScale ? axisCalc("density", padScale) : axisCalc("density") }],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ── Errors ───────────────────────────────────────────────────────────
|
|
274
|
+
// validate() never throws; expand() throws the first error as prose and
|
|
275
|
+
// carries the whole machine-readable report on err.report — that report is
|
|
276
|
+
// the repair loop M5's MCP server hands to agents.
|
|
277
|
+
const HEADLINE = {
|
|
278
|
+
UNKNOWN_KEY: (e) => `Unknown top-level key "${e.path}".`,
|
|
279
|
+
UNKNOWN_TOKEN: (e) => `Unknown token ${q(e.got)} in region "${regionOf(e.path)}".`,
|
|
280
|
+
UNKNOWN_KIND: (e) => `Unknown section kind ${q(e.got)} in region "${regionOf(e.path)}".`,
|
|
281
|
+
UNKNOWN_SLOT: (e) => `Unknown content slot ${q(e.got)}.`,
|
|
282
|
+
UNKNOWN_DRIVER: (e) => `Unknown effect driver ${q(e.got)} at ${e.path}.`,
|
|
283
|
+
BAD_AXIS: (e) => `Bad axis at ${e.path}: ${describe(e.got)}.`,
|
|
284
|
+
TOKEN_CONFLICT: (e) => `Token ${q(e.got)} conflicts with a token already in region "${regionOf(e.path)}".`,
|
|
285
|
+
BAD_BONES: (e) => `Bad bones at ${e.path || "bones"}: ${describe(e.got)}.`,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const VALID_LABEL = {
|
|
289
|
+
UNKNOWN_KEY: "keys", UNKNOWN_TOKEN: "tokens", UNKNOWN_KIND: "kinds",
|
|
290
|
+
UNKNOWN_SLOT: "slots", UNKNOWN_DRIVER: "drivers", BAD_AXIS: "axes",
|
|
291
|
+
TOKEN_CONFLICT: "tokens", BAD_BONES: "values",
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
function regionOf(path){
|
|
295
|
+
return String(path).split(/[.[]/)[0];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function describe(v){
|
|
299
|
+
if (v === undefined) return "missing";
|
|
300
|
+
if (typeof v === "string") return `"${v}"`;
|
|
301
|
+
if (v === null || typeof v !== "object") return String(v);
|
|
302
|
+
return Array.isArray(v) ? "an array" : "an object";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `got` is usually the offending string, but a malformed region hands us a
|
|
306
|
+
// whole object — quoting that produces "[object Object]".
|
|
307
|
+
const q = (v) => (typeof v === "string" ? `"${v}"` : describe(v));
|
|
308
|
+
|
|
309
|
+
function errorProse(e){
|
|
310
|
+
let out = HEADLINE[e.code](e);
|
|
311
|
+
if (e.suggestions.length) out += `\n Did you mean "${e.suggestions[0]}"?`;
|
|
312
|
+
if (e.valid.length) out += `\n Valid ${VALID_LABEL[e.code]}: ${e.valid.join(", ")}.`;
|
|
313
|
+
return `${out} (${e.code})`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function layoutSpecError(report){
|
|
317
|
+
const err = new Error(errorProse(report.errors[0]));
|
|
318
|
+
err.name = "LayoutSpecError";
|
|
319
|
+
err.report = report;
|
|
320
|
+
return err;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── validate ─────────────────────────────────────────────────────────
|
|
324
|
+
function validate(spec){
|
|
325
|
+
const errors = [];
|
|
326
|
+
const push = (code, path, got, suggestions, valid) => {
|
|
327
|
+
errors.push({ code, path, got, suggestions: suggestions || [], valid: valid || [] });
|
|
328
|
+
};
|
|
329
|
+
const s = (spec && typeof spec === "object" && !Array.isArray(spec)) ? spec : {};
|
|
330
|
+
|
|
331
|
+
// --- axes -------------------------------------------------------
|
|
332
|
+
const axisNames = Object.keys(AXES);
|
|
333
|
+
const given = (s.axes && typeof s.axes === "object" && !Array.isArray(s.axes)) ? s.axes : null;
|
|
334
|
+
if (s.axes !== undefined && !given) push("BAD_AXIS", "axes", s.axes, [], axisNames);
|
|
335
|
+
if (given) {
|
|
336
|
+
for (const name in given) {
|
|
337
|
+
if (!(name in AXES)) {
|
|
338
|
+
push("BAD_AXIS", `axes.${name}`, name, suggest(name, axisNames), axisNames);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const v = given[name];
|
|
342
|
+
if (typeof v !== "number" || !isFinite(v) || v < 0 || v > 1) {
|
|
343
|
+
push("BAD_AXIS", `axes.${name}`, v, [], axisNames);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- bones ------------------------------------------------------
|
|
349
|
+
const regions = regionKeys(s);
|
|
350
|
+
checkBones(s.bones, given, regions, push);
|
|
351
|
+
|
|
352
|
+
// --- regions ----------------------------------------------------
|
|
353
|
+
const vocabulary = declaredRegions(s.bones);
|
|
354
|
+
const keyVocabulary = RESERVED.concat(vocabulary);
|
|
355
|
+
for (const key of Object.keys(s)) {
|
|
356
|
+
if (RESERVED.includes(key)) continue;
|
|
357
|
+
if (!vocabulary.includes(key)) {
|
|
358
|
+
push("UNKNOWN_KEY", key, key, suggest(key, keyVocabulary), keyVocabulary);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
checkRegion(key, s[key], push);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- content ----------------------------------------------------
|
|
365
|
+
if (s.content && typeof s.content === "object" && !Array.isArray(s.content)) {
|
|
366
|
+
const ids = collectSlots(s, regions).map((sl) => sl.id);
|
|
367
|
+
for (const key of Object.keys(s.content)) {
|
|
368
|
+
if (!ids.includes(key)) push("UNKNOWN_SLOT", `content.${key}`, key, suggest(key, ids), ids);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { ok: errors.length === 0, errors };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Top-level keys that are not reserved — the regions this page has, in
|
|
376
|
+
// declaration order.
|
|
377
|
+
function regionKeys(s){
|
|
378
|
+
return Object.keys(s).filter((k) => !RESERVED.includes(k));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// The vocabulary a region key must belong to. `bones.areas` names the
|
|
382
|
+
// regions explicitly; without it the registry's region entries are the
|
|
383
|
+
// vocabulary, and a key outside both is the silently-dead-key bug that
|
|
384
|
+
// ground rule 4 exists to stop.
|
|
385
|
+
function declaredRegions(bones){
|
|
386
|
+
const areas = (bones && !Array.isArray(bones) && bones.areas);
|
|
387
|
+
if (!Array.isArray(areas)) return REGION_NAMES;
|
|
388
|
+
const out = [];
|
|
389
|
+
for (const row of areas) {
|
|
390
|
+
for (const cell of String(row).trim().split(/\s+/)) {
|
|
391
|
+
if (cell !== "." && !out.includes(cell)) out.push(cell);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// The kinds a region may hold: its own vocabulary, or the shared `section`
|
|
398
|
+
// one. `nav` declares `link`; `main` borrows the section kinds.
|
|
399
|
+
function kindsOf(def){
|
|
400
|
+
return def.kinds || (def.sections ? REGISTRY.section.kinds : null);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A region's value is an array whose entries are STRINGS (tokens — how the
|
|
404
|
+
// region behaves) or OBJECTS (kinds — what it contains). One shape for
|
|
405
|
+
// every region, so `nav: ["floating", { kind: "link" }, { kind: "link" }]`
|
|
406
|
+
// needs no syntax that `main` did not already have.
|
|
407
|
+
function checkRegion(name, value, push){
|
|
408
|
+
const def = REGISTRY[name];
|
|
409
|
+
if (!def) return; // not a registry region (areas named it); nothing to check
|
|
410
|
+
const tokens = def.tokens ? Object.keys(def.tokens) : [];
|
|
411
|
+
const kinds = kindsOf(def);
|
|
412
|
+
const kindNames = kinds ? Object.keys(kinds) : [];
|
|
413
|
+
if (!Array.isArray(value)) {
|
|
414
|
+
push(tokens.length ? "UNKNOWN_TOKEN" : "UNKNOWN_KIND", name, value, [],
|
|
415
|
+
tokens.length ? tokens : kindNames);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const seen = [];
|
|
420
|
+
value.forEach((entry, i) => {
|
|
421
|
+
const path = `${name}[${i}]`;
|
|
422
|
+
if (typeof entry === "string") {
|
|
423
|
+
if (!tokens.length) {
|
|
424
|
+
push("UNKNOWN_KIND", path, entry, suggest(entry, kindNames), kindNames);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (!tokens.includes(entry)) {
|
|
428
|
+
push("UNKNOWN_TOKEN", path, entry, suggest(entry, tokens), tokens);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const conflicts = def.tokens[entry].conflictsWith || [];
|
|
432
|
+
if (conflicts.some((c) => seen.includes(c))) {
|
|
433
|
+
// The fix is to drop one of the two, so the useful hint is
|
|
434
|
+
// the tokens that WOULD compose with what is already there.
|
|
435
|
+
const compatible = tokens.filter((v) =>
|
|
436
|
+
v !== entry && !seen.includes(v) &&
|
|
437
|
+
!(def.tokens[v].conflictsWith || []).some((c) => seen.includes(c)));
|
|
438
|
+
push("TOKEN_CONFLICT", path, entry, compatible, tokens);
|
|
439
|
+
}
|
|
440
|
+
seen.push(entry);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const kind = entry && entry.kind;
|
|
445
|
+
if (!kindNames.includes(kind)) {
|
|
446
|
+
push("UNKNOWN_KIND", `${path}.kind`, kind, suggest(kind, kindNames), kindNames);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
checkEffect(entry, path, push);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function checkEffect(entry, path, push){
|
|
454
|
+
if (entry.effect === undefined) return;
|
|
455
|
+
const effects = Object.keys(REGISTRY.effect.tokens);
|
|
456
|
+
if (!Array.isArray(entry.effect)) {
|
|
457
|
+
push("UNKNOWN_TOKEN", `${path}.effect`, entry.effect, [], effects);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const [token, ...drivers] = entry.effect;
|
|
461
|
+
if (!effects.includes(token)) {
|
|
462
|
+
push("UNKNOWN_TOKEN", `${path}.effect[0]`, token, suggest(token, effects), effects);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const valid = REGISTRY.effect.tokens[token].drivers;
|
|
466
|
+
drivers.forEach((d, j) => {
|
|
467
|
+
if (!valid.includes(d)) {
|
|
468
|
+
push("UNKNOWN_DRIVER", `${path}.effect[${j + 1}]`, d, suggest(d, valid), valid);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── bones ────────────────────────────────────────────────────────────
|
|
474
|
+
// A track is a string (`"@split"`, `"1fr"`, `"auto"`, `"240px"`, `"20%"`)
|
|
475
|
+
// or `{ size, min, max, priority }`. Any constrained track routes its
|
|
476
|
+
// whole dimension through the allocator.
|
|
477
|
+
const AXIS_REF = /^@([A-Za-z0-9_]+)$/;
|
|
478
|
+
const FR = /^([0-9.]+)fr$/;
|
|
479
|
+
const PX = /^(-?[0-9.]+)px$/;
|
|
480
|
+
const PCT = /^(-?[0-9.]+)%$/;
|
|
481
|
+
|
|
482
|
+
function checkBones(bones, axes, regions, push){
|
|
483
|
+
if (bones === undefined) {
|
|
484
|
+
push("BAD_BONES", "bones", undefined, [], ["cols", "rows", "gap", "areas"]);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const shape = Array.isArray(bones) ? { cols: bones } : bones;
|
|
488
|
+
if (!shape || typeof shape !== "object") {
|
|
489
|
+
push("BAD_BONES", "bones", bones, [], ["cols", "rows", "gap", "areas"]);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const keys = ["cols", "rows", "gap", "areas"];
|
|
493
|
+
if (!Array.isArray(bones)) {
|
|
494
|
+
for (const k of Object.keys(bones)) {
|
|
495
|
+
if (!keys.includes(k)) push("BAD_BONES", `bones.${k}`, k, suggest(k, keys), keys);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const dim of ["cols", "rows"]) {
|
|
499
|
+
const list = shape[dim];
|
|
500
|
+
if (list === undefined) continue;
|
|
501
|
+
if (!Array.isArray(list)) { push("BAD_BONES", `bones.${dim}`, list, [], []); continue; }
|
|
502
|
+
list.forEach((t, i) => checkTrack(t, `bones.${dim}[${i}]`, axes, push));
|
|
503
|
+
}
|
|
504
|
+
if (shape.gap !== undefined) checkTrack(shape.gap, "bones.gap", axes, push);
|
|
505
|
+
|
|
506
|
+
if (Array.isArray(shape.areas)) {
|
|
507
|
+
const widths = shape.areas.map((r) => String(r).trim().split(/\s+/).length);
|
|
508
|
+
if (widths.some((w) => w !== widths[0])) {
|
|
509
|
+
push("BAD_BONES", "bones.areas", shape.areas, [], []);
|
|
510
|
+
}
|
|
511
|
+
// A declared grid has to be the shape it says it is.
|
|
512
|
+
if (Array.isArray(shape.cols) && widths[0] !== shape.cols.length) {
|
|
513
|
+
push("BAD_BONES", "bones.areas", shape.areas, [], []);
|
|
514
|
+
}
|
|
515
|
+
if (Array.isArray(shape.rows) && shape.areas.length !== shape.rows.length) {
|
|
516
|
+
push("BAD_BONES", "bones.rows", shape.rows, [], []);
|
|
517
|
+
}
|
|
518
|
+
} else if (regions.length && Array.isArray(shape.rows) && shape.rows.length !== regions.length) {
|
|
519
|
+
// Without `areas`, each region takes a row — so a declared `rows`
|
|
520
|
+
// has to have one track per region. Name the cells in `areas` when
|
|
521
|
+
// the layout is not one-region-per-row.
|
|
522
|
+
push("BAD_BONES", "bones.rows", shape.rows, [], []);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function checkTrack(track, path, axes, push){
|
|
527
|
+
const t = (track && typeof track === "object" && !Array.isArray(track)) ? track : { size: track };
|
|
528
|
+
if (track && typeof track === "object" && !Array.isArray(track)) {
|
|
529
|
+
const keys = ["size", "min", "max", "priority"];
|
|
530
|
+
for (const k of Object.keys(track)) {
|
|
531
|
+
if (!keys.includes(k)) push("BAD_BONES", `${path}.${k}`, k, suggest(k, keys), keys);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (typeof t.size !== "string") { push("BAD_BONES", `${path}.size`, t.size, [], []); return; }
|
|
535
|
+
|
|
536
|
+
const ref = AXIS_REF.exec(t.size);
|
|
537
|
+
if (ref) {
|
|
538
|
+
const name = ref[1];
|
|
539
|
+
const axisNames = Object.keys(AXES);
|
|
540
|
+
if (!(name in AXES)) {
|
|
541
|
+
push("BAD_BONES", `${path}.size`, t.size, suggest(name, axisNames), axisNames);
|
|
542
|
+
} else if (AXES[name].map.unit === "") {
|
|
543
|
+
// A unitless axis is a factor, not a length: it cannot size a track.
|
|
544
|
+
push("BAD_BONES", `${path}.size`, t.size, [], axisNames.filter((n) => AXES[n].map.unit !== ""));
|
|
545
|
+
} else if (!axes || !(name in axes)) {
|
|
546
|
+
// Routing a track through an axis means stating that axis's value.
|
|
547
|
+
push("BAD_AXIS", `axes.${name}`, undefined, [], axisNames);
|
|
548
|
+
}
|
|
549
|
+
} else if (!FR.test(t.size) && !PX.test(t.size) && !PCT.test(t.size) && t.size !== "auto") {
|
|
550
|
+
push("BAD_BONES", `${path}.size`, t.size, [], ["@<axis>", "<n>fr", "<n>px", "<n>%", "auto"]);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
for (const bound of ["min", "max"]) {
|
|
554
|
+
if (t[bound] === undefined) continue;
|
|
555
|
+
if (typeof t[bound] !== "string" || !PX.test(t[bound])) {
|
|
556
|
+
push("BAD_BONES", `${path}.${bound}`, t[bound], [], ["<n>px"]);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (t.priority !== undefined && !(t.priority in PRIORITY)) {
|
|
560
|
+
const names = Object.keys(PRIORITY);
|
|
561
|
+
push("BAD_BONES", `${path}.priority`, t.priority, suggest(t.priority, names), names);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Normalize one declared track into the record the allocator and the CSS
|
|
566
|
+
// emitter both read.
|
|
567
|
+
function normalizeTrack(track, id){
|
|
568
|
+
const t = (track && typeof track === "object" && !Array.isArray(track)) ? track : { size: track };
|
|
569
|
+
const ref = AXIS_REF.exec(t.size);
|
|
570
|
+
const constrained = t.min !== undefined || t.max !== undefined || t.priority !== undefined;
|
|
571
|
+
return {
|
|
572
|
+
id,
|
|
573
|
+
size: t.size,
|
|
574
|
+
axis: ref ? ref[1] : null,
|
|
575
|
+
fr: FR.test(t.size) ? parseFloat(FR.exec(t.size)[1]) : null,
|
|
576
|
+
min: t.min === undefined ? 0 : parseFloat(PX.exec(t.min)[1]),
|
|
577
|
+
max: t.max === undefined ? null : parseFloat(PX.exec(t.max)[1]),
|
|
578
|
+
priority: PRIORITY[t.priority === undefined ? DEFAULT_PRIORITY : t.priority],
|
|
579
|
+
constrained,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Rows a spec did not declare: one per region, content-sized, except the
|
|
584
|
+
// last which fills what is left. That is what `["auto", "1fr"]` spells out
|
|
585
|
+
// for a nav above a main, so the default and the written form agree.
|
|
586
|
+
function defaultRows(regions){
|
|
587
|
+
if (!regions.length) return ["1fr"];
|
|
588
|
+
return regions.map((_, i) => (i === regions.length - 1 ? "1fr" : "auto"));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function normalizeBones(bones, regions){
|
|
592
|
+
const shape = Array.isArray(bones) ? { cols: bones } : bones;
|
|
593
|
+
const cols = (shape.cols || ["1fr"]).map((t, i) => normalizeTrack(t, `cols-${i}`));
|
|
594
|
+
const rows = (shape.rows || defaultRows(regions)).map((t, i) => normalizeTrack(t, `rows-${i}`));
|
|
595
|
+
const gap = shape.gap === undefined ? null : normalizeTrack(shape.gap, "gap");
|
|
596
|
+
// `areas` as declared, or synthesized ROW-MAJOR: each region takes a row
|
|
597
|
+
// and spans every column, in declaration order. A nav belongs across the
|
|
598
|
+
// top, not down one side — and it is what MORPH-PLAN's canonical
|
|
599
|
+
// `areas: ["nav nav", "side main"]` does with its first row. To put a
|
|
600
|
+
// region in a single column, name it in `areas` and let `@split` size
|
|
601
|
+
// that column; that is what the `side` region is for.
|
|
602
|
+
const areas = Array.isArray(shape.areas)
|
|
603
|
+
? shape.areas.map((r) => String(r).trim().split(/\s+/).join(" "))
|
|
604
|
+
: regions.map((r) => new Array(cols.length).fill(r).join(" "));
|
|
605
|
+
return { cols, rows, gap, areas };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── The allocator ────────────────────────────────────────────────────
|
|
609
|
+
// Taken verbatim from MORPH-MVP.md's appendix (written and executed
|
|
610
|
+
// 2026-08-08; the traces there are real output). `pref` is a track's
|
|
611
|
+
// preferred size in px; the solver distributes the container in that
|
|
612
|
+
// proportion, freezing any track whose share would break its min or max
|
|
613
|
+
// and re-dividing what is left. Freezes at least one track per pass, so
|
|
614
|
+
// it terminates.
|
|
615
|
+
const REQUIRED = Infinity;
|
|
616
|
+
|
|
617
|
+
function tryAllocate(container, tracks, relaxed) {
|
|
618
|
+
const minOf = t => (relaxed.has(t.id) ? 0 : (t.min ?? 0));
|
|
619
|
+
if (tracks.reduce((s, t) => s + minOf(t), 0) > container) return null;
|
|
620
|
+
const frozen = new Map();
|
|
621
|
+
for (let pass = 0; pass <= tracks.length; pass++) { // freezes ≥1/pass → terminates
|
|
622
|
+
const free = tracks.filter(t => !frozen.has(t.id));
|
|
623
|
+
if (!free.length) break;
|
|
624
|
+
const remaining = container - [...frozen.values()].reduce((a, b) => a + b, 0);
|
|
625
|
+
const sumPref = free.reduce((s, t) => s + t.pref, 0) || 1;
|
|
626
|
+
const ideal = t => remaining * t.pref / sumPref;
|
|
627
|
+
const violator = free.find(t => ideal(t) < minOf(t) || ideal(t) > (t.max ?? Infinity));
|
|
628
|
+
if (!violator) { free.forEach(t => frozen.set(t.id, ideal(t))); break; }
|
|
629
|
+
frozen.set(violator.id,
|
|
630
|
+
Math.min(Math.max(ideal(violator), minOf(violator)), violator.max ?? Infinity));
|
|
631
|
+
}
|
|
632
|
+
return Object.fromEntries(frozen);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Integer px with largest-remainder distribution, so Σ sizes === container
|
|
636
|
+
// EXACTLY — a browser given tracks that sum to 1199 or 1201 reflows the
|
|
637
|
+
// last one, which is visible as a 1px jitter while a slider is dragged.
|
|
638
|
+
// Equal remainders go to the first-declared track (Array.prototype.sort is
|
|
639
|
+
// stable since ES2019, and `order` is built in declaration order).
|
|
640
|
+
function roundSizes(container, tracks, sizes){
|
|
641
|
+
const total = Math.round(container);
|
|
642
|
+
const order = tracks.filter((t) => t.id in sizes);
|
|
643
|
+
if (!order.length) return {};
|
|
644
|
+
const px = order.map((t) => Math.max(0, Math.floor(sizes[t.id])));
|
|
645
|
+
const rem = order.map((t, i) => ({ i, r: Math.max(0, sizes[t.id]) - px[i] }));
|
|
646
|
+
rem.sort((a, b) => b.r - a.r);
|
|
647
|
+
let left = total - px.reduce((a, b) => a + b, 0);
|
|
648
|
+
for (let k = 0; left > 0; k++, left--) px[rem[k % rem.length].i] += 1;
|
|
649
|
+
const out = {};
|
|
650
|
+
order.forEach((t, i) => { out[t.id] = px[i]; });
|
|
651
|
+
return out;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function allocate(container, tracks) {
|
|
655
|
+
const relaxed = new Set();
|
|
656
|
+
for (;;) {
|
|
657
|
+
const sizes = tryAllocate(container, tracks, relaxed);
|
|
658
|
+
if (sizes) {
|
|
659
|
+
return { sizes, relaxed: [...relaxed], rounded: roundSizes(container, tracks, sizes) };
|
|
660
|
+
}
|
|
661
|
+
const next = tracks // lowest priority yields first
|
|
662
|
+
.filter(t => (t.min ?? 0) > 0 && !relaxed.has(t.id) && t.priority !== REQUIRED)
|
|
663
|
+
.sort((a, b) => a.priority - b.priority)[0];
|
|
664
|
+
if (!next) throw new Error("unsatisfiable: required mins exceed container");
|
|
665
|
+
relaxed.add(next.id);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ── Solving a dimension ──────────────────────────────────────────────
|
|
670
|
+
// A track's preferred px at the current axis values. `fr` tracks share
|
|
671
|
+
// whatever the sized ones leave over, which is what makes the appendix's
|
|
672
|
+
// desktop trace (side 324 + gap 28 + main 848 = 1200) come out exact.
|
|
673
|
+
function preferredSizes(tracks, axes, container){
|
|
674
|
+
const pref = new Map();
|
|
675
|
+
let fixed = 0, frSum = 0;
|
|
676
|
+
for (const t of tracks) {
|
|
677
|
+
if (t.fr != null) { frSum += t.fr; continue; }
|
|
678
|
+
let px = 0;
|
|
679
|
+
if (t.axis) {
|
|
680
|
+
const v = axisValue(t.axis, axes[t.axis]);
|
|
681
|
+
px = AXES[t.axis].map.unit === "%" ? (v / 100) * container : v;
|
|
682
|
+
} else if (PX.test(t.size)) {
|
|
683
|
+
px = parseFloat(PX.exec(t.size)[1]);
|
|
684
|
+
} else if (PCT.test(t.size)) {
|
|
685
|
+
px = (parseFloat(PCT.exec(t.size)[1]) / 100) * container;
|
|
686
|
+
}
|
|
687
|
+
px *= t.span || 1;
|
|
688
|
+
pref.set(t.id, px);
|
|
689
|
+
fixed += px;
|
|
690
|
+
}
|
|
691
|
+
const leftover = Math.max(0, container - fixed);
|
|
692
|
+
for (const t of tracks) {
|
|
693
|
+
if (t.fr == null) continue;
|
|
694
|
+
pref.set(t.id, frSum > 0 ? (leftover * t.fr) / frSum : 0);
|
|
695
|
+
}
|
|
696
|
+
return tracks.map((t) => ({ ...t, pref: pref.get(t.id) }));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// One solved dimension. The gap joins the dimension as a single track
|
|
700
|
+
// carrying all (n-1) gutters, so a 2-column grid reduces exactly to the
|
|
701
|
+
// appendix's three-track model and wider grids stay correct.
|
|
702
|
+
function solveDimension(dim, tracks, gap, container, axes){
|
|
703
|
+
const span = Math.max(0, tracks.length - 1);
|
|
704
|
+
// The gap's bounds are per-gutter; the track carries all of them, so
|
|
705
|
+
// they scale with the gutter count too.
|
|
706
|
+
const gapTrack = gap && {
|
|
707
|
+
...gap, id: "gap", span,
|
|
708
|
+
min: gap.min * span,
|
|
709
|
+
max: gap.max == null ? null : gap.max * span,
|
|
710
|
+
};
|
|
711
|
+
// Declaration order, gutters sitting between the tracks they separate.
|
|
712
|
+
const list = gapTrack && span > 0
|
|
713
|
+
? [tracks[0], gapTrack, ...tracks.slice(1)]
|
|
714
|
+
: tracks.slice();
|
|
715
|
+
if (list.some((t) => t.size === "auto")) {
|
|
716
|
+
const e = new Error(
|
|
717
|
+
`[nodality] morph: an "auto" track cannot be resolved by the allocator ` +
|
|
718
|
+
`(bones.${dim}); give it a size, or drop the constraints on that dimension.`);
|
|
719
|
+
e.name = "LayoutSpecError";
|
|
720
|
+
throw e;
|
|
721
|
+
}
|
|
722
|
+
const { sizes, relaxed, rounded } = allocate(container, preferredSizes(list, axes, container));
|
|
723
|
+
const vars = {};
|
|
724
|
+
for (const t of list) {
|
|
725
|
+
if (!(t.id in rounded)) continue;
|
|
726
|
+
vars[`--nod-${t.id}`] = t.id === "gap"
|
|
727
|
+
? `${Math.round(rounded[t.id] / t.span)}px`
|
|
728
|
+
: `${rounded[t.id]}px`;
|
|
729
|
+
}
|
|
730
|
+
return { vars, sizes, relaxed, list };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Everything the grid shell needs: the CSS for cols/rows/gap plus, when a
|
|
734
|
+
// dimension is constrained, the track model morphController re-solves on
|
|
735
|
+
// every axis stroke.
|
|
736
|
+
function solveBones(bones, axes){
|
|
737
|
+
const vars = {};
|
|
738
|
+
const tracks = {};
|
|
739
|
+
const cssFor = (dim) => bones[dim].map((t) =>
|
|
740
|
+
(t.axis ? axisCalc(t.axis) : t.size)).join(" ");
|
|
741
|
+
|
|
742
|
+
let cols = cssFor("cols");
|
|
743
|
+
let rows = cssFor("rows");
|
|
744
|
+
let gap = bones.gap
|
|
745
|
+
? (bones.gap.axis ? axisCalc(bones.gap.axis) : bones.gap.size)
|
|
746
|
+
: null;
|
|
747
|
+
|
|
748
|
+
const constrained = (list) => list.some((t) => t.constrained);
|
|
749
|
+
const gapConstrained = !!(bones.gap && bones.gap.constrained);
|
|
750
|
+
|
|
751
|
+
// The gap is one gutter shared by both dimensions, so it is solved with
|
|
752
|
+
// the first dimension that needs solving and read as a fixed size by
|
|
753
|
+
// the second.
|
|
754
|
+
let gapOwner = null;
|
|
755
|
+
for (const dim of ["cols", "rows"]) {
|
|
756
|
+
const needs = constrained(bones[dim]) || (gapConstrained && !gapOwner);
|
|
757
|
+
if (!needs) continue;
|
|
758
|
+
const box = dim === "cols" ? CONTAINER.width : CONTAINER.height;
|
|
759
|
+
const gapForDim = bones.gap && !gapOwner ? bones.gap : null;
|
|
760
|
+
const solved = solveDimension(dim, bones[dim], gapForDim, box, axes);
|
|
761
|
+
Object.assign(vars, solved.vars);
|
|
762
|
+
tracks[dim] = { container: box, list: solved.list };
|
|
763
|
+
if (gapForDim) gapOwner = dim;
|
|
764
|
+
const css = bones[dim].map((t) => `var(--nod-${t.id})`).join(" ");
|
|
765
|
+
if (dim === "cols") cols = css; else rows = css;
|
|
766
|
+
}
|
|
767
|
+
if (gapOwner) gap = "var(--nod-gap)";
|
|
768
|
+
|
|
769
|
+
return { cols, rows, gap, vars, tracks };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Re-solve every constrained dimension at the given axis values. Pure, and
|
|
773
|
+
// shared by expand() (build-time poster values) and morphController()
|
|
774
|
+
// (every stroke) — which is why prerendered and live output agree.
|
|
775
|
+
function solveTracks(tracks, axes){
|
|
776
|
+
const vars = {};
|
|
777
|
+
for (const dim in tracks) {
|
|
778
|
+
const { container, list } = tracks[dim];
|
|
779
|
+
const { rounded } = allocate(container, preferredSizes(list, axes, container));
|
|
780
|
+
for (const t of list) {
|
|
781
|
+
if (!(t.id in rounded)) continue;
|
|
782
|
+
vars[`--nod-${t.id}`] = t.id === "gap"
|
|
783
|
+
? `${Math.round(rounded[t.id] / t.span)}px`
|
|
784
|
+
: `${rounded[t.id]}px`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return vars;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ── Slots ────────────────────────────────────────────────────────────
|
|
791
|
+
// Deterministic and seed-independent: `<region>.<kind>.<role>`, and a kind
|
|
792
|
+
// that repeats within a region takes an index from its second occurrence
|
|
793
|
+
// on (`main.features1.title`).
|
|
794
|
+
function collectSlots(s, regions){
|
|
795
|
+
const out = [];
|
|
796
|
+
for (const region of regions) {
|
|
797
|
+
const def = REGISTRY[region];
|
|
798
|
+
if (!def) continue;
|
|
799
|
+
if (def.slots) {
|
|
800
|
+
for (const role of def.slots) out.push({ id: `${region}.${role}`, kind: region });
|
|
801
|
+
}
|
|
802
|
+
const kinds = kindsOf(def);
|
|
803
|
+
if (!kinds || !Array.isArray(s[region])) continue;
|
|
804
|
+
const seen = {};
|
|
805
|
+
for (const entry of s[region]) {
|
|
806
|
+
if (!entry || typeof entry !== "object") continue; // a token
|
|
807
|
+
const kind = entry.kind;
|
|
808
|
+
const def2 = kinds[kind];
|
|
809
|
+
if (!def2) continue;
|
|
810
|
+
const n = seen[kind] = (seen[kind] || 0);
|
|
811
|
+
seen[kind] = n + 1;
|
|
812
|
+
const name = n === 0 ? kind : `${kind}${n}`;
|
|
813
|
+
for (const role of def2.slots) {
|
|
814
|
+
out.push({ id: `${region}.${name}.${role}`, kind });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ── expand ───────────────────────────────────────────────────────────
|
|
822
|
+
function expand(spec){
|
|
823
|
+
const report = validate(spec);
|
|
824
|
+
if (!report.ok) throw layoutSpecError(report);
|
|
825
|
+
|
|
826
|
+
const s = spec;
|
|
827
|
+
const seed = s.seed == null ? DEFAULT_SEED : s.seed;
|
|
828
|
+
const rng = mulberry32(seed);
|
|
829
|
+
const content = (s.content && typeof s.content === "object") ? s.content : {};
|
|
830
|
+
|
|
831
|
+
// AXES declaration order, spec values overriding the design defaults.
|
|
832
|
+
const axes = {};
|
|
833
|
+
for (const name in AXES) {
|
|
834
|
+
axes[name] = (s.axes && typeof s.axes[name] === "number") ? s.axes[name] : AXES[name].default;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const regions = regionKeys(s);
|
|
838
|
+
const bones = normalizeBones(s.bones, regions);
|
|
839
|
+
const solved = solveBones(bones, axes);
|
|
840
|
+
|
|
841
|
+
const vars = {};
|
|
842
|
+
for (const name in AXES) vars[AXES[name].css] = axes[name];
|
|
843
|
+
Object.assign(vars, solved.vars);
|
|
844
|
+
|
|
845
|
+
const slots = collectSlots(s, regions);
|
|
846
|
+
const rootId = "nod-" + s.target;
|
|
847
|
+
const children = regions.map((region) => expandRegion(region, s[region], { axes, seed, rng, content }));
|
|
848
|
+
|
|
849
|
+
const root = {
|
|
850
|
+
type: "wrap",
|
|
851
|
+
name: s.target,
|
|
852
|
+
id: rootId,
|
|
853
|
+
disp: "grid",
|
|
854
|
+
cols: solved.cols,
|
|
855
|
+
rows: solved.rows,
|
|
856
|
+
areas: bones.areas.map((r) => `"${r}"`).join(" "),
|
|
857
|
+
vars,
|
|
858
|
+
children,
|
|
859
|
+
};
|
|
860
|
+
if (solved.gap) root.gap = solved.gap;
|
|
861
|
+
|
|
862
|
+
return {
|
|
863
|
+
elements: [root],
|
|
864
|
+
nodes: [],
|
|
865
|
+
slots,
|
|
866
|
+
meta: { rootId, vars, tracks: solved.tracks, slots },
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function expandRegion(region, value, base){
|
|
871
|
+
const def = REGISTRY[region];
|
|
872
|
+
const el = {
|
|
873
|
+
// A region is an ordinary Wrapper unless the registry names a
|
|
874
|
+
// dedicated element for it — `nav` is a UINavBar.
|
|
875
|
+
type: (def && def.element) || "wrap",
|
|
876
|
+
id: `nod-${region}`,
|
|
877
|
+
name: region,
|
|
878
|
+
area: region,
|
|
879
|
+
children: [],
|
|
880
|
+
};
|
|
881
|
+
if (def && def.tag) el.kind = def.tag;
|
|
882
|
+
if (!def) return el;
|
|
883
|
+
|
|
884
|
+
// Base options first, so a token always has the last word over them.
|
|
885
|
+
if (def.expand) Object.assign(el, def.expand({ ...base, slotId: (r) => `${region}.${r}` }).options);
|
|
886
|
+
|
|
887
|
+
// Region-level slots (nav.brand) come before any tokens touch the box.
|
|
888
|
+
if (def.slots) {
|
|
889
|
+
const ctx = { ...base, slotId: (role) => `${region}.${role}` };
|
|
890
|
+
for (const role of def.slots) el.children.push(textEl(ctx, role));
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const kinds = kindsOf(def);
|
|
894
|
+
if (Array.isArray(value)) {
|
|
895
|
+
const seen = {};
|
|
896
|
+
for (const entry of value) { // declaration order
|
|
897
|
+
if (typeof entry === "string") {
|
|
898
|
+
const out = def.tokens[entry].expand({ ...base, slotId: (r) => `${region}.${r}` });
|
|
899
|
+
Object.assign(el, out.options);
|
|
900
|
+
if (out.children) el.children.push(...out.children);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
const kind = entry.kind;
|
|
904
|
+
const n = seen[kind] = (seen[kind] || 0);
|
|
905
|
+
seen[kind] = n + 1;
|
|
906
|
+
const name = n === 0 ? kind : `${kind}${n}`;
|
|
907
|
+
el.children.push(expandKind(region, name, kind, entry, base, kinds));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return el;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function expandKind(region, name, kind, section, base, kinds){
|
|
914
|
+
const def = kinds[kind];
|
|
915
|
+
const ctx = { ...base, slotId: (role) => `${region}.${name}.${role}` };
|
|
916
|
+
const out = def.expand(ctx);
|
|
917
|
+
// A `bare` kind IS its single element — a nav link is an <a>, not an <a>
|
|
918
|
+
// inside a section wrapper that exists only because the machinery does.
|
|
919
|
+
if (def.bare) return out.children[0];
|
|
920
|
+
const el = {
|
|
921
|
+
type: "wrap",
|
|
922
|
+
kind: "section",
|
|
923
|
+
id: `nod-${region}-${name}`,
|
|
924
|
+
name: `${region}.${name}`,
|
|
925
|
+
children: out.children || [],
|
|
926
|
+
...out.options,
|
|
927
|
+
};
|
|
928
|
+
// `effect` is not a new element capability — it is the existing
|
|
929
|
+
// `raster:` option, which every class already accepts through
|
|
930
|
+
// commonMethods. One mapping, no wiring.
|
|
931
|
+
if (Array.isArray(section.effect)) {
|
|
932
|
+
const [op, ...drivers] = section.effect;
|
|
933
|
+
const node = { op, seed: Math.floor(base.rng() * 0x100000000) };
|
|
934
|
+
if (drivers.length) node.by = drivers[0];
|
|
935
|
+
el.raster = [node];
|
|
936
|
+
}
|
|
937
|
+
return el;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// ── morphController — the only DOM-touching export ───────────────────
|
|
941
|
+
// Two call forms, one implementation:
|
|
942
|
+
//
|
|
943
|
+
// morphController(spec) — derives meta by running the pure
|
|
944
|
+
// expansion, finds #nod-<target> itself
|
|
945
|
+
// morphController(meta, rootEl) — for callers that already expanded
|
|
946
|
+
//
|
|
947
|
+
// Every stroke writes custom properties and nothing else: no DOM
|
|
948
|
+
// mutations, no re-render, no style tag. That is the whole claim, and the
|
|
949
|
+
// demo page's mutation counter is what proves it.
|
|
950
|
+
function morphController(specOrMeta, rootEl){
|
|
951
|
+
const meta = (specOrMeta && specOrMeta.rootId) ? specOrMeta : expand(specOrMeta).meta;
|
|
952
|
+
const root = rootEl ||
|
|
953
|
+
(typeof document !== "undefined" ? document.getElementById(meta.rootId) : null);
|
|
954
|
+
if (!root) {
|
|
955
|
+
throw new Error(`[nodality] morph: no element with id "${meta.rootId}" — ` +
|
|
956
|
+
`mount the expansion before creating the controller, or pass the root element.`);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Current axis values, read back out of the vars the expansion emitted.
|
|
960
|
+
const axes = {};
|
|
961
|
+
for (const name in AXES) axes[name] = meta.vars[AXES[name].css];
|
|
962
|
+
const hasTracks = meta.tracks && Object.keys(meta.tracks).length > 0;
|
|
963
|
+
const slotIds = (meta.slots || []).map((sl) => sl.id);
|
|
964
|
+
|
|
965
|
+
const write = (name) => {
|
|
966
|
+
root.style.setProperty(AXES[name].css, String(axes[name]));
|
|
967
|
+
if (!hasTracks) return;
|
|
968
|
+
const solved = solveTracks(meta.tracks, axes);
|
|
969
|
+
for (const key in solved) root.style.setProperty(key, solved[key]);
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
const api = {
|
|
973
|
+
axis(name, v){
|
|
974
|
+
if (!(name in AXES)) {
|
|
975
|
+
const names = Object.keys(AXES);
|
|
976
|
+
throw new Error(`[nodality] morph: unknown axis "${name}". ` +
|
|
977
|
+
`Valid axes: ${names.join(", ")}.`);
|
|
978
|
+
}
|
|
979
|
+
axes[name] = Math.min(1, Math.max(0, Number(v)));
|
|
980
|
+
write(name);
|
|
981
|
+
return api;
|
|
982
|
+
},
|
|
983
|
+
axes(map){
|
|
984
|
+
for (const name in map) api.axis(name, map[name]);
|
|
985
|
+
return api;
|
|
986
|
+
},
|
|
987
|
+
content(slotId, value){
|
|
988
|
+
if (!slotIds.includes(slotId)) {
|
|
989
|
+
throw new Error(`[nodality] morph: unknown content slot "${slotId}". ` +
|
|
990
|
+
`Valid slots: ${slotIds.join(", ")}.`);
|
|
991
|
+
}
|
|
992
|
+
const id = slotElementId(slotId);
|
|
993
|
+
const node = root.querySelector(`#${id}`) ||
|
|
994
|
+
(typeof document !== "undefined" ? document.getElementById(id) : null);
|
|
995
|
+
if (node) node.textContent = typeof value === "object" && value ? String(value.text) : String(value);
|
|
996
|
+
return api;
|
|
997
|
+
},
|
|
998
|
+
dispose(){ return api; },
|
|
999
|
+
};
|
|
1000
|
+
return api;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
export { expand, validate, allocate, REGISTRY, AXES, morphController, mulberry32 };
|