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: 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 1 issue found:
Notes:
Still open from earlier reviews (9 findings):
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 runsPrevious run archived 2026-09-02T19:41:56ZCode ReviewRisk: 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 I did not open any other changed files, including all files listed under Still open from earlier reviews (9 findings):
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:47ZCode ReviewRisk: 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 1 issue found:
Notes:
Still open from earlier reviews (9 findings):
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:52ZCode ReviewRisk: 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:
Still open from earlier reviews (9 findings):
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:35ZCode ReviewRisk: 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: Notes:
Still open from earlier reviews (9 findings):
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:16ZCode ReviewRisk: 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 1 issue found:
Notes:
Still open from earlier reviews (8 findings):
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:06ZCode ReviewRisk: 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 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):
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:37ZCode ReviewRisk: 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 1 issue found:
Still open from earlier reviews (7 findings):
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:55ZCode ReviewRisk: 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:
Still open from earlier reviews (7 findings):
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:10ZCode ReviewRisk: Medium — issues that should be addressed before merge. Reviewed the following changed files: 1 issue found:
Still open from earlier reviews (6 findings):
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:16ZCode ReviewRisk: 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 Still open from earlier reviews (6 findings):
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(); | ||
| } |
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.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
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
…at/v2-component-port
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
…at/v2-component-port
…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
…nto feat/v2-component-port
`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
…feat/v2-component-port
| url: URL, | ||
| requestInit: RequestInit, | ||
| self: unknown, | ||
| ) => string; | ||
| return fn.call(this, response, url, requestInit, self); |
There was a problem hiding this comment.
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.
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
…at/v2-component-port
`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
… feat/v2-component-port
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
…at/v2-component-port
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
…at/v2-component-port
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
…v2-component-port
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
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)HoverableIndexable/withIndexIndexableiswithIndex(Base)plus the component name — one implementation, the same split aswithTransition/TransitionInView(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)Carouselfamily, which absorbed the four controls it had andSliderlackedStickyTimer(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 itwithDeprecationAccordion(Accordion,AccordionItem)Disclosure+DisclosureGroupFrame(6 classes +AbstractFrameTrigger)FetchModal/ModalWithTransition/PanelDialogScrollAnimation(7 classes),animationScrollWithEase,withScrollAnimationDebugui-motionImageGrid,Reinsurance,StyledModal,StyledPanel(Twig)AbstractSliderChildSliderContext; its export subpath is removedTabsstylesdropped with the last consumer ofconfig.options[…].merge,enable/disablenamespaced totabs-*CircularMarqueeBasewithscrolled()/ticked(), which v4 only calls when the matching service is mixed in — so it did nothing at all. The generic-API redesign is still outstanding30 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 theCarouselfamily — along withwithDeprecation.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
Everything this section originally listed has since landed except the redesigns. What is left:
Cursor,LargeTextandCircularMarqueegeneric-API redesigns the review asks for. All three are ported as they stand; the redesigns are API design work, not migration.packages/twig-extension) and anything else on the PHP/Composer side.Carouselcontrols. Worth noting v1 shipped none forSliderorCarouseleither, so this is an addition rather than a regression.2.0.0-alpha.0release.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.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 forIndexableInterface.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 3 · Helpers 0 · Types 110 · All exports 241 —withIndexandIndexableInterfaceare the two symbols the restoration added back.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.Re-verified in a browser after the
waitUntilfixddev 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 ownupdating style… / script updated!chatter.Motion/examples→with-dialogplaygrounddocumentlistener aswaitUntil=function. A test extension registered oncloseheld the dialog open and scroll-locked for 2 s; releasing it hid the dialog and released the scroll. On the dialog element theclosename is delivered twice, as documented: the extendable one (bubbles=true, detail object) then the platform's native one (bubbles=false,detail=undefined) after hidingMotionView/examples→ambient-dialogplaygroundMotionViewinstances entered onopen(opacity-0→opacity-100,translate-y-8 scale-95→translate-y-0 scale-100) and left onclose. The recorded order isview0:leave:start, view1:leave:start, view0:leave:end, view1:leave:end, native close— concurrent, and the native hide lastMotionView/examples→ambient-bind-ifplaygroundMotionView.update(), which the literaldom-updatelistener never didCaveat on how this was measured. esm.sh could not build
@studiometa/js-toolkit@4.0.0-alpha.1/EVENTSduring the session — 20 requests over 13 minutes returned408or timed out, while already-built subpaths of the same package returned200— so@studiometa/ui-motionwould not load in the playground at all. The twoMotionViewruns above used the real builtMotionView.jswith that one bare specifier rewritten to an inline module of the same constant;Dialog,emitExtendable(),domUpdate()andMotionView's own code are the real build. Thewith-dialogrun needed no such rewrite. Theambient-dialogstory 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 (importingEVENTSfrom the barrel) would pull the whole js-toolkit graph into aMotionView-only CDN import.Still open in the docs
The v1 → v2 migration guide is unwritten.Written — see The migration guide below.— both are audited against their ported sources in the last round.@studiometa/ui-mapboxand@studiometa/ui-motiondocumentation is untouched— both sources are on v4 now, and their pages follow.TabsandCircularMarqueedocumentation is untouchedHeroimage 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,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.Twenty-one of the ported components self-register throughObsolete: the decorators are gone, so every component needs an explicit@component()and the rest do not.registerComponent()— which is also what v1 required, so it is not a v1 → v2 breaking change.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— reversed. 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 indecorators/withIndex.ts, andIndexableiswithIndex(Base)plus the component name, so there is one implementation rather than two copies that can drift../withIndexis back in theexportsmap,withIndexandIndexableInterfaceare back in the root barrel, andpackages/tests/decorators/withIndex.spec.tscovers the mixin form — the port dropped the feature with no test failing, which was the real defect.V2-COMPONENTS-REVIEW.mdrecords the resolution in round 5.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 v4Slider— round 7 supersedes it with theCarouselfamily rather than rewriting it ontoIndexableTabs— its 3 red spec assertions were the specificationCursor,LargeTextandCircularMarqueefor a generic API@studiometa/ui-motiononto v4, then turnoptimizeDeps.noDiscoveryback off@studiometa/ui-mapboxonto v4 — it is what still blockedautoload/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/playground@0.4.0shipped and the stories executejs twoslashblocks are not type-checkedlint:prettier's glob, which checks 3 of 204 markdown filespackages/eslint-plugin-ui, which still names dropped components — its list is generated from the autoload catalog nowwithDeprecationearns its place with no consumer left — dropped@studiometa/js-toolkit/utilsbarrel imports vs. per-symbol subpaths in the ported sourcesalien-signalsfrom the@studiometa/uidependencies —Data*no longer uses it2.0.0-alpha.0, write the changelog entries and publish undernextRound: 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 portingSlider's physics ontoIndexable; 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, andscroll-snap-type: nonegives the freeform settle natively, so the one capability it uniquely bought is a CSS option rather than a second engine.Accessibility contract
Carouselpreviously emitted no accessibility implementation at all. Now:inerton 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.role="group"with a required accessible name, and acarousel.unnameddiagnostic when it is missing.groupoverregiondeliberately: an unnamed region is dropped from the landmark list anyway, and four carousels on a listing page should not each be a landmark.slide-labeloption, replacingaria-label = this.$id, which named every slide after an internal identifier.aria-roledescriptiondropped rather than shipped untranslated.tabindex="0"+ role + name only when the track has no focusable descendants, nevertabindex="-1".scroll-paddingmirrored from the track, andprefers-reduced-motionobserved at runtime through the shipped service.One measured finding worth knowing:
isIntersectingalone is the wrong test. An element whose box merely touches the root edge reportsisIntersecting: truewithintersectionRatio: 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
__snap()now splits on a flick threshold ofclamp(viewport * 0.2, 50, 225)px and clamps to exactly one snap above it, withskipSnapsas the opt-out. No magic multiplier — the projection is the drag service's own.scrollSnapTypewas restored on ascrollendthat 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.
CarouselPlayAutoplay as a component, off by default, extending
TimerProgressso no new timing code exists:timer-end→goNext(), 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, noaria-pressed, pause on hover and focus-in without auto-resume, and reduced motion suppressing only the automatic start.Also in this round
CircularMarqueewas the last v3 component and did nothing at all —scrolled()/ticked()are only called when the matching service is mixed in, and it extended bareBase. Ported; the workspace is now at zero type errors.CI had been failing at
npm cisince the playground bump: apatch-packagepatch 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 droppedAccordiontemplate, and aunit_nodejob 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_nodefails 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 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.The documentation round
Five commits, after the ports and the decorator removal.
The migration guide
migration-guides/1.0-2.0/index.mdis rewritten and thedangercallout 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 is2.x, which is v1.10.0:@component()decorator episode.git grep "@component(" main -- packages/ui/srcis empty. The decorators were introduced by8d5412fcon this branch and removed by5727055eon this branch. v1 already required an explicitregisterComponent()—packages/docs/guide/usage/index.mdonmaindocuments exactly that. There is no registration breaking change, apart fromregisterComponent()losing its second alias/selector argument andcreateApp()going away, both of which the guide covers as js-toolkit changes.main's family index files list their exports explicitly and contain noexport *. They leaked to the surface during the port and were withdrawn by118192c4. Not a migration item.Fetch.navigate/send/restoreandCarouselWrapper.reportScroll. v1 already names themonClick,onSubmit,onWindowPopstateandonScroll(main:packages/ui/src/Fetch/Fetch.ts:238,258,270andCarousel/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), sodetailwas the array of positional arguments for every component — includingFetch'sfetch-*andDraggable'sdrag-*, whose payload was already a single object and arrived as[{ … }]. Everyevent.detail[0]in a v1 codebase has to change. Also added:Dialog's changedwaitUntiltransitioner shape, theTransitiongroupoption,withTransition's droppedtransition-toggle,MotionView's droppedtoggle, 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 fourMotionfamily pages, which had no registration or installation section at all. Three sentences were wrong rather than missing — theDraggablestory 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 aMapboxClusterItemregistering with its closestMapboxCluster, which is still true.Reference pages audited against the sources
Documented API that does not exist, now removed:
DisclosureGroup.register()/unregister(),SliderBtn'scontainoption,SliderDrag'sscrollLockThresholdoption,ViewTransition'stoggleevent.Wrong claims corrected: the four
Disclosureevents carry no payload; transition failures are$error()not$warn();Indexable's boundaries act at both bounds;SliderCount'scurrentref is one-based;SliderItemhas eight public members rather than none;SliderDragis optional in theSlideranatomy;MapboxMap.mapandAbstractMapboxMapChild.mapareundefineduntilmapbox-glresolves;isLoadedis a field;MapboxClusterItem'spopupis 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.twigforwardedevent.detail[0], and v4 puts the payload object ondetaildirectly, so every renderer receivedundefinedand 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'swaitUntilis restored, on js-toolkit v4's own primitivev1's
Dialogdispatchedopenandclosewith adetail.waitUntil()function and awaited every extension a listener registered. The port replaced it with bare$emit()calls, sodetailbecamenulland 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'semitExtendable(), which is that behaviour generalised — bubblingCustomEvent, synchronous-only registration reported asprotocol.late-registration, every registration awaited, a failing extension reported ascallback.extendable-event-extension-failedrather than rejecting the choreography.One contract changed on purpose. v1 duck-typed a transitioner —
enter()awaited onopen,leave()onclose. The shared primitive looks up a method named after the event (invoke(registration, event, [])), so a duck-typed object now offersopen()/close(). Anything with a different pair of names registers with the function form, which is what the primitive documents for exactly this case:Thenables — the simpler half of the contract and the one most consumer code uses — are unchanged. The alternative, keeping
enter()/leave()and adapting insideDialog, is not reachable:emitExtendable()owns thewaitUntilclosure, 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/ViewTransitionchild never receives them and cannot register itself twice. Both mechanisms start in the same tick inside onePromise.all, so they run concurrently — a slow extension does not delay the children and vice versa — andclose()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 secondclose()in that window would otherwise emit the event again, run everyleave()again and release the scroll twice.MotionViewwas the live casualty, and it had a second dead wiring.__onDialogPhasehandedthistowaitUntil(), which the event-name lookup could never call.__onDomUpdatelistened for the literaldom-updatewhiledomUpdate()dispatchesEVENTS.dom.update—js-toolkit:dom:update— so nothingFetchorDataBindannounced ever reached it either. Both specs passed because they dispatched hand-made events matching the component's mistake; they now exercise the realemitExtendable()anddomUpdate(), plus the nativecloseevent a<dialog>fires under the same name.Dialog/js-api.md, the migration guide and the four dependent pages describe the restored behaviour. TheMotionwith-dialogstory hands each animation towaitUntil()— with a?.guard, because the platform's owncloseevent shares the name and carries no detail — so its exit is no longer cut off.MotionView'sambient-dialogstory runs.Why
js twoslashblocks are not type-checkedThe cause is two missing compiler options. Twoslash runs the block as
index.js, and TypeScript does not report diagnostics in a.jsfile unlessallowJsandcheckJsare on..vitepress/config.tssets neither, so a block importing a symbol@studiometa/uidoes 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
checkJson, so turning it on in the config is a one-line change that would makedocs:buildactually verify the samples. Not made here — it is a tooling change, and this branch is already long.Verified in a browser
ddev startagainst the built output, with@studiometa/playground@0.4.0. Pages loaded, and what the console said:/migration-guides/1.0-2.0/Disclosure/examplesdisclosure-opendetail=null,disclosure-group-opendetailkeysitem,index,disclosure-group-changekeysitems, single-open honoured — the guide's payload table, confirmed at runtimeIndexable/examplestranslateX(-100%), counter readsSlide 2 of 4Slider/examplesrole="group"+aria-roledescription="carousel"on the root,tabindex="0"on the wrapper,SliderDragon the wrapper, prev<button>disabledat index 0; → from the wrapper moves1 / 10→2 / 10and thecurrentref1→2Dialog/examplesopenandcloseboth arrive withdetail === null— nowaitUntil(measured before the fix; see the re-run below)Motion/examplesopacity: 0→1, transform reset); 60 ms after close the dialog is shut and the reverse has barely movedMotionView/examplesambient-dialogstory opens the dialog with bothMotionViewelements stillopacity-0— the wiring never firesSlider/js-api/slider,Sentinel/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
npm run docs:buildvalidate-reference: 55 Reference entries, 239 symbols, 5 concepts. ~29 sallowJs/checkJson, which the config does not setprettier --checkon the 44 changed filesIndexable/stories/slider/app.twig, unchanged by this roundnpm run testTabsfailures as the baseline, run three times with identical results. ThewaitUntilwork adds 14 passing testsnpm run lint:typesTabs(3) andCircularMarquee(2)npm run lint:staticStill open
No story on the docs site executes.Fixed by@studiometa/playground@0.4.0.— fixed; it is built on first use, and all 114Fetch.ts:81constructsnew DOMParser()at module scopedistmodules import under Node.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.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.mdcarries both.Built
Slider's seven classes are deleted. TheCarouselfamily gains the four controls it lacked —CarouselDots,CarouselThumbnails,CarouselCount,CarouselProgress— each on its own element, each optional, none importingCarousel. All eight children are registered byCarousel, soregisterComponent(Carousel)is the whole setup:config.componentsentries resolve per element, so a carousel with no play button never constructs one.Three round-7 claims the work disproved
Slider'smode: left|center|rightas equivalent to scroll alignment. It was not:Carouselhardcodedalign: center, so a programmatic move and a native snap only agreed where a slide filled the track.Carousel.positionsreads each slide'sscroll-snap-alignnow and scrolls to the offset that alignment names.mode="left"isscroll-snap-align: start,mode="right"isend. No option was added — analignoption would be a second place to say what the CSS already says.skipSnapsoption that existed only to opt out of it.fitBounds: falseis restored, as CSS.scroll-snap-type: nonefrees 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
CarouselWrapper.onScrollreported the closest slide on every scroll frame, including the frames of the smooth scrollgoTo()had just started, so clicking a dot three slides away drove the index3 → 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."__restoreSnappingstripped inlinescroll-snap-typedeclarations. 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 fromscrollLeft, 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
CarouselBtnmarked the current slidearia-disabledwhileCarouselDotsandCarouselThumbnailsmarked itaria-current— three pickers, one job, two CSS hooks. They all usearia-currentnow;prev/nextkeep the nativedisabled, which names an action that genuinely cannot be performed.eslint-plugin-ui'sUI_COMPONENT_NAMESwas hand-maintained under a comment asking the next person to keep it in sync. It had drifted both ways — still namingAccordion,Frame*,LazyIncludeandModal, never having learnedCarousel,Dialog,Fetch,Timer,ToasterorTrack. It is generated from the autoload catalog now andmanifest:checkfails 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 forcenterand 32/354/675 forend; free scrolling lands a drag at 803px on a 177px slide pitch, between slides. The four carousels that were tripping the component's owncarousel.unnamedwarning in our own docs are named.State
npm run testnpm run lint:typesnpm run docs:buildnpm run manifest:checkdistmodules that import under NodeKnown 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.