42 KiB
Background
The host of a surface's background layers — image, video, pattern, gradient, scrim, or a scene the app mounts itself. It is what a section, a card or a column composes when it needs something behind its content that CSS alone would make it hand-roll.
<Section>
<Background on="dark">
<Background.Pattern pattern="grid" fade="radial" />
<Background.Gradient colors="aurora" opacity="muted" blend="screen" />
<Background.Scrim gradient="to-t" />
</Background>
<Container>…the content, a sibling of the stack…</Container>
</Section>
It is a CHILD, never a wrapper
<Background> does not wrap anything and is not a box in the flow: it renders
as a child of the surface it dresses and pins itself behind that surface's own
content (position: absolute; inset: 0; z-index: -1). Two consequences worth
stating plainly, because they are the whole design:
- Any element can host it — a
Section, aCard, aDialog.Content, aBoxthat happens to be a grid column, a bare<li>. There is no wrapper to introduce and no layout to inherit: layout stays where it was. - The host is adopted, not configured. The foundation emits
:where(:has(> [data-background])) { --box-position: relative; position: relative; isolation: isolate }, so the parent becomes a positioned, isolated box by the mere fact of holding a Background. Mantine'sOverlayis the counter-example the doctrine points at: it needs its parent to be positioned, does not say so in code, and when it is not, the layer silently escapes to the nearest positioned ancestor.
:where() is deliberate: at specificity 0 a recipe's own position still wins
(Dialog.Content stays fixed — measured), and isolation changes no layout. A
parent with display: contents generates no box and therefore cannot host —
documented, not patched.
Why the rule also writes --box-position. Specificity 0 is not enough for
the most common hosts: box.css declares position: var(--box-position, revert-layer) at [data-box] — (0,1,0) — and eidos ships no @layer, so
revert-layer rolls the property back to the UA value. A Section or a Grid
column hosting a Background therefore computed static, and the stack
escaped to the nearest positioned ancestor: measured in Chrome on 2026-08-17,
and the same trap affix/types.ts documents from the other side (it refuses to
compose Box for exactly this reason). Feeding Box's OWN variable resolves it
inside Box's mechanism instead of out-specifying it, so an explicit
position prop — which arrives inline — still wins.
The parts
| Part | What it is |
|---|---|
<Background> |
the stack. Owns the geometry, the clip, the ink context (on) and the pause state; publishes the context below |
.Layer |
the generic layer — and the SLOT where an app mounts what the canon does not own (an Ambient scene) |
.Pattern |
glow · mesh · grid · dots · lines · noise · rings · vignette, all from tokens |
.Gradient |
a canonical named gradient, or an explicit stop list |
.Scrim |
the veil that buys legibility — flat or graded, optionally frosted |
.Image |
a photograph: composes <Image>, one source per theme mode, priority for the LCP case |
.Video |
a clip, with the five playback policies below |
.Pause |
the WCAG 2.2.2 control. Rendered FOR you when a layer moves; compose it only to move or restyle it |
Source order IS the stacking order: the first child sits at the bottom. Every
layer is aria-hidden and pointer-transparent; a scene opts back in with
pointer.
Legibility is composed, and that is on purpose
A framework cannot know how dark a photograph is, so nothing here auto-corrects
contrast. What it does is give both halves a name, so a consumer stops inventing
an inline style for each: <Background.Scrim> veils the layers, and
<Background on="dark"> re-binds the host's content roles (§39 D12) so the text
above resolves to the on-solid ink. The documented limits of that context are
D12's, unchanged: nested components resolve their OWN tokens, and portaled
content escapes.
The scrim's ink is the FLOOR of the ink in force, and that is the whole
design. on puts an ink in force (D12, theming/reference.md §39); the veil
underneath it is the other member of that pair, opaque — so strength IS the
alpha, one axis with one meaning:
| stack | ink above (D12) | the veil under it |
|---|---|---|
on="dark" |
on-solid (white) |
--color-content-on-solid-contrast (#1c1917) |
on="light" |
on-solid-contrast |
--color-content-on-solid (white) |
no on |
the page's own ink | --color-surface-default (the page's floor) |
Five weights, and each one names the WORK it does. Measured with the framework's
own maths ($color's apcaLc + wcagContrastRatio) at the worst artwork of each
context, and confirmed per pixel in Chrome on 2026-08-18:
strength |
weight | on="dark" over a WHITE photo |
on="light" over a BLACK photo |
|---|---|---|---|
xs |
0.08 | 1.17:1 | 1.05:1 |
sm |
0.13 | 1.31:1 | 1.09:1 |
(default) md |
0.19 | 1.49:1 | 1.33:1 |
lg |
0.40 | 2.52:1 | 3.05:1 |
xl |
0.70 | 6.45:1 · Lc 85 | 8.29:1 · Lc 61 |
xl is the only step that promises legibility over ANY photograph, and it
promises it against both floors the framework already uses: the on-solid pair
criterion (lib/on-solid.ts — APCA |Lc| ≥ 60 and WCAG ≥ 3) and §40's AA 4.5
for body text, in both ink contexts, over the worst artwork each one can
meet. 0.70 is the smallest weight that clears all four. The steps below it are
atmosphere, not guarantees: reach for xl when the artwork is unknown, or put the
copy on the calm end of a graded scrim when you own the picture. Stated because a
framework that hides this ships a hero that is illegible on somebody else's photo.
What this replaced, and why it was a defect and not a number. The ink used to be
--color-overlay— which issurface.backdrop, the modal dim (MD3/Radix/Vaul), tuned PER MODE:rgb(28 25 23 / 0.42)light,rgb(0 0 0 / 0.66)dark. Borrowed as a legibility tool it brought two defects. The same photograph read differently per mode (1.48:1 light vs 2.10:1 dark) — a photograph does not change with the mode, and legibility is not an axis the mode gets to move. And its own alpha capped the scale: even weight 1 could only reach 2.66:1, so no step could ever promise AA.on="light"was worse than capped: the veil was DARK under dark ink, fighting the very ink it was there to carry. The default's paint is unchanged in light mode to the digit (0.189 → 0.19, because the old backdrop's hue WAS this floor); dark-mode heroes lighten from 0.297 to 0.19, which is the mode letting go of a decision that was never its own.
Media, and how it fails
A background does not report its own failure. It gets out of the way, and the
layer below becomes the composition — which is why the source order reads
"cheap first, media on top". Measured in Chrome: a broken Background.Image
computes display: none on its layer (data-status='error') and the
Background.Pattern beneath it keeps painting. THE STACK IS THE FALLBACK.
So Background.Image composes the canonical <Image> with a background's
defaults: placeholder='none' (a full-bleed skeleton behind a hero's copy is
worse than a plain surface), no error glyph, and no alt — every layer is
aria-hidden, so a name here would be a promise the tree never keeps. A
photograph that MEANS something is content, and content is not a background.
Background.Video answers five questions before it plays a frame, and none of
them is the consumer's to remember:
| Policy | What stops it |
|---|---|
| out of view | the stack's seen — one observer for every layer |
| hidden document | IsDocumentVisible; a background tab decodes nothing |
reduced-motion |
it never autoplays; the poster stands in (WCAG 2.3.3) |
reduced-data |
the file is not even requested — the poster IS the layer |
| paused | the control below |
The element is driven by play() / pause() rather than the autoplay
attribute, because four of the five are runtime state that flips both ways and
the attribute is a one-shot at parse time.
And a sixth rule that is a law, not a policy: a background never sounds.
muted is not a prop — it is written true unconditionally, so the API cannot
express an audible background. Three reasons, in order of how much they bind:
- The component's own doctrine. Every layer is
aria-hiddenand decoration is never content, so a clip here cannot be carrying meaning that audio delivers. An audible layer would be content wearing a background's clothes. - WCAG 1.4.2. Audio that starts on its own and lasts more than three seconds owes the reader a way to stop it. The pause control happens to provide one, but a decoration should not be asking for consent it was never given in the first place.
- It would leak past
arts/sound, and that is the framework part. An audible<video>here plays OUTSIDE the audio graph:sound.buses.content.setMuted(true)would not silence it, it would not take part in audio focus (so it would talk over aMediaPlayerinstead of ducking for it), it would not attenuate theuibus while it plays, and thesoundslot of$prefs— the app's own "no sound" switch, projected asdata-sound— would never reach it. OneAudioContextper document is the whole point of that art; a background singing past it is the exact leak it exists to prevent.
A clip that MUST be heard is content: compose MediaPlayer, which is a citizen of
sound.media() and gets focus, ducking and MediaSession for free — or mount your
own element through Background.Layer and register it yourself.
The pause control (WCAG 2.2.2)
Content that starts on its own, lasts more than five seconds and sits beside other content must offer a way to stop it. The stack counts the layers that declared themselves moving and renders the control as its SECOND ROOT NODE — never inside the stack, which paints behind the host's content and eats no pointer events.
- The label CHANGES and there is no
aria-pressed(D-BG.18). The APG offers two ways to name a two-state control and doing both announces the state twice in two contradicting readings. It is the shapeMediaPlayer.PlayButtonalready uses for the same act. - It comes FIRST in the DOM, the order the APG fixes for a carousel's
rotation control: the keyboard reaches the way to stop the movement before
wading through what moves. Measured: reachable,
<button type="button">, outside anyaria-hiddensubtree. - Persistent, never hover-revealed — a hover control does not exist on a touch screen, and the keyboard user meets it first.
- To move or restyle it, pass the
pausesnippet; supplying it suppresses the default, the waySwitchchooses between a body snippet and its own thumb. Measured in Chrome: the default control and a snippet-supplied one both drivedata-pausedand freeze the drift (animation-play-state: paused).
Parallax, depth, and staying put
Three ways a layer can refuse to sit still, and none of them costs this component a scroll listener on the engines that ship the CSS for it.
| Prop | What it does |
|---|---|
speed |
how much of --background-parallax-travel the layer covers as the host crosses the viewport. 0 is pinned, 1 is the full travel. Depth reads as the INVERSE of speed: the far layer takes the smaller number |
bleed |
grow the layer past its host on the block axis so the travel never drags its own edge into view. Defaults to exactly the travel |
depth |
how far the layer drifts against the POINTER, in CSS length. Rising depth across a stack reads as parallax in Z |
spotlight |
a soft light that follows the pointer — a light, not a treatment: it composes over whatever the layer already paints |
attach |
fixed pins the layer while the host scrolls over it |
The travel is CSS, not JavaScript. animation-timeline: view() drives it
from the scroll position: no scroll listener, no rAF, no work of ours per frame.
Where an engine lacks it (Firefox is still behind a flag as of 2026-08), the
stack — and only then — runs ScrollProgress and writes
--background-progress, which the @supports not (…) branch feeds into the
same declaration. The two paths can never both be live: the JS side tests the
identical condition with CSS.supports, and both are gated on reduced motion.
It is not compositor-side, and the difference is worth stating because it is
the price of the design below: the travel animates a registered custom property,
and custom properties cannot be composited — the browser recalculates style for
them each frame. Compositing would require animating translate directly, which
would cost the pointer axis entirely. One recalculated property per travelling
layer, and none under reduced motion, is the better trade for a decoration.
Two axes, one translate. The scroll and the pointer both want to move the
same layer, and an animation on translate beats any static declaration — the
pointer would simply stop existing. So the scroll animates a REGISTERED custom
property (@property, or it would interpolate discretely and jump), and one
translate composes both terms. Measured: parallax alone gives 0px 30px, and
with a pointer at the top-right corner and depth: 20px it becomes
20px 10px — both terms, one property. translate and never the transform
shorthand, the same rule the draggable lift follows with scale.
The travel, measured in a visible Chrome (2026-08-18 — a hidden browser pane
never activates a CSS scroll-driven animation, so this is the only place these
numbers can come from). Over a 288px host with speed: 1, scrolling the page:
progress ran 43.5% → 95.5% and translate ran 0px -8.34px → 0px 58.19px,
monotonic and linear, reaching 0px 64px — the full --background-parallax-travel
(4rem) — at the end of the range. The keyframes run −travel → +travel, so the
midpoint is 0 (measured -0.03px at 49.98%) and the peak-to-peak amplitude is
twice the token.
And it costs nothing per frame. 200 frames of continuous scroll with the
travel live: median frame 16.7ms, p95 17.0ms, max 17.1ms, zero frames over
20ms — 60fps with no dropped frame, and a speed: 0 baseline in the same session
was no better (max 63.6ms, 6 frames over 20ms, ambient noise). Over the same 200
frames, driving BOTH axes (scroll plus a pointer with depth), the Long
Animation Frames observer that uix.perf uses reported 0ms of forced style +
layout — with the instrument proved by mutation: a deliberate 65ms layout
thrash on the same page reported 43ms forced and named the script.
attach="fixed" is position: fixed, never background-attachment. That
property is ignored outright on mobile Safari, and where it works it repaints
the whole layer every frame. A fixed child escapes the stack's overflow: clip
— clip does not contain fixed descendants — so the stack pairs it with a
clip-path: inset(0), which does. It follows that a Dialog.Content is not a
host for this mode: inside a fixed, transformed or contained ancestor a fixed
child behaves as absolute. No error, no effect — documented, not patched.
Five limits, stated rather than discovered.
- An ancestor with
overflow: hiddensilently kills the travel.view()binds to the nearest SCROLL CONTAINER, andoverflow: hiddenis one — even when it can never scroll (andoverflow-x: hiddenalone makes the element a scroll container on BOTH axes, which is how a landing page's "contain the decoration" wrapper does it). The timeline then resolves against something static,currentTimefreezes at whatever the layout happens to give, and the layer holds one position forever: no error, no warning, and atranslatethat looks like a plausible value. Measured on this component's OWN demo, whose harness pinned it at 52.63% at every scroll position until the stage moved tooverflow: clip. Useclip— it clips identically, respectsborder-radius, and is not a scroll container. Where the scroller is real and intentional (a scrolling panel), the travel correctly measures against it. speeddoes nothing underattach="fixed". The layer no longer moves relative to the scrollport, so there is no travel to scale. Pick one.- A fixed layer is clipped to a RECTANGLE.
clip-pathtakes no radius fromborder-radius, so inside a rounded Card the corners square off. The alternative would have the recipe guess the host's radius, which it cannot. - A
Dialog.Contentis not a host forfixed. Inside a fixed, transformed or contained ancestor, a fixed child behaves as absolute — no error, no effect. - The fallback measures against the WINDOW,
view()against the nearest scroller. For a background inside a scrolling panel the two paths disagree. It is the fallback that is narrower, and it only runs where the CSS is absent.
The runtime variables, written by the stack and read by the layers:
--background-pointer-x / -y (−1…1), --background-pointer-px / -py
(0…100%, for the spotlight's centre) and --background-progress (0…1, only on
the fallback path). They are runtime state, not theme tokens — a theme has no
business setting them.
Under prefers-reduced-motion the travel stops and the pointer listener is
never attached. Parallax is motion tied to the reader's own scrolling, which
is the exact class that triggers vestibular symptoms. The layer stays where it
is: reduce="static", not hide.
Talla y tema
37 claves públicas en lib/recipes/base.ts (bloque background). El componente
no tiene eje size: sus escalas propias son la fuerza del velo
(scrim-strength-{xs…xl}) y la escarcha (scrim-blur-{sm…xxl}), cada una
resuelta por su atributo (data-strength, data-blur).
Lo que entró el 2026-08-23 (57 % → 95 %), y por qué:
pattern-mesh-image— el patrónmeshleíavar(--gradient-aurora)a pelo. La costura: el VALOR es del catálogo de gradientes, el KNOB es de este componente, así que un tema puede dar otro mesh sin mover el aurora en todas partes. Verificado: el token resuelve al mismo valor que el primitivo.scrim-blur-{sm,md,lg,xl,xxl}— los cinco pasos leíanvar(--blur-*)a pelo. Misma costura, y con una razón extra:blures familia MÉTRICA del ejescaling(theming §23), así que un literal ahí sería ciego al zoom global. Medido paso a paso: 4 · 8 · 12 · 16 · 24 px, idénticos antes y después.
Lo que NO se acuña, y queda firmado en su declaración:
- El grano del ruido es un
feTurbulenceen un data-URI: la textura ES la técnica —el único patrón que el UA no elimina bajoforced-colors— y re-escaparlo para meterlo en el contrato cambiaría el valor serializado sin cambiar la imagen.pattern-noise-opacitysigue siendo su mando. - El
100%deimg/videoes identidad: una capa de medio ES la caja de su capa. --_background-gradient-imagees CANAL DE VALOR, no superficie de tema: lo escribebackground-gradient.svelteen línea, desde la propcolors. Un público encima no lo alcanzaría (el inline gana) y mentiría — el mismo caso que--gp-current-gradientdel gradient-picker.
Preferences
| Preference | What happens |
|---|---|
prefers-reduced-motion |
self-moving layers freeze (reduce="static", the default) or the decoration goes away (reduce="hide"). Never the content |
prefers-contrast: more |
the saturated bloom (glow, mesh) drops; the structural patterns stay |
forced-colors |
every layer goes. The UA strips gradients by itself, but an url() — the noise, an image, a video — survives, and it would sit under text the OS has just repainted |
The context
getBackgroundContext() publishes { paused, reduced, seen, registerAnimated }
to the subtree — including a layer's CONTENT, which is how the Ambient pack
will honour a pause without the canon ever importing it (the dependency runs
pack → framework). registerAnimated() is how a layer DECLARES that it moves on
its own; the count is what decides whether a pause control is owed at all
(WCAG 2.2.2).
Baseline
Two, both in-tree:
Backdrop— deleted 2026-08-18 (D-BG.1, on the author's explicit order; it lived ateidos/components/backdropwith its own morfo). Its four patterns (glow · mesh · grid · dots), their fade mask and theprefers-contrastdrop were migrated here value for value, so nothing of it is lost — read it in the history if you need the original. It painted ONE layer in a::beforeand wrapped its content; that is the shape this component supersedes, and the name collided with the modal veil (MUI/Vuetify, and theoverlayarchetype), which is whyBackgroundis not calledBackdrop.- The hero block's
backgroundlayout (blocks/hero/hero.svelte) — three hand-madeBoxlayers, an inline scrim (style="background: var(--color-overlay); opacity: var(--opacity-scrim)") and a scoped, justified<style>for the media'sobject-fit. The measured case that motivated the component: a block cannot write CSS, so the geometry had to become canon.
The seed collections (web/routes/demos/heroscrolling,
web/routes/demos/animations/background) stay as comparative reference; nothing
is ported from them.
Comparativa
| Capability | UIX Background |
Mantine (BackgroundImage + Overlay) |
Vuetify v-parallax |
react-scroll-parallax ParallaxBanner |
Aceternity / Magic UI / shadcn.io |
|---|---|---|---|---|---|
| Stacked layers with blend / weight / mask | ✓ one primitive | ✗ (two components, no stack) | ✗ | ✓ (layers array) | ✗ (one effect = one wrapper) |
| Host adopted automatically | ✓ (:has, foundation) |
✗ (the parent must be positioned; silent when it is not) | n/a | ✗ | ✗ |
| Patterns from theme tokens | ✓ 8, re-tint per theme + mode | ✗ | ✗ | ✗ | ✓ but colours hand-picked per snippet |
| Scrim / overlay | ✓ (flat · graded · frosted; ink = the floor of the ink in force, one step measured to clear AA over any photo) | ✓ Overlay (color + opacity + blur — the consumer picks the number) |
✗ | ✗ | ad hoc |
| Named themeable gradients | ✓ (colors="aurora" | stop list) |
✗ | ✗ | ✗ | hex per snippet |
| Reduced-motion / forced-colors / contrast by construction | ✓ | ✗ | ✗ | disabled by hand |
rare |
| Ink context for the content above | ✓ (on, D12) |
✗ | ✗ | ✗ | ✗ |
Declared contract (parts, aria-hidden, tokens, guards) |
✓ morfo + audit + lint | ✗ | ✗ | ✗ | ✗ |
| Breadth of exotic effects | the Ambient pack (32) |
✗ | ✗ | ✗ | ✓ (dozens, copy-paste) |
The last row is the deliberate split, not a gap: an effect is decoration and lives in the pack tier; the HOST is contract surface and lives here. A layer is the seam between them.
Sema events
0 events on Background's own morfo. The stack is a composition frame: per
CANON §5 the signal belongs to the act inside the frame, and the only act in
reach is the pause — which fires contact-activate from the IconButton that
Background.Pause composes (D-BG.18: an IconButton with a label that changes
and no aria-pressed, never a Toggle). The audit therefore classifies this
component as interactive by composition (its pause part is a <button>),
which is the same reading fab carries.
Subset
color— the FULL set: any hierarchy role, any of the palette scales, or a raw CSS value (ComponentColorProp), on.Scrim,.Patternand.Gradient. Decoration is the one place where restricting the palette would be arbitrary: a background is not signalling anything, so no value of the scale is wrong for it. What the colour must NOT do is arrive by inheritance — see the presence guard in §Decisiones, which is why aScriminside a<Card color="teal">still paints a veil and not teal.intent— not accepted, on purpose. Intent is evaluative (affirm,risk,threat…) and a background makes no claim about anything; the one place an evaluative colour belongs here is the content ABOVE the stack, which resolves its own. The demo showsintentchips only because the shared<PalettePicker>binds both axes; there they do exactly one thing — a non-neutral intent SUPPRESSES thecolorprop (color={intent === 'neutral' ? color : undefined}), which is the harness being honest about a prop this component does not take.
Audit exceptions
R-1.5 exception:the focus treatment lives inbutton.css— the pause control IS the canonical<IconButton>(the Fab precedent: the same element carries both markers). This recipe only adds the placement and the layer geometry; a focus ring of its own would be a second ring on one control.A2.3 exception:none of the three parts declares adata-*in the morfo, and that is the contract being honest rather than thin. The attrs this component does stamp (data-kind,data-pattern,data-blend,data-opacity,data-fade,data-strength,data-attach,data-paused…) are wrapper attrs: there is no soma layer here, so no prop of theirs crosses the boundary the thumb rule names (theming/reference.md§39) and putting them in the morfo would promise a contract no other layer consumes. The parts have no contract STATE of their own either — the stack's only state is the pause, and it lives in theButtonthe control composes, where itsdata-*already is.- The layers are
aria-hiddenand pointer-transparent, so there is no other focusable surface here to treat.
Passive justification
Zero events, scope: ['eidos']. A background paints and reacts to scroll or
pointer as CONTINUOUS MODULATION, never as an act — the same contract-level
criterion scroll-frames records for itself, plus the standing rule against
emitting per frame. The one user act in reach, pausing, belongs to the
IconButton the Pause part composes and is declared in THAT morfo
(contact-activate, from button.ts).
Decisiones
- Child, not wrapper; no
Box(D-BG.14). ComposingBoxwould duplicate a whole layout API on a component that owns no layout, and wrapping would force every consumer to restructure. The host-adoption rule is what makes the child form work without a footgun. - The visual knobs are NOT in the morfo.
data-kind,data-pattern,data-blend,data-opacity,data-fade,data-paused… are wrapper attrs: there is no soma layer, so none of their props crosses the boundary the thumb rule names (theming/reference.md§39; the callimage.tsrecords). colorsis discriminated by SHAPE (TextGradient's D-T3): a bare string names a canonical gradient, an array is a stop list. One prop, not two.- The gradient paints through the
backgroundSHORTHAND. A mesh token serialises to layered radial-gradients plus a trailing base COLOUR, which is not a validbackground-imagelayer — through the longhand it computes tononeand paints nothing. - The scrim mixes its weight into the paint instead of setting
opacity: anopacityon the layer would fade thebackdrop-filterbehind it too. - The ink is the FLOOR of the ink in force, not the modal's dim. A scrim is a
legibility tool, so its colour is the floor of whatever
onput in force — three existing roles, no new one invented (reference.md§16.C). Borrowing--color-overlay(surface.backdrop) made the same photograph read differently per mode and capped the scale below AA; the table above records both, measured. The FOLLOW-ON is a:where()on the context selector: at plain specificity it reached (0,4,0) and beat an explicitcolor, so a<Background.Scrim color="teal">inside anon="dark"stack painted the floor instead of teal. Wrapped, the family sits at (0,2,0), the context wins over the base block by ORDER, and[data-color](0,3,0) wins over both — measured. - The weight scale is the VEIL'S OWN, and each step names a JOB. The weights
used to borrow
--opacity-*, which names how opaque an ELEMENT is; read as veil names that scale was not even monotonic (subtle0.80 veiled MORE thanoverlay0.65, which tied withmuted— a consumer asking forsubtlegot the heaviest veil in the set). Now five steps, ordered by construction, withmdholding the paint the hero shipped andxlcarrying the only promise the component makes about contrast. The numbers come from$color's own maths against the framework's own floors, not from taste — see the table. - A layer reads the shared palette only when IT carries the colour.
--palette-*inherits, so an unguarded read would make aScriminside a<Card color="teal">paint a teal veil instead of a veil. The presence guard ([data-background-layer][data-color]) is the same one THM-2 uses one level up. Measured in Chrome: inside a teal Card the scrim resolves its own floor and the pattern--color-primary-solid; with<Background.Scrim color="teal">it resolves teal — including inside anon="dark"stack, where the explicit colour beats the ink context (the:where()above is what buys that). - The media fit lives in the recipe, not in each consumer's scoped style —
the layer IS the box, so
object-fit: coveron a slotted<img>/<video>belongs to it. - A layer is a one-cell grid, so whatever the app drops in fills it — a
Surface, a composed<Image>, an app wrapper. Grid'sstretchdoes that without this recipe writing sizes onto content it does not own. Media is the exception and is sized explicitly:<img>/<video>are REPLACED elements, whichstretchdoes not apply to (measured: a bare<img>stayed 1×1 while every other child filled). - A layer registers as "moving" from INIT, never from a
$effect— rule A30 (anchor-nav-provider.svelte.ts§registerLink). Registering from a reactive effect writes the stack's count during the effect phase, invalidates the sibling that reads it, and the flush re-enters:effect_update_depth_exceeded, measured in Chrome. It does not merely log — it KILLS the root effect, so the pause button renders and then every click on the whole surface is silently ignored. The consequence is deliberate: whether a layer drifts is read at MOUNT, an authoring fact, not a live one. Bisected, not guessed: aPauseSlotcomponent that moved the read into its own render effect did NOT stop the loop, and neither diduntrackalone. Moving the REGISTRATION to init did, and removing the extra component afterwards kept it fixed. - The pointer's host box is cached in DOCUMENT coordinates, against
pageX/pageY. MeasuringgetBoundingClientRect()on every move is what makes a smooth effect stutter, so the box is cached — but a viewport-relative rect has to be invalidated every time the host MOVES in the viewport, and scrolling moves it without firingpointerenteror a resize, the only two things that drop the cache. Measured in Chrome with a real mouse: one wheel tick over a 288px host threw--background-pointer-y0.77 out of true — the 111px of scroll over half the height, to the hundredth — pushing the value outside the −1…1 the vars promise and visibly tearing the spotlight away from the cursor, and it stayed wrong until the pointer left and re-entered. Since a realpointermovealready carriespageX/pageYwith the scroll folded in, caching the box in document coordinates fixes it with no layout read in the hot path and no scroll listener — the property this component exists to keep. Residual, stated rather than patched: a host inside a NESTED scroller drifts again, becausepageYdoes not see that scroller's offset; it self-heals on the next enter. (A syntheticPointerEventcannot test this: a constructed event reportspageY === clientY, scroll not included.) - The pause control is keyed on
[data-placement], not on its bare marker. The composed<Button>declaresposition: relativeat[data-button]— the same (0,1,0) — so a bare rule would be decided by which stylesheet the bundler emitted last. Measured: the control computedrelativeand sat 18px outside its host. Two attributes make the outcome a fact instead of a build detail.
Gaps
| Gap | Disposition |
|---|---|
strength scale is not ordered by veil weightsubtle (0.80) veiled MORE than overlay (0.65), which tied with muted. Measured 2026-08-17 |
RESUELTO 2026-08-18 — the veil got its own five-step scale (xs…xl), ordered by construction, md holding the shipped 0.45. See §Decisiones |
RESUELTO 2026-08-18 — the veil's ink became the FLOOR of the ink in force (opaque, three existing roles), so weight IS alpha and the ceiling is gone: xl clears both framework floors in both contexts over the worst artwork (6.45:1 / Lc 85 and 8.29:1 / Lc 61). The same change fixed on="light", whose veil used to be DARK under dark ink. See §Legibility |
|
| Demo page + the 9-tab harness | implementar — F4 |
The hero's background layout still hand-builds its layers |
implementar — F2, once Image/Video exist |
Ambient reading the context to pause its scene |
diferir — a task of the PACK (D-BG.8), after F2 |
| Per-layer scroll velocity in a nested scroller | descartar — the fondo does not orchestrate content; ScrollFrames owns scrub, and pinned storytelling is another initiative |