@m0saic/knowledge 0.2.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/dist/index.d.ts +8 -0
  4. package/dist/index.js +7 -0
  5. package/docs/README.md +60 -0
  6. package/docs/file-formats/m0-iteration-protocol.md +120 -0
  7. package/docs/file-formats/m0p-and-custom-field.md +195 -0
  8. package/docs/handbook/README.md +27 -0
  9. package/docs/handbook/composition-arithmetic.md +278 -0
  10. package/docs/handbook/dsl-complexity.md +75 -0
  11. package/docs/handbook/dsl-rules.md +367 -0
  12. package/docs/handbook/feasibility-precision-quantization.md +591 -0
  13. package/docs/handbook/m0-construction-methods.md +201 -0
  14. package/docs/handbook/precision-tiers.md +84 -0
  15. package/docs/m0saic-thesis.md +95 -0
  16. package/docs/runtime/README.md +17 -0
  17. package/docs/runtime/cli-usage.md +372 -0
  18. package/docs/runtime/ffmpeg-expression-limits.md +117 -0
  19. package/docs/runtime/reduce-to-one.md +96 -0
  20. package/docs/skills/README.md +40 -0
  21. package/docs/skills/axis-and-geometry.md +103 -0
  22. package/docs/skills/dsl-stdlib-method-catalog.md +7 -0
  23. package/docs/skills/identity.md +123 -0
  24. package/docs/skills/labels-and-masks.md +170 -0
  25. package/docs/skills/m0saic-string-generation.md +251 -0
  26. package/docs/skills/more-atoms-not-bigger-atoms.md +77 -0
  27. package/docs/skills/overlay-semantics.md +194 -0
  28. package/docs/skills/parse-apis.md +79 -0
  29. package/docs/skills/passthrough-semantics.md +136 -0
  30. package/docs/skills/structural-construction.md +86 -0
  31. package/docs/skills/text-in-templates.md +126 -0
  32. package/docs/skills/zero-overlay-analysis.md +87 -0
  33. package/docs/templates/README.md +65 -0
  34. package/docs/templates/capability-templates.md +72 -0
  35. package/docs/templates/construction-strategy.md +329 -0
  36. package/docs/templates/data-pipeline.md +324 -0
  37. package/docs/templates/emission-patterns.md +130 -0
  38. package/docs/templates/geometry-recipes.md +248 -0
  39. package/docs/templates/layout-contract.md +168 -0
  40. package/docs/templates/output-resolution-tree.md +202 -0
  41. package/docs/templates/patterns/case-study-lessons.md +69 -0
  42. package/docs/templates/patterns/perf-authoring-rules.md +100 -0
  43. package/docs/templates/patterns/primitive-extraction-pattern.md +103 -0
  44. package/docs/templates/philosophy-and-contract.md +310 -0
  45. package/docs/templates/recursion-nested-rendering.md +138 -0
  46. package/docs/templates/reference/grid.md +104 -0
  47. package/docs/templates/reference/json-prop-type.md +169 -0
  48. package/docs/templates/reference/mosaic-color.md +81 -0
  49. package/docs/templates/reference/mosaic-placement-props.md +103 -0
  50. package/docs/templates/reference/prop-bindings.md +203 -0
  51. package/docs/templates/reference/template-flags.md +205 -0
  52. package/docs/templates/render-lifecycle.md +117 -0
  53. package/docs/templates/rendering-model-contract.md +392 -0
  54. package/docs/templates/standalone-pack-authoring.md +233 -0
  55. package/docs/templates/theming.md +81 -0
  56. package/docs/templates/ui-controls.md +150 -0
  57. package/package.json +37 -0
@@ -0,0 +1,169 @@
1
+ # The `"json"` prop type + typed-output templates
2
+
3
+ Two additive capabilities that unblock structured-config props and typed
4
+ data-fetcher templates. Both are **shipped and current** — verified against
5
+ https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/template/template.ts, https://github.com/m0saic-project/m0saic-packages/blob/main/packages/template-utils/src/template,
6
+ and `validateTemplateProps`.
7
+
8
+ ---
9
+
10
+ ## 1. `type: "json"`
11
+
12
+ `"json"` is a member of `MosaicTemplatePropType`, alongside the string / number /
13
+ boolean / media / structural (`group`, `list`) and m0-family (`m0`, `m0c`, `m0p`)
14
+ entries.
15
+
16
+ ### When to reach for it
17
+
18
+ Use `"json"` when a prop's value is a **structured object or array that maps to a
19
+ TypeScript interface**:
20
+
21
+ - `filter` / `sceneFilter` — a remote API's filter input passed wholesale
22
+ - `{ low, high, mode }` clip-duration configs
23
+ - `Array<MosaicTimeRangeMs>` — the multi-range clip seam (see §3)
24
+ - Per-tile decoration bundles
25
+
26
+ **Don't** use it for:
27
+
28
+ | Instead of `"json"` | Use |
29
+ |---|---|
30
+ | a primitive that merely *looks* like JSON stringified | `"string"` / `"number"` / `"boolean"` |
31
+ | an array of primitives | `"string[]"` / `"number[]"` |
32
+ | a media reference | `"media"` |
33
+ | a fixed set of named sub-fields you want as individual controls | `"group"` with `fields` |
34
+
35
+ That last row is the common mistake: a `"group"` **without** `fields` degrades to a
36
+ raw JSON bag in the editor, which is strictly worse than declaring `"json"` on
37
+ purpose.
38
+
39
+ ### Validator behavior — permissive by design
40
+
41
+ `validateTemplateProps` accepts **any non-undefined value** when `type === "json"`.
42
+ The engine boundary does not parse, shape-check, or reject. That is deliberate:
43
+ editors may deliver an already-parsed object *or* a raw JSON string, and both are
44
+ legal at the boundary.
45
+
46
+ **Consequence for template authors:** render-time parsing and shape-checking is
47
+ **your** job. Call `JSON.parse` if the editor handed you a string, then validate
48
+ however you like (Zod, Ajv, hand-rolled). Do not assume you received an object.
49
+
50
+ There is intentionally no `JSON_PROP_SCHEMA_MISMATCH` diagnostic. If shape drift
51
+ becomes a recurring failure mode, wiring one up would be additive.
52
+
53
+ ### `meta.constraints.jsonSchema` — an editor hint, never a gate
54
+
55
+ Optional, two forms:
56
+
57
+ | Form | Meaning |
58
+ |---|---|
59
+ | `{ ref: "<host-resolved-id>" }` | Points into a shared JSON-schema registry the host owns. Use when several templates share a payload shape. |
60
+ | inline `Record<string, unknown>` | A JSON-Schema-ish object describing the payload. |
61
+
62
+ Editors that understand the schema may render a structured form; those that don't
63
+ fall back to a plain code editor. **The engine never validates this field.**
64
+
65
+ ### ⚠️ `as never` is NOT required — and existing casts are stale
66
+
67
+ Several shipped templates write `type: "json" as never`
68
+ (`charts/bar-graph/v1` + `v2`, `charts/line-chart/v1`, `internal/bars-stack`). **The
69
+ cast is unnecessary.** Those are leftovers from before `"json"` joined the union.
70
+
71
+ `definePropsSchema<P>` is typed as `Record<keyof P, MosaicTemplatePropDefinition>` —
72
+ it constrains only the *key set*, never the relationship between a prop's TS type and
73
+ its declared `type`. So this compiles clean, no casts:
74
+
75
+ ```ts
76
+ type P = { barColor?: string | string[]; spec: Record<string, unknown> };
77
+
78
+ const propsSchema = definePropsSchema<P>({
79
+ barColor: { type: "json", required: false },
80
+ spec: { type: "json", required: true },
81
+ });
82
+ ```
83
+
84
+ (Verified 2026-07-26 with the repo's own `tsc -p https://github.com/m0saic-project/m0saic-packages/blob/main/packages/templates/tsconfig.json.)
85
+ `brand/qr-stamp/video/v2` (`luminanceBuckets`) is the correct, cast-free reference.
86
+
87
+ **Do not copy the `as never` pattern into new templates**, and drop it opportunistically
88
+ when you touch one of the offenders. The same goes for the neighbouring
89
+ `type: "group" as any` casts — check whether they're still load-bearing before
90
+ propagating them.
91
+
92
+ ---
93
+
94
+ ## 2. The 5-generic `defineMosaicTemplate`
95
+
96
+ `MosaicTemplate` has carried 5 generics for a while; the helper now matches:
97
+
98
+ ```ts
99
+ defineMosaicTemplate<
100
+ P extends MosaicTemplateProps,
101
+ O extends MosaicTemplateOutputs = MosaicTemplateOutputs,
102
+ U extends MosaicTemplateUpstreamVariables = MosaicTemplateUpstreamVariables,
103
+ D extends MosaicTemplateUpstreamData = MosaicTemplateUpstreamData,
104
+ S extends MosaicTemplateSidecars = MosaicTemplateSidecars,
105
+ >(template: MosaicTemplate<P, O, U, D, S>): MosaicTemplate<P, O, U, D, S>
106
+ ```
107
+
108
+ Defaults preserve full back-compat — every existing 1-generic call site still
109
+ compiles unchanged.
110
+
111
+ **Use the full form when** your template (1) publishes typed outputs via
112
+ `outputsSchema`, (2) publishes typed sidecars via `sidecarsSchema`, or (3) reads
113
+ `ctx.upstreamData.<alias>` and wants type-checked access. Otherwise stay on
114
+ `defineMosaicTemplate<P>` — it remains the recommendation for ordinary renderables.
115
+
116
+ ### Gotcha — don't narrow `U` / `D` to an empty shape
117
+
118
+ A fetcher that reads no upstream is tempted to tighten `U` / `D` to
119
+ `Record<string, never>`. **Don't.** `registerTemplate` accepts only the loose form
120
+ (`Record<string, unknown>`), and narrowing reverses contravariance — TS rejects the
121
+ registration.
122
+
123
+ Pass the loose defaults explicitly for `U` and `D`; tighten only `O` and `S`.
124
+ Consumer templates that *do* read upstream are the right place to tighten `U` / `D`,
125
+ because there the context is an **input** to the bound, not an output.
126
+
127
+ ```ts
128
+ export const RepoFetcher = defineMosaicTemplate<
129
+ Props,
130
+ Outputs,
131
+ MosaicTemplateUpstreamVariables, // loose — narrowing breaks registerTemplate
132
+ MosaicTemplateUpstreamData, // (contravariance)
133
+ Sidecars
134
+ >({
135
+ id: asTemplateId("@example/github/repo-fetcher/v1"),
136
+ role: "data-fetcher",
137
+ capabilities: { tier: "capability", caps: { net: { fetch: true } } },
138
+ propsSchema: {
139
+ apiUrl: { type: "string", required: true },
140
+ tokenRef: { type: "string", required: false },
141
+ filter: {
142
+ type: "json",
143
+ required: false,
144
+ meta: { constraints: { jsonSchema: { ref: "@example/repo-filter/v1" } } },
145
+ },
146
+ },
147
+ defaultProps: { apiUrl: "https://api.github.com" },
148
+ async render(props, ctx) { /* … */ },
149
+ });
150
+ ```
151
+
152
+ ---
153
+
154
+ ## 3. Cross-references
155
+
156
+ - **Data-fetcher pattern** — `../data-pipeline.md`. The five-generic
157
+ `defineMosaicTemplate<Props, Outputs, …, Sidecars>` signature its fetcher contract
158
+ relies on was aspirational when first documented; it is valid TypeScript today.
159
+ - **Multi-range clip picker** — `picker: "time-ranges"` is declared on a **single**
160
+ `type: "json"` prop whose value is `Array<MosaicTimeRangeMs>`
161
+ (`{ startMs, endMs, label? }`, integer ms, source-relative, sorted ascending;
162
+ overlaps allowed, empty array = no selection). Unlike the paired `"time-range"`
163
+ picker there is no prop pairing — the editor reads and writes the whole array
164
+ through that one prop in a single write. Boundary reader:
165
+ `parseTimeRangesValue` in `template-utils/src/media/timeRanges.ts` — use it rather
166
+ than hand-parsing, precisely because the value may arrive as object *or* string.
167
+ - **Prop-shape consistency** — `npm run audit:props` in https://github.com/m0saic-project/m0saic-packages/blob/main/packages/templates emits
168
+ `PROPS-SHAPE.md`, a cross-template prop-shape matrix. Run it after adding a
169
+ `"json"` prop to see how the shelf compares.
@@ -0,0 +1,81 @@
1
+ # MosaicColor
2
+
3
+ > **Source of truth:** [https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/colors/mosaicColor.ts](https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/colors/mosaicColor.ts) (the type), https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/colors/parseMosaicColors.ts (the validators), https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/colors/ffmpegNamedColors.ts (the named-color set).
4
+
5
+ ## The type
6
+
7
+ Verbatim from `mosaicColor.ts:25`:
8
+
9
+ ```ts
10
+ export type MosaicColor =
11
+ | "none"
12
+ | FfmpegNamedColor
13
+ | HexColor
14
+ | `${FfmpegNamedColor}${AlphaSuffix}`
15
+ | `${HexColor}${AlphaSuffix}`
16
+ | `none${AlphaSuffix}`;
17
+ ```
18
+
19
+ Accepted: ffmpeg named colors (`"white"`, `"darkgray"`), hex (`"#fff"`,
20
+ `"#ffffff"`, `"#ff00ffaa"`), optional alpha suffix (`"white@0.5"`), and
21
+ `"none"` for transparency. `HexColor` is just `` `#${string}` `` — the type is
22
+ a **shape contract, not a full validator** (no hex well-formedness, alpha
23
+ range, or casing checks). Runtime validation is required on production paths.
24
+
25
+ ## Validate with the real exports — never hand-roll
26
+
27
+ `@m0saic/types` ships the canonical pair; use them instead of local regexes
28
+ (a naive "has an `@` suffix" check accepts `"garbage@0.5"` — the real one
29
+ re-validates the base):
30
+
31
+ - **`toMosaicColor(input, ctx?)`** (`parseMosaicColors.ts:16`) — throws on
32
+ invalid; returns a normalized (lowercased) `MosaicColor`. Splits any `@alpha`
33
+ suffix, requires alpha finite and in `[0, 1]` (`:39-41`), then validates the
34
+ **base** as `"none"`, a named color, or well-formed hex. `ctx` names the error.
35
+ - **`isMosaicColor(input)`** (`parseMosaicColors.ts:75`) — non-throwing type guard.
36
+ - **`FFMPEG_NAMED_COLORS_SET`** (`ffmpegNamedColors.ts:157`) — the membership
37
+ set behind named-color validation.
38
+
39
+ Real caller: https://github.com/m0saic-project/m0saic-packages/blob/main/packages/templates/src/m0saic/charts/bar-graph/v1/internal/plot-area.ts#L30
40
+ imports it and uses it as `toMosaicColor("#ffffff", "PlotArea.BASELINE_COLOR")` (:83).
41
+
42
+ Where to validate: primitives validate incoming color props before passing them
43
+ to sources; heroes normalize early; template-utils must never emit invalid
44
+ `MosaicColor`. Never widen a field to `string` to silence the compiler.
45
+
46
+ ## `"none"` and alpha
47
+
48
+ - `"none"` means transparent. The engine mapping to an ffmpeg color string
49
+ lives in the render engine source (not published) (`ffmpegColor()`) —
50
+ templates use `"none"` as intent, never hand-encoding the ffmpeg form.
51
+ - Prefer `visual.opacity` for compositing; use an alpha suffix only when
52
+ transparency is part of the color's identity — never encode opacity in both.
53
+
54
+ ## Ternary widening (common pitfall)
55
+
56
+ When assigning a `MosaicColor` via a conditional, TypeScript widens the literal
57
+ branches to `string` and the assignment fails:
58
+
59
+ ```ts
60
+ const style = {
61
+ borderColor: isDark ? "#fff" : "#000", // ❌ widens to string — type error
62
+ };
63
+ ```
64
+
65
+ ✅ Use `as const` on the literals:
66
+
67
+ ```ts
68
+ const style = {
69
+ borderColor: (isDark ? ("#fff" as const) : ("#000" as const)),
70
+ };
71
+ ```
72
+
73
+ ✅ Or extract a shared style record so each value is a known literal:
74
+
75
+ ```ts
76
+ const COLORS = { light: "#fff", dark: "#000" } as const;
77
+ const style = { borderColor: COLORS[isDark ? "dark" : "light"] };
78
+ ```
79
+
80
+ The same trap fires on every `MosaicColor`-typed field reached via a ternary
81
+ (`borderColor`, `fillColor`, …). Fix at the literal site, never by widening.
@@ -0,0 +1,103 @@
1
+ # MosaicPlacementProps
2
+
3
+ > **Source of truth:** [https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/source/source.ts#L99-188`](https://github.com/m0saic-project/m0saic-packages/blob/main/packages/types/src/source/source.ts); runtime validation in https://github.com/m0saic-project/m0saic-packages/blob/main/packages/platform/src/mosaic/validate/validateSources.ts; cover-crop math in the render engine source (not published).
4
+
5
+ ## The union
6
+
7
+ `MosaicPlacementProps = MosaicPlacementContain | MosaicPlacementCover`
8
+ (`source.ts:188`) — a discriminated union on `fit`. Fields that are meaningless
9
+ on one fit are declared `?: never` on that variant, so misuse is a **compile
10
+ error**, not a silent no-op:
11
+
12
+ | Field | Contain (`fit?: "contain"`, default) | Cover (`fit: "cover"`) |
13
+ |---|---|---|
14
+ | `inset` | ✅ (`source.ts:128`, on the shared base) | ✅ |
15
+ | `padding` | ✅ (`source.ts:152`) | ❌ `padding?: never` (:166) |
16
+ | `hAlign` / `vAlign` | ✅ (:133-134) | ❌ `hAlign?: never` / `vAlign?: never` (:162-163) |
17
+ | `focusX` / `focusY` | ❌ `focusX?: never` / `focusY?: never` (:155-156) | ✅ (:177, :185) |
18
+
19
+ Defaults when omitted: `fit="contain"`, `hAlign="center"`, `vAlign="middle"`,
20
+ no `inset`, no `padding`; on cover, focus defaults to centered (`0.5`).
21
+
22
+ ## Boxes: tile rect → inset → padding → content box
23
+
24
+ - **Tile rect**: the rectangle the layout engine allocates to the source.
25
+ - **`inset?: MosaicBoxFrac`** (both fits): shrinks the destination rect **before**
26
+ fit math. Role: the **slot-level gutter**, typically owned by the composing
27
+ layer. Fractions of **tile size**.
28
+ - **`padding?: MosaicBoxFrac`** (contain-only): extra dead space reserved
29
+ **inside** the inset box, before contain math. Role: content-level breathing
30
+ room, composing with (not fighting) the slot's `inset`. Fractions of the
31
+ **inset box** (= of tile size when `inset` is unset; the engine resolves
32
+ `padLeft = insetBox.width * padding.left`). Cover forbids it — "cover +
33
+ padding would create intentional dead space" — enforced by the type AND the
34
+ `*_COVER_PADDING` validator (`validateSources.ts:561`).
35
+ - **`MosaicBoxFrac`** (`source.ts:85-97`): `0.02` (all sides) | `{x, y}`
36
+ (symmetric per axis) | `{top, right, bottom, left}` (per-side overrides win).
37
+ All fractions 0..1; validators require each side in `[0, 0.49]`.
38
+ - **Content box**: the tile rect after `inset` (and, for contain, `padding`).
39
+
40
+ ⚠️ **Both are applied BEFORE the per-tile effects chain.** Quoting the `inset`
41
+ JSDoc (`source.ts:117-121`): "Applied before the per-tile effects chain — the
42
+ content's buffer IS the shrunk box, so effects that need headroom
43
+ (`effects.rotate`) clip against it. Rotation headroom must be real geometry
44
+ instead: a nested child whose declared `size` equals the padded box (see
45
+ watermark page-mode `buildPaddedInstanceChild`)."
46
+
47
+ ## Contain
48
+
49
+ Uniform scale, aspect preserved, so the media fits entirely inside the content
50
+ box. Leftover per axis: `leftoverX = contentW - scaledW`, `leftoverY = contentH - scaledH`.
51
+
52
+ Alignment maps to factors — `left/top = 0`, `center/middle = 0.5` (default),
53
+ `right/bottom = 1` — and placement is:
54
+
55
+ ```
56
+ x = contentX + (leftoverX > 0 ? leftoverX * hFactor(hAlign) : 0)
57
+ y = contentY + (leftoverY > 0 ? leftoverY * vFactor(vAlign) : 0)
58
+ ```
59
+
60
+ **Exact-fit invariant (still observable, and required):** on an axis with zero
61
+ leftover, varying the alignment MUST NOT change output — byte-identical frames.
62
+ This is the surviving observability check now that cover-side misuse is
63
+ unrepresentable.
64
+
65
+ ## Cover
66
+
67
+ Uniform scale, aspect preserved, so the media fully covers the content box;
68
+ overflow is cropped to exactly `contentW × contentH`; placed at
69
+ `(contentX, contentY)`. No leftover space, so alignment is unrepresentable.
70
+
71
+ ### Crop anchor: `focusX` / `focusY`
72
+
73
+ ```ts
74
+ placement: { fit: "cover", focusX?: number, focusY?: number } // 0..1 each
75
+ ```
76
+
77
+ **Offset per axis = `(scaled - content) * focus`.** `0` keeps the leading edge
78
+ (left/top), `0.5` centers (default), `1` keeps the trailing edge. Motivating
79
+ case: portrait photos put faces near the top — `focusY: 0` keeps the head.
80
+
81
+ - **Byte-identity guarantee.** Focus absent or exactly `0.5` emits the exact
82
+ legacy `(in_w-W)/2` string — helper `coverCropOffsetExpr`
83
+ (the render engine source (not published)). Existing goldens do not move.
84
+ - **Enforced twice.** Type level: `focusX?: never` on contain. Runtime:
85
+ `*_FOCUS_REQUIRES_COVER` (https://github.com/m0saic-project/m0saic-packages/blob/main/packages/platform/src/mosaic/validate/validateSources.ts#L575)
86
+ plus range checks `*_INVALID_FOCUSX` / `*_INVALID_FOCUSY` in `[0, 1]`
87
+ (`validateFocus`, `validateSources.ts:502`). Note these live in **platform**,
88
+ not core.
89
+ - **Also steered by focus:** `zoomInPercent`'s zoom crop on cover tiles
90
+ (contain + zoom stays centered), and `doc.backgroundImage`'s cover fill.
91
+ - **Not `effects.camera`.** Same focus vocabulary, different layer: the camera
92
+ is the animatable zoom/pan downstream on the already-cropped content box;
93
+ placement focus is the static, plan-time fit-crop anchor. They compose.
94
+ - **Web parity:** `GeometricPreview` maps cover focus to CSS
95
+ `object-position: ${fx*100}% ${fy*100}%` — identical percentage math.
96
+
97
+ ## Historical note
98
+
99
+ Pre-v2 versions of this spec carried "for `fit="cover"`, `hAlign`/`vAlign`
100
+ MUST NOT affect output" invariants. The union now declares those fields `never`
101
+ on cover (verified against `source.ts` 2026-07-27), so the misuse is
102
+ unrepresentable and those invariants are moot — only the contain exact-fit
103
+ invariant above remains testable.
@@ -0,0 +1,203 @@
1
+ # Prop bindings — the rect that SHOWS a prop is its handle in Make
2
+
3
+ A template marks the source that DISPLAYS a prop with `source.editor.binding`
4
+ (or `bindings`, several leaves on one rect). Make resolves them per render and
5
+ turns the rect into the prop's canvas handle: double-click / a badge to edit,
6
+ a file dropped on it to fill a media slot. Bindings are re-resolved on every
7
+ render and keyed by the PROP — never by a stableKey, which changes whenever the
8
+ geometry does.
9
+
10
+ One predicate decides what is bindable: `classifyBindableProp` in
11
+ `@m0saic/platform` (`mosaic/propBindings`). Template tests
12
+ (`resolvePropBindings` in `@m0saic/template-utils`) and the Make page both use
13
+ it, so a template can never offer an edit the page refuses, or vice versa.
14
+ `bindProps` / `bindPropPath` THROW at author time for what the platform would
15
+ silently drop — a template test should never reach the silent path.
16
+
17
+ ## The wire shape
18
+
19
+ ```ts
20
+ binding: {
21
+ propKey: string; // dotted path as propsSchema nests it: "label", "titles.title"
22
+ index?: number; // ONE element of string[] / number[] / media[]
23
+ path?: Array<string | number>; // route into a json / list / array value: [2, "title"]
24
+ kind?: "string" | "number" | "color" | "rect" | "media"; // required on structured leaves
25
+ layer?: number; // which text layer shows the leaf (multi-layer text)
26
+ onClear?: "remove-element" | "unset-leaf";
27
+ seedDraft?: string;
28
+ companion?: boolean;
29
+ range?: { start: number; end: number }; // one line of a multi-line string leaf
30
+ focus?: { start: number; end: number }; // the token the rect shows, inside range
31
+ }
32
+ ```
33
+
34
+ Rules:
35
+
36
+ - `propKey` is exactly as `propsSchema` nests it; every segment past the
37
+ first walks a `type: "group"` prop's `fields`.
38
+ - Basic lists (`string[]`, `number[]`, `media[]`) bind ONE element — a
39
+ non-negative integer `index`. The list itself is never bindable.
40
+ - Structured props (`json` / `list` / `array`) bind a LEAF: `path` from the
41
+ prop's value plus an explicit `kind` (the schema has no per-leaf type, and an
42
+ empty leaf must still be an "add" handle). `index` is shorthand for a leading
43
+ numeric segment.
44
+ - `meta.ui.hidden` props, booleans, enum / connection-option strings (closed
45
+ pickers) and whole lists are never bindable. A stray `index` on a scalar is
46
+ ignored.
47
+ - Bind even when the value is empty — the rect is then a handle to ADD.
48
+
49
+ ## Kinds
50
+
51
+ | prop / leaf | binding | kind | Make handle |
52
+ |---|---|---|---|
53
+ | free-text `string` (or one `string[]` element, or a structured leaf) | `bindProp(src, key[, i])` / `bindPropPath(src, key, path, "string")` | `"string"` | T — the inline text form |
54
+ | colour-valued string (`isColor` / `colorPicker`) | same | `"color"` | swatch — a picker |
55
+ | `number` (or one `number[]` element, or a leaf) | same, `"number"` | `"number"` | 123 — the inline form |
56
+ | `json` with `picker: "regions"`, `regions.max: 1` | `bindPropRect(src, key)` — no path | `"rect"` | move glyph — the regions draw session on the cell |
57
+ | `media`, one `media[]` element, or a leaf holding a path | `bindProp(src, key[, i])` / `bindPropPath(…, "media")` | `"media"` | picture glyph — a DROP target + the media chip |
58
+
59
+ Order the entries the way a double-click should read them: `fields[0]` is the
60
+ primary action (a plain double-click); the badge row offers every kind.
61
+
62
+ ## Authoring helpers (`@m0saic/template-utils`)
63
+
64
+ - `bindProp(src, propKey, index?)` — the basic case.
65
+ - `bindProps(src, entries)` — several leaves on one rect (a multi-layer text,
66
+ a cell that moves AND edits AND takes a drop). Writes `editor.bindings`,
67
+ which wins over the single `binding`.
68
+ - `bindPropPath(src, propKey, path, kind, { onClear?, seedDraft? })` — one
69
+ structured leaf.
70
+ - `bindPropRect(src, propKey)` — a one-region regions picker edited in place.
71
+ - `bindPropRange(src, propKey, index, range, focus?)` — one line of a
72
+ multi-line string prop (a code state, a lyric block); offsets index the RAW
73
+ prop string.
74
+ - `stripPropBindings(doc)` — a host composing ANOTHER template's render drops
75
+ its bindings (they name the inner template's props).
76
+ - `tag(src, label)` composes either way round with all of them.
77
+
78
+ ## `onClear` — what an EMPTY commit means
79
+
80
+ ```ts
81
+ { propKey: "days", path: [rowIndex, "day"], kind: "number", onClear: "remove-element" }
82
+ ```
83
+
84
+ - `"remove-element"` — splice the element at the leading numeric path segment
85
+ (the whole row, or one element of a basic list) out of its array. Needs an
86
+ `index` or a numeric first `path` segment.
87
+ - `"unset-leaf"` — delete the leaf key from its object, or unset a basic prop
88
+ so its default shows again. Not for array-element leaves (no key).
89
+ - Absent — numbers and colours refuse an empty commit; strings write `""`;
90
+ a scalar media clears to `""`; a `media[]` element refuses (a hole in a media
91
+ list is not a value the engine can open).
92
+
93
+ Editor rules: "empty" is blank for numbers / colours, `""` for text. An empty
94
+ commit against an already-empty leaf writes nothing. In a multi-field session a
95
+ row removal supersedes its siblings' writes; rows go highest index first. The
96
+ panel stays the primary place rows are deleted — canvas clear is an accelerator.
97
+
98
+ ## `seedDraft` — prefill an EMPTY leaf from the rect's context
99
+
100
+ ```ts
101
+ { propKey: "days", path: [len, "day"], kind: "number", onClear: "remove-element", seedDraft: String(info.day) }
102
+ ```
103
+
104
+ - Applies only when the bound leaf reads EMPTY / absent; a leaf with a value
105
+ ignores it. A DRAFT: shown pre-selected (Enter alone accepts it), written on
106
+ commit like typed text, never applied by itself. Deleting it and committing
107
+ empty still runs `onClear`.
108
+ - Blur commits too, so an untouched seeded form that loses focus WRITES the
109
+ seed — mind this when seeding.
110
+ - Wire shape is a string; a blank seed, or a non-numeric seed on a number
111
+ leaf, throws at author time; `rect` and `media` leaves take no seed.
112
+ - Focus lands on the FIRST field: order a seeded field first when the seed is
113
+ the whole suggestion.
114
+
115
+ ## `kind: "media"` — a rect that takes a dropped file
116
+
117
+ A media-bound rect is a DROP target on the Make canvas (a file dragged onto it
118
+ writes the path) with a picture-glyph handle that opens the media chip —
119
+ `Choose file…` / `Replace…` · `Remove` (when clearable) · "or drop a file on
120
+ the cell". A file in flight lights every media slot; a slot whose prop refuses
121
+ the kind lights muted.
122
+
123
+ - What may land is the PROP's say: `meta.control.accept` (`["image","video"]`)
124
+ and `meta.control.extensions`. Judged by MIME while in flight, by extension
125
+ at drop.
126
+ - `media[]`: index `< length` replaces, `=== length` appends (an add handle),
127
+ `> length` is refused. A media list is never padded with `""`.
128
+ - A tile pairs handles freely — the drop-calendar's facecam is
129
+ `bindProps(src, [{ propKey: "facecamRegion", kind: "rect" }, { propKey: "facecam" }])`:
130
+ move it, or drop on it. Put the MEDIA entry first when a double-click should
131
+ mean "replace the picture".
132
+
133
+ ### A drop commits the tile's seeded companions
134
+
135
+ When a file lands, every EMPTY sibling leaf on the tile that carries a
136
+ `seedDraft` is written with its seed — the rect's meaning travels with the
137
+ gesture (the clicked date; the slot number). Leaves with a value, or empty
138
+ leaves without a seed, are untouched.
139
+
140
+ `companion: true` marks a seeded leaf ONLY the drop fills:
141
+
142
+ ```ts
143
+ { propKey: "days", path: [row, "teaser"], kind: "number", seedDraft: String(teasers.length + 1), companion: true }
144
+ ```
145
+
146
+ It never shows in the text form (a blur-commit there would write the seed on
147
+ an unrelated edit) and never earns a badge. Needs a kept `seedDraft`; refused
148
+ on media / rect leaves. The drop-calendar date cell, in full — one drop births
149
+ a pictured row:
150
+
151
+ ```ts
152
+ bindProps(cell, [
153
+ { propKey: "days", path: [n, "day"], kind: "number", onClear: "remove-element", seedDraft: String(day) },
154
+ { propKey: "days", path: [n, "title"], kind: "string" },
155
+ { propKey: "teasers", index: teasers.length, kind: "media" },
156
+ { propKey: "days", path: [n, "teaser"], kind: "number", seedDraft: String(teasers.length + 1), companion: true },
157
+ ])
158
+ ```
159
+
160
+ ## Starter media — the slot before the footage
161
+
162
+ A template that wants the slot present (and droppable) before the user has a
163
+ file renders a stand-in while the prop is empty. Two sources:
164
+
165
+ 1. **Bring your own** — media bundled next to the template
166
+ (`assets/*.png|mp4|svg`, mirrored by copy-assets), built with
167
+ `fileAsset(resolve(__dirname, "assets"), name, kind)` from
168
+ `@m0saic/template-utils/dist/m0saic/assetPath` (asar-safe).
169
+ 2. **Included defaults** — `starterMedia(role)` from
170
+ `@m0saic/template-utils/dist/media/defaultMedia` (node-only deep import):
171
+ roles `portrait` `landscape` `square` `facecam` `avatar` `logo`, one small
172
+ viewBox-only SVG each (`assets/starter/`, minted by
173
+ `tools/mint-starter-svg.mjs`). Returns `null` when the file is not on disk —
174
+ degrade to the template's own stand-in, never an error mosaic.
175
+
176
+ An `.svg` file asset is rasterized to a PNG at plan time by the engine, and
177
+ Make's preview still-extractor rasterizes it the same way before ffmpeg sees
178
+ it — ffmpeg itself has no svg decoder, so never hand an svg path to it directly.
179
+ A still stand-in in a video slot keeps a no-footage render a poster (PNG);
180
+ motion arrives with the real clip.
181
+
182
+ Make marks an EXISTING slot with no path (a starter showing) with a persistent
183
+ dashed outline and an "Add …" glyph. An add handle (`teasers[len]`, an unborn
184
+ row) is not a slot and stays quiet — otherwise every empty date cell would light.
185
+
186
+ ## What Make does with them (the host side, for orientation)
187
+
188
+ - Badge row on hover: one glyph per kind (T · 123 · swatch · move · picture),
189
+ collapsing to one dot on a tile under ~60 on-screen px. A click on a glyph is
190
+ a double-click WITH that intent.
191
+ - Text / number / colour → the inline form over the rect (Enter / blur commit,
192
+ Esc cancels, an unchanged draft writes nothing). Rect → the regions draw
193
+ session seeded from the painted cell. Media → the chip; a drop needs no click.
194
+ - Every write goes through the ONE prop writer, so it survives every other
195
+ edit and re-renders the template; the panel field updates because it IS the
196
+ prop.
197
+ - The double-clicked tile is often chrome stacked over the bound rect (gridlines,
198
+ glass) — Make looks THROUGH the stack under the pointer for the topmost bound
199
+ tile, for clicks and drops alike.
200
+
201
+ First implementers: `@m0saic-dev/creator/drop-calendar/v1` (every kind, the
202
+ companion, the facecam slot); `@m0saic/alpine/*` (text / number / colour);
203
+ `@m0saic-dev/music/lyric-video/v1` (ranges).