Skip to content

Add motion tiers (calm / standard / expressive) as a context-suggested stance - #49

Merged
kurenn merged 2 commits into
mainfrom
feat/motion-tiers
Jul 29, 2026
Merged

Add motion tiers (calm / standard / expressive) as a context-suggested stance#49
kurenn merged 2 commits into
mainfrom
feat/motion-tiers

Conversation

@kurenn

@kurenn kurenn commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Why

v0.3 ships one motion setting for every prototype. But motion must be context-fit: a dense dashboard with cinematic hero-morphs is friction; a marketing landing with only a crossfade under-delivers. This gates motion intensity by a tier — a stance the skill decides from context and records in DESIGN.md, exactly like the existing signature-move / icon-stance / imagery-stance decisions. standard is the default it dials up or down from.

The three tiers — html[data-motion="calm|standard|expressive"] (default standard)

Root transition Hero morph .enter / .reveal Scroll-driven Micro-interactions
calm fast crossfade only (~130ms, no slide) off render instantly off kept (:active/hover/focus)
standard (default) crossfade + slide on as authored off kept
expressive crossfade + slide on --spring + richer distance/duration .reveal upgraded to scroll-driven kept
  • calm — dense tools/dashboards. Suppresses the directional slide, the list→detail hero morph, and all entrance/reveal animation. Deliberately keeps press/hover/focus feedback — that's feedback, not friction. Loading/Speed is a separate control, untouched.
  • standard — exactly today's v0.3 behavior; adds no rules.
  • expressive — marketing/landing/pitch. Standard plus a gentle --spring easing (linear() with a cubic-bezier fallback, ~2–3% overshoot) on .enter, slightly richer entrance distance/duration, and .reveal upgraded to scroll-driven where animation-timeline: view() is supported.

Changes

  • index.html / 404.html — inline anti-FOUC <head> script now also sets data-motion from localStorage before first paint (pagereveal fires pre-paint, so the tier must be on <html> before the transition CSS resolves); static <html> tag gets data-motion="standard". New compact Motion section (Calm / Std / Exp) in #proto-controls; motion.js added after theme.js in the script order.
  • css/styles.css--spring token (@supports linear() + cubic-bezier fallback); calm overrides ::view-transition-*(root) to a short fade and forces .enter/.reveal to their visible end-state; expressive applies --spring to .enter and adds a reduced-motion-gated scroll-driven .reveal. The global prefers-reduced-motion block still wins over every tier.
  • js/vt.jspageswap/pagereveal early-return the hero-morph naming under calm (root crossfade still runs via CSS).
  • js/app.js — reveal observer short-circuits under calm.
  • js/motion.js (new) — mirrors theme.js: reads/writes localStorage['proto-motion'] (+ ?motion= param), applies data-motion, syncs aria-pressed, wires [data-motion-option] buttons.
  • reference/discovery.md — new "Motion tier" DESIGN.md decision (picked from register + tone + screen types; a stance to decide and record, never a default to forget).
  • reference/build.md — documents the three tiers, the copy-list, file-tree, and script order; cross-references "Page transitions" and .enter.

Zero-dep (CSS + vanilla JS + Tailwind CDN). Everything degrades cleanly — the tier only scales motion that already degrades on its own. SKILL.md untouched (no headroom).

Verification

  • node --check on vt.js / motion.js / theme.js / app.js — all pass.
  • Headless Chrome, zero JS console errors under each tier; html[data-motion] flips calm/standard/expressive via ?motion=.
  • calm reveals render instantly (computed opacity:1) while the global :active press rule stays; standard unchanged (.reveal opacity:0 + translateY(12px)); expressive .enter computes the spring linear() easing.
  • Control bar stays one row at 1440 in all three tiers (scrollWidth == clientWidth).
  • check-overflow.sh 390PASS.
  • prefers-reduced-motion neutralizes expressive: .enter duration clamped to 0.01ms, .enter/.reveal forced visible.

Needs live tuning

The --spring linear() curve and the expressive scroll-driven .reveal animation-range are set by reason, not by eye — worth a quick look on real content to confirm the overshoot reads as "gentle" and the reveal timing feels right, not laggy.

🤖 Generated with Claude Code

kurenn and others added 2 commits July 29, 2026 11:23
…d stance

v0.3 shipped one motion setting for every prototype, but motion must be
context-fit: cinematic hero-morphs in a dense dashboard are friction; a
marketing landing with only a crossfade under-delivers. Gate motion intensity
by a tier the skill decides from context and records in DESIGN.md — a stance,
like signature-move / icon-stance / imagery-stance.

Gate everything off html[data-motion="calm|standard|expressive"] (default
standard):

- calm — dense tools/dashboards: root transition is a fast crossfade only (no
  directional slide), no list->detail hero morph, .enter/.reveal render instantly,
  scroll-driven effects off. KEEP micro-interactions (:active press, hover, focus)
  — feedback, not friction. Loading/Speed is a separate control, untouched.
- standard (default) — exactly today's v0.3 behavior; adds no rules.
- expressive — marketing/landing/pitch: standard plus scroll-driven .reveal where
  supported, a gentle --spring easing (linear() with a cubic-bezier fallback,
  ~2-3% overshoot) on .enter, and slightly richer entrance distance/duration.

Implementation:
- index.html/404.html: inline anti-FOUC <head> script now also sets data-motion
  from localStorage before first paint (pagereveal fires pre-paint, so the tier
  must be on <html> before the transition CSS resolves); static html tag gets
  data-motion="standard" as the pre-JS default.
- styles.css: --spring token (@supports linear() + cubic-bezier fallback); calm
  overrides ::view-transition-*(root) to a ~130ms fade and forces .enter/.reveal
  to their visible end-state; expressive applies --spring to .enter and upgrades
  .reveal to scroll-driven (animation-timeline: view(), reduced-motion-gated). The
  global prefers-reduced-motion block still wins over every tier.
- vt.js: pageswap/pagereveal early-return the hero-morph naming under calm (root
  crossfade still runs via CSS).
- app.js: reveal observer short-circuits under calm (show immediately).
- new js/motion.js: mirrors theme.js — reads/writes localStorage['proto-motion']
  (+ ?motion= param), applies data-motion, syncs aria-pressed, wires
  [data-motion-option] buttons. Loaded after theme.js in both html files.
- control bar: compact Motion section (Calm / Std / Exp) — stays one row at 1440.
- docs: discovery.md gains a "Motion tier" DESIGN.md decision; build.md documents
  the three tiers, the copy-list, file-tree, and script order.

Zero-dep (CSS + vanilla JS + Tailwind CDN). Everything degrades cleanly — the
tier only scales motion that already degrades on its own. SKILL.md untouched.

Verified: node --check on vt/motion/theme/app; headless Chrome zero JS console
errors under each tier; data-motion flips; calm reveals render instantly while
press feedback stays; control bar one row at 1440; check-overflow.sh 390 PASS;
prefers-reduced-motion neutralizes expressive (duration clamped, forced visible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…g overshoot, editorial reconcile, calm entrance-only transition

Fixes from adversarial review of PR #49 (motion tiers):
- expressive .reveal no longer hardcodes opacity:0 — when the ViewTimeline is
  inactive (no scrollable overflow, or an overflow:hidden ancestor), content no
  longer gets stuck permanently invisible; the cascade falls back to the base
  .reveal/.reveal.visible pair with app.js's IntersectionObserver as a working
  safety net instead of a "harmless" no-op.
- expressive .enter re-declares transition-delay after the transition shorthand,
  which was resetting it to 0 and collapsing the staggered entrance.
- --spring's cubic-bezier fallback (used when linear() isn't supported) now
  actually overshoots (~2%, was ~0.02%) — cubic-bezier(.3, 1.25, .5, 1).
- discovery.md: dropped "editorial" from the expressive tier's tone list so it
  no longer contradicts the pre-existing "corporate/editorial calm" pacing line.
- build.md: reworded the expressive tier-table cell so it doesn't imply --spring
  applies to .reveal (it's .enter only; .reveal stays standard unless scroll-driven).
- calm's .enter no longer uses a blanket transition:none (which also silenced
  later hover/press transitions on the same element) — now only transition-delay
  is zeroed and @starting-style is pinned to the end-state, so mount is still
  instant but the transition stays armed for whatever comes after. calm's .reveal
  keeps its transition:none as-is (load-bearing against a pre-JS fade).
- anti-FOUC head script (index.html + 404.html) now validates the stored
  data-motion value against calm|standard|expressive before applying it.
- SKILL.md: added "motion" to both control-bar enumerations, trimmed a
  redundant sentence elsewhere to stay within the 2600-token budget (2598).

Verified in headless Chrome against the real scaffold: expressive .reveal on a
non-scrolling page now resolves to opacity 1 (was stuck at 0); expressive
.enter with --i:3 has transition-delay: 0.12s (was 0s); standard tier unchanged;
calm .enter mounts instantly while keeping its transition armed for a later
hover; the reduced-motion !important block is untouched and still neutralizes
every tier. check-overflow.sh passes at 390px; control bar stays one row at 1440px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@kurenn

kurenn commented Jul 29, 2026

Copy link
Copy Markdown
Owner Author

Addressed the adversarial review findings in b11d4c3:

1 (MAJOR) — expressive .reveal stuck invisible. Removed the opacity: 0 declaration from html[data-motion="expressive"] .reveal. When the ViewTimeline is active, the reveal-scroll animation's both fill already owns the hidden pre-entry state. When it's inactive (no scrollable overflow, or an overflow:hidden ancestor), the cascade now correctly falls through to base .reveal{opacity:0} + .reveal.visible{opacity:1}, with app.js's IntersectionObserver as a working safety net (comment updated in both styles.css and app.js — it's no longer "harmless," it's load-bearing).

2 (MAJOR) — expressive .enter stagger killed. Re-declared transition-delay: calc(min(var(--i, 0), 8) * 40ms); after the transition: shorthand in html[data-motion="expressive"] .enter, which was resetting the delay to 0 and collapsing the stagger.

3 (MINOR) — --spring fallback overshoot. Replaced cubic-bezier(.22, 1.02, .36, 1) (~0.02% overshoot) with cubic-bezier(.3, 1.25, .5, 1) (~2.04% overshoot, verified by solving the Bezier max). Comment corrected.

4 (MINOR) — editorial contradiction. Dropped "editorial" from the expressive tier's tone list in discovery.md (kept playful/bold → expressive), so it no longer conflicts with the "corporate/editorial calm" pacing line.

5 (MINOR) — SKILL.md missing "motion". Added "motion" to both control-bar enumerations (~line 83, ~110). Trimmed one redundant sentence in Step 0 to stay in budget. Final token count: 2598 (≤2600, per benchmark/context-cost.sh).

6 (MINOR) — build.md over-claims. Reworded the expressive tier-table cell: `.enter`: `--spring` + richer distance/duration · `.reveal`: as standard unless scroll-driven — no longer implies --spring applies to .reveal.

7 (POLISH) — calm's blanket transition: none. Replaced calm's .enter { transition: none; ... } with transition-delay: 0s + a calm-scoped @starting-style pinned to the end-state (opacity:1, transform:none). Mount is still instant (start === end, nothing to interpolate), but the transition stays armed for a later hover/press on the same element. .reveal's calm transition: none is untouched (load-bearing against a pre-JS fade).

8 (NIT) — inline data-motion validation. Both index.html and 404.html's anti-FOUC script now validate the stored value against calm|standard|expressive before applying it, defaulting to standard otherwise.

Verification (headless Chrome against the real scaffold, not just reasoning):

  • Expressive .reveal on a non-scrolling page: opacity: 1, .visible class present (was stuck at opacity: 0).
  • Expressive .enter with --i:3: transition-delay: 0.12s (was 0s).
  • Standard tier: unchanged (--ease-out-quart, no spring, 0.12s delay as before).
  • Calm .enter (with a simulated hover effect on the same element): mounts at opacity:1/no transform offset instantly, transition-delay: 0s, but transition-property is opacity, transform (not none) — hover/press on that element still eases.
  • Reduced-motion !important block: zero diff, unscoped by data-motion, so it still wins over every tier including expressive.
  • check-overflow.sh templates/scaffold-base 390 → PASS.
  • Control bar at 1440px viewport: ~38px height, overflow-x: auto — single row, unchanged.
  • node --check clean on app.js.

Not merging — leaving this for review.

🤖 Generated with Claude Code

@kurenn
kurenn merged commit c853c6c into main Jul 29, 2026
1 check passed
@kurenn
kurenn deleted the feat/motion-tiers branch July 29, 2026 17:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant