Skip to content

Hazel HTML - #2115

Open
disconcision wants to merge 121 commits into
devfrom
hazel-html
Open

Hazel HTML#2115
disconcision wants to merge 121 commits into
devfrom
hazel-html

Conversation

@disconcision

@disconcision disconcision commented Feb 9, 2026

Copy link
Copy Markdown
Member

Elm-style web apps as Hazel programs. Write a 4-tuple (init, update, view, subs) anywhere in a program, project it, and it runs:

type Action = Int in
let update(model: Int, action: Action): (Int, Cmd) = (model + action, CmdNone) in
let view(model: Int): HTML =
  Div([], [Button([OnClick(1)], [Text("+")]), Int(model)]) in
(0, update, view, fun _ -> SubNone)

Editing update while it runs rebinds the live app and keeps its state, as long as the new view still accepts the old model.

HTML, Attr, Cmd, Sub and KeyEvent/MouseEvent are builtin types; HazelDOM renders values of them to vdom. App state lives in AppStore, an id-keyed store in page state rather than in the syntax — models hold closures, and SetModel is an edit action, so putting it in the tree would mean whole-document statics per message. Dispatch is is_edit=false and non-historic, so clicking a button doesn't touch statics or undo.

The model comes first in update, matching fold_left's (acc, elem) and Hazel's subject-first convention, so update folds directly over a list of actions. update always returns (Model, Cmd); a program that issues no commands can return a bare Model and lift it with a two-line noCmd at the tuple, which keeps the 4-tuple's shape uniform. (The runtime tolerates a bare model via a warning fallback, but relying on it would put two update types through one slot.)

There is one HTML projector, not two. It picks its commit target per render from the projected expression's live value: a 4-tuple gets state commit, where the model lives in AppStore; bare HTML gets syntax commit, where the expression itself is the model and an action is an Html -> Html transform spliced back in. So "self-modifying HTML" is not a separate feature, just what the projector does when handed HTML — and it only works on closed HTML, since evaluation erases variables before the splice.

To facilitate displaying HTML apps alongside code, all projectors gain a generic placement toggle (Inline | Sidebar): Alt+S on any projector docks it to a new Projectors panel, leaving a chip at the code site.

Ten example apps in hazel-programs/mvu/, each also shipping as a documentation slide under MVU / … with its app already running and docked. regen-slides.sh re-encodes the slides from the .hz sources; nothing detects a stale encoding, so that script is the only correct way to edit them. The generated slides in src/mvu/ are most of this PR's diff — ~54k lines, in line with the existing src/b2t2 (5.8 MB) and src/web/init/docs (1.3 MB) slide libraries.

Also here because the ~50-variant recursive builtin types made it necessary: ConstructorMap.venn_regions was O(n^2), builtin constructor annotations now stay in compact Var form instead of expanding, and physical-equality fast paths were added to Typ.meet/normalize/Equality.

docs/mvu.md is the feature doc: program shape, the type and event reference, the design decisions and the known limitations.

Known caveats: init accepts any Var/Ap (the value can only be checked after evaluation), so the HTML context-menu entry offers itself broadly; AppStore.gc is implemented but unwired; the Projectors panel shows only the reported editor's projectors; subscriptions are torn down and recreated on every model change, so timers restart their countdown each tick. Opening a slide shows the code plus a chip, and the app appears once the Projectors panel is revealed — the panel choice then persists, so that is one click per browser profile rather than per slide.

Not caused by this branch but worth flagging, since interactive apps make it visible: an MVU dispatch is dominated by IncrEval.remove_pat_bindings (~50%) and UUID minting during evaluation (~30%). Measured, with a writeup, but untouched here.

disconcision and others added 30 commits July 19, 2025 23:46
# Conflicts:
#	src/haz3lcore/projectors/ProjectorCore.re
#	src/haz3lcore/projectors/ProjectorInit.re
#	src/language/term/Typ.re
Documents a staged plan for building a full-featured HTML/DOM wrapper
for Hazel programs, including:

- Expanded Html element types (structural, forms, tables, semantic)
- Expanded Attr types with typed common attributes + string fallback
- Event data types (KeyEvent, MouseEvent)
- Cmd system for fire-and-forget effects (focus, scroll, clipboard)
- Sub system for event source subscriptions (resize, keyboard, time)
- App viewer integration options

Uses "self-modifying" pattern (handlers return Html -> Html) to work
without type parameters. Notes future work to parameterize over message
type once Hazel gains type parameters.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add KeyEvent and MouseEvent product types for event data
- Expand HTML.t with ~40 element constructors (forms, tables, semantic sections)
- Expand HTML.attr with ~45 attribute/event constructors
- Add mouse/keyboard event handlers with proper JS interop
- Update HazelDOM.re renderer to support all new elements and attributes
- Use Node.create() escape hatch for HTML5 elements without Virtual_dom functions
- Fix constructor registration to handle non-sum types (product types)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add Cmd type to BuiltinsADT.re with variants:
  - CmdNone, Batch(List(Cmd))
  - Focus(String), Blur(String), ScrollIntoView(String), ScrollTo(String, Float, Float)
  - CopyToClipboard(String), Delay(Float, Html -> Html), Log(String)
- Create CmdRunner.re to interpret Cmd values as Ui_effect.t
- Commands are fire-and-forget effects that work without type parameters
- Delay command uses Bonsai.Effect.Expert.handle for async state updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add Sub type to BuiltinsADT.re with variants:
  - SubNone, SubBatch(List(Sub))
  - OnResize((Html, Int, Int) -> Html)
  - OnVisibilityChange((Html, Bool) -> Html)
  - OnDocumentKeyDown/OnDocumentKeyUp((Html, KeyEvent) -> Html)
  - Every(Float, (Html, Float) -> Html)
  - AnimationFrame((Html, Float) -> Html)
- Create SubManager.re to manage subscription lifecycle
- Track event listener IDs for proper cleanup
- Rename Cmd.Batch to CmdBatch for consistency with SubBatch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add App type as product: ((HTML, Cmd), HTML -> Sub)
- init provides initial state and startup command
- subscriptions function returns event sources based on current state
- Foundation for projector-based app runner

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add process_handler_result() to detect (Html, Cmd) tuples
- Event handlers now support returning either Html or (Html, Cmd)
- When handler returns tuple, CmdRunner executes the command
- Backwards compatible: plain Html returns still work

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Mark completed deliverables in Phases 1-5
- Add implementation status summary at top
- Note remaining work: Sub lifecycle integration, App runner, error boundaries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Add projector_id and subscriptions fields to HazelDOM.t
- Add global active_subscriptions registry keyed by projector ID
- Add manage_subscriptions() to set up/clean up subs on render
- HTMLProj passes projector_id for subscription tracking
- Subscriptions are cleaned up when projector re-renders

Note: App type detection for automatic subscription extraction
is still TODO - currently subscriptions must be passed explicitly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- detect_app() checks if expression is App type: ((HTML, Cmd), HTML -> Sub)
- When App detected, extracts html model and evaluates subscriptions function
- Subscriptions are passed to HazelDOM for lifecycle management
- Plain Html expressions continue to work as before

Note: init_cmd from App is detected but not yet executed on startup.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Create hazel-html-implementation.md with complete technical reference
  - Architecture overview with diagram
  - All types with full constructor listings
  - Self-modifying pattern explanation
  - Runtime components (HazelDOM, CmdRunner, SubManager)
  - Usage examples for plain HTML, commands, and full App
  - Known limitations and future enhancements

- Update hazel-html-plan.md
  - Point to implementation doc as primary reference
  - Mark completed items
  - Note deviations from original plan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Run the startup command from App init tuple via CmdRunner
when the projector first renders an App type expression.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
disconcision and others added 13 commits July 23, 2026 16:29
…at load

Remove the [PERF] printf timers (TimeUtil.log_time + call sites in
CachedStatics/Statics/Evaluator) and the profiling counter refs baked
into Typ.normalize/meet, keeping every optimization intact. The Var-Rec
meet fast path now verifies the alias body against the Rec (=== or
syntactic equal) instead of trusting the bound name alone. Ascriptions'
builtin ctx is set once at Evaluator module load rather than inside
evaluate, so stepper/PatternMatch/Unboxing entry points no longer
depend on an evaluation having run first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vents

Consolidate the triplicated app/HTML shape detection and DHExp
extraction helpers into projectors/MvuShape (HTML constructor names now
derived from BuiltinsADT.HTML.t, so they can't drift). Fix the Elm-mode
error branches in on_input/on_mouse/on_key, which injected an error-HTML
node into the user's update function as if it were a Msg — they now
report and ignore. Implement OnSubmit (prevent-default + dispatch)
instead of silently doing nothing. Remove dead code (is_cmd,
AnimationHandle) and the web-layer [PERF] timers + step-trace console
logs. KeyEvent/MouseEvent become labeled tuples (key/code/ctrl/... and
x/y/button/...; clientX/clientY renamed) so handlers can use projection
like e.key instead of positional destructuring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Convert let-bound lambdas to the let f(args) = ... sugar (including
annotated forms let f(x: A) : R = ...) across all 15 examples, and
switch event consumers to labeled projection (e.key etc.). All examples
verified via hazel run; inline test suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the Globals.app_view_state singleton with apps: AppStore.t
(Id.Map) in the State stratum. Entries split state (model) from memos
(update/view/subs closures, html) and own their subscription handles;
subscription reconciliation moves from render into the update path,
retiring HazelDOM's module-global active_subscriptions Hashtbl —
HazelDOM.go is now render-only, and timers no longer reset phase on
unrelated renders. AppView* actions are id-keyed (sidebar keeps a
synthetic id). On re-init after program re-evaluation, the old model
survives iff view(old_model) still evaluates — live-editing the
program keeps app state when compatible. GC implemented, wiring
deferred (TODO at MakeActive).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Projector model strings are opaque to statics (audited every kind:
sliders/checkbox/textarea/livelits commit through SetSyntax, which
stays Full), so Project(SetModel) now classifies as Layout: CachedSyntax
and measured/shape maps rebuild, CachedStatics is reused, and no worker
re-eval is scheduled. New SetModelQuiet variant (non-historic twin) lets
drag gestures coalesce: HTMLProj resize pushes one history entry on the
first tick and streams quiet updates after, so undo restores pre-drag
size in one step. Autosave still fires via a new Updated.save field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-modifying mode is Elm with update = apply: a handler always produces
a msg, and only the commit target differs. HazelDOM.t shrinks to
{inject, view_term, commit} with commit = State | Syntax — State lets
AppStore evaluate update(msg, model), Syntax lets HTMLProj evaluate
msg(model) and splice via SetSyntax. Payload events build the msg as
`fun m -> handler((m, payload))` (payload_transform), so the per-handler
update_fn branching disappears; Cmd results still run, and Delay works
uniformly since a delayed msg is just another transform.

Cuts, all legacy-side: the 2- and 3-tuple app shapes and their detection,
SetAppViewModel and the sidebar's dual inject, OnMousedown, the
Checkbox/Radio/Range element constructors (Input + Type covers them),
CmdRunner's update_fn and its legacy Delay reinterpretation, and
process_handler_result. Node/Create/BoolAttr escape hatches stay.

Test_MVU drops the legacy-shape cases and adds three for the unified
syntax-commit path (handler-as-msg, payload_transform, (Html, Cmd)).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add placement (Inline | Sidebar) to the projector instance, defaulted
via [@sexp.default] so documents serialized before the field still load
(verified against the six projector-bearing doc slides). Alt+S on an
indicated projector, or the context menu, toggles it: the code site
keeps a chip — kind icon plus ~10 chars of abbreviated syntax — and the
projector's primary view renders as a card in a new Projectors sidebar
panel, in syntax order, with jump-to-source and undock.

Routing is generic, so this works for every projector kind without
per-projector changes: mk_view is unchanged and placement-agnostic, and
split_views picks chip-vs-inline the same way refractors already pick
skip_inline. Overlay and offside keep rendering at the code site in both
placements. ProjectorChip owns the chip's segment and its Shape so the
reserved footprint and the drawn width can't drift. TogglePlacement is a
Layout edit (footprint changes, semantics can't), historic, one undo
step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An Elm 4-tuple anywhere in a program can now be projected, so an app no
longer has to be the last expression — and since projectors have
placement, it can be docked to the sidebar with Alt+S. One projector,
two commit modes, chosen by the live value: an app commits to AppStore
(State), bare HTML keeps splicing into the document (Syntax).

Detection is two-phase because init only sees pre-eval syntax: init
accepts permissively (HTML, 4-tuple literal, Var, Ap) and view decides
authoritatively from the evaluated value. That value comes from the
probe system via Projector.dynamics — which turned out to be a dead
flag, its consumer removed when legacy probe code was dropped from
maketerm. Rewired it in CachedStatics: dynamics-requesting projectors'
ids join the refractor probe ids, additively, so nothing changes for
projectors that don't ask.

Core can't depend on web, so AppBridge holds function refs (inert by
default) that AppBridgeInstall populates — the Ascriptions.ctx_ref
precedent. ProjectorView's ViewCache gains an app-version key, since
store updates are an input the cache otherwise can't see; non-app kinds
pass 0 so a ticking app doesn't invalidate every projector on screen.

Checkpoints: an entry's model is serialized into the projector model
after 2s idle, but only when closure-free, via the quiet non-historic
write. On restore it must both evaluate and yield HTML under the current
view — Hazel is gradual, so a wrong-shaped model returns Indet rather
than failing, and eval-success alone would accept stale state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From testing the docked-projector feature:

Cards appeared in reverse syntax order — MakeTerm builds projector_list
by prepending, so it is reverse-syntax; sidebar_views now sorts against
the reversed list. The list itself is left alone: action idx values
index the unreversed one.

The chip is now uniform and fold-like: a single ⇥ glyph for every kind
(no per-kind icon, no abbreviated syntax), two columns wide, with the
shard backing always drawn. Per-kind CSS was the reason chips looked
different from each other — some kinds hide or recolor that backing —
so a `chipped` class neutralizes it. Clicking a chip opens the
Projectors panel, expanding the sidebar when collapsed.

Multi-line projectors collapsed to one line when docked: they size
themselves with height:100% against the box the code editor gives them,
which the panel didn't provide. The panel now sets an explicit height
from the projector's own placeholder shape.

Fixes a pre-existing crash this made reachable: TextArea/Livelit/Probe
focus handlers asserted their element exists, but it doesn't when the
projector isn't drawn at the code site — docked, or culled from the
viewport, which could already crash before this branch. They no-op
instead, and key_handoff no longer offers arrow keys to a docked
projector.

detect_app_kind also accepts a labeled app tuple — (init=…, update=…,
view=…, subs=…) matched by name, so order is free — alongside the
positional form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The panel was still showing cards in the wrong order after the previous
attempt, because that attempt was built on a wrong assumption: I took
projector_list to be reverse-syntax-order (MakeTerm prepends to it) and
reversed it. But MakeTerm logs projectors during a skel-driven descent,
so the list follows traversal order, which is neither source order nor
its reverse in general.

Sort by the projector's measured origin (row, col) instead. That is the
same measurement used to absolutely position it at the code site, so it
is on-screen order by construction and depends on no list ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old handle computed size from absolute cursor position against a
container rect it re-read every move, using a px-per-char ratio derived
from offsetWidth/cols rather than the real font metrics. So error scaled
with drag distance, and because resizing reflows the document, the
reference moved under the drag — hence bouncing. The handle also lived
inside the overflow:auto wrapper, so once app content overflowed it
anchored to the content's corner instead of the visible box.

Now: anchor cursor position and starting cols/rows at pointerdown and
size from the delta, using the editor's true col_width/row_height, never
re-reading geometry mid-drag — immune to reflow by construction. Handles
are siblings of the scrolling wrapper, each with its own 6px gutter:
right edge resizes width, bottom edge height, corner both. Docked shows
only the bottom edge; width belongs to the sidebar. All pointer events
now, with lostpointercapture handled so a lost capture can't strand a
drag, and a click that never moves no longer emits a model update.

Two framework gaps closed to make this possible: View.args now carries
col_width/row_height (projectors had no access to font metrics, which
live web-side), and View.status carries placement (projectors could not
tell whether they were docked). Both are generally useful.

Also fixes a latent staleness bug this surfaced: ViewCache keyed on
everything except font metrics, so cached views kept closures over the
old cell size across a zoom or font-size change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The App View sidebar tab rendered the app at the bottom of the program.
An app can now be projected anywhere and docked to the Projectors panel,
so the tab is redundant and goes, along with its plumbing: Sidebar's
cell_editor parameter, Page.Update.get_cell_editor, AppStore.sidebar_id,
and the RefreshAppView and ResetAppView actions, which only the panel
dispatched (RefreshAppView was a byte-for-byte duplicate of InitAppView's
handler). InitAppView and AppViewMsg stay -- the projector path uses both
through AppBridge.

The checkpoint guard was rejecting every function, which is stricter than
it needs to be: the evaluator substitutes environments away, so a
function reaching a model is already closed, and a closed function is
ordinary syntax that serializes and restores. is_closure_free becomes
is_checkpointable and rejects only terms carrying a captured environment.

Environments turn out to be serializable, so that is not the reason:
Closure(env_init, _) round-trips at 228 KB, which is the reason -- along
with the evaluator invariant saying it should never arrive. FixF with an
environment is rejected on the same grounds. Round-trips verified per
form: closed Fun, TypFun, FixF without an environment, BuiltinFun, and a
function inside a labelled model.

Note what the guard does not prove: closedness. Only evaluate guarantees
that, and restore's render check is not an airtight backstop, since a
view can embed a function without applying it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking a card moved the caret only when the target happened to be in
the active editor, and even then left it invisible. Both sidebars were
injecting ActiveEditor(Move(Goal(TileId(id)))), which searches only the
active editor and skips the global JumpToTile handler -- the one that
resolves which cell holds the id and schedules focus, without which the
caret stays hidden, since #caret is visibility:hidden unless the editor
has :focus. Cross-editor was the case it could never serve, and in
Exercises and Tutorial modes the panel is pinned to a fixed cell, so
panel-shows-one-cell-while-another-is-active is the normal state.

The card header's syntax budget was 10 characters -- sized for the
inline chip back when the chip displayed syntax. The chip has been a
single glyph since, so the constant only served the card, where the
sidebar affords about 32. Renamed to say so. The chip's reserved width
was always separate from it, so the footprint is unchanged.

Card headers drop their per-kind icon: the syntax identifies the row and
the space is better spent on it. That was kind_icon's only caller, so it
goes too. Tab icon is a library rather than a chest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@disconcision
disconcision marked this pull request as ready for review July 27, 2026 08:13
@disconcision
disconcision requested a review from cyrus- July 27, 2026 08:13
disconcision and others added 15 commits July 27, 2026 01:15
`make ci` runs `dune runtest --instrument-with bisect_ppx --force`, which
is a good deal heavier than the local `@test-quick` loop: `@runtest`
includes the Slow QCheck property tests that `@test-quick` skips, coverage
instrumentation slows every test, and `--force` disables caching. It has
been overrunning 20 minutes on several branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sum type compared unequal to a structural copy of itself whenever it
had two variants with the same constructor name, so `Typ.meet` of such a
sum with itself returned None — spurious type errors on a state users
reach by typing a variant. The stepper/evaluator confluence property was
failing on it; both engines were producing identical terms and equality
was wrong.

Cause: ba0c45b replaced venn_regions' linear List.partition scan with
a Hashtbl index, but Hashtbl.add stacks duplicate keys and find_opt and
remove are LIFO, so with names repeated the pairing crossed: xs[0] with
ys[1], xs[1] with ys[0]. That commit also computed the right region from
a Hashtbl.fold, i.e. in arbitrary order, and had quietly dropped two
BadEntry behaviours.

Restores dev's pairing semantics while keeping the O(n) index: index ys
keeping the first entry per name, consume every ys entry sharing a
matched name as List.partition did, and derive the right region by
filtering the original ys so its order is deterministic again. Still
linear for large ADTs — the only scan left is over BadEntries, of which
real ADTs have none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The suite went from 5m31s on dev to 23m38s on this branch. Cause: the
expression generator emits constructors named `A` and `B` in its
minimal-names mode, and this branch adds an HTML anchor tag as a builtin
constructor named `A`. So half the constructors in every random program
resolved to `<a>`, whose type expands to 7,836 nodes — 4.5M normalize
steps across 300 programs, against 14,828 with non-colliding names.

Names now avoid the builtin constructors, derived from the builtin
context rather than listed by hand, since the free-form branch could also
generate Div, Text, Br, Main, Form, Label, Code and Span. Statics.Properties
goes from over 600s to 87s (dev: 88.6s) and the full suite to 5m53s.

This also restores coverage these properties had quietly lost: they mean
to test arbitrary user-defined constructors, and half of them were
testing HTML typing instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Normalizing to compare is what made HTML programs slow in statics.
`Var("HTML")` expands to ~7,800 nodes — its 46 variants re-expand `Attr`,
whose 48 variants re-expand the event types — and both sides expand
unconditionally before the comparison starts, so the cost is paid even
when the types differ at the head.

In practice the two sides are the same type. Instrumenting the example
programs: 100% of calls in mvu-counter, 97% in lunar-growth, 92% in
todo-list, 85% in gameoflife are settled by a syntactic check. Equal
types normalize equally, so checking that first is sound and skips the
expansion. Time in normalize: mvu-counter 244ms -> 0, gameoflife 310ms
-> 3ms, lunar-growth 1681ms -> 46ms.

The remainder — types that are equal only after alias resolution, and
those that genuinely differ — still normalize. Bailing out lazily on the
latter would want a comparison that resolves as it descends; not done
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither had property coverage: Test_Equality was unit tests only, and
Statics.Properties either checks that statics doesn't crash or gathers
statistics without asserting. So a sum comparing unequal to a copy of
itself — which reached the editor as spurious type errors while typing a
variant — was caught only incidentally, by the evaluator/stepper
confluence property, and only after it had shipped.

Five properties over random types: a type equals a distinct copy of
itself; a type meets a distinct copy of itself (the same thing stated as
the user-visible symptom); equality is symmetric, since the bug was
order-dependent pairing and asymmetry is the shape worth pinning rather
than the one instance; equal types normalize equally, which is the
soundness condition fresh_ascription's fast path depends on; and
normalize is idempotent.

Copies go through a sexp round trip, since comparing a value with itself
can short-circuit on physical equality and miss exactly this class.

Verified against the real defect: restoring the buggy venn_regions fails
three of the five in 0.6s, where the confluence property took ~100s and
found it by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The examples were written in February against logic that the probe study
has since revised. Three are re-derived from the July study tasks with
views ported onto the newer logic — crop-plotter, which replaces
emojipaint with the same idea reskinned as planting a grove, plus
harvest-streak and nutrient-rotation. Tictactoe takes the study's
Sprout/Shroom board; its players are renamed to match, since a view that
says "X's turn" over 🌱 and 🍄 reads wrong.

Note the two study sources are DEBUGGING tasks and ship planted bugs:
harvest-streak compares a quality to itself so every harvest continues a
streak, and nutrient-rotation's Nightshade boosts phosphorus where its own
comment and test expect nitrogen. Both fail their own tests. Our logic was
kept in those two places.

The seven keepers get light night-garden theming — moonflowers, a watering
timer, a planting list, a firefly steered with the arrow keys, a garden
that spreads by Conway's rules — renaming captions and identifiers without
touching what each example teaches. Dead/Alive and Glider stay as they are.

todo-list's input row becomes a real Form with OnSubmit, Required and a
Disabled submit button, which is the first coverage OnSubmit has had.

Dropped: animation (AnimationFrame can't reach 60fps against the current
whole-page vdom floor), tamagotchi and lunar-growth to keep the set
tight, emojipaint as superseded, and _test_eval as a working file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The eleven example programs are now slides under docs/mvu/, encoded with
`hazel slide-encode` and registered alongside the existing documentation.
Each opens as a working Elm-style app: project the 4-tuple at the bottom
to run it.

Module names carry an Mvu prefix because `include_subdirs unqualified`
flattens the namespace, and Timer and TodoList are names the web library
could plausibly want later.

The encoded segments are 4.2MB of source and add about the same to the
dev bundle, which is the cost of shipping working programs rather than
listings. The .hz files stay the editable source; re-encode after editing
them or the slide and the file drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Value and TextArea set only the `value` attribute. Once a user has typed
into an input the browser stops mirroring that attribute onto the displayed
value, so an app could never clear or reset a field: after a form submit the
typed text stayed visible while the model believed it was empty, and a
`Disabled(input == "")` submit button went dead with text still on screen.
Set the property alongside the attribute (Attr.value_prop).

Also flip the runtime's update call to `update(model, action)`, matching the
argument order the example programs now use. The order lives in the tuple
AppStore.dispatch builds, so changing only the program signatures silently
passed the action where the model belongs; views then rendered unevaluated
source text. Neither `analyze` nor `test` catches that class of bug, since
tests apply `update` directly and never exercise the runtime's call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Style: the examples were on a GitHub-dark palette that read as foreign
widgets inside a warm parchment editor. They now use Hazel's own palette
through var(), so they track it rather than snapshotting it, with the
editor's own conventions — hairline BR1 rules, small radii mixed with square
edges, one typeface, four sizes, and colour only where it carries meaning.
Root cards have no padding so section rules run the full card width; buttons
sit in uniform grids. Firefly keeps a dark sky, now a portrait grove so it
fits the sidebar without a horizontal scrollbar, and Seed Catalog's night
mode reuses that same sky.

API: `Msg` becomes `Action` (five examples already said Action), and update
takes the model first. That matches fold_left's (acc, elem) and Hazel's
subject-first convention, so update folds straight over a list of actions —
four programs had a shim existing only to flip the arguments, now gone. The
nine programs that never issue a command return a bare Model and lift it
with a local noCmd at the tuple, keeping the 4-tuple's shape uniform; the
runtime tolerates a bare model via a warning fallback, but relying on it
would put two update types through one slot. Seed Catalog issues real
commands and returns the pair directly.

Also: drop full-app, use real emoji where a field was named `emoji` and held
ASCII, replace tictactoe's staircase of nested cases with the winning lines
as data, give Seed Catalog named field setters instead of respelling six
fields per branch, and root out vertical margins, which overflow the
projector box at any size and leave a permanent scrollbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The encoded slides move from src/web/init/docs/mvu to their own library,
mirroring src/b2t2, and register under an "MVU / " prefix so they collapse
into a nested dropdown like the B2T2 and Derivations slides.

regen-slides.sh is now the way to rebuild them. Nothing detects a stale
encoding, so an edited .hz with no re-encode silently keeps shipping the old
program. Beyond a plain slide-encode it has to do four things:

- Strip leading indentation. Hazel computes indentation at layout time and
  renders literal leading spaces on top of it, so baked-in indentation shows
  up doubled and drifting. slide-decode hides this, printing raw segment
  text rather than the layout.
- Wrap the trailing tuple in ^^html(...) so a slide opens with its app
  already running, and switch the projector to placement Sidebar.
- Set a per-program size. HTMLProj defaults to 40x12 character cells, which
  clips all but the counter; the sizes here are measured from each app's
  rendered content height, with headroom for the three that grow as you use
  them.
- Format the result, since slide-encode's output is not ocamlformat-clean and
  would otherwise fail the next `make test-quick` on the promotion alone.

check-comments.py guards the trap that Hazel comments are single-line: one
spanning two lines does not error at the delimiter, it silently reparses the
following text as code and surfaces as a pile of unrelated static errors. A
lone `#` on its own line does the same, which is the natural thing to write
when separating a title from a description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AppBridge was 56% comment and ProjectorChip 40%. Keep the non-obvious parts
— why core reaches web through refs at all, why the chip owns both its glyph
width and its reserved shape — and drop the restatements of what the code
says plainly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches src/mvu and the "MVU / ..." slide group, so the source directory,
the generated library and the docs all use the one name for the concept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A slide stores a serialized zipper AND a backup_text, and
DocSlides.ReparseBackuptext asserts that parsing the text reproduces the
segment. Only what the TEXT can express survives, so a slide can carry a
projector only in its default model state. The MVU slides broke that three
ways; all ten failed.

Placement now lives in the invoke token (`^^html_sidebar`), threaded through
Token, Triggers, ProjectorPerform and ProjectorInit. The emit side was the
actual bug: projector_to_invoke built text from the kind alone and dropped
pr.placement, so a docked projector could never round-trip at all.

HTMLProj no longer keeps `exp` in its model. It duplicated the projector's
own syntax, nothing ever updated it — so as a fallback it would render the
expression as it was when the projector was first added — and it embedded
ids, which a reparse regenerates. It is the only projector that stored a
term; the rest are unit, an enum or a small record. Its one reader now takes
the syntax as the sole source, and an unconvertible syntax renders "no value
yet" rather than a stale app.

Docked HTML projectors size to their content instead of to ui.rows, so the
slides no longer bake a per-app size either. The hand-tuned sizes only
existed because the docked box was pinned at 40x12 and clipped; the panel
now fits the app exactly. Vertical resize is hidden while docked, since the
app decides its own height.

Also raise the CI test timeout to 60 minutes: `make ci` runs the suite under
bisect_ppx with --force, and the doc-slide tests dominate it. 20 was not
enough once these slides landed, and neither was 30.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kept the Ascriptions builtin-ctx init alongside dev's extracted Trampoline; gave the HTML projector a ProjectorCatalog entry; threaded the Updated.save field through agentCore's fold; adapted Test_MVU to the eval_info-based evaluate signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant