Skip to content

Land the ui v2 component port on js-toolkit v4 - #642

Merged
titouanmathis merged 133 commits into
2.xfrom
feat/v2-component-port
Sep 3, 2026
Merged

titouanmathis merged 133 commits into
2.xfrom
feat/v2-component-port

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

This is the first step of @studiometa/ui v2: it lands the component port that was written and proved against @studiometa/js-toolkit v4 in the js-toolkit repository, migrates four families the port never covered, moves the whole workspace onto @studiometa/js-toolkit@4.0.0-alpha.1 (published under the next dist-tag), and replaces the test runner with Vitest browser mode on Playwright Chromium.

The port's own write-up — every deviation from the v1 sources, why it was made, and the size measurements — is packages/js-toolkit/migration/REPORT.md in studiometa/js-toolkit. The v4 API is documented at https://js-toolkit-v4.studiometa.dev.

The plan of record is V2-COMPONENTS-REVIEW.md, committed here as-is in the first commit. It outranks the port wherever the two disagree, and everything below is sourced from it.

Families

Family State Reason
Action (Action, ActionEvent, Target) Ported Keep
AnchorNav (AnchorNav, AnchorNavLink, AnchorNavTarget) Ported Keep — scrollspy pattern nothing else covers
Carousel (7 classes) Ported Keep
ClickOutside Ported Keep
Cursor Ported Keep. The generic-API redesign the review asks for is not in this PR
Data* (DataScope, DataBind, DataModel, DataComputed, DataEffect) Ported Keep. DataChannel and withGroup are replaced by one DataRegistry on core's signal() and provide/inject
Dialog Ported Keep
Disclosure / DisclosureGroup Migrated here Keep. No port existed. Events are namespaced — see below
Draggable Ported Keep
Fetch (Fetch, FetchShopifyPartial, FetchShopifySection) Ported Keep
Figure (AbstractFigure, AbstractFigureDynamic, Figure, FigureShopify, FigureTwicpics) Ported Keep
FigureVideo (FigureVideo, FigureVideoTwicpics) Ported Keep
Hoverable Ported Keep
Indexable / withIndex Ported Keep both. The mixin is the primitive and Indexable is withIndex(Base) plus the component name — one implementation, the same split as withTransition/Transition
InView (InView, InViewOnce) Ported Keep
LargeText Migrated here Keep. No port existed. The generic-API redesign the review asks for remains outstanding
LazyIncludeDefer Ported, renamed Keep as the lightweight primitive, renamed. Directory, class, config.name and events all follow: contentdefer-content, errordefer-error, alwaysdefer-always
Menu (Menu, MenuBtn, MenuList) Ported Keep
Prefetch (AbstractPrefetch, PrefetchWhenVisible, PrefetchWhenOverPrefetchOnInteraction) Ported, renamed Keep, renamed. The port already carried the rename
ScrollReveal Migrated here Keep. No port existed
AnchorScrollToScrollTo Ported, renamed Keep, renamed. The port already carried the rename; AnchorNavLink follows it
Sentinel Ported Keep as-is — it exposes the raw IntersectionObserverEntry that InView collapses away, and Sticky needs boundingClientRect.y
Slider (7 classes) Dropped Round 7: superseded by the Carousel family, which absorbed the four controls it had and Slider lacked
Sticky Ported Keep as-is — it coordinates stacked sticky elements, which CSS cannot
Timer (Timer, TimerProgress) Ported Keep
Toaster (Toast, Toaster) Ported Keep
Track (AbstractTrack, Track, TrackContext, TrackEvent, TrackShopify) Ported Keep
Transition / ViewTransition / withTransition Ported Keep. withTransition is back as a real mixin (js-toolkit REPORT gap 45); ViewTransition/scheduler.ts is deleted, core absorbed it
withDeprecation Dropped It had no consumer left in v2, so the module, its subpath, its docs page and its catalog entries go rather than leaving dead code behind an export
Accordion (Accordion, AccordionItem) Dropped Superseded by Disclosure + DisclosureGroup
Frame (6 classes + AbstractFrameTrigger) Dropped Superseded by Fetch
Modal / ModalWithTransition / Panel Dropped Superseded by Dialog
ScrollAnimation (7 classes), animationScrollWithEase, withScrollAnimationDebug Dropped Superseded by ui-motion
ImageGrid, Reinsurance, StyledModal, StyledPanel (Twig) Dropped Review round 3
AbstractSliderChild Dropped Absorbed into SliderContext; its export subpath is removed
Tabs Rewritten Round 6: the full WAI-ARIA Tabs pattern, styles dropped with the last consumer of config.options[…].merge, enable/disable namespaced to tabs-*
CircularMarquee Migrated It extended bare Base with scrolled()/ticked(), which v4 only calls when the matching service is mixed in — so it did nothing at all. The generic-API redesign is still outstanding

30 component families on v4, and nothing left on a v1 source. 7 groups are dropped — the six above plus Slider, which round 7 superseded with the Carousel family — along with withDeprecation.

The runner is a browser now

The suite ran under happy-dom through @happy-dom/global-registrator. That runner cannot answer the questions these components ask — no IntersectionObserver, no layout geometry, no computed styles, no document location, and none of the browser globals @studiometa/js-toolkit is entitled to use, reportError() among them. It is now Vitest browser mode on Playwright Chromium, the runner js-toolkit proves its own 1570 tests with.

The result: 169 failures became 3, and the flakiness is gone. Three consecutive full runs gave the identical 3 failed | 571 passed | 1 skipped (575), against a happy-dom band of 154–169 out of 539 that retry: 3 never settled.

The suite is two projects. chromium runs everything that touches the DOM. node runs the four export-surface specs that read the repository instead — the TypeScript compiler API, node:fs, node:child_process, import.meta.resolve — and cannot run in a page.

The previous round's two-project attempt hung; the cause is found and it was not the split. It was test.root: '..'. Pointing the root at packages/ is harmless for Node, which needs no dev server, but as a Vite server root it makes the dev server serve and crawl the whole monorepo — docs, playground, cdn, api, every package's node_modules — and the run never reaches the first test. The root is this package now. That also stops the suite silently collecting packages/eslint-plugin-ui's five rule-tester specs, which have their own test script in their own package and were only ever swept in by the wide root — the whole 539 → 534 test-count drop at that step.

Dependency discovery is off for a related reason: ui-motion, ui-mapbox and Tabs still import subpaths v4 removed, and the up-front scan turned one dead subpath into a failure of the entire run instead of the one spec that owns it.

Removed with the dependency: packages/tests/__utils__/happydom.ts in full — the three shims for platform invariants happy-dom breaks (window instanceof Window, HTMLCollection[Symbol.iterator], reportError) and the scrollY / requestAnimationFrame patches beside them. resetDom() from @studiometa/js-toolkit/test is the whole teardown. Also gone, all with zero consumers: lifecycle.ts (whose destroy() called the v3-only $destroy()), resizeWindow.ts (assigns to the read-only window.innerWidth), matchMedia.ts (clobbered a real global for every file importing the barrel), mockImageLoad.ts, mockVideoLoad.ts, faketimers.ts and components.ts.

Three failures the browser exposed, fixed rather than skipped

  • Disclosure's recovered transition failure became an unhandled global error. The component reports it on the diagnostic channel, whose default sink is reportError() — which in a real browser raises a global error the runner flags. Per the ruling that this is correct js-toolkit behaviour, the spec now captures the channel with captureDiagnostics(), which both asserts the code and suppresses the sink.
  • MapboxMap/dependencies.spec.ts and Motion/dependencies.spec.ts reloaded their module singleton with vi.resetModules(), which cannot clear the page's own module registry — verified with a standalone probe: the call returns the identical namespace object. Each test was therefore asserting against the previous test's sentinel. A distinct query string is a genuinely distinct module URL, and still goes through Vite's transform so vi.mock() still applies.
  • subpath-exports asserted '$isBase' in X, a v3 static. v4's brand is a private symbol, so the assertion is X.prototype instanceof Base.

LargeText

LargeText was the last import keeping the barrel from loading. withMountWhenInView(Base, { rootMargin: '50%' }) is the string in-view:50%: the registry owns the observer before any instance exists, and data-mount overrides the margin per element, which v3 could only do by declaring a second class. The three services v3 bound by the mere presence of resized(), scrolled() and ticked() are named as three mixins.

Two v4 signature changes are carried rather than worked around. damp() is per elapsed millisecond, not per frame, so the same 0.25 factor now means the same speed on every display — a behaviour change, and the one the framework intends. transform() is pure: it formats a string and leaves the write to the caller, so the style write is scheduled explicitly into the write phase of the frame the read ran in, replacing v3's return-a-closure-from-ticked().

LargeText.twig writes no ref, option or event that moved, so it is unchanged. The generic-API redesign V2-COMPONENTS-REVIEW.md asks for is not in this PR and remains outstanding, together with the same redesigns for Cursor and CircularMarquee.

The family gets its first spec: six cases covering the mount strategy, the measured loop distance, the reversal under a negative sensitivity, the wrap and the optional skew. It only means anything in a browser, because the loop distance is clientWidth.

Disclosure events

Both sides emitted a bare open/close, and v4's $emit() bubbles, so a listener on the group's element heard its children too — telling them apart meant reading event.target or the shape of the payload. Both take the family prefix Defer (defer-*) and Fetch (fetch-*) already set. The final names:

Before Now
Disclosure open, close, after-open, after-close disclosure-open, disclosure-close, disclosure-after-open, disclosure-after-close
DisclosureGroup open, close, change disclosure-group-open, disclosure-group-close, disclosure-group-change

disclosure-group-* is my resolution of the collision, not a user instruction — flagging it explicitly for review. So is renaming the whole event set of each side rather than only the two that collided: a half-namespaced family reads as an oversight, and after-open beside disclosure-open is one vocabulary spelled two ways. Disclosure.twig writes no event name and nothing else in the workspace listens for these, so the rename reaches the $emits types, the emit sites, the spec and packages/docs/reference/items/Disclosure/js-api.md.

Default exports restored

export default is back on every family's main component. The port had dropped it across the board — v1 had it on all thirty — and subpath-exports.spec.ts is right to assert it: @studiometa/ui/<Component> has always exposed the main component both ways. The decision named Disclosure and ScrollReveal; the spec names six, and a half-restored convention is worse than either extreme, so it is restored everywhere. Family members and sub-components keep only their named export, which is the distinction the spec draws.

Migrated families

Disclosure / DisclosureGroup. v3 answered "which group owns me?" with $closest('DisclosureGroup:mounted') and, because nothing announced a mount, backed it with a two-way document CustomEvent handshake and a document-wide MutationObserver. All of it is gone. The group holds a live $watchChildren(Disclosure) collection and claims the disclosures below it; a disclosure refuses a claim from a group further away than its current one, and falls back to the nearest still-mounted group when its own unmounts.

enable()/disable() wrote to $options in v3. REPORT.md counts that pair as the one genuine reconfiguration in the whole of @studiometa/ui, and names the replacement: write the presence-only attribute the option reads. optionDisabledChanged() replaces updated().

ScrollReveal. v3 built it on withMountWhenInView. v4's two successors each answer half of what it needs, and the repeat option chooses between exactly those two behaviours at runtime, from an attribute. So the component mounts normally and subscribes to useInView(), the core service the strategies are themselves built on. $terminate() has no v4 equivalent and needs none: the one-shot guard is a field.

Not in this PR

Everything this section originally listed has since landed except the redesigns. What is left:

  • The Cursor, LargeText and CircularMarquee generic-API redesigns the review asks for. All three are ported as they stand; the redesigns are API design work, not migration.
  • The Twig extension (packages/twig-extension) and anything else on the PHP/Composer side.
  • Twig templates for the Carousel controls. Worth noting v1 shipped none for Slider or Carousel either, so this is an addition rather than a regression.
  • The version bump and changelog for the 2.0.0-alpha.0 release.

Documentation

packages/docs follows the port, in four commits: 195 files changed, 831 insertions, 5 354 deletions.

Removed

The Reference items of every dropped component, with their stories and catalog entries: Accordion, the whole Frame family, ImageGrid, Modal, Panel, Reinsurance, the ScrollAnimation family, animationScrollWithEase, withScrollAnimationDebug and withIndex11 item directories, 102 files. withIndex's item has since come back: the decorator is restored, so /reference/items/withIndex/ exists again with a rewritten page, a catalog entry and a public-contract entry for IndexableInterface.

viewTransition goes with them for a different reason: core absorbed it, so @studiometa/ui/scheduler no longer exists and the helper comes from @studiometa/js-toolkit. It was the only documented helper, so the sidebar stops offering a group with nothing in it while /reference/helpers/ keeps its URL and explains the absence. The Reference overview now reads Components 45 · Primitives 8 · Decorators 3 · Helpers 0 · Types 110 · All exports 241withIndex and IndexableInterface are the two symbols the restoration added back.

Transition's group option is removed too. It collected sibling instances from a global registry v4 does not keep, so the option, the Group example and its story are gone, and the FigureShopify reveal — which used group to run two transitions as one — drives the overlay it contains through $query() instead.

Renamed

LazyIncludeDefer with its defer-content / defer-error / defer-always events, AnchorScrollToScrollTo, PrefetchWhenOverPrefetchOnInteraction. Directories, prose, headings, stories, catalog ids and the sidebar all follow; each page keeps the old name as a search alias and carries a "Renamed in v2" note. The old URLs 404 — VitePress has no redirect mechanism here, and adding one was out of scope.

The catalog is the generated surface

reference/all-exports/, /types/, /components/, /primitives/, /decorators/, /helpers/ and the whole Reference sidebar are all rendered from .vitepress/reference/catalog.ts and public-contracts.ts, which are hand-maintained and validated against TypeScript's module graph by scripts/validate-reference.ts on every docs:build. That check was failing with 60 errors before this work. Both files are rebuilt against the real export surface — 78 stale symbols removed, 48 added — and it passes. The 16 unexported internal helpers needed nothing: they were never documented.

packages/ui/src/manifest.ts is the other generated file; npm run manifest:check is clean, so nothing was needed there.

Ported onto v4

Every live example is real component code, so the runtime move broke a set of them in ways no build catches:

  • createApp() does not exist in v4. Four stories and eight Reference pages used it. An application component is now an ordinary component with a data-component root, and $children.X[0] is $query('X')[0].
  • registerComponent() takes one argument. The alias/selector second parameter is gone, so registerComponent(ScrollTo, 'a[href^="#"]') registered nothing. Aliasing is a subclass declaring name.
  • withResponsiveOptions() is gone; every option is responsive. data-option-mode:xxs:xs:s="click" becomes the v4 cascade, data-option-mode="click" plus data-option-mode:m="hover".
  • withMountWhenInView is gone. Five components now name the mount strategy they declare, and InView / InViewOnce lose their intersectionObserver option with it — the margin is the data-mount suffix.
  • @component() registers its class on import, so the Draggable story's subclass was refused by the registry and its override never ran.
  • The autoloading guide was v3 end to end: data-loaddata-mount, four strategies → six, js-toolkit:eager / autoload() / registerManifests() / js-toolkit:errorregisterManifest() and the js-toolkit:diagnostic channel.

Verified in a browser

The docs' own live examples cannot run on this branch, for two reasons that are both in the port and neither in the documentation.

  1. @studiometa/playground@0.3.13's front-end is a js-toolkit v3 application. The workspace overrides pin js-toolkit to 4.0.0-alpha.1, so its shell throws (0, Z.createApp) is not a function and never renders a story. It also uses $children and destroyed(), so this is a port of that package, not a patch.
  2. The @studiometa/ui build emits untranspiled decorators. tsdown/rolldown at target: 'esnext' writes var Defer = @component({…}) class extends Base {} into dist/, which no shipping browser parses — 21 modules, and the same output reaches the playground bundle. Lowering the target to es2022 does not change it. The test suite never sees this because Vitest transforms the sources with esbuild, which does lower decorators.

So the stories were exercised through a temporary harness: each one's Twig rendered by the same /api/ endpoint the playground uses, its app.js bundled with esbuild against the real @studiometa/ui sources, served from the ddev docroot with console.warn/console.error, window.onerror, unhandledrejection and the js-toolkit:diagnostic channel all captured.

All 126 in-scope stories load with zero console errors, zero page errors and zero unhandled rejections. The only network failure anywhere is https://defer.invalid/ — which is the Defer error example doing its job.

Sixteen stories were then driven the way a reader would, in Chromium:

Story What happened
Disclosure basic Clicking a trigger moved aria-expanded and hidden across the group; single-open honoured
Defer basic hello world! injected, loading ref hidden
Defer error error ref revealed (display: block)
ScrollTo Click intercepted (defaultPrevented), window.scrollTo() called once, page at 1848px
Slider left / center / right Counter 1 / 102 / 10 on all three
Carousel horizontal / vertical scrollLeft 0 → 32, scrollTop 0 → 56
DataModel basic Typing propagated to the bound element
Fetch simple Page A → Page B swapped in
Action counter Double is 0.Double is -2.
Dialog modal <dialog>.open === true
Toaster basic 0 → 1 Toast
FigureShopify reveal Both halves of the reveal ran, image loaded from the Shopify CDN

Pages were also loaded directly: /reference/, /reference/all-exports/, /reference/helpers/, /guide/autoloading/, /reference/items/Defer/js-api, /reference/items/Prefetch/, /reference/items/Disclosure/examples. /reference/items/Modal/, /Frame/ and /LazyInclude/ correctly 404.

Two tooling defects found on the way

  • js twoslash blocks are not type-checked. A block importing a symbol @studiometa/ui does not export builds clean, with no error marker; every hover in the built site is empty. Twoslash itself is fine — run directly it reports the error. So docs:build says nothing about the code samples. All 21 remaining blocks were checked by hand through the twoslasher and pass.
  • npm run lint:prettier checks 3 of the 204 markdown files. packages/docs/**/*.md is passed to sh, where ** is not globstar, so it expands to files exactly two levels deep. Not changed here: quoting the glob would fail on dozens of pre-existing files. The 25 files this work touched were checked explicitly and pass.

Re-verified in a browser after the waitUntil fix

ddev start, npm run docs:build, https://ui.ddev.site. Zero console errors and zero page errors throughout; the only console output is the playground's own updating style… / script updated! chatter.

Page Result
Motion/exampleswith-dialog playground Both events reach a document listener as waitUntil=function. A test extension registered on close held the dialog open and scroll-locked for 2 s; releasing it hid the dialog and released the scroll. On the dialog element the close name is delivered twice, as documented: the extendable one (bubbles=true, detail object) then the platform's native one (bubbles=false, detail=undefined) after hiding
MotionView/examplesambient-dialog playground With no wiring attribute at all, both MotionView instances entered on open (opacity-0opacity-100, translate-y-8 scale-95translate-y-0 scale-100) and left on close. The recorded order is view0:leave:start, view1:leave:start, view0:leave:end, view1:leave:end, native close — concurrent, and the native hide last
MotionView/examplesambient-bind-if playground Ticking and unticking the checkbox both run through MotionView.update(), which the literal dom-update listener never did

Caveat on how this was measured. esm.sh could not build @studiometa/js-toolkit@4.0.0-alpha.1/EVENTS during the session — 20 requests over 13 minutes returned 408 or timed out, while already-built subpaths of the same package returned 200 — so @studiometa/ui-motion would not load in the playground at all. The two MotionView runs above used the real built MotionView.js with that one bare specifier rewritten to an inline module of the same constant; Dialog, emitExtendable(), domUpdate() and MotionView's own code are the real build. The with-dialog run needed no such rewrite. The ambient-dialog story will stay inert on the public docs site until esm.sh builds that subpath — a cold-start artefact of a never-requested subpath, not a code defect, and the alternative (importing EVENTS from the barrel) would pull the whole js-toolkit graph into a MotionView-only CDN import.

Still open in the docs

  • The v1 → v2 migration guide is unwritten. Written — see The migration guide below.
  • @studiometa/ui-mapbox and @studiometa/ui-motion documentation is untouched — both are audited against their ported sources in the last round.
  • Tabs and CircularMarquee documentation is untouched — both sources are on v4 now, and their pages follow.
  • The four Hero image examples ship no :script, so their Figure never registers and the image stays a placeholder. Pre-existing, unrelated to the port, left alone.

Verification

Run from the repository root, on this branch, at 9962d2f2.

npm run docs:build

Passes. validate-reference reports 55 Reference entries, 239 symbols and 5 concepts, and the site builds in ~27s. It failed at validate-reference with 60 errors before this round.

npm run lint

lint:static reports 28 warnings and 0 errors (29 before; one warning left with a deleted helper). lint:prettier passes.

lint:types fails, with 52 errors, down from 65, and none of them in a family on v4:

Package / area Errors Was Cause
packages/ui-mapbox/src 30 30 v3 package
packages/ui-motion/src 17 17 v3 package
packages/ui/src/LargeText 0 13 ported in this round
packages/ui/src/Tabs 3 3 v1 source, deferred rewrite
packages/ui/src/CircularMarquee 2 2 v1 source, out of scope

Does the @studiometa/ui barrel import?

Yes. index.spec.ts collects and passes, and the three other specs that waited on it are unblocked:

Spec Project Result
index.spec.ts chromium 1 passed (snapshot regenerated)
barrel-exports/barrel-exports.spec.ts node 3 passed (snapshot regenerated)
subpath-exports/subpath-exports.spec.ts chromium 10 passed
subpath-exports/resolution.spec.ts node 11 passed
subpath-exports/backward-compat.spec.ts chromium 3 passed
subpath-exports/backward-compat-resolution.spec.ts node 4 passed
autoload/manifest-freshness.spec.ts node 1 passed
autoload/manifest.spec.ts chromium still fails to collect — it imports @studiometa/ui-mapbox, whose v3 source reaches for @studiometa/js-toolkit/withExtraConfig. Not LargeText, and not fixable without the ui-mapbox port

Three of those files needed splitting, because they mixed two incompatible requirements. Importing @studiometa/ui registers every component, and registration needs a MutationObserver, so a spec that loads the barrel cannot run under Node at all — while the same files also called import.meta.resolve, node:fs and execFileSync. The repository half of each moved to its own *-resolution / *-freshness file in the Node project; the identity half stayed in the browser. No assertion was dropped.

npm run test

 Test Files  29 failed | 53 passed (82)
      Tests  3 failed | 571 passed | 1 skipped (575)

Three consecutive full runs produced that line, character for character. The happy-dom baseline on the same tree and the same alpha was 169 failed | 370 passed (539) across a 154–169 band. The one skipped test is a pre-existing it.skip in Tabs/Tabs.spec.ts; I did not delete or skip a single test.

The 3 remaining test failures are all Tabs/Tabs.spec.ts, and they are real: v1 Tabs emits $emit('enable', item), and v4 delivers the payload differently, so the listener receives undefined. The runner is not hiding them any more — happy-dom never collected this file. They belong to the deferred Tabs rewrite and are left failing rather than adjusted.

The 29 failing test files are all collection failures in unported packages: 20 MapboxMap/* and 7 Motion/* (v3 sources importing ./withExtraConfig, ./utils/nextTick, ./utils/addClass, ./utils/removeClass), autoload/manifest.spec.ts (same cause, via ui-mapbox), and Tabs/Tabs.spec.ts (counted as failed because of its 3 red tests). None is a @studiometa/ui v4 family.

Export-surface snapshots

Both were regenerated. Every removal is a documented v2 drop or rename — the Accordion, Frame, Modal and ScrollAnimation families with their *Props types, AbstractSliderChild, viewTransition (core absorbed it), and the three renames LazyIncludeDefer, AnchorScrollToScrollTo, PrefetchWhenOverPrefetchOnInteraction.

The additions split three ways:

  1. New surface from the port, expected: AbstractFigure, AbstractFigureDynamic, AbstractTrack, ActionEvent, TrackEvent, CarouselContext, SliderContext, DataRegistry, DataRegistryContext, plus the renamed classes and a long tail of new *Props/state types.

  2. Named constants, following the framework-wide "frozen const object over magic strings" convention: EFFECT_ARGUMENTS, FETCH_EVENTS, HEADER_NAMES, INDEXABLE_BOUNDARIES, INDEXABLE_INSTRUCTIONS, MODIFIERS, SECTIONS_PARAMETER, TRACK_PSEUDO_EVENTS. MODIFIERS and HEADER_NAMES are very generic names for a public barrel. Kept deliberately: they exist so a no-build-step consumer writes INDEXABLE_BOUNDARIES.loop rather than a bare string, which is the whole point of the convention.

  3. Sixteen internal helpers — resolved: removed from the public surface. compileExpression, getCallback, getClosestIndex, getEffect, isCheckbox, isInput, isSelect, parseEventDefinition, readControlValue, resolveDataRegistry, resolveDetailPlaceholders, resolvePropertyName, serializeControlValue, setProperty, valuesEqual and writeControlValue no longer leave the package, together with the two types that described their returns (CompiledExpression, ParsedEventDefinition).

    They reached the surface because the families export them by name for each other and the root barrel re-exported those family barrels whole. The test applied was documented plus a dedicated subpath, which separates the set cleanly: of the eighteen candidates only withDeprecation and withTransition are documented, and they are the only two with a subpath entry of their own. Both stay.

    None of the sixteen was reachable by subpath, so no exports entry changed, and every call site imports relatively and is untouched — the type-error count holds at 52 and the suite at 3 failed / 571 passed. Both snapshots are regenerated.

Also worth noting: several *Props types the v1 barrel exported for components that survive (ClickOutsideProps, CarouselItemProps, CarouselWrapperProps, CarouselDragProps, SliderItemProps, TargetProps) no longer exist anywhere in the sources — the port simply never declared them.

Judgement calls worth challenging

  1. disclosure-group-* is my naming, not a decision I was handed. So is prefixing the whole event set of each side rather than only the two names that collided.
  2. export default was restored on all 26 remaining main components, where the decision named two. The spec names six, and stopping at six would leave the convention half-applied.
  3. damp()'s new time base is a behaviour change in LargeText, not a translation. The v3 marquee ran faster on a 120 Hz display; it no longer does. Same for CircularMarquee when it is ported.
  4. LargeText keeps its public mutable fields (x, deltaY, transform, width) exactly as v1 had them, including the transform field shadowing the imported transform() helper. That is the port being a port; the redesign is where it should change.
  5. optimizeDeps.noDiscovery is a workaround for unported packages, not a permanent setting. When ui-motion and ui-mapbox are ported it should come back off, and the up-front scan is worth having.
  6. The node project deliberately has no typescript resolve condition. Nothing in it imports a package under test, and two of its specs assert where the published exports map points — which the source condition would answer with src/ and hide.
  7. Disclosure and DisclosureGroup lost their props type parameter, deleting roughly fifteen as unknown as Disclosure casts and costing a subclass the ability to add its own props.
  8. An unmounted DisclosureGroup whose element is still in the DOM hands its children back to the outer group. v1's behaviour, preserved by the group's mounted() cleanup rather than by context resolution.
  9. Disclosure no longer reflects its open state into data-option-open. aria-expanded on the trigger is the DOM statement of open state. Markup styling on [data-option-open] after the first render would break. Confirmed by decision.
  10. Excluding ui-mapbox and ui-motion from the playground bundle is what makes docs:build pass at all. It is a workaround for two unported packages, and their stories cannot run until they are ported. Challenge it if you would rather the build stay red.
  11. The Transition group option and its example were deleted rather than reimplemented. group has no v4 equivalent and the review does not ask for one, but a reader who used it now has no documented replacement beyond the $query() pattern the FigureShopify reveal shows.
  12. The Defer error example points at https://defer.invalid/. It has to: fetch() resolves for a 404, and Defer does not check response.ok, so the previous /404 example injected the error page's markup instead of showing the error ref. The example is honest now, but the component arguably should treat a non-OK response as an error — that is a port decision, not a docs one. Defer also leaves the loading ref visible after a failure; only defer-content hides it.
  13. Twenty-one of the ported components self-register through @component() and the rest do not. Obsolete: the decorators are gone, so every component needs an explicit registerComponent() — which is also what v1 required, so it is not a v1 → v2 breaking change.
  14. ScrollReveal.repeat keeps the page-direction skip through useWindowScroll(), rather than reading which edge the element crossed off the IntersectionObserverEntry useInView() already delivers. The alternative would be local and behave better in a nested scroll root, but it is a behaviour change rather than a port.

Contradictions found

  1. The port does not cover the whole surface the review keeps. V2-COMPONENTS-REVIEW.md marks Disclosure, DisclosureGroup, ScrollReveal, CircularMarquee and LargeText as kept, and REPORT.md never ported any of them. Four of the five are migrated here; CircularMarquee is not.
  2. Slider is a full port in REPORT.md and a rewrite in the review. Resolved by decision: the port lands now, the rewrite is future work.
  3. withIndex is kept by the review and does not exist in the port. I followed the port and dropped the decoratorreversed. The review wins and the mixin is restored, because the class form cannot serve a component that already extends something else, which is the whole reason a mixin exists. It is not a compatibility layer: the behaviour now lives only in decorators/withIndex.ts, and Indexable is withIndex(Base) plus the component name, so there is one implementation rather than two copies that can drift. ./withIndex is back in the exports map, withIndex and IndexableInterface are back in the root barrel, and packages/tests/decorators/withIndex.spec.ts covers the mixin form — the port dropped the feature with no test failing, which was the real defect. V2-COMPONENTS-REVIEW.md records the resolution in round 5.
  4. REPORT.md's "proved in Vitest browser mode" is now literally true here too. The previous round's note that SliderDrag "cannot pass in this repository's runner" is obsolete — all 29 Slider tests pass, as do all 28 Disclosure tests, all 9 ScrollReveal tests and all 12 InView tests.
  5. V2-COMPONENTS-REVIEW.md asks for a generic-API redesign of LargeText. This PR ports it as it stands. The redesign is outstanding.
  6. migration-guides/1.0-2.0/ documents a v2 that no longer exists. It tells the reader to migrate ScrollAnimation to ScrollAnimationTimeline and ScrollAnimationTarget, and v2 removes the whole family in favour of @studiometa/ui-motion. Flagged on the page, not rewritten.

What remains for v2

  • Run the ported specs in a real browser (Vitest browser mode)
  • Port LargeText onto v4 — the barrel imports again
  • Regenerate the index.spec.ts and barrel-exports.spec.ts export-surface snapshots
  • Delete or rewrite packages/tests/__utils__/lifecycle.ts
  • Replace the SmoothToRecord interface copied into Cursor.ts
  • Port CircularMarquee onto v4
  • Replace Slider — round 7 supersedes it with the Carousel family rather than rewriting it onto Indexable
  • Rewrite Tabs — its 3 red spec assertions were the specification
  • Redesign Cursor, LargeText and CircularMarquee for a generic API
  • Port @studiometa/ui-motion onto v4, then turn optimizeDeps.noDiscovery back off
  • Port @studiometa/ui-mapbox onto v4 — it is what still blocked autoload/manifest.spec.ts
  • Decide on the 16 Data/Action helpers and the 8 named constants now pinned in the barrel snapshot
  • Update packages/docs for the v2 surface: drop the Frame and ScrollAnimation stories, rename the LazyInclude, AnchorScrollTo and PrefetchWhenOver pages
  • Write the v1 → v2 migration guide
  • Port @studiometa/playground onto v4, or pin it, so the docs' live examples run again — @studiometa/playground@0.4.0 shipped and the stories execute
  • Fix the twoslash renderer: hovers are empty and errors never reach the page. The cause is found — see Why js twoslash blocks are not type-checked
  • Fix lint:prettier's glob, which checks 3 of 204 markdown files
  • Update packages/eslint-plugin-ui, which still names dropped components — its list is generated from the autoload catalog now
  • Update the Twig extension and the PHP side
  • Decide whether withDeprecation earns its place with no consumer left — dropped
  • Decide on @studiometa/js-toolkit/utils barrel imports vs. per-symbol subpaths in the ported sources
  • Remove alien-signals from the @studiometa/ui dependencies — Data* no longer uses it
  • Bump to 2.0.0-alpha.0, write the changelog entries and publish under next

Round: the Carousel becomes the v2 slider

Following the design work in the proposal and recorded as round 7 in V2-COMPONENTS-REVIEW.md, which withdraws round 2's scope. Round 2 called for porting Slider's physics onto Indexable; a source audit, a twelve-library benchmark and cross-engine keyboard tests say the foundation was never the problem — the transport is. Slider's per-item transform engine causes most of its defects, and scroll-snap-type: none gives the freeform settle natively, so the one capability it uniquely bought is a CSS option rather than a second engine.

Accessibility contract

Carousel previously emitted no accessibility implementation at all. Now:

  • inert on slides that are not intersecting — not "everything but the snapped one", which hides visible slides in multi-slide layouts. Proven by mutation: the multi-slide spec fails under the wrong rule, the single-slide one does not.
  • Root role="group" with a required accessible name, and a carousel.unnamed diagnostic when it is missing. group over region deliberately: an unnamed region is dropped from the landmark list anyway, and four carousels on a listing page should not each be a landmark.
  • Slide names from a live total via a slide-label option, replacing aria-label = this.$id, which named every slide after an internal identifier.
  • aria-roledescription dropped rather than shipped untranslated.
  • Track focusability decided by a runtime probe — tabindex="0" + role + name only when the track has no focusable descendants, never tabindex="-1".
  • scroll-padding mirrored from the track, and prefers-reduced-motion observed at runtime through the shipped service.

One measured finding worth knowing: isIntersecting alone is the wrong test. An element whose box merely touches the root edge reports isIntersecting: true with intersectionRatio: 0, and touching edges is exactly how adjacent slides sit — every slide read as presented. rootMargin: '-1px' turns the touch into a miss.

Drag defects

  • A hard flick could cross many slides. __snap() now splits on a flick threshold of clamp(viewport * 0.2, 50, 225)px and clamps to exactly one snap above it, with skipSnaps as the opt-out. No magic multiplier — the projection is the drag service's own.
  • Snapping could be left permanently off. scrollSnapType was restored on a scrollend that never fires when the position does not change. Now restored immediately when nothing will scroll, with a timeout racing the listener otherwise.

The new specs fail 6 of 9 against the pre-change file.

CarouselPlay

Autoplay as a component, off by default, extending TimerProgress so no new timing code exists: timer-endgoNext(), and the per-frame ratio is the progress ring already built. What it adds is the contract — rotation control first in the tab order, flipping label, no aria-pressed, pause on hover and focus-in without auto-resume, and reduced motion suppressing only the automatic start.

Also in this round

CircularMarquee was the last v3 component and did nothing at allscrolled()/ticked() are only called when the matching service is mixed in, and it extended bare Base. Ported; the workspace is now at zero type errors.

CI had been failing at npm ci since the playground bump: a patch-package patch pinned to @studiometa/playground@0.3.13, obsolete because both hunks shipped upstream. With that cleared, CI ran the browser suite for the first time and surfaced a stale Pest test for the dropped Accordion template, and a unit_node job that never installed a browser.

State

Locally: 907 tests / 86 files / 0 failures, zero type errors, Pest green, docs build clean. On CI, 18 of 19 checks pass; unit_node fails on 3 of 907, all wall-clock-fragile specs on a runner that takes 346s where a laptop takes 40s — a reduced-motion assumption and two timer repeats. Those need hardening, not code changes.

🤖 Generated with Claude Code

https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R

Decorators removed, and the last two packages ported

Two changes landed after the documentation pass, on merged branches.

The published package was broken, and decorators were the cause. @studiometa/ui's build emitted untranspiled decorator syntax into dist/var Defer = @component({…}) class extends Base {} — so 21 modules threw SyntaxError: Invalid or unexpected token on import, in Node 24 as well as in browsers. The test suite could not see it: Vitest transforms sources with esbuild, which lowers decorators, so the shipped artifact was never exercised.

The fix is not a build flag. The ui components no longer use decorators at all: @component() ×21 → static config, @on() ×8 → the on<Event> convention or an explicit $on(), plus @provide(), @children() ×2 and @write() ×2. @rollup/plugin-swc and @swc/core are gone from packages/tests.

Two consequences worth review:

  • Registration is now the consumer's call, everywhere. @component() called registerComponent(this) internally, so those 21 components self-registered merely by being imported. None of the ~60 never-decorated components did, every spec and story registers explicitly, and registerManifest() registers the resolved class itself — so the autoloader never depended on it. "sideEffects": ["./dist/autoload.js"] already declared that no other module has side effects, which means that self-registration was droppable by any tree-shaker and never reliable.
  • Fetch.navigate, Fetch.send, Fetch.restore and CarouselWrapper.reportScroll are renamed to their convention names. A downstream subclass overriding one becomes dead code with no error.

ui-mapbox and ui-motion are ported. The four dead imports resolved as: withExtraConfig deleted (v4 merges config along the prototype chain), nextTick deleted in favour of $watchChildren() (StoreLocator was polling for a child), addClass/removeClassclassList. debounce was never gone and is untouched.

The playground bundle exclusion is reverted, and autoload/manifest.spec.ts collects and passes — it had been asserting a v3 manifest shape (token, packageName, strategy) that no v4 entry has, hidden behind its own collection failure.

Verified on the merged branch

Before this round Now
dist modules that parse 96 of 117 148 of 148
Type errors 52 5 (Tabs 3, CircularMarquee 2)
Test files 29 failed / 53 passed 1 failed / 81 passed (82)
Tests 3 failed / 571 passed (575) 3 failed / 797 passed / 1 skipped (801)
docs:build passes passes (55 entries, 239 symbols)
oxlint 28 warnings, 0 errors 27 warnings, 0 errors

226 tests were revived and all pass. The only failing file is Tabs, which waits on its rewrite.

The documentation round

Five commits, after the ports and the decorator removal.

The migration guide

migration-guides/1.0-2.0/index.md is rewritten and the danger callout is gone. It covers the six dropped families with the markup for both sides, the three class renames with their event renames, the event payload change, the removed options, subpaths and types, and the js-toolkit v4 changes that reach a consumer — linking to the v4 guide rather than restating it.

Three items the brief asked for are deliberately absent, because they are branch-internal churn a v1 reader never saw. Each was checked against main, which is 2.x, which is v1.10.0:

  1. The @component() decorator episode. git grep "@component(" main -- packages/ui/src is empty. The decorators were introduced by 8d5412fc on this branch and removed by 5727055e on this branch. v1 already required an explicit registerComponent()packages/docs/guide/usage/index.md on main documents exactly that. There is no registration breaking change, apart from registerComponent() losing its second alias/selector argument and createApp() going away, both of which the guide covers as js-toolkit changes.
  2. The sixteen internal helpers. None was ever exported from the v1 barrel: main's family index files list their exports explicitly and contain no export *. They leaked to the surface during the port and were withdrawn by 118192c4. Not a migration item.
  3. Fetch.navigate / send / restore and CarouselWrapper.reportScroll. v1 already names them onClick, onSubmit, onWindowPopstate and onScroll (main:packages/ui/src/Fetch/Fetch.ts:238,258,270 and Carousel/CarouselWrapper.ts:113). The rename is port-internal.

What the guide gained that the brief did not list: the event payload change is bigger than a rename. js-toolkit v3 built every event as new CustomEvent(name, { detail: args }) (3.9.0:packages/js-toolkit/Base/Base.ts:689), so detail was the array of positional arguments for every component — including Fetch's fetch-* and Draggable's drag-*, whose payload was already a single object and arrived as [{ … }]. Every event.detail[0] in a v1 codebase has to change. Also added: Dialog's changed waitUntil transitioner shape, the Transition group option, withTransition's dropped transition-toggle, MotionView's dropped toggle, the removed subpaths (/scheduler, /types, /AbstractSliderChild) and the removed types.

Registration guidance

Six pages showed a component imported and declared in markup with no registerComponent() call anywhere: Sentinel, InView, InViewOnce, ViewTransition, and the four Motion family pages, which had no registration or installation section at all. Three sentences were wrong rather than missing — the Draggable story claimed its class "registers itself when the module is imported", and two pages called the mapbox family "self-registering" in the same breath as telling the reader to register each component.

The eight MapboxMap / StoreLocator "self-register" occurrences are left alone: they describe a MapboxClusterItem registering with its closest MapboxCluster, which is still true.

Reference pages audited against the sources

Documented API that does not exist, now removed: DisclosureGroup.register() / unregister(), SliderBtn's contain option, SliderDrag's scrollLockThreshold option, ViewTransition's toggle event.

Wrong claims corrected: the four Disclosure events carry no payload; transition failures are $error() not $warn(); Indexable's boundaries act at both bounds; SliderCount's current ref is one-based; SliderItem has eight public members rather than none; SliderDrag is optional in the Slider anatomy; MapboxMap.map and AbstractMapboxMapChild.map are undefined until mapbox-gl resolves; isLoaded is a field; MapboxClusterItem's popup is a plain query, not a ref; and the autoloading guide pointed at the wrong option for the Mapbox access token.

One published example could not work. reference/items/Indexable/stories/slider/app.twig forwarded event.detail[0], and v4 puts the payload object on detail directly, so every renderer received undefined and the track never moved. Fixed and verified in a browser.

LargeText's frame-rate independence is documented for the first time, and its catalog entry — which described a component that scales text to fill a width — now describes the scroll-driven marquee it is.

Dialog's waitUntil is restored, on js-toolkit v4's own primitive

v1's Dialog dispatched open and close with a detail.waitUntil() function and awaited every extension a listener registered. The port replaced it with bare $emit() calls, so detail became null and every listener holding the dialog open went silently dead — with no failing test, because nothing covered the feature. Fixed: both events now go through js-toolkit v4's emitExtendable(), which is that behaviour generalised — bubbling CustomEvent, synchronous-only registration reported as protocol.late-registration, every registration awaited, a failing extension reported as callback.extendable-event-extension-failed rather than rejecting the choreography.

One contract changed on purpose. v1 duck-typed a transitionerenter() awaited on open, leave() on close. The shared primitive looks up a method named after the event (invoke(registration, event, [])), so a duck-typed object now offers open()/close(). Anything with a different pair of names registers with the function form, which is what the primitive documents for exactly this case:

// v1
event.detail.waitUntil(view);
// v2
event.detail.waitUntil(event.type === 'open' ? () => view.enter() : () => view.leave());

Thenables — the simpler half of the contract and the one most consumer code uses — are unchanged. The alternative, keeping enter()/leave() and adapting inside Dialog, is not reachable: emitExtendable() owns the waitUntil closure, so honouring a second method-name pair would mean reimplementing the primitive rather than using it.

How the extension point relates to the declared transition children. They never overlap. The events are dispatched on the dialog element and bubble upwards, so a Transition/ViewTransition child never receives them and cannot register itself twice. Both mechanisms start in the same tick inside one Promise.all, so they run concurrently — a slow extension does not delay the children and vice versa — and close() still hides the native dialog only after the last of them settles.

close() also regains v1's in-flight guard. An extension can now hold the close open indefinitely, and a second close() in that window would otherwise emit the event again, run every leave() again and release the scroll twice.

MotionView was the live casualty, and it had a second dead wiring. __onDialogPhase handed this to waitUntil(), which the event-name lookup could never call. __onDomUpdate listened for the literal dom-update while domUpdate() dispatches EVENTS.dom.updatejs-toolkit:dom:update — so nothing Fetch or DataBind announced ever reached it either. Both specs passed because they dispatched hand-made events matching the component's mistake; they now exercise the real emitExtendable() and domUpdate(), plus the native close event a <dialog> fires under the same name.

Dialog/js-api.md, the migration guide and the four dependent pages describe the restored behaviour. The Motion with-dialog story hands each animation to waitUntil() — with a ?. guard, because the platform's own close event shares the name and carries no detail — so its exit is no longer cut off. MotionView's ambient-dialog story runs.

Why js twoslash blocks are not type-checked

The cause is two missing compiler options. Twoslash runs the block as index.js, and TypeScript does not report diagnostics in a .js file unless allowJs and checkJs are on. .vitepress/config.ts sets neither, so a block importing a symbol @studiometa/ui does not export builds clean. With both enabled, the same block throws [2305] Module '"@studiometa/ui"' has no exported member.

All 21 blocks already pass with checkJs on, so turning it on in the config is a one-line change that would make docs:build actually verify the samples. Not made here — it is a tooling change, and this branch is already long.

Verified in a browser

ddev start against the built output, with @studiometa/playground@0.4.0. Pages loaded, and what the console said:

Page Result
/migration-guides/1.0-2.0/ Renders, all 29 headings, all 16 internal links resolve, no console output
Disclosure/examples Clicking a trigger: disclosure-open detail=null, disclosure-group-open detail keys item,index, disclosure-group-change keys items, single-open honoured — the guide's payload table, confirmed at runtime
Indexable/examples The fixed slider story works: Next moves the track to translateX(-100%), counter reads Slide 2 of 4
Slider/examples role="group" + aria-roledescription="carousel" on the root, tabindex="0" on the wrapper, SliderDrag on the wrapper, prev <button> disabled at index 0; from the wrapper moves 1 / 102 / 10 and the current ref 12
Dialog/examples open and close both arrive with detail === null — no waitUntil (measured before the fix; see the re-run below)
Motion/examples The rewired dialog story plays its entrance in full (opacity: 01, transform reset); 60 ms after close the dialog is shut and the reverse has barely moved
MotionView/examples The ambient-dialog story opens the dialog with both MotionView elements still opacity-0 — the wiring never fires
Slider/js-api/slider, Sentinel/ Render, headings correct

Zero console errors, zero warnings and zero page errors across every page loaded. The only console output anywhere is the playground's own updating style… / script updated! log chatter.

Internal links were also crawled on nine of the changed pages (Disclosure/js-api, Indexable/js-api, LargeText/js-api, ScrollReveal/js-api, Slider/anatomy, MapboxMap/js-api, MotionView/js-api, Dialog/js-api, guide/autoloading) — all resolve.

Numbers

Result
npm run docs:build Passes. validate-reference: 55 Reference entries, 239 symbols, 5 concepts. ~29 s
Twoslash, run directly 21 blocks, 0 failed — with allowJs/checkJs on, which the config does not set
prettier --check on the 44 changed files Passes. The one error is the pre-existing Twig parse failure on Indexable/stories/slider/app.twig, unchanged by this round
npm run test 3 failed / 811 passed / 1 skipped (815), 1 failing file of 82 — the same three Tabs failures as the baseline, run three times with identical results. The waitUntil work adds 14 passing tests
npm run lint:types 5 errors — the baseline, all in the deferred Tabs (3) and CircularMarquee (2)
npm run lint:static 27 warnings, 0 errors — the baseline

Still open

  • No story on the docs site executes. Fixed by @studiometa/playground@0.4.0.
  • Fetch.ts:81 constructs new DOMParser() at module scope — fixed; it is built on first use, and all 114 dist modules import under Node.
  • MapboxCluster keeps its v3 register()/unregister() handshake rather than watching its items, because both are documented public API.
  • MotionSequence and MotionScrollTimeline read children inside mounted() and await whenDOMSettled(), which does not wait for visibility strategies.

Round 7 and 8: the Carousel absorbs the Slider, and the CSS decides

Round 7 is built and round 8 records what building it disproved. V2-COMPONENTS-REVIEW.md carries both.

Built

Slider's seven classes are deleted. The Carousel family gains the four controls it lacked — CarouselDots, CarouselThumbnails, CarouselCount, CarouselProgress — each on its own element, each optional, none importing Carousel. All eight children are registered by Carousel, so registerComponent(Carousel) is the whole setup: config.components entries resolve per element, so a carousel with no play button never constructs one.

Three round-7 claims the work disproved

  • The alignment is the slide's own CSS. Round 7 treated Slider's mode: left|center|right as equivalent to scroll alignment. It was not: Carousel hardcoded align: center, so a programmatic move and a native snap only agreed where a slide filled the track. Carousel.positions reads each slide's scroll-snap-align now and scrolls to the offset that alignment names. mode="left" is scroll-snap-align: start, mode="right" is end. No option was added — an align option would be a second place to say what the CSS already says.
  • The one-slide flick clamp is removed. Round 7 called an unclamped flick a defect. Crossing many slides on one hard throw is the inertia, and it is what v1 had; a clamp turns a throw into a step. Gone, and with it the flick threshold and the skipSnaps option that existed only to opt out of it.
  • fitBounds: false is restored, as CSS. scroll-snap-type: none frees the scroll, but __snap() used to scroll to the nearest slide regardless. It reads the track's declaration now and coasts to the projected position instead. Again no option: the track already says it.

Two defects with passing tests over them

  • The current-slide marker flickered on every click. CarouselWrapper.onScroll reported the closest slide on every scroll frame, including the frames of the smooth scroll goTo() had just started, so clicking a dot three slides away drove the index 3 → 0 → 2 → 3. Traced frame by frame in a browser. Reporting is held for a programmatic scroll now. The existing spec had written the bug down as intended behaviour: "the smooth scroll reports the slide it is passing on the way, so the marker settles rather than jumps."
  • __restoreSnapping stripped inline scroll-snap-type declarations. It restored snapping by clearing the inline value, which is right only for a track styled from a stylesheet — on one styled inline, as every spec fixture is, it deleted the author's declaration and the first drag turned snapping off for the life of the page. The specs asserted the cleared value and called it "restored".

The drag settle keeps the pointer's velocity

scrollTo({ behavior: 'smooth' }) eases in from a standstill. Measured on the docs example, releasing from a steady 60 px/frame drag, the track moved 0 px on the first frame, 1.9 on the second, 10 on the third, and took five frames to reach the speed the hand already had.

The settle is animated by the component now, decaying at the drag service's own rate — the model the service used to project the throw in the first place — so the track leaves the pointer at 76% of its velocity and slows monotonically. Two consequences: the scrollend-racing-a-timeout restore is gone, because the component knows when its own animation ends; and the settle position is carried in a field rather than read back from scrollLeft, which a scroller reports rounded, so the sub-pixel tail used to stall three pixels short of the target for ever.

One marker for a picker, one source for the lint list

A numeric CarouselBtn marked the current slide aria-disabled while CarouselDots and CarouselThumbnails marked it aria-current — three pickers, one job, two CSS hooks. They all use aria-current now; prev/next keep the native disabled, which names an action that genuinely cannot be performed.

eslint-plugin-ui's UI_COMPONENT_NAMES was hand-maintained under a comment asking the next person to keep it in sync. It had drifted both ways — still naming Accordion, Frame*, LazyInclude and Modal, never having learned Carousel, Dialog, Fetch, Timer, Toaster or Track. It is generated from the autoload catalog now and manifest:check fails when it is stale. That gap was load-bearing in the plugin's own test: class Carousel extends Base {} was the fixture's valid case, passing because the rule could not see the component.

Examples

Two variants had prose but nothing runnable, and both are behaviours declared in CSS and read by the component, so an example is the only way to show that the CSS is the whole configuration. Verified in a browser: alignment stops the same index at 322/643/965 for start, 177/498/820 for center and 32/354/675 for end; free scrolling lands a drag at 803px on a 177px slide pitch, between slides. The four carousels that were tripping the component's own carousel.unnamed warning in our own docs are named.

State

Result
npm run test 940 passed, 0 failed (86 files)
npm run lint:types 0 errors
npm run docs:build passes
npm run manifest:check passes, and now covers the lint plugin's list
dist modules that import under Node 114 of 114, barrel included
CI 19 of 19 checks

Known trade-off

Native scroll clips at the padding box, so slides can no longer overflow the track's bounds the way Slider's transform engine allowed. A full-bleed track with gutter-width padding recovers the effect toward the page edges; overflow over adjacent content is not reachable with a scroll container. Accepted.

titouanmathis and others added 4 commits August 26, 2026 22:06
Record the full inventory of the v2 public surface with the per-component
keep, drop, rename and rewrite decisions. This document is the plan of
record for the ui v2 major version and outranks the js-toolkit migration
port wherever the two disagree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Move every package that depends on @studiometa/js-toolkit from ^3.9.0 to
^4.0.0-alpha.0, and lift the root override with them. v2 of the ui
packages is built on v4, so the whole workspace has to resolve one major
version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Replace the v1 TypeScript sources of 23 component families with the port
written and proved against js-toolkit v4 in the js-toolkit repository, and
drop the families the v2 review drops.

Ported: Action, AnchorNav, Carousel, ClickOutside, Cursor, Data, Dialog,
Draggable, Fetch, Figure, FigureVideo, Hoverable, InView, Indexable, Menu,
Prefetch, Sentinel, Sticky, Timer, Toaster, Track, Transition and
ViewTransition, plus the withTransition mixin the port brought back.

Renamed, per the review: LazyInclude to Defer (directory, class, config
name and its three events, now defer-content, defer-error and
defer-always), AnchorScrollTo to ScrollTo, PrefetchWhenOver to
PrefetchOnInteraction.

Dropped: Accordion (superseded by Disclosure and DisclosureGroup), Frame
(superseded by Fetch), Modal and Panel (superseded by Dialog),
ScrollAnimation with animationScrollWithEase and withScrollAnimationDebug
(superseded by ui-motion), the ImageGrid and Reinsurance templates, the
StyledModal and StyledPanel templates, and the withIndex decorator, whose
whole body is now the Indexable class.

Convert every ported `#private` field and method to the `__` prefix the ui
conventions require, with an `@private` or `@protected` docblock on methods
and getters, and put back the `__abortController`, `__appendSections` and
`__snap` names the port had turned into `protected` keywords.

Rebuild the catalog, the generated manifest and the package exports map
around the v2 surface. A v4 manifest entry is `{ mountStrategy, load }`;
the v3 token, packageName, subpath, exportName, group, children, styles and
integrations fields had no runtime consumer and stay in the authoring
catalog.

Keep the Twig templates of every family the port touches. None of them
needed a rename: their refs (img, video, inner), their options (lazy,
transform, domain, path) and their nested Sentinel all survive the port
unchanged.

Slider is deliberately left on its v1 source: the review calls for a
rewrite onto the Indexable foundation, which is separate work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The port keeps its specs beside its sources; in ui they live in
packages/tests, so move all 37 of them there and resolve their imports
through the `#private/*` alias. Delete the v1 specs of the families the
port replaces, and the spec directories of the families v2 drops:
Accordion, Frame, Modal and ScrollAnimation. The v1 Indexable spec goes
with them, because withIndex is gone and the port ships no replacement.

Two harness changes the port forces:

Stage-3 decorators. The ported components declare `@component` and `@on`,
which Oxc, Vite's TypeScript transformer, does not lower, so every file
holding one failed to parse. Compile them with SWC first, behind a filter
that keeps the transform off files with no decorator in them. This is the
same plugin js-toolkit uses for its own suite.

Per-test cleanup. The setup file destroyed every mounted instance through
v3's page-wide `getInstances()`, which v4 removed: instances live on their
element and nothing collects them. Empty the document with `resetDom()`
from `@studiometa/js-toolkit/test` instead, which lets the shared mutation
observer dispose every controller it built.

Point the export-surface specs at families v2 keeps, and read the merged
transition config through `$options` rather than js-toolkit's unpublished
`resolveConfig()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

Code Review

Risk: Medium — issues that should be addressed before merge.

This change ports the UI component families to js-toolkit v4, rewrites Tabs and Carousel behavior, updates documentation and redirects, and replaces the test environment with Vitest browser mode. I reviewed packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Fetch/Fetch.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Disclosure/Disclosure.ts; I did not open the other changed files. I also did not open any of the files listed under .code-review-skipped.

1 issue found:

  • issuepackages/ui/src/Fetch/Fetch.ts:405 — Pass the defined response parser argument

Notes:

  • The remaining changed files and all files listed under .code-review-skipped were not opened and are outside this review.

Still open from earlier reviews (9 findings):

  • packages/ui/src/Menu/MenuList.ts:109issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:83issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:174issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:52issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:403issue (blocking): Pass a defined self binding to response expressions
  • README.md:30issue: README still registers removed components

Review usage: 806,775 in (621,445 cached) / 1,525 out tokens — $0.1515 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 300df9a.

Previous review runs

Previous run archived 2026-09-02T19:41:56Z

Code Review

Risk: Low — No concrete defects were identified in the reviewed source files; the reviewed changes are safe to merge aside from the unreviewed files noted below.

The reviewed sources migrate Dialog, Tabs, Carousel, CarouselWrapper, and CarouselDrag to the js-toolkit v4 APIs, adding accessibility behavior, extendable dialog events, scroll settling, and carousel state coordination. I opened and reviewed packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Dialog/Dialog.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Carousel/Carousel.ts.

I did not open any other changed files, including all files listed under <skipped_files> and the remaining changed files not listed above.

Still open from earlier reviews (9 findings):

  • packages/ui/src/Menu/MenuList.ts:109issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:83issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:174issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:52issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:403issue (blocking): Pass a defined self binding to response expressions
  • README.md:30issue: README still registers removed components

Review usage: 124,729 in (58,580 cached) / 840 out tokens — $0.0468 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit c7fd36a.

Previous run archived 2026-09-02T13:20:47Z

Code Review

Risk: High — blocking issues remain — resolve them before merge.

This change ports the component families to js-toolkit v4, rewrites Tabs, consolidates Carousel controls, updates the browser-based test setup, and refreshes related runtime and documentation surfaces. I reviewed the staged diffs for packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Dialog/Dialog.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Fetch/Fetch.ts. I did not open the other staged diffs, including the files listed under .code-review-skipped, so those changes remain unreviewed.

1 issue found:

  • issue (blocking)packages/ui/src/Dialog/Dialog.ts:282 — Unmount cleanup leaves the non-modal focus trap active

Notes:

  • The staged diff contains many additional changed files that were not opened during this review, including source, tests, configuration, generated files, and documentation files listed in the provided skipped-files inventory.

Still open from earlier reviews (9 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:408issue (blocking): Pass a defined self binding to response expressions
  • README.md:30issue: README still registers removed components

Review usage: 613,286 in (489,601 cached) / 2,234 out tokens — $0.1085 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 41680f2.

Previous run archived 2026-09-02T12:53:52Z

Code Review

Risk: Low — No demonstrable defect was identified in the files reviewed; the reviewed changes appear safe to merge, subject to completing review of the skipped files.

The change migrates the component packages to js-toolkit v4, rewrites Tabs, expands Carousel accessibility and controls, and updates the related runtime behavior. The reviewed Carousel, Tabs, and Fetch source diffs consistently implement the described v2 APIs and lifecycle changes.

Notes:

  • Reviewed: packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Carousel/Carousel.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Fetch/Fetch.ts.
  • Not opened: all other changed files, including the remaining component sources, tests, documentation, generated catalogs/manifests, package/configuration files, workflow changes, and deleted-file diffs listed under .code-review-skipped/.

Still open from earlier reviews (9 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:408issue (blocking): Pass a defined self binding to response expressions
  • README.md:30issue: README still registers removed components

Review usage: 848,962 in (765,938 cached) / 2,047 out tokens — $0.0963 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 3d89de3.

Previous run archived 2026-09-02T12:18:35Z

Code Review

Risk: Low — No demonstrable blocking defect was identified in the files opened for review; the merge is safe from the reviewed code, aside from the unreviewed files listed below.

The change ports the component families to js-toolkit v4, rewrites Tabs, adds Carousel accessibility and controls, migrates the auxiliary packages, and moves the test suite to browser-mode Vitest. I reviewed these staged diffs: packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Carousel/Carousel.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Dialog/Dialog.ts.

Notes:

  • The remaining changed files were not opened: all other files listed under .code-review-skipped/, including the other component sources, tests, configuration, generated manifests, documentation, package metadata, workflow files, and PHP files. No inline findings are provided for files that were not opened.

Still open from earlier reviews (9 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:408issue (blocking): Pass a defined self binding to response expressions
  • README.md:30issue: README still registers removed components

Review usage: 805,800 in (737,497 cached) / 1,848 out tokens — $0.0851 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 0e8c6d5.

Previous run archived 2026-09-02T10:12:16Z

Code Review

Risk: Medium — issues that should be addressed before merge.

The MR ports the UI component families to js-toolkit v4, rewrites Tabs, consolidates Slider into Carousel, adds Carousel controls and accessibility behavior, updates Mapbox and Motion integrations, and moves the browser test suite to Vitest with Playwright Chromium. I reviewed packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Dialog/Dialog.ts; I did not open the other changed source files, generated files, tests, configuration files, or documentation files. The skipped-file diffs listed in the request were not opened individually.

1 issue found:

  • issueREADME.md:30 — README still registers removed components

Notes:

  • README.md:30 — the skipped diff still imports and registers Frame and ScrollAnimation, although this MR removes both families; the file was not opened because it was listed in the skipped files.
  • The review is incomplete for the many changed files that were not opened, including the skipped source, test, configuration, generated, and documentation diffs.

Still open from earlier reviews (8 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:408issue (blocking): Pass a defined self binding to response expressions

Review usage: 2,008,204 in (1,639,465 cached) / 1,876 out tokens — $0.3181 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit f32774a.

Previous run archived 2026-09-02T09:55:06Z

Code Review

Risk: Low — No concrete blocking or wrong-behaviour defect was identified in the reviewed source changes; the reviewed subset is safe to merge aside from the unreviewed files.

The change migrates the component families and supporting packages to js-toolkit v4, replaces the test runner with browser-mode Vitest, rewrites Tabs, adds Carousel accessibility and controls, and removes the Slider family. I reviewed packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/Carousel.ts, packages/ui/src/Carousel/CarouselPlay.ts, packages/ui/src/Dialog/Dialog.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Fetch/Fetch.ts diffs. I did not open the remaining staged diff files, including the other component sources, tests, configuration, generated files, and documentation files listed in the supplied skipped-files list.

The intent's promised v2 port, browser test runner, Carousel additions, Slider removal, and related documentation updates are represented in the supplied commit history; no separate omission is noted.

Still open from earlier reviews (8 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof
  • packages/ui/src/Fetch/Fetch.ts:408issue (blocking): Pass a defined self binding to response expressions

Review usage: 629,151 in (555,629 cached) / 1,677 out tokens — $0.0788 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 89442f9.

Previous run archived 2026-09-02T09:25:37Z

Code Review

Risk: High — blocking issues remain — resolve them before merge.

This change ports the component families to js-toolkit v4, introduces the Carousel controls and accessibility behaviour, rewrites Tabs, and updates Fetch, Disclosure, and related integrations. I reviewed packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/Carousel.ts, packages/ui/src/Carousel/utils.ts, packages/ui/src/Carousel/CarouselItem.ts, packages/ui/src/Carousel/CarouselPlay.ts, packages/ui/src/Tabs/Tabs.ts, packages/ui/src/Fetch/Fetch.ts, and packages/ui/src/Disclosure/Disclosure.ts. I did not open the remaining changed files, including the skipped files listed in the request, so those parts were not reviewed.

1 issue found:

  • issue (blocking)packages/ui/src/Fetch/Fetch.ts:408 — Pass a defined self binding to response expressions

Still open from earlier reviews (7 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof

Review usage: 1,006,937 in (876,400 cached) / 2,173 out tokens — $0.1319 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 71e852b.

Previous run archived 2026-09-02T08:41:55Z

Code Review

Risk: Low — No demonstrable blocking defect was identified in the files opened for review; the merge is safe from this review scope, but the unreviewed files require separate coverage.

The change ports the component families to js-toolkit v4, replaces the test environment with Vitest browser mode, adds Carousel accessibility and controls, rewrites Tabs, and updates the documentation and migration guidance. The Carousel wrapper now suppresses intermediate index updates during programmatic scrolling while keeping progress live.

Notes:

  • Reviewed: packages/ui/src/Carousel/CarouselWrapper.ts, packages/ui/src/Carousel/CarouselDrag.ts (via targeted search), packages/ui/src/Carousel/Carousel.ts (via targeted search), packages/ui/src/Fetch/Fetch.ts (via targeted search), .github/workflows/tests.yml, package-lock.json, package.json, packages/docs/.vitepress/config.ts, packages/docs/.vitepress/reference/catalog.ts, packages/docs/.vitepress/reference/public-contracts.ts, packages/docs/guide/autoloading/index.md, packages/docs/guide/concepts/composition.md, packages/docs/guide/concepts/declarative-runtime.md, packages/docs/guide/concepts/index.md, packages/docs/guide/concepts/packages-and-surfaces.md, packages/docs/guide/concepts/templates-and-customization.md, packages/docs/guide/contributing/index.md, packages/docs/guide/eslint-plugin/index.md, packages/docs/guide/usage/index.md, packages/docs/migration-guides/0.1.0-0.2.0/index.md, packages/docs/migration-guides/1.0-2.0/index.md, packages/docs/migration-guides/vue-mapbox-gl/index.md, and packages/docs/reference/helpers/index.md.
  • Not opened: all other changed files, including most component sources, tests, generated manifests, package metadata under packages/*, remaining documentation pages, Composer files, and skipped files. The supplied skipped files were not reviewed, including .github/workflows/tests.yml's surrounding workflow context, README.md, V2-COMPONENTS-REVIEW.md, package manifests, most packages/ui, packages/ui-mapbox, packages/ui-motion, and packages/tests files.

Still open from earlier reviews (7 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning
  • packages/ui/src/Fetch/Fetch.ts:57issue: Guard the Headers global before using instanceof

Review usage: 1,853,021 in (1,635,645 cached) / 1,702 out tokens — $0.2227 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 1dee2cb.

Previous run archived 2026-09-02T08:17:10Z

Code Review

Risk: Medium — issues that should be addressed before merge.

Reviewed the following changed files: packages/ui/src/Carousel/CarouselPlay.ts, packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/Carousel.ts, packages/ui/src/Tabs/Tabs.ts, and packages/ui/src/Fetch/Fetch.ts. I did not open the other changed files or any of the files listed as skipped, so those areas remain unreviewed.

1 issue found:

  • issuepackages/ui/src/Fetch/Fetch.ts:57 — Guard the Headers global before using instanceof

Still open from earlier reviews (6 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:67issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:224issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning

Review usage: 340,790 in (237,970 cached) / 1,060 out tokens — $0.0793 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 166e1ed.

Previous run archived 2026-09-01T20:37:16Z

Code Review

Risk: Low — No demonstrable blocking defects were found in the reviewed source files; the reviewed changes appear safe to merge aside from the unreviewed files noted below.

The change ports the component families to js-toolkit v4, adds the Tabs rewrite and Carousel accessibility/autoplay behavior, updates the test runner to Chromium browser mode, and refreshes the documentation and package metadata. I reviewed packages/ui/src/Tabs/Tabs.ts, packages/ui/src/Carousel/Carousel.ts, packages/ui/src/Carousel/CarouselDrag.ts, packages/ui/src/Carousel/CarouselPlay.ts, packages/ui/src/Indexable/Indexable.ts, packages/ui/src/decorators/withIndex.ts, and packages/ui/src/Timer/Timer.ts. I did not open the remaining changed files, including the files listed in .code-review-skipped/; those files were not reviewed.

Still open from earlier reviews (6 findings):

  • packages/ui/src/Menu/MenuList.ts:113issue: Iterate the child list collection items
  • packages/ui/src/manifest.ts:47issue (blocking): Remove the unported CircularMarquee from the v4 manifest
  • packages/ui/src/manifest.ts:232issue: Remove the unported Tabs from the v4 manifest
  • packages/ui/src/Defer/Defer.ts:87issue: Reject non-2xx responses before injecting content
  • packages/ui/src/Dialog/Dialog.ts:176issue: Release focus trapping when an open dialog unmounts
  • packages/ui/src/Tabs/Tabs.twig:40issue: Preserve the first selected tab during template scanning

Review usage: 1,369,251 in (1,259,122 cached) / 2,146 out tokens — $0.1382 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 4dd343c.

for (const menuList of this.$children.MenuList) {
menuList.close();
for (const list of this.__lists) {
list.close();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Iterate the child list collection items

this.__lists is a ChildrenCollection, whose instances are accessed through .items elsewhere in this file (transitionChildren.items and viewTransitionChildren.items). Iterating the collection object directly causes nested-list closing to fail at runtime instead of closing each child. Iterate over this.__lists.items.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 1d2958a.

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.32%. Comparing base (c939977) to head (2d5253b).

Additional details and impacted files
@@             Coverage Diff              @@
##                2.x     #642      +/-   ##
============================================
- Coverage     87.80%   86.32%   -1.48%     
  Complexity      145      145              
============================================
  Files           153       20     -133     
  Lines          5443      746    -4697     
  Branches       1037       88     -949     
============================================
- Hits           4779      644    -4135     
+ Misses          580       95     -485     
+ Partials         84        7      -77     
Flag Coverage Δ
unittests 86.32% <ø> (-1.48%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.
see 136 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

titouanmathis and others added 23 commits August 27, 2026 00:45
Land the seven-class port proved in `studiometa/js-toolkit`
(`packages/js-toolkit/migration/Slider/`), replacing the v1 source.

`AbstractSliderChild` is deleted: its whole job was finding the parent
Slider and subscribing to its store, retried from `mounted()`, `resized()`
and `updated()` because none was reliable alone. One `SliderContext`
carrying `{ state, goTo, goNext, goPrev }` replaces all of it, so no
control imports the `Slider` class. `config.emits` becomes `$emits` in the
props type, `$children` becomes `$watchChildren`, and `goTo()` clamps
instead of throwing now the slide collection is live.

The rewrite onto `Indexable` that `V2-COMPONENTS-REVIEW.md` calls for is
deliberately deferred, not overlooked: this lands the working port now and
leaves the rewrite as separate work. The ported `Slider` does not touch
`Indexable` at all.

The `#`-private fields the port used become `__`-prefixed members with
`@private` docblocks, per this repository's conventions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
No port existed for this family, so this is the migration itself.

The group/child handshake is gone. v3 answered "which group owns me?" with
`$closest('DisclosureGroup:mounted')` and, because nothing announced a
mount, backed it with a two-way document `CustomEvent` handshake and a
document-wide `MutationObserver` re-running the lookup after any DOM
change. `DisclosureGroup` now holds a live `$watchChildren()` collection
and claims the disclosures below it; a disclosure refuses a claim from a
group further away than its current one, and falls back to the nearest
still-mounted group when its own unmounts. Nesting, mounting in either
order, DOM moves and group teardown all follow from that, with no listener
on `document` and no observer.

`enable()`/`disable()` wrote to `$options` in v3 — the one genuine
reconfiguration in the whole of `@studiometa/ui`, per the port's report.
They now write the presence-only attribute the option reads, and
`optionDisabledChanged()` replaces `updated()` for markup-driven changes.
The other three `$options` writes were state in the wrong place and are
the `isOpen` field.

`config.emits` becomes `$emits`: the disclosure's four events carry no
payload, since v4 hands the emitter over as the event target, and the
group's three carry the item and index v3 passed as extra arguments.
`destroyed()` becomes the cleanup `mounted()` returns, and `nextTick()`
becomes the scheduler's background lane.

The spec is rewritten against the v4 lifecycle: `$destroy()` and
`$update()` no longer exist, so it registers components and drives real
DOM instead of constructing instances by hand.

`reportError()` is polyfilled in the happy-dom setup. It is a platform
global neither Node nor happy-dom provides, js-toolkit's diagnostic
channel calls it as its default error sink, and `$error()` therefore threw
a `ReferenceError` that poisoned the rest of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
No port existed for this family either.

v3 built it on `withMountWhenInView`, and v4 has no decorator to replace.
Its two successors each answer half of what this needs: `mountStrategy:
'visible'` mounts once and never unmounts, `'in-view'` mounts and unmounts
on every crossing — and the `repeat` option chooses between exactly those
two behaviours at runtime, from an attribute, which a static declaration
cannot express. So the component mounts normally and subscribes to
`useInView()`, the core service the strategies are themselves built on.
That also keeps `intersectionObserver` meaningful as the observer's init
rather than a `rootMargin` smuggled into a `data-mount` suffix.

`$terminate()` has no v4 equivalent and needs none: the one-shot guard is a
field, which survives the unmount/mount pair a DOM move now is. The `static
scrollDirectionY` fed by a page-wide `useScroll()` callback registered
under a fixed key and never removed is a per-instance subscription: a v4
service is shared and lazy, and this instance now lives as long as its
element.

`ScrollReveal extends Transition` becomes `withTransition(Base)`, so it no
longer registers under a parent's identity, and the family gets its first
spec.

Two more happy-dom gaps are papered over in the test setup, both of which
break `useScroll()`/`useWindowScroll()` for every family, not just this
one: `window instanceof Window` is false under the global registrator, so
the service cannot tell a window target from an element one, and
`HTMLCollection` has no `Symbol.iterator`, which WebIDL gives it in every
browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Bump `@studiometa/js-toolkit` to `4.0.0-alpha.1` in every package that
declares it and in the workspace override, then refresh the lockfile.
The installed version is checked rather than inferred from the range.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`4.0.0-alpha.1` exports `SmoothToRecord` from
`@studiometa/js-toolkit/utils`, so the interface copied into `Cursor.ts`
against `4.0.0-alpha.0` is deleted along with the comment explaining it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Replace happy-dom with Vitest browser mode on Playwright Chromium, the
runner `@studiometa/js-toolkit` proves its own suite with. The components
ask the platform for `IntersectionObserver`, layout geometry, computed
styles and `document.location`, and js-toolkit is entitled to the browser
globals it uses — `reportError()` among them. A DOM emulation answers none
of that, and 167 of the 169 failures were the emulation rather than the
port.

The suite is two projects now. `chromium` runs everything that touches the
DOM. `node` runs the four export-surface specs that read the repository
instead — the TypeScript compiler API, `node:fs`, `node:child_process` —
and cannot run in a page.

The root moves from `packages/` to this package. That is what hung the
previous attempt: `packages/` is harmless as a Node root, but as a Vite
server root it makes the dev server crawl the whole monorepo and the run
never reaches the first test. Dependency discovery is off for the same
class of reason — `ui-motion`, `ui-mapbox` and `Tabs` still import subpaths
v4 removed, and the up-front scan turned one dead subpath into a failure
of the whole run instead of the one spec that owns it.

The three shims that papered over happy-dom breaking platform invariants
(`window instanceof Window`, `HTMLCollection[Symbol.iterator]`,
`reportError`) are gone with the dependency, and so are the `scrollY` and
`requestAnimationFrame` patches beside them. `resetDom()` from
`@studiometa/js-toolkit/test` is the whole teardown.

The helpers a real browser makes pointless go too: `lifecycle.ts` (no
consumer left, and its `destroy()` called the v3-only `$destroy()`),
`resizeWindow.ts` (assigns to the read-only `window.innerWidth`),
`matchMedia.ts` (clobbered a real global for every file importing the
barrel), `mockImageLoad.ts`, `mockVideoLoad.ts`, `faketimers.ts` and
`components.ts` — none had a consumer.

Three specs the new runner exposed are fixed rather than skipped:

- `Disclosure` reports its recovered transition failure on the diagnostic
  channel, whose default sink is `reportError()`. In a browser that becomes
  a global error the runner flags. The spec captures the channel with
  `captureDiagnostics()`, which both asserts the code and suppresses the
  sink.
- The two `dependencies.spec.ts` files reloaded their module singleton with
  `vi.resetModules()`, which cannot clear the page's own module registry.
  A distinct query string is a genuinely distinct module, and still goes
  through Vite's transform so `vi.mock()` still applies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`Disclosure` and `DisclosureGroup` both emitted a bare `open` and `close`,
and v4's `$emit()` bubbles, so a listener on the group's element heard its
children too — telling them apart meant reading `event.target` or the shape
of the payload. Both sides take the family prefix `Defer` (`defer-*`) and
`Fetch` (`fetch-*`) already set:

- `Disclosure` emits `disclosure-open`, `disclosure-close`,
  `disclosure-after-open` and `disclosure-after-close`.
- `DisclosureGroup` emits `disclosure-group-open`,
  `disclosure-group-close` and `disclosure-group-change`.

The whole event set of each side is renamed, not only the two that
collided: a half-namespaced family reads as an oversight, and `after-open`
beside `disclosure-open` is the same event vocabulary spelled two ways.
`Disclosure.twig` writes no event name, so it needs no edit; nothing else
in the workspace listens for these.

`export default` comes back on every family's main component. The port had
dropped it across the board, and `subpath-exports.spec.ts` is right to
assert it: `@studiometa/ui/<Component>` has always exposed the main
component both ways. Family members and sub-components keep only their
named export, which is the distinction that spec draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`LargeText` was the last import keeping the `@studiometa/ui` barrel from
loading: it reached for `./withMountWhenInView`, which v4 replaced with the
`data-mount` strategies. `withMountWhenInView(Base, { rootMargin: '50%' })`
is the string `in-view:50%` — the registry owns the observer before any
instance exists, and `data-mount` overrides the margin per element, which v3
could only do by declaring a second class.

The three services v3 bound by the mere presence of `resized()`,
`scrolled()` and `ticked()` are named as three mixins. Two v4 signature
changes are carried rather than worked around: `damp()` takes the elapsed
milliseconds the frame service already delivers, so the same `0.25` factor
now means the same speed on every display; and `transform()` is pure, so the
style write is scheduled explicitly instead of happening inside it. The
template writes no ref, option or event that moved, so it is unchanged. The
generic-API redesign the review asks for is **not** in this commit.

The family gets its first spec: six cases covering the mount strategy, the
measured loop distance, the reversal, the wrap and the optional skew. It
only means anything in a browser, because the loop distance is
`clientWidth`.

With the barrel loading, the four specs that waited on it are unblocked, and
three of them turned out to need splitting. Importing `@studiometa/ui`
registers every component, and registration needs a `MutationObserver`, so a
spec that loads the barrel cannot run under Node at all — while the same
files also asserted `import.meta.resolve`, `node:fs` and a generator run.
The repository half of each moves to its own `*-resolution` /
`*-freshness` file in the Node project; the identity half stays in the
browser. `'$isBase' in X` becomes `X.prototype instanceof Base`, because
v4's brand is a private symbol.

Both export-surface snapshots are regenerated. Every removal is a documented
v2 drop or rename; the additions are the new families, contexts and named
constants — plus sixteen `Data`/`Action` helpers the port exports by name,
which the snapshot now pins for review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The v2 barrel snapshot pinned 16 helpers as public API that no consumer
asks for: the `Data` form-control internals (`isCheckbox`, `setProperty`,
`valuesEqual`, `readControlValue`, `writeControlValue`,
`serializeControlValue`, `resolvePropertyName`, `isInput`, `isSelect`),
the two expression compilers (`getCallback`, `compileExpression`), the
event-definition parser, and `getEffect`, `getClosestIndex`,
`resolveDetailPlaceholders` and `resolveDataRegistry`.

They reached the surface because the families export them by name for
each other, and the root barrel re-exported those family barrels whole.

The test for public is documentation plus a dedicated subpath, and it
separates the set cleanly: only `withDeprecation` and `withTransition`
are documented, and they are the only two of the eighteen candidates with
a subpath entry of their own. Both stay. So do the eight named constants
(`FETCH_EVENTS`, `HEADER_NAMES`, `INDEXABLE_BOUNDARIES`, …), which exist
so a no-build-step consumer writes `INDEXABLE_BOUNDARIES.loop` instead of
a bare string.

`CompiledExpression` and `ParsedEventDefinition` go too. Each described
the return of a function that is no longer exported, so keeping them left
a type nothing public could produce.

Nothing was reachable by subpath, so no `exports` entry changes. Every
call site imports relatively and is untouched: the type-error count holds
at 52, all of it in the unported `ui-mapbox`, `ui-motion`, `Tabs` and
`CircularMarquee`, and the suite holds at 3 failed / 571 passed — the
three being `Tabs`, which waits on its rewrite.

Both snapshots are regenerated: `index.spec.ts` loses the 16 runtime
values, and `barrel-exports.spec.ts` those plus the two types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
**Removed.** `Accordion`, `AccordionItem`, the whole `Frame` family, `Modal`,
`ModalWithTransition`, `StyledModal`, `Panel`, `StyledPanel`, the
`ScrollAnimation` family with `animationScrollWithEase` and
`withScrollAnimationDebug`, `ImageGrid`, `Reinsurance`, `withIndex` and
`AbstractSliderChild` are gone from the package, so their Reference items,
stories and catalog entries go with them.

`viewTransition` follows them for a different reason: core absorbed it, so
`@studiometa/ui/scheduler` no longer exists and the helper is imported from
`@studiometa/js-toolkit`. It was the only documented helper, so the sidebar
stops offering an empty group while the overview page keeps its URL and
explains the absence.

**Renamed.** `LazyInclude` → `Defer` with its `defer-content` /
`defer-error` / `defer-always` events, `AnchorScrollTo` → `ScrollTo`, and
`PrefetchWhenOver` → `PrefetchOnInteraction`. Directories, prose, headings,
stories and catalog ids follow; each page carries the old name as an alias so
search still finds it.

The reference catalog and the public contract list are rebuilt against the
real export surface: 78 stale symbols removed, 48 new ones documented, and
every remaining `related` id and cross-link repointed at a page that still
exists. The sixteen internal helpers the port unexported were never
documented, so nothing referenced them.

`packages/docs/scripts/validate-reference.ts` went from 60 errors to 0, so
`npm run docs:build` reaches the playground build again — where it now stops
on `@studiometa/ui-mapbox` and `@studiometa/ui-motion`, whose unported v3
sources import subpaths js-toolkit v4 removed. Both are left out of the
playground bundle until their ports land; the comment naming the condition is
in `packages/playground/meta.config.js`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Every live example is real component code, so the runtime move broke a set of
them in ways a build does not catch. Each fix below was checked in Chromium.

- **`createApp()` is gone from v4.** The four stories that built an app class
  (`Transition`, `ViewTransition`, `Indexable`, `FigureShopify`) register it
  with `registerComponent()` instead, and their markup gains the
  `data-component="App"` root the registry needs. `$children.X[0]` becomes
  `$query('X')[0]`.
- **`registerComponent()` takes one argument.** The second name/selector
  parameter no longer exists, so `registerComponent(ScrollTo, 'a[href^="#"]')`
  registered nothing: the story's links carry `data-component="ScrollTo"` now.
  The alias form — `registerComponent(FigureShopify, 'Figure')` — becomes a
  subclass which declares `name: 'Figure'`, because a component mounts on its
  configured name and the Twig and Liquid templates write `Figure`.
- **`withResponsiveOptions()` is gone; every option is responsive.** The
  responsive mega-menu drops the decorator, and
  `data-option-mode:xxs:xs:s="click"` becomes the v4 cascade —
  `data-option-mode="click"` with `data-option-mode:m="hover"`. `data-breakpoint`
  goes with it.
- **`@component()` registers its class on import.** `Draggable` is one of the
  twenty-one components that do, so the `dynamic-parent` story's subclass was
  refused by the registry and its `parent` override never ran. It is
  `RingDraggable` now, with its own name.
- **`Transition`'s `group` option has no v4 equivalent** — it collected
  siblings from a global instance registry v4 does not keep. The option, the
  `Group` example and its story are removed, and the `FigureShopify` reveal —
  which used `group` to run two transitions as one — drives the overlay it
  contains through `$query()` instead. Its `img` ref was also prefixed for the
  old class name and never resolved.
- **`Slider`'s `center` mode rejects `contain`**, which the center story asked
  for; it warned eleven times per load. The attribute is dropped.
- **The `Defer` error example could not fail.** It fetched `/404`, and
  `fetch()` resolves for any HTTP status, so the component injected the error
  page's markup. It points at an unresolvable `.invalid` host now, and the
  `error` ref finally shows.
- `FigureVideoTwicpics` included the **image** template, so its markup wrote
  `data-component="Figure"` while the script registered `FigureVideo`. It
  includes `@ui/FigureVideo/FigureVideoTwicpics.twig` now.

`Transition`'s JS API also loses `transition-toggle`, which the mixin does not
emit, and gains the array form of `target` and the optional argument on
`enter()` and `leave()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The guide still taught v3's runtime, which the port replaced wholesale.

- **Registering.** `registerComponent()` takes the class and nothing else in
  v4: the alias and CSS-selector second argument is gone, so the guide shows a
  named subclass instead, and notes that twenty-one `@studiometa/ui` classes
  register themselves when their module is imported.
- **`createApp()` does not exist.** "When to use `createApp`" becomes "When to
  use an application component": an app root is an ordinary component with a
  `data-component` on the page root, which is also what scopes its refs.
- **Events bubble**, so the hook section says why each family prefixes its
  event names, and `on<Child><Event>` is documented with the payload v4 hands
  it — `{ target, payload }`.
- **Autoloading was v3 end to end.** `data-load` is `data-mount`, and the four
  strategies are six: `eager`, `visible`, `in-view`, `idle`, `interaction` and
  `media:<query>`, two of them reversible, with the `:<rootMargin>` and
  `:page` suffixes. The `js-toolkit:eager` `<meta>`, `autoload()`,
  `registerManifests()` and the `js-toolkit:error` event are all gone; the
  entry is `registerManifest()` and failures arrive on the
  `js-toolkit:diagnostic` channel with a stable `DIAGNOSTICS` code.

In the Reference, `withMountWhenInView` no longer exists, so the five
components that documented it now name the mount strategy they declare —
`InView` is `in-view`, `InViewOnce` is `visible`, `Figure`, `FigureVideo` and
`AnchorNavTarget` are `in-view`, `LargeText` is `in-view:50%`, `CarouselDrag`
is `media:(pointer: fine)`. `InView` and `InViewOnce` lose their
`intersectionObserver` option with it: the margin belongs to the strategy and
is written as the `data-mount` suffix. `ScrollReveal` keeps its option, and
its page says why — `repeat` picks between the two strategies at runtime, so
it subscribes to the viewport itself.

Every remaining link to the v3 documentation site is repointed at
js-toolkit-v4.studiometa.dev, except in `@studiometa/ui-mapbox`'s pages, whose
sources are still v3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`docs:build` does not validate a `twoslash` block. A page importing a symbol
`@studiometa/ui` does not export builds clean, with no error marker and an
empty hover — the transformer is running (the popper markup is in the output)
but every hover it emits is empty and no diagnostic reaches the page.
Twoslash itself is fine: run directly, it reports
`Module '"@studiometa/ui"' has no exported member` for the same snippet. So
the samples below were wrong for as long as the workspace has been on v4 and
nothing said so.

Eight pages still handed the reader `createApp()`, `$children` or the
two-argument `registerComponent()`. `Figure`, `FigureShopify`,
`FigureTwicpics`, `FigureVideo`, `FigureVideoTwicpics` and `ScrollTo` show the
named subclass a renamed `data-component` needs; `Indexable` and `Slider` show
`registerComponent()` with the `data-component="App"` root that scopes an
application component's refs. `Slider`'s two pages drop the subclass entirely:
its optional controls are registered like any other component.

`Toaster` described `Timer` cancelling its countdown from `destroyed()`, which
v4 removed; the cleanup returned by `mounted()` does it.

Every one of the 21 remaining `twoslash` blocks was type-checked by hand
through the twoslasher, and all 21 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Declare the playback and transition events in the props types now that
`config.emits` is gone, replace `$children.Motion` with live
`$watchChildren()` collections, and move the teardowns off `destroyed()`
— onto the cleanup `mounted()` returns where it pairs with its setup,
onto `unmounted()` where it does not.

`addClass`/`removeClass` do not exist in v4: both were one-line wrappers
over `classList`, so `MotionView` splits its space separated option
value itself, exactly as the ported `ViewTransition` does.

`$warn()` now takes a namespaced diagnostic code, and `$emit()` carries
one named payload object instead of a spread `detail` array — which also
retires `Motion.__dispatch()`, a hand-rolled bubbling `CustomEvent`,
since v4's `$emit()` already bubbles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Declare every map event in the props types now that `config.emits` is
gone. A v4 payload is one named object rather than a spread `detail`
array, so the values that used to travel by position are read by name:
`detail.map`, `detail.error`, `detail.items`, `detail.clusterId`.

Drop `withExtraConfig()` from the three controls: it existed because v3
did not merge config along the prototype chain, and v4 does, so each
control is a plain subclass with its own `static config`. The same change
retires the `name`/`debug`/`log` filtering in `AbstractMapboxControl`,
since v4 defines none of those built-in options.

Replace `StoreLocator`'s bounded `nextTick` retry loop — v4 exports no
`nextTick` — and its `MAPBOX_CLUSTER_CONNECTED` subscription with two
`$watchChildren()` collections. Both existed to answer "has the cluster
mounted yet?", which a live collection answers with nothing to poll.
`MAPBOX_CLUSTER_CONNECTED` stays for `MapboxClusterItem`, which looks
*up* for its cluster, and v4 has no watching counterpart to `$closest()`.

`destroyed()` becomes `unmounted()`, and `$warn()` takes a namespaced
diagnostic code — with the contained failures moving to `$error()`,
which carries the cause a warning had nowhere to put.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The build emits stage-3 decorator syntax verbatim — Oxc does not lower it —
so 21 of the 117 modules in the published `dist/` were unparseable and threw
`SyntaxError` on import, in Node as well as in browsers. Rewrite every
decorator call to its plain equivalent:

- `@component({ … })` becomes `static config`, which also drops the
  `registerComponent(this)` the decorator ran on the side. Registration is now
  the consumer's job for every component in the package, exactly as it already
  was for the 60-odd components that never carried the decorator; the
  autoloader registers each class itself once its manifest entry resolves.
- `@on('click' | 'submit' | 'scroll')` becomes `onClick`, `onSubmit` and
  `onScroll`, and `@on(window, 'popstate')` becomes `onWindowPopstate` — the
  name `FetchShopifySection` already referred to in a docblock.
- `Defer`'s three `@on('defer-content' | 'defer-error')` handlers keep their
  descriptive names and are subscribed from `mounted()`, whose returned
  releases own them: the naming convention cannot spell a hyphenated event.
- `@provide()` becomes `$provide()` and `@children()` becomes
  `$watchChildren()`, matching `Slider` and `Menu`.
- `@write` becomes an explicit `$write()` call around the method body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`@rollup/plugin-swc` and `@swc/core` were added only because Oxc cannot lower
stage-3 decorators, and no source in the repository uses one any more —
`packages/ui`, `packages/ui-mapbox`, `packages/ui-motion` and the specs
themselves are all decorator-free, and `@studiometa/js-toolkit` ships built
JavaScript. Vite's own transformer is enough, so the plugin, its filter and
both dependencies go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Two pages said some `@studiometa/ui` classes register themselves on import,
because they carried `@component()`. None does now, so the asymmetry a reader
could not see is gone: importing defines a class, registering is what makes it
mount, and the autoloader is the alternative that does both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Their v3 sources imported subpaths js-toolkit v4 removed, which is a hard
resolve error for the bundler and failed `docs:build` outright. Both are
ported, so the exclusion — and the comment naming the condition — go with
it. This restores the 32 stories the two packages own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`importWhenVisible` and its siblings do not exist in js-toolkit v4:
lazy registration is a manifest entry — a `load` importer plus a
`mountStrategy` the element can override with `data-mount`. Every
lazy-loading section is rewritten around `registerManifest()`, and the
packages' own `./manifest` subpath is named as the shortest way in.

The documented event payloads follow the runtime: a v4 `$emit()` carries
one named object, so `map-load` is `{ map }`, a forwarded mapbox event is
`{ event }`, `map-item-click` is `{ item, feature, event }`, and so on.
Delegated handlers read `{ payload }` rather than v3's `{ args: [...] }`,
and the `Motion`/`Timer` story reads `event.detail.ratio`.

`createApp()` is gone too, so the remaining examples register their
application component like any other, and the prose stops promising a
`$query` retry loop `StoreLocator` no longer runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
v4 guarantees no mount ordering and `$watchChildren()` seeds its
collection in a microtask, so `MotionSequence` and `MotionScrollTimeline`
could reach `mounted()` with an empty collection and silently skip the
autoplay or the scroll links. Both now await `whenDOMSettled()` first,
which is the guarantee v3 got for free by having a parent construct its
own children.

`StoreLocator` gains the same correction from the other side: a cluster
that wires before the map has loaded gets a `__refresh()` with no map to
fit or filter against, so the map-load path refreshes unconditionally
rather than only on a first wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`StoreLocator.mounted()` read the `mapboxMap` getter, which warned when
`$query('MapboxMap')` was empty — and v4 mounts a wrapper before the
elements inside it, so every healthy locator reported
`store-locator.no-map` on every page load and recovered silently a moment
later through its `MAPBOX_MAP_CONNECTED` subscription.

The getter no longer warns. A locator with genuinely no map in its markup
is reported once, after `whenDOMSettled()`, and judged on the *element*
rather than the mounted instance: DOM ancestry is a fact that exists
before anything mounts, so the check is immune both to mount ordering and
to the map's mount strategy.

Also corrects the docblocks the port made wrong — the containment routes
to `$error()` now, not `$warn()`, and `destroyed()` is `__onDestroyed()`
— and records why `unmounted()` deliberately keeps `__readyMap`, which an
in-flight `MapboxImage` injection depends on to undo itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Both suites failed at collection, so none of their 226 tests ran. They
are registry-driven now: v3 built a fake `MapboxMap` per test and stubbed
`instance.$closest`, while v4 mounts the real family through
`registerComponents()` and `mount()` and lets each child resolve its own
parent. `MapboxMap/harness.ts` holds what every spec needed — an unloaded
map wrapping given markup, a `load()` that fires `map-load` once the test
has configured the double, and an `append()` that waits for the observer.

The mechanical half: `$destroy()` is `$unmount()`, event payloads are read
by name instead of by position, and `$warn`/console spies become
`captureDiagnostics()`, which reads the channel rather than one of its
sinks.

Two assertions could not survive `config.emits` and are re-expressed
against what the components do. `@studiometa/ui-motion` drives a full
playback lifecycle and asserts the five names it observed. The mapbox
suite pins each component's event set with an exhaustive
`Record<EmitNames<Props>, true>`, so adding or removing an event in the
source is a compile error, and asserts the `map-` prefix at runtime.

Every file keeps its exact test count. Nothing is deleted and nothing is
skipped; the three remaining failures are the deferred `Tabs` rewrite.

`autoload/manifest.spec.ts` was asserting a v3 manifest shape it never
got to run — `token`, `packageName` and `strategy` are gone from a v4
entry — so it now reads `mountStrategy` and checks the loaded class
against the key the registry resolves it by, with `ui-motion` added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
titouanmathis and others added 8 commits September 2, 2026 14:53
Re-auditing the PHP and Twig surface against the v2 catalog turned up no
structural staleness: the Twig extension registers two directory
namespaces rather than a component list, `phpcs.xml`, `phpstan.neon` and
`phpunit.xml` all point at paths that still exist, every `@ui/…` include
in the Pest suite resolves, and no snapshot is orphaned. The MCP tool
docblocks and the `/components/` redirects were fixed on this branch
already. What is left is four documentation references and one redirect
that lands on a page the docs do not build.

`/components/PrefetchWhenOver` redirected to
`/reference/items/PrefetchOnInteraction`. The class is called that, but
the reference item is `Prefetch` — it documents `AbstractPrefetch`,
`PrefetchOnInteraction` and `PrefetchWhenVisible` on one page, which is
also what the catalog's symbols point at. The redirect therefore 301'd a
v1 link straight into a 404, the exact failure the rule exists to
prevent. The two sibling rules already target the item, not the class.

The package READMEs are the first thing a reader sees on npm and GitHub,
and their one usage example imported `Frame` and `ScrollAnimation` —
neither of which v2 exports. `Fetch` covers what the example was showing.
The eslint plugin README illustrated "a name that matches a UI component"
with `Accordion` and `Modal`, the two most prominent drops, so the rule's
own documentation named nothing the rule would match; its generated
`UI_COMPONENT_NAMES` set is correct. `prefer-transition` made the same
mistake in its rule comment and in the fixture that covers "extends a UI
component, so do not report" — the fixture passes either way, because the
guard it exercises keys on the import source rather than the name, but it
should not teach a dropped class.

`pest` stays at 74 passed (148 assertions, plus the pre-existing
IconFetcher warning about an unreachable host), `phpcs` and `phpstan`
stay green, and the eslint plugin's own suite is 5 files / 5 tests green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
…ix too

Two corrections to the redirect work.

`/components/PrefetchWhenOver` pointed at `/reference/items/PrefetchOnInteraction`,
which does not exist: the reference item is `Prefetch`, one page covering
AbstractPrefetch, PrefetchOnInteraction and PrefetchWhenVisible. The rule
301'd a v1 link into a 404, the exact failure it exists to prevent. My
earlier check asserted the redirect target string and never that the
target resolves, so it passed a broken rule.

The rename and drop rules also only existed on `/components/`, the
pre-1.0 prefix. v1.10's own reference already lived at
`/reference/items/`, so every link published from the 1.10 site to an
item v2 renamed or dropped still 404s. Those rules are mirrored onto the
current prefix, anchored to the end of the item name so a longer v2 name
starting with the same letters is never caught.

Verified by serving the real built docs behind the config in nginx and
following each redirect to its end: 20 paths, all 200, and the v2 pages
that must not be redirected still serve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The `/source` action concatenated the unvalidated `?path=` query parameter onto a
base directory and returned whatever file it landed on, so a payload of `../`
segments escaped the base and read any file the PHP process could reach. Booted
against the real app it returned `/etc/passwd` (1821 bytes) and the app's own
`.env`.

The endpoint is dead, so removing it beats hardening it:

- its only caller was the Storybook Twig integration, deleted in the very same
  2022 commit that created the Symfony route (82277fd);
- its base directory has never resolved since the app moved into
  `packages/docs/.symfony/` on the same day, and still does not in
  `packages/api/` — `packages/api/ui/` does not exist;
- nothing else reaches for it: the playground only POSTs to `/api/`, the docs
  site proxies `/api/` as a whole, and the MCP tools read the built docs through
  `DocsRepository`.

Unreachable code carrying a traversal is a liability with no upside, and the
missing base directory is the only thing standing between the payload and the
filesystem — one `mkdir` away from a full read primitive.

The README line and the nginx comment that advertised the route follow it out.
The new Pest test asserts the controller routes nothing but `/` and never reads
a file from a request-supplied path; the API app ships its own `composer.json`
and its Symfony dependencies are not installed for this suite, so the assertion
is made on the controller source, which is enough to fail if the endpoint comes
back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`packages/ui` has no `dependencies` block any more: `alien-signals`,
`compute-scroll-into-view`, `deepmerge` and `morphdom` all went away because
nothing under `packages/ui/src` imports them. `@studiometa/ui` now declares its
whole runtime need through its `@studiometa/js-toolkit` peer. Two build configs
still named those packages.

The playground `dependencies` list becomes import-map entries: a bare string is
resolved to `https://esm.sh/<name>@<version>`, the version read from
`packages/playground/package.json`. That map is only consulted for bare
specifiers, so the entries served exactly one purpose — letting the script
editor `import` a peer of `ui`/`ui-mapbox`. `compute-scroll-into-view`,
`deepmerge` and `morphdom` are no longer a peer of anything, and no story,
doc or source file imports them. js-toolkit's own `morphdom` needs no entry
either: esm.sh rewrites it to an absolute esm.sh URL, so the import map never
sees it. `@motionone/easing` goes for the same reason — it is not a declared
peer of `ui`, `ui-mapbox` or `ui-motion`, nothing imports it, and `motion` is
resolved with `esmSh: { bundle: true }`, which inlines its easing helpers.

`optimizeDeps.include: ['deepmerge']` in the docs Vite config was added when
`@studiometa/ui` — which is `exclude`d, so it is served as source — imported the
CJS-only `deepmerge` package by its bare name. It now imports
`@studiometa/js-toolkit/utils/deepmerge` instead. The entry still resolves today
only because `packages/playground` keeps its own `deepmerge` devDependency and
npm hoists it to the workspace root; Vite hard-errors on an `include` entry it
cannot resolve, so leaving it in wires the docs dev server to an unrelated
package's dependency list.

Verified: `npm run test` 940 passed / 86 files / 0 failures, `npm run build` and
`npm run docs:build` (which rebuilds the playground) both pass, and the built
docs served with the ddev `/api/` Twig endpoint render every live story on
`/reference/items/Carousel/examples.html` (7 stories) and
`/reference/items/Dialog/examples.html` (2 stories) with zero console errors,
zero page errors and zero failed requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
url: URL,
requestInit: RequestInit,
self: unknown,
) => string;
return fn.call(this, response, url, requestInit, self);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Pass the defined response parser argument

parseResponse() invokes the compiled expression with self as an argument, but self is not defined in this method's scope, so every successful fetch reaches a ReferenceError before parsing its response or updating the DOM. Pass the current instance instead, such as this.

Confidence: high.


Reviewed by @weareikko/code-review v0.9.5 for commit 300df9a.

titouanmathis and others added 20 commits September 2, 2026 23:34
Cursor spent eight options hardcoding one visual: a dot that translates
and scales, over two fixed state names, with two fixed scale factors and
three damp factors, writing a matrix() straight onto the element. An
author who wanted a ring that changes colour, an image that rotates or a
blend-mode blob had to fight all of it.

Every one of those options was a CSS declaration in disguise.
`[data-cursor-state='grow'] { scale: 2 }` says what `growTo: 2` said, in
the author's own easing, on a compositable property, and for any number
of states the author names. So the component now publishes what only
JavaScript can know and leaves the drawing to the cascade:

- `--cursor-x` / `--cursor-y`, the damped position in pixels
- `data-cursor-state`, resolved from the new `states` map
- `data-cursor-down`, present while a pointer button is down

`growSelectors`, `shrinkSelectors`, `scale`, `growTo`, `shrinkTo`,
`translateDampFactor`, `growDampFactor` and `shrinkDampFactor` are
replaced by `damping` and `states`. It is the convention Carousel
already sets with `--carousel-progress` and `--carousel-item-active`.

Two decisions worth their reasons:

Pointer-down is published apart from the state, not as a reserved `down`
state name. v1 forced the shrink scale while the button was down, so a
press over a growing element silently lost its grow and no author could
change that precedence. Two independent facts get two hooks, the cascade
arbitrates, and no state name is reserved.

`data-cursor-state` is always present and empty when nothing matches. A
channel that disappears is one a stylesheet has to test for existence
before it can style around it, and it makes `[data-cursor-state]`
silently select the resting cursor too. CarouselItem already writes
`--carousel-item-active: 0` rather than removing the property.

The component still writes the transform itself, because a benchmark
found no difference between custom-property and transform writes beyond
the run-to-run noise: publishing alone would buy nothing and cost the
out-of-the-box default. It writes it into `translate`, not `transform`,
because the individual transform properties compose with `translate`
outermost — a position inside a stylesheet's `scale` would make a cursor
at scale 2 chase the pointer at twice its speed.

Selectors are matched with closest(), which is why the defaults no
longer need their `a *` companions, and a malformed selector now reports
`cursor.invalid-selector` once instead of throwing on every frame.

Cursor.twig ships the default stylesheet, wrapped in `:where()` so any
author rule wins without `!important`, and drops its Tailwind classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`LargeText` and `CircularMarquee` were the same component. Both accumulated
the scroll delta, damped it at a hardcoded `0.25` and wrote a transform; only
the transform property differed. "Circular" was the SVG `textPath` in
`CircularMarquee.twig` and nothing else — zero JavaScript. And `LargeText` did
not make text large: it was a scroll-driven marquee wearing the name of a
component that once scaled text to fill a width.

`Marquee` computes the travel once and publishes it, the convention `Carousel`
set with `--carousel-progress`: `--marquee-progress` (0→1, wrapping),
`--marquee-offset` (the same travel unwrapped and signed) and
`--marquee-velocity` (the damped rate, in loops per second). CSS decides
whether that is a `translateX`, a `rotate` or a `skewX`. Both templates ship
the `transform` that reads it, so neither renders inert.

Publishing rather than painting is what removes the measurement. With a
normalised progress, `-100%` *is* the content width by definition, so
`clientWidth`, `resized()`, `withResize`, the `target` ref and the `width`
property are all gone — there is no end of a loop left to measure.

Three options replace four, in units that say what they mean: `speed` is the
idle travel in loops per second, `sensitivity` the scroll boost in loops per
pixel scrolled (negative reverses, as v1's did), `damping` the smoothing both
components hardcoded. v1 hid the idle speed inside the magic `+ 1` of
`(Math.abs(deltaY) + 1) * sensitivity`, so one number set the idle speed and
the scroll boost at once, and the frame rate set the real value of both.

Reduced motion is honoured, and the line is drawn at who moves it: the idle
`speed` stops and the scroll-driven travel continues, because continuous idle
motion is decorative motion nobody asked for while travel tied to the scroll
is the user's own gesture and stops when they stop. The query is read through
`usePrefersReducedMotion()` and stays subscribed, so turning the setting on
mid-session stops a marquee already mounted. Neither v1 component honoured it.

Two v1 defects fall out of the rewrite. The scroll delta was latched and never
consumed, so a marquee ran on for ever at the speed of a scroll that had
finished; each frame now consumes the distance actually scrolled since the
previous one. And `CircularMarquee` had no test at all — the new specs read
the element's published values rather than the instance's fields, so an inert
component writes nothing and fails.

`CircularMarquee.twig` survives under `Marquee/` as a Twig-only helper: the
radius/perimeter geometry is useful and needs no JavaScript of its own.
`Marquee.twig` keeps the repeat-the-content mechanism that makes the loop
seamless and drops the `position_factor` it derived from the sign of the
sensitivity — a wrapped progress only ever travels from 0 to -100%, whichever
way it is going, so the copies always sit to the right.

One deviation from the plan, recorded in the review: `--marquee-offset` counts
loops, not pixels. A pixel figure would need the element measured, which is
the read this redesign exists to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
…mponent-port

# Conflicts:
#	V2-COMPONENTS-REVIEW.md
Each round was measured against the same 940-test baseline in its own
worktree, so both figures were right alone and wrong together. Rounds 9
and 10 landed in the same merge: 940 + 9 for Cursor + 13 for Marquee
- 6 for the deleted LargeText spec = 956.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The comments carried over from the v3 port explained what the previous
implementation did and how this one differs. A reader has no other
implementation to compare against, so the narrative was noise. State what
each piece of code does and why, keep the reasons that are facts about the
code as it stands, and drop the docblocks that existed only to narrate the
port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
`provideMotion()` and `provideMapboxGl()` told the reader to call them
"right before `createApp`", an entry point this framework does not expose.
A consumer following the advice would land on an error. Name the calls that
actually put the components on the page instead: `registerComponent()` and
`registerManifest()`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The spec comments were written during the component port and explained how a
previous implementation behaved. A reader of a spec needs to know what it pins
and why, so each comment now states the fact about the code under test and
drops the comparison. Comments that recorded a defect a test exists to catch
keep that reason, restated. Comments that only restated the assertion below
them are gone.

Comments only: no test, fixture or assertion is touched, and the suite still
reports 956 passed across 86 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The comment pass left these because renaming a test changes its
identity in reports. They state the same thing the comments did — what
an older version used to do — in a string a reader sees, so they get
the same treatment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
A comment that says a behaviour changed still compares against something a
reader cannot run, even when it names no version number. The "used to", "no
longer" and "was removed" phrasings that described an earlier implementation
now state the fact about the code under test: what would go wrong without the
guard, or what the exports map says today. Phrasings that describe runtime
state or the order of events inside a test are untouched, because those are
sequence, not history.

Comments only. The suite still reports 956 passed across 86 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The source docblocks explained what earlier versions did and how this one
differs. A reader of this code has no earlier version to compare against, so
every comparison is dropped and the reason behind it — when the reason is a
fact about the code as it stands — is restated on its own terms. Docblocks
that existed only to narrate the port are removed, along with prose that
restated the code or re-explained a framework feature.

Comments only: no behaviour, signature or formatting changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Merge main and rebuild its two Fetch fixes on this branch's structure, which
renamed the handlers, moved the events into a module constant and swapped
through js-toolkit — a textual merge would have landed none of it.

Folding a GET form onto the base URL used `set` per field, which keeps one
value per name. A checkbox group is repeated names by design, so ticking a
second box changed nothing: FormData held both values and the request carried
one. The fold now deletes the base's values the first time it meets a name and
appends after that, keeping the override `set` was there for without discarding
the rest.

History pushed the URL that was fetched, so a `src` pointing at a lighter
endpoint put its own parameters in the address bar and in anything a visitor
copied out of it. History now follows the element's own destination — a link's
href, a form's action folded with its data — through a new `historyUrl` getter,
and `onClick` and `onSubmit` defer to the element so the declarative path takes
it too. A URL passed explicitly to `fetch()` is still pushed as given.

FetchShopifySection and FetchShopifyPartial each override `fetch()` with a URL
of their own, which read as a caller naming a destination and opted them out;
both now forward an absent URL as absent.

Thirteen specs cover the two faults, ten of which fail against the code before
the fix. The versions move to 1.11.1, the released history joins the changelog
alongside the unreleased entries, and the `url` and `historyUrl` documentation
lands on the reference page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
The `Unreleased` section held four entries — ui-motion, the `data-bind:if`
binding, `Dialog` and `Fetch` — that shipped on `main` as `v1.11.0`, and the
`v1.11.0` section below says the same things. They are duplicates, so they go.

The one part of them that was not released on `main` is v2-only and breaking:
`Dialog` looks a duck-typed `waitUntil()` extension up by the event's own name
(`open()`, `close()`) instead of v1's `enter()`/`leave()` pair. It moves into
the new section.

`## [v2.0.0-alpha.0]` takes the place of `Unreleased`, written from the
`1.0-2.0` migration guide and `V2-COMPONENTS-REVIEW.md`: the six dropped
families and what supersedes each, the three renames, the `Marquee` merge, the
`Cursor` redesign, the `Tabs` rewrite, the completed `Carousel` family with its
accessibility contract, and the event payload change that reaches every
component. Every entry cites #642, the pull request the whole port lands in.

No version number is bumped — that is a separate step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
Bumps the nine workspace manifests, composer.json and the lockfile.

Done without `npm version`: the root `postversion` hook shells out to
`npm version --workspaces`, which wants to commit and tag, and a tag is
what triggers the publish workflow. The versions are written directly
and `scripts/update-composer-version.js` is called on its own, so the
tag stays a separate, deliberate step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
@titouanmathis
titouanmathis merged commit f34f7fd into 2.x Sep 3, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant