Vendored-skill content refreshed by update-all.sh step 8 during the soak update runs: demo-shell + output-format reworked upstream, new report-template.html reference. Content-only, no wiring change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpphkdTosUzokBDNG7PToS
1007 lines
62 KiB
HTML
1007 lines
62 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Tally — Motion Audit · design-motion-principles</title>
|
||
|
||
<!--
|
||
════════════════════════════════════════════════════════════════════
|
||
design-motion-principles MOTION AUDIT — reference template
|
||
worked example: "Tally" (habit & streak tracker · React + Framer Motion)
|
||
════════════════════════════════════════════════════════════════════
|
||
Canonical HTML report for the motion audit. Self-contained: one file,
|
||
no local asset dependencies. Fonts load from a CDN with a robust
|
||
fallback stack and degrade gracefully offline.
|
||
|
||
DISTINCT SIBLING of thumb-first's report-template.html — same system
|
||
architecture (OKLCH tokens, mono micro-labels, severity channel,
|
||
finding cards), but its OWN identity: COOL slate-graphite (hue 255)
|
||
instead of warm graphite, a magenta-rose accent, and the Familjen /
|
||
Onest / Geist Mono type family. The personality is time + movement.
|
||
|
||
COLOR CHANNELS — keep them separate:
|
||
• Fixed identity .... cool slate-graphite + chalk. Carries NO meaning.
|
||
• --accent .......... ONE color SAMPLED FROM THE AUDITED PRODUCT.
|
||
Brand chrome only (eyebrow tick, section
|
||
numerals, links, recommended-motion accents).
|
||
NEVER used for severity or the timing ramp.
|
||
• Severity .......... fixed: --crit / --imp / --opp. Never adaptive.
|
||
• Timing ramp ....... fixed: instant/responsive (green) · deliberate
|
||
(amber) · sluggish (red). Diagram + legend only.
|
||
• Verdict chips ..... fixed: strong / concern / problem / mixed.
|
||
|
||
THE DEMO CARDS are the centerpiece — the only animated elements in the
|
||
document. Each Critical or Important finding gets a live, looping CSS
|
||
demo of the RECOMMENDED motion, with a pure-CSS Light/Dark stage toggle
|
||
so the reader can check the motion on either surface. Opportunities
|
||
never get a demo card. The report itself has NO entrance, scroll, or
|
||
mount animation — anything else would reproduce the slop this skill
|
||
audits against.
|
||
|
||
MODULAR SECTIONS — render a section ONLY if the audit produced it.
|
||
-->
|
||
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@400;500;600;700&family=Public+Sans:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||
|
||
<style>
|
||
:root {
|
||
/* ——— Fixed identity: COOL slate-graphite + chalk (near-achromatic) ——— */
|
||
--ink: oklch(0.155 0.006 255);
|
||
--surface: oklch(0.195 0.007 255);
|
||
--surface-2: oklch(0.235 0.008 255);
|
||
--paper: oklch(0.945 0.004 255);
|
||
--paper-dim: oklch(0.800 0.006 255);
|
||
--muted: oklch(0.680 0.008 255);
|
||
--faint: oklch(0.505 0.008 255);
|
||
--line: oklch(0.95 0.01 255 / 0.09);
|
||
--line-strong:oklch(0.95 0.01 255 / 0.16);
|
||
|
||
/* ——— Accent (NEUTRAL BY DEFAULT) ———
|
||
The report has no primary color. --accent aliases the contrast
|
||
neutral (--paper) so any element that historically wanted a brand
|
||
splash now reads as quiet chrome. An individual audit can repoint
|
||
these three to a sampled brand color if appropriate, but the
|
||
default — and the worked example — keeps the chrome neutral and
|
||
lets severity be the only color in the report. */
|
||
--accent: var(--paper);
|
||
--accent-soft: var(--paper-dim);
|
||
--accent-tint: color-mix(in oklch, var(--paper) 12%, transparent);
|
||
|
||
/* ——— Severity (FIXED — never adaptive) ——— */
|
||
--crit: oklch(0.655 0.185 25);
|
||
--crit-tint: oklch(0.655 0.185 25 / 0.15);
|
||
--imp: oklch(0.805 0.125 78);
|
||
--imp-tint: oklch(0.805 0.125 78 / 0.15);
|
||
--opp: oklch(0.745 0.135 152);
|
||
--opp-tint: oklch(0.745 0.135 152 / 0.15);
|
||
|
||
/* ——— Timing-budget ramp (FIXED — diagram + its legend only) ——— */
|
||
--t-good: oklch(0.745 0.135 152); /* instant + responsive */
|
||
--t-mid: oklch(0.805 0.125 78); /* deliberate */
|
||
--t-slow: oklch(0.655 0.185 25); /* sluggish */
|
||
|
||
/* ——— Verdict (FIXED — lens table) ——— */
|
||
--strong: var(--opp);
|
||
--concern: var(--imp);
|
||
--problem: var(--crit);
|
||
|
||
/* ——— Spacing (4pt scale, semantic) ——— */
|
||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px;
|
||
--s6:32px; --s7:48px; --s8:64px; --s9:96px;
|
||
|
||
--display: "Familjen Grotesk", "Hanken Grotesk", system-ui, sans-serif;
|
||
--sans: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||
--mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||
}
|
||
|
||
/* ═══════════════ LIGHT-MODE OVERRIDES ═══════════════
|
||
Activated when the global theme toggle is set to Light. Every token
|
||
above gets a counterpart here — same hue, mirrored L — so the report
|
||
reads cleanly on light backgrounds. Hue stays 255 (cool slate) so the
|
||
identity is the same character, just inverted. */
|
||
:root:has(#theme-light:checked) {
|
||
--ink: oklch(0.985 0.003 255); /* light page bg */
|
||
--surface: oklch(0.965 0.004 255);
|
||
--surface-2: oklch(0.940 0.005 255);
|
||
--paper: oklch(0.180 0.006 255); /* dark text */
|
||
--paper-dim: oklch(0.330 0.008 255);
|
||
--muted: oklch(0.475 0.010 255);
|
||
--faint: oklch(0.640 0.009 255);
|
||
--line: oklch(0.180 0.010 255 / 0.12);
|
||
--line-strong:oklch(0.180 0.010 255 / 0.22);
|
||
|
||
/* Accent stays aliased to --paper (now the dark ink in light mode),
|
||
so chrome inverts automatically. No additional override needed. */
|
||
|
||
/* Severity: same hues, deeper L for legibility on light bg */
|
||
--crit: oklch(0.555 0.195 25);
|
||
--crit-tint: oklch(0.555 0.195 25 / 0.12);
|
||
--imp: oklch(0.580 0.155 78);
|
||
--imp-tint: oklch(0.580 0.155 78 / 0.14);
|
||
--opp: oklch(0.555 0.140 152);
|
||
--opp-tint: oklch(0.555 0.140 152 / 0.13);
|
||
|
||
/* Timing ramp follows severity */
|
||
--t-good: oklch(0.555 0.140 152);
|
||
--t-mid: oklch(0.580 0.155 78);
|
||
--t-slow: oklch(0.555 0.195 25);
|
||
}
|
||
|
||
/* SVG dot label: light text in light-mode (deeper dots), dark text in dark-mode (lighter dots) */
|
||
:root:has(#theme-light:checked) .tf-dot-text { fill: oklch(0.985 0.003 255); }
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; }
|
||
body {
|
||
background: var(--ink);
|
||
color: var(--paper);
|
||
font-family: var(--sans);
|
||
font-size: 16px;
|
||
line-height: 1.65;
|
||
-webkit-font-smoothing: antialiased;
|
||
text-rendering: optimizeLegibility;
|
||
}
|
||
::selection { background: var(--accent-tint); }
|
||
a { color: var(--accent-soft); text-underline-offset: 3px; text-decoration-thickness: 1px; }
|
||
|
||
.wrap { max-width: 1080px; margin: 0 auto; padding: clamp(40px, 6vw, 72px) clamp(20px, 5vw, 40px) 96px; }
|
||
|
||
/* shared micro-label */
|
||
.mono-label {
|
||
font-family: var(--mono);
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.18em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
font-weight: 500;
|
||
}
|
||
code { font-family: var(--mono); font-size: 0.86em; color: var(--paper-dim); }
|
||
|
||
/* visually-hidden (theme + stage radios) */
|
||
.vh { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
||
|
||
/* ───────────── Global theme switch ───────────── */
|
||
.theme-switch { display: flex; justify-content: flex-end; margin-bottom: var(--s5); }
|
||
.theme-switch__pill { display: inline-flex; align-items: center; gap: 0; border: 1px solid var(--line-strong); border-radius: 100px; overflow: hidden; }
|
||
.theme-switch__pill label { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); padding: 6px 14px; cursor: pointer; user-select: none; transition: background 0.15s ease, color 0.15s ease; }
|
||
#theme-dark:checked ~ .theme-switch__pill label[for="theme-dark"],
|
||
#theme-light:checked ~ .theme-switch__pill label[for="theme-light"] { background: var(--accent-tint); color: var(--accent-soft); }
|
||
|
||
/* ───────────── Header ───────────── */
|
||
header.doc { padding-bottom: var(--s7); margin-bottom: var(--s8); border-bottom: 1px solid var(--line); }
|
||
.eyebrow {
|
||
font-family: var(--mono);
|
||
font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
|
||
color: var(--muted); display: inline-flex; align-items: center; gap: var(--s3);
|
||
}
|
||
.eyebrow::before { content: ""; width: 22px; height: 2px; background: var(--accent); display: inline-block; }
|
||
h1.title {
|
||
font-family: var(--display);
|
||
font-size: clamp(38px, 6vw, 60px);
|
||
font-weight: 700; letter-spacing: -0.025em; line-height: 1.02;
|
||
margin: var(--s5) 0 var(--s4); color: var(--paper); max-width: 17ch;
|
||
}
|
||
.lede { font-size: clamp(17px, 2.2vw, 19px); color: var(--paper-dim); max-width: 66ch; line-height: 1.6; }
|
||
.meta-row { display: flex; flex-wrap: wrap; gap: var(--s6); margin-top: var(--s6); }
|
||
.meta-row .k { display: block; margin-bottom: 4px; }
|
||
.meta-row .v { font-size: 15px; color: var(--paper); }
|
||
|
||
.stats { display: flex; gap: clamp(20px, 4vw, 44px); margin-top: var(--s7); flex-wrap: wrap; }
|
||
.stat .num { font-family: var(--display); font-size: 40px; font-weight: 600; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }
|
||
.stat.crit .num { color: var(--crit); }
|
||
.stat.imp .num { color: var(--imp); }
|
||
.stat.opp .num { color: var(--opp); }
|
||
.stat .lbl { margin-top: var(--s2); }
|
||
.stat a { color: inherit; text-decoration: none; }
|
||
.stat a:hover .num { text-decoration: underline; text-underline-offset: 5px; text-decoration-thickness: 2px; }
|
||
|
||
/* ───────────── Section scaffolding ───────────── */
|
||
section { margin-bottom: var(--s9); }
|
||
.sec-index { font-family: var(--mono); font-size: 12px; letter-spacing: 0.16em; color: var(--accent-soft); }
|
||
h2.section-title {
|
||
font-family: var(--display);
|
||
font-size: clamp(24px, 3.4vw, 32px);
|
||
font-weight: 600; letter-spacing: -0.02em; line-height: 1.1;
|
||
margin: var(--s2) 0 var(--s4); color: var(--paper);
|
||
}
|
||
.section-sub { color: var(--paper-dim); max-width: 74ch; margin: 0 0 var(--s6); }
|
||
.section-sub em { color: var(--paper); font-style: italic; }
|
||
|
||
/* ───────────── Overall assessment ───────────── */
|
||
.assessment { border-top: 1px solid var(--line); padding-top: var(--s6); }
|
||
.assessment .mono-label { color: var(--accent-soft); }
|
||
.assessment p { font-family: var(--display); font-weight: 400; font-size: clamp(20px, 2.8vw, 26px); line-height: 1.42; letter-spacing: -0.01em; color: var(--paper); max-width: 34ch; margin: var(--s4) 0 0; }
|
||
.assessment p em { font-style: italic; color: var(--accent-soft); }
|
||
|
||
/* ───────────── Lens summary table ───────────── */
|
||
.lens-table { width: 100%; border-collapse: collapse; border: 1px solid var(--line); }
|
||
.lens-table th, .lens-table td { padding: var(--s4) var(--s5); text-align: left; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||
.lens-table tr:last-child td { border-bottom: none; }
|
||
.lens-table thead th { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); font-weight: 500; }
|
||
.lens-table tbody tr:hover { background: oklch(0.95 0.01 255 / 0.018); }
|
||
.lens-name { font-weight: 600; color: var(--paper); width: 28%; }
|
||
.lens-name .who { display: block; margin-top: 2px; color: var(--muted); font-weight: 400; font-size: 13.5px; }
|
||
.lens-name .weight { display: inline-block; margin-top: 6px; font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-soft); }
|
||
.lens-read { color: var(--paper-dim); }
|
||
.lens-read em { color: var(--paper); font-style: italic; }
|
||
.verdict { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 500; }
|
||
.verdict::before { content: ""; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||
.v-strong { color: var(--strong); } .v-strong::before { background: var(--strong); }
|
||
.v-concern { color: var(--concern); } .v-concern::before { background: var(--concern); }
|
||
.v-problem { color: var(--problem); } .v-problem::before { background: var(--problem); }
|
||
.v-mixed { color: var(--muted); } .v-mixed::before { background: var(--muted); }
|
||
|
||
/* ───────────── Timing-budget diagram ───────────── */
|
||
.timing-grid { display: grid; grid-template-columns: 1fr 320px; gap: clamp(28px, 5vw, 56px); align-items: start; }
|
||
@media (max-width: 860px) { .timing-grid { grid-template-columns: 1fr; } }
|
||
.timing-figure { width: 100%; height: auto; }
|
||
.timing-key h4 { margin: 0 0 var(--s4); }
|
||
.key-list { list-style: none; margin: 0 0 var(--s6); padding: 0; display: grid; gap: 2px; }
|
||
.key-list li { display: grid; grid-template-columns: 22px 1fr auto; align-items: baseline; gap: var(--s3); padding: var(--s2) 0; border-bottom: 1px solid var(--line); }
|
||
.key-list li:last-child { border-bottom: none; }
|
||
.key-list .kn { font-family: var(--mono); font-size: 12px; color: var(--paper); font-variant-numeric: tabular-nums; }
|
||
.key-list .ka { color: var(--paper-dim); }
|
||
.key-list .kt { font-family: var(--mono); font-size: 11px; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
|
||
.kt.good { color: var(--t-good); }
|
||
.kt.mid { color: var(--t-mid); }
|
||
.kt.slow { color: var(--t-slow); }
|
||
.kt.none { color: var(--faint); }
|
||
.zone-defs { display: grid; gap: var(--s3); margin-top: var(--s5); }
|
||
.zone-def { display: grid; grid-template-columns: 12px 1fr; gap: var(--s3); align-items: start; }
|
||
.zone-def .dot { width: 11px; height: 11px; border-radius: 50%; margin-top: 6px; }
|
||
.zone-def .zt { font-weight: 600; color: var(--paper); }
|
||
.zone-def .zd { font-size: 13.5px; color: var(--muted); }
|
||
.whats-off { margin-top: var(--s5); padding-top: var(--s5); border-top: 1px solid var(--line); }
|
||
.whats-off .mono-label { color: var(--accent-soft); }
|
||
.whats-off p { margin: var(--s2) 0 0; color: var(--paper-dim); font-size: 14px; max-width: 60ch; }
|
||
|
||
/* ───────────── Per-lens sections ───────────── */
|
||
.lens-sec { margin-bottom: var(--s9); padding-top: var(--s6); border-top: 1px solid var(--line); }
|
||
.lens-sec__head { display: flex; align-items: baseline; gap: var(--s4); flex-wrap: wrap; margin-bottom: var(--s2); }
|
||
.lens-sec__head h3 { font-family: var(--display); font-size: clamp(22px, 3vw, 28px); font-weight: 600; letter-spacing: -0.02em; line-height: 1.1; margin: 0; color: var(--paper); }
|
||
.lens-sec__weight { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-soft); border: 1px solid var(--line-strong); border-radius: 100px; padding: 4px 10px; }
|
||
.lens-sec__verdict { margin: 0 0 var(--s6); }
|
||
|
||
.lens-block { margin: var(--s6) 0; }
|
||
.lens-block > .mono-label { display: block; margin-bottom: var(--s4); }
|
||
.lens-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s3); max-width: 78ch; }
|
||
.lens-list li { display: grid; grid-template-columns: 22px 1fr; gap: var(--s2); align-items: start; color: var(--paper-dim); }
|
||
.lens-list .mk { font-family: var(--mono); font-size: 13px; line-height: 1.5; }
|
||
.lens-list.good .mk { color: var(--strong); }
|
||
.lens-list.opp .mk { color: var(--accent-soft); }
|
||
.lens-list li em { color: var(--paper); font-style: italic; }
|
||
|
||
.lens-take { margin-top: var(--s6); background: var(--surface-2); border-radius: 10px; padding: var(--s5); }
|
||
.lens-take .mono-label { color: var(--accent-soft); display: block; margin-bottom: var(--s2); }
|
||
.lens-take p { margin: 0; color: var(--paper); max-width: 70ch; }
|
||
|
||
.lens-empty { font-style: italic; color: var(--faint); padding: var(--s3) 0; }
|
||
|
||
/* ───────────── Finding rows (prose + live demo card) ───────────── */
|
||
.findings { display: flex; flex-direction: column; gap: var(--s5); }
|
||
.finding-row { display: grid; grid-template-columns: 1fr 380px; gap: clamp(24px, 3vw, 36px); align-items: start; background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: clamp(22px, 3vw, 30px); }
|
||
.finding-row[data-sev="crit"] { border-color: var(--crit-tint); }
|
||
@media (max-width: 860px) { .finding-row { grid-template-columns: 1fr; } }
|
||
|
||
.find-tags { display: flex; flex-wrap: wrap; gap: var(--s2); align-items: center; margin-bottom: var(--s3); }
|
||
.severity { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; font-weight: 500; padding: 4px 9px; border-radius: 100px; }
|
||
.severity.crit { background: var(--crit-tint); color: var(--crit); }
|
||
.severity.imp { background: var(--imp-tint); color: var(--imp); }
|
||
.severity.opp { background: var(--opp-tint); color: var(--opp); }
|
||
.lens-chip { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); padding: 4px 9px; border: 1px solid var(--line-strong); border-radius: 100px; }
|
||
.find-title { font-family: var(--display); font-size: clamp(19px, 2.4vw, 23px); font-weight: 600; letter-spacing: -0.015em; line-height: 1.22; margin: 0 0 var(--s4); color: var(--paper); }
|
||
.find-body { display: grid; gap: var(--s4); }
|
||
.find-body p { margin: 0; color: var(--paper-dim); }
|
||
.find-body p em { color: var(--paper); font-style: italic; }
|
||
.find-body .label { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted); display: block; margin-bottom: 3px; }
|
||
.fix { background: var(--surface-2); border-radius: 8px; padding: var(--s4) var(--s5); }
|
||
.fix .label { color: var(--accent-soft); }
|
||
.fix p { color: var(--paper); }
|
||
.find-loc { font-size: 13px; }
|
||
|
||
/* ───────────── Demo card (the centerpiece) ───────────── */
|
||
.demo { position: relative; align-self: start; position: sticky; top: var(--s5); }
|
||
@media (max-width: 860px) { .demo { position: static; } }
|
||
.demo__bar { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3); margin-bottom: var(--s3); }
|
||
.demo__meta { min-width: 0; }
|
||
.demo__title { display: block; font-size: 13.5px; font-weight: 600; color: var(--paper); line-height: 1.3; }
|
||
.demo__timing { display: block; font-family: var(--mono); font-size: 11px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||
.demo__controls { display: flex; align-items: center; gap: var(--s3); flex-shrink: 0; }
|
||
.demo__loop { font-family: var(--mono); font-size: 12px; color: var(--faint); }
|
||
|
||
/* segmented Auto/Light/Dark stage toggle (pure CSS).
|
||
Auto = follow global theme via theme tokens. Light/Dark = locked overrides. */
|
||
.seg { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 100px; overflow: hidden; }
|
||
.seg label { font-family: var(--mono); font-size: 9.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); padding: 4px 9px; cursor: pointer; user-select: none; transition: background 0.15s ease, color 0.15s ease; }
|
||
.demo input[id$="-a"]:checked ~ .demo__bar .seg label[for$="-a"],
|
||
.demo input[id$="-l"]:checked ~ .demo__bar .seg label[for$="-l"],
|
||
.demo input[id$="-d"]:checked ~ .demo__bar .seg label[for$="-d"] { background: var(--accent-tint); color: var(--accent-soft); }
|
||
|
||
.demo__stage {
|
||
/* AUTO default — stage uses the report's elevated surface, follows theme */
|
||
--st-bg: var(--surface-2);
|
||
--st-fg: var(--paper);
|
||
--st-line: var(--line-strong);
|
||
--st-dim: color-mix(in oklch, var(--paper) 55%, transparent);
|
||
background: var(--st-bg); color: var(--st-fg);
|
||
border: 1px solid var(--line); border-radius: 10px;
|
||
padding: var(--s6) var(--s5); min-height: 168px;
|
||
display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||
}
|
||
/* Locked Light: hardcoded light, regardless of report theme */
|
||
.demo input[id$="-l"]:checked ~ .demo__stage {
|
||
--st-bg: oklch(0.985 0.003 255);
|
||
--st-fg: oklch(0.180 0.006 255);
|
||
--st-line: oklch(0.180 0.010 255 / 0.18);
|
||
--st-dim: oklch(0.180 0.010 255 / 0.45);
|
||
}
|
||
/* Locked Dark: hardcoded dark, regardless of report theme */
|
||
.demo input[id$="-d"]:checked ~ .demo__stage {
|
||
--st-bg: oklch(0.180 0.006 255);
|
||
--st-fg: oklch(0.985 0.003 255);
|
||
--st-line: oklch(0.985 0.010 255 / 0.18);
|
||
--st-dim: oklch(0.985 0.010 255 / 0.50);
|
||
}
|
||
|
||
/* generic demo primitives, themed by the stage vars
|
||
(intentionally follow --st-fg/--st-bg, NOT the page accent — so they
|
||
contrast correctly even when a stage is locked to a different theme
|
||
than the page). */
|
||
.ui-btn { display: inline-flex; align-items: center; justify-content: center; padding: 9px 18px; border-radius: 8px; background: var(--st-fg); color: var(--st-bg); font-family: var(--sans); font-size: 13px; font-weight: 600; }
|
||
.ui-card { background: var(--st-bg); border: 1px solid var(--st-line); border-radius: 10px; padding: 14px 16px; min-width: 180px; box-shadow: 0 8px 24px oklch(0 0 0 / 0.12); }
|
||
.ui-row { display: flex; align-items: center; gap: 10px; font-size: 13px; color: var(--st-fg); }
|
||
.ui-check { width: 22px; height: 22px; border-radius: 7px; background: var(--st-fg); display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||
.ui-check svg { width: 13px; height: 13px; stroke: var(--st-bg); }
|
||
.ui-num { font-family: var(--display); font-size: 34px; font-weight: 700; color: var(--st-fg); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
|
||
.ui-label { font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--st-dim); }
|
||
/* milestone badge + sparks read off stage tokens (--st-fg disc, --st-bg ink),
|
||
set inline on the elements — same contract as every other stage primitive,
|
||
never --accent, correct in any locked stage. */
|
||
|
||
/* ───────────── Combined recommendations ───────────── */
|
||
.rec { margin-bottom: var(--s7); }
|
||
.rec .tier-label { display: flex; align-items: center; gap: var(--s4); margin: 0 0 var(--s4); }
|
||
.rec .tier-label .mono-label { white-space: nowrap; }
|
||
.rec .tier-label .rule { height: 1px; background: var(--line); flex: 1; }
|
||
.rec .tier-label .count { font-family: var(--mono); font-size: 11px; color: var(--faint); }
|
||
.rec.crit .mono-label { color: var(--crit); }
|
||
.rec.imp .mono-label { color: var(--imp); }
|
||
.rec.opp .mono-label { color: var(--opp); }
|
||
.rec-table { width: 100%; border-collapse: collapse; border: 1px solid var(--line); font-size: 14px; }
|
||
.rec-table th, .rec-table td { text-align: left; padding: var(--s3) var(--s4); border-bottom: 1px solid var(--line); vertical-align: top; }
|
||
.rec-table tr:last-child td { border-bottom: none; }
|
||
.rec-table thead th { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); font-weight: 500; }
|
||
.rec-table td:first-child { color: var(--paper); }
|
||
.rec-table td:nth-child(2) { white-space: nowrap; }
|
||
.rec-table td { color: var(--paper-dim); }
|
||
|
||
/* ───────────── Reference summary (closing) ───────────── */
|
||
.ref-summary { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: clamp(24px, 4vw, 40px); }
|
||
.ref-summary .big { font-family: var(--display); font-size: clamp(18px, 2.4vw, 22px); font-weight: 600; letter-spacing: -0.015em; color: var(--paper); margin: var(--s2) 0 0; }
|
||
.ref-summary .why { color: var(--paper-dim); margin: var(--s3) 0 var(--s6); max-width: 64ch; }
|
||
.ref-lean { display: grid; gap: var(--s3); margin: 0; padding: 0; list-style: none; }
|
||
.ref-lean li { display: grid; grid-template-columns: 130px 1fr; gap: var(--s4); padding: var(--s3) 0; border-top: 1px solid var(--line); color: var(--paper-dim); font-size: 14px; }
|
||
.ref-lean .nm { font-weight: 600; color: var(--paper); }
|
||
|
||
footer.doc { margin-top: var(--s8); padding-top: var(--s5); border-top: 1px solid var(--line); font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; color: var(--faint); display: flex; justify-content: space-between; flex-wrap: wrap; gap: var(--s3); }
|
||
|
||
/* ───────────── Narrow screens ───────────── */
|
||
@media (max-width: 600px) {
|
||
.lens-table { display: block; padding: var(--s2) var(--s4); }
|
||
.lens-table thead { display: none; }
|
||
.lens-table tbody, .lens-table tr, .lens-table td { display: block; }
|
||
.lens-table tr { padding: var(--s4) 0; border-bottom: 1px solid var(--line); }
|
||
.lens-table tr:last-child { border-bottom: none; }
|
||
.lens-table td { padding: 0; border: none; }
|
||
.lens-table .lens-name { width: auto; margin-bottom: var(--s2); }
|
||
.lens-table .verdict { margin-bottom: var(--s2); }
|
||
.ref-lean li { grid-template-columns: 1fr; gap: var(--s1); }
|
||
}
|
||
|
||
/* ───────────── The ONLY animation in the report: the demo cards ───────────── */
|
||
@media (prefers-reduced-motion: no-preference) {
|
||
.lens-table tbody tr, .finding-row { transition: border-color 0.2s ease; }
|
||
|
||
/* 01 · quick tab crossfade — 180ms ease-out */
|
||
@keyframes m1 {
|
||
0% { opacity: 0; transform: translateX(7px); }
|
||
55% { opacity: 1; transform: translateX(0); }
|
||
100% { opacity: 1; transform: translateX(0); }
|
||
}
|
||
.demo-1__mt { animation: m1 3s cubic-bezier(0.22,1,0.36,1) infinite; }
|
||
|
||
/* 02 · sheet enter (mirror for exit) — 300ms ease-out-quint */
|
||
@keyframes m2 {
|
||
0% { opacity: 0; transform: translateY(115%); }
|
||
45% { opacity: 1; transform: translateY(0); }
|
||
100% { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.demo-2__mt { animation: m2 3s cubic-bezier(0.22,1,0.36,1) infinite; }
|
||
|
||
/* 03 · calm check — 200ms ease-out, no bounce */
|
||
@keyframes m3 {
|
||
0% { opacity: 0; transform: scale(0.9); }
|
||
55% { opacity: 1; transform: scale(1); }
|
||
100% { opacity: 1; transform: scale(1); }
|
||
}
|
||
.demo-3__mt .ui-check { animation: m3 3s cubic-bezier(0.33,1,0.68,1) infinite; transform-origin: center; }
|
||
|
||
/* 04 · number roll — 220ms, opacity + translateY + blur */
|
||
@keyframes m4 {
|
||
0% { opacity: 0; transform: translateY(10px); filter: blur(5px); }
|
||
55% { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||
100% { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||
}
|
||
.demo-4__mt .ui-num { animation: m4 3s cubic-bezier(0.22,1,0.36,1) infinite; display: inline-block; }
|
||
|
||
/* 05 · milestone badge enter — 260ms ease-out + one-shot sparkle */
|
||
@keyframes m5 {
|
||
0% { opacity: 0; transform: scale(0.8); }
|
||
50% { opacity: 1; transform: scale(1); }
|
||
100% { opacity: 1; transform: scale(1); }
|
||
}
|
||
@keyframes m5spark {
|
||
0% { opacity: 0; transform: translate(0,0) scale(0.4); }
|
||
35% { opacity: 1; }
|
||
70% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(1); }
|
||
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(1); }
|
||
}
|
||
.demo-5__mt .badge { animation: m5 3s cubic-bezier(0.34,1.0,0.5,1) infinite; transform-origin: center; }
|
||
.demo-5__mt .spark { animation: m5spark 3s ease-out infinite; }
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.demo__loop { display: none; }
|
||
[class*="__mt"], [class*="__mt"] * { animation: none !important; transition: none !important; }
|
||
.demo-5__mt .spark { opacity: 0; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<!-- ═══════════ GLOBAL THEME TOGGLE ═══════════ -->
|
||
<div class="theme-switch" role="radiogroup" aria-label="Report theme">
|
||
<input class="vh" type="radio" name="theme" id="theme-dark" checked>
|
||
<input class="vh" type="radio" name="theme" id="theme-light">
|
||
<div class="theme-switch__pill">
|
||
<label for="theme-dark">Dark</label>
|
||
<label for="theme-light">Light</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════ HEADER ═══════════ -->
|
||
<header class="doc">
|
||
<div class="eyebrow">Motion audit · design-motion-principles</div>
|
||
<h1 class="title">Tally — what to slow down, speed up, and finish</h1>
|
||
<p class="lede">
|
||
A pass over Tally's core loop — check-off, tab switches, the add-habit sheet, and the streak milestones —
|
||
read through three motion practitioners' lenses, ordered by what users feel most and the order to fix it.
|
||
</p>
|
||
<div class="meta-row">
|
||
<div><span class="mono-label k">What it is</span><span class="v">Habit & streak tracker, used in quick daily bursts</span></div>
|
||
<div><span class="mono-label k">Stack</span><span class="v">Mobile web · React + Framer Motion</span></div>
|
||
</div>
|
||
<div class="stats">
|
||
<div class="stat"><div class="num">7</div><div class="mono-label lbl">Findings</div></div>
|
||
<div class="stat crit"><a href="#rec-crit"><div class="num">2</div><div class="mono-label lbl">Critical</div></a></div>
|
||
<div class="stat imp"><a href="#rec-imp"><div class="num">3</div><div class="mono-label lbl">Important</div></a></div>
|
||
<div class="stat opp"><a href="#rec-opp"><div class="num">2</div><div class="mono-label lbl">Opportunities</div></a></div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ═══════════ ASSESSMENT ═══════════ -->
|
||
<section class="assessment">
|
||
<span class="mono-label">Overall</span>
|
||
<p>
|
||
Tally has real motion personality — but it's <em>uneven</em>. The highest-frequency action, the check-off,
|
||
is the most over-animated; the add-habit sheet that should glide just snaps shut. Tighten the frequent
|
||
moments toward speed, finish the half-built transitions, then spend delight where it's earned: the streaks.
|
||
</p>
|
||
</section>
|
||
|
||
<!-- ═══════════ 01 · LENS SUMMARY ═══════════ -->
|
||
<section>
|
||
<div class="sec-index">01</div>
|
||
<h2 class="section-title">How each practitioner reads the motion</h2>
|
||
<p class="section-sub">
|
||
Three lenses, weighted for this context — a frequently-opened utility where most motion should get out of
|
||
the way, and a little should be memorable. The read is what each would push on hardest today.
|
||
</p>
|
||
<table class="lens-table">
|
||
<thead><tr><th>Lens</th><th>Verdict</th><th>One-line read</th></tr></thead>
|
||
<tbody>
|
||
<tr>
|
||
<td class="lens-name">Restraint & Speed <span class="who">Emil Kowalski</span><span class="weight">Secondary</span></td>
|
||
<td><span class="verdict v-concern">Concern</span></td>
|
||
<td class="lens-read">The check-off and tab switches are over-animated for actions this frequent — durations run long.</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="lens-name">Production Polish <span class="who">Jakub Krehel</span><span class="weight">Primary</span></td>
|
||
<td><span class="verdict v-problem">Problem</span></td>
|
||
<td class="lens-read">Several transitions are half-built: <em>enters without exits</em>, state changes that snap. Craft is uneven.</td>
|
||
</tr>
|
||
<tr>
|
||
<td class="lens-name">Experimentation & Delight <span class="who">Jhey Tompkins</span><span class="weight">Selective</span></td>
|
||
<td><span class="verdict v-strong">Strong</span></td>
|
||
<td class="lens-read">Restraint is mostly right. The open prize is making the streak milestones actually feel earned.</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<!-- ═══════════ 02 · TIMING-BUDGET DIAGRAM ═══════════
|
||
The motion-native analog of thumb-first's thumb-zone map.
|
||
Each dot is one of Tally's animations, plotted by duration.
|
||
Zone bands describe the COST of a duration, not a hard rule. -->
|
||
<section>
|
||
<div class="sec-index">02</div>
|
||
<h2 class="section-title">Where the timings land</h2>
|
||
<p class="section-sub">
|
||
Duration is a budget. A 320ms sheet is correct — a large surface earns the time. A 600ms tab switch on an
|
||
action you fire dozens of times a session is the mistake. Each dot is one of Tally's animations, plotted
|
||
where it runs today.
|
||
</p>
|
||
<div class="timing-grid">
|
||
<svg class="timing-figure" viewBox="0 0 660 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Duration scale from 0 to 600 milliseconds showing where Tally's animations fall across instant, responsive, deliberate and sluggish zones">
|
||
<style>
|
||
/* Theme-aware: every fill below resolves via the report's CSS vars,
|
||
so the diagram re-tones when the global Light/Dark toggle flips. */
|
||
.tf-track-stop-good { stop-color: var(--t-good); }
|
||
.tf-track-stop-mid { stop-color: var(--t-mid); }
|
||
.tf-track-stop-slow { stop-color: var(--t-slow); }
|
||
.tf-track-stroke { stroke: var(--line-strong); }
|
||
.tf-grid { stroke: var(--line-strong); }
|
||
.tf-axis { fill: var(--faint); }
|
||
.tf-freq { fill: var(--muted); }
|
||
.tf-bracket { stroke: var(--faint); fill: none; }
|
||
.tf-zone-good { fill: var(--t-good); }
|
||
.tf-zone-mid { fill: var(--t-mid); }
|
||
.tf-zone-slow { fill: var(--t-slow); }
|
||
.tf-dot-good { fill: var(--t-good); }
|
||
.tf-dot-mid { fill: var(--t-mid); }
|
||
.tf-dot-slow { fill: var(--t-slow); }
|
||
/* Dot label flips per theme — dot fills are lighter in dark mode
|
||
(dark text reads on them) and deeper in light mode (light text reads). */
|
||
.tf-dot-text { fill: oklch(0.165 0.006 255); }
|
||
.tf-missing-ring { stroke: var(--faint); }
|
||
.tf-missing-text { fill: var(--muted); }
|
||
</style>
|
||
<defs>
|
||
<linearGradient id="track" x1="0" y1="0" x2="1" y2="0">
|
||
<stop offset="0%" class="tf-track-stop-good" stop-opacity="0.30"/>
|
||
<stop offset="50%" class="tf-track-stop-good" stop-opacity="0.22"/>
|
||
<stop offset="58%" class="tf-track-stop-mid" stop-opacity="0.26"/>
|
||
<stop offset="83%" class="tf-track-stop-mid" stop-opacity="0.26"/>
|
||
<stop offset="90%" class="tf-track-stop-slow" stop-opacity="0.36"/>
|
||
<stop offset="100%" class="tf-track-stop-slow" stop-opacity="0.42"/>
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<!-- track: x 40..640 maps ms 0..600 -->
|
||
<rect x="40" y="118" width="600" height="58" rx="10" fill="url(#track)" class="tf-track-stroke"/>
|
||
|
||
<!-- zone boundary ticks at 100 / 300 / 500 ms -->
|
||
<g class="tf-grid" stroke-dasharray="2 5">
|
||
<line x1="140" y1="112" x2="140" y2="182"/>
|
||
<line x1="340" y1="112" x2="340" y2="182"/>
|
||
<line x1="540" y1="112" x2="540" y2="182"/>
|
||
</g>
|
||
|
||
<!-- axis labels -->
|
||
<g font-family="Geist Mono, monospace" font-size="11" class="tf-axis" text-anchor="middle" font-variant-numeric="tabular-nums">
|
||
<text x="40" y="204">0</text>
|
||
<text x="140" y="204">100</text>
|
||
<text x="340" y="204">300</text>
|
||
<text x="540" y="204">500</text>
|
||
<text x="636" y="204" text-anchor="end">600ms</text>
|
||
</g>
|
||
|
||
<!-- zone band names -->
|
||
<g font-family="Geist Mono, monospace" font-size="9.5" letter-spacing="1.6">
|
||
<text x="90" y="106" text-anchor="middle" class="tf-zone-good">INSTANT</text>
|
||
<text x="240" y="106" text-anchor="middle" class="tf-zone-good">RESPONSIVE</text>
|
||
<text x="440" y="106" text-anchor="middle" class="tf-zone-mid">DELIBERATE</text>
|
||
<text x="592" y="106" text-anchor="middle" class="tf-zone-slow">SLUGGISH</text>
|
||
</g>
|
||
|
||
<!-- "missing" marker for the no-transition streak counter, parked at 0 -->
|
||
<circle cx="40" cy="147" r="11" fill="none" class="tf-missing-ring" stroke-width="1.5" stroke-dasharray="3 3"/>
|
||
<text x="40" y="151" font-family="Geist Mono, monospace" font-size="11" font-weight="600" text-anchor="middle" class="tf-missing-text">5</text>
|
||
|
||
<!-- plotted animation dots (color = zone). text carried by key list. -->
|
||
<g font-family="Geist Mono, monospace" font-size="11" font-weight="600" text-anchor="middle">
|
||
<!-- 3 · card hover 80ms (instant) -->
|
||
<circle cx="120" cy="147" r="12" class="tf-dot-good"/><text x="120" y="151" class="tf-dot-text">3</text>
|
||
<!-- 7 · toast 200ms (responsive) -->
|
||
<circle cx="240" cy="147" r="12" class="tf-dot-good"/><text x="240" y="151" class="tf-dot-text">7</text>
|
||
<!-- 6 · page route 250ms (responsive) -->
|
||
<circle cx="290" cy="147" r="12" class="tf-dot-good"/><text x="290" y="151" class="tf-dot-text">6</text>
|
||
<!-- 4 · sheet enter 320ms (deliberate, correct) -->
|
||
<circle cx="360" cy="147" r="12" class="tf-dot-mid"/><text x="360" y="151" class="tf-dot-text">4</text>
|
||
<!-- 2 · check-off bounce 450ms (deliberate, too long for frequency) -->
|
||
<circle cx="490" cy="147" r="12" class="tf-dot-mid"/><text x="490" y="151" class="tf-dot-text">2</text>
|
||
<!-- 1 · tab switch 600ms (sluggish) -->
|
||
<circle cx="632" cy="147" r="12" class="tf-dot-slow"/><text x="632" y="151" class="tf-dot-text">1</text>
|
||
</g>
|
||
|
||
<!-- frequency brace: groups the two rightmost (slowest) dots — check-off (2)
|
||
and tab switch (1) — which are also the app's most-fired actions. The
|
||
upward end-ticks sit directly under those dots; one caption carries it.
|
||
Lands the core mismatch: most-fired should be fastest, but these are slowest. -->
|
||
<path class="tf-bracket" stroke-width="1" d="M490 218 L490 224 L632 224 L632 218 M561 224 L561 230"/>
|
||
<text x="561" y="244" font-family="Public Sans, sans-serif" font-size="11" text-anchor="middle" class="tf-freq">fire most often</text>
|
||
</svg>
|
||
|
||
<div class="timing-key">
|
||
<h4 class="mono-label">The animations</h4>
|
||
<ol class="key-list">
|
||
<li><span class="kn">1</span><span class="ka">Tab switch</span><span class="kt slow">600ms</span></li>
|
||
<li><span class="kn">2</span><span class="ka">Check-off (bounce)</span><span class="kt mid">450ms</span></li>
|
||
<li><span class="kn">3</span><span class="ka">Card hover lift</span><span class="kt good">80ms</span></li>
|
||
<li><span class="kn">4</span><span class="ka">Add-habit sheet enter</span><span class="kt mid">320ms</span></li>
|
||
<li><span class="kn">5</span><span class="ka">Streak counter change</span><span class="kt none">none</span></li>
|
||
<li><span class="kn">6</span><span class="ka">Page route</span><span class="kt good">250ms</span></li>
|
||
<li><span class="kn">7</span><span class="ka">Toast</span><span class="kt good">200ms</span></li>
|
||
</ol>
|
||
|
||
<div class="zone-defs">
|
||
<div class="zone-def"><span class="dot" style="background:var(--t-good)"></span><div><span class="zt">Instant / Responsive</span> · 0–300ms — <span class="zd">where almost everything belongs. Taps, toggles, tabs, hovers.</span></div></div>
|
||
<div class="zone-def"><span class="dot" style="background:var(--t-mid)"></span><div><span class="zt">Deliberate</span> · 300–500ms — <span class="zd">earned by large surfaces: sheets, modals, full-screen routes.</span></div></div>
|
||
<div class="zone-def"><span class="dot" style="background:var(--t-slow)"></span><div><span class="zt">Sluggish</span> · 500ms+ — <span class="zd">feels laggy. Reserve for rare, deliberately cinematic moments — or nothing.</span></div></div>
|
||
</div>
|
||
|
||
<div class="whats-off">
|
||
<span class="mono-label">What's off</span>
|
||
<p>The two dots furthest right — tab switch (1) and check-off (2) — are the actions that fire most. Frequency and duration are inversely related: the more often it runs, the faster it should be. And the streak counter (5) has no transition at all.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══════════ 03 · PER-LENS — JAKUB (primary) ═══════════ -->
|
||
<section class="lens-sec">
|
||
<div class="sec-index">03</div>
|
||
<div class="lens-sec__head">
|
||
<h3>Jakub Krehel — Production Polish</h3>
|
||
<span class="lens-sec__weight">Primary lens</span>
|
||
</div>
|
||
<p class="lens-sec__verdict"><span class="verdict v-problem">Problem</span></p>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">What's working well</span>
|
||
<ul class="lens-list good">
|
||
<li><span class="mk">✓</span><span>Page routes use a clean opacity + 8px translate at 250ms — the right shape and duration. <code>routes/transition.tsx:14</code></span></li>
|
||
<li><span class="mk">✓</span><span>Toasts enter and <em>exit</em> symmetrically — the exit isn't an afterthought. <code>ui/Toast.tsx:31</code></span></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">Issues to address</span>
|
||
<div class="findings">
|
||
|
||
<!-- FINDING 02 (Critical) -->
|
||
<div class="finding-row" data-sev="crit" id="finding-2">
|
||
<div class="finding-row__prose">
|
||
<div class="find-tags"><span class="severity crit">Critical</span><span class="lens-chip">Jakub</span></div>
|
||
<h4 class="find-title">The add-habit sheet enters, then snaps shut with no exit</h4>
|
||
<div class="find-body">
|
||
<p><span class="label">What</span>The bottom sheet animates up on open (320ms, good), but on dismiss it's removed from the tree instantly — no exit. The component renders conditionally with no <code>AnimatePresence</code> wrapper, so Framer Motion never gets to play the exit.</p>
|
||
<p><span class="label">Why it matters</span>A surface that glides in and <em>vanishes</em> reads as broken — the eye expects symmetry. It's the single most common "half-built motion" tell, and it's on the app's primary create flow.</p>
|
||
<div class="fix"><p><span class="label">Recommended motion</span>Wrap the sheet in <code>AnimatePresence</code> and mirror the enter: slide down + fade over 300ms with the same <code>ease-out-quint</code>. The demo shows the enter; the exit is its reverse.</p></div>
|
||
<p class="find-loc"><code>screens/Habits/AddSheet.tsx:48</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="demo">
|
||
<input class="vh" type="radio" name="st2" id="st2-a" checked>
|
||
<input class="vh" type="radio" name="st2" id="st2-l">
|
||
<input class="vh" type="radio" name="st2" id="st2-d">
|
||
<div class="demo__bar">
|
||
<div class="demo__meta"><span class="demo__title">Sheet enter (mirror for exit)</span><span class="demo__timing">300ms · ease-out-quint</span></div>
|
||
<div class="demo__controls">
|
||
<div class="seg"><label for="st2-a">Auto</label><label for="st2-l">Light</label><label for="st2-d">Dark</label></div>
|
||
<span class="demo__loop">↻</span>
|
||
</div>
|
||
</div>
|
||
<div class="demo__stage" style="align-items:flex-end;">
|
||
<div class="demo-2__mt" style="width:100%;">
|
||
<div class="ui-card" style="min-width:0;width:100%;">
|
||
<div class="ui-label" style="margin-bottom:8px;">New habit</div>
|
||
<div class="ui-row" style="justify-content:space-between;">
|
||
<span>Drink water</span>
|
||
<span class="ui-btn" style="padding:6px 14px;">Add</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FINDING 04 (Important) -->
|
||
<div class="finding-row" data-sev="imp" id="finding-4">
|
||
<div class="finding-row__prose">
|
||
<div class="find-tags"><span class="severity imp">Important</span><span class="lens-chip">Jakub</span></div>
|
||
<h4 class="find-title">Streak counter jumps between values with no transition</h4>
|
||
<div class="find-body">
|
||
<p><span class="label">What</span>When a streak increments, the number is replaced in place — a hard swap. There's no transition on the value change, so the most rewarding number in the app updates with the least ceremony.</p>
|
||
<p><span class="label">Why it matters</span>The streak count is the payoff of the whole interaction. A snap makes a hard-won number feel like a re-render, not an achievement.</p>
|
||
<div class="fix"><p><span class="label">Recommended motion</span>Roll the new value in: opacity + 10px translateY + a 5px blur that clears, 220ms. Subtle, but it tells the eye <em>something changed and it's good</em>.</p></div>
|
||
<p class="find-loc"><code>components/StreakBadge.tsx:22</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="demo">
|
||
<input class="vh" type="radio" name="st4" id="st4-a" checked>
|
||
<input class="vh" type="radio" name="st4" id="st4-l">
|
||
<input class="vh" type="radio" name="st4" id="st4-d">
|
||
<div class="demo__bar">
|
||
<div class="demo__meta"><span class="demo__title">Number roll-in</span><span class="demo__timing">220ms · opacity + Y + blur</span></div>
|
||
<div class="demo__controls">
|
||
<div class="seg"><label for="st4-a">Auto</label><label for="st4-l">Light</label><label for="st4-d">Dark</label></div>
|
||
<span class="demo__loop">↻</span>
|
||
</div>
|
||
</div>
|
||
<div class="demo__stage">
|
||
<div class="demo-4__mt" style="text-align:center;">
|
||
<div class="ui-label" style="margin-bottom:6px;">Current streak</div>
|
||
<div><span class="ui-num">7</span> <span class="ui-num" style="font-size:18px;color:var(--st-dim);">days</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">Opportunities</span>
|
||
<ul class="lens-list opp">
|
||
<li><span class="mk">💡</span><span>Habit cards lean on a drop shadow that's invisible on the dark theme — a 1px border would carry the elevation on both. <code>components/HabitCard.tsx:9</code></span></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="lens-take">
|
||
<span class="mono-label">Through Jakub's lens</span>
|
||
<p>The vocabulary is right; the sentences are unfinished. Pair every enter with an exit, give the streak its moment, and Tally crosses from "animated" to "polished."</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══════════ 04 · PER-LENS — EMIL (secondary) ═══════════ -->
|
||
<section class="lens-sec">
|
||
<div class="sec-index">04</div>
|
||
<div class="lens-sec__head">
|
||
<h3>Emil Kowalski — Restraint & Speed</h3>
|
||
<span class="lens-sec__weight">Secondary lens</span>
|
||
</div>
|
||
<p class="lens-sec__verdict"><span class="verdict v-concern">Concern</span></p>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">What's working well</span>
|
||
<ul class="lens-list good">
|
||
<li><span class="mk">✓</span><span>Card hover lift is 80ms — instant, exactly right for a passive affordance. <code>components/HabitCard.tsx:18</code></span></li>
|
||
<li><span class="mk">✓</span><span>No animation on keyboard-driven navigation — keyboard users aren't taxed with motion they didn't ask for.</span></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">Issues to address</span>
|
||
<div class="findings">
|
||
|
||
<!-- FINDING 01 (Critical) -->
|
||
<div class="finding-row" data-sev="crit" id="finding-1">
|
||
<div class="finding-row__prose">
|
||
<div class="find-tags"><span class="severity crit">Critical</span><span class="lens-chip">Emil</span></div>
|
||
<h4 class="find-title">Tab switches slide for 600ms — far too slow for the most frequent action</h4>
|
||
<div class="find-body">
|
||
<p><span class="label">What</span>The four bottom tabs cross-slide the full panel width over 600ms with an <code>ease-in-out</code>. Tabs are the app's highest-frequency navigation; a 600ms slide means every switch holds the user behind an animation.</p>
|
||
<p><span class="label">Why it matters</span>Emil's rule: the more often an action fires, the less it should animate. At this duration the motion stops being feedback and becomes a toll. <code>ease-in-out</code> also adds a slow start, compounding the lag.</p>
|
||
<div class="fix"><p><span class="label">Recommended motion</span>Drop to a 180ms opacity crossfade with a 7px slide, <code>ease-out</code>. Better still: consider no slide at all — a fast crossfade is plenty of orientation for a tab.</p></div>
|
||
<p class="find-loc"><code>navigation/TabView.tsx:63</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="demo">
|
||
<input class="vh" type="radio" name="st1" id="st1-a" checked>
|
||
<input class="vh" type="radio" name="st1" id="st1-l">
|
||
<input class="vh" type="radio" name="st1" id="st1-d">
|
||
<div class="demo__bar">
|
||
<div class="demo__meta"><span class="demo__title">Quick tab crossfade</span><span class="demo__timing">180ms · ease-out</span></div>
|
||
<div class="demo__controls">
|
||
<div class="seg"><label for="st1-a">Auto</label><label for="st1-l">Light</label><label for="st1-d">Dark</label></div>
|
||
<span class="demo__loop">↻</span>
|
||
</div>
|
||
</div>
|
||
<div class="demo__stage">
|
||
<div class="demo-1__mt" style="text-align:left;width:100%;">
|
||
<div class="ui-label" style="margin-bottom:10px;">Today</div>
|
||
<div class="ui-row" style="margin-bottom:8px;"><span class="ui-check"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5"><path d="M5 13l4 4L19 7"/></svg></span><span>Morning walk</span></div>
|
||
<div class="ui-row"><span class="ui-check" style="background:var(--st-line);"></span><span>Read 10 pages</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FINDING 03 (Important) -->
|
||
<div class="finding-row" data-sev="imp" id="finding-3">
|
||
<div class="finding-row__prose">
|
||
<div class="find-tags"><span class="severity imp">Important</span><span class="lens-chip">Emil</span></div>
|
||
<h4 class="find-title">Check-off pops from scale(0) with a spring bounce</h4>
|
||
<div class="find-body">
|
||
<p><span class="label">What</span>Ticking a habit animates the checkmark from <code>scale(0)</code> with a bouncy spring (~450ms to settle). It's the app's core, most-repeated gesture, and it's the showiest animation in the product.</p>
|
||
<p><span class="label">Why it matters</span>Bounce on a high-frequency confirm gets tiring fast — the overshoot draws attention to motion the user has already mentally completed. Starting from <code>scale(0)</code> exaggerates the distance and the time.</p>
|
||
<div class="fix"><p><span class="label">Recommended motion</span>Scale <code>0.9 → 1</code> with opacity, 200ms <code>ease-out</code>, no overshoot. Confident and done before the finger lifts.</p></div>
|
||
<p class="find-loc"><code>components/HabitCheck.tsx:27</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="demo">
|
||
<input class="vh" type="radio" name="st3" id="st3-a" checked>
|
||
<input class="vh" type="radio" name="st3" id="st3-l">
|
||
<input class="vh" type="radio" name="st3" id="st3-d">
|
||
<div class="demo__bar">
|
||
<div class="demo__meta"><span class="demo__title">Calm check, no bounce</span><span class="demo__timing">200ms · ease-out</span></div>
|
||
<div class="demo__controls">
|
||
<div class="seg"><label for="st3-a">Auto</label><label for="st3-l">Light</label><label for="st3-d">Dark</label></div>
|
||
<span class="demo__loop">↻</span>
|
||
</div>
|
||
</div>
|
||
<div class="demo__stage">
|
||
<div class="demo-3__mt">
|
||
<div class="ui-row" style="font-size:15px;"><span class="ui-check" style="width:30px;height:30px;"><svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3.5"><path d="M5 13l4 4L19 7"/></svg></span><span>Drink water</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lens-take">
|
||
<span class="mono-label">Through Emil's lens</span>
|
||
<p>Speed up everything the user touches constantly. The tab switch and the check-off should feel instant; their current durations are the difference between an app that feels fast and one that feels fussy.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══════════ 05 · PER-LENS — JHEY (selective) ═══════════ -->
|
||
<section class="lens-sec">
|
||
<div class="sec-index">05</div>
|
||
<div class="lens-sec__head">
|
||
<h3>Jhey Tompkins — Experimentation & Delight</h3>
|
||
<span class="lens-sec__weight">Selective lens</span>
|
||
</div>
|
||
<p class="lens-sec__verdict"><span class="verdict v-strong">Strong</span></p>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">What's working well</span>
|
||
<ul class="lens-list good">
|
||
<li><span class="mk">✓</span><span>Tally resists the urge to animate everything — restraint <em>is</em> the right default for a daily utility. Delight is rationed, which makes room for it to land where it counts.</span></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">Issues to address</span>
|
||
<div class="findings">
|
||
|
||
<!-- FINDING 05 (Important) -->
|
||
<div class="finding-row" data-sev="imp" id="finding-5">
|
||
<div class="finding-row__prose">
|
||
<div class="find-tags"><span class="severity imp">Important</span><span class="lens-chip">Jhey</span></div>
|
||
<h4 class="find-title">Hitting a milestone streak passes by with no celebration</h4>
|
||
<div class="find-body">
|
||
<p><span class="label">What</span>Crossing a 7-, 30-, or 100-day streak looks identical to any other day — the number just increments. The one moment in the app that has genuinely earned a flourish gets none.</p>
|
||
<p><span class="label">Why it matters</span>This is where delight pays for itself. A milestone is rare, emotionally loaded, and shareable — exactly the place to spend motion the rest of the app withholds. Skipping it leaves the payoff flat.</p>
|
||
<div class="fix"><p><span class="label">Recommended motion</span>On a milestone only: a badge that scales <code>0.8 → 1</code> (260ms <code>ease-out</code>) with a short, one-shot sparkle burst. Fires once on the event — not a looping pulse.</p></div>
|
||
<p class="find-loc"><code>components/StreakBadge.tsx:40</code></p>
|
||
</div>
|
||
</div>
|
||
<div class="demo">
|
||
<input class="vh" type="radio" name="st5" id="st5-a" checked>
|
||
<input class="vh" type="radio" name="st5" id="st5-l">
|
||
<input class="vh" type="radio" name="st5" id="st5-d">
|
||
<div class="demo__bar">
|
||
<div class="demo__meta"><span class="demo__title">Milestone badge enter</span><span class="demo__timing">260ms · ease-out + sparkle</span></div>
|
||
<div class="demo__controls">
|
||
<div class="seg"><label for="st5-a">Auto</label><label for="st5-l">Light</label><label for="st5-d">Dark</label></div>
|
||
<span class="demo__loop">↻</span>
|
||
</div>
|
||
</div>
|
||
<div class="demo__stage">
|
||
<div class="demo-5__mt" style="position:relative;width:96px;height:96px;display:flex;align-items:center;justify-content:center;">
|
||
<div class="badge" style="width:78px;height:78px;border-radius:50%;background:var(--st-fg);display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--st-bg);">
|
||
<span style="font-family:var(--display);font-size:26px;font-weight:700;line-height:1;">7</span>
|
||
<span style="font-family:var(--mono);font-size:8px;letter-spacing:0.12em;margin-top:2px;">DAY</span>
|
||
</div>
|
||
<span class="spark" style="--dx:-30px;--dy:-26px;position:absolute;width:7px;height:7px;border-radius:50%;background:var(--st-fg);"></span>
|
||
<span class="spark" style="--dx:32px;--dy:-20px;position:absolute;width:5px;height:5px;border-radius:50%;background:var(--st-fg);"></span>
|
||
<span class="spark" style="--dx:24px;--dy:30px;position:absolute;width:6px;height:6px;border-radius:50%;background:var(--st-fg);"></span>
|
||
<span class="spark" style="--dx:-28px;--dy:28px;position:absolute;width:5px;height:5px;border-radius:50%;background:var(--st-fg);"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lens-block">
|
||
<span class="mono-label">Opportunities</span>
|
||
<ul class="lens-list opp">
|
||
<li><span class="mk">💡</span><span>The today-list could stagger its rows in on first paint — 30ms apart, opacity + 6px rise. One orchestrated load beats scattered micro-interactions. <code>screens/Today.tsx:51</code></span></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="lens-take">
|
||
<span class="mono-label">Through Jhey's lens</span>
|
||
<p>Don't add more motion — add it in <em>one</em> right place. The streak milestone is the moment worth engineering; everywhere else, keeping your hands off the controls is the sophisticated move.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══════════ 06 · COMBINED RECOMMENDATIONS ═══════════ -->
|
||
<section>
|
||
<div class="sec-index">06</div>
|
||
<h2 class="section-title">In the order I'd fix them</h2>
|
||
<p class="section-sub">
|
||
Ordered by what users feel: <em>critical</em> (degrades the core loop on every use) → <em>important</em>
|
||
(real friction or a missed payoff) → <em>opportunity</em> (could enhance).
|
||
</p>
|
||
|
||
<div class="rec crit" id="rec-crit">
|
||
<div class="tier-label"><span class="mono-label">Critical · must fix</span><span class="rule"></span><span class="count">2</span></div>
|
||
<table class="rec-table">
|
||
<thead><tr><th>Issue</th><th>File</th><th>Fix</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Tab switch runs 600ms on the highest-frequency action</td><td><code>TabView.tsx:63</code></td><td>180ms opacity crossfade + 7px slide, ease-out</td></tr>
|
||
<tr><td>Add-habit sheet has no exit — snaps shut</td><td><code>AddSheet.tsx:48</code></td><td>Wrap in AnimatePresence; mirror the 300ms enter on exit</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="rec imp" id="rec-imp">
|
||
<div class="tier-label"><span class="mono-label">Important · should fix</span><span class="rule"></span><span class="count">3</span></div>
|
||
<table class="rec-table">
|
||
<thead><tr><th>Issue</th><th>File</th><th>Fix</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Check-off pops from scale(0) with a bounce</td><td><code>HabitCheck.tsx:27</code></td><td>scale 0.9→1 + opacity, 200ms ease-out, no overshoot</td></tr>
|
||
<tr><td>Streak counter swaps with no transition</td><td><code>StreakBadge.tsx:22</code></td><td>220ms opacity + translateY + blur roll-in</td></tr>
|
||
<tr><td>Milestone streaks have no celebration moment</td><td><code>StreakBadge.tsx:40</code></td><td>One-shot badge scale-in + sparkle, milestones only</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="rec opp" id="rec-opp">
|
||
<div class="tier-label"><span class="mono-label">Opportunities · could enhance</span><span class="rule"></span><span class="count">2</span></div>
|
||
<table class="rec-table">
|
||
<thead><tr><th>Enhancement</th><th>Where</th><th>Impact</th></tr></thead>
|
||
<tbody>
|
||
<tr><td>Habit-card elevation invisible on dark theme</td><td><code>HabitCard.tsx:9</code></td><td>1px border carries elevation on both themes</td></tr>
|
||
<tr><td>Today-list could stagger in on first paint</td><td><code>Today.tsx:51</code></td><td>30ms stagger, opacity + 6px rise — one orchestrated load</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ═══════════ 07 · LENS REFERENCE SUMMARY ═══════════ -->
|
||
<section style="margin-bottom: var(--s6);">
|
||
<div class="sec-index">07</div>
|
||
<h2 class="section-title">Which lens carried this audit</h2>
|
||
<div class="ref-summary">
|
||
<span class="mono-label">Referenced most</span>
|
||
<p class="big">Jakub Krehel — Production Polish</p>
|
||
<p class="why">Tally's gaps are craft gaps, not taste gaps: enters without exits, state changes that snap. That's Jakub's territory — finishing what's been started — so his lens drove the ordering. Emil set the durations; Jhey marked the one place to spend.</p>
|
||
<ul class="ref-lean">
|
||
<li><span class="nm">Lean Emil</span><span>Treat every duration as a budget. Push tab, check, and toggle timings under 200ms and question any motion on a high-frequency action.</span></li>
|
||
<li><span class="nm">Lean Jakub</span><span>Audit every conditional render for a missing exit. Pair enters and exits, and give meaningful state changes a transition.</span></li>
|
||
<li><span class="nm">Lean Jhey</span><span>Pick the single highest-emotion moment — the milestone — and over-invest there, with @property, springs, or scroll-driven touches.</span></li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<footer class="doc">
|
||
<span>Tally motion audit · design-motion-principles</span>
|
||
<span>7 findings · 2 critical · 3 important · 2 opportunities</span>
|
||
</footer>
|
||
|
||
</div>
|
||
</body>
|
||
</html>
|