Land the ui v2 component port on js-toolkit v4 - #642
Conversation
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
Code ReviewRisk: Low — no blocking issues; safe to merge aside from nits. This MR ports the UI component families and the ui-motion/ui-mapbox packages to js-toolkit v4, updates the browser-based test harness, and migrates the playground and documentation to the new runtime and export surface. I reviewed the added diffs for Notes:
Still open from earlier reviews (3 findings):
Review usage: 485,672 in (398,258 cached) / 2,092 out tokens — $0.0808 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit cabaea3. Previous review runsPrevious run archived 2026-08-28T12:19:34ZCode ReviewRisk: High — blocking issues remain — resolve them before merge. This change ports most component families and related packages to js-toolkit v4, updates manifests and exports, migrates documentation, and replaces the test harness with Vitest browser mode. The declared intent is not fully met because 2 issues found:
Notes:
Still open from earlier reviews (1 finding):
Review usage: 943,814 in (844,071 cached) / 3,959 out tokens — $0.1164 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 5817b7c. Previous run archived 2026-08-28T12:11:45ZCode ReviewRisk: Low — No concrete defects were identified in the reviewed source files; the change is safe to merge subject to reviewing the remaining staged diffs. The MR ports the component families and the ui-motion/ui-mapbox packages to js-toolkit v4, migrates Disclosure, LargeText, ScrollReveal and Slider, updates event and registration APIs, and moves browser tests to Vitest with Playwright Chromium. I reviewed Still open from earlier reviews (1 finding):
Review usage: 474,644 in (423,062 cached) / 1,630 out tokens — $0.0583 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit bd06b0e. Previous run archived 2026-08-28T10:28:38ZCode ReviewRisk: Low — No demonstrable defects were found in the reviewed source diffs; the change is safe to merge from the inspected files. This MR ports component families to js-toolkit v4, migrates Disclosure, LargeText, ScrollReveal, and Slider, updates exports and documentation, and switches the test suite to Vitest browser mode. I reviewed Still open from earlier reviews (1 finding):
Review usage: 408,405 in (361,956 cached) / 1,435 out tokens — $0.0514 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 9962d2f. Previous run archived 2026-08-27T21:21:07ZCode ReviewRisk: Low — No concrete defects were identified in the reviewed source changes; the reviewed subset is safe to merge aside from the unreviewed files listed below. The MR ports component families to js-toolkit v4, adds the Slider, Disclosure, ScrollReveal, and LargeText migrations, updates the manifest and exports, and moves tests to browser mode. The reviewed implementations consistently adopt v4 lifecycle, context, signal, service, and event APIs, with the intentional Slider and LargeText redesign deferrals documented in the change context. Notes:
Still open from earlier reviews (1 finding):
Review usage: 192,755 in (156,115 cached) / 1,519 out tokens — $0.0353 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 118192c. Previous run archived 2026-08-27T15:39:45ZCode ReviewRisk: Low — No demonstrable blocking or wrong-behaviour defects were found in the reviewed changes; the migration is safe to merge aside from the unreviewed files noted below. This MR ports the component families to js-toolkit v4, adds the Disclosure, ScrollReveal, Slider, and LargeText migrations, updates the generated manifest and exports, and moves the test runner to browser mode. The declared Slider rewrite onto Indexable remains intentionally deferred, consistent with the stated plan. Reviewed files: Still open from earlier reviews (1 finding):
Review usage: 168,783 in (132,631 cached) / 1,095 out tokens — $0.0325 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 02e6436. Previous run archived 2026-08-27T12:59:58ZCode ReviewRisk: Low — No demonstrable blocking defects were found in the source files reviewed; the reviewed changes are safe to merge aside from the unreviewed files listed below. This change ports the component families to js-toolkit v4, adds the Slider, Disclosure, and ScrollReveal migrations, and updates lifecycle, event, context, and collection handling for the new API. I reviewed Still open from earlier reviews (1 finding):
Review usage: 254,655 in (217,445 cached) / 1,791 out tokens — $0.0395 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit f75793e. Previous run archived 2026-08-26T23:12:22ZCode ReviewRisk: Medium — issues that should be addressed before merge. Ports the retained component families to js-toolkit v4, updates the catalog and manifest surface, renames Defer, ScrollTo and PrefetchOnInteraction, and removes the families listed as dropped by the decision record. I reviewed 1 issue found:
Review usage: 217,952 in (157,188 cached) / 2,481 out tokens — $0.0533 (openrouter/openai/gpt-5.6-luna, thinking: low) Reviewed by @weareikko/code-review v0.9.5 for commit 1d2958a. |
| for (const menuList of this.$children.MenuList) { | ||
| menuList.close(); | ||
| for (const list of this.__lists) { | ||
| list.close(); | ||
| } |
There was a problem hiding this comment.
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 Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 2.x #642 +/- ##
============================================
- Coverage 87.80% 86.09% -1.71%
============================================
Files 153 7 -146
Lines 5443 223 -5220
Branches 1037 88 -949
============================================
- Hits 4779 192 -4587
+ Misses 580 24 -556
+ Partials 84 7 -77
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
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
The default snippet the editor loads still imported and called `createApp()`, which v4 removed, so every visitor's first playground session started on a dead API. `registerComponent()` alone would have been a worse trap. In v4 an instance exists because its element is in the document *and* its class is registered, so registering `App` against markup that never declares it produces a class that mounts nothing, with no error — which is exactly how the three playground editor subclasses and the 21 self-registering `@studiometa/ui` components were nearly lost. The default markup now declares `data-component="App"`. Verified by loading the default session at /play/ against a locally built `@studiometa/playground` 0.4.0: the preview iframe completes, the element carries `data-component="App"`, and the console reports `getDefault script` with no errors. The comment about js-toolkit's registry is reworded for the same reason; it named `createApp` as the thing holding the mutable state. Note that the site's playground shell stays broken until `@studiometa/playground` 0.4.0 is released — studiometa/playground#79. That is a version bump here, not a change to this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
| exportName: 'CarouselWrapper', | ||
| strategy: 'eager', | ||
| group: 'carousel', | ||
| mountStrategy: 'eager', | ||
| load: () => | ||
| import('./Carousel/CarouselWrapper.js').then(({ CarouselWrapper }) => CarouselWrapper), | ||
| }, | ||
| CircularMarquee: { |
There was a problem hiding this comment.
issue (blocking): Remove the unported CircularMarquee from the v4 manifest
CircularMarquee.ts still imports @studiometa/js-toolkit/utils/damp and @studiometa/js-toolkit/utils/transform and still uses the v3 props.delta.y and ticked() APIs. Those v3 subpaths are removed by the v4 migration, so resolving this manifest entry attempts to load a component that cannot be imported. Port CircularMarquee to v4 before adding it to the manifest, or omit it from the v4 manifest until that port lands.
Confidence: high.
Reviewed by @weareikko/code-review v0.9.5 for commit 5817b7c.
| strategy: 'eager', | ||
| group: 'sticky', | ||
| children: ['Sentinel'], | ||
| mountStrategy: 'eager', | ||
| load: () => import('./Sticky/Sticky.js').then(({ Sticky }) => Sticky), | ||
| }, | ||
| Tabs: { |
There was a problem hiding this comment.
issue: Remove the unported Tabs from the v4 manifest
Tabs.ts still imports @studiometa/js-toolkit/Base and @studiometa/js-toolkit/utils/transition, and still declares config.emits, all of which are v3-era APIs while the workspace now resolves js-toolkit v4. Resolving this manifest entry therefore fails when the runtime loads Tabs; port it to v4 or remove the entry until the deferred rewrite is complete.
Confidence: high.
Reviewed by @weareikko/code-review v0.9.5 for commit 5817b7c.
`@studiometa/playground` 0.3.13 was a js-toolkit v3 application: its shell called `createApp()`, which v4 removed, so the shell threw before it rendered anything and no example on this site executed. 0.4.0 is the v4 migration of that package — studiometa/playground#79. This is the whole of the fix on our side. The version conflict resolves itself: 0.3.13 declared `^3.4.3` against a workspace pinned to 4.0.0-alpha.1, which npm reported as an invalid tree, and 0.4.0 declares `^4.0.0-alpha.1`. `npm ls @studiometa/js-toolkit` is clean. Verified in a browser through ddev against the published package, not a local build: the Disclosure examples page mounts three `Disclosure` instances, clicking the second moves the group from `["true","false","false"]` to `["false","true","false"]`, and the console carries no errors where it used to report `createApp is not a function` plus three follow-on failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011izFBQT4AsFcD4tVZz1f7R
This is the first step of
@studiometa/uiv2: it lands the component port that was written and proved against@studiometa/js-toolkitv4 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 thenextdist-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.mdinstudiometa/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
Action(Action,ActionEvent,Target)AnchorNav(AnchorNav,AnchorNavLink,AnchorNavTarget)Carousel(7 classes)ClickOutsideCursorData*(DataScope,DataBind,DataModel,DataComputed,DataEffect)DataChannelandwithGroupare replaced by oneDataRegistryon core'ssignal()and provide/injectDialogDisclosure/DisclosureGroupDraggableFetch(Fetch,FetchShopifyPartial,FetchShopifySection)Figure(AbstractFigure,AbstractFigureDynamic,Figure,FigureShopify,FigureTwicpics)FigureVideo(FigureVideo,FigureVideoTwicpics)HoverableIndexablewithIndexis gone: its whole body is theIndexableclass nowInView(InView,InViewOnce)LargeTextLazyInclude→Deferconfig.nameand events all follow:content→defer-content,error→defer-error,always→defer-alwaysMenu(Menu,MenuBtn,MenuList)Prefetch(AbstractPrefetch,PrefetchWhenVisible,PrefetchWhenOver→PrefetchOnInteraction)ScrollRevealAnchorScrollTo→ScrollToAnchorNavLinkfollows itSentinelIntersectionObserverEntrythatInViewcollapses away, andStickyneedsboundingClientRect.ySlider(7 classes)Indexableis deferred by decision, not overlookedStickyTimer(Timer,TimerProgress)Toaster(Toast,Toaster)Track(AbstractTrack,Track,TrackContext,TrackEvent,TrackShopify)Transition/ViewTransition/withTransitionwithTransitionis back as a real mixin (js-toolkit REPORT gap 45);ViewTransition/scheduler.tsis deleted, core absorbed itwithDeprecation$warn()on the diagnostic channel instead ofconsole.warnbehindisDev. It has no consumer left in v2Accordion(Accordion,AccordionItem)Disclosure+DisclosureGroupFrame(6 classes +AbstractFrameTrigger)FetchModal/ModalWithTransition/PanelDialogScrollAnimation(7 classes),animationScrollWithEase,withScrollAnimationDebugui-motionImageGrid,Reinsurance,StyledModal,StyledPanel(Twig)withIndexIndexableclassAbstractSliderChildSliderContext; its export subpath is removedTabsCircularMarquee27 families on v4 (23 ported from the js-toolkit exercise,
Sliderlanded from it here,Disclosure/DisclosureGroup,ScrollRevealandLargeTextmigrated here from their v1 sources), 6 groups dropped, 2 families left on their v1 sources (Tabsdeferred by decision,CircularMarqueeout of scope).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 — noIntersectionObserver, no layout geometry, no computed styles, no document location, and none of the browser globals@studiometa/js-toolkitis 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 thatretry: 3never settled.The suite is two projects.
chromiumruns everything that touches the DOM.noderuns 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 atpackages/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'snode_modules— and the run never reaches the first test. The root is this package now. That also stops the suite silently collectingpackages/eslint-plugin-ui's five rule-tester specs, which have their owntestscript 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-mapboxandTabsstill 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.tsin full — the three shims for platform invariants happy-dom breaks (window instanceof Window,HTMLCollection[Symbol.iterator],reportError) and thescrollY/requestAnimationFramepatches beside them.resetDom()from@studiometa/js-toolkit/testis the whole teardown. Also gone, all with zero consumers:lifecycle.ts(whosedestroy()called the v3-only$destroy()),resizeWindow.ts(assigns to the read-onlywindow.innerWidth),matchMedia.ts(clobbered a real global for every file importing the barrel),mockImageLoad.ts,mockVideoLoad.ts,faketimers.tsandcomponents.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 isreportError()— 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 withcaptureDiagnostics(), which both asserts the code and suppresses the sink.MapboxMap/dependencies.spec.tsandMotion/dependencies.spec.tsreloaded their module singleton withvi.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 sovi.mock()still applies.subpath-exportsasserted'$isBase' in X, a v3 static. v4's brand is a private symbol, so the assertion isX.prototype instanceof Base.LargeText
LargeTextwas the last import keeping the barrel from loading.withMountWhenInView(Base, { rootMargin: '50%' })is the stringin-view:50%: the registry owns the observer before any instance exists, anddata-mountoverrides the margin per element, which v3 could only do by declaring a second class. The three services v3 bound by the mere presence ofresized(),scrolled()andticked()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 same0.25factor 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.twigwrites no ref, option or event that moved, so it is unchanged. The generic-API redesignV2-COMPONENTS-REVIEW.mdasks for is not in this PR and remains outstanding, together with the same redesigns forCursorandCircularMarquee.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 readingevent.targetor the shape of the payload. Both take the family prefixDefer(defer-*) andFetch(fetch-*) already set. The final names:Disclosureopen,close,after-open,after-closedisclosure-open,disclosure-close,disclosure-after-open,disclosure-after-closeDisclosureGroupopen,close,changedisclosure-group-open,disclosure-group-close,disclosure-group-changedisclosure-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, andafter-openbesidedisclosure-openis one vocabulary spelled two ways.Disclosure.twigwrites no event name and nothing else in the workspace listens for these, so the rename reaches the$emitstypes, the emit sites, the spec andpackages/docs/reference/items/Disclosure/js-api.md.Default exports restored
export defaultis back on every family's main component. The port had dropped it across the board — v1 had it on all thirty — andsubpath-exports.spec.tsis right to assert it:@studiometa/ui/<Component>has always exposed the main component both ways. The decision namedDisclosureandScrollReveal; 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 documentCustomEventhandshake and a document-wideMutationObserver. 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$optionsin v3.REPORT.mdcounts 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()replacesupdated().ScrollReveal. v3 built it onwithMountWhenInView. v4's two successors each answer half of what it needs, and therepeatoption chooses between exactly those two behaviours at runtime, from an attribute. So the component mounts normally and subscribes touseInView(), 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
Sliderrewrite ontoIndexable, and theTabsrewrite.Cursor,LargeTextandCircularMarqueegeneric-API redesigns, and theCircularMarqueemigration.packages/twig-extension) and anything else on the PHP/Composer side.migration-guides/1.0-2.0/exists but predates the port and now carries a warning saying so; the real guide is unwritten.ui-motionandui-mapboxports. Their peer dependency is bumped so the workspace resolves one major version, but their sources are still v3 code.Documentation
packages/docsfollows 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 wholeFramefamily,ImageGrid,Modal,Panel,Reinsurance, theScrollAnimationfamily,animationScrollWithEase,withScrollAnimationDebugandwithIndex— 11 item directories, 102 files.viewTransitiongoes with them for a different reason: core absorbed it, so@studiometa/ui/schedulerno 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 2 · Helpers 0 · Types 109 · All exports 239.Transition'sgroupoption is removed too. It collected sibling instances from a global registry v4 does not keep, so the option, theGroupexample and its story are gone, and theFigureShopifyreveal — which usedgroupto run two transitions as one — drives the overlay it contains through$query()instead.Renamed
LazyInclude→Deferwith itsdefer-content/defer-error/defer-alwaysevents,AnchorScrollTo→ScrollTo,PrefetchWhenOver→PrefetchOnInteraction. 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.tsandpublic-contracts.ts, which are hand-maintained and validated against TypeScript's module graph byscripts/validate-reference.tson everydocs: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.tsis the other generated file;npm run manifest:checkis 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 adata-componentroot, and$children.X[0]is$query('X')[0].registerComponent()takes one argument. The alias/selector second parameter is gone, soregisterComponent(ScrollTo, 'a[href^="#"]')registered nothing. Aliasing is a subclass declaringname.withResponsiveOptions()is gone; every option is responsive.data-option-mode:xxs:xs:s="click"becomes the v4 cascade,data-option-mode="click"plusdata-option-mode:m="hover".withMountWhenInViewis gone. Five components now name the mount strategy they declare, andInView/InViewOncelose theirintersectionObserveroption with it — the margin is thedata-mountsuffix.@component()registers its class on import, so theDraggablestory's subclass was refused by the registry and its override never ran.data-load→data-mount, four strategies → six,js-toolkit:eager/autoload()/registerManifests()/js-toolkit:error→registerManifest()and thejs-toolkit:diagnosticchannel.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.
@studiometa/playground@0.3.13's front-end is a js-toolkit v3 application. The workspaceoverridespin js-toolkit to4.0.0-alpha.1, so its shell throws(0, Z.createApp) is not a functionand never renders a story. It also uses$childrenanddestroyed(), so this is a port of that package, not a patch.@studiometa/uibuild emits untranspiled decorators. tsdown/rolldown attarget: 'esnext'writesvar Defer = @component({…}) class extends Base {}intodist/, which no shipping browser parses — 21 modules, and the same output reaches the playground bundle. Lowering the target toes2022does 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, itsapp.jsbundled with esbuild against the real@studiometa/uisources, served from the ddev docroot withconsole.warn/console.error,window.onerror,unhandledrejectionand thejs-toolkit:diagnosticchannel 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 theDefererror example doing its job.Sixteen stories were then driven the way a reader would, in Chromium:
Disclosurebasicaria-expandedandhiddenacross the group; single-open honouredDeferbasichello world!injected,loadingref hiddenDefererrorerrorref revealed (display: block)ScrollTodefaultPrevented),window.scrollTo()called once, page at 1848pxSliderleft / center / right1 / 10→2 / 10on all threeCarouselhorizontal / verticalscrollLeft0 → 32,scrollTop0 → 56DataModelbasicFetchsimpleActioncounterDouble is 0.→Double is -2.Dialogmodal<dialog>.open === trueToasterbasicToastFigureShopifyrevealPages 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 twoslashblocks are not type-checked. A block importing a symbol@studiometa/uidoes 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. Sodocs:buildsays nothing about the code samples. All 21 remaining blocks were checked by hand through the twoslasher and pass.npm run lint:prettierchecks 3 of the 204 markdown files.packages/docs/**/*.mdis passed tosh, 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.Still open in the docs
migration-guides/1.0-2.0/describes theScrollAnimationrefactor, whose result v2 removes entirely, and assumes js-toolkit v3 throughout. It now carries adangercallout saying both. A real guide needs: the six dropped families and what replaces each, the three renames with their event renames, the sixteen unexported helpers, and the js-toolkit v3 → v4 changes that reach a@studiometa/uiconsumer.@studiometa/ui-mapboxand@studiometa/ui-motiondocumentation is untouched and still describes v3 (importWhenVisible,withExtraConfig), which matches their unported sources. Both are excluded from the playground bundle inpackages/playground/meta.config.js— without that, their dead js-toolkit subpaths fail the bundler and takedocs:buildwith them. Put them back in the commit that ports them.TabsandCircularMarqueedocumentation is untouched, as their sources are.Heroimage examples ship no:script, so theirFigurenever 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:buildPasses.
validate-referencereports55 Reference entries, 239 symbols and 5 concepts, and the site builds in ~27s. It failed atvalidate-referencewith 60 errors before this round.npm run lintlint:staticreports 28 warnings and 0 errors (29 before; one warning left with a deleted helper).lint:prettierpasses.lint:typesfails, with 52 errors, down from 65, and none of them in a family on v4:packages/ui-mapbox/srcpackages/ui-motion/srcpackages/ui/src/LargeTextpackages/ui/src/Tabspackages/ui/src/CircularMarqueeDoes the
@studiometa/uibarrel import?Yes.
index.spec.tscollects and passes, and the three other specs that waited on it are unblocked:index.spec.tsbarrel-exports/barrel-exports.spec.tssubpath-exports/subpath-exports.spec.tssubpath-exports/resolution.spec.tssubpath-exports/backward-compat.spec.tssubpath-exports/backward-compat-resolution.spec.tsautoload/manifest-freshness.spec.tsautoload/manifest.spec.ts@studiometa/ui-mapbox, whose v3 source reaches for@studiometa/js-toolkit/withExtraConfig. NotLargeText, and not fixable without theui-mapboxportThree of those files needed splitting, because they mixed two incompatible requirements. Importing
@studiometa/uiregisters every component, and registration needs aMutationObserver, so a spec that loads the barrel cannot run under Node at all — while the same files also calledimport.meta.resolve,node:fsandexecFileSync. The repository half of each moved to its own*-resolution/*-freshnessfile in the Node project; the identity half stayed in the browser. No assertion was dropped.npm run testThree 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-existingit.skipinTabs/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: v1Tabsemits$emit('enable', item), and v4 delivers the payload differently, so the listener receivesundefined. The runner is not hiding them any more — happy-dom never collected this file. They belong to the deferredTabsrewrite and are left failing rather than adjusted.The 29 failing test files are all collection failures in unported packages: 20
MapboxMap/*and 7Motion/*(v3 sources importing./withExtraConfig,./utils/nextTick,./utils/addClass,./utils/removeClass),autoload/manifest.spec.ts(same cause, viaui-mapbox), andTabs/Tabs.spec.ts(counted as failed because of its 3 red tests). None is a@studiometa/uiv4 family.Export-surface snapshots
Both were regenerated. Every removal is a documented v2 drop or rename — the
Accordion,Frame,ModalandScrollAnimationfamilies with their*Propstypes,AbstractSliderChild,withIndex,viewTransition(core absorbed it), and the three renamesLazyInclude→Defer,AnchorScrollTo→ScrollTo,PrefetchWhenOver→PrefetchOnInteraction.The additions split three ways:
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.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.MODIFIERSandHEADER_NAMESare very generic names for a public barrel. Kept deliberately: they exist so a no-build-step consumer writesINDEXABLE_BOUNDARIES.looprather than a bare string, which is the whole point of the convention.Sixteen internal helpers — resolved: removed from the public surface.
compileExpression,getCallback,getClosestIndex,getEffect,isCheckbox,isInput,isSelect,parseEventDefinition,readControlValue,resolveDataRegistry,resolveDetailPlaceholders,resolvePropertyName,serializeControlValue,setProperty,valuesEqualandwriteControlValueno 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
withDeprecationandwithTransitionare 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
exportsentry 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
*Propstypes 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
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.export defaultwas 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.damp()'s new time base is a behaviour change inLargeText, not a translation. The v3 marquee ran faster on a 120 Hz display; it no longer does. Same forCircularMarqueewhen it is ported.LargeTextkeeps its public mutable fields (x,deltaY,transform,width) exactly as v1 had them, including thetransformfield shadowing the importedtransform()helper. That is the port being a port; the redesign is where it should change.optimizeDeps.noDiscoveryis a workaround for unported packages, not a permanent setting. Whenui-motionandui-mapboxare ported it should come back off, and the up-front scan is worth having.nodeproject deliberately has notypescriptresolve condition. Nothing in it imports a package under test, and two of its specs assert where the publishedexportsmap points — which the source condition would answer withsrc/and hide.DisclosureandDisclosureGrouplost their props type parameter, deleting roughly fifteenas unknown as Disclosurecasts and costing a subclass the ability to add its own props.DisclosureGroupwhose element is still in the DOM hands its children back to the outer group. v1's behaviour, preserved by the group'smounted()cleanup rather than by context resolution.Disclosureno longer reflects its open state intodata-option-open.aria-expandedon the trigger is the DOM statement of open state. Markup styling on[data-option-open]after the first render would break. Confirmed by decision.ui-mapboxandui-motionfrom the playground bundle is what makesdocs:buildpass 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.Transitiongroupoption and its example were deleted rather than reimplemented.grouphas 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 theFigureShopifyreveal shows.Defererror example points athttps://defer.invalid/. It has to:fetch()resolves for a 404, andDeferdoes not checkresponse.ok, so the previous/404example injected the error page's markup instead of showing theerrorref. 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.Deferalso leaves theloadingref visible after a failure; onlydefer-contenthides it.@component()and the rest do not. The docs describe it as a fact rather than a rule, because it is not one yet:Disclosure,Slider,Figure,Dialogand theData*family still need an explicitregisterComponent(), and nothing on the page tells a reader which is which.ScrollReveal.repeatkeeps the page-direction skip throughuseWindowScroll(), rather than reading which edge the element crossed off theIntersectionObserverEntryuseInView()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
V2-COMPONENTS-REVIEW.mdmarksDisclosure,DisclosureGroup,ScrollReveal,CircularMarqueeandLargeTextas kept, andREPORT.mdnever ported any of them. Four of the five are migrated here;CircularMarqueeis not.Slideris a full port inREPORT.mdand a rewrite in the review. Resolved by decision: the port lands now, the rewrite is future work.withIndexis kept by the review and does not exist in the port. I followed the port and dropped the decorator: keeping a v3 decorator that wraps a class which no longer needs wrapping would be a compatibility layer.REPORT.md's "proved in Vitest browser mode" is now literally true here too. The previous round's note thatSliderDrag"cannot pass in this repository's runner" is obsolete — all 29Slidertests pass, as do all 28Disclosuretests, all 9ScrollRevealtests and all 12InViewtests.V2-COMPONENTS-REVIEW.mdasks for a generic-API redesign ofLargeText. This PR ports it as it stands. The redesign is outstanding.migration-guides/1.0-2.0/documents a v2 that no longer exists. It tells the reader to migrateScrollAnimationtoScrollAnimationTimelineandScrollAnimationTarget, and v2 removes the whole family in favour of@studiometa/ui-motion. Flagged on the page, not rewritten.What remains for v2
LargeTextonto v4 — the barrel imports againindex.spec.tsandbarrel-exports.spec.tsexport-surface snapshotspackages/tests/__utils__/lifecycle.tsSmoothToRecordinterface copied intoCursor.tsCircularMarqueeonto v4Slideronto theIndexablefoundation, keeping the continuous-drag physicsTabs— its 3 red spec assertions are the specificationCursor,LargeTextandCircularMarqueefor a generic API@studiometa/ui-motiononto v4, then turnoptimizeDeps.noDiscoveryback off@studiometa/ui-mapboxonto v4 — it is what still blocksautoload/manifest.spec.tsData/Actionhelpers and the 8 named constants now pinned in the barrel snapshotpackages/docsfor the v2 surface: drop theFrameandScrollAnimationstories, rename theLazyInclude,AnchorScrollToandPrefetchWhenOverpages@studiometa/playgroundonto v4, or pin it, so the docs' live examples run again@studiometa/uibuild lower its decorators —dist/is unparseable in a browser todaylint:prettier's glob, which checks 3 of 204 markdown files@studiometa/ui-mapboxand@studiometa/ui-motioninpackages/playground/meta.config.jswhen they are portedpackages/eslint-plugin-ui, which still names dropped componentswithDeprecationearns its place with no consumer left@studiometa/js-toolkit/utilsbarrel imports vs. per-symbol subpaths in the ported sourcesalien-signalsfrom the@studiometa/uidependencies —Data*no longer uses it🤖 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 intodist/—var Defer = @component({…}) class extends Base {}— so 21 modules threwSyntaxError: Invalid or unexpected tokenon 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 → theon<Event>convention or an explicit$on(), plus@provide(),@children()×2 and@write()×2.@rollup/plugin-swcand@swc/coreare gone frompackages/tests.Two consequences worth review:
@component()calledregisterComponent(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, andregisterManifest()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.restoreandCarouselWrapper.reportScrollare renamed to their convention names. A downstream subclass overriding one becomes dead code with no error.ui-mapboxandui-motionare ported. The four dead imports resolved as:withExtraConfigdeleted (v4 merges config along the prototype chain),nextTickdeleted in favour of$watchChildren()(StoreLocatorwas polling for a child),addClass/removeClass→classList.debouncewas never gone and is untouched.The playground bundle exclusion is reverted, and
autoload/manifest.spec.tscollects 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
distmodules that parseTabs3,CircularMarquee2)docs:build226 tests were revived and all pass. The only failing file is
Tabs, which waits on its rewrite.Still open
@studiometa/playground's own runtime is a js-toolkit v3 application and throwscreateApp is not a functionbefore rendering anything. Site-wide and pre-existing — it hits pages that were never excluded — so it is not caused by either change here, but the playground cannot be trusted until that package is ported.Fetch.ts:81constructsnew DOMParser()at module scope, which makesFetch, both Shopify subclasses and the rootindex.jsun-importable under Node or SSR.MapboxClusterkeeps its v3register()/unregister()handshake rather than watching its items, because both are documented public API.MotionSequenceandMotionScrollTimelineread children insidemounted()and awaitwhenDOMSettled(), which does not wait for visibility strategies.