You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Tracking issue for everything still outstanding on the v4 prototype.
Where v4 stands (reconciled 2026-08-18 against main at #856). Three rounds merged (#777, #778, #779), then the round this issue was opened for (#781 → #789), and then 54 more PRs, #790 → #846, which this body had fallen behind. Fifteen ui families are ported as feasibility tests — Accordion, Dialog, ScrollAnimation, Slider, Data*, Action, ClickOutside, InView, Track, and from #847Fetch, LazyInclude, Prefetch, Cursor, Draggable, Carousel — and the gap list they produce, now 1–43 in migration/REPORT.md, is what drove this roadmap. That list is now closed apart from three entries, and the work since has come from reviewing the closures rather than from porting anything new.
Every section is closed. F2 landed in #860, which closes this issue — the v4 prototype's outstanding work is done.
Section E is closed by #847, the last feasibility round: every family this roadmap named is ported, and the ten gaps it filed are section K. Sections A, B, C, D, I and J are closed. What is left is F (two ui-level refactors), K (the port round's open gaps) and three decisions in G.
#847's review is where four of those gaps came from, and it closed three of them.swap() grew the axis its consumers asked for, @on learned to type its own handler, smoothTo() became usable by the components it was written for, and two families stopped writing to the DOM in the scheduler's read phase. #848 followed from the same review: interaction:page.
Then #849 → #856 closed the investigation round, and three of those PRs exist because a question found something the roadmap had not: removing $terminate() uncovered a live listener leak, making booleans presence-only turned three port fixtures from quietly-right into plainly-wrong, and the first lint rule found a scheduler-lane defect in ScrollAnimation that no test could fail on.
Section B is closed: #785 landed every correctness item, two of them by deciding the reported behaviour was the contract rather than a bug. D2, I5, I6, C8 and C9 landed with it.
Sizes: S ≈ one focused change, M ≈ needs design judgement, L ≈ a round of its own.
Gap numbers are reconciled. The branches of the #781–#789 round each appended to REPORT.md's gap list independently and several numbered a gap 23. #783 reconciled the list on merge: main's gaps 23–32 kept their numbers because #784's prose cross-references them, and the responsive gap moved to 33. #847 appended 34–39. The list now runs 1–39, no hole, no duplicate.
A — Core blockers
A1 — onWindow<Event> / onDocument<Event> (gap 15) · S · PR feat(v4): resolve onWindow and onDocument handlers #781 ClickOutside had no v4 form at all. Proved by porting it: four lines of body, and $emit() makes the announcement bubble where v3 hand-built a CustomEvent.
A2 — Suspend/resume a service subscription within a mount cycle (gap 1) · M
DESIGN.md §7 promises no permanent rAF loop; that promise is false today on any page with a slider or scroll animation. Two of four early ports dropped withRaf for a hand-rolled start/stop. Update: the InView/Track port did not independently confirm this — that family hand-rolls for a different reason and never wants to suspend. The case rests on the earlier two families alone, so it is weaker than first thought.
A3 — $options writability + factory defaults · done buildDefault() treats a callable default as a factory. Superseded by B7: the literal copy this entry described is gone — core no longer repairs a literal object or array default, it warns. The contract is a primitive can be a default; any other type needs a factory function, enforced by the types for the TypeScript audience and by the warning for the no-build-step one.
A4 — $id on Base · S · PR [Feature] Add stable v4 component IDs #796 — moved to C3, and the move was reversed
This entry said a property on every instance costs every component, while uid('Accordion') costs only its callers, so uid() should join the utils list instead. [Feature] Add stable v4 component IDs #796 decided the other way and shipped $id: a readonly <ComponentName>-<sequence>, resolved from the merged config.name, available to derived field initializers and stable across destroy and remount cycles. The port is the evidence — migration/utils/uid.ts is deleted and AccordionItem, SliderItem and now CarouselItem read $id. There is no uid() in src/utils/.
A5 — $watchChildren subclass matching (gap 18) · M · PR [Feature] Watch v4 child component subclasses #798 — downgraded, built after all
It shipped as the class form the deferral had ruled out for the predicate: $watchChildren(ComponentClass) and @children(ComponentClass) match by instanceof, over one matcher shared by the initial DOM sweep and the lifecycle updates. Constructor types flow through to the collections and callbacks. No global registry was added, and no predicate overload was: the open-set case the deferral described is what instanceof already covers.
B — Correctness and latent bugs · closed by PR #785
B1 — $destroy() cancelled scheduler tasks after the cleanups (gap 5) · S
The task set is swapped out and cancelled before the cleanups run, so a cleanup's $write() lands in a fresh set and survives.
B2 — gap 14 ($options must be a type alias, not an interface) · S
Was still open after gap 22, which fixed how Options<T> is read rather than what BaseProps constrains. The failure is interface MyProps extends BaseProps; the intersection form already worked. Fixed by relaxing BaseProps.$options to object, whose Record<string, unknown> constraint rejected nothing anyway. $refs/$emits stay strict — their constraints reject something real.
B3 — Option definitions lost merge: true (gap 9) · won't fix
Measured usage in ui is two call sites — Accordion/AccordionItem.ts:53 and Tabs/Tabs.ts:58 — both a styles option with a nested default meant to be partially overridden. Accordion is superseded by Disclosure, which does without it. merge does not come to v4; ui's remaining consumer (Tabs) is a ui-2.0 concern.
B4 — Codemod for data-ref="x[]" (gap 11) · no codemod needed — v3's spelling restored
The 36 occurrences were never breakages: v4 was wrong, not the markup. A config.refs: ['dots[]'] definition selects [data-ref="dots[]"] again, as in v3 — the suffix is carried by the declaration and the attribute. One spelling only; the unsuffixed attribute is a different ref. A dev warning covers the mistake that is actually possible: a name[] definition finding nothing while data-ref="name" sits in the markup. Amended by C8: the derived forms are suffix-free ($refs.dots, onDotsClick()) but the decorator names the declaration — @on('dots[]', 'click').
B5 — perTarget() keyed by the target alone and dropped its remaining arguments (gap 26) · S · was a live bug on main
Now WeakMap<Target, Map<string, Service>>, keyed by JSON.stringify() over the remaining arguments, with a keyOf escape for arguments that do not serialise. Fixes useDrag(el, options) with no call-site change.
B6 — mountStrategy did not merge along the prototype chain (gap 24) · S resolveStrategy() calls resolveConfig(), now exported from Base.ts. Every subclass of a strategy-declaring component no longer falls back to eager.
B7 — Deep object/array option defaults are still shared · not a bug — the contract
Both the deep and the shallow copy are gone. A primitive can be a default; any other type needs a factory function. A literal object or array now warns once per declaration, naming the component, the option and the fix. The type-level ban was verified real — TypedOptionDefinition types an Object/Array default as () => OptionValue<T> only — and the warning reaches the no-build-step audience it cannot. Nothing in src/, migration/ or demo/ was relying on the copy: zero declarations had to be converted.
C — Cheap capability wins
C1 — useDrag({ axis }) (gap 12) · S · PR [Feature] Add v4 drag axis and inertia controls #797 axis: 'x' | 'y' | 'both' controls the filtered movement props and the owned touch-action; both is the old default. Consumer CSS still wins, and the prior inline value returns after final teardown.
C2 — useDrag({ inertia: false }) (gap 13) · S · PR [Feature] Add v4 drag axis and inertia controls #797
Keeps the exact projected finalX/finalY on drop, completes through stop/idle, and starts no scheduler tick.
C10 — memo() over the hand-rolled caches in Base.ts · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853 — and only one of the two was a cache resolveConfig() goes through memo(). optionReaders does not, because it is not a cache: nothing computes it — buildOptions() writes it as a side effect while returning something else — so it became a private field, which is where an instance keeps its other state. Measured rather than assumed: memo() is ~15 ns/hit slower than the hand-rolled WeakMap, so this is consistency and five fewer lines, not speed. perTarget() is not a candidate — see “Deferred”. refPropertyName() was considered by feat(v4): C9 — restore v3's namespaced data-ref form #789 and rejected on measurement: one endsWith and one slice, once per ref per mount.
C4 — useScrollProgress(el, { offset }) · dropped, see “Deferred”
C5 — The keyframes interpolator, split from the player · dropped, see “Deferred”
C6 — A parameterised mount strategy (gap 23) · M · PR feat(v4): parameterize viewport mount margins #799
Answered by the parameterized name, which is what media:<query> already proved: visible:<rootMargin> and in-view:<rootMargin> pass the exact suffix as IntersectionObserverInit.rootMargin, through the one applyMountStrategy() path, so config, data-mount and lazy manifest entries all get it. Bare visible:/in-view: are treated as bare rather than passing an empty margin. The scope is deliberately narrower than this entry asked for: margin only. No threshold, no root element, no JSON options, no idle timeout, no second attribute. That is the case ui documents; anything past it waits for a consumer. Extended by feat(v4): add the interaction:page mount strategy #848, on the same principle and from a review question: interaction was element-scoped and the docs did not say so, while the page-wide form — mount the deferred widget once the visit proves it has a user — had no spelling at all. interaction:page is that spelling, with one shared, captured listener set for the whole page rather than one per waiting element.
C7 — An intersection service (gap 25) · S withMountWhenInView is answered by data-mount="in-view" (60 lines → 0); withIntersectionObserver is answered by nothing. The port wrote useInView(el, init) in 20 lines.
C8 — @on takes a target value, not only a string (was G1) · S · PR feat(v4): let @on take its target as a value (C8) #787 @on(window, 'load'), @on(document, 'click') and @on(Child, 'click'). No reserved string, so @on('Window', 'resize') still means a child named Window. window/document are matched by identity, so another realm's global is refused rather than bound elsewhere. An arbitrary EventTarget is refused: a decorator runs once at class definition with no instance and no document, so it could only be a module-scope value shared by every instance — that belongs in mounted(). The string forms are not a fallback, they are the answer for a lazy child. A child declared () => import('./Child.js') exists to keep its chunk out of its parent's, so @on(Child, 'open') would import exactly what the thunk defers. @on('Child', 'open') imports nothing. A ref is named by its declaration:@on('dots[]', 'click'), while the derived forms stay suffix-free ($refs.dots, onDotsClick()). One spelling — @on('dots', …) does not reach a dots[] declaration. The mismatch cannot be a type error, since the decorator sees only a string and cannot read config.refs; it warns at bind time instead, and only when the other spelling matches a declared ref.
C9 — v3's namespaced data-ref="Component.name" form, restored · S · PR feat(v4): C9 — restore v3's namespaced data-ref form #789 belongsTo() refuses a ref with any data-component between it and the root, so a ref nested in a child component cannot belong to an outer one. The namespaced form is the escape: data-ref="App.form" binds to the enclosing App past an intervening component. ui's three usages are all that shape — a ref wrapped in a presentational component. The namespace lives in the markup only, never in config.refs — and that is not a simplification, it is forced. FigureShopify declares refs: ['img'] once, inherited from AbstractFigure, and its templates write data-ref="img" three times and data-ref="FigureShopify.img" once: one declaration, two spellings, chosen per template by nesting depth. A namespace in the declaration would force one spelling on every template and make an inherited ref undeclarable, since a base cannot know its subclass's name. So @on never sees a namespace, and C8's rule is untouched: next and Slider.next are not two spellings of one thing, they are two questions — nearest owner versus named owner — answered into one property.
Namespaced list spelling is Component.name[], the order v3's code produces. Two queries rather than a selector list, measured: 8.7 µs against 11.2 µs over a 25-element subtree, versus 8.0 µs for the plain single query — a selector list costs Chromium its single-attribute fast path. The ancestor check is token-aware (~=), so data-component="Slider Other" counts as a Slider; v3 compared the whole attribute and could not. Open follow-up: a config.refs: ['Slider.next'] declaration half-works silently — the property becomes $refs['Slider.next']. One line of dev warning would close it. Not ruled on.
D — Unmeasured subsystems
D1 — Autoload manifests · L · PR feat(v4): give the registry a lazy half with registerManifest() #782
v3's 1,033 lines become +245 in registry.ts; ~260 lines are absorbed outright by the one observer and data-mount. The loadStrategy/mountStrategy split was dropped on evidence: ui's entries are per-package policy, and data-load appears once in the whole repo, in a docs page.
D2 — Responsive options · M · PR feat(v4)!: give options a responsive form, derived on read #783⚠️ breaking
Derived on read, so read-only $options stays closed. Renames $optionChanged → $optionsChanged. Decided 2026-08-14: it stays in core, unconditionally — the plugin seam is rejected, a basic feature belongs in the core graph. And the responsive: true opt-in is gone: every option is responsive, because an option should support data-option-<name>:<breakpoint> by being an option, not by naming itself.
The cost is on the read path, not the observer. Widening attributeFilter from 44 to 300 names left mutation throughput flat within noise — Chromium does not scan it linearly. But the breakpoint cascade cost 4.70 µs per read against 0.052 µs for a plain attribute, essentially all of it eight MediaQueryList.matches reads. Memoising the active breakpoint for the length of a task brings it to 0.38 µs (12.3× faster, 7.4× off a plain attribute). The residual is the cascade walk itself — up to nine getAttribute() calls — which is the feature. See memo() under C3.
D3 — Non-bubbling child events (mouseenter / mouseleave) · M
E — Feasibility ports
E1 — Frame +Fetch · M · PR test(v4): port the six remaining ui families onto v4 #847 Frame is not ported and will not exist in ui 2.0 — a product decision: Fetch does the same job and is easier to use. Fetch and FetchShopifySection are ported (397 → 410 code lines, +3 %, 60 specs). FetchShopifyPartial is skipped with its reason recorded: it rebuilds the whole fetch lifecycle around a dynamic import of an uninstalled preview package, and every core-relevant seam it exercises is already covered by FetchShopifySection. swap() finally had consumers, and they disagreed — which is the finding this entry was opened for. LazyInclude was covered completely; Fetch could not use it at all and copied 26 lines core already had. The axis between them was only whether the element itself is replaced, and naming it is what closed K3 in review: swap(target, content, { self }), after which Fetch's four-branch update is one call and the copy is deleted.
E2 — Carousel · L · PR test(v4): port the six remaining ui families onto v4 #847 — the largest coordinator in ui
431 → 444 code lines (+3 %) over seven classes plus a context, and 206 → 157 (−24 %) for its withIndex/Indexable infrastructure. The distribution is the finding, not the total: AbstractCarouselChild −72 % and CarouselItem −67 %, both reappearing as +35 % on the coordinator that absorbed their geometry.
E5 — Cursor / Draggable · M · PR test(v4): port the six remaining ui families onto v4 #847 — Cursor 121 → 107 (−12 %), Draggable 191 → 222 (+16 %) These two closed gap 1 by measurement, in the components the gap was found in: a spec counts requestAnimationFrame calls with a Cursor at rest and gets zero. See A2.
Fifteen of roughly forty-four ui families ported, and every family this roadmap named is done. The round is flat overall — 1489 → 1486 code lines — with 157 new specs.
F — ui-level refactors surfaced but never owned
F1 — Modal surface: reference-counted scroll lock · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853 lockScroll(target?) counts its holders in the shared runtime: the first lock saves the inline value it found, the last release restores exactly that, and the release is idempotent so a surface calls it on close and on destroy. It fixed a live bug — a dialog opened from inside a drawer gave the page its scroll back under the still-open drawer — and a leak, since neither ui component released on destroy. <dialog>'s showModal() does not lock scroll, so the item was not obsolete. The focus half had already landed in feat(v4): port the next batch of utils from v3 #819.
F2 — Converge the two expression evaluators · S · PR refactor(v4): converge the two expression evaluators #860
One compileExpression(argNames, body) in migration/expression.ts, replacing migration/Action/expression.ts's and migration/Data/expression.ts's separate caches and the uncached third call site in Fetch.parseResponse(), which recompiled on every response. The cache is two Map levels — argument list, then body — instead of a joined cacheKey string, which is what fixes Data's collision: getCallback() drops its group parameter entirely, since it was never part of the executed function's arguments.
G — Decisions, no code
G1 — The naming set · decided 2026-08-14, four answers:
config.components keeps its name and its object shape, and gains v3's dynamic import form — a value may be a class or a () => import('./Child.js'). That lets a manifest declare only the parent and leave its children's loading to it. Not a rename, so the open question closes; the import form is work, tracked as I5, and it is also what answers I2.
$watchChildren stays.$children(name, callbacks) is not adopted.
Announcement event names — component:mounted / component:destroyed stand for now.
G2 — mountStrategy vocabulary · closed. The vocabulary shipped with its table and its stated visible vs in-view split (mount-strategies.ts), and withMountWhen* has no v4 existence to interact with — withMountWhenInView is answered by data-mount="in-view". Parameterising the vocabulary stays open as C6. Closes DESIGN.md open question 3.
G3 — Record the $emit cancelation decision (open question 2) · S Decided: defaultPrevented is a userland channel. Nothing framework-side reads it — DESIGN.md already states this for the lifecycle and negotiated events ("the step is announced, not proposed"), and the same rule now covers a userland $emit. All that remains is writing it into open question 2.
G4 — @on has no global form · moved to C8, and answered by taking the target as a value rather than a reserved string.
G5 — A subclass cannot narrow $emits · won't fix, see “Deferred”
G6 — with<Service> is a compile-time name · folded into J2, which is its answer.
G7 — $options writability (gap 2) · S · PR fix(v4): $options is a read-only view — an option is an input, never a store #854 — the one REPORT gap this roadmap never carried Ruled: read-only, forever. An option is an input, never a store. The surface was 9 assignments in 3 ui classes, only two of them genuine reconfiguration — and both are a toggleAttribute call, which is the idiom feat(v4): turn a boolean option off with data-option-no-<name> #849 documented. A setter cannot be made coherent: at a breakpoint it has no answer to which of nine spellings to write, and the write returns as a mutation record. $options is typed Readonly<Options<T>>, and $el, $id, $options and $refs are now non-writable properties of the instance — readonly for a reader with a build step, a property descriptor for everyone else. The lint half is K10's no-options-assignment.
G8 — Write gap 28's sentence into the docs · S · 682d182d, direct to main — and the sentence it asked for was wrong
The ask was to write down "with<Service> is a compile-time name". Writing it showed the phrase is wrong twice: withRaf(Base) is an ordinary runtime call which returns a class, and the audience this framework puts first has no compile step for the phrase to describe. What is fixed is that one method is one subscription, under the name the service owns, decided by the class rather than by the markup — so a component whose subscriptions are one per markup declaration has no method to name and no fixed count, and subscribes by hand. DESIGN.md §8 states it with the escape as the intended path; REPORT.md had repeated the wrong phrase four times and all four are corrected.
I1 — Manifest generation from a bundler glob · ~40 lines — ui has 98 components and generates manifests today; the obvious next layer
I2 — A lazy component drags its config.components family into one chunk · answered by I5 — a child declared as () => import(…) is its own chunk, so the family splits where the author says it splits
I5 — config.components accepts a dynamic import (from G1) · M · PR feat(v4): accept a dynamic import in config.components (I5) #786
A value may be a class or a () => import('./Child.js'). The thunk is never called at registration: it becomes a lazy entry under its key in the manifest half of the registry, and scheduleFor()/scheduleLoad()/importComponent() handle it unchanged. The map key supplies the name, which is what makes the object shape load-bearing — a thunk cannot name itself until it resolves. A class is told from a thunk by the prototype chain, the same test resolveComponentClass() already uses; a class that does not extend Base is caught at registration through its non-writable prototype descriptor rather than throwing on an element much later.
No mountStrategy field on an entry — that would put back the knob feat(v4): give the registry a lazy half with registerManifest() #782 dropped. Before the class loads the chain is data-mount > eager; after it registers, B6's merged config means a lazy child that subclasses inherits the strategy its base declared.
I6 — registerComponent() walked the ownconfig.components, not the merged one · S · PR fix(v4): register the merged config.components (I6) #788 — B6's shape, for a different field registerFamily() now reads resolveConfig(…).components, so a subclass registers the family its base declared. The behaviour change reaches class children too, deliberately: $config already merges, so the family a subclass inherits is the family its instances have. Recursion needed no new guard — registerComponent() maps the name before walking the family, so a cycle closes on its second visit and returns silently, which is also why a base and its subclass registering one family raise no spurious warning. resolveConfig() merges the maps per key rather than taking the nearest, confirmed by test.
I3 — Two bundled copies are two registries · ~25 lines — a Symbol.for guard, and a v4-wide question (scheduler and services have it too), not an autoload one
I4 — No error-reporting convention · 8 lines once v4 has one at all
Evidence this is one shape, not four similar ones:
Action.mounted() and AbstractTrack.mounted() are the same fifteen lines — scan $el.attributes, #bind(name, #parseAttribute(…)), $watchAttributes filtered on the prefix, release the Map on teardown — down to the same justifying comments. Written independently by two different ports of two different families.
ui carries the duplication today, not as a port artefact: Action/ActionEvent.ts:63 and Track/TrackEvent.ts:39 each implement split('.') over the same modifier vocabulary. Action's is prevent | stop | once | passive | capture | debounce; Track's is that set plus throttle — a superset, not a variant.
Data is the same family one generation behind: it memoises its bindings, so a data-bind:* rewritten in place never rebinds — the exact bug $watchAttributes was built to fix, which Action and Track both now consume.
Why four parsers is not carelessness: both mechanisms only became available this round. Filter registration existed for options; $watchAttributes landed two rounds ago. Nobody could have written a unified parser before now.
J1 — Settle the grammar · closed by feat(v4): settle the attribute grammar, and unify its four parsers #859 Ruled: the colon has one meaning — pick one member of the vocabulary the namespace declares — and the two readings this issue found are two kinds of namespace, not two meanings of the separator. A namespace is fixed (written in a module: data-component, data-on, data-bind) or generated (one per declared option, so columns owns data-option-columns); the colon after either picks one member. What falls out is a checkable invariant — at most one colon per attribute — pinned in attributes.spec.ts. Aligning options onto data-option:columns and dropping the prefix for data-columns were both weighed and refused; see DESIGN.md §3 and RATIONALE.md for why. RESPONSIVE_SEPARATOR is renamed QUALIFIER_SEPARATOR.
J2 — An attribute-namespace primitive in core · closed by feat(v4): settle the attribute grammar, and unify its four parsers #859 watchAttributeNamespace(el, namespace, bind, options?) in src/attribute-namespaces.ts. Absorbs the identical block from Action and AbstractTrack, gives DataBind the live rebinding it lacked, and an optional finite qualifier vocabulary now warns once (attribute.unknown-qualifier) instead of an attribute silently doing nothing. One correction to this ask's own framing: the mechanism follows from whether the whole name is enumerable, not whether the qualifier is finite — data-bind's six binding types are finite while the name after the dot is not, so it is watched despite the finite head; a declared option's names are attribute × breakpoint, which is enumerable, so responsive options keep their own registration rather than being routed through a shared selector with one caller. Absorbs G6, as scoped.
J3 — One modifier parser in ui · closed by feat(v4): settle the attribute grammar, and unify its four parsers #859 migration/event-modifiers.ts: one frozen MODIFIERS object and one parseEventDefinition(), consumed by ActionEvent and TrackEvent. The per-family default delay (Action 100 ms, Track 300 ms) is what actually differed, so the parser reports only the delay an author wrote and each family keeps its own fallback; an unknown modifier now warns instead of binding silently. The Modifier/TrackModifier barrel collision is gone.
The split: core owns when to re-parse and how the attribute is observed; ui owns what the string means.
Sizing: the affected ui files are DataBind 522, TrackEvent 283, AbstractTrack 240, ActionEvent 235, Action 90 — but the overlapping shape is only ~40–60 lines each. This is a consolidation of roughly 150 duplicated lines, not a rewrite.
Sequencing:#783 added the fourth parser to core, and deciding J1 before it merged would have been cheaper than unifying later across a shipped API. That moment passed, and the surface has grown since — #842 gave core's own attribute names one owner (src/attributes.ts, a leaf module that imports nothing from core), and #844 added multiple option types. Read attributes.ts as prior art, not as J2: it owns which names core spells, not how a namespace is parsed and observed, and it is the fifth parser's foundation rather than the primitive that removes it. #859 is where this got settled: J1's ruling, J2's watchAttributeNamespace(), and J3's event-modifiers.ts landed together, closing the fifth-parser risk this paragraph describes.
K — From the six-family port round and its review (#847, #848), gaps 34–43
The last feasibility round, and the first whose findings are mostly about what a component author gets wrong silently rather than about a missing primitive. Most of them fail with no warning, no type error and no exception.
Six came from the port (34–39) and four more from reviewing it (40–43). All ten are closed — six by growing the axis a written consumer asked for, two by ruling that the limit is the contract, and two by checking what a type cannot. Nothing here is open.
K1 — data-option-no-<name> (gap 34) · S · PR feat(v4): turn a boolean option off with data-option-no-<name> #849 — implemented, not refused
The investigation recommended refusing the prefix and warning. It was implemented instead, and the implementation is small because the negation resolves to a raw value the boolean rule already reads: one cascade, one parsing path, and a scoped form (data-option-no-x:s) for free. Only an option which can hold false gets one. And booleans became presence-only with it — data-option-open="false" reads true, as disabled does on the platform — which caught three port fixtures stringifying a boolean into an attribute. The noSort collision turned out not to exist: that option's own negation is data-option-no-no-sort.
K2 — $terminate() does not survive a DOM move (gap 35) · M · PRs fix(v4): the two lifecycle rulings — service mixins and $terminate() #850, refactor(v4)!: remove the termination lifecycle notion #852 Ruled, then removed. v4 already preserves instance identity across a move, so "do this once per element" is a field: LazyInclude keeps one and the red spec went green with no framework change. Then the question does $terminate() still make sense? found that its only production caller anywhere was the registry, and all five ui usages were the same misuse — so the termination notion is gone (refactor(v4)!: remove the termination lifecycle notion #852): no $terminate(), no terminated(), no $isTerminated. Removing it uncovered a live leak it had been hiding: $watchChildren added one document listener per watcher, released only on termination, so 20 watchers meant 20 never-released listeners and every removed-and-forgotten watcher kept its instance alive for the life of the page. One shared listener over weak references replaces it.
K3 — swap() can only replace a target's children (gap 36) · M · closed in test(v4): port the six remaining ui families onto v4 #847's review SWAP_MODES were Fetch's four modes exactly and adoptScripts() was ui's helper in substance, yet Fetch could use neither, because replace was replaceChildren() and morph passed childrenOnly: true. self is the axis, and it is an option rather than a fifth mode:mode says how the content is applied, self says what is replaced. With it an Element content is the replacement — reading it as a container is what dropped the attributes the option exists to carry — and the additive modes warn (swap.self-ignored) instead of ignoring the ask quietly. Script adoption follows whatever ends up in the document, so it covers a replacement that is itself a <script>. Fetch.updateDOM() is one call and Fetch/utils.ts is deleted.
K4 — scrollTo() has no alignment (gap 37) · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853 align: 'start' | 'center' | 'end', or one per axis, plus scrollPosition() — the measuring half, because a carousel asks which slide is nearest three times for every time it travels. The names are physical (x/y) rather than the platform's inline/block, since nothing here maps a writing mode. The dependency was measured and refused:compute-scroll-into-view walks every scrolling ancestor, which is what v4's single rootElement contract declines and what ui already cancels with boundary, and its own source implements neither writing modes nor scroll-padding — a real delta of ~20 lines.
K5 — A service mixin's mounted() was silently skipped (gap 38) · S · PR fix(v4): the two lifecycle rulings — service mixins and $terminate() #850
The ask was a diagnostic; the trap was removed instead. A mixin binds from $mount()/$destroy() now — the framework's own methods, where $terminate() already lived — so mounted(), destroyed() and terminated() belong to the component author and there is nothing to chain. The whole suite passed before a spec was touched, and the eleven ui files which mix a service in without chaining will work unchanged. A userland mixin which puts its work in mounted() still needs the chain, which is the rule as DESIGN.md §8 now states it.
K6 — A mixin's target resolver is typed against Base (gap 39) · S · PR fix(v4): report a mixin target resolver which comes back with nothing #858 — the typing stands, the silence does not
A mixin is applied while its class's extends clause is still being evaluated, so the resolver cannot be typed against the class being defined; the call site asserts a shape, as v3 does with @ts-expect-error. That limit is documented rather than worked around. What is fixed is what the assertion hid: a stale one resolves to undefined, and every service with a default target takes over — useResize() defaults to the document element, so a renamed ref left the component observing the page and looking like it worked, measured rather than assumed. A caller's resolver returning nothing now reports service.missing-target and starts no subscription; withRaf, whose own target is nothing by design, is untouched. A ref-name form was built and rejected — it removed the cast but added a second spelling of one option, and left the same silence for every hand-written resolver.
K7 — @on(type) typed its handler as the base Event (gap 40) · S · closed in test(v4): port the six remaining ui families onto v4 #847's review
The magic-name form always allowed onClick(event: MouseEvent); the decorator's one-argument overload took (event: Event) => void, which contravariance rejects a narrower parameter for — so the sugar was the stricter of two spellings of one binding. Found the moment the ported families were converted to decorators, in three handlers across two families. The overload now maps a name in HTMLElementEventMap to its platform type, and a name outside it — a component event, whose detail only its emitter knows — infers the type the handler declares. Types only.
K8 — A @read/@write method cannot be overridden by a subclass (gap 41) · S · PR docs(v4): rule that @read and @write are leaf-method sugar #857 — ruled, not built
A phase decorator returns a wrapper, and a wrapper is a property of that class: a subclass override replaces it, so the base's scheduling disappears and the body runs in the caller's phase. The ruling is that the phase belongs to the call site — @read/@write are leaf-method sugar, and a template method schedules where the call is made, which is what AbstractCarouselChild already writes. The alternative was refused: dispatching through an indirection a subclass cannot replace would make a decorator's behaviour depend on inheritance depth, which nothing else in v4 does. It cannot be a lint rule either — base and subclass live in different files, and the plugin from feat(eslint-plugin): add the v4 lint rules and a v4 config #856 sees one file at a time. One spec pins it.
K9 — smoothTo() has no consumer in fifteen ports (gap 42) · M · closed in test(v4): port the six remaining ui families onto v4 #847's review, except one axis
Four ported components hand-rolled the loop the helper exists to own, for two reasons. damping was captured at creation while every consumer's factor is a live option — and Cursor's scale factor also depends on the direction of travel; both are answered by damping taking a function, read per frame and per channel. The helper was scalar, so a position was two instances, two callbacks and two settle states; answered by a record of named channels — not { x, y }, since a component smooths a scale or a progress as readily as a coordinate — on one subscription, one settled state and one subscriber call. jump() and a per-mode precision default fell out of converting the consumer. Cursor consumes all of it (108 → 100 lines, its mixin and hook gone). Still open, and deliberately unbuilt:Draggable steps its damping from a drag event with a nominal frame, and the helper owns its clock. One hypothetical consumer is not evidence for a hand-stepped variant.
K10 — A frame subscriber runs in the read phase (gap 43) · M · PR feat(eslint-plugin): add the v4 lint rules and a v4 config #856
Six rules ship in @studiometa/eslint-plugin-js-toolkit, with a configs.v4 and a packages/v4/** override — without which none of them run anywhere. oxlint already loads that ESLint plugin as a JS plugin, so there was no second implementation to write; the constraint is that a JS plugin gets no type information, so every rule is type-free. no-write-in-read-phase is proven rather than asserted: it reports both historical defects on their pre-fix files and is silent on the fixed ones. It also found a real defect on its way in — four ScrollAnimation sites used the global scheduler because a comment claimed $destroy() cancels tasks after the cleanups, when it cancels them before. The others: no-options-assignment, prefer-instance-scheduler, option-default-factory, no-conflicting-negated-option, and a v4 mode on the deprecated-properties rule.
L — What reviewing the closures produced (not on this roadmap when it started)
Every entry here exists because a question was asked about work that was already "done". None of it was planned, and three of the four are defects the test suite could not see.
The termination lifecycle is gone · PR refactor(v4)!: remove the termination lifecycle notion #852 — $terminate(), terminated(), $isTerminated and the terminate-callback list. Asking does it still make sense? found that the registry was its only production caller and all five ui usages meant "my work is done", which K2 had just ruled it does not mean.
$watchChildren leaked a document listener per watcher · PR refactor(v4)!: remove the termination lifecycle notion #852 — measured at 20 watchers → 20 listeners added, 0 removed. Element removal calls $destroy(), and only $terminate() released the listener, so every removed-and-forgotten watcher kept its instance alive for the life of the page. One shared listener over weak references replaces it, with the owner holding its watchers strongly — otherwise the watcher is collected while its owner still lives.
A boolean option reads presence, not value · PR feat(v4): turn a boolean option off with data-option-no-<name> #849 — data-option-open="false" is true, as disabled is on the platform. It caught three port fixtures stringifying a boolean into an attribute, all of them quietly right under the old rule.
$el, $id, $options and $refs are fixed properties · PR fix(v4): $options is a read-only view — an option is an input, never a store #854 — non-writable, so an assignment throws rather than replacing what the whole framework reads. A get-only accessor was tried and measured instead: ~20 % slower on the five-thousand-component mount benchmark, since $el is read on every handler bind and ref query, so the data property stays. The experiment left one improvement behind — DataBind is generic in its props, so a subclass narrows through the type parameter rather than redeclaring a member.
LazyInclude remembers a load only when it succeeded · PR fix(v4): retry a failed LazyInclude on the next mount #851 — always fires from the request's finally, so a failed fetch had been marking the element as loaded. v3 has the same defect for the same reason; this is the port's one deliberate departure.
interaction:page · PR feat(v4): add the interaction:page mount strategy #848 — the page-wide scope of the interaction mount strategy, from asking what interaction was bound to. One shared, captured listener set for the whole page rather than one per waiting element.
Landed outside this roadmap
Recorded so this issue is not read as the whole picture. Between #790 and #846, core gained: the storage layer with six adapters (#818, #841), createGroup() (#826), the mutation service (#825), the key service (#846), responsive component declarations (#794), the unified diagnostic protocol (#800, #815), the cross-copy shared runtime (#801), the standalone helper split — watchAttributes(), context subscriptions, UI helpers (#811, #812, #813), manifest generation (#795), the in-view and scroll-progress services (#792, #793), element-relative pointer coordinates (#827), resource loading helpers (#828), $id (#796), instances keyed by symbol (#831), mount benchmarks against v3 (#829, #830), type-aware linting (#838), and ten correctness fixes (#802–#807, #832, #835, #839, #840). DESIGN.md was split into a spec and a RATIONALE.md.
Deferred / rejected
$watchChildren subclass predicate (was A5, gap 18) — no longer deferred: built in [Feature] Watch v4 child component subclasses #798, as the class form rather than the predicate. See A5. The reasoning kept here for the record: config.name is the identifier, and the family that raised it (Data*) did not end up using $watchChildren for membership at all; the closed-set need would have been string[], and the open-set need is what instanceof turned out to answer.
data-load shim — refused during the autoload work; the two-strategy split it belongs to was dropped on evidence.
A subclass narrowing $emits (was G5, gap 27). Won't fix, and it should not be fixed: narrowing is unsound. A Slider-typed reference may emit slide, so a subclass promising less breaks the contract its own base type advertises. The need usually behind the report is widening, which a class generic in its props already covers — class Slider<P extends SliderProps = SliderProps> extends Base<P> — at the price of a type parameter on every component meant to be extended.
Folding perTarget() onto memo(), and multi-argument memo().perTarget() stays hand-rolled. Its second level keys by value — JSON.stringify() over the arguments — because the arguments are fresh object literals: useInView(el, { threshold: 0.5 }) allocates a new object on every call, so identity keying would miss every time and hand each caller its own service, which is worse than gap 26 was. Making memo() variadic does not help: a trie keys each position by identity, which is exactly the wrong equality here. Only a keyOf/cacheKey option would fit, and that is the axis memo()'s design rejected on evidence. Revisit if a caller appears that keys on two genuine identities — an (element, class) pair, say.
useScrollProgress(el, { offset }) and the keyframes interpolator (were C4 and C5). The next version of @studiometa/ui ships a @studiometa/ui-motion package that owns scroll-linked animation, so both lose their consumer. C4 already rested on ScrollAnimation alone — the InView/Track port gave it no support, Track being an analytics component rather than a scroll-driven one — and that one consumer is exactly what moves out of core's reach. Revisit only if a family outside motion asks for either.
Tracking issue for everything still outstanding on the v4 prototype.
Where v4 stands (reconciled 2026-08-18 against
mainat #856). Three rounds merged (#777, #778, #779), then the round this issue was opened for (#781 → #789), and then 54 more PRs, #790 → #846, which this body had fallen behind. Fifteen ui families are ported as feasibility tests —Accordion,Dialog,ScrollAnimation,Slider,Data*,Action,ClickOutside,InView,Track, and from #847Fetch,LazyInclude,Prefetch,Cursor,Draggable,Carousel— and the gap list they produce, now 1–43 inmigration/REPORT.md, is what drove this roadmap. That list is now closed apart from three entries, and the work since has come from reviewing the closures rather than from porting anything new.Every section is closed. F2 landed in #860, which closes this issue — the v4 prototype's outstanding work is done.
Section E is closed by #847, the last feasibility round: every family this roadmap named is ported, and the ten gaps it filed are section K. Sections A, B, C, D, I and J are closed. What is left is F (two ui-level refactors), K (the port round's open gaps) and three decisions in G.
#847's review is where four of those gaps came from, and it closed three of them.
swap()grew the axis its consumers asked for,@onlearned to type its own handler,smoothTo()became usable by the components it was written for, and two families stopped writing to the DOM in the scheduler's read phase. #848 followed from the same review:interaction:page.Then #849 → #856 closed the investigation round, and three of those PRs exist because a question found something the roadmap had not: removing
$terminate()uncovered a live listener leak, making booleans presence-only turned three port fixtures from quietly-right into plainly-wrong, and the first lint rule found a scheduler-lane defect inScrollAnimationthat no test could fail on.Section B is closed: #785 landed every correctness item, two of them by deciding the reported behaviour was the contract rather than a bug. D2, I5, I6, C8 and C9 landed with it.
Sizes: S ≈ one focused change, M ≈ needs design judgement, L ≈ a round of its own.
A — Core blockers
onWindow<Event>/onDocument<Event>(gap 15) · S · PR feat(v4): resolve onWindow and onDocument handlers #781ClickOutsidehad no v4 form at all. Proved by porting it: four lines of body, and$emit()makes the announcement bubble where v3 hand-built aCustomEvent.DESIGN.md §7 promises no permanent rAF loop; that promise is false today on any page with a slider or scroll animation. Two of four early ports dropped
withRaffor a hand-rolled start/stop.Update: the
InView/Trackport did not independently confirm this — that family hand-rolls for a different reason and never wants to suspend. The case rests on the earlier two families alone, so it is weaker than first thought.· done$optionswritability + factory defaultsbuildDefault()treats a callable default as a factory. Superseded by B7: the literal copy this entry described is gone — core no longer repairs a literal object or array default, it warns. The contract is a primitive can be a default; any other type needs a factory function, enforced by the types for the TypeScript audience and by the warning for the no-build-step one.$idonBase· S · PR [Feature] Add stable v4 component IDs #796 —moved to C3, and the move was reversedThis entry said a property on every instance costs every component, while
uid('Accordion')costs only its callers, souid()should join theutilslist instead. [Feature] Add stable v4 component IDs #796 decided the other way and shipped$id: a readonly<ComponentName>-<sequence>, resolved from the mergedconfig.name, available to derived field initializers and stable across destroy and remount cycles. The port is the evidence —migration/utils/uid.tsis deleted andAccordionItem,SliderItemand nowCarouselItemread$id. There is nouid()insrc/utils/.$watchChildrensubclass matching (gap 18) · M · PR [Feature] Watch v4 child component subclasses #798 —downgraded, built after allIt shipped as the class form the deferral had ruled out for the predicate:
$watchChildren(ComponentClass)and@children(ComponentClass)match byinstanceof, over one matcher shared by the initial DOM sweep and the lifecycle updates. Constructor types flow through to the collections and callbacks. No global registry was added, and no predicate overload was: the open-set case the deferral described is whatinstanceofalready covers.B — Correctness and latent bugs · closed by PR #785
$destroy()cancelled scheduler tasks after the cleanups (gap 5) · SThe task set is swapped out and cancelled before the cleanups run, so a cleanup's
$write()lands in a fresh set and survives.$optionsmust be a type alias, not an interface) · SWas still open after gap 22, which fixed how
Options<T>is read rather than whatBasePropsconstrains. The failure isinterface MyProps extends BaseProps; the intersection form already worked. Fixed by relaxingBaseProps.$optionstoobject, whoseRecord<string, unknown>constraint rejected nothing anyway.$refs/$emitsstay strict — their constraints reject something real.Option definitions lost(gap 9) · won't fixmerge: trueMeasured usage in ui is two call sites —
Accordion/AccordionItem.ts:53andTabs/Tabs.ts:58— both astylesoption with a nested default meant to be partially overridden.Accordionis superseded byDisclosure, which does without it.mergedoes not come to v4; ui's remaining consumer (Tabs) is a ui-2.0 concern.Codemod for(gap 11) · no codemod needed — v3's spelling restoreddata-ref="x[]"The 36 occurrences were never breakages: v4 was wrong, not the markup. A
config.refs: ['dots[]']definition selects[data-ref="dots[]"]again, as in v3 — the suffix is carried by the declaration and the attribute. One spelling only; the unsuffixed attribute is a different ref. A dev warning covers the mistake that is actually possible: aname[]definition finding nothing whiledata-ref="name"sits in the markup. Amended by C8: the derived forms are suffix-free ($refs.dots,onDotsClick()) but the decorator names the declaration —@on('dots[]', 'click').perTarget()keyed by the target alone and dropped its remaining arguments (gap 26) · S · was a live bug onmainNow
WeakMap<Target, Map<string, Service>>, keyed byJSON.stringify()over the remaining arguments, with akeyOfescape for arguments that do not serialise. FixesuseDrag(el, options)with no call-site change.mountStrategydid not merge along the prototype chain (gap 24) · SresolveStrategy()callsresolveConfig(), now exported fromBase.ts. Every subclass of a strategy-declaring component no longer falls back toeager.Deep object/array option defaults are still shared· not a bug — the contractBoth the deep and the shallow copy are gone. A primitive can be a default; any other type needs a factory function. A literal object or array now warns once per declaration, naming the component, the option and the fix. The type-level ban was verified real —
TypedOptionDefinitiontypes anObject/Arraydefault as() => OptionValue<T>only — and the warning reaches the no-build-step audience it cannot. Nothing insrc/,migration/ordemo/was relying on the copy: zero declarations had to be converted.C — Cheap capability wins
C1 —
useDrag({ axis })(gap 12) · S · PR [Feature] Add v4 drag axis and inertia controls #797axis: 'x' | 'y' | 'both'controls the filtered movement props and the ownedtouch-action;bothis the old default. Consumer CSS still wins, and the prior inline value returns after final teardown.C2 —
useDrag({ inertia: false })(gap 13) · S · PR [Feature] Add v4 drag axis and inertia controls #797Keeps the exact projected
finalX/finalYondrop, completes throughstop/idle, and starts no scheduler tick.C3 — A
utilsport (gap 10) · M · PRs feat(v4): port the next batch of utils from v3 #819, chore(v4): empty migration/utils — core covers some, families own the rest #820, feat(v4): add deepmerge to the utils #821, refactor(v4)!: make deepmerge variadic #822, feat(v4): port getOffsetSizes, scrollTo and noop #824, feat(v4): port the resource loading helpers #828 —now including, see A4uid()~530 lines were copied across four ports; the estimate was ~200 lines in core. What shipped is larger and organised by subject:
maths,easings,timing,strings,is,dom,focus,transition,transform,history,load,random,noop,scrollTo,smoothTo,selectors,deepmerge,memo— each with a spec and its own subpath.migration/utils/is empty and deleted; what survives lives beside the single family that calls it.Landed with it:
memo()(feat(v4)!: give options a responsive form, derived on read #783), one function replacing v3's three (memo,memoize,cache) — single argument keyed by identity,WeakMapfor object keys, lifetime owned by the caller throughclear(). NomaxAge: nothing in v4 goes stale on a clock. Anddeepmerge(feat(v4): add deepmerge to the utils #821, made variadic by refactor(v4)!: make deepmerge variadic #822), which is whatTrackhad to carry as a dependency.C10 —
memo()over the hand-rolled caches inBase.ts· S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853 — and only one of the two was a cacheresolveConfig()goes throughmemo().optionReadersdoes not, because it is not a cache: nothing computes it —buildOptions()writes it as a side effect while returning something else — so it became a private field, which is where an instance keeps its other state. Measured rather than assumed:memo()is ~15 ns/hit slower than the hand-rolledWeakMap, so this is consistency and five fewer lines, not speed.perTarget()is not a candidate — see “Deferred”.refPropertyName()was considered by feat(v4): C9 — restore v3's namespaced data-ref form #789 and rejected on measurement: oneendsWithand oneslice, once per ref per mount.C4 —
· dropped, see “Deferred”useScrollProgress(el, { offset })C5 —
The keyframes interpolator, split from the player· dropped, see “Deferred”C6 — A parameterised mount strategy (gap 23) · M · PR feat(v4): parameterize viewport mount margins #799
Answered by the parameterized name, which is what
media:<query>already proved:visible:<rootMargin>andin-view:<rootMargin>pass the exact suffix asIntersectionObserverInit.rootMargin, through the oneapplyMountStrategy()path, so config,data-mountand lazy manifest entries all get it. Barevisible:/in-view:are treated as bare rather than passing an empty margin.The scope is deliberately narrower than this entry asked for: margin only. No threshold, no
rootelement, no JSON options, no idle timeout, no second attribute. That is the case ui documents; anything past it waits for a consumer.Extended by feat(v4): add the interaction:page mount strategy #848, on the same principle and from a review question:
interactionwas element-scoped and the docs did not say so, while the page-wide form — mount the deferred widget once the visit proves it has a user — had no spelling at all.interaction:pageis that spelling, with one shared, captured listener set for the whole page rather than one per waiting element.C7 — An intersection service (gap 25) · S
withMountWhenInViewis answered bydata-mount="in-view"(60 lines → 0);withIntersectionObserveris answered by nothing. The port wroteuseInView(el, init)in 20 lines.C8 —
@ontakes a target value, not only a string (was G1) · S · PR feat(v4): let @on take its target as a value (C8) #787@on(window, 'load'),@on(document, 'click')and@on(Child, 'click'). No reserved string, so@on('Window', 'resize')still means a child namedWindow.window/documentare matched by identity, so another realm's global is refused rather than bound elsewhere. An arbitraryEventTargetis refused: a decorator runs once at class definition with no instance and no document, so it could only be a module-scope value shared by every instance — that belongs inmounted().The string forms are not a fallback, they are the answer for a lazy child. A child declared
() => import('./Child.js')exists to keep its chunk out of its parent's, so@on(Child, 'open')would import exactly what the thunk defers.@on('Child', 'open')imports nothing.A ref is named by its declaration:
@on('dots[]', 'click'), while the derived forms stay suffix-free ($refs.dots,onDotsClick()). One spelling —@on('dots', …)does not reach adots[]declaration. The mismatch cannot be a type error, since the decorator sees only astringand cannot readconfig.refs; it warns at bind time instead, and only when the other spelling matches a declared ref.C9 — v3's namespaced
data-ref="Component.name"form, restored · S · PR feat(v4): C9 — restore v3's namespaced data-ref form #789belongsTo()refuses a ref with anydata-componentbetween it and the root, so a ref nested in a child component cannot belong to an outer one. The namespaced form is the escape:data-ref="App.form"binds to the enclosingApppast an intervening component. ui's three usages are all that shape — a ref wrapped in a presentational component.The namespace lives in the markup only, never in
config.refs— and that is not a simplification, it is forced.FigureShopifydeclaresrefs: ['img']once, inherited fromAbstractFigure, and its templates writedata-ref="img"three times anddata-ref="FigureShopify.img"once: one declaration, two spellings, chosen per template by nesting depth. A namespace in the declaration would force one spelling on every template and make an inherited ref undeclarable, since a base cannot know its subclass's name. So@onnever sees a namespace, and C8's rule is untouched:nextandSlider.nextare not two spellings of one thing, they are two questions — nearest owner versus named owner — answered into one property.Namespaced list spelling is
Component.name[], the order v3's code produces. Two queries rather than a selector list, measured: 8.7 µs against 11.2 µs over a 25-element subtree, versus 8.0 µs for the plain single query — a selector list costs Chromium its single-attribute fast path. The ancestor check is token-aware (~=), sodata-component="Slider Other"counts as aSlider; v3 compared the whole attribute and could not.Open follow-up: a
config.refs: ['Slider.next']declaration half-works silently — the property becomes$refs['Slider.next']. One line of dev warning would close it. Not ruled on.D — Unmeasured subsystems
v3's 1,033 lines become +245 in
registry.ts; ~260 lines are absorbed outright by the one observer anddata-mount. TheloadStrategy/mountStrategysplit was dropped on evidence: ui's entries are per-package policy, anddata-loadappears once in the whole repo, in a docs page.Derived on read, so read-only
$optionsstays closed. Renames$optionChanged→$optionsChanged.Decided 2026-08-14: it stays in core, unconditionally — the plugin seam is rejected, a basic feature belongs in the core graph. And the
responsive: trueopt-in is gone: every option is responsive, because an option should supportdata-option-<name>:<breakpoint>by being an option, not by naming itself.The cost is on the read path, not the observer. Widening
attributeFilterfrom 44 to 300 names left mutation throughput flat within noise — Chromium does not scan it linearly. But the breakpoint cascade cost 4.70 µs per read against 0.052 µs for a plain attribute, essentially all of it eightMediaQueryList.matchesreads. Memoising the active breakpoint for the length of a task brings it to 0.38 µs (12.3× faster, 7.4× off a plain attribute). The residual is the cascade walk itself — up to ninegetAttribute()calls — which is the feature. Seememo()under C3.mouseenter/mouseleave) · ME — Feasibility ports
Frame+Fetch· M · PR test(v4): port the six remaining ui families onto v4 #847Frameis not ported and will not exist in ui 2.0 — a product decision:Fetchdoes the same job and is easier to use.FetchandFetchShopifySectionare ported (397 → 410 code lines, +3 %, 60 specs).FetchShopifyPartialis skipped with its reason recorded: it rebuilds the whole fetch lifecycle around a dynamic import of an uninstalled preview package, and every core-relevant seam it exercises is already covered byFetchShopifySection.swap()finally had consumers, and they disagreed — which is the finding this entry was opened for.LazyIncludewas covered completely;Fetchcould not use it at all and copied 26 lines core already had. The axis between them was only whether the element itself is replaced, and naming it is what closed K3 in review:swap(target, content, { self }), after whichFetch's four-branch update is one call and the copy is deleted.Carousel· L · PR test(v4): port the six remaining ui families onto v4 #847 — the largest coordinator in ui431 → 444 code lines (+3 %) over seven classes plus a context, and 206 → 157 (−24 %) for its
withIndex/Indexableinfrastructure. The distribution is the finding, not the total:AbstractCarouselChild−72 % andCarouselItem−67 %, both reappearing as +35 % on the coordinator that absorbed their geometry.InView+Track· M · PR test(v4): port the InView and Track families onto v4 #784Prefetch/LazyInclude· M · PR test(v4): port the six remaining ui families onto v4 #847 —Prefetch87 → 82 (−6 %),LazyInclude56 → 64 (+14 %)Cursor/Draggable· M · PR test(v4): port the six remaining ui families onto v4 #847 —Cursor121 → 107 (−12 %),Draggable191 → 222 (+16 %)These two closed gap 1 by measurement, in the components the gap was found in: a spec counts
requestAnimationFramecalls with aCursorat rest and gets zero. See A2.Fifteen of roughly forty-four ui families ported, and every family this roadmap named is done. The round is flat overall — 1489 → 1486 code lines — with 157 new specs.
F — ui-level refactors surfaced but never owned
lockScroll(target?)counts its holders in the shared runtime: the first lock saves the inline value it found, the last release restores exactly that, and the release is idempotent so a surface calls it on close and on destroy. It fixed a live bug — a dialog opened from inside a drawer gave the page its scroll back under the still-open drawer — and a leak, since neither ui component released on destroy.<dialog>'sshowModal()does not lock scroll, so the item was not obsolete. The focus half had already landed in feat(v4): port the next batch of utils from v3 #819.One
compileExpression(argNames, body)inmigration/expression.ts, replacingmigration/Action/expression.ts's andmigration/Data/expression.ts's separate caches and the uncached third call site inFetch.parseResponse(), which recompiled on every response. The cache is twoMaplevels — argument list, then body — instead of a joinedcacheKeystring, which is what fixesData's collision:getCallback()drops itsgroupparameter entirely, since it was never part of the executed function's arguments.G — Decisions, no code
The naming set· decided 2026-08-14, four answers:config.componentskeeps its name and its object shape, and gains v3's dynamic import form — a value may be a class or a() => import('./Child.js'). That lets a manifest declare only the parent and leave its children's loading to it. Not a rename, so the open question closes; the import form is work, tracked as I5, and it is also what answers I2.$watchChildrenstays.$children(name, callbacks)is not adopted.component:mounted/component:destroyedstand for now.config.usevsconfig.siblings— not planned. [Feature] Add support for sibling configuration #697 is not on this roadmap.· closed. The vocabulary shipped with its table and its statedmountStrategyvocabularyvisiblevsin-viewsplit (mount-strategies.ts), andwithMountWhen*has no v4 existence to interact with —withMountWhenInViewis answered bydata-mount="in-view". Parameterising the vocabulary stays open as C6. Closes DESIGN.md open question 3.$emitcancelation decision (open question 2) · SDecided:
defaultPreventedis a userland channel. Nothing framework-side reads it —DESIGN.mdalready states this for the lifecycle and negotiated events ("the step is announced, not proposed"), and the same rule now covers a userland$emit. All that remains is writing it into open question 2.· moved to C8, and answered by taking the target as a value rather than a reserved string.@onhas no global formA subclass cannot narrow· won't fix, see “Deferred”$emits· folded into J2, which is its answer.with<Service>is a compile-time name$optionswritability (gap 2) · S · PR fix(v4): $options is a read-only view — an option is an input, never a store #854 — the one REPORT gap this roadmap never carriedRuled: read-only, forever. An option is an input, never a store. The surface was 9 assignments in 3 ui classes, only two of them genuine reconfiguration — and both are a
toggleAttributecall, which is the idiom feat(v4): turn a boolean option off with data-option-no-<name> #849 documented. A setter cannot be made coherent: at a breakpoint it has no answer to which of nine spellings to write, and the write returns as a mutation record.$optionsis typedReadonly<Options<T>>, and$el,$id,$optionsand$refsare now non-writable properties of the instance —readonlyfor a reader with a build step, a property descriptor for everyone else. The lint half is K10'sno-options-assignment.682d182d, direct tomain— and the sentence it asked for was wrongThe ask was to write down "
with<Service>is a compile-time name". Writing it showed the phrase is wrong twice:withRaf(Base)is an ordinary runtime call which returns a class, and the audience this framework puts first has no compile step for the phrase to describe. What is fixed is that one method is one subscription, under the name the service owns, decided by the class rather than by the markup — so a component whose subscriptions are one per markup declaration has no method to name and no fixed count, and subscribes by hand.DESIGN.md§8 states it with the escape as the intended path;REPORT.mdhad repeated the wrong phrase four times and all four are corrected.I — Autoload follow-ups (from #782)
A lazy component drags its· answered by I5 — a child declared asconfig.componentsfamily into one chunk() => import(…)is its own chunk, so the family splits where the author says it splitsconfig.componentsaccepts a dynamic import (from G1) · M · PR feat(v4): accept a dynamic import in config.components (I5) #786A value may be a class or a
() => import('./Child.js'). The thunk is never called at registration: it becomes a lazy entry under its key in the manifest half of the registry, andscheduleFor()/scheduleLoad()/importComponent()handle it unchanged. The map key supplies the name, which is what makes the object shape load-bearing — a thunk cannot name itself until it resolves. A class is told from a thunk by the prototype chain, the same testresolveComponentClass()already uses; aclassthat does not extendBaseis caught at registration through its non-writableprototypedescriptor rather than throwing on an element much later.No
mountStrategyfield on an entry — that would put back the knob feat(v4): give the registry a lazy half with registerManifest() #782 dropped. Before the class loads the chain isdata-mount > eager; after it registers, B6's merged config means a lazy child that subclasses inherits the strategy its base declared.registerComponent()walked the ownconfig.components, not the merged one · S · PR fix(v4): register the merged config.components (I6) #788 — B6's shape, for a different fieldregisterFamily()now readsresolveConfig(…).components, so a subclass registers the family its base declared. The behaviour change reaches class children too, deliberately:$configalready merges, so the family a subclass inherits is the family its instances have. Recursion needed no new guard —registerComponent()maps the name before walking the family, so a cycle closes on its second visit and returns silently, which is also why a base and its subclass registering one family raise no spurious warning.resolveConfig()merges the maps per key rather than taking the nearest, confirmed by test.Symbol.forguard, and a v4-wide question (scheduler and services have it too), not an autoload one· probably fixed by B6, not flakyautoload.spec.tsflakes under parallel loadlets the element data-mount win over the entry defaultfailed intermittently before fix(v4): the correctness and latent-bug set from #780 (section B) #785, including 2/2 in CI on one branch whilemainwas green — a ratio too consistent for a flake. It has not recurred since fix(v4): the correctness and latent-bug set from #780 (section B) #785 landed, across CI and local runs on four branches. fix(v4): the correctness and latent-bug set from #780 (section B) #785 rewrotemountStrategyresolution along the prototype chain (B6), which is exactly what that test exercises. Reopen if it returns.J — The declarative attribute language (investigated 2026-08-14) · closed by #859
Four families implement the same shape —
data-<ns>[-<subject>]:<qualifier>[.<part>…]— with four independent parsers. One of them is core's.data-option-columns:sattributeFilterActiondata-on:click.prevent.stop$watchAttributesTrackdata-track:scroll.throttle200$watchAttributesDatadata-bind:prop.valueEvidence this is one shape, not four similar ones:
Action.mounted()andAbstractTrack.mounted()are the same fifteen lines — scan$el.attributes,#bind(name, #parseAttribute(…)),$watchAttributesfiltered on the prefix, release theMapon teardown — down to the same justifying comments. Written independently by two different ports of two different families.Action/ActionEvent.ts:63andTrack/TrackEvent.ts:39each implementsplit('.')over the same modifier vocabulary. Action's isprevent | stop | once | passive | capture | debounce; Track's is that set plusthrottle— a superset, not a variant.Datais the same family one generation behind: it memoises its bindings, so adata-bind:*rewritten in place never rebinds — the exact bug$watchAttributeswas built to fix, whichActionandTrackboth now consume.Why four parsers is not carelessness: both mechanisms only became available this round. Filter registration existed for options;
$watchAttributeslanded two rounds ago. Nobody could have written a unified parser before now.Ruled: the colon has one meaning — pick one member of the vocabulary the namespace declares — and the two readings this issue found are two kinds of namespace, not two meanings of the separator. A namespace is fixed (written in a module:
data-component,data-on,data-bind) or generated (one per declared option, socolumnsownsdata-option-columns); the colon after either picks one member. What falls out is a checkable invariant — at most one colon per attribute — pinned inattributes.spec.ts. Aligning options ontodata-option:columnsand dropping the prefix fordata-columnswere both weighed and refused; see DESIGN.md §3 and RATIONALE.md for why.RESPONSIVE_SEPARATORis renamedQUALIFIER_SEPARATOR.watchAttributeNamespace(el, namespace, bind, options?)insrc/attribute-namespaces.ts. Absorbs the identical block fromActionandAbstractTrack, givesDataBindthe live rebinding it lacked, and an optional finite qualifier vocabulary now warns once (attribute.unknown-qualifier) instead of an attribute silently doing nothing. One correction to this ask's own framing: the mechanism follows from whether the whole name is enumerable, not whether the qualifier is finite —data-bind's six binding types are finite while the name after the dot is not, so it is watched despite the finite head; a declared option's names areattribute × breakpoint, which is enumerable, so responsive options keep their own registration rather than being routed through a shared selector with one caller. Absorbs G6, as scoped.migration/event-modifiers.ts: one frozenMODIFIERSobject and oneparseEventDefinition(), consumed byActionEventandTrackEvent. The per-family default delay (Action100 ms,Track300 ms) is what actually differed, so the parser reports only the delay an author wrote and each family keeps its own fallback; an unknown modifier now warns instead of binding silently. TheModifier/TrackModifierbarrel collision is gone.The split: core owns when to re-parse and how the attribute is observed; ui owns what the string means.
Sizing: the affected ui files are
DataBind522,TrackEvent283,AbstractTrack240,ActionEvent235,Action90 — but the overlapping shape is only ~40–60 lines each. This is a consolidation of roughly 150 duplicated lines, not a rewrite.Sequencing: #783 added the fourth parser to core, and deciding J1 before it merged would have been cheaper than unifying later across a shipped API. That moment passed, and the surface has grown since — #842 gave core's own attribute names one owner (
src/attributes.ts, a leaf module that imports nothing from core), and #844 added multiple option types. Readattributes.tsas prior art, not as J2: it owns which names core spells, not how a namespace is parsed and observed, and it is the fifth parser's foundation rather than the primitive that removes it. #859 is where this got settled: J1's ruling, J2'swatchAttributeNamespace(), and J3'sevent-modifiers.tslanded together, closing the fifth-parser risk this paragraph describes.K — From the six-family port round and its review (#847, #848), gaps 34–43
The last feasibility round, and the first whose findings are mostly about what a component author gets wrong silently rather than about a missing primitive. Most of them fail with no warning, no type error and no exception.
Six came from the port (34–39) and four more from reviewing it (40–43). All ten are closed — six by growing the axis a written consumer asked for, two by ruling that the limit is the contract, and two by checking what a type cannot. Nothing here is open.
data-option-no-<name>(gap 34) · S · PR feat(v4): turn a boolean option off with data-option-no-<name> #849 — implemented, not refusedThe investigation recommended refusing the prefix and warning. It was implemented instead, and the implementation is small because the negation resolves to a raw value the boolean rule already reads: one cascade, one parsing path, and a scoped form (
data-option-no-x:s) for free. Only an option which can holdfalsegets one. And booleans became presence-only with it —data-option-open="false"readstrue, asdisableddoes on the platform — which caught three port fixtures stringifying a boolean into an attribute. ThenoSortcollision turned out not to exist: that option's own negation isdata-option-no-no-sort.$terminate()does not survive a DOM move (gap 35) · M · PRs fix(v4): the two lifecycle rulings — service mixins and $terminate() #850, refactor(v4)!: remove the termination lifecycle notion #852Ruled, then removed. v4 already preserves instance identity across a move, so "do this once per element" is a field:
LazyIncludekeeps one and the red spec went green with no framework change. Then the question does$terminate()still make sense? found that its only production caller anywhere was the registry, and all five ui usages were the same misuse — so the termination notion is gone (refactor(v4)!: remove the termination lifecycle notion #852): no$terminate(), noterminated(), no$isTerminated. Removing it uncovered a live leak it had been hiding:$watchChildrenadded onedocumentlistener per watcher, released only on termination, so 20 watchers meant 20 never-released listeners and every removed-and-forgotten watcher kept its instance alive for the life of the page. One shared listener over weak references replaces it.(gap 36) · M · closed in test(v4): port the six remaining ui families onto v4 #847's reviewswap()can only replace a target's childrenSWAP_MODESwereFetch's four modes exactly andadoptScripts()was ui's helper in substance, yetFetchcould use neither, becausereplacewasreplaceChildren()andmorphpassedchildrenOnly: true.selfis the axis, and it is an option rather than a fifth mode:modesays how the content is applied,selfsays what is replaced. With it anElementcontent is the replacement — reading it as a container is what dropped the attributes the option exists to carry — and the additive modes warn (swap.self-ignored) instead of ignoring the ask quietly. Script adoption follows whatever ends up in the document, so it covers a replacement that is itself a<script>.Fetch.updateDOM()is one call andFetch/utils.tsis deleted.scrollTo()has no alignment (gap 37) · S · PR feat(v4): three core primitives — scroll alignment, a counted scroll lock, and memo over resolveConfig #853align: 'start' | 'center' | 'end', or one per axis, plusscrollPosition()— the measuring half, because a carousel asks which slide is nearest three times for every time it travels. The names are physical (x/y) rather than the platform'sinline/block, since nothing here maps a writing mode. The dependency was measured and refused:compute-scroll-into-viewwalks every scrolling ancestor, which is what v4's singlerootElementcontract declines and what ui already cancels withboundary, and its own source implements neither writing modes norscroll-padding— a real delta of ~20 lines.mounted()was silently skipped (gap 38) · S · PR fix(v4): the two lifecycle rulings — service mixins and $terminate() #850The ask was a diagnostic; the trap was removed instead. A mixin binds from
$mount()/$destroy()now — the framework's own methods, where$terminate()already lived — somounted(),destroyed()andterminated()belong to the component author and there is nothing to chain. The whole suite passed before a spec was touched, and the eleven ui files which mix a service in without chaining will work unchanged. A userland mixin which puts its work inmounted()still needs the chain, which is the rule asDESIGN.md§8 now states it.targetresolver is typed againstBase(gap 39) · S · PR fix(v4): report a mixin target resolver which comes back with nothing #858 — the typing stands, the silence does notA mixin is applied while its class's
extendsclause is still being evaluated, so the resolver cannot be typed against the class being defined; the call site asserts a shape, as v3 does with@ts-expect-error. That limit is documented rather than worked around. What is fixed is what the assertion hid: a stale one resolves toundefined, and every service with a default target takes over —useResize()defaults to the document element, so a renamed ref left the component observing the page and looking like it worked, measured rather than assumed. A caller's resolver returning nothing now reportsservice.missing-targetand starts no subscription;withRaf, whose own target is nothing by design, is untouched. A ref-name form was built and rejected — it removed the cast but added a second spelling of one option, and left the same silence for every hand-written resolver.(gap 40) · S · closed in test(v4): port the six remaining ui families onto v4 #847's review@on(type)typed its handler as the baseEventThe magic-name form always allowed
onClick(event: MouseEvent); the decorator's one-argument overload took(event: Event) => void, which contravariance rejects a narrower parameter for — so the sugar was the stricter of two spellings of one binding. Found the moment the ported families were converted to decorators, in three handlers across two families. The overload now maps a name inHTMLElementEventMapto its platform type, and a name outside it — a component event, whose detail only its emitter knows — infers the type the handler declares. Types only.@read/@writemethod cannot be overridden by a subclass (gap 41) · S · PR docs(v4): rule that @read and @write are leaf-method sugar #857 — ruled, not builtA phase decorator returns a wrapper, and a wrapper is a property of that class: a subclass override replaces it, so the base's scheduling disappears and the body runs in the caller's phase. The ruling is that the phase belongs to the call site —
@read/@writeare leaf-method sugar, and a template method schedules where the call is made, which is whatAbstractCarouselChildalready writes. The alternative was refused: dispatching through an indirection a subclass cannot replace would make a decorator's behaviour depend on inheritance depth, which nothing else in v4 does. It cannot be a lint rule either — base and subclass live in different files, and the plugin from feat(eslint-plugin): add the v4 lint rules and a v4 config #856 sees one file at a time. One spec pins it.(gap 42) · M · closed in test(v4): port the six remaining ui families onto v4 #847's review, except one axissmoothTo()has no consumer in fifteen portsFour ported components hand-rolled the loop the helper exists to own, for two reasons.
dampingwas captured at creation while every consumer's factor is a live option — andCursor's scale factor also depends on the direction of travel; both are answered bydampingtaking a function, read per frame and per channel. The helper was scalar, so a position was two instances, two callbacks and two settle states; answered by a record of named channels — not{ x, y }, since a component smooths a scale or a progress as readily as a coordinate — on one subscription, one settled state and one subscriber call.jump()and a per-modeprecisiondefault fell out of converting the consumer.Cursorconsumes all of it (108 → 100 lines, its mixin and hook gone). Still open, and deliberately unbuilt:Draggablesteps its damping from a drag event with a nominal frame, and the helper owns its clock. One hypothetical consumer is not evidence for a hand-stepped variant.Six rules ship in
@studiometa/eslint-plugin-js-toolkit, with aconfigs.v4and apackages/v4/**override — without which none of them run anywhere. oxlint already loads that ESLint plugin as a JS plugin, so there was no second implementation to write; the constraint is that a JS plugin gets no type information, so every rule is type-free.no-write-in-read-phaseis proven rather than asserted: it reports both historical defects on their pre-fix files and is silent on the fixed ones. It also found a real defect on its way in — fourScrollAnimationsites used the global scheduler because a comment claimed$destroy()cancels tasks after the cleanups, when it cancels them before. The others:no-options-assignment,prefer-instance-scheduler,option-default-factory,no-conflicting-negated-option, and a v4 mode on the deprecated-properties rule.L — What reviewing the closures produced (not on this roadmap when it started)
Every entry here exists because a question was asked about work that was already "done". None of it was planned, and three of the four are defects the test suite could not see.
$terminate(),terminated(),$isTerminatedand the terminate-callback list. Asking does it still make sense? found that the registry was its only production caller and all five ui usages meant "my work is done", which K2 had just ruled it does not mean.$watchChildrenleaked adocumentlistener per watcher · PR refactor(v4)!: remove the termination lifecycle notion #852 — measured at 20 watchers → 20 listeners added, 0 removed. Element removal calls$destroy(), and only$terminate()released the listener, so every removed-and-forgotten watcher kept its instance alive for the life of the page. One shared listener over weak references replaces it, with the owner holding its watchers strongly — otherwise the watcher is collected while its owner still lives.data-option-open="false"istrue, asdisabledis on the platform. It caught three port fixtures stringifying a boolean into an attribute, all of them quietly right under the old rule.$el,$id,$optionsand$refsare fixed properties · PR fix(v4): $options is a read-only view — an option is an input, never a store #854 — non-writable, so an assignment throws rather than replacing what the whole framework reads. A get-only accessor was tried and measured instead: ~20 % slower on the five-thousand-component mount benchmark, since$elis read on every handler bind and ref query, so the data property stays. The experiment left one improvement behind —DataBindis generic in its props, so a subclass narrows through the type parameter rather than redeclaring a member.LazyIncluderemembers a load only when it succeeded · PR fix(v4): retry a failed LazyInclude on the next mount #851 —alwaysfires from the request'sfinally, so a failed fetch had been marking the element as loaded. v3 has the same defect for the same reason; this is the port's one deliberate departure.interaction:page· PR feat(v4): add the interaction:page mount strategy #848 — the page-wide scope of the interaction mount strategy, from asking whatinteractionwas bound to. One shared, captured listener set for the whole page rather than one per waiting element.Landed outside this roadmap
Recorded so this issue is not read as the whole picture. Between #790 and #846, core gained: the storage layer with six adapters (#818, #841),
createGroup()(#826), the mutation service (#825), the key service (#846), responsive component declarations (#794), the unified diagnostic protocol (#800, #815), the cross-copy shared runtime (#801), the standalone helper split —watchAttributes(), context subscriptions, UI helpers (#811, #812, #813), manifest generation (#795), the in-view and scroll-progress services (#792, #793), element-relative pointer coordinates (#827), resource loading helpers (#828),$id(#796), instances keyed by symbol (#831), mount benchmarks against v3 (#829, #830), type-aware linting (#838), and ten correctness fixes (#802–#807, #832, #835, #839, #840).DESIGN.mdwas split into a spec and aRATIONALE.md.Deferred / rejected
(was A5, gap 18) — no longer deferred: built in [Feature] Watch v4 child component subclasses #798, as the class form rather than the predicate. See A5. The reasoning kept here for the record:$watchChildrensubclass predicateconfig.nameis the identifier, and the family that raised it (Data*) did not end up using$watchChildrenfor membership at all; the closed-set need would have beenstring[], and the open-set need is whatinstanceofturned out to answer.data-loadshim — refused during the autoload work; the two-strategy split it belongs to was dropped on evidence.$emits(was G5, gap 27). Won't fix, and it should not be fixed: narrowing is unsound. ASlider-typed reference may emitslide, so a subclass promising less breaks the contract its own base type advertises. The need usually behind the report is widening, which a class generic in its props already covers —class Slider<P extends SliderProps = SliderProps> extends Base<P>— at the price of a type parameter on every component meant to be extended.perTarget()ontomemo(), and multi-argumentmemo().perTarget()stays hand-rolled. Its second level keys by value —JSON.stringify()over the arguments — because the arguments are fresh object literals:useInView(el, { threshold: 0.5 })allocates a new object on every call, so identity keying would miss every time and hand each caller its own service, which is worse than gap 26 was. Makingmemo()variadic does not help: a trie keys each position by identity, which is exactly the wrong equality here. Only akeyOf/cacheKeyoption would fit, and that is the axismemo()'s design rejected on evidence. Revisit if a caller appears that keys on two genuine identities — an(element, class)pair, say.useScrollProgress(el, { offset })and the keyframes interpolator (were C4 and C5). The next version of@studiometa/uiships a@studiometa/ui-motionpackage that owns scroll-linked animation, so both lose their consumer. C4 already rested onScrollAnimationalone — theInView/Trackport gave it no support,Trackbeing an analytics component rather than a scroll-driven one — and that one consumer is exactly what moves out of core's reach. Revisit only if a family outside motion asks for either.Suggested order. Everything is done.
J1, then J2/J3— done, feat(v4): settle the attribute grammar, and unify its four parsers #859.F2— done, refactor(v4): converge the two expression evaluators #860.🤖 Generated with Claude Code