Floor Texture Thermal Shimmer โ make the material behave, not animate¶
Status: design PARKED 2026-09-14. Written up because the feasibility is settled, not because it is scheduled. Nothing here is a commitment to build it; it is here so the mechanism and the three traps are not re-derived from scratch later.
Scope note. This is a delta on the floor-texture layer stack described in render-harness and the registry in
src/textures/floor-texture-registry.js. Every seam it needs already exists โ no new renderer, no new service, no new token type.
1. The idea¶
Black One's marble reads as scorched stone with partially molten inclusions: a dark matrix that has stayed solid, threaded by an irregular orange network that looks like lower-melting-point material gone plastic inside fractures and grain boundaries.
The observation this design turns on: that read comes from the irregularity. The veins do not glow evenly. Some of the network looks molten, some merely oxidised, some like a heat-affected seam. It implies a thermal history the card never states.
Extend that from appearance to behaviour. Drift the two vein layers' opacity by 1โ2% on slow, mutually prime periods, so different parts of the fracture network appear to heat and cool independently. Not a pulse โ a shimmer.
Why it would read as material rather than as animation: the masks are fixed PNGs. The fracture geometry cannot move. Only the apparent energy in the inclusions changes, so the stone stays solid and the inclusions look unstable. That is the whole effect, and it is also why the amplitude has to stay tiny. At 10โ20% it becomes a glowing-lava GIF. At 1โ2%, with periods that do not divide into each other (7.3s against 11.1s beats at ~81s), it does not resolve as a loop at all.
This extends the theme language from what does this material look like to how does this material behave over time, which the card already does once โ see the status pulse in ยง2.
2. What already supports it¶
| Fact | Where |
|---|---|
The veins are genuinely separate compositing layers โ marble is four spans (base, micro, vein-major, vein-minor), each with its own data-role |
marble's layers[] in src/textures/floor-texture-registry.js, emitted by renderers/floor-texture-surface.js::_renderFloorTextureLayer |
So each is independently targetable and can carry its own animation-duration |
.evcc-ftx-layer[data-role="vein-major"] |
| Theme-defined motion is an established pattern, not a new concept | src/styles/rooms.js โ animation: evccPulse var(--evcc-status-pulse-duration, 1.6s) infinite |
prefers-reduced-motion is already handled in three stylesheets |
styles/learning.js, styles/mobile.js, styles/rooms.js |
| Visual baselines are safe โ the harness freezes animation | harness/mount-entry.js FREEZE_STYLE sets animation-duration: 0s !important under freeze: true, which every preview and visual spec passes |
Because freeze collapses an animation to its end state, a frozen capture lands on the 100% keyframe every time. Author the keyframes so 0% and 100% hold the same value and the baseline is deterministic by construction.
3. TRAP: the obvious implementation does not work¶
A CSS custom property cannot be animated in keyframes.
An unregistered custom property is not interpolatable. A keyframe on
--evcc-floor-marble-vein-major-opacity flips discretely at 50%, so the result is a
two-state flicker, not a drift. Registering it
(@property { syntax: "<number>"; inherits: true; initial-value: โฆ }) would make it
interpolate, but that adds a registration surface for every token involved and couples the
theme catalog to a second declaration mechanism.
Do not animate the token. Animate a real CSS property and let tokens parameterize it โ exactly what the status pulse does. The token supplies the duration; the keyframes are fixed in the stylesheet.
3a. TRAP: the layer already owns opacity¶
.evcc-ftx-layer sets:
opacity: calc(
var(--evcc-floor-textures-card-enabled, 1) *
var(--floor-opacity-card, 0.85) *
var(--layer-opacity, 1)
);
An animation on opacity overrides that normal declaration outright for the duration
of the animation. Naively adding one snaps every vein to full strength the instant it starts.
The keyframes have to reproduce the product and scale it:
@keyframes evccFtxThermalMajor {
0%, 100% { opacity: calc(var(--evcc-floor-textures-card-enabled,1) * var(--floor-opacity-card,0.85) * var(--layer-opacity,1) * 0.99); }
50% { opacity: calc(var(--evcc-floor-textures-card-enabled,1) * var(--floor-opacity-card,0.85) * var(--layer-opacity,1) * 1.01); }
}
Verbose, but it keeps the whole token chain intact โ including the card-textures kill switch, which must still be able to turn the layer off.
3b. TRAP: opacity is cheap, blur is not¶
Opacity animates on the compositor. filter: blur() forces a repaint every frame, and
these are full-bleed masked layers on every room card โ five to twelve on screen at once,
two vein layers each. An always-on blur oscillation is a permanent repaint load for an effect
nobody consciously registers.
Ship opacity drift only. If the blur breathing is wanted, confine it to the theme-preview
swatch (renderers/theme-preview.js::_renderFloorPreviewCard), where exactly one card renders
and the user is deliberately looking at the material.
4. Why it must animate the SPAN, not the token โ a second, independent reason¶
The map renderer composites its own SVG version of each floor type, and reads the opacity
once through a hidden probe: bindings/map.js::_resolveFloorOpacity applies the token's
value to a real CSS property and reads back the computed result. It samples. It cannot follow
an animation.
Animate the span's opacity and the map is simply unaffected โ it keeps using the base value.
Card shimmers, map stays still. Animate the token and the map silently disagrees with the
card about what the material looks like.
That disagreement has happened before, and the scar is worth reading: FTX-VEIN-1. Marble's
two vein layers point at computed --โฆ-opacity-eff tokens that nothing defines in CSS, whose
registry default is a clamp(0, calc(var(--evcc-floor-marble-vein-opacity,0.5) + โฆ), 1)
string. The map's parseFloat("clamp(...)") was NaN, fell back to 1, and composited
both veins at full strength while the card rendered them at 0.5/0.38 โ three editor sliders
moved the card swatch and nothing else. The probe exists because of that bug.
5. What it would take¶
| Piece | Where | Note |
|---|---|---|
| Two keyframe blocks | src/styles/floor-texture-styles.js |
major and minor, each reproducing the opacity product (ยง3a) |
Two animation declarations |
same | on [data-role="vein-major"] / [data-role="vein-minor"] |
| Two period tokens | src/theme-tokens/floor-textures.js |
kind duration. Default 0s = off, so every existing theme is unchanged and this is opt-in per theme |
| An amplitude token, or a fixed 1% | โ | open, see ยง6 |
A prefers-reduced-motion arm |
same stylesheet | non-optional: a shimmering floor under the whole UI is the case that media query exists for |
| A gate | harness/tests/gallery-completeness.spec.mjs, beside [FLOOR-1] |
assert the animation is absent at the default period and present when one is set โ otherwise this is a feature whose off-switch nothing checks |
Honest size: four layers, two surfaces (card and map), a reduced-motion path and an opt-in default. It looks like three lines of CSS and is not.
6. Open โ decide before building, not during¶
- Amplitude: token or constant? A token is one more thing to tune and to get wrong (the whole effect dies above ~2%). A fixed 1% cannot be ruined by a submitter. Leaning constant, with the period tokenized โ the period is what makes it feel bespoke.
- Per-material or marble-only? โ SETTLED: per-material, zero everywhere, but for a better reason than "only marble makes sense". See ยง8; the renderer must not be the thing deciding what is physically sensible.
- Does the map need a matching treatment? Card shimmers, map does not (ยง4). Acceptable, or does the map need a static "hot" bias so the two surfaces do not visibly disagree?
- What does the theme editor show? A period slider with no live preview is a control the
author cannot evaluate. The floor-materials board (
harness/fixtures/gallery.js) is frozen for capture, so it cannot demonstrate this at all.
7. Why it is parked¶
The feasibility is not the risk โ that is settled above. The risk is that the effect is subliminal by design: at the amplitude where it works, nobody can tell you whether it is on. That makes it very hard to know when it is finished, and close to impossible to review. That is the sort of thing to start rested and deliberately, not at the end of a session.
8. The renderer does not enforce realism¶
The obvious grouping is by whether a material's texture already implies heterogeneous internal structure:
| Plausibly reactive | marble, granite, concrete | the masks already read as fracture networks, aggregate, and inclusions |
| Fights the illusion | tile, wood, carpet | rigid manufactured geometry, or a flat texture where opacity drift reads as brightness pumping rather than material behaviour |
And within the reactive set the recipes should differ, or it looks like one animation pasted onto three textures: marble is vein energy, granite is aggregate speckle, concrete is broad/micro mottling.
But that table is a statement about DEFAULTS, not about capability, and the distinction is the whole design. Wood grain brightening out of phase would read as wetness or a supernatural glow long before it read as heat โ which is a defect in Black One and the entire point in an arcane, bioluminescent, alien-organic or corrupted-tech theme. Marble veins as magic channels. Granite aggregate as a starfield. Concrete microtexture as embers.
Tile is the clearest case, and it inverts the table: rigid grout geometry is a liability for thermal shimmer and an asset for deliberate artificial energy โ grout as emissive circuit traces, with a pulse chasing through the intersections. Same layer system, completely different fiction.
So the architecture and the authoring are separate questions:
- Can a material carry motion? Any of them. The layers are already independent and the renderer has no opinion.
- Should it, by default? Only where it matches the material โ and even then, opt-in.
This is what makes the per-material, zero-default shape correct. Not because six materials minus marble is nonsense, but because deciding what a layer MEANS is the theme author's job, not the renderer's. A generic "every floor inherits shimmer because the primitive exists" would foreclose exactly the interesting cases.