refactor(bundle-size): convert API classes to factory functions#531
Draft
layershifter wants to merge 48 commits into
Draft
refactor(bundle-size): convert API classes to factory functions#531layershifter wants to merge 48 commits into
layershifter wants to merge 48 commits into
Conversation
Class scaffolding (constructor + private field declarations + this-binding on arrow methods) doesn't mangle as well as plain top-level functions and closures. Switching RestorerAPI to a factory shaves ~570 B minified / ~45 B gzipped on getRestorer. Pattern: closures replace private fields; the returned object exposes only the public methods the interface requires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Static methods (getDeloser, getHistory, forceRestoreFocus) are kept under a same-named const namespace, so external call sites in CrossOrigin.ts continue to work unchanged. The internal state needed by those statics is exposed via an internal interface with _-prefixed accessors. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📊 Bundle size report🤖 This report was generated against a579ebbd50e37f1565551549fe57bbc9ddafab64 |
Coordinated migration of the Subscribable abstract class and all 5 consumers — none can be done in isolation since the class hierarchy goes away. - `Subscribable` → `createSubscribable<A,B>()` factory returning a `SubscribableCore` interface. The public surface (subscribe/ subscribeFirst/unsubscribe) matches `Types.Subscribable`; setVal/ getVal/trigger are exposed for the composing factory only. - `KeyboardNavigationState` → `createKeyboardNavigationState` - `FocusedElementState` → `createFocusedElementState`. External statics (findNextTabbable, forgetMemorized, isTabbing) preserved under a same-named const namespace. - `ObservedElementAPI` → `createObservedElementAPI` - `CrossOriginFocusedElementState` → `createCrossOriginFocusedElementState`, with `setVal` static kept under same-named const namespace - `CrossOriginObservedElementState` → `createCrossOriginObservedElementState`, with `trigger` static kept under same-named const namespace Bundle-size impact (vs branch's previous tip): | Fixture | Δ min | Δ gzip | |--------------------|----------:|--------:| | all exports | -2.367 kB | -111 B | | createTabster | -1.151 kB | -126 B | | getCrossOrigin | -2.367 kB | -242 B | | getDeloser | -1.161 kB | -128 B | | getGroupper | -1.161 kB | -144 B | | getModalizer | -1.161 kB | -162 B | | getMover | -1.161 kB | -107 B | | getObservedElement | -2.304 kB | -227 B | | getOutline | -1.161 kB | -128 B | | getRestorer | -1.161 kB | -119 B | Most of the ~1.16 kB drop on every getX is the core's Subscribable + KeyboardNav + FocusedElement boilerplate that lives in every bundle. ObservedElement and CrossOrigin pick up an additional ~1.1 kB each from their own factory conversion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds each fixture in bundle-size/ with webpack + source-map devtool, then walks every byte of the minified output and asks the source map which original module each byte came from. Aggregates per-source totals and prints a sorted breakdown. Tool tells us where the bytes are actually going post-minification — not pre-mangle source size, but real bundle attribution. Useful for deciding which modules are worth optimizing next. Usage: node scripts/analyze-bundle.mjs # all fixtures node scripts/analyze-bundle.mjs createTabster # one fixture Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DummyInput was 23.6% of the core bundle (the single largest module).
Converting the four classes here in one coordinated pass:
- DummyInput → createDummyInput
- DummyInputManager → createDummyInputManager (interface + factory),
with externally-used statics moveWithPhantomDummy
and addPhantomDummyWithTarget kept under a same-
named const namespace
- DummyInputManagerCore → createDummyInputManagerCore. The class's
constructor-return-existing-instance trick (used
when an element already has dummy inputs from
another subsystem) becomes a natural early-return
in a factory.
- DummyInputObserver → createDummyInputObserver
The four DummyManager subclasses (RootDummyManager, ModalizerDummyManager,
MoverDummyManager, GroupperDummyManager) are migrated from `extends
DummyInputManager` to composing `createDummyInputManager` and calling
`manager.setHandlers(...)` directly. They become free functions
(createRootDummyManager etc.) returning the same DummyInputManager
interface, so the consuming `dummyManager` field on Root/Modalizer/Mover/
Groupper is now typed as the interface rather than the subclass.
Bundle-size impact (vs branch's previous tip):
| Fixture | Δ min | Δ gzip |
|--------------------|----------:|--------:|
| all exports | -3.486 kB | -485 B |
| createTabster | -3.266 kB | -413 B |
| getCrossOrigin | -3.486 kB | -491 B |
| getDeloser | -3.265 kB | -432 B |
| getGroupper | -3.279 kB | -415 B |
| getModalizer | -3.283 kB | -457 B |
| getMover | -4.612 kB | -471 B |
| getObservedElement | -3.264 kB | -433 B |
| getOutline | -3.271 kB | -439 B |
| getRestorer | -3.264 kB | -429 B |
The ~3.3 kB drop on every fixture is the core DummyInput conversion;
getMover gets an extra ~1.3 kB because MoverDummyManager was its own
subclass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bytes mode tells you which source files made it into the bundle;
this new identifiers mode tells you which symbols are still long after
minification. Terser preserves property names by default, so anything
accessed as `.foo` keeps its full string per call site — those are the
actionable rename targets.
Output sorts by `count × length` and tags each name as:
property every occurrence is a property access (`.foo` / `["foo"]`)
free none are — bare references (locals, imports, globals)
mixed a mix of both
Findings on the current branch (createTabster, 35 kB):
count len total kind name
36 7 252 B mixed dispose
21 12 252 B mixed nodeContains
20 12 240 B mixed uncontrolled
25 8 200 B mixed _tabster
7 21 147 B mixed _forgetMemorizedTimer
9 16 144 B mixed _setFocusedTimer
8 14 112 B mixed _dummyObserver
The `_`-prefixed names are private fields on Tabster/TabsterCore/
Focusable/Root (still classes). Either enable Terser's
`mangle.properties` with a `^_` regex, or finish the class→factory
migration and they'll become closure-captured locals that mangle to
single chars.
Usage:
node scripts/analyze-bundle.mjs # bytes only
node scripts/analyze-bundle.mjs createTabster --mode=identifiers
node scripts/analyze-bundle.mjs --mode=both # both reports
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ith addListener/removeListener helpers Two thin wrappers in Utils.ts: addListener(target, type, handler, options?) removeListener(target, type, handler, options?) Each call site shrinks from `el.addEventListener(...)` (16 chars preserved by the minifier as a property name) to a function call whose name mangles to a single character. Helpers also accept nullable targets via optional chaining, so `el?.addEventListener(...)` patterns become a flat `addListener(el, ...)` call. Bundle-size impact (vs branch's previous tip): | Fixture | Δ min | Δ gzip | |--------------------|---------:|--------:| | all exports | -683 B | -28 B | | createTabster | -211 B | -2 B | | getCrossOrigin | -586 B | -36 B | | getDeloser | -244 B | -2 B | | getGroupper | -312 B | -12 B | | getModalizer | -247 B | +1 B | | getMover | -290 B | -2 B | | getObservedElement | -202 B | +1 B | | getOutline | -272 B | -6 B | | getRestorer | -316 B | -12 B | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…th createTimer()
createTimer() returns a small handle (set/clear/isActive) wrapping a
single timer id. set() auto-cancels any pending schedule; clear() is
idempotent; the callback auto-clears the id when it fires. That kills
the 5-line `if (this._fooTimer) { win.clearTimeout(this._fooTimer);
this._fooTimer = undefined; }` boilerplate that lived in 15+ places
and inlined `setTimeout`/`clearTimeout` (10–12 char property names) at
each one.
Migrated:
- Outline: updateTimer
- Modalizer: restoreModalizerFocusTimer, hiddenUpdateTimer
- Mover: ignoredInputTimer
- Deloser: restoreFocusTimer
- CrossOrigin: blurTimer (factory), _pingTimer (internal class)
- Groupper: updateTimer
- DummyInput: disposeTimer (also drops the clearDisposeTimeout
closure that wrapped manual id-tracking),
addTimer (DummyInputManagerCore)
- Tabster: _initTimer, _forgetMemorizedTimer (TabsterCore)
- Root: _setFocusedTimer
Skipped: DummyInputObserver's updateTimer / updateDummyInputsTimer,
because they call `this._win?.()` after dispose — the helper assumes
getWindow() throws if disposed. Not worth a special case.
Bundle-size impact (vs branch's previous tip):
| Fixture | Δ min | Δ gzip |
|--------------------|--------:|--------:|
| all exports | -748 B | -183 B |
| createTabster | -248 B | -3 B |
| getCrossOrigin | -748 B | -126 B |
| getDeloser | -328 B | -19 B |
| getGroupper | -299 B | -31 B |
| getModalizer | -341 B | -7 B |
| getMover | -340 B | -42 B |
| getObservedElement | -245 B | -13 B |
| getOutline | -347 B | -9 B |
| getRestorer | -253 B | -7 B |
Net code-quality win: ~80 lines of `if (timer) { clearTimeout(timer);
timer = undefined; }` collapse to one-liners.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Window
Per feedback, the timer is now just \`{ id }\` and the helpers are free
functions. Two wins:
1) No window plumbing. \`createTimer()\` takes no args. setTimer/clearTimer
call the global setTimeout/clearTimeout. The window-binding inside the
helper was load-bearing only for cross-window scenarios, but the timers
here are always in the main window context.
2) Each call site shrinks. With methods (\`timer.clear()\`), the property
name \`.clear\` was preserved by the minifier (~5 chars per call site).
With free functions, \`clearTimer\` mangles to a single character at
the call site (the function name is local). Same for setTimer/
isTimerActive.
API:
const t = createTimer();
setTimer(t, callback, delay);
clearTimer(t);
isTimerActive(t);
Migrated all 30+ call sites and dropped the \`createTimer(getWindow)\`
arg from each.
Bundle-size impact (vs branch's previous tip):
| Fixture | Δ min | Δ gzip |
|--------------------|-------:|--------:|
| all exports | -86 B | +10 B |
| createTabster | -38 B | +5 B |
| getCrossOrigin | -86 B | +27 B |
| getDeloser | -49 B | +16 B |
| getGroupper | -40 B | +19 B |
| getModalizer | -33 B | +22 B |
| getMover | -38 B | +20 B |
| getObservedElement | -33 B | +12 B |
| getOutline | -51 B | +7 B |
| getRestorer | -31 B | +13 B |
The minified gain is real per call site; gzip ticks up slightly because
the new exported function names \`setTimer\`/\`clearTimer\` are less
gzip-friendly than the previously-shared \`.set\`/\`.clear\` patterns —
but minified is what matters for CDN size and parse cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add per-method fixtures (findAll, findLast, findNext, findPrev) so any future split of FocusableAPI into tree-shakable parts shows up as divergence from the createTabster baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Breaking change. Removes `tabster.focusable.X(...)` and replaces it with free-function exports: findFirstFocusable, findLastFocusable, findNextFocusable, findPrevFocusable, findDefaultFocusable, findAllFocusable, findFocusable, isFocusable, isElementVisible, isElementAccessible, getFocusableProps. The 11 functions take the Tabster wrapper as the first argument, internal `_*` variants take TabsterCore for cross-module use. Also threads `window` through setTimer/clearTimer so timers route through the per-tabster window's setTimeout/clearTimeout. Migrated remaining raw .setTimeout/.clearTimeout call sites to use the helpers. Bundle-size impact (vs prior): - createTabster (core): 34.74 → 33.80 kB (-940 B) - all exports: 98.26 → 96.47 kB (-1.79 kB) - Per-getX fixtures: -180 B to -1.16 kB Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t helper Adds dispatchEvent(target, event) helper in Utils.ts that mangles to a single char and migrates ~21 internal call sites. Mirrors the pattern used for addListener/removeListener/setTimer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the public getAllObservedElements method from ObservedElementAPI. The use case is covered by onObservedElementChange, which streams adds / removes / name changes as they happen — callers can maintain their own map if needed. Removes the corresponding test, the storybook APIDemonstration story, and the createObservedWrapperWithAPIDemo helper. Bundle-size: getObservedElement -244 B. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… class Move the three static methods (getTabsterContext, getRoot, getRootByUId — last one was dead code) out of the RootAPI class body and into a new src/Context.ts. The 30+ internal call sites now import a free function instead of going through `RootAPI.getTabsterContext(...)` on a class that ships ~310 lines of unrelated instance code. The RootAPI class still ships (it's the runtime backing for tabster.root), but consumer modules that only want to walk the context tree no longer pull the entire class definition through the import graph. _autoRoot and _autoRootCreate are no longer `private` — Context.ts reaches into them for the auto-root fallback. Marked /** @internal */. Bundle-size: - createTabster (core): 33.80 → 33.38 kB (-418 B) - all exports: 96.47 → 95.40 kB (-1.07 kB) - Each getX fixture: -350 to -560 B Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s Set TabsterCore.dispose() previously hard-coded eight `tabster.X?.dispose()` calls — one per extended API. Each `getX` factory now adds itself to `tabster.disposers` (a Set<Disposable>), and dispose() iterates and clears the set instead. Adding a new extended API no longer requires editing TabsterCore.dispose. Bundle-size: - createTabster (core): 33.38 → 33.26 kB (-123 B) - Each getX fixture: -92 to -104 B - all exports / getCrossOrigin: +46 to +65 B (each of 8 APIs adds a `.add()` call; small overhead when all are loaded together) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new dimensions for bundle introspection:
- --mode=functions per-method drilldown using @babel/parser; attributes
source-mapped bytes to the enclosing function so we
can see which methods inside a heavy file dominate.
- --mode=exports rebuilds with usedExports=true, concatenateModules=false;
dumps each tabster module's provided/used/unused
exports to surface API surface that survived
tree-shaking but is never called.
- --diff <a> <b> builds two fixtures and prints the per-source delta
(composes with --mode=functions or --mode=exports for
per-method or per-export deltas).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… dispatch
The function had explicit per-feature switches in both the add and remove
loops, so every consumer paid for knowledge of every feature even when
they didn't use it.
Add loop is now: plain-value keys (focusable/uncontrolled/sys) get a verbatim
assignment, otherwise dispatch to a handler registered by the feature's getX
factory. Three previously-inline cases now register handlers from where
their state lives:
- root: RootAPI constructor (always-on, paid by createTabster)
- outline: getOutline.ts (paid only when getOutline is called)
- observed stays inline because its post-assignment notification triggers
synchronous waiters that read tabsterOnElement.observed back through
storage — handlers can't express that ordering.
Remove loop collapses the 9-case switch to a generic "dispose if disposable
+ delete" pattern, with one observed-specific notification.
Drop RootAPI.onRoot — its rootById bookkeeping moves into createRoot and
_onRootDispose, which both already manage _roots; nothing else called it.
ObservedElementAPI.onObservedElementUpdate now takes the new observed
props as an argument instead of reading them back from storage, since the
remove path needs to call it after delete.
createTabster: 32.52 kB → 32.16 kB (-360 B / -1.1%)
Instance.js: 1.43 kB → 1.01 kB (-420 B / -29%)
Root.js: 2.81 kB → 2.88 kB (+74 B for handler registration +
inlined rootById ops)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r resolver
The big mover/groupper containment-conflict block inside _acceptElement
was 50% of Focusable.js but only ran when at least one of those features
was loaded — createTabster paid for code it could never execute.
Move the block (containment resolution + mover/groupper.acceptElement
dispatch) into a new src/MoverGroupperResolver.ts and register it on
core.focusableContextResolver from getMover/getGroupper. Without those
gets, the resolver field is undefined and _acceptElement skips the call.
Drop the dead `!mover` short-circuit (block 1) and the redundant
`moverElement && groupperElement` guard (block 4) — both were always
satisfied by the time the surrounding `if` ran.
createTabster: 32.16 kB → 31.65 kB (-510 B)
Focusable.js: 3.92 kB → 3.41 kB (-510 B)
_acceptElement (within Focusable.js):
1.97 kB → 1.46 kB (-510 B / -26%)
getMover / getGroupper add 598 B for MoverGroupperResolver.js, which is
roughly the bytes that previously lived in Focusable.js — they moved
rather than disappeared, but createTabster no longer carries them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The used/unused list reports what webpack's tree-shaker considers reachable, not what survives Terser DCE in the production build. Side-effect-free class definitions and unused functions get stripped later, so a long "unused" list in this view is often already gone from the actual bundle. Note the limitation in both the file header and the report header so we cross-check with bytes mode before chasing a phantom leak. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ummyInputs The dummy-input factory and its dependencies (createDummyInputManager, DummyInputManagerCore, focusedElement.getFirstOrLastTabbable, keyboardNavigation.setNavigatingWithKeyboard, nativeFocus) accounted for ~3 kB inside Root.js + DummyInput.js even when an app didn't want browser-Tab handling. Move the factory to a new src/RootDummyManager.ts and gate Root.addDummyInputs() on tabsterCore.rootDummyManagerFactory, which is registered by a new public getRootDummyInputs(tabster) helper. Without that opt-in, Root.addDummyInputs() is a no-op even when controlTab/rootDummyInputs is true. Apps that need browser-Tab handling call getRootDummyInputs(tabster) once after createTabster (and after the flag is set) — same shape as the other getX factories. The internal RootAPI.onRoot handshake is gone (folded into createRoot and _onRootDispose by an earlier commit), so the factory only needs the core, the WeakHTMLElement, the setFocused callback, and sys. Also wire test-setup.js and .storybook/preview.mjs to call getRootDummyInputs when controlTab/rootDummyInputs is set so existing tests/stories continue to behave the same. createTabster: 31.65 kB → 28.54 kB (-3.11 kB / -9.8%) DummyInput.js: 5.54 kB → 2.73 kB (-2.81 kB) Root.js: 2.88 kB → 2.60 kB (-280 B) Breaking change: controlTab=true (default) no longer auto-creates dummy inputs; consumers that need browser Tab handling must now call getRootDummyInputs(tabster) once. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ithDefaultAction With dummy inputs now opt-in via getRootDummyInputs, the phantom-dummy fallback path inside Root.moveOutWithDefaultAction has no remaining audience: opted-in consumers always have a _dummyManager that handles the move; opted-out consumers don't want Tabster managing Tab boundaries at all. Drop the fallback. Root.moveOutWithDefaultAction becomes a thin pass- through that no-ops without a manager, and Root.ts loses its runtime import of DummyInputManager (now type-only for the field). The other phantom-dummy call site (FocusedElement.ts:298, used for iframes / uncontrolled regions) is unaffected. createTabster: 28.54 kB → 28.45 kB (-90 B) Root.js: 2.60 kB → 2.50 kB (-100 B) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the "controlTab: false" path where the consumer must opt into dummy-input behavior via getRootDummyInputs. Today the diff against plain getModalizer is only ~440 B (the getRootDummyInputs helper plus RootDummyManager) because the per-Modalizer dummy code is still pulled in statically via Modalizer.ts's createModalizerDummyManager. The fixture will be the measurement target for upcoming refactors that move the per-feature dummy factories behind the same opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each part used to inline its own per-instance create*DummyManager that
runs only when controlTab=false. With controlTab=true (the default), the
factory was dead code that still shipped — Terser couldn't prove it
unreachable since the gate is a runtime flag.
Move the three factories out to MoverDummyManager.ts /
GroupperDummyManager.ts / ModalizerDummyManager.ts. Register them on
tabsterCore.{mover,groupper,modalizer}DummyManagerFactory inside
getRootDummyInputs alongside the root factory; each part's constructor
calls the registered factory instead of an inline import. Without
getRootDummyInputs, the dummy code is absent from the bundle.
Also restore the phantom-dummy fallback for Root.moveOutWithDefaultAction
behind the same opt-in. With dummy inputs disabled the call no-ops
(consistent with the rest of the opt-in surface); when getRootDummyInputs
is called the registered moveOutOfRoot routes either through the root's
dummy manager or through DummyInputManager.moveWithPhantomDummy. This
restores the master behaviour for `controlTab: false` /
`rootDummyInputs: false` consumers without putting moveWithPhantomDummy
back into the always-on Root path.
Test setup now calls getRootDummyInputs unconditionally so
controlTab=false runs get the per-part dummy factories.
createTabster: 28.45 kB → 28.48 kB (+30 B for routing)
getMover: 45.05 kB → 41.66 kB (-3.39 kB / -7.5%)
getGroupper: 38.05 kB → 34.42 kB (-3.63 kB / -9.5%)
getModalizer: 38.80 kB → 35.80 kB (-3.00 kB / -7.7%)
getModalizerWithDummyInputs: 40.19 kB (the controlTab=false
cost: +4.39 kB over
plain getModalizer)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TabsterCore unconditionally created a DummyInputObserver in its constructor — but the observer only earns its keep when dummy inputs exist (it tracks their positions through layout/scroll changes and notifies them on DOM mutations). Apps that don't opt into dummy inputs were paying for ~900 B of observer code (createDummyInputObserver + its dispose/domChanged/updatePositions/focusIn closure) that never ran. Move the createDummyInputObserver call into getRootDummyInputs and make the field optional on TabsterCore. The two call sites that used it (DummyInput.ts internals + MutationEvent.ts batch hook) already guarded the property; switch to optional chaining on the observer itself so the no-opt-in path is a no-op end to end. Disposal goes through the disposers Set, which is how every other extended API already cleans up. createTabster: 28.48 kB → 27.60 kB (-880 B / -3.1%) DummyInput.js: 2.73 kB → 1.93 kB (-800 B in createTabster baseline) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stries
Two changes that work together to make the default consumer experience
honest while keeping the opt-out path slim:
1. createTabster auto-installs root-level dummy-input infrastructure
when controlTab (default true) or rootDummyInputs is on. Before this,
the default createTabster(window) silently produced a Tabster that
couldn't drive Tab navigation until the consumer added a separate
getRootDummyInputs(tabster) call. The flag's default now matches the
actual default behaviour.
2. Drop the moverDummyManagerFactory / groupperDummyManagerFactory /
modalizerDummyManagerFactory registry fields. They no longer serve a
purpose: each part's getX always pairs with its dummy-manager file
import (the bytes load with the feature regardless), and the
"is dummy infrastructure installed" gate is already provided by
tabster._dummyObserver. Mover/Groupper/Modalizer now import their
factories directly and check `tabster._dummyObserver` before
creating a dummy manager.
getRootDummyInputs is also slimmer — it only registers the root
factory, the observer, and moveOutOfRoot routing. Per-part factories
no longer travel with it, so a Modalizer-only consumer doesn't pay
for Mover/Groupper dummy code.
createTabster: 27.60 kB → 31.94 kB (+4.34 kB to honour the default;
still -580 B vs master 32.52 kB)
getMover: 41.66 kB → 45.35 kB (+3.69 kB for auto-installed dummies)
getGroupper: 34.42 kB → 38.30 kB (+3.88 kB likewise)
getModalizer: 35.80 kB → 39.49 kB (+3.69 kB likewise)
The bytes regression on getMover/Groupper/Modalizer is the cost of
making controlTab=true "just work" without a follow-up call. Apps that
explicitly pass controlTab=false and skip getRootDummyInputs keep the
slim baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With dummy inputs now an opt-in driven by tabsterCore.rootDummyManagerFactory, Root.addDummyInputs() is a no-op without a registered factory and idempotent when one is present. The book-keeping that used to gate it is no longer load-bearing: - Root constructor: drop the `if (controlTab || rootDummyInputs)` guard and call `this.addDummyInputs()` unconditionally. Without a factory it returns early; with one it creates the manager exactly once. - RootAPI: drop the `_forceDummy` field and the matching `if (this._forceDummy) newRoot.addDummyInputs()` in createRoot. Future roots already pick up dummies through their constructor. - RootAPI.addDummyInputs: drop the `_forceDummy = true` write and just iterate existing roots — that's all the public method ever needed to do (the persistent flag was for newly-created roots, which the constructor already handles). Also tighten Tabster.dispose by merging the two `if (win)` blocks into one (no bytes change after Terser, but the dispose is easier to read in source). createTabster: 31.94 kB -> 31.83 kB (-110 B) Root.js: 2.54 kB -> 2.44 kB (-100 B) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds with Terser DCE/inlining intact but mangle:false, keep_fnames:true, keep_classnames:true, beautify:true. Writes the output to bundle-size/.readable/<fixture>.js (gitignored) so leak hunting can read original symbol names instead of single-letter mangled identifiers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…erCore fields
Two leaks identified by the readable-mode analyzer were both in code
that runs on createTabster()'s default path even when no Mover/
Groupper/Modalizer is in use:
1. TabsterCore declared groupper/mover/outline/deloser/modalizer/
observedElement/crossOrigin/restorer/_dummyObserver as class fields.
Each compiled to a `this.x = void 0` write in the constructor (~15 B
each). Removed the declarations and rely on `undefined`-on-read; the
typed slots are still declared on Types.TabsterCore, so type safety
is preserved.
2. FocusedElementState.findNextTabbable had a hardcoded if/else chain
selecting between groupper/mover/modalizer plus a `callFindNext`
closure with parent-context fallback. Replaced with a strategy
registry — Mover/Groupper/Modalizer push their dispatcher onto
tabsterCore.findNextTabbableStrategies from their getX factories.
The parent-fallback helper is exported from FocusedElement.ts and
only Mover/Groupper import it (Modalizer is a hard trap and skips
the parent walk), so createTabster pays for none of it.
Bundle deltas vs prior commit:
createTabster: 32.52 → 31.44 kB (-490 B)
getModalizer: 39.38 → 39.14 kB (-160 B)
getMover: 45.29 kB (no change — strategy registration costs
are offset by the closure removal)
getGroupper: 38.24 kB (same)
Tests pass: 3 pre-existing failures in default/uncontrolled/
root-dummy-inputs modes; no regressions.
Note: a deeper refactor that moved the Mover/Groupper/Modalizer
context bookkeeping out of getTabsterContext was prototyped and
reverted — the indirection cost (+480 B per feature mode) outweighed
the createTabster save (-90 B).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onstructor Same pattern we applied to TabsterCore, now extended to the part subclasses: \`field: T;\` declarations that the constructor immediately overwrites compile to a \`this.x = void 0\` initializer at runtime (target ES2022 with \`useDefineForClassFields\` defaulting to true). Prefixing them with \`declare\` keeps the type info for TS while suppressing the runtime emit. Touched: Root, RootAPI, Mover, Groupper, Modalizer, Deloser, DeloserItem, DeloserHistoryByRootBase, DeloserHistory, Restorer's History. Babel needed \`allowDeclareFields: true\` on \`@babel/preset-typescript\` for the test runner to parse the syntax (TSC accepts it natively). Bundle deltas vs prior commit: createTabster: 31.44 → 31.37 kB (-70 B) getModalizer: 39.14 → 39.01 kB (-130 B) getMover: 45.29 → 45.07 kB (-220 B) getGroupper: 38.24 → 38.13 kB (-110 B) getDeloser: 39.42 → 39.21 kB (-210 B) Tests: 3 pre-existing failures, no new regressions in default, uncontrolled, or root-dummy-inputs modes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removed webkit/moz/ms fullscreen detection. \`fullscreenchange\` / \`document.fullscreenElement\` are unprefixed across all evergreen browsers (Chrome ≥71, Firefox ≥64, Edge Chromium, Safari ≥16.4) — the prefixed branches haven't been reachable in supported browsers for years. With them gone, the runtime \`fullScreenEventName\` / \`fullScreenElementName\` lookups collapse to a string literal and a single property read. Bundle delta: getOutline: 37.85 → 37.67 kB (-180 B) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Continued the cleanup pass into the classes that the prior commit missed: \`Tabster\` wrapper (5 fields), \`TabsterCore\` (13 typed-only fields whose initial values come from the constructor), the \`TabsterPart\` abstract base, \`WeakHTMLElement\`, and the \`CrossOrigin\` transaction hierarchy plus \`OutlinePosition\`. All were declarations TS wanted for type-checking but whose runtime \`field;\` emit was redundant — the constructor body assigns them immediately. Bundle deltas vs prior commit: createTabster: 31.37 → 31.12 kB (-260 B) getModalizer: 39.01 → 38.77 kB (-240 B) getMover: 45.07 → 44.83 kB (-240 B) getGroupper: 38.13 → 37.89 kB (-240 B) getDeloser: 39.21 → 38.96 kB (-260 B) getOutline: 37.67 → 37.41 kB (-260 B) getCrossOrigin: 89.95 kB (~ -250 B) Tests pass: 3 pre-existing failures, no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleaner helpers across Utils.ts; smaller compiled output as a side-effect. - \`getInstanceContext\` and \`getWindowUId\` use \`??=\` for the lazy-init patterns instead of explicit if-not-set assignments. - \`getElementUId\` does the same on \`element.__tabsterElementUID\`. - \`disposeInstanceContext\` no longer wipes maps it's about to drop — the WeakHTMLElement entries die with the context object anyway. - \`getUId\` builds the result with \`Array.from\` + concatenation instead of a manually-pushed string array. - \`getRadioButtonGroup\` chains \`filter(isRadio)\` + \`find(checked)\` instead of mutating an array in a single pass. - \`getTabsterAttributeOnElement\` skips the redundant \`hasAttribute\` probe — \`getAttribute\` already returns \`null\` when absent. - \`clearElementCache\` flattens the conditional skip. - \`MutationEvent.observeMutations\` uses \`for…of\` over NodeList instead of indexed loops. Bundle deltas vs prior commit: createTabster: 31.12 → 30.85 kB (-280 B) getModalizer: 38.77 → 38.50 kB (-280 B) getMover: 44.83 → 44.55 kB (-280 B) getGroupper: 37.89 → 37.62 kB (-280 B) getDeloser: 38.96 → 38.69 kB (-280 B) getOutline: 37.41 → 37.13 kB (-280 B) getCrossOrigin: 90.07 → 89.63 kB (-440 B) Tests pass: 3 pre-existing failures, no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two of Focusable's hardcoded knowledge-of-Modalizer references are now gone: 1. \`core.modalizer?.acceptElement(element, state)\` in \`_acceptElement\` was a special-cased first dispatch followed by \`core.focusableContextResolver?.()\` for Mover/Groupper. Generalized the slot into a \`focusableContextResolvers\` chain — Modalizer registers its own resolver via \`unshift\` (so its trap-out behaviour keeps precedence over containment checks), Mover/Groupper register the existing \`resolveMoverGroupperContext\` via \`push\`. Focusable iterates the chain without naming any specific feature. 2. \`core.modalizer?.isAugmented(el)\` in \`_isHidden\` was Modalizer's peek into the \`aria-hidden\` carve-out. Switched to checking Tabster's per-element augmentation storage via \`storageEntry(el)?.aug\` — \`augmentAttribute\` is the system-level mechanism Modalizer was already using under the hood, so the new check is a direct read of the same fact. Stored value is the *original* attribute (often \`null\`), so we probe presence with \`in\`, not falsiness. The remaining two Modalizer references in Focusable (\`core.modalizer?.activeId\`, \`ctx.modalizer?.userId\`) are state reads for \`modalizerUserId\` plumbing; abstracting them through hooks would cost more bytes than it saves and only moves the indirection. Bundle deltas vs prior commit: ±30 B per fixture (essentially flat). Tests pass: 3 pre-existing failures, no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…vent field Two micro-cleanups landed independently: - \`Subscribable.createSubscribable\` shed its private \`callCallbacks\` helper — \`trigger\` already does the same thing, just inlined into the returned object. - \`TabsterCustomEvent.details\` is marked \`@deprecated\` (kept for backwards compat with consumers reading \`.details\` instead of \`.detail\`). Adding \`declare\` drops the implicit \`this.details = void 0\` initializer SWC was emitting before the constructor's actual \`this.details = detail\` assignment overwrote it. The deprecated read path still works. Bundle deltas vs prior commit: createTabster: 30.84 → 30.83 kB (-10 B) getModalizer: 38.58 → 38.56 kB (-20 B) getMover: 44.60 → 44.58 kB (-20 B) getCrossOrigin: 89.75 → 89.73 kB (-20 B) Tests pass: 3 pre-existing failures, no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five deprecated items removed: 1. \`TabsterCustomEvent.details\` — duplicate of native CustomEvent \`detail\`. Constructor body shed the \`this.details = detail\` line. 2. \`Deprecated.ts\` (and the \`export * from "./Deprecated.js"\` in \`index.ts\`). Dropped \`dispatchGroupperMoveFocusEvent\`, \`dispatchMoverMoveFocusEvent\`, \`dispatchMoverMemorizedElementEvent\` — thin wrappers around \`element.dispatchEvent(new ...Event(...))\`. 3. \`TabsterCoreProps.checkUncontrolledTrappingFocus\` — replaced by \`checkUncontrolledCompletely\`. The fallback \`||\` chain in TabsterCore.constructor is gone. 4. \`getModalizer(tabster, alwaysAccessibleSelector, accessibleCheck)\` second arg. Same effect achievable via the \`accessibleCheck\` callback. Dropped from \`getModalizer\`, \`createModalizerAPI\`, and the supporting \`querySelectorAll(body, selector)\` walk inside \`hiddenUpdateInternal\`. Tests for the selector mode removed; the \`accessibleCheck\` test updated to the new (2-arg) shape. 5. \`HTMLElementWithTabsterFlags.noDirectAriaHidden\` — added for FluentUI v9/v8 interop. Whole \`HTMLElementWithTabsterFlags\` interface gone (no other flags defined). \`Modalizer.walk\` no longer special-cases \`__tabsterElementFlags?.noDirectAriaHidden\`. Test removed. Bundle deltas vs prior commit: createTabster: 30.83 → 30.78 kB (-50 B) getModalizer: 38.56 → 38.43 kB (-130 B) getMover: 44.58 → 44.54 kB (-40 B) getGroupper: 37.65 → 37.60 kB (-50 B) getOutline: 37.11 → 37.06 kB (-50 B) getCrossOrigin: 89.73 → 89.59 kB (-140 B) allExports: 92.19 → 92.05 kB (-140 B) Tests: 3 pre-existing failures, no regressions across default, uncontrolled, and root-dummy-inputs modes (2 deprecated-feature tests removed alongside the dropped APIs). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This partially reverts 86d6d8e — restores \`HTMLElementWithTabsterFlags.noDirectAriaHidden\` and the corresponding Modalizer \`walk\` carve-out plus its test. FluentUI v8 interop is still needed for consumers that haven't migrated; the flag isn't really deprecated for them. The four other deprecated items dropped in 86d6d8e stay gone. Bundle deltas vs prior commit: getModalizer: 38.43 → 38.47 kB (+40 B) getCrossOrigin: 89.59 → 89.64 kB (+50 B) createTabster: unchanged Tests: 3 pre-existing failures, no regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`patch-package\` postinstall hook applies three changes to keyborg@2.14.0 covering both the ESM (\`dist/index.js\`) and CJS (\`dist/index.cjs\`) bundles: 1. \`event.details = details\` — drop the \`@deprecated\` alias of \`event.detail\`. Tabster reads \`e.detail\` exclusively (verified across src/State/FocusedElement.ts and the rest of the codebase). 2. \`triggerKeys\` / \`dismissKeys\` props + the supporting \`shouldDismiss\` / \`scheduleDismiss\` / \`dismissTimer\` machinery. Tabster only ever calls \`createKeyborg(getWindow())\` with no props. 3. \`canOverrideNativeFocus\` runtime probe. Replaces the \`_canOverrideNativeFocus\` flag with the implicit-true assumption modern browsers (everything since IE9) already satisfy. The conditional \`details.isFocusedProgrammatically\` write becomes unconditional — semantically identical when override works. Bundle deltas (createTabster default-mode): keyborg slice: 3.71 → 3.12 kB (-590 B, -16%) createTabster: 30.78 → 30.18 kB (-600 B) getModalizer: 38.47 → 37.87 kB (-600 B) getMover: 44.54 → 43.94 kB (-600 B) getCrossOrigin: 89.64 → 89.04 kB (-600 B) allExports: 92.09 → 91.50 kB (-590 B) Tests pass: 3 pre-existing failures, no regressions across default, uncontrolled, and root-dummy-inputs modes. Stop-gap until upstream microsoft/keyborg can release the same trims (the changes belong there, not as a Tabster-side fork). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`--mode=profile` builds every fixture in parallel, collapses per-source byte attribution into a presence matrix, and reports core sources (in every fixture, the irreducible floor), variable sources sorted by max bytes, per-fixture above-floor deltas, and a near-core hint that flags sources present in N-1 fixtures (proven tree-shakeable elsewhere). Surfaces optimization targets that single-fixture views can't. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`tabster.focusedElement.{focusFirst,focusLast,focusDefault,resetFocus,
getFirstOrLastTabbable,requestAsyncFocus,cancelAsyncFocus,
getLastFocusedElement}\` were properties on a returned object literal,
so Terser couldn't DCE them out of consumers that didn't use them. They
now live as top-level exports (and \`_\`-prefixed internal variants taking
\`TabsterCore\`); internal callers in Deloser/Modalizer/Groupper/
Restorer/RootDummyManager import the internal forms directly. Public
consumers migrate from \`tabster.focusedElement.focusFirst(props)\` to
\`focusFirst(tabster, props)\` (re-exported from the package root).
\`Types.FocusedElementState\` is now a slim surface: \`subscribe\`/
\`subscribeFirst\`/\`unsubscribe\`, \`dispose\`, \`getFocusedElement\`,
\`focus\`. \`getFirstOrLastTabbable\`/\`requestAsyncFocus\`/
\`cancelAsyncFocus\` were already \`@internal\`; their public-facing
counterparts \`focusFirst\`/\`focusLast\`/\`focusDefault\`/\`resetFocus\`/
\`getLastFocusedElement\` move to the new top-level exports.
Saves ~12.7 kB suite-wide across the bundle-size fixtures (~1.17 kB on
core/find-only fixtures, smaller deltas on feature fixtures depending
on which moved helpers they actually pull in).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tab interception, root dummy inputs, and the \`DummyInputManager\` phantom helpers used to be eagerly installed by every \`createTabster(win)\` call, even for consumers that only used \`findFocusable\` or feature APIs. The machinery is now in \`src/Tab.ts\` and only enters the bundle when the consumer calls \`getRootDummyInputs(tabster)\`. Specifically: - New \`src/Tab.ts\` owns \`onKeyDown\`, \`findNextTabbable\`, \`getUncontrolledCompletelyContainer\`, the \`isTabbing\` flag, and \`installTabKeyHandler(tabster, getWindow)\`. \`FocusedElementState.findNextTabbable\` / \`FocusedElementState.isTabbing\` are gone — Mover/Groupper/Modalizer dummy managers import \`findNextTabbable\` directly; Mover reads \`isTabbing()\` as a function. - \`Tabster.ts\` no longer imports \`getRootDummyInputs\` and no longer auto-calls it. \`controlTab\` defaults to \`false\` (slim baseline). \`FocusedElementState.forgetMemorized\` becomes \`_forgetMemorized\` (drops the const wrapper). - \`getRootDummyInputs(tabster)\` now also installs the keydown handler; root dummies + keyhandler installation are gated on \`controlTab || rootDummyInputs\` so callers can use the function purely to register per-feature dummy infrastructure (factories + observer) needed by Mover/Groupper/Modalizer dummies in uncontrolled mode. - Test wrapper (\`tests/test-setup.js\`) defaults \`controlTab: true\` and auto-calls \`getRootDummyInputs\` so existing tests that create their own Tabster instance still get controlled Tab behaviour. Bundle savings vs. baseline (monosize): createTabster (core) -12.68 kB (-41%) findAllFocusable -9.63 kB (-31%) getRestorer -12.34 kB (-37%) getObservedElement -12.74 kB (-36%) getOutline -12.68 kB (-34%) getDeloser -8.12 kB (-21%) getMover -4.74 kB (-11%) getGroupper -4.58 kB (-12%) getModalizer -4.03 kB (-10%) getCrossOrigin -3.79 kB (-4.2%) all exports -3.83 kB (-4.1%) getModalizer (with dummy inputs) -0.56 kB (-1.4%) \`getModalizerWithDummyInputs\` is the "consumer asks for Tab" baseline: the fixture explicitly imports \`getRootDummyInputs\`, so most of the machinery is back in. The 0.56 kB Δ there comes from the FocusedElement api-method moves in the prior commit. Apps that want the previous "Tab just works" behaviour should pair \`createTabster(win)\` with \`getRootDummyInputs(tabster)\`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`getMover\`/\`getGroupper\`/\`getModalizer\` now lazy-create \`tabster._dummyObserver\` themselves, so per-feature dummy-input redirection works without requiring the consumer to also call \`getRootDummyInputs\`. The observer setup lives in a new \`ensureDummyInputObserver(tabster)\` helper in DummyInput.ts; \`getRootDummyInputs\` shares the same helper. Mover/Groupper/Modalizer constructors drop the \`&& tabster._dummyObserver\` half of the gate (the per-feature factories guarantee it exists by the time a part instance is built); they still skip the per-part dummy when \`controlTab: true\` because the keyhandler intercepts Tab and never lets focus reach a dummy. \`getRootDummyInputs\` no longer has the inner \`if (controlTab || rootDummyInputs)\` gate — calling it *is* the opt-in for Tab control + root dummies, so it always installs the keyhandler and adds root dummies. Test wrapper gates the call externally so uncontrolled, no-root-dummies tests skip it and rely on the per-feature dummies the get* factories install. Bundle deltas vs. previous commit (per-feature fixtures +~1 kB; slim baseline unchanged): createTabster 18.193 kB unchanged findAllFocusable 21.306 kB unchanged getMover 41.165 kB +0.95 kB getGroupper 34.222 kB +0.95 kB getModalizer 35.653 kB +0.94 kB allExports 90.754 kB +0.92 kB Net vs. master baseline still strongly negative (createTabster -41%, findFocusable family -31%). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
\`tabster.uncontrolled.isUncontrolledCompletely\` was a one-method object wrapping a single user-supplied callback (\`checkUncontrolledCompletely\`). The wrapper added byte overhead and indirection for one internal caller (the Tab keyhandler) without any public surface beyond the callback the consumer already passed to \`createTabster\`. Drop \`tabster.uncontrolled\` and \`Types.UncontrolledAPI\` entirely. \`TabsterCore\` now exposes \`checkUncontrolledCompletely\` directly (picked through from \`TabsterCoreProps\`); \`src/Tab.ts\` inlines the "callback overrides element flag, undefined falls back" check at the single call site. \`src/Uncontrolled.ts\` and \`createUncontrolledAPI\` are removed. Public consumers reading \`tabster.uncontrolled\` need to migrate — none in this repo (tests/stories) referenced it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Convert API classes behind
get*entry points (andUncontrolledAPI) fromclassto factory functions returning a closed-over public surface. Class scaffolding (constructor + private field declarations + arrow-bound method properties) doesn't mangle as well as plain top-level functions and closures, so eachgetXconsumer ships less code.8 APIs converted, one commit per API:
RestorerAPIOutlineAPIDeloserAPIGroupperAPIMoverAPIModalizerAPICrossOriginAPIUncontrolledAPIPattern
Private fields → closure variables. Arrow-bound private methods → local
constarrows. Constructor body → factory body. Public API → returned object. The interface inTypes.tsis unchanged; consumers insrc/get/*.tsswapnew XAPI(...)forcreateXAPI(...).Static methods
DeloserAPIhad static methods (getDeloser,getHistory,forceRestoreFocus) used fromCrossOrigin.ts. Kept under a same-namedconst DeloserAPI = { ... }namespace so call sites continue to work unchanged. Internal state needed by the statics is exposed via a_-prefixed internal interface.Bundle-size impact
Measured against
master(post-#529). AllgetXfixtures shed bytes; per-API factory savings were ~570 B – 2.3 kB minified depending on API size.OutlineAPIwas the biggest individual win — many small private helper methods compress better as closure-captured locals.getCrossOriginshows the cumulative effect since it bundles every other API.Out of scope
Three groups of remaining classes were intentionally not converted:
KeyboardNavigationState,FocusedElementState,ObservedElementAPI, plus the two CrossOrigin-internal subscribable classes all extend a sharedSubscribable<A,B>base. Converting requires a coordinated migration of the base and all 5 subclasses in one PR. Estimated additional savings: ~500 B in core. Worth doing as a follow-up.RootAPI— has heavy external static usage (RootAPI.getTabsterContext,RootAPI.getRootcalled from Modalizer/Focusable/etc.). Doable with the same const-namespace trick used forDeloserAPI, but the blast radius warrants its own PR.FocusableAPI— no external statics, mechanical conversion. Skipped only to keep this PR focused; trivial follow-up.Verification
npm run type-check(lib + tests + stories) passesnpm run lint:checkpassesnpm run format:checkpassesBehavior changes
None. The public interfaces in
Types.tsare unchanged; only the runtime construction differs (new XAPI(...)→createXAPI(...)). All call sites are insrc/get/*.tsandsrc/Tabster.ts.🤖 Generated with Claude Code