0.36.0 2026-10-06
A correctness and UI-surface release. Three data-integrity bugs in the collaborative-document layer are fixed — a cold-miss op could discard every shape on a board, the doc cache grew without limit, and a stale op could silently overwrite a newer edit to the same shape. The Basecoat skin is repaired (it had been rendering without most of its CSS) and the Morpheus skin is removed. Adds a versioned, drift-checked dependency on the vendored stelow coding standards.
Added
- The vendored stelow standards can now be refreshed with one reviewed command, and the update is proposed as a PR rather than committed silently. Three layers, each with a distinct job:
| Layer | Mechanism | Role |
|---|---|---|
| 1. Detect | bin/check-stelow-drift.sh (advisory pre-commit; --strict via make check-stelow-drift) | Warns locally when the vendored copy or the pin is off |
| 2. Report | .github/workflows/stelow-drift.yml (weekly) | Fails its own run when upstream has moved |
| 3. Fix | scripts/refresh-stelow.sh, proposed by .github/workflows/stelow-auto-bump.yml (weekly) | Opens a PR against the new pin |
scripts/refresh-stelow.sh fetches each file from raw.githubusercontent.com/<repo>/<sha>/<path>, which returns byte-identical content without cloning the stelow repo and without spending GitHub API rate budget on content. It resolves a branch or an explicit commit, discovers the upstream file list (API tree → local clone → local files, warning loudly in the last case because only the upstream tree can reveal a new reference), fetches and verifies, installs, bumps UPSTREAM_SHA, and re-runs the strict drift check. Any fetch failure aborts before touching the tree, so a partial upstream change cannot land.
The update lands as a PR, never an auto-commit: the vendored copy is normative — it is the universal coding principles this template enforces — and a silent commit would change the rules the project holds itself to with nobody reading the diff. This mirrors the existing "human must approve all AGENTS.md changes" rule.
Removed
- The Morpheus skin is gone. It was added in v0.24.0 as a third UI skin and shipped since, but its upstream is a two-month-old alpha (created 2026-07-07, 22 stars) and its layout classes resolved to nothing:
cal14-grid,cal14-left,cal14-rightandneo-tab-activehad zero definitions in any stylesheet, andmorpheus.csswas only--neo-design tokens — the components themselves are shadow-DOM<neo->elements styled inside their own boundary, so the utility classes in the template silently did nothing. That is why the skin looked broken rather than merely stylistically different.
Removed rather than rewritten: web/skins/morpheus/ (the vendored bundle and its neolib/ component set), web/resources/static/morpheus/, the SkinMorpheus constant, the _ "…/web/skins/morpheus" registration, the handleIndex / renderTodoListRegion dispatch branches, the skins-extra trim-unit entries, and the Morpheus leg of the Playwright skin sweep. An unknown UI_SKIN or ?skin= value still falls back to DaisyUI, so a bookmarked ?skin=morpheus URL degrades to the default skin rather than erroring. DaisyUI remains the default; Basecoat remains the supported option.
Fixed
- Split three files back under the 500-line budget (
features/whiteboard/handler.go511 →handler.go424 +handler_peers.go;internal/collab/docstore_bounds_test.go507 →docstore_bounds_test.go+shape_version_test.go;features/whiteboard/web_test.go548 →web_test.go+web_conflict_test.go). No behaviour change — same package, same tests. CI had caught this:check-sizeswas a gate that had not been run locally before the first push, which is why the tag was moved onto the commit where CI passed.
- A whiteboard op could be applied to an empty document, silently discarding every shape drawn before it.
DocStoreonly ever added docs and never rehydrated on a miss, so a request for a doc this process had not loaded started fromNewDoc(id)— empty — applied the op, and then persisted that resolved snapshot over the good one. The reachable production path is exactly the offline-replay flow: after a server restart the service worker serves the board page from cache, the SSE stream connects (loading nothing), and the client's IndexedDB outbox then replays an op into the empty doc. The store now rehydrates from thePersisterbefore publishing the doc into the map (so no concurrent caller can observe a half-loaded doc), and bothNewWebSyncWorkerandNewSyncWorkerwire the persister in — the NATS side needed it too, since a cross-instance update for a non-resident doc had the same failure. Red-proofed: with rehydration disabled, the new test fails withprior shapes were discarded.
DocStoregrew without limit. OneLoroDocperdocIDwas retained for the process lifetime, each holding a full op log, with no delete or evict path anywhere in the package. A whiteboard server accumulated a doc for every board ever touched. It is now a bounded LRU (DefaultMaxDocs= 256,DefaultIdleTTL= 5 min) with an explicitEvict, andcrdtstore's per-ownerdocsmap (same unbounded shape, one doc per owner) got the same treatment.
The TTL floor is what makes eviction correct without reference counting: a single request mutates a doc for microseconds, so a doc idle for minutes cannot be mid-mutation. Without it an LRU could evict a doc between the GetOrCreate and the ApplyOp of the same request, putting two concurrent ops on two different Doc instances and losing one. A doc in active use is therefore never evicted even when that briefly exceeds the cap — exceeding the cap is the correct trade. Both properties are pinned by tests, and the bound test is red-proofed.
- A stale op can no longer overwrite a newer edit to the same shape. The store is a
LoroMap, where writing an existing key is last-writer-wins: every replica agrees deterministically on which write survives, but the other edit is lost with no signal to anyone. For a canvas that is the wrong trade — a user who moves a shape and watches it snap back has no way to know a peer also moved it. Shapes now carry a server-assignedVersionand clients send back the revision they last saw asShapeOp.BaseVersion; a write based on a superseded revision is refused with 409 + the current shape list, so the browser resyncs and the edit can be re-applied against the newer base instead of vanishing. The check and the write happen under one lock hold, so two concurrent ops cannot both pass it. The version is persisted (it survives a snapshot round-trip), and a rejected op is neither persisted nor broadcast. Red-proofed: disabling the check makes both conflict tests fail withwant ErrShapeConflict.
Scope, stated plainly: the canvas today only adds shapes, so this was not yet reachable from the UI — it becomes load-bearing as soon as shapes can be moved or resized. Two consequences worth knowing: a replayed duplicate add is now a 409 rather than a silent no-op (the safer answer, and the state is still unchanged), and clear still takes no version because it cannot conflict.
- Two claims about the CRDT's concurrency behaviour were measured and refuted; the measurements are now pinned as regression tests. A replayed
cleardoes not wipe concurrent additions (a clear of keyxconcurrent with an add of keyyconverges to{y}), andadd/clearreplays are state-idempotent. Measurement method matters here: compare resolved state, neverEncodeSnapshot()bytes — snapshot bytes are not stable for the same logical state because Loro records every op, so a byte comparison reports "not idempotent" for a genuinely idempotent operation. The one real hazard found this way — a stale offline op silently clobbering a newer write — is fixed above, and its regression test asserts the rejection rather than the old behaviour.
- The Basecoat skin was missing most of its CSS, and the cause was the scan roots, not the class names.
src/css/basecoat-input.cssdeclared its inputs as@import "tailwindcss" source("../../features/") source("../../web/")— Tailwind v4'ssource()takes directories, not recursive globs, soweb/skins/basecoat/.templandfeatures/todo/components/.templcontributed nothing to the bundle. Every layout utility those templates rely on (grid-cols-3,p-0,py-2,space-y-3,lg:grid-cols-5, …) was simply absent, which is why the skin looked broken while its class names were in fact correct. Replaced withsource(none)plus explicit@sourceglobs that mirrorsrc/css/input.cssexactly, so a class that works under DaisyUI cannot silently vanish under Basecoat.
- Basecoat had no stepper, so the async-demo progress was invisible. The demo panels render
<li class="step">and togglestep-primary/step-success/step-errorthrough Datastardata-class. Those names are DaisyUI's.stepscomponent; Basecoat ships no equivalent, so every state class resolved to nothing and the list rendered as static text that never changed. The three state utilities are now defined insrc/css/basecoat-input.css, which keeps the state wiring in the templates as the single source of truth instead of forking the markup per skin.
data-variant="accent"silently opted out of Basecoat's styling. Basecoat has noaccentvariant, and its default rules are written as.badge:not([data-variant])— so any unrecognised variant value excludes the element from the default rule rather than falling back to it. The DagNats badge and the “Run durable workflow” button rendered unstyled. Both now usedata-variant="secondary".
make css-installdid not checkbasecoat-css. The version guard that reinstalls whennode_modulesdrifts frompackage-lock.jsoncoveredtailwindcss,@tailwindcss/clianddaisyuibut notbasecoat-css, so a stale Basecoat could emit a bundle differing from the committed one and failcss-checkwith no source change — the exact false alarm the guard exists to prevent.
Verification
make ci-localgreen end-to-end, including the binary boot smoke test and the Playwright browser smoke across both skins;make site-checkgreen (22 pages, all anchors)- Red-proofs for every fix in this release: disabling rehydration fails with
prior shapes were discarded; disabling eviction fails withstore.Len() = 10, want <= 4andresident = 261, want <= 256; disabling the version check fails withwant ErrShapeConflict; a content mismatch in the vendored stelow copy prints and exits non-zero under--strict;bin/check-stelow-drift.sh's round-trip to an older ref and back restores byte-identically - Two of this session's own claims were measured and refuted (a replayed
cleardoes not wipe concurrent additions;add/clearreplays are state-idempotent) and are pinned as regression tests golangci-lint0 issues with 33 linters, scoped to the touched packages (never the whole tree on a shared host)