Palette:
- Add `fuchsia` (~H334) and `steel` as the 32nd/33rd named scales; rename
radix-scales.ts -> color-scales.ts and drop the "Radix parity" framing
(seeded from Radix, but the palette is ours).
- generatePalette: per-family nearest-hue donor (L-per-hue) so bright hues
(yellow/amber/lime) stay vivid instead of landing muddy.
Theme builder (web/routes/temas):
- estudio: new "palette-first" color mode — character (vivacity/tone/neutral)
regenerates the 33 families; roles are selected by hue harmony or manually
and applied live to real components via --primitive-* overrides.
- paleta: palette generator + role harmony chooser (the model, visualized).
Color guards (THEMING.md §25.2/§25.4):
- G1: completeColorRoleMap throws when a hierarchy alias and a valenced intent
share a scale; the builder picker excludes intent-occupied scales.
- G2: re-expose role slots bg2*2 / separator*6 / borderHover*8 / textStrong*12
(13 slots) — real UI/a11y needs the original nine had dropped.
- G3: document the CVD / WCAG 1.4.1 rule (an intent needs a non-color cue) plus
the affirm/fulfill activation distinction; audit-grounded.
- G4: palette-invariant.test.ts checks the delta-E floor on every generator's
OUTPUT (multi-step, monochrome-exempt); clamp builder tone to +0.08.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top blocks already say FINALIZED, but the body still read as a live P1→P4
plan ("flag OFF", "runtime still @floating-ui", "P4 pending") — stale and
contradictory now that the finalize + flip rollout shipped. Replace the old
"Status: P1 COMPLETE" block with a clear HISTORICAL marker so the remaining plan
reads as the completed build record, not a to-do. Own engine is the runtime;
@floating-ui is out of the library; native path + avoidCollisions:'flip' shipped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A perceptible line showed across the caret base on every side but bottom: the
panel's border draws across the caret's base edge, "cutting" it off from the panel.
Add the popover's per-side fix — pull the caret base into the panel by the border
width (margin toward the panel, keyed on data-side) so the fill covers that border
and the caret's side strokes continue the panel outline seamlessly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single-direction drop-shadow on the caret is wrong: the caret sits at the panel
edge pointing AT the trigger, so on a bottom-side tooltip (caret at the top) the
downward shadow falls onto the panel below it — a dark smudge over the content.
Remove it. The caret keeps the panel-bg fill + border edges (no black blob, no
smudge). A shadow that reads correctly would have to wrap the whole panel+caret
silhouette (content-level drop-shadow), not the caret alone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the un-clip fix (c3def5d6). With the caret no longer clipped it was
visible, but it rendered in `currentColor` (the dark text colour) — a black triangle
on a light panel. Colour it to the panel bg (a seamless caret) + the panel border on
the two exposed edges, and give it its own `drop-shadow` so the matched (light) caret
still reads against the page — the panel's box-shadow is rectangular and never reaches
the caret. This is the "matched + visible" caret (the drop-shadow-on-the-silhouette
idea) now that overflow:visible lets it show.
VERIFICATION CAVEAT: confirmed the colour at the computed-style level (polygon fill ==
panel background, was the dark text colour). Could NOT capture a stable screenshot —
the demo tooltip closes on every programmatic/cursor interaction, so the visual pop of
the drop-shadow is unverified here. If the matched caret reads too faint, bump the
drop-shadow alpha or fall back to the darker caret.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tooltip content is `position: relative; overflow: auto`, so the caret
(`position: absolute`, containing block = the content) that pokes past the panel
edge was CLIPPED away — invisible regardless of its colour. Confirmed by measuring:
the caret sits fully outside the content box, and the content clips its overflow.
Change the content to `overflow: visible` — the same reason the popover content is
`overflow: visible` (its comment literally says "fixes the arrow that overflow:auto
used to clip"). Tooltips are short, so no inner scroll viewport is needed.
Verified in a real browser: the dark caret is now clearly visible pointing at the
trigger. Combined with a40d1573 (caret colour back to currentColor) + the earlier
`display: block` (correct arrow height → correct trigger gap), the tooltip arrow is
visible and correctly placed again.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
My previous commit (4afadfd7) over-corrected: it changed the caret fill from
`currentColor` (the panel's dark text colour — long-standing, visible) to the
panel background, aiming for a "seamless extension". On a light-on-light tooltip
that makes the caret INVISIBLE — the panel reads only because of its box-shadow,
which the separate caret element doesn't share. The user had a visible caret
before; the "correct" colour regressed it.
Revert the polygon/path colour rules (caret returns to `currentColor` = visible)
and keep ONLY the `display: block` on the SVG — that was the fix that mattered: it
removes the ~21px of inline-baseline phantom height that both misaligned the caret
and, because the positioner adds the measured arrow height to the gap, pushed the
panel too far from the trigger. So: caret visible again + panel sits at the correct
~13px gap. A properly matched-and-visible caret (bg fill + its own drop-shadow) is
deferred until it can be verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tooltip arrow SVG carries `fill="currentColor"` / `stroke="currentColor"`
presentation attributes, so setting `fill`/`stroke` on the wrapping `<span>`
(`[data-tooltip-arrow] { fill: … }`) never reached the shapes — an element's own
presentation attribute beats an inherited value. The caret therefore painted with
`color` (the dark text colour) instead of the panel background, and the `<svg>` was
left `display: inline`, adding phantom baseline height (26px box for a 5px caret)
that misaligned it. Net effect: the caret didn't read as a caret.
Fix mirrors the popover recipe: colour the `polygon` (fill = panel bg) + `path`
(stroke = panel border) directly, and set the `<svg>` to `display: block`. The
tooltip caret is now byte-identical in treatment to the popover's (fill = content
bg, 1px border-coloured edge). Also fixes the outline variant, which had the same
span-vs-shape mistake.
Pre-existing bug, unrelated to the avoidCollisions:'flip' rollout. Verified in a
real browser: polygon fill now equals the content background, svg is block, caret
positioned correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Widens `avoidCollisions` from `boolean` to `boolean | 'flip'` on the 6 overlays
that expose it (combobox, context-menu, dropdown-menu, link-preview, select,
tooltip) — the FloatingContent engine already decoupled flip from shift, so
'flip' (flip-only, no slide) works everywhere and lets an arrowless, non-shift
overlay take the native CSS-anchor path. Additive: `boolean` is unchanged.
- Public prop types + provider opts widened (types.ts + *-provider). JSDoc on
the 3 descriptive ones (combobox/select/tooltip) documents the 3 modes; the
terse `@default true` menu types keep their inline style (the type shows 'flip').
- Demos: the 4 that had an avoidCollisions checkbox (dropdown-menu, context-menu,
link-preview, tooltip) now expose a 3-way full/flip/off control.
Verified in a real foreground browser (Claude-in-Chrome):
- dropdown-menu + avoidCollisions='flip' + no arrow → routes to NATIVE
(data-floating-native, position-anchor link to trigger, zero JS transform,
placed correctly).
- context-menu correctly STAYS on JS with 'flip' — it anchors to the cursor
(a Measurable), which the dispatcher's virtualAnchor gate forces to JS. The
discriminator works as designed.
- tooltip / link-preview 3-way controls render.
0 new check errors; 41 overlay + floating tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified in-browser that a borderless filled diamond anchored to the trigger with
mirrored position-try flips natively in sync with the content (the symmetric rotated
square needs no per-side logic), so hasArrow COULD be dropped to let arrow overlays
go native. Not built: the popover caret is bordered (border on the 2 side-facing
edges, drawn per resolved side), and a native flip can't reproduce that — no API
exposes the resolved side. The flip-safe arrow is filled-only (loses the bordered
caret outline) and the win is narrow (shift forces JS anyway). Decision: keep the
bordered caret on JS; the filled-diamond pattern stays a documented, ready option for
solid-fill overlays (tooltips). Captured in strategy.ts + CONTINUE.md so it isn't
re-explored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two parts: the cutover (own JS engine replaces @floating-ui as the runtime, dep
out of the library) and the native CSS Anchor Positioning path layered on top.
Cutover (P4, reconciled):
- Flip USE_OWN_ENGINE -> true, then delete it (flag.ts removed). Collapse the A/B
in use-floating + floating.svelte to own-only (drop the fui imports, the factory
shim, the `as unknown as` casts). useFloating's `dom` is now required.
- @floating-ui moved dependencies -> devDependencies: the LIBRARY (src/arts/ethereal
+ src/uix/soma) imports it NOWHERE; it stays only for the visual demo + the parity
oracle. Zero-dep doctrine met for consumers.
Dual engine (P2 — built + proven end-to-end):
- `selectPositioningStrategy` ($ethereal/strategy.ts, 5 tests) picks native vs JS
per instance on discriminators (arrow / shift / virtual-anchor / explicit-boundary;
sticky is subsumed by shift). The behavioural twin of supportsCssAnchor.
- eidos `renderFloatingNativeBlock`: @supports (anchor-name) and (position-area)
with position-area from data-side/align (logical axes — validated against the spec),
per-side gap margin, position-try-fallbacks for flip. base.css regenerated.
- soma wrapper: when native, stamps data-floating-native + an inline anchor-name<->
position-anchor link and PAUSES the JS positioner (no computePosition, no autoUpdate).
- New `avoidCollisions: 'flip'` (flip-only, no slide) decouples flip from shift so an
overlay can flip natively. Popover demo: avoidCollisions = full / flip / off.
- Proven in a real browser: a flip-only arrowless popover routes to native, positions
via position-area, and flips natively (flip-block) at an edge — zero JS transform.
Honest scope: shift has no native primitive, so native lights up only for non-shift
overlays; the JS engine stays the default for collision-avoiding ones. data-side
doesn't track a native flip, so native is gated to the arrowless case.
Verified: 537 tests green (ethereal + 7 overlays + floating + generated-css), 0 new
check errors. Docs: CONTINUE.md, PERF.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
24h picker driven by a horizontal daylight gradient — drag the sun/moon
thumb across the night→day→night sky, jump with moment chips, or nudge
with hour/minute/second steppers. Inline panel + trigger/popover variants,
built on the generic Picker (deferValue) composing Slider, Button,
PickerShell, Popover and Tooltip.
- granularity (hour|minute|second) + optional band props; steppers keep a
fixed 3-column grid; shape/size propagate to panel, trigger and chips.
- fixed-tone sky palette as named recipe tokens (theme-independent).
- fix soma Tooltip.Arrow (empty children slot hid the default SVG) and
make PickerShell footer labels locale-reactive.
The in-house positioning engine (relocated to its own art in 58683b03) is
renamed floating -> ethereal: src/arts/floating -> src/arts/ethereal, alias
$floating -> $ethereal (vite + svelte config), all soma wrappers + the demo
repointed. The reactive wrappers stay in soma/layers/floating (soma's
floating-overlay concept); the *library* is ethereal.
Also lands the cross-browser validation + perf characterization the rename
batch was verified against:
- engine-dom parity suite runs on Chromium + WebKit + Firefox via
UIX_CROSS_BROWSER=1 (env-gated in vite.config; default chromium). 411 runs
green — exercises the isWebKit-gated paths Chromium never ran.
- PERF.md: honest write-up. Bundle measured (own 6.1 KB vs @floating-ui
8.2 KB gzip -> ~2 KB net win). Speed numbers labelled CPU-work, NOT
perceived wall-clock; the +1-frame latency documented as a real cost.
- web/routes/demos/ethereal: visual A/B demo vs @floating-ui.
@floating-ui untouched — flag still OFF, P4 not executed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The positioning engine is pure collision geometry over `$adom` — a runtime
artifact, not soma-specific. Moves it out of `soma/layers/floating/engine/` into
a new `arts/floating` art (`$floating`), exactly parallel to `$motion`: both soma
(the JS positioning path) and eidos (the CSS-anchor path, future) build on it,
so the shared pure core belongs in arts, not buried in one consumer.
Moved to `$floating` (git renames, history preserved): geometry, rects, clipping,
overflow, supports, compute, auto-update, flag, types, the 7 middleware, and the
two parity test suites. `placement.ts` and the contract types `Measurable` /
`Middleware` / `MiddlewareData` move too — `$floating` is now self-contained
(depends only on `$adom`, never back on soma). New `arts/floating/index.ts` barrel
is the public surface. Alias `$floating` added to vite.config.ts + svelte.config.js.
Stays in `soma/layers/floating/` (reactive composition): use-floating.svelte.ts
(runes), floating.svelte.ts (providers/context), shell.ts, safe-polygon.ts,
utils.ts, the reactive types, index.ts. These now import `$floating`; soma's
types.ts/index.ts + soma/types/index.ts re-export the placement/contract types.
Two latent type gaps the typed `$floating` surface exposed (the soma loose-factory
shim had hidden them) are fixed: `DetectOverflowOptions` now declares `boundary`
(the middleware genuinely accept it; compute's read phase extracts it), and a
`size` test's `apply` returns void.
`@floating-ui` is UNTOUCHED — P4 deliberately NOT executed: the dep stays installed,
the fui imports + the `USE_OWN_ENGINE` flag (still OFF) remain in the wrappers, the
runtime still positions via floating-ui. Verified: 350 synthetic + 137 real-DOM
parity cases green from the new location, soma overlay providers green, check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drives the dual-system comparison suites to 487 cases (350 synthetic + 137 real-
DOM, all green) by implementing the gaps a coverage-enumeration audit
(floating-parity-coverage-gaps, 9 agents) found untested. Two more real bugs
surfaced and fixed:
- rects.ts getRectRelativeToOffsetParent passed the ELEMENT's own scale as the
getBoundingClientRect basis; floating-ui always uses the offsetParent's. They
diverge on an anisotropically-scaled or bordered offsetParent (getScale's
fallback rounds the two boxes differently) → sub-pixel reference shift. Now
passes offsetParent as the basis (the anisotropic-scale tests caught it).
- rects.ts omitted floating-ui's setLeftRTLScrollbarOffset for a window
offsetParent (offsets.x = getWindowScrollBarX(documentElement)). ~0 on a normal
LTR document, but non-zero under a left-side scrollbar / writing-mode:vertical-rl
→ wrong flip decision (the writing-mode test caught it). Also ports the
getViewportRect scrollbar-gutter correction + SCROLLBAR_MAX, and the isTopLayer
short-circuit in getClippingRect (native popover / modal <dialog> escape clip).
New coverage (synthetic): flip bestFit/fallbackStrategy/crossAxis-alignment/
multi-step, limitShift offset-object/axis-toggles/origin-side, size single-axis
shift + symmetric shrink, offset crossAxis under RTL, hide boundary + numeric
offsets, arrow padding clamp, the reset loop (bare-true preserve + MAX_RESET_COUNT),
padding-object × offsetScale, shift data delta.
New coverage (real-DOM): document scrollbar, reference-clip ≠ floating-clip
(altBoundary), anisotropic + scaled-and-scrolled offsetParent, scaled clipping
ancestor, multi-element collisionBoundary, different offsetParents, non-default
flip options, modal <dialog>, body-as-scroller, nested table chain, transformed
<html> scrolled, thick asymmetric border, writing-mode vertical-rl, shadow
crossing, floating in two scrollers, fixed×scaled sweep.
Documented as deliberate non-features (out of the frozen consumer API): derivable
function options, virtual-anchor contextElement, rootBoundary:'document',
visualViewport pinch-zoom, deep cross-iframe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds two suites asserting the in-house engine == @floating-ui (flag stays OFF;
no removal, no migration). Together they exercise the whole pipeline and caught
9 real bugs in the P1 DOM-read layer; all fixed and re-verified.
Suites:
- engine.test.ts — 232 synthetic math cases: runMiddleware vs @floating-ui/core
over identical synthetic rects. Full 12-placement × 7-edge matrix (full chain),
flip-at-edges, shift (main/cross × limiter), arrow (+ alignmentOffset), size,
hide, extremes (oversized/zero-size/fractional/negative-padding/huge-offset),
and scroll/scale/rtl variants. Pixel-identical.
- engine-dom.svelte.test.ts — 60 real-DOM cases (chromium/client project):
BOTH systems' computePosition on the SAME real elements — the only suite that
exercises rects.ts + clipping.ts. Nested scroll, transformed + CSS-scaled
offsetParent, fixed strategy, scrolled page, individual-transform containing
block, static-table-cell offsetParent, position:fixed escaping a scroll
container, arrow + size.
Bugs fixed (independently confirmed by the floating-engine-parity-audit workflow,
25 agents):
- rects.ts getRectRelativeToOffsetParent: inverted scroll/offset signs (+ missing
htmlOffset) — broke every scrolled page / positioned offsetParent.
- rects.ts convert: early-returned for a window offsetParent (=== win instead of
=== documentElement) leaving viewportDelta = 0, plus inverted signs, plus the
offsetParent rect must be RAW (includeScale:false) and scale must be applied.
- overflow.ts: scale the element rect by offsetScale (rect·scale + viewportDelta)
so CSS-scaled offsetParents detect overflow correctly; synthetic platform.convert
updated to model rect·scale too.
- rects.ts isContainingBlock: add translate/scale/rotate, gate filter/backdrop
behind !isWebKit(), drop container-type (matches shipped floating-ui), widen
the willChange regex. New isWebKit()/isTableElement() helpers.
- rects.ts getOffsetParent: skip static td/th, not just table.
- clipping.ts: getClippingElementAncestors (+ hasFixedPositionAncestor) — drop the
body and any overflow ancestor the positioned element escapes via a fixed/absolute
containing block; getViewportRect gates the visual-viewport offset on isWebKit();
getOverflowAncestors includes win.visualViewport for auto-update zoom tracking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New value-type-agnostic Picker — the shared transactional core that will
later replace the five system pickers' duplicated coordinators (date /
color / time / range), parameterised by TValue.
- morfo/components/picker.ts — provider part only; open/close delegated to
the composed Popover (no dialog semantics, no events).
- soma/components/picker — PickerProvider<TValue> + root wrapper (composes
Popover). Exposes workingValue (draft-or-bound), commit/cancel/clear and a
PickerShellHandle for the shared footer.
- web/routes/uix/components/picker — interactive deferValue testbed.
Unified transaction (the deferValue axis):
- deferValue=false: workingValue IS the bound value (live); cancel() reverts
to the open-edge snapshot.
- deferValue=true: workingValue routes to an internal draft; the bound value
only updates on commit() (Accept); any close that is not an explicit accept
discards. The form / onValueChange only ever see accepted values.
Verified: npm run check 0 new errors. Browser — deferred edit/accept/cancel/
dismiss-discard + live edit + live cancel-revert all correct, no console errors.
Not committed (shared files carry a parallel session's WIP): the soma/components
barrel export + the docs sidebar entry. Demo imports the picker via subpath so
the commit is self-contained.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finishes the in-house positioning engine that replaces @floating-ui. The DOM-read
core (rects + clipping ancestor-walk) was already committed; this adds the rest:
- engine/overflow.ts — detectOverflow, PURE over the pre-read clipping +
viewportDelta/offsetScale (no DOM read in the middleware phase).
- engine/middleware/{offset,shift,flip,arrow,size,hide,limit-shift}.ts — each a
factory with @floating-ui's call signatures + index barrel.
- engine/compute.ts — one dom.measure read phase → the pure, exported
runMiddleware loop (base coords → chain → flip/arrow reset, capped at 50).
- engine/auto-update.ts — ancestor scroll/resize via $adom listen, element
resize via ResizeObserver, optional rAF loop; no raw window/getBoundingClientRect.
- MiddlewareState gains viewportDelta/offsetScale/rtl (precomputed upfront so the
middleware stay pure); MiddlewareReturn.reset widened to boolean | {placement}.
- geometry.ts gains getOppositeAxis/getAlignmentSides/getExpandedPlacements/
getOppositeAxisPlacements; clipping.ts exports getOverflowAncestors.
- Middleware de-vendored from @floating-ui: engine/types owns it, ../types
re-exports it as the public surface.
- engine/flag.ts — USE_OWN_ENGINE A/B switch, default OFF (runtime still
@floating-ui). use-floating + floating.svelte branch on it and thread `dom`.
Verified: engine/engine.test.ts — 13-case parity guard proving runMiddleware is
pixel-identical to @floating-ui/core on synthetic rects (offset, flip with/without
overflow, shift, limitShift, arrow centerOffset + alignmentOffset, size, hide,
full chain with viewportDelta/scroll, rtl). npm run check at baseline (0 new).
Note: the engine/ dir keeps its P1 no-semicolon style (pre-existing), so it does
not match the repo's prettier semi:true; left as-is to avoid reformatting the
committed P1 files. P2/P3 (broad foreground-browser A/B) + P4 (@floating-ui
removal) remain — see engine CONTINUE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The last neutral component on a bespoke hover. tabs trigger (transparent base) moves to
the shared state layer:
background: var(--tabs-trigger-bg-hover)
-> background-image: linear-gradient(var(--state-hover), var(--state-hover))
Overlay (background-image) keeps it filled-safe for the segmented/pill variant. Removed
the now-orphan --tabs-trigger-bg-hover recipe token + regenerated base.css.
tabs.css is concurrently being reworked by the other session (segmented variant + focus
outline migration, uncommitted). Staged ONLY the hover line via line-level staging
(checkout HEAD + sed the single line) so their uncommitted segmented/focus work stays
untouched + unstaged in the working tree.
Completes the --state-* neutral-hover unification: every neutral component now uses the
state layer; only valenced (palette) hovers keep their own.
Verified: tabs.css uses --state-hover, token gone from base.css, segmented tokens intact;
eidos suite 15 failed (pre-existing) / 255 passed.
color-picker.css is clean (committed in 72e8fb57), so the deferred color-picker fold is
unblocked. The trigger + eye-dropper neutral hovers move from bespoke
--color-picker-{trigger,eye-dropper}-hover-bg to the shared state layer:
background: var(--color-picker-trigger-hover-bg)
-> background-image: linear-gradient(var(--state-hover), var(--state-hover))
The trigger is filled (--color-picker-trigger-bg base), so the tint overlays via
background-image (not background, which would replace the base). Kept the border-color /
text-color hover shifts. Removed the 2 now-orphan recipe tokens + regenerated base.css.
Leaves tabs as the only neutral component still on a bespoke hover (blocked by the other
session's tabs.css WIP).
Verified: color-picker hover tokens gone from base.css; color-picker.css uses --state-hover
x2; eidos suite 15 failed (pre-existing, unchanged) / 255 passed.
Self-contained CONTINUE.md to resume the positioning-layer rebuild cold: the
decisions (CSS-anchor primary + full-parity JS engine via $adom, reimplement-not-
vendor, P5 deferred), what's done (P0 + P1 foundation + the DOM-read/clipping core),
the read-phase architecture, the ordered remaining work (overflow + middleware +
compute + auto-update + wiring + dep deletion), the exact @floating-ui removal
surface, and the A/B verification plan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The synchronous read layer of the own engine — invoked only from the single
dom.measure read phase (post-layout, coalesced), reimplemented from floating-ui's
platform/dom (spec):
- engine/rects.ts — getOffsetParent (containing-block aware), scale-aware rects,
reference-rect-relative-to-offsetParent, viewport↔offsetParent conversion.
- engine/clipping.ts — getClippingRect: the FULL overflow-ancestor walk (option 2),
intersection of every scroll/clip ancestor + the viewport — what keeps an overlay
inside a nested scroll container. isOverflowElement / getOverflowAncestors.
Single-document fidelity (deep cross-iframe accumulation simplified — the rare
case). Node helpers reused from $adom. Compiles clean (0 new errors); not wired yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of the own positioning engine (replacing @floating-ui's math). Pure,
no-DOM foundation, not wired yet:
- engine/types.ts — internal positioning types (Rect/Coords/ElementRects/
ClippingContext/MiddlewareState/MiddlewareReturn/Middleware/ComputePosition*).
Read-phase model: one coalesced dom.measure reads rects + the full clipping
ancestor-walk + arrow dims upfront; middleware then run purely.
- engine/geometry.ts — pure placement/coords math (getSide/getAlignment,
getOppositePlacement, getOppositeAlignmentPlacement, computeCoordsFromPlacement)
reimplemented from the floating-ui algorithm.
Compiles clean (0 new errors). The DOM-read core (rects + clipping ancestor-walk)
is the substantial next slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First step of removing @floating-ui (the last external dep). Move the simple
positioning types off the dependency into our own placement.ts/types.ts and
scaffold the CSS-anchor capability gate — runtime still calls floating-ui's
computePosition (no behaviour change).
- placement.ts: own Placement (Side | `${Side}-${start|end}`, center = bare side)
+ Strategy.
- types.ts: own FloatingElement / ReferenceElement / MiddlewareData (only the
arrow/hide/transformOrigin keys we actually read). Middleware stays from
@floating-ui until P1 (de-vendored with the engine that defines its State/Return).
- use-floating / floating / safe-polygon: repoint type imports to ./placement | ./types.
- engine/supports.ts: supportsCssAnchor(win) — behavioural twin of the eidos
@supports gate (unused yet).
Verify: npm run check back to baseline (0 new errors); popover+tooltip tests 7/7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
decisions.md (the E3 design-rationale entry point) gains a cross-cutting entry
for the read-timing & token-resolution work — including the decision to reject a
static grep-guard in favour of the runtime `uix.perf` detector — pointing to where
the full argument lives (active_architecture §7 + arts/adom + arts/perf READMEs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the layout-read + token-resolution work across the doc corpus, beyond the
per-artifact READMEs already shipped (adom / color / perf):
- CLAUDE.md: `$perf` alias + perf/color in the arts list (the read-timing doctrine
bullet landed in abf76332).
- arts/README.md: perf + color rows in the artifact map; adom row notes post-layout
read scheduling (`measure`); `$perf` + `$color` in the aliases block.
- active_architecture.md §7 (Reglas duras): the read-timing discipline — layout
reads run post-layout (`dom.measure` / `dom.raf`), never sync-after-write; theme
token → colour via `eidos.resolveToken`; `uix.perf` detects violations. The
framework now governs READS like `dom.apply` governs writes.
- eidos/README.md: `ActiveEidos.resolveToken` / `resolveTokens` in "Runtime activo".
- COLOR_ENGINE_RFC.md: `uix.color` realized (`EngineColor`) + `resolveToken` consumer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add the read-timing rule to Key Conventions: layout-forcing reads
(getComputedStyle / getBoundingClientRect / offset* / scroll* / client*) run
post-layout via `dom.measure` (or a `dom.raf` callback), NEVER synchronously
after a write; a theme token → concrete colour via `eidos.resolveToken`, not a
`getComputedStyle` probe; `uix.perf` detects violations at runtime.
- Also lands a pre-existing uncommitted behavioral-guidelines preamble
(Think Before Coding / Simplicity First / Surgical Changes / Goal-Driven).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- floating: read the resolved content z-index via `dom.measure` instead of a raw
`requestFrame` + manual `cancelFrame` — coalesced post-layout, disposer returned
directly. The canonical `dom.measure` adopter.
- color-picker demo: resolve preset-swatch token colours via `eidos.resolveTokens`
(config + `$color`, pure JS) instead of the `getComputedStyle(probe)` round-trip
that forced the reflow. Restores the Clear footer button (`{#if showClear}`,
was a stray `{#if false}` debug edit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `arts/perf` — a dev-only forced-reflow detector on the Long Animation Frames
API. Turns Chrome's opaque "[Violation] Forced reflow while executing JavaScript
took Nms" into an attributed report: which script forced how much synchronous
style+layout (`forcedStyleAndLayoutDuration`). It catches the actual runtime bug
regardless of static pattern — what a grep guard can't do (the codebase has ~120
legitimate layout reads across ~48 components; the fault is the temporal
sync-read-after-write ordering, not the read itself).
`createActivePerf({ threshold, onReport, log })` owns the only PerformanceObserver
the framework creates; inert where LoAF is unsupported (non-Chromium). Discoverable
as `uix.perf`, opt-in via `createActiveUix({ reflowDetector: import.meta.env.DEV })`;
`ActivePerf` (stateful → Active*) is disposed by the composition root.
- src/arts/perf/{types,active-perf,index}.ts + README + 6 tests
- $perf alias (vite.config.ts + svelte.config.js)
- ActiveUix.perf getter + reflowDetector option
- disabled-dom stub gains measure() (completes the dom.measure interface)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `ActiveDom.measure(read, node?)` — schedule a layout-forcing read
(getBoundingClientRect / getComputedStyle / offset* / scroll*) in a coalesced
animation frame instead of synchronously. All reads queued in one turn run
together in a single rAF per window, so a read never forces a synchronous
reflow mid-write-turn — the cause of "[Violation] Forced reflow while executing
JavaScript". Returns the same idempotent disposer shape as `raf`; `dispose()`
cancels pending frames. Reads-only by design (writes sequence through `apply`).
The sanctioned home for layout reads in components: it owns *when* the read
runs (post-turn, coalesced), not *which* element.
- active-dom.svelte.ts — measure() + per-window queue + dispose cleanup
- test/active-dom.test.ts — 6 tests (defer / coalesce / dispose / throw-isolation)
- README.md — API + dated decision entry
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `ActiveEidos.resolveToken(token)` / `resolveTokens(tokens)` — resolve a
colour token (`--scale-{name}-{step}` / `--primitive-{role}-{step}`) to a
concrete sRGB hex purely in JS (eidos config + the `$color` engine), with NO
DOM read. This replaces the `getComputedStyle(probe)` round-trip consumers
used to reach a token's value, which forces a synchronous reflow. Role
primitives honour an applied `applyColorScheme` override (override-first);
palette scales resolve theme-scoped with the primitive palette as fallback.
Expose the `$color` engine at runtime as `uix.color` (`EngineColor` — stateless,
so `Engine*` not `Active*`) — a discoverable accessor next to `uix.motion` /
`uix.timers`. eidos keeps importing `$color` directly for build/SSR.
- src/uix/eidos/lib/resolve-token.ts — pure parseColorToken + normalizeToHex
- ActiveEidos.resolveToken/resolveTokens + ParsedColorToken export
- ActiveUix.color getter + EngineColor type
- arts/color README documents the uix.color surface
- tests: 16 (pure + integration against the base config)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The --state-* rollout (7de6c76b) migrated 19 neutral components' hovers to the state
layer but left their now-unused bespoke --{x}-bg-hover recipe tokens in base.ts. The
concurrent session's base.ts work is now committed (72e8fb57), clearing the entanglement
that blocked this. Removed the 16 confirmed-orphan tokens (0 var() consumers, verified
per-token against the consumed set + the component->line map):
accordion-trigger, breadcrumb-ellipsis, collapsible-trigger, spin-field-control,
calendar-control, calendar-day, pagination-control, radio-cards, select-trigger,
toolbar-control, file-upload-button, tag-group-item, tag-group-remove, editable-trigger,
tags-input-action, stepper-trigger
Kept every consumed/valenced hover (tag-group/toast/toggle palette, pagination-selected,
menu-item accent) + tabs-trigger + color-picker (still consumed). tabs stays bespoke
(blocked by the other session's tabs.css WIP); color-picker fold deferred. The existing
recipe-css-contract "does not leave declared public recipe variables orphaned" test is
the standing guard -- no new guard needed.
Verified: eidos suite 15/239 IDENTICAL with and without this change (stash-compared) --
the 15 failures are the concurrent session's pre-existing WIP, none from this. base.css
regenerated (palette.css unchanged).
- New <ColorSwatch> eidos primitive: transparency-checker base + colour
overlay, opt-in inset ring, inherits the container corner-shape.
- ColorPicker presets tab + saved colours: `presets` prop, bindable
`savedColors`, ColorPicker.Presets / SavedSwatches / SaveAction parts
(canonical hsvToHex comparison; save/delete component-owned).
- Readonly: the trigger no longer opens the popover when readonly — gated
via a PopoverTrigger `disabled` opt, rolled out across the color / date /
date-range / time / time-range pickers.
- ColorField swatch respects its container (no bg/border, inherits shape via
corner-shape); picker trigger swatch matches the button shape.
- Recipe: popover content-width tokens.
Documents this session's reference-grade coherence work in the canonical theming docs
(framework changes land in the framework's own docs, same pass):
- New §38 "Capa de estado (state-layer) --state-*": the MD3 token set
(hover 8% / press+selected 12%, currentColor -> theme-adaptive), the neutral-vs-valenced
doctrine, the overlay mechanism (transparent base vs filled gradient), the rollout
(accordion pilot + 19 comps + transversal fold of the archetype trigger/item rules),
the deferred cleanup. The ~168-hover unification had no doc until now.
- §32 (focus ring): new subsection "Outline en superficies" -- why box-shadow dies under
forced-colors/overflow + the hardcoded surface-default gap (misaligned halo on planes),
the 5 migrated components (ab62cca7), fields kept on box-shadow (inner-ring), the now
orphan --focus-ring token.
- TOC completed (§36 / §37 / §38 were missing) + revision date -> 2026-06-29.
- eidos/README.md reference table: focus-ring / touch-target / state-layer rows.
The --size-{tier}-font-size bundle (STATIC_SIZE) still encoded the superseded coupled
model -- each tier used the font ONE STEP DOWN (md->sm=14px, lg->md, xl->lg, xxl->xl),
the "compact control text" doctrine. But THEMING.md section 5 already revoked that on
2026-06-17 ("1:1 universal: control text follows the type scale; md-control is 16px,
not 14") and flagged the bundle's 14px as a stale fossil that "no longer reflects the
rule". This realigns the orphan bundle to the already-decided canon:
STATIC_SIZE md/lg/xl/xxl fontSize -> their own tier (1:1)
=> --size-md-font-size = var(--font-size-md) = 16px (was var(--font-size-sm) = 14px)
No component changes -- the bundle is orphan (0 recipe consumers; recipes already
declare 1:1 directly). This fixes the orphan reference + the docs so the size primitive
stops contradicting the live canon. Also realigns the paired line-height + letter-spacing.
- static.ts: 4 tiers fontSize -> 1:1
- active-eidos-config.test.ts: assertion --font-size-sm -> --font-size-md
- THEMING.md section 5: value, the revoked "two md / compact" box, the orphan note
- base.css: regenerated (--size-* hunks only; the concurrent session's base.ts work
left unstaged)
Verified: eidos suite 15/239 unchanged; no new type errors.
The box-shadow token --focus-ring hardcodes var(--color-surface-default) as its
gap-fill (render-css.ts:184), so a control focused inside a raised/overlay/filled
plane showed a mismatched halo; box-shadow rings are also clipped by overflow:hidden
and die under forced-colors. Migrate the last 5 box-shadow consumers to the canonical
outline pattern already used by button/card/~40 components:
outline: var(--focus-ring-width) solid var(--focus-ring-color)
outline-offset: var(--focus-ring-offset) (or 0, flush, for input + scrollbar)
- command-input (offset 0, keeps border-color shift) - collapsible-trigger - toggle -
splitter-resize-trigger (offset var) - scroll-area-scrollbar (offset 0)
Outline follows border-radius on every evergreen browser, isn't clipped by overflow,
and the forced-colors fallback already maps outline. The gap-color mismatch disappears
-- no --focus-ring-surface token needed.
NOTE: the box-shadow token --focus-ring now has zero CSS consumers (only THEMING.md +
a palabras doc reference it). Left in place -- it's a public foundation token; removing
it is a separate API decision (bundle with the --size-* orphan cleanup).
Verified: grep 0 box-shadow focus rings in component CSS; eidos suite 15/239 unchanged;
served rule confirmed in browser ([data-toggle]:focus-visible -> outline). Visual look
on keyboard focus worth a live Tab-through (CDP screenshot hung all session).
Folds the two TRANSVERSAL neutral-interaction rules in archetypes.css into the
canonical state layer (completes the --state-* unification at the archetype level):
- [data-archetype='trigger']:hover — opacity-dim (0.85) -> the state-layer tint
(background-image gradient of --state-hover), so bare triggers feel like the rest
instead of fading their own text.
- [data-archetype='item'/'option'] highlight (hover/focus/highlighted/selected) -
surface-raised swap -> --state-hover, so every menu/list active row (dropdown,
context, select, combobox, listbox, command) reads via the shared layer.
These were the LAST two neutral-hover idioms (canonical cross-component rules). No
base.ts touched -> no collision with the concurrent session.
Verified: folded rules served (state-layer gradient), old opacity/surface-raised gone;
eidos suite unchanged (15 pre-existing failures, none from this). Visual note: the 8%
tint ~ surface-raised (step-2) in light; menu-row highlight worth a live hover/keyboard
check (CDP screenshot hung all session).
Two fixes surfaced by the ColorPicker demo's
"[Violation] 'setTimeout' handler took 75ms".
Overlay open was gated behind the ~240ms perceptual hold. Popover
`present` / Dialog `open` / Drawer `present` flip `open` in the trigger
HANDLER but declared `sequence: 'pre'`, so the runtime awaited the emit
(and its hold) BEFORE mounting the content: the overlay opened ~240ms
late and its heavy first render ran inside the hold's setTimeout turn —
the violation. Switched these open events to `sequence: 'post'`
(mount-then-signal); `close` stays `pre` (signal must precede unmount).
Measured popover open 291ms -> 20ms, longtask 77ms -> 0.
Sema scheduled its hold / haptic delay / earcon completion with raw
setTimeout and the engine never received the timers service. Added a
`timers` port (semaDelay, src/uix/sema/timers.ts) forwarded through the
engine into all three channels; active-uix and defineEngineSemantic
inject uix.timers. Perceptual timing is now cancellable on dispose,
observable, and fake-clock deterministic; raw setTimeout survives only as
a unit-test fallback. Verified live: the hold now flows
VisualChannel.handle -> semaDelay -> uix.timers.schedule -> clock.
Docs: sema/README.md (timing via uix.timers + the post-open doctrine).
Propagates the state-layer unification (after the accordion pilot a13ca387): each
neutral :hover swaps its bespoke --{x}-bg-hover for the canonical MD3 state layer via
`background-image: linear-gradient(var(--state-hover), var(--state-hover))` — a
translucent currentColor overlay that works UNIFORMLY on transparent (tint shows) AND
filled (base bg survives under the overlay) bases. Keeps each component's `color:` text
shift; leaves the VALENCED palette hovers (pagination-selected, tag-group per-color,
toast, toggle, menu-item highlight) untouched.
Components: accordion, breadcrumb, calendar, collapsible, editable, field-control-trigger,
field-segment, file-upload, month-grid, pagination(control), radio-cards, range-calendar,
select(filled base), spin-field, stepper, tag-group(item+remove), tags-input, toolbar,
year-grid.
Verified: the gradient resolves to currentColor at 8% (`srgb .../0.08`) + the base bg
survives under it (computed probe); NO new test failures (the 6 failing eidos test files
are identical with and without the migration, and --state-hover/press/selected appear in
NONE of the failures — those are pre-existing card-group / palabras / dialog / icon).
Deferred: tabs + color-picker carry another session's uncommitted work (migrate once they
commit). Orphan --{x}-bg-hover recipe-token cleanup + guard test + the transversal
trigger/item fold follow.
Documents the 3 touch-target passes (a265d39a/c0904fc6/2750b9ce): 44px tap targets
gated on @media (pointer: coarse), controls + list rows + radio label-row, the
:not(lg)(xl) double-duty, markers via the React Aria label-row (not a Compose-style
visual restructure), and the WCAG AA-via-spacing conformance for bare grouped markers.
First step of the state-archetype unification (the audit's Arq.8 / the plugin's #1
rec): collapse the ~168 bespoke hover idioms into a canonical MD3 state layer. Adds
--state-hover / --state-press / --state-selected to archetypes.css — a translucent
`currentColor` overlay (8% / 12% / 12%), the single source for NEUTRAL interaction
feedback. Per-variant accent hover (solid -> palette-solid-hover, soft -> element, …)
stays in the recipes; the state layer owns the neutral / ghost tier.
Pilot: accordion-trigger:hover now uses var(--state-hover) instead of its bespoke
--accordion-trigger-hover-bg. Verified (computed): the token resolves to an 8%
currentColor tint on a real trigger (`srgb 0.125 / 0.08`); the color-mix + currentColor
chain confirmed directly. (Visual screenshot blocked by a renderer-hang in the tooling;
hover it live to see the tint.)
Rollout pending a design call: accordion has a TRANSPARENT base so a `background` swap
works; FILLED surfaces need a layered overlay (not a bg replace). The other ~30
neutral-hover components + the recipe orphan cleanup (--*-hover-bg) follow.
Marker touch-target, done the reference-grade WEB way (React Aria + WCAG), NOT a
Compose-style visual restructure. The clickable label ROW [data-radio-group-row]
(dot + text) becomes the 44px tap target on COARSE pointers; the dot stays small.
This mirrors React Aria (indicator + label wrapped as one target) + WCAG 2.5.5's
Equivalent exception (the larger row is the conformant target). Desktop (fine
pointer) keeps the compact 20px row.
The bare dot is intentionally NOT grown: grouped markers already clear WCAG 2.5.8
(24px AA) via the spacing exception — checkbox (12px gap) and radio (12/16px gap)
keep the 24px circles from intersecting (verified).
Verified: coarse rule serves; desktop row stays 20px (min-block-size auto), gate
doesn't leak.
Checkbox/switch have no built-in label-row (bare boxes; labels come from Field /
consumer) — their AAA label-row is a Field-level follow-up; grouped bare boxes are
already AA-conformant.
Second touch-target pass (after the button-like controls, a265d39a). On COARSE
pointers (touch), menu / select / listbox / command rows grow to the 44px mobile
minimum. `--list-item-height` (list-surface.css) is the row's min-block-size floor
that every list surface bridges to, so lifting it for the small sizes (xs/sm/md,
all < 44) grows every list row at once; lg (44) / xl (52) already clear it. Desktop
(fine pointer) keeps its compact rhythm.
Verified: coarse rule serves; md list-surface matches (->44), lg doesn't; desktop
md row stays calc(36px).
The framework's only axis below industry (ARCHETYPE_COHERENCE_AUDIT flagged it
RED: 0 components guaranteed 44/48px, 0 hit-area, md=36 < the mobile minimum). On
COARSE pointers (touch), grow button-like controls to the 44px target (WCAG 2.5.5
AAA / Apple HIG 44pt / Material 48dp); the desktop (fine pointer) keeps its compact
density — gated out, zero regression.
Transversal rule in archetypes.css: the trigger / close / action archetypes + the
standalone [data-button]. `:not([data-size='lg']):not([data-size='xl'])` does double
duty — skips the already->=44 sizes (so min-* only GROWS xs/sm/md, never shrinks
lg/xl) AND lifts specificity to 0,2,0 to beat the recipes' own min-block-size. Both
axes, so icon-only / short-label controls reach 44x44.
Verified: rule serves; md button + sm trigger match, lg button + item rows don't;
desktop button stays 36px (gate doesn't leak).
Follow-up passes: item/option rows (list-surface height), field-trigger, thumb, and
the small markers (checkbox/radio/switch — their clickable label often already
provides the target).
Presets tab reorganized into 4 labeled groups: Sistema (3 roles) + Intent
(6, incl. neutral) side by side, Paleta (31 scales) + Guardados (savedColors)
full width. Role/scale tokens resolved to rgb() via a color-mix probe so the
SwatchTrigger can commit on click. Labels follow the field canon (one step
below the input, regular weight). Preset swatches drop the recipe checker +
border (opaque). Bottom padding equalizes the two tabs' height.
A "Guardar color" button in the footer (ghost/neutral, matching Borrar/
Cancelar) saves the current value to Guardados: enabled only when a color is
selected, not already saved, and under the 10-color cap.
KNOWN ISSUE: the footer overflows with 4 buttons (Listo clipped) -- the save
button needs to become icon-only. See color-picker/CONTINUE.md.
Materializes the framework's signature corner. SURFACES (overlay panels +
non-floating cards) carry the continuous (squircle) corner by default; CONTROLS
stay arc. At surface-scale radii (>=~10px) arc and squircle visibly diverge
(premium); at control radii they're indistinguishable, so the split costs no
coherence.
The foundation rule (renderShapeBlocks) ENUMERATES the tier — NOT a
`[data-archetype='content']` hook, which also marks tabs/accordion/collapsible/
table content (non-surfaces) and would over-apply (verified per-attr). `:where()`
(specificity 0) lets a `[data-shape]` override win, so the `shape` prop flips from
opt-IN (activate squircle) to opt-OUT (`shape='rounded'` escapes to arc) on
surfaces. Theme knob `--shape-surface-default` reverts the whole tier; degrades to
arc where `corner-shape` is unsupported (border-radius magnitude is universal).
- Tier A — floating panels [data-{c}-content] (pickers inherit via Popover) + [data-command]
- Tier B — [data-card], [data-banner], [data-radio-cards-item]
- Guard test: surfaces resolve to squircle, controls/rows/pills stay arc
- THEMING.md §30 updated; pilot moved out of card.css into the foundation
Verified live (Chrome 150 renders the squircle); per-attr computed coverage
matches the taxonomy.
box-shadow is a single property, so the interactive hover shadow replaced the
selection ring on hover (the ring vanished). Compose both — ring first, hover
shadow second — for soft and solid variants.
The audit that kicked off the radius-decoupling / archetype-:where / floating-gap
canon work — per-component matrix for the 5 inheritance rules (scaling, density,
ambient size, sub-component scale, concentric corners) + the corrected v2 doctrine
(radius = Radix decoupled-from-size, corners = Apple concentric, ambient = Ant).
The per-menu panel anchors to the in-bar trigger, but the trigger is INSET from
the bar edge by the bar's padding PLUS its 1px vertical centering (the trigger's
`min-block-size` subtracts 2px → 1px each side). So the canonical `menu` gap (4px
from the trigger) left the panel flush with the BAR. Add that full inset back per
size to the content's `--floating-gap` so the visible gap reads exactly --space-1
(4px) FROM THE BAR. Also stamp a `data-menubar-content` marker on the eidos content
so the rule can target the menubar panel (it only carried `data-dropdown-menu-content`
before, so the existing `[data-menubar-content]` selector matched nothing).
Verified: File menu shows gapFromBar=4 (was -1, flush).
Canon-read fix verified live (menubar + dropdown show the 4px canonical gap on
real interaction); menu demos cleaned (commit 9c48164f). Notes the demo-only
stale-anchor artifact (dropdown auto-opens before layout settles).