feat(v4): port ten simple ui families and fill the FetchShopifyPartial gap - #861
Conversation
Sentinel forwards the raw IntersectionObserverEntry via `withInView`, unlike InView's collapsed in/out boolean. Sticky needs the entry's boundingClientRect to tell "scrolled above the viewport" apart from "scrolled below it".
The static `Set<Sticky>` instance registry v3 kept in sync by hand from
mounted()/destroyed() is gone: `getInstances('Sticky')` already answers
it live. Sizing the sentinel moved from mounted() to $watchChildren's
`added` callback, since a v4 mount carries no ordering guarantee and
the sentinel may not exist yet on the first cycle.
withRelativePointer's per-target progress maps onto withPointer's
ElementPointerProps.relativeProgress{X,Y} directly, which already
carries the outside-the-box range v1's contained/clamp logic needs.
v4's scrollTo() is a no-op on a missing target rather than throwing, so the existence check that decided whether to preventDefault() moves into the component instead of a try/catch around the call.
AnchorNavTarget's mount/unmount replaces withMountWhenInView with the in-view mount strategy. AnchorNav no longer listens for the target's mounted/destroyed lifecycle events under their plain v3 names — v4 dispatches those under a namespaced type magic-name delegation can't bind to — and reacts through $watchChildren's added/removed callbacks instead, which already fire on exactly that transition. AnchorNavLink extends the newly-ported ScrollTo and calls the same enterTransition/leaveTransition utilities Transition itself calls, since v3's withTransition mixin has no v4 equivalent to mix onto an unrelated base class anymore.
$closest('Menu') replaces getClosestParent(target, this.constructor)
for both the menuBtn/menuList getters and the click/hover guards, since
$watchChildren collects every matching descendant regardless of depth
and a nested submenu's own button/list would otherwise match too.
Menu no longer destroys itself in mounted() when a required child is
missing — v4 gives no ordering guarantee for child vs. parent mount, so
the button/list wiring moves to $watchChildren's added callback, and a
Menu with no list is inert rather than a hard failure.
MenuList implements Transitionable directly instead of extending the
ported Transition class: the latter is a plain, non-generic Base
subclass, and MenuList already overrides every one of its methods to
force enterKeep/leaveKeep true (the v3 $options-getter override this
needed has no v4 equivalent, since $options is a read-only own
property with no override point).
keyed() maps onto withKey's KeyProps one for one. The nextTick-deferred
close on mouseleave uses defaultScheduler.background(), the documented
v4 replacement.
Timer's internal timing fields stay plain fields rather than #private:
TimerProgress reads them from a subclass, and a JS private field is
invisible to a subclass entirely — there is no v4 equivalent of
"protected". Positional array-detail dispatch (__dispatch(name,
...detail)) becomes a named payload object, matching $emit's contract.
withRaf(Timer, { manual: true }) replaces v3's $services.disable
('ticked') workaround for the RafService auto-enabling on any class
that declares ticked() — manual mode never auto-starts, so there is
nothing left to neutralize in mounted().
A real finding along the way: a boolean option's DOM presence is its
value regardless of the string written — data-option-x="false" reads
true — so turning off a true-default option (Toast's autostart) takes
the negated attribute name (data-option-no-autostart), not ="false".
Fixed in Toaster's sticky-toast branch and audited across every other
family this session for the same mistake.
$emit only takes one payload object, so Toast/Toaster's positional
v3 emits (__dispatch(name, ...detail), $emit('show', toast, message,
type)) become named payloads, the same adaptation Timer needed.
Tests poll for a viewTransition()-driven DOM mutation rather than
trusting settle() or a fixed wait: the scheduler's write task that
flushes the transition queue returns as soon as
document.startViewTransition(...).finished is requested, not once it
settles, and a real headless compositor can take longer than usual to
finish one.
AbstractFigure implements Transitionable directly on Base rather than
extending the ported Transition class, the same reason MenuList does:
Transition is a plain, non-generic Base subclass, and this hierarchy
(AbstractFigure -> AbstractFigureDynamic -> FigureShopify/FigureTwicpics)
needs generic prop threading through four levels.
Figure drops v3's onLoad() { $terminate() }: v4 has no termination (the
LazyInclude port hit the same gap), and none is needed here either —
AbstractFigure.mounted() only loads when src !== this.src, which is
already false once loaded, so a later remount is a no-op on its own.
AbstractFigureDynamic gains resized() through withResize(AbstractFigure),
since v4 requires a mixin for a service hook v3 auto-wired from the
method's mere presence.
FigureVideo implements Transitionable directly on Base, same as
AbstractFigure and MenuList. Unlike Figure, it has no naturally
idempotent load check (load() unconditionally reassigns every source),
so v3's onLoad() { $terminate() } becomes a load-bearing hasLoaded
flag rather than documentation of an already-idempotent path.
FigureVideoTwicpics's onLoad() override was an empty no-op cancelling
that termination so it could still reload on resize; with no
termination to cancel, there is nothing to override.
Adapts Fetch to Shopify's @shopify/partial-rendering API, falling back to the base id-based swap when no partials are configured, the request can't be expressed through the partials transport (non-GET, a body, an unsupported RequestInit field, a non-internal header), or the preview package fails to resolve. Reuses mergeRequestInit and the module-constant FETCH_EVENTS/HEADER_NAMES the Fetch port already carved out for exactly this consumer. The one static that stays a static (PARTIALS_MODULE / loadPartialsModule) is the one actually meant to be overridden, by a test or a subclass — unlike FETCH_EVENTS, which nothing in ui ever replaces.
Adds §16 for the ten families and the FetchShopifyPartial gap fill, crediting each already-established primitive they confirmed (getInstances, $watchChildren's added/removed, $closest) rather than re-deriving them, and files the one genuinely new finding — Transition ported as a non-generic class forces MenuList/Figure/FigureVideo to implement Transitionable directly instead of extending it — as gap 45. Updates the report's family table and opening test counts (99 new specs this round, 489 in migration/, 1512 across the whole packages/v4 suite) to match.
Timer.spec.ts, TimerProgress.spec.ts and AnchorNav.spec.ts were fixed in the working tree when gap 34 (data-option-x="false" reads true) was caught during the Toast/Toaster port, but the fix only made it into the Toaster commit — these three files kept the pre-fix content in history despite every test run since passing against the corrected working tree. No behavior change: data-option-no-autostart replaces the no-op data-option-autostart="false", and the misleading data-option-leave-keep="false" (also a no-op, on an assertion it never affected) is dropped from AnchorNav.spec.ts.
Export sizeBundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped. @studiometa/js-toolkit-v4
Unchanged (382)@studiometa/js-toolkit
@studiometa/js-toolkit-v4
|
Code ReviewRisk: Low — No demonstrable defects were found in the reviewed changes; the reviewed scope is safe to merge aside from the files not opened in this pass. The MR ports the listed UI families to v4, adds the Shopify partial-rendering Fetch adapter, restores the generic transition mixin, and exposes component-scoped diagnostics. It also incorporates fixes for header normalization, asynchronous partial application failures, and failed video loading. Notes:
Review usage: 126,466 in (93,796 cached) / 1,606 out tokens — $0.0299 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 816983b. Previous review runsPrevious run archived 2026-08-24T09:52:00ZCode ReviewRisk: Medium — issues that should be addressed before merge. This MR ports the listed UI families, adds the Shopify partial-rendering Fetch adapter, restores the generic transition mixin, and exposes component-scoped diagnostics. I reviewed 1 issue found:
Still open from earlier reviews (2 findings):
Review usage: 148,017 in (110,966 cached) / 1,974 out tokens — $0.0346 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 6273853. Previous run archived 2026-08-24T09:44:32ZCode ReviewRisk: Medium — issues that should be addressed before merge. This change ports the listed UI families, adds Shopify partial rendering support, restores the generic transition mixin, and exposes component-scoped diagnostics. I reviewed 1 issue found:
Notes:
Still open from earlier reviews (1 finding):
Review usage: 147,640 in (105,579 cached) / 1,679 out tokens — $0.0366 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 6d291e7. Previous run archived 2026-08-24T09:39:32ZCode ReviewRisk: Medium — issues that should be addressed before merge. This change ports ten UI families, adds 1 issue found:
Notes:
Review usage: 221,820 in (175,689 cached) / 2,324 out tokens — $0.0446 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit d364868. Previous run archived 2026-08-24T09:38:29ZCode ReviewRisk: Medium — issues that should be addressed before merge. This MR ports the documented component families, restores the generic transition mixin, adds Shopify partial rendering, and exposes component-scoped diagnostics. I reviewed these changed files: 2 issues found:
Notes:
Still open from earlier reviews (3 findings):
Review usage: 286,898 in (237,068 cached) / 3,272 out tokens — $0.0528 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 40270d4. Previous run archived 2026-08-24T08:41:55ZCode ReviewRisk: Medium — issues that should be addressed before merge. The reviewed source files add Sentinel, Sticky, Hoverable, ScrollTo, AnchorNav, Menu, Timer, Toaster, Figure, FigureVideo, SliderDots, generic transition support, and FetchShopifyPartial implementations. I reviewed: 2 issues found:
Still open from earlier reviews (1 finding):
Review usage: 143,612 in (103,193 cached) / 2,068 out tokens — $0.0366 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit bea4464. Previous run archived 2026-08-20T09:43:48ZCode ReviewRisk: Low — no blocking issues; safe to merge aside from nits. This change ports the listed UI families, restores the generic transition mixin, and adds Shopify partial-rendering support to Still open from earlier reviews (1 finding):
Review usage: 94,959 in (62,104 cached) / 1,329 out tokens — $0.0276 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 22fd1d1. Previous run archived 2026-08-20T08:47:05ZCode ReviewRisk: Medium — issues that should be addressed before merge. Adds the ten migration families described in the intent, restores the generic 1 issue found:
Review usage: 188,967 in (153,453 cached) / 2,222 out tokens — $0.0365 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit e13bd44. Previous run archived 2026-08-20T08:03:20ZCode ReviewRisk: Low — no blocking issues; safe to merge aside from nits. This MR ports the Sentinel, Sticky, Hoverable, ScrollTo, AnchorNav, Menu, Timer, Toast/Toaster, Figure, FigureVideo, and FetchShopifyPartial families to v4, together with migration documentation and corrected specs. I reviewed Review usage: 120,355 in (88,142 cached) / 2,217 out tokens — $0.0312 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 99bbac0. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #861 +/- ##
=======================================
Coverage 97.04% 97.04%
=======================================
Files 175 175
Lines 4535 4535
Branches 1323 1322 -1
=======================================
Hits 4401 4401
Misses 122 122
Partials 12 12
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
v4 mount benchmarksBase and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back. A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner. No benchmark moved beyond the noise floor. Within noise (18)
|
Restores v3's structure, where the behaviour lived in a mixin and the Transition component was withTransition(Base). The earlier port collapsed the two on the finding that the decorator used `this` for nothing but reading two options — true at one consumer, false at five: MenuList, AbstractFigure, FigureVideo and AnchorNavLink each reimplemented the same state/target/enter/leave/toggle block. This is v4's first non-service mixin. It reuses createServiceMixin's type shape (MixedClass) so a consumer threads its own props exactly as it does for withResize, and its concrete-parameter-plus-cast split for the same reason core needs it: a class extending a type parameter must otherwise declare constructor(...args: any[]). It declares no config. BaseConfig requires a name for the static side to stay assignable to Base's, and that name would be inherited by any consumer which forgot its own, registering a component under a name it never chose — so consumers keep spreading TRANSITION_OPTIONS, the one line they already had. Core's service mixins declare no config either. transitionOptions is a new hook, defaulting to $options: it is what lets MenuList force enterKeep/leaveKeep without reimplementing enter()/leave(). v3 forced them by overriding the $options getter, which v4 refuses — $options is a read-only view with no override point — so the override moves onto the declaration the mixin reads. ViewTransition gains the same type parameter but stays a direct Transitionable implementation: the browser owns its animation and its event names are its own.
MenuList, AbstractFigure, FigureVideo and AnchorNavLink each carried their own state/target/enter/leave/toggle block around the same two core utility calls. -138 net lines, and every spec passes unchanged. AnchorNavLink becomes withTransition(ScrollTo), which is v3's own declaration restored, and is the case that shows why the mixin has to exist: the transition belongs on a class that already extends something else. Its spec asserting the inherited ScrollTo onClick still fires confirms magic-name handler binding traverses the mixin now in the prototype chain. MenuList's forced enterKeep/leaveKeep is now a four-line transitionOptions getter instead of two reimplemented methods. AbstractFigure and FigureVideo keep only their target override, onto the img and video refs — which is what that hook is for.
…amilies I never ran `npm run lint` while porting: main was clean and this branch had introduced six errors plus two warnings. All fixed, and oxfmt (the project's formatter — not prettier) applied to the whole migration dir. The substantive one is the project's own `js-toolkit(no-write-in-read- phase)` rule firing twice on Sticky, which is gap 43 caught by the lint rule that gap asked for. Sticky.hide()/show() write classList and restack every instance, and their caller is scrolled() — the scroll service emits from inside defaultScheduler.read(), so those writes interleave into the read phase. The DOM half moves to an applyVisibility() marked @Write; isVisible stays synchronous, since it is logical state setPosition() reads on every instance and only the DOM work belongs in a later phase. One spec assertion now awaits settle(). The rest are mechanical: two async resized() methods handed a promise to a hook declared void (no-misused-promises), and three returns of `any` through a loose BaseConstructor host or a non-literal dynamic import (no-unsafe-return).
Gap 45 is resolved rather than open: withTransition restores v3's mixin, both transition classes are generic, and the four consumers lost 138 net lines. Records why §4b's collapse was right about its evidence and wrong about its scope — a consumer needs the state machine and events around the two utility calls, not the calls — plus why the mixin can declare no config and why MenuList's forced flags land on transitionOptions. §4b and the size-table note now point forward to it instead of leaving the superseded conclusion as the last word. Gap 43 gains the case its own "ask" produced: the lint rule it asked for shipped, and immediately found a third instance in Sticky's scrolled() — a hook the earlier thirteen-family audit had not checked, because the first two cases were in ticked(). Also records that the fix has to split the method rather than decorate it, since @Write would defer the state flag along with the paint. §16's closing claim is corrected: the round was chosen as the one needing no design deliberation and produced two findings anyway, neither of which was visible from the component list.
… gap 2
Two corrections, both from review.
**The mixin can declare config, and I was wrong that it could not.** The
first version had all five consumers spread TRANSITION_OPTIONS, on the
belief that BaseConfig's required `name` blocked a mixin from declaring
one. resolveConfig() folds each own config with `{ ...merged, ...own }`,
so a config with no `name` key contributes its options and leaves the
name below it untouched — which is what a mixin is. The declaration is
typed rather than named, the five consumers dropped their spread, and
two new specs pin it: the options resolve on a consumer declaring none,
and resolveConfig().name still answers the consumer's own.
SliderDots deliberately keeps its own spread and its direct utility
calls: it transitions its child dots, two elements per update, with no
state, no single target and no events, so the mixin's shape does not
fit. §4b's observation was right for the component it was made on.
**Gap 2's audit undercounted, and this is the more interesting one.**
It measured $options *assignments* via grep '\$options\.[a-z]* *=',
which by construction cannot match a getter override — the other way v3
allowed an option to be reshaped. `grep -rl 'get [$]options'` over ui
finds two, MenuList and FrameLoader, and both force enterKeep/leaveKeep
so a transition keeps its end state. Being overridable is *why*
$options was a getter in v3, and that is the whole of what ui used it
for. The ruling stands — both of its coherence arguments apply to an
override as much as to an assignment — but it costs one capability more
than recorded, and transitionOptions is the narrow replacement rather
than an incidental convenience. Recorded in gap 2 and gap 45, including
that a lint rule for this half must match `get $options`.
…ke class **The mixin dropped both halves of v3's target surface.** v3's `target` getter returns HTMLElement | HTMLElement[], and enter/leave/toggle each take an optional target which *replaces* it for that one call. Restored: #elements() flattens `[target ?? this.target]`, and #run() fans one direction across them with Promise.all over a synchronous map — which is the ordering v3 got from handing the whole list to one transition() call, since every element is staged before any reaches the next frame. **SliderDots is the consumer that proves it, and I had it wrong twice.** §4b concluded the decorator used `this` only to read two options; I then wrote that SliderDots could not use the mixin because it transitions two elements in two directions. v3's source contradicts both: it *is* withTransition(AbstractSliderChild), overrides get target() to return its whole dots ref list, and calls leave(previous)/enter(next). It is the canonical consumer of both halves — which is exactly why dropping them made it look like a non-consumer. Now a mixin consumer again, with its direct utility imports gone and the currentIndex -1 guard kept (v3 throws there). **And a flake class worth naming (gap 46).** A spec asserting a kept transition class after settle() is racy: open()/close() start the transition without returning it, and the end state lands several frames later. MenuList failed ~1 run in 3 under full-suite load while passing 6/6 in isolation, which is what made it look like noise the first two times I saw it. Polled with waitForClass in the three positive assertions of this shape; the negative one is deliberately left alone, since leaveTransition() clears the other direction synchronously, so polling for an absence would pass before anything happened. Five consecutive full-suite runs clean.
…s $warn half
AbstractFigure was reimplementing a `warn` utility. So were ten other
files — one four-line `console.warn` wrapper per family, none aware of
the others — while gap 31's cancelable diagnostic channel sat unused,
because it was closed to consumers: `warn`/`reportDiagnostic` were
`@internal` and unexported, and ToolkitDiagnosticCode was a closed
union of core's own codes. A consumer could listen and could not report.
Core changes:
- ToolkitDiagnosticCode is now core's enumerated set (kept as
ToolkitCoreDiagnosticCode, still pinned by its spec) OR a
`${string}.${string}` consumer code. The namespace is mandatory
because a listener filters on it.
- Base gains $warn(code, message) and $error(code, message, error),
filling in the component name and element — the two things a listener
filters and inspects on, and the instance is what always knows both.
- warn/reportDiagnostic become public for the one call site with no
instance to report as (parseEventDefinition, a module-level parser).
The severity split is the part worth keeping: $warn for a malformed
declaration where nothing threw, $error for a recovered failure that
has a cause and must carry it. The rule is whether v3 had an error in
hand — its `warn('Invalid JSON in …:', error)` becomes $error, its
`warn('…')` becomes $warn. The image and poster load failures now carry
the Error v3 discarded.
Eleven local copies deleted, Track/utils.ts with the last of them.
Three specs that asserted on console.warn now assert on the channel,
which pins the code rather than the sink. One of them turned out to be
leaking a document listener that cancelled the default sink for every
later test in the file — fixed.
Also updated the project's own no-deprecated-properties rule, which
said `"$warn" is removed in v4. Use console.warn() instead.` — true
when written, now the opposite of the advice.
v3 mount benchmarksBase and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back. A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner. No benchmark moved beyond the noise floor. Within noise (10)
|
All three were real, all three high-confidence, and none was caught by
the specs I wrote — worth recording as a class: each is an unhappy path
the port inherited from v3 unexamined.
**Headers were only read in their record form.** RequestInit.headers is
a HeadersInit — a record, a list of tuples, or a Headers instance — and
spreading one only sees the record; a Headers yields no own enumerable
keys at all. So a custom header written that way passed
canUsePartials() unnoticed and was then dropped, because the partials
API forwards nothing but { url, signal }. Now read through a
headerNames() helper covering all three forms; the method/body check no
longer needs the merged object either.
**A rejected partials.apply() was an unhandled rejection.** The apply
phase is fire-and-forget on purpose, so an apply failure is not
misattributed to the fetch phase — but without a catch it had no
observable failure at all. Routed through the component's own error().
**FigureVideo hung forever when its sources failed.** loadSources()
waited on `loadeddata` alone (as v3 does), so a video whose sources all
fail never settled, and mounted() awaits it — meaning no enter
transition, no `load` event, no hasLoaded, and no error either. It now
settles on `error` too and mounted() reports it, leaving the component
un-loaded so a later mount cycle retries.
Five specs added, one per behaviour plus the internal-header case that
must still take the partials path.
Three high-confidence issues came out of the PR review rather than the suite, and they share a shape worth naming: each is an error path v3 also got wrong or never had, carried over because the port asked whether behaviour matched v3 rather than what happens on failure. Every spec in the port was written from the component's documented behaviour, so each exercises the path the component is for. None of the three bugs is reachable that way — two need a resource to fail, one needs a caller to use a different-but-legal form of a platform type. The rule that falls out: for each awaited external outcome, ask what settles it on failure; for each platform type accepted at a boundary, ask which of its forms the code actually reads.
The second hard-coded count, in the consumer fixture `check:package` runs — whose own comment warns that `npm test` does not cover it, which is exactly how I missed it after updating the one in exports.spec.ts. Verified against the built dist: 86 root exports, with `warn` and `reportDiagnostic` both surviving the pack. Both are now asserted by name, so the next person gets a useful failure rather than an off-by-two on a bare number.
The review's second round found my first round was incomplete, and
chasing it turned up a worse bug underneath.
**The popstate history guard still indexed headers as a record**, in
both FetchShopifyPartial.applyPartials() and the base Fetch.update() —
I had fixed canUsePartials() and stopped there. A `Headers` instance
carrying the internal x-triggered-by header read as undefined, so a
back/forward navigation pushed a new history entry.
**And the root cause was mergeRequestInit(), which spread headers.**
Spreading a `Headers` yields nothing, so a caller's
`fetch(url, { headers: new Headers(…) })` was silently emptied before
the request was built — the header never reached fetch() at all. That
is the base Fetch on its ordinary path, not just the partials variant.
Fixed by merging through one headerEntries() reader, which headerNames()
and headerValue() are now derived from, all three living in Fetch.ts
beside HEADER_NAMES.
**FigureVideoTwicpics.loadSources() still hung**, because it overrides
the base entirely and I had only fixed the base — it waits on
`canplaythrough` where the base waits on `loadeddata`, with the same
missing `error` listener. Same fix applied to the override.
Five specs added: a Headers instance and a tuple array both reach
fetch() with the per-call value winning, the popstate guard reads a
Headers instance, a non-popstate request still pushes, and the
TwicPics override settles and reports on a media error.
Summary
Continues the
@studiometa/ui→ v4 migration feasibility test (packages/v4/migration/, seeREPORT.md) with ten component families that needed no design deliberation, plus the one gap theFetchport had deliberately left open:Sentinel,Sticky,HoverableScrollTo(renamed fromAnchorScrollTo)AnchorNavfamilyMenufamilyTimer/TimerProgressToast/ToasterFigurefamilyFigureVideofamilyFetchShopifyPartialThe round was picked as the one needing no design deliberation and produced two findings anyway, neither visible from the component list.
withTransitionrestored (gap 45)Transitionhad been ported as a non-genericBasesubclass, soclass X<T> extends Transition<Y & T>did not type-check — and four families needed exactly that, each reimplementing the samestate/target/enter/leave/toggleblock. §4b had collapsed v3's mixin into the component on the finding that its body usedthisonly to read two options; that held at one consumer and not at four, because what a consumer needs is the state machine around those two calls.So v3's structure is back:
withTransitionis v4's first non-service mixin,TransitioniswithTransition(Base)again, both it andViewTransitionare generic, and the four consumers lost 138 net lines.AnchorNavLinkis once more literallywithTransition(ScrollTo). A newtransitionOptionshook is what letsMenuListforceenterKeep/leaveKeepin four lines — v3 did it by overriding the$optionsgetter, which gap 2 ruled out.Two bugs the tooling caught
data-option-x="false"reads astruein v4 (a boolean option's presence is its value). Hit inToaster's sticky-toast branch, fixed to the negated attribute name, and audited across every family in the round. This is gap 34 arriving at the exact call site it predicts.js-toolkit(no-write-in-read-phase)fired twice onSticky— a third instance of gap 43, inscrolled()rather thanticked(), which the earlier thirteen-family audit had not thought to check. The scroll service emits from insidedefaultScheduler.read(), sohide()/show()were interleavingclassListwrites into the read phase. The DOM half moved to anapplyVisibility()marked@write, keepingisVisiblesynchronous.Test plan
packages/v4suite green: 1512 tests, 112 filestsc --noEmitclean (both project tsconfigs)oxlint --type-aware packages/v4back tomain's baseline (one pre-existing warning insrc/context.ts)oxfmt --checkclean🤖 Generated with Claude Code
https://claude.ai/code/session_019vGCvbrSfjBKFzMHSiu9wg