You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/components/background/README.md

40 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, a Card, a Dialog.Content, a Box that 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's Overlay is 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 is surface.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-hidden and 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 a MediaPlayer instead of ducking for it), it would not attenuate the ui bus while it plays, and the sound slot of $prefs — the app's own "no sound" switch, projected as data-sound — would never reach it. One AudioContext per 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 shape MediaPlayer.PlayButton already 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 any aria-hidden subtree.
  • 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 pause snippet; supplying it suppresses the default, the way Switch chooses between a body snippet and its own thumb. Measured in Chrome: the default control and a snippet-supplied one both drive data-paused and 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: hidden silently kills the travel. view() binds to the nearest SCROLL CONTAINER, and overflow: hidden is one — even when it can never scroll (and overflow-x: hidden alone 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, currentTime freezes at whatever the layout happens to give, and the layer holds one position forever: no error, no warning, and a translate that 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 to overflow: clip. Use clip — it clips identically, respects border-radius, and is not a scroll container. Where the scroller is real and intentional (a scrolling panel), the travel correctly measures against it.
  • speed does nothing under attach="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-path takes no radius from border-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.Content is not a host for fixed. 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.

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 at eidos/components/backdrop with its own morfo). Its four patterns (glow · mesh · grid · dots), their fade mask and the prefers-contrast drop 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 ::before and wrapped its content; that is the shape this component supersedes, and the name collided with the modal veil (MUI/Vuetify, and the overlay archetype), which is why Background is not called Backdrop.
  • The hero block's background layout (blocks/hero/hero.svelte) — three hand-made Box layers, an inline scrim (style="background: var(--color-overlay); opacity: var(--opacity-scrim)") and a scoped, justified <style> for the media's object-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, .Pattern and .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 a Scrim inside 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 shows intent chips only because the shared <PalettePicker> binds both axes; there they do exactly one thing — a non-neutral intent SUPPRESSES the color prop (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 in button.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 a data-* 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 the Button the control composes, where its data-* already is.
  • The layers are aria-hidden and 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). Composing Box would 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 call image.ts records).
  • colors is 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 background SHORTHAND. A mesh token serialises to layered radial-gradients plus a trailing base COLOUR, which is not a valid background-image layer — through the longhand it computes to none and paints nothing.
  • The scrim mixes its weight into the paint instead of setting opacity: an opacity on the layer would fade the backdrop-filter behind 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 on put 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 explicit color, so a <Background.Scrim color="teal"> inside an on="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 (subtle 0.80 veiled MORE than overlay 0.65, which tied with muted — a consumer asking for subtle got the heaviest veil in the set). Now five steps, ordered by construction, with md holding the paint the hero shipped and xl carrying 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 a Scrim inside 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 an on="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: cover on 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's stretch does 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, which stretch does 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: a PauseSlot component that moved the read into its own render effect did NOT stop the loop, and neither did untrack alone. 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. Measuring getBoundingClientRect() 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 firing pointerenter or 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-y 0.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 real pointermove already carries pageX/pageY with 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, because pageY does not see that scroller's offset; it self-heals on the next enter. (A synthetic PointerEvent cannot test this: a constructed event reports pageY === clientY, scroll not included.)
  • The pause control is keyed on [data-placement], not on its bare marker. The composed <Button> declares position: relative at [data-button] — the same (0,1,0) — so a bare rule would be decided by which stylesheet the bundler emitted last. Measured: the control computed relative and sat 18px outside its host. Two attributes make the outcome a fact instead of a build detail.

Gaps

Gap Disposition
The strength scale is not ordered by veil weight — subtle (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
A bright photograph is illegible at EVERY weight, and the scale has a ceiling — 1.48:1 at the default and 2.66:1 at the top, because the borrowed ink's own alpha bounded it. Re-measured per pixel 2026-08-18 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

Powered by TurnKey Linux.