Files

23 KiB
Raw Permalink Blame History

P5 Motion library: evidence log

Package card: docs/phase2/PLAN.md §2.3 "P5 Motion library". Contract: docs/phase2/contracts/motion.md. Owns: docs/phase2/research/springs.py (the one source), theme/02-motion.nowrap.css, device/shared/motion.js, native/shared/motion_tokens.h (all three generated by springs.py --write), docs/phase2/contracts/motion.md. Steam build at the time of writing: 11094443.

Status

M1 reached (2026-10-07 ~04:00): contract docs/phase2/contracts/motion.md written; the three outputs are generated and complete (not stubs: tokens, keyframes and functions are inert until a caller uses them, so no flag is needed); springs.py --check (MO-1) and --test-js (MO-2) pass offline; glass.py check-theme PASS.

M3 reached, card complete (2026-10-07 05:55): every Builds item is in and MO-1 to MO-5 pass (MO-3 and MO-4 live in both input-mode stubs, pad and laser). P5 has no layer, plate or popup fragments, so M4's native gates do not apply to it; M5 is V2's run. Version of the token table: ed4e044810 (all three outputs, springs.py --check).

Session 2 (2026-10-07 05:30–06:05, resumed after the usage-limit cut at 04:30):

  • Live: motion.js loaded by P1's runtime (rt.shared.motion = window.__LGS_MOTION); MO-2 in CEF; MO-3 and MO-4 PASS (springs.py --live mo3|mo4|cef [--mode pad|laser]).
  • Filmstrips next to window-nav-motion.html (shots/p2_cmp_p5_motion.png): alerts and sheets popped in, so large glass now fades over the glass channel (D-P5-8, contract changelog); after the fix all 5 storyboard rows match.
  • G-MOTION (P10's gates) judges P5's keyframes through __LGS_MOTION and catches non-token controls. The scroll-driven lgs-edge-in is wrongly failed as "running at rest": isScrollDriven() added, REQ P5->P10 open.
  • MO-5: P9 builds glassd with the header; own run of glassd --selftest phase PASS; header compiles on the Frame with -Wall -Wextra -Wpedantic, no warnings.
  • Reproducible from the repo: springs.py --live cef|mo3|mo4|film (the filmstrip moved out of scratch into springs.py; mo3/mo4 now go back to the route they found).

Remaining (none blocking): REQ P5->P10 (skip scroll-driven animations in G-MOTION) and REQ P5->P7 (optional use of create()) are answered by their owners; any REQ addressed to P5 later is handled in a new session.

How to regenerate

python docs/phase2/research/springs.py --write     # rewrite the three outputs
python docs/phase2/research/springs.py --check     # MO-1 + outputs up to date
python docs/phase2/research/springs.py --test-js   # MO-2 (node)
python docs/phase2/research/springs.py --live cef          # MO-2 inside Steam's CEF (locked lab step)
python docs/phase2/research/springs.py --live mo3 [--mode pad|laser]   # MO-3 (one locked shot step on /zoo/buttons)
python docs/phase2/research/springs.py --live mo4 [--mode pad|laser]   # MO-4 (same, --media reduce)
python docs/phase2/research/springs.py --live film [--media reduce]    # filmstrips + shots/p2_cmp_p5_motion.png

Evidence

Test Command Result Shots / JSON Date Steam build
P5-K1 probe: two 0% keyframe blocks with different easings python glass.py js "<probe1.js>" (temporary style + off-screen div in main, removed) getKeyframes() keeps 2 keyframes at offset 0 (linear(0 0%, 1 50%, 1 100%) for opacity, linear for translate); at 250 ms of 1000: opacity .4, translate −6 px, i.e. each property eased by its own block — 2026-10-07 11094443
P5-K2 probe: @keyframes redefined inside @media same step a matching @media (min-width: 1px) redefinition wins (from .7); a non-matching @media (max-width: 1px) one is ignored (from .3) — 2026-10-07 11094443
P5-K3 probe: implicit end keyframe same step 0%, 35% { opacity: 0; filter: blur(8px) } ends at the element's own opacity .8 and filter: none — 2026-10-07 11094443
MO-1 python docs/phase2/research/springs.py --check b0, b15, b20, b25 linear() byte-identical to DESIGN2 §11.3 and present in 02-motion.nowrap.css; all 3 outputs up to date; version ff9644ee1e — 2026-10-07 n/a (offline)
MO-2 (node) python docs/phase2/research/springs.py --test-js every token at 1 ms steps: max error 1.0e-3 (the settle clamp at the last sample; limit 2e-3); create() retarget keeps value and velocity (jumps ≤ 4e-5, ≤ 5e-4/s), done exactly at the CSS duration (441 / 488 / 735 / 210 / 250 ms) — 2026-10-07 n/a (offline)
check-theme python glass.py check-theme PASS (35 files) — 2026-10-07 n/a (offline)
MO-1 (re-run) python docs/phase2/research/springs.py --check PASS: 4 curves byte-identical, 3 outputs up to date, version 2428c76f74 (changed from ff9644ee1e by the exit-curve fix, see D-P5-7) — 2026-10-07 05:31 n/a (offline)
MO-2 (node, re-run) python docs/phase2/research/springs.py --test-js PASS: worst 1.01e-3 (limit 2e-3); create() done at 441 / 488 / 735 / 210 / 250 ms — 2026-10-07 05:31 n/a (offline)
Live load python glass.py js "…__LGS_RT.shared…" rt.shared = [motion], rt.shared.motion.version = window.__LGS_MOTION.version = 2428c76f74, failedLoads empty — 2026-10-07 05:33 11094443
MO-2 in CEF python docs/phase2/research/springs.py --live cef (evaluates the generated motion.js in SharedJSContext, samples every token at 1 ms, compares with the Python closed form on the PC) PASS: worst 1.01e-3 (limit 2e-3; before the settle clamp 5e-10); create('depth') done at 441 ms; the runtime's global restored — 2026-10-07 05:40 11094443
MO-3 python docs/phase2/research/springs.py --live mo3 (one glass.py shot main p2_p5_mo3 --route /zoo/buttons --theme on step; its pre builds 20 glass test elements off-screen in the main window, one per keyframe as contract §2 calls it plus the three utility classes; samples f = 0, .15, .35, .5, .75, .999 while paused; then plays them all and waits until 1 s after the longest one ends) PASS 20 / 20: 1 s after, no lgs-* animation on any element; each element's opacity, scale, translate, filter, backdrop-filter, clip-path, background-color, box-shadow equal its own values before the animation (no resting scale, filter or clip-path); the .lgs-mat-out exit holds at opacity 0 (forwards, for a node removed at animationend); stage removed; timeline advanced 1844 ms (window visible); __LGS_MOTION.audit() finds every one a token duration and token easing. Samples match the contract, e.g. lgs-mat-glass-in scale 1.0375 → 1.0172 at .5 → 1, backdrop blur(0px) saturate(1) → 22 px at 92 %, coverage 1 by 28 %, content 0 until 35 %; lgs-sheet-in scale .97 → .9983 at .5; lgs-morph clip from the source inset to 0; lgs-page-in translate 16 → .9 px at .5 shots/p2_p5_mo3.png (the route after the step); full table in this session's output, rerun to see it 2026-10-07 05:38 11094443
MO-4 python docs/phase2/research/springs.py --live mo4 (same step with --media reduce: step: media=reduce on 11 targets) PASS 20 / 20: matchMedia(reduce) true; tokens on html: snappy 180, sheet-in 200, hover-in 150, mat-in 180 ms, --lgs-ease-snappy = the b0 curve; every keyframe animates opacity only (entrances, exits, lgs-focus-in from .6) or nothing (lgs-mat-content-*, lgs-sheet-content-*, lgs-morph-content, lgs-catch, lgs-knob-lift, lgs-shift-in); every duration 150–200 ms; nothing left 1 s after shots/p2_p5_mo4.png 2026-10-07 05:39 11094443
MO-5 P9: ./build/glassd --selftest phase and GLASSD=build/glassd sh tools/test_phase.sh with glassd built against native/shared/motion_tokens.h (-I../shared) PASS (P9's run): selftest worst 0.0010 (limit 0.002), test_phase.sh 40 values worst 0.001 (window cover, thick slab and plate on sheet-in / sheet-out; liquid on 250 / 350 ms) wp/P9.md rows MO-5, GL-4 2026-10-07 05:06 11094443
Filmstrip v1 python <scratch>/p5/film.py A (one glass.py shot main p2_p5_film_a --pre <film.js> step: 30 miniature scenes of our own nodes in the main window, the storyboard's 5 rows, each keyframe called as contract §2 says, P4's thick glass, every animation paused at f × row time; stage removed after) + tools/mockshot.py window-nav-motion.html Morph, sheet out and route match; alert and sheet present popped in (glass fully opaque at f .15, storyboard ≈ .37 / .50): fixed in springs.py (D-P5-8), version 2428c76f74 → ed4e044810 (overwritten by v2) 2026-10-07 05:42 11094443
MO-1, MO-2 after the fix springs.py --write, --check, --test-js; glass.py check-theme; glass.py sync PASS: 4 curves byte-identical, outputs up to date, version ed4e044810; MO-2 worst 1.01e-3; check-theme PASS (38 files) — 2026-10-07 05:44 n/a
MO-3 after the fix python docs/phase2/research/springs.py --live mo3 PASS 20 / 20, nothing left 1 s after, stage removed. New opacity samples at f .15 / .35 / .5 / .75: lgs-mat-large-in .20 / .48 / .68 / 1; lgs-sheet-in .50 / 1 / 1 / 1; lgs-sheet-out .75 / .21 / .07 / .01; lgs-toast-in exactly 1 from f .15 (was .998) shots/p2_p5_mo3.png 2026-10-07 05:45 11094443
Filmstrip v2 vs mockup film.py A B, then <scratch>/p5/compose.py (storyboard rows above live rows) Match on all 5 storyboard rows: morph clip grows from the More circle (40 % at 91 ms vs 41 %), content in 15–50 %; alert glass faint at 66 ms, content from 35 % of 250 ms, swell 1.019 → 1, scrim on fade; sheet .97 → 1 with glass half at 110 ms, content 25–70 %; dismiss: content out first, glass gone by ~50 %; route: old out in 150 ms linear, new in on page after 40 ms with 16 px parallax. Known differences: no overshoot on the morph clip (D-P5-9); the storyboard tiles have a room photo behind the window. Set B (no storyboard; checked against MO §3.4, §4.4, §4.10, §4.15): small glass in (glass leads, content sharpens 35–100 %), small glass out (content gone by 55 %, then the glass), toast in (materialize in the first 250 ms, 8 px drop on b15), focus first frame at .6, toggle knob lifted into clear glass 20–65 % while it travels shots/p2_p5_film_a.png, shots/p2_p5_film_b.png, shots/p2_cmp_p5_motion.png 2026-10-07 05:46 11094443
G-MOTION with P5 keyframes python glass.py gates main --route /zoo/buttons --only motion --json --pre <3 test divs: lgs-mat-glass-in on mat-in, lgs-sheet-in on sheet-in, lgs-fade-in 300ms ease>; then the same with lgs-fade-in var(--lgs-d-fade) ease P5's keyframes (with their composite per-keyframe curves) pass P10's token checks through __LGS_MOTION; the negative controls are caught (duration 300 ms, then keyframe easing ease); at rest 1 s after: 0 running, 0 lgs-* left; test divs removed. Without a pre: MOTION PASS — 2026-10-07 05:47 11094443
P5-K4 probe: scroll-driven lgs-edge-in gates main --route /zoo/buttons --only motion --pre <scroller with scroll-timeline, sticky child animation: lgs-edge-in linear both; animation-timeline; animation-range: 0px 24px>; then glass.py js with __LGS_MOTION.audit() The animation is in document.getAnimations(), playState running, timeline ScrollTimeline, duration auto; opacity 0 at scrollTop 0, .5 at 12 px. G-MOTION FAILs it (running at rest, lgs left, non-token duration): REQ P5->P10. motion.js now exports isScrollDriven() and audit() marks the row scroll: true, tokenDuration: true (node test + live) — 2026-10-07 05:49 11094443
MO-3 / MO-4, both input modes springs.py --live mo3 --mode pad, --live mo3 --mode laser, --live mo4 --mode pad PASS each: 20 / 20, nothing left, stage removed (step: mode=pad via classes, mode=laser via rt.test.input.set, media=reduce on 11 targets) — 2026-10-07 05:53 11094443
MO-5 (own run) frame_ssh.py run "…/native/glassd/glassd --selftest phase" (offline, no SteamVR) PASS: worst 0.0010 (limit 0.002); sheet-in settles 735 ms, sheet-out 515 ms, linear 250 / 350, reduce 180 ms. The installed binary prints header version 2428c76f74: the header changed since only in kVersion (CSS composites are in the hash), no rebuild needed — 2026-10-07 05:50 11094443
Header compile test g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 on the Frame (GCC 15.1.1) of a test that includes motion_tokens.h and evaluates spring() at each token's settle time (/tmp/lgs/p5, deleted after) PASS, no warnings: |y| ≤ 0.00101 at every settle time; coverage(.15) .5000; version ed4e044810. settled() (which also bounds velocity) is still false at the exact settle ms for release-touch, morph-open, sheet-out and true 1 ms later, as in glassd's selftest (515 ms) — 2026-10-07 05:51 11094443
Filmstrip, reproducible python docs/phase2/research/springs.py --live film (the filmstrip is now part of springs.py: two locked shot steps, stage removed after each, mockup rendered with tools/mockshot.py, comparison composed) Same frames as v2 (all 5 storyboard rows match; set B as above); stage gone after both steps shots/p2_p5_film_a.png, shots/p2_p5_film_b.png, shots/p2_cmp_p5_motion.png 2026-10-07 05:58 11094443
Filmstrip, Reduce Motion springs.py --live film --media reduce Every interaction is a cross-fade ≤ 200 ms (all animations 150–200 ms): menu appears whole at half opacity at 91 ms with no clip morph, alert and sheet fade in place with no swell, route new page with no parallax, small glass fades with no blur ramp, knob does not lift; nothing scales or moves shots/p2_p5_film_a_reduce.png, shots/p2_p5_film_b_reduce.png 2026-10-07 06:00 11094443

Decisions (PLAN §1.17 rule: safe default, noted here)

  • D-P5-1 Reduce Motion durations. PLAN §1.5 and VP P-56 ask for fades of at most 200 ms. Every duration token is redefined under prefers-reduced-motion: reduce: 150 ms (hover-in, interactive, morph-close, CTL §4's "150 ms fade from .6"), 200 ms (sheet-in, MO §4.8), 180 ms for the rest (--lgs-d-reduce); glow-off, swap, page-out keep 90 / 120 / 150 ms. Bounce curves become b0 (D2 §16).
  • D-P5-2 Reduce Motion keyframes. Instead of asking every caller for a media query, each lgs-* keyframe is redefined inside the media query as opacity only (content and decorative keyframes empty). Proven mechanism: P5-K2.
  • D-P5-3 Extra keyframes. Context packages own no *.nowrap.css file, so every keyframe they need must be here. Added beyond the card's list, from the concepts: lgs-mat-large-in (alerts, R8), lgs-sheet-content-*, lgs-morph-content, lgs-catch (MO §4.6), lgs-knob-lift (MO §4.15, CTL), lgs-shift-in (SET §SteamVR switch), lgs-fade-in/-out, lgs-edge-in (GP scroll edge). The card's lgs-morph is the clip keyframe of MO §6.4.
  • D-P5-4 Extra fixed tokens. --lgs-d-page-out 150, --lgs-d-glow-off 90, --lgs-d-swap 120, --lgs-d-reduce 180 and the delay tokens (--lgs-delay-*, --lgs-stagger) carry durations the specs name outside D2 §11.2's table, so call sites never write literals (P-58).
  • D-P5-5 Not defined here. --lgs-press-grow, --lgs-press-max, --lgs-focus-first, --lgs-card-lift, --lgs-disc-lift are P4's (contracts/tokens.md); lgs-focus-in reads --lgs-focus-first.
  • D-P5-6 No @property in 02-motion. The card lists "@property declarations", but P4 already registers --lgs-hover, --lgs-press, --lgs-focus (identical to D2 §16) with the states that use them. A second registration in the later 02 file would silently override any future P4 change, so P5 registers none; no keyframe needs one.
  • D-P5-7 Exit curves sit at the hold offset. The first live MO-3 run (session 1, 04:28) showed that Chromium 126 merges the implicit start values of the other properties into an explicit 0% keyframe, easing included, so a custom coverage curve on 0% { opacity: 1 } also drove scale and backdrop-filter. springs.py's tail_of() now finds where the exit curve leaves 1 (86.5 % for lgs-mat-glass-out, 26.5 % for lgs-sheet-out) and puts the curve on a keyframe at that offset; 0% keeps the caller's easing. Version ff9644ee1e → 2428c76f74. Proven by MO-3's samples: lgs-mat-glass-out scale stays 1 until 55 % and is 1.0167 at 75 % (the --lgs-ease-mat-out curve).
  • D-P5-8 Large glass fades over the glass channel. First version: every keyframe used glassd's coverage alpha smoothstep(0, .3, m) (MO §9). The live filmstrip next to window-nav-motion.html showed alerts and sheets popping in (fully opaque by 69 ms and 110 ms) where the storyboard fades them over most of the glass channel. In glassd the frost, tint and lensing keep ramping until m = .92, so the slab still materializes; a large CSS slab may not ramp its blur (MO R8), so its opacity is the only glass channel left. P5 now gives large glass the perceived optics ramp instead: min(1, m / .8) (LARGE_COVER in springs.py; light 0–.6 with frost and tint .2–.92 average to about that), and the mirror on exit, min(1, (1 − p) / .8). The storyboard draws 1.3 g (alert), 1.25 p (sheet in) and 1 − ramp(p, .25, 1) (sheet out); measured live: alert .20 / .48 / .68 / 1 at f .15 / .35 / .5 / .75 of 250 ms, sheet in .50 at 110 ms (storyboard .50), sheet out .75 / .21 / .07 (storyboard .80 / .22 / .08). Small glass and toasts keep glassd's coverage, because their blur, tint and shade ramp alongside. glassd itself is unchanged.
  • D-P5-9 Menu morph clip has no overshoot. The storyboard's morph row shows the b .20 morph-open overshoot (1 %); lgs-morph clips on the b0 curve as MO §6.4 specifies (a negative inset() is not dependable); glassd draws the overshoot in native mode. Measured: clip 40 % at 91 ms (storyboard 41 %), 83 % at 212 ms (90 %).

Requests

  • REQ P5->P4: in theme/00-tokens.nowrap.css, the Phase 1 Reduce Motion rule --lgs-t-fast: 1ms; --lgs-t: 1ms; --lgs-t-slow: 1ms is the rule MO C8 withdraws ("fades stay"; VP P-56 wants fades ≤ 200 ms, not instant). Please map it to --lgs-t-fast: var(--lgs-d-interactive); --lgs-t: var(--lgs-d-hover-in); --lgs-t-slow: var(--lgs-d-fade) (P5 sets those to 150 / 150 / 180 ms under Reduce Motion), and consider aliasing --lgs-spring: var(--lgs-ease-b15) and --lgs-ease: var(--lgs-ease-b0) (D2 §11.3: they replace Phase 1's curves). Your call on timing; the aliases die with Phase 1 anyway. P4 (2026-10-07): done: under Reduce Motion --lgs-t-fast/-t/-t-slow = var(--lgs-d-interactive/-hover-in/-fade) (fallbacks 150/150/180 ms); --lgs-spring: var(--lgs-ease-b15, <P1 curve>), --lgs-ease: var(--lgs-ease-b0, <P1 curve>).
  • REQ P5->P9: please build glassd with native/shared/motion_tokens.h (#include "../../shared/motion_tokens.h" or -I../shared) and take the phase constants from it: lgs_motion::kPhaseUp / kPhaseDown (d .5 / .35, b 0) instead of the literals 0.5f / 0.35f in PhaseAnim::step, kMatInMs / kMatOutMs / kReduceMs instead of 0.25f / 0.35f / 0.18f, lgs_motion::spring() / settled() for springB0, and kRampLight … kRampAlpha + coverage() in phaseMap. Then tools/test_phase.sh is MO-5 / GL-4. The header compiles standalone with g++ -std=c++17 -Wall (see evidence) and its values equal the ones glassd uses today, so the build output is unchanged.
    • P9 (2026-10-07 05:12): done. glassd.cpp includes motion_tokens.h (-I../shared); PhaseAnim and phaseMap use kPhaseUp/kPhaseDown, kMat*Ms, kReduceMs, spring()/settled(), the kRamp* ranges and coverage(). glassd --selftest phase PASS (worst 0.0010) and tools/test_phase.sh PASS (40 values, worst 0.001), evidence in wp/P9.md.
  • REQ P5->P8: motion.js publishes globalThis.__LGS_MOTION in vr:systemui when you prepend it to lgs_sg.js (daemon §9). On stop() please also delete globalThis.__LGS_MOTION there (or call __LGS_MOTION.remove()), so G-REMOVE finds no global left. In SharedJSContext P1's loader already deletes it.
    • P8 (2026-10-07 04:28): done. motion.js is prepended in the same scope as lgs_sg.js (opts.motion = true; the sg version hashes both files), and every destroy() path (teardown, nothing left to show) also calls __LGS_MOTION.remove() and deletes globalThis.__LGS_MOTION in vr:systemui (SG_DESTROY_JS).
  • REQ P5->P7: lgs_sg.js can use __LGS_MOTION.create(token, {value}) for the depth channel: .to(target, now) keeps value and velocity (MO C5), .done(now) latches exactly at the CSS settle time for a step (441 ms for depth) with the criterion of your sg.md §3.4, and .value(now) is then exactly the target. Token names equal yours (depth, sheet-in, sheet-out, fade, snappy). __LGS_MOTION.spring(y0, v0, tSeconds, d, b) is MO §8 verbatim if you prefer to keep your own state. Contract §3.
    • P7 (2026-10-07 08:20): done the second way: with motion.js prepended lgs_sg.js uses __LGS_MOTION.spring and tokens[name].d/.b (status().motion = "motion.js") and keeps its own channel state (it records every retarget and push for timeline()); same settle criterion. SG-1 PASS with motion.js: max error 0.0002 mm against the closed form, 27 pushes, final push at 444 ms, 0 pushes after.
  • REQ P5->P10: for G-MOTION (gates MOTION, glass.py motion): CSS animations carry their easing in the keyframes (effect.getKeyframes()[i].easing, serialised by Chromium as linear(0 0%, …, 1 100%)), and P5's keyframes use a few composite per-keyframe curves (coverage ramps, content windows). Please accept every curve in __LGS_MOTION.allowed.easings (or call __LGS_MOTION.isTokenEasing(str) / isTokenDuration(ms), which handle the serialisation), and every duration in __LGS_MOTION.allowed.durations (includes the Reduce Motion ones: 90, 120, 150, 180, 200). __LGS_MOTION.audit(doc) returns one row per getAnimations() entry with both verdicts, if you want to call it directly (SharedJSContext, when the runtime is loaded).
    • P10 (2026-10-07 05:40): done. gates (MOTION) and glass.py motion call __LGS_MOTION.isTokenDuration(ms) / isTokenEasing(str) whenever __LGS_MOTION is loaded in the page they audit (SharedJSContext or the vr: page), for the animation's easing and every per-keyframe easing; the local D2 §11.2 table is only the fallback without the runtime. motion also records __LGS_MOTION.audit(doc) as motionLib in its JSON. Only animations from our stylesheets (our keyframes, or transitions our rules declare) are judged; Steam's own are listed. contracts/lab.md §4.
  • REQ P5->P10: G-MOTION (gates MOTION and motion) fails a scroll-driven animation: the scroll edge effect (lgs-edge-in on a scroll timeline, MO §4.13, contract §2) is listed as running at rest, as lgsLeft, and as a non-token "duration 2.85714% ms" (probe P5-K4 below), although nothing moves while the scroll is still. Please skip animations for which __LGS_MOTION.isScrollDriven(a) is true (timeline is a ScrollTimeline / ViewTimeline, i.e. not document.timeline) in the at-rest, lgsLeft and duration checks, and keep the easing check. audit() rows carry scroll: true for them.
    • P10 (2026-10-07 06:35): done: animations with __LGS_MOTION.isScrollDriven(a) (else a.timeline !== document.timeline) are skipped in the at-rest, lgsLeft and duration checks; their easing is still checked.