feat(widgets): add the widget node model and the five built-ins #32

Merged
TJC merged 13 commits from feat/m5-widgets into main 2026-08-18 15:17:23 +01:00
Collaborator

Summary

  • widget nodes copy their spec into the tree on insert, so the library is a palette rather than a live dependency and no cycle guard exists anywhere
  • unpack to layout explodes a copy into plain containers permanently, inlining each input value and renaming the internals into the tree's id scope
  • the inspector is generated from the inputs schema, one field per declared input
  • the five built-ins ship as bundled JSON specs, loaded read-only, with duplicate copying one into an editable spec
  • the static page now draws all five at every default the plan states
  • closes #16
  • closes #17

Test plan

  • pnpm test - 385 tests, including a per-widget pass over defaults, geometry and a snapshot digest
  • unpacking is checked by laying the same widget out before and after and comparing the primitives
  • pnpm lint and pnpm build clean
  • rasterised the page and eyeballed it: date rules, seven day circles, ten timeline rows, eight todo rows, ruled notes, no overflow
## Summary - widget nodes copy their spec into the tree on insert, so the library is a palette rather than a live dependency and no cycle guard exists anywhere - unpack to layout explodes a copy into plain containers permanently, inlining each input value and renaming the internals into the tree's id scope - the inspector is generated from the inputs schema, one field per declared input - the five built-ins ship as bundled JSON specs, loaded read-only, with duplicate copying one into an editable spec - the static page now draws all five at every default the plan states - closes #16 - closes #17 ## Test plan - `pnpm test` - 385 tests, including a per-widget pass over defaults, geometry and a snapshot digest - unpacking is checked by laying the same widget out before and after and comparing the primitives - `pnpm lint` and `pnpm build` clean - rasterised the page and eyeballed it: date rules, seven day circles, ten timeline rows, eight todo rows, ruled notes, no overflow
- one field per declared input, opened on the value the instance passed
  or on the declared default
- a value passed as an expression is handed over rather than replaced,
  since no control can edit a template variable propagating inward
- a list reads back out of the list() call that writes it, being the one
  value with no literal form
- the library is a palette, not a live dependency: no reference to go
  missing on import and no version skew under a saved template
- a nested widget was copied the same way when its spec was saved, so
  there is no cycle to guard against anywhere
- the outline stops at a widget node, which is what keeps the internals
  unselectable and the simple path simple
- a container has nowhere to declare inputs, so each reference to one is
  replaced by the value the instance gave it: a one-way door, and the
  sheet it draws is unchanged
- internals move into the tree's id scope, so they are renamed under the
  widget's own id and become selectable
- unpacking stops rather than quietly changing the drawing when a repeat
  inside the spec would rebind a value passed at the instance site
- a nested widget node stays a widget, being a copy of its own

Closes #16
- built-ins load through the same parse a pasted spec does and are
  frozen, so a fork cannot edit the copy every template is handed
- duplicate deep-copies one into an editable spec of its own, which is
  decision 13's on-ramp to authoring
- grow spacers between the circles distribute them, so the row spreads
  to whatever width it is dropped into
- the letter is a text mark centred in an ellipse box, which is the one
  overlap decision 3 allows
- rows are grow 1 and split the height the container gives them, so row
  height is a readout and the block can never overflow
- the step is a select and timeSequence takes minutes, so a conditional
  bridges them: there are no string operations to convert with
- auto-sized, at a pitch that matches ruled notes so the two line up
  side by side
- the tick box is a rect or a circle chosen by when: shape is not a
  value slot, and decision 19 already has both marks
- a dotted rule is a fill repeat of short dashes, a line primitive
  carrying no dash pattern of its own
- line and dot count come from repeat: { fill: true }, so the block
  absorbs whatever space is left of the page
- dot and graph are grids of marks rather than a pattern, which is the
  count decision 6's primitive cap is set against
- each one inserted the way the insert menu will insert it, at every
  default it declares, and the sheet fits the page with no overflow
- clicking any of them selects the widget rather than a mark inside it,
  so the tracer template goes back to being a fixture

Closes #17
Author
Collaborator

Authoring the five turned up three places the format made a widget say something
sideways. None needed an engine change, all three are commented where they bite:

  • stepMinutes is a select and select values are strings, so the timeline
    bridges it to timeSequence with a conditional. A number-valued select would
    be a change to the inputs schema (decision 11).
  • shape is an enum on the node rather than a value slot, so the todo tick box
    is a rect and a circle chosen by when rather than one mark with a shape
    input.
  • a line primitive carries no dash pattern, so a dotted rule is a fill repeat of
    short dashes - the same answer decision 6 already budgets for a dot grid.

Three defaults are mine rather than the plan's, which names none for them:

  • todo rows 8
  • timeline start 08:00, end 18:00
  • notes style ruled, which is what the stated 7mm pitch goes with
Authoring the five turned up three places the format made a widget say something sideways. None needed an engine change, all three are commented where they bite: - `stepMinutes` is a select and select values are strings, so the timeline bridges it to `timeSequence` with a conditional. A number-valued select would be a change to the inputs schema (decision 11). - `shape` is an enum on the node rather than a value slot, so the todo tick box is a rect and a circle chosen by `when` rather than one mark with a shape input. - a line primitive carries no dash pattern, so a dotted rule is a fill repeat of short dashes - the same answer decision 6 already budgets for a dot grid. Three defaults are mine rather than the plan's, which names none for them: - todo `rows` 8 - timeline `start` 08:00, `end` 18:00 - notes `style` ruled, which is what the stated 7mm pitch goes with
TJC requested review from TJC 2026-08-18 14:45:44 +01:00
- build the scope and instance-value maps by definition, not assignment
- an assigned __proto__ landed on the map's prototype and read back as a
  name nothing declared
- copy the widget node up front and clone each inlined value per reference site
- decision 14 keeps the tree from before the unpack as the undo snapshot, so a
  shared expression would be edited in both at once
- decision 11 generates the panel from the declared bounds either way, so an
  out-of-range prop opened the control on a number the user could not return to
- only a literal is checkable at parse time; an expression has no number until
  layout evaluates it
Author
Collaborator

Review pass over the branch turned up four low-severity issues, all fixed here.

Fixed

  • An input named __proto__ was not a variable of its own. The scope and instance-value maps were built by assignment, so the name landed on the map's prototype and read back through the own-property guard as undeclared - unpack refused with reads $__proto__, which is not one of its inputs and layout reported unknown variable __proto__. Both maps are now built by definition. (1aca0ea)
  • Unpacking shared expression objects. valueOf handed the same Expr object to every reference site, and the container reused when, repeat, width and height from the tree it replaced. Decision 14 keeps both trees live as undo snapshots, so one in-place edit would have reached both. The widget node is now copied up front and each inlined value is cloned per site. (4ac018f)
  • Every instantiated widget shared one Size object for width and height. parseTemplate rebuilt them, so nothing on the page was affected, but a node built by instantiate and used unparsed was not. (a58e12b)
  • min/max were only ever checked against the declared default. A template passing rows: 500 to an input declared max: 40 parsed fine and opened the number control on a value the user could never return to - the exact case checkBounds exists to prevent, at the other way into the control. Rejected at parse time now; an expression prop is still allowed through, since it has no number until layout evaluates it. (00347bb)

Checked and sound

  • when/repeat.over substitute in the outer frame and everything else in the repeat-shadowed frame, matching resolve.ts's evaluation order.
  • shadow() mirrors bind(scope, {i}) vs {item, i} for count/fill vs over.
  • childDefiniteness treats the stack wrapper unpack produces like a widget parent, so fill repeats survive unpacking.
  • All five built-ins round-trip through unpack with identical laid-out geometry.

Test plan

  • 391 tests pass, up from 385; each of the six new tests fails against the code before its fix.
  • tsc --noEmit, eslint . and prettier --check clean.
Review pass over the branch turned up four low-severity issues, all fixed here. ## Fixed - **An input named `__proto__` was not a variable of its own.** The scope and instance-value maps were built by assignment, so the name landed on the map's prototype and read back through the own-property guard as undeclared - `unpack` refused with `reads $__proto__, which is not one of its inputs` and layout reported `unknown variable __proto__`. Both maps are now built by definition. (1aca0ea) - **Unpacking shared expression objects.** `valueOf` handed the same `Expr` object to every reference site, and the container reused `when`, `repeat`, `width` and `height` from the tree it replaced. Decision 14 keeps both trees live as undo snapshots, so one in-place edit would have reached both. The widget node is now copied up front and each inlined value is cloned per site. (4ac018f) - **Every instantiated widget shared one `Size` object** for `width` and `height`. `parseTemplate` rebuilt them, so nothing on the page was affected, but a node built by `instantiate` and used unparsed was not. (a58e12b) - **`min`/`max` were only ever checked against the declared default.** A template passing `rows: 500` to an input declared `max: 40` parsed fine and opened the number control on a value the user could never return to - the exact case `checkBounds` exists to prevent, at the other way into the control. Rejected at parse time now; an expression prop is still allowed through, since it has no number until layout evaluates it. (00347bb) ## Checked and sound - `when`/`repeat.over` substitute in the outer frame and everything else in the repeat-shadowed frame, matching `resolve.ts`'s evaluation order. - `shadow()` mirrors `bind(scope, {i})` vs `{item, i}` for count/fill vs over. - `childDefiniteness` treats the `stack` wrapper unpack produces like a `widget` parent, so fill repeats survive unpacking. - All five built-ins round-trip through unpack with identical laid-out geometry. ## Test plan - 391 tests pass, up from 385; each of the six new tests fails against the code before its fix. - `tsc --noEmit`, `eslint .` and `prettier --check` clean.
TJC approved these changes 2026-08-18 15:17:20 +01:00
TJC merged commit 86c2c63f29 into main 2026-08-18 15:17:23 +01:00
TJC deleted branch feat/m5-widgets 2026-08-18 15:17:27 +01:00
Sign in to join this conversation.
No reviewers
TJC
No labels
chore
core
render
ui
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TJC/daily-planner-template!32
No description provided.