feat(mascot): componentized mascot — core service, solid/react wrappers, 16 effects, consumer migrations - #490
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (28)
🚧 Files skipped from review as they are similar to previous changes (13)
📝 WalkthroughWalkthroughThe pull request adds a framework-free mascot service, composable effects, scale-relative binary emitters, browser validation, Storybook coverage, and shared live-session state for launcher busy indicators. ChangesMascot core and validation
Live-session FAB state
Estimated code review effort: 5 (Critical) | ~120 minutes Merge Risk: 🟡 Moderate · up to The PR adds pointer-follow and emitter behavior while preserving an adapter for existing consumers, but merge readiness is held back by a lint error that may fail CI, a teardown path that can leave a stale gaze offset, documentation that overstates site-FAB support, and a browser test cleanup issue. Sequence Diagram(s)sequenceDiagram
participant MascotConsumer
participant MascotService
participant ActivityController
participant EffectMount
MascotConsumer->>MascotService: Register DOM parts and update configuration
MascotService->>ActivityController: Start or stop activity
ActivityController->>EffectMount: Mount, anchor, drain, or remove effects
MascotConsumer->>MascotService: Destroy service
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
⚔️ Resolve merge conflicts 💡
🧪 Generate unit tests (beta)
Comment |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s and legacy adapter Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nect refs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds packages/mascot/harness (verify.mjs + page.html): a real-Chromium Playwright harness that serves the built dist and asserts 78 behavior facts across 15 checks — the legacy closed/open/work trio through createFabRobotRig plus the createMascot lifecycle, connect() ref stability, reduced motion, gaze falloff, channel discipline and re-registration guarantees. Run with `pnpm --filter @conciv/mascot verify:behavior`; it is a manual/agent gate, not wired into turbo test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An unknown --only value selected zero checks and reported ALL CHECKS PASS with exit 0 — and the donor-parity claim rides on that flag. The harness now derives its valid section list from the check table and refuses an empty selection with a non-zero exit before it launches Chromium. Also widens the exit-drain sample window 350ms to 400ms (assertion unchanged; clears the gsap ticker-lag flake margin), pins an explicit viewport in openPage so the gaze pointer offsets never depend on the Playwright default, and rescopes the changeset's parity wording to the channels actually measured — the old work timeline's head bob is intentionally dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…anchor Pose no longer kills the channels activity owns while working. killPosedTweens narrows to head (all four), eyes scaleX and antenna rotation; eyes scaleY and antenna scaleX/scaleY are handoff channels, owned by pose while idle and by activity while working. Activity gains setEyeRest, which retargets the blink return tween in place (vars + invalidate) instead of rebuilding the timeline, so a mid-work state change keeps the original work timeline running and no longer re-runs emitter.start (which fired a spurious returnToFull tween). registerParts now requires the root. readyParts stopped falling back to effectHost as the stage, so a bound effectHost could register with no root and nulling the root never tore down. Root is the stage and coordinate origin; effectHost is an optional separate mount target passed to the activity controller as the emitter's parent. The emitter re-anchors on a mid-work state change: trackTip runs a short gsap tween that re-measures the antenna tip each frame while the pose settles. Tip and lean-pivot measurement moved to tip-anchor.ts and became transform aware: the antenna's untransformed layout box is walked up to the host and the tip point is mapped through the element's own matrix, instead of reading getBoundingClientRect (whose box inflates under rotation and would have thrown the anchor ~15px off the tip on a rest to awake change). wrapForLean computes the wrapper's transform-origin in pixels from the antenna's own box, so the pivot lands on the antenna base regardless of the host's layer inset — the site insets its layers 6px inside a 56px button, where the '50% 32.8%' string resolved 2.06px above the antenna base. Widget behavior is unchanged (inset 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ontract gsapAsset rejects a request whose normalized path escapes the gsap package directory. The harness page's emitter/lean-wrapper predicates are structural (digit count and child count) instead of keyed off inline style strings. Check A now pins that a mid-work state change keeps the ORIGINAL work timeline object running, keeps the same emitter node, fires no returnToFull (emitter scale stays 1) and re-anchors the emitter shell to the leaned tip. New check M binds all four required refs plus an effectHost and asserts that nulling any one required ref tears down (wrappers 0, listeners 0, antenna restored), and that an effectHost on its own never registers, never arms and never emits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The changeset claimed both consumers keep their existing states, verified against the previous implementation. That was false: the rig that shipped on main had no gaze and no emitter, and this merge turns pointer-follow on for the closed state and the binary emitter on for the work state on both the widget FAB and the site FAB. Parity was measured against PR #486's prototype on the donor branch, not against main; the changeset now says so. Plan amendments: decision 1 records eyes scaleY and antenna scale as handoff channels rather than disjoint ones, with the kill-narrowing as the enforcement; decision 4 records the identical-parts short-circuit as the implemented, StrictMode-preferred behavior; Task 5 step 2 drops `contain` from the listed layer styling, which would clip the emitter. The phase-2 plan gains an M1/M2 acceptance inventory recording both consumers' real layer boxes and the bare-<Mascot> default-sizing requirement for W2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
antennaTipAnchor mis-measured whenever an effectHost was bound. EFFECT_HOST_STYLE is position:absolute inset:0 — a sibling overlay of the layers, never on the antenna's offsetParent chain — so layoutOffsetWithin walked past it to null and returned document-relative offsets, positioning the emitter by the stage's whole page offset. It measures both the antenna and the host to the shared offset root and subtracts, which is correct for an ancestor host (the base offsets cancel as a prefix) and for a sibling host. Neither consumer binds an effect host today, which is why phase 1 never surfaced it. The tip measurement is normalized to rotation only. The antenna's throb drives scaleX/scaleY continuously, so a tracker that ended on an arbitrary throb phase settled the anchor with sub-pixel nondeterminism; rotation is the only antenna channel that relocates the tip. Harness: check M binds an effectHost together with all four required refs under working: true and pins the emitter's parent and its offset inside that host; check M2 pins the resting anchor against stage width x 0.5 and stage height x 0.15625, so the geometry module's absolute correctness no longer rests on a one-off manual measurement. Both pins also pass against the donor dist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
startWorking measured the antenna tip once and never re-measured. On the open -> work rising edge applyPose only STARTS the rest timeline, so startEmitter reads the antenna while it still carries the awake -4deg rotation; the pose then animates the rotation back to 0 and the emitter stays at the leaned tip. That is the site's real path (hover -> awake, click -> work), where it left the digit column ~1.4px off the tip on a 44px rig. startWorking now calls trackTip over the same 0.45s window as the mid-work path; it early-returns without an emitter, so reduced motion is unaffected. Wiring that up exposed a second source of nondeterminism in the anchor: gsap's CSSPlugin applies autoRound to px-valued CSS properties, so every gsap.set on the emitter shell quantized the anchor to whole pixels — up to 0.5px of jitter as the pose settles, and inconsistent with the raw float the shell is created with. Disabled with autoRound: false. Harness: new core check N drives the adapter open -> work and pins that the anchor enters at the leaned tip and settles exactly on the rest tip, against the absolute stage-relative values the tip pins established. New anchorOf helper reads the sub-pixel style value, because offsetLeft is integer-rounded by the DOM and cannot resolve a 1.4px offset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The launcher's working state came only from sessions.list, and the one refresh fired at send time raced the server's live-run registration: core awaits the run row, content expansion and live-run settling before liveRuns.start, so the refetch answered running:false and nothing asked again until the turn ended. Track the streaming session ids locally in the app context and derive the launcher state from that OR the server rows, and expose the state through aria-busy so it is perceivable and assertable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two panes can stream the same session id (quick-route duplicate panes, PiP), so the live-session ids are a multiset: start pushes an occurrence, stop removes exactly one, and each pane pops only the occurrence it still holds. Cover the paths the first commit left unasserted: the closed launcher after a mid-run minimize, and the handoff from local truth to server truth when the streaming pane unmounts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bespoke harness (own static server, PASS/FAIL printer, --only/--dist flags, verify:behavior script) is gone. All 17 checks and 101 assertions move into a @playwright/test suite under tests/e2e grouped by concern, following the embed IT conventions: playwright.config.ts mirrors embed's, assets are served through page.route instead of a hand-rolled server, and the page-side measurement helpers live in tests/e2e/helpers. vitest keeps the unit tests under tests/unit; the package test script now runs both, so turbo's test gate covers the browser checks and CI picks up chromium provisioning from the @playwright/ devDependency prefix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Trim the mascot changeset to the user-facing effect: the harness paragraph described a vehicle that no longer exists, and the donor/PR archaeology is not release-note material. Replace live-sessions' array bag with a per-session reference count so a session that starts twice needs two stops, and the signal still hands back a fresh map on every change. Give the fab-working IT's immediacy claim in test 3 the same tight timeout its siblings use (the busy-state handoff keeps the default: it waits on a round-trip), and move the pane-harness live-sessions import into its group. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The binary emitter read its geometry as absolute pixels approved against the 44px widget FAB stage, so on a large stage the robot art scaled and the digits stayed 9px specks. Every emitter distance is now multiplied by min(stageWidth, stageHeight) / EMITTER_REFERENCE_STAGE_PX, measured on the effect host when the emitter is created: font size, the two lane offsets, the digit placement and the rise. Rise duration, stagger and eases are timing, not geometry, and stay fixed; the tip anchor and the tip enter/exit scale are already fraction-based. A stage that measures zero falls back to the reference factor. At the 44px product stage the factor is exactly 1, so the shipped FAB is byte-for-byte the same geometry, pinned by a parity test at 44px next to a scaling test at 132px. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two controls join the state x working x follow matrix. stageSizePx is a 44-320px range whose lower bound is the widget FAB stage, and it demos the stage-relative emitter directly: the digits grow with the stage. poseApply picks how a state change lands, animated through update() or instantly through the registration path. Both ride one derived registration key on a keyed Show, so a size change or a set-mode state change re-registers the parts and nothing else does: no extra signal, no effect writing state. The emitter reads its scale factor when it is created, so re-registering is what makes a size change take effect. No reduced-motion control: prefers-reduced-motion is a browser-level media query page script cannot flip, and emulating it in the story would demo the story instead of the core. The docs block points at the OS setting and the DevTools Rendering panel instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setRunning(id, false) for a session the map never saw still rebuilt the map and handed the signal a fresh reference, waking every anyRunning reader for a no-op. Return the same map when the id is absent and the delta is negative. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The factor came off the effect host, which is the same box as the antenna on the widget FAB but not on the site FAB: that one insets its layers inside a 56px button, so the emitter scaled by 56/44 and shipped digits 27% too big while the changeset claimed the FAB was untouched. Read the factor off the antenna layer instead. That is the frame the tip math already works in, and it is the box the antenna art is drawn into, so the digits now stay proportional to the art wherever the art lands. The widget FAB measures 44px there and keeps factor 1 exactly; a stage and an inset button that render the same 44px antenna now render the same emitter. Covered by a site-shaped case in the scale suite: a 44px antenna inset in a 56px button must produce the approved geometry, which fails at 11.4545px font before this change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
anyRunning read the counts signal directly, so every setRunning woke every reader even when the boolean did not move: the second start and the first stop of a multiset session both notified for nothing. A memo collapses them, and the test's notification count drops from 5 to 3, which is the assertion that proves it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The unknown-id guard in withDelta and the anyRunning memo solved the same problem twice. The memo is the general one: it stops every no-op transition, including the multiset middle start and stop the guard never saw. With it in place no test could fail on the guard alone, because counts is private and an unknown stop never flips the boolean, so the guard was unfailable code. One good way; the memo stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three hand-plumbed layer spans and the createFabRobotRig ref dance are replaced by a single <Mascot> from @conciv/mascot/react inside the existing button. The stage carries its 44px size as a class so the prerendered html paints the robot before hydration installs the default stage stylesheet. Hover and the working toggle feed the mascot through state/working/follow; the unreachable onActivate and label props are gone, so the aria-label is derived from the working state alone. A focused site e2e covers the prerendered stage, the 44px box, the awake pose on hover, the rest pose while hovering mid-work, the working toggle with its binary emitter and label, and a clean unmount. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…not bake stale emitter geometry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lished api Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The package entry is the framework-free core service now: createFabRobotRig and its RigState/RigLayers/FabRobotRig types are gone, exports "." points at the core index, and every story, harness and test reads the core and effect subpaths directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The effect host itself claims its slot now, so a <Mascot.Effect> child stands the default binary down exactly the way <Mascot.Binary> already did, and the gallery gains a compound story over the published solid entry. The gallery effect catalog is keyed by the sixteen effect names, so the lookup is total instead of falling back to binary, and only travelling effects read the curve, so switching curves no longer remounts an anchored effect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ticle effects matrix, notes, pixel-bubbles and steam each rebuilt the same emitter: measure the antenna, anchor a tip shell, append the particles and hand the shell to the nozzle emitter. That shape lives in effect-support now, and the gaze suite gets its saturating setup as a helper instead of a third copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The widget FAB derives three mascot props from open/working, so the embed suite now pins the table observably: closed and idle rests and tracks the pointer, open with nothing running wakes, and opening the panel mid-run keeps the emitter alive. The site prerender check reads the root tag before matching its class, so attribute order cannot break it, and build-storybook gets the heap it needs to finish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e the changeset Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… needs The three fab tests that hold a run open each repeated the same five lines, so the setup moves into the chat helpers. The mascot readme snippets read as components instead of leading-semicolon expressions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The exported MascotEffectProps no longer carries the internal fallback flag, so a consumer cannot re-open the double-host bug by passing it; the root and the binary shorthand render the wider EffectHost instead. The readme names React's own conventions rather than claiming identical props, and warns that mount is a dependency there — its example hands over a stable module-level mount. The embed pin is named for what it asserts: the emitter keeps running while the panel is open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ests read the skin The react entry no longer exports ClaimToken or PartClaim: the claim tokens are how the wrapper tracks slots, not API, and the solid entry exports neither. The context memo drops the permanently-stable curve ref from its deps, the packed install probe reads the one effect catalog the tests already share, and the stage helpers derive the tip and origin fractions from robotSkin instead of repeating its numbers. The readme and the changeset now describe the merge and the react mount contract the way the code actually behaves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 140 out of 145 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (3)
packages/mascot/src/core/parts/follow.ts:229
dispose()kills the gaze return/quick-to tweens without restoring their current values. IfregisterParts()replaces one part while reusing the eyes element, ordestroy()runs mid-return, the eyes retain a nonzerox/y; a subsequent registration with follow disabled never touches those properties, so the mascot stays permanently off-center. Reset the owned follow transforms during disposal.
packages/mascot/src/react/mascot-root.tsx:39- React slot claims are only registered in layout effects, which do not run during server rendering. Thus SSR of
<Mascot><Mascot.Eyes /></Mascot>serializes both the custom eyes child and this default eyes fallback (and custom effects similarly serialize alongside the default binary), violating the one-slot contract until hydration. The fallback decision needs an SSR-aware claim mechanism rather than relying solely on the post-commit claim store.
packages/mascot/src/core/config.ts:31 - The PR description says the work-state head bob is intentionally dropped until phase 2, but the new default enables
bob, so every consumer that omitsactivitygets the bob immediately. Either disable this default for phase 1 or update the PR description and behavioral scope to reflect what is actually shipping.
| canvas.style.cssText = | ||
| `position:absolute;left:${tip.x - geometry.originX}px;top:${tip.y - geometry.originY}px;` + | ||
| `width:${geometry.width}px;height:${geometry.height}px;pointer-events:none;${WILL_CHANGE_STYLE}` |
…in at the tip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ition .jsx dist The solid-start e2e app excluded every workspace /packages/*/dist/ path from vite-plugin-solid to keep solid-refresh's dev wrapper off precompiled minified .js dists. @conciv/mascot now ships dist/solid/*.jsx as real JSX source behind the "solid" export condition (the pattern @ark-ui/solid publishes), and the embed bundle externalizes @conciv/mascot/solid, so the consumer host resolves and compiles it. The over-broad exclude swallowed that JSX: vite fell back to esbuild's classic runtime and served React.createElement calls, so the widget's Solid render threw into an error boundary and the shadow root mounted empty with no page error. Narrow the exclude to .js so precompiled dists stay excluded and JSX source is compiled for Solid. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rom viewport edges Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m a valid drop Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 144 out of 150 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Suppressed comments (9)
apps/storybook/src/mascot/mascot-gallery.stories.tsx:405
- The repository review rules prohibit em dashes in TS/JS string literals. Use colons for these section-label separators.
**Compound** — the same robot from \`@conciv/mascot/solid\`: \`<Mascot>\` renders its own three layers, and the
\`<Mascot.Effect>\` child mounts an effect subpath in place of the default binary.
**Index** — the map of the package: the parts a host renders, all sixteen effect subpaths, and the three
apps/storybook/src/mascot/mascot-gallery.stories.tsx:408
- The repository review rules prohibit em dashes in TS/JS string literals. Replace this separator with a colon.
**Reduced motion** — under \`prefers-reduced-motion: reduce\` poses are set instantly, follow never arms and no
packages/mascot/src/react/mascot-root.tsx:39
- During server rendering, compound children cannot publish their claims because
claimPart/claimEffectrun only in layout effects. ThereforerenderToString(<Mascot><Mascot.Eyes /></Mascot>)emits both the custom eyes and this default eyes (and custom effects likewise emit the default binary), then hydration removes the defaults. Derive claims before selecting fallbacks or add an SSR-safe claim mechanism, and cover server-rendered custom slots.
.changeset/fab-drag-viewport-clamp.md:5 - This release note describes a launcher drag/clamp implementation that is not part of this mascot PR, so the
@conciv/embedrelease would advertise behavior this changeset does not ship. Replace it with a note for the widget mascot migration and live working-state changes.
Dragging the closed launcher no longer jitters outside the viewport and no longer snaps back from a valid drop. The live drag position is clamped so the button never renders past a viewport edge, the drag offset rides a single owner (an inline transform relative to the button's own resting anchor instead of competing `left`/`top` writes), and the release glides to the snap point through one Web Animations pass whose end state is the resting state — so the hand-off back to CSS moves nothing. A drop that is already at the resting spot skips the animation entirely.
apps/storybook/src/mascot/mascot-gallery.stories.tsx:400
- The repository review rules prohibit em dashes in TS/JS string literals. Replace these separators with punctuation such as colons.
This issue also appears in the following locations of the same file:
- line 402
- line 408
**Playground** — the assembled robot over \`createMascot\`. \`state\` picks the resting expression, \`working\`
runs the activity overlay, \`follow\` arms pointer tracking per channel, \`bob\`/\`throb\`/\`blink\` isolate the
three overlay pieces, \`effect\` swaps which of the sixteen effects is mounted (switching drains the outgoing one
and starts the incoming one), \`curve\` picks the path travelling digits ride out of the antenna tip, and
\`stageSizePx\` resizes the stage — the emitter is scale-relative, so the whole effect grows with the robot.
packages/mascot/src/binary.stories.tsx:124
- The repository review rules prohibit em dashes in TS/JS string literals. Replace this separator with a colon.
packages/mascot/src/matrix.stories.tsx:102 - The repository review rules prohibit em dashes in TS/JS string literals. Replace this separator with a colon.
packages/mascot/src/pixel-bubbles.stories.tsx:102 - The repository review rules prohibit em dashes in TS/JS string literals. Rephrase both separators without em dashes.
packages/mascot/src/speech-bubble.stories.tsx:72 - The repository review rules prohibit em dashes in TS/JS string literals. Replace both separators with a colon and comma.
| import {useIsomorphicLayoutEffect} from './use-layout-effect.js' | ||
| import {useMascotHost} from './use-mascot.js' | ||
|
|
||
| export function MascotRoot(props: MascotProps): ReactElement { |
Mascot componentization — full feature (phases 1 + 2)
Replaces the prototype FAB robot rig with a published, framework-free mascot component and mounts it in every consumer.
What ships
@conciv/mascot):createMascotservice — pose/follow/activity controllers over shared part elements, config diffing,connect()prop-getter surface, keyedmountEffect/unmountEffect, visibility parking,ResizeObserver-driven remeasure, skinnable art values (robotSkindefault).@conciv/mascot/solid+@conciv/mascot/react— compound<Mascot>/Mascot.Head|Eyes|Antenna|Binary|Effect; single-slot claim with synchronous throw on double mount; default parts stand in for unclaimed slots; default 44px stage via adopted:where()stylesheet; geometry-critical style blocklist on layers; React StrictMode-safe.@conciv/mascot/effects/<name>): binary, matrix, steam, notes, pixel-bubbles (nozzle physics — particles snapshot the tip and fly world-fixed) + 11 attached ornaments. Module-graph tests prove per-effect chunk isolation.apps/conciv, −17.6 kB gz JS / −19 kB gz CSS via de-duplicated layer art) and site landing FAB (apps/site, SSR-sized first paint). LegacycreateFabRobotRigadapter deleted;"."now exports the core.Verification
packages/mascot(browser + e2e, stepped-clock harness, packed-install fixtures for core-only/solid-only/react-only), 123 embed ITs incl. FAB state-table pins, site e2e 17, storybook suite, publint + attw, fallow 0 introduced across all categories; full forced landing gate green (107 tasks).Known follow-ups (ledgered, non-blocking)
exports["./solid"].solid → dist/solid/index.jsx(JSX-preserved condition) has no downstream compile test; covered indirectly.--max-old-space-size=8192(pre-existing ceiling, now scripted).