style(motion): add strict animations and fix layout properties

- Change ReadingProgress and RouteTable to use transform (scaleX/scaleY) instead of width/height
- Convert existing easing functions to 200ms cubic-bezier(.2,0,0,1)
- Document animation purpose with CSS comments
- Add guide panel swap, review desk detail swap, and tally pop animations
- Implement prefers-reduced-motion for all new states
This commit is contained in:
Marcos Paulo
2026-09-05 23:13:59 +00:00
parent 71c74fb7f1
commit 9ca3f48def
10 changed files with 66 additions and 12 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ button{font-family:inherit}
.tree-detail{display:grid;grid-template-columns:.7fr 1fr 1.6fr;gap:1px;border-top:1px solid #41596b;background:#41596b}.tree-detail>div,.tree-detail>p,.tree-detail>code{margin:0;padding:18px;background:#102536}.tree-detail div{display:grid;gap:8px}.tree-detail span{color:var(--accent);font:500 8px var(--font-mono);letter-spacing:.1em}.tree-detail strong{color:var(--paper);font:500 11px var(--font-mono)}.tree-detail p{color:#aebbc3;font-size:11px;line-height:1.55}.tree-detail code{grid-column:1/-1;color:var(--gold);font:11px var(--font-mono)} .tree-detail{display:grid;grid-template-columns:.7fr 1fr 1.6fr;gap:1px;border-top:1px solid #41596b;background:#41596b}.tree-detail>div,.tree-detail>p,.tree-detail>code{margin:0;padding:18px;background:#102536}.tree-detail div{display:grid;gap:8px}.tree-detail span{color:var(--accent);font:500 8px var(--font-mono);letter-spacing:.1em}.tree-detail strong{color:var(--paper);font:500 11px var(--font-mono)}.tree-detail p{color:#aebbc3;font-size:11px;line-height:1.55}.tree-detail code{grid-column:1/-1;color:var(--gold);font:11px var(--font-mono)}
.route-console{display:grid;gap:14px}.route-table button{display:grid;grid-template-columns:.8fr .9fr 1.4fr;width:100%;padding:0;border:0;color:inherit;background:transparent;text-align:left;cursor:pointer}.route-table button>*{padding:15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.route-table button:hover,.route-table button.active{background:#e8ecee}.route-table button.active strong{box-shadow:inset 4px 0 0 var(--gold)} .route-console{display:grid;gap:14px}.route-table button{display:grid;grid-template-columns:.8fr .9fr 1.4fr;width:100%;padding:0;border:0;color:inherit;background:transparent;text-align:left;cursor:pointer}.route-table button>*{padding:15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.route-table button:hover,.route-table button.active{background:#e8ecee}.route-table button.active strong{box-shadow:inset 4px 0 0 var(--gold)}
.route-detail{display:grid;grid-template-columns:100px 1fr;gap:22px;align-items:center;padding:22px;color:var(--paper);background:var(--deep)}.route-meter{display:grid;align-items:end;width:76px;height:76px;padding:7px;border:1px solid #496274}.route-meter span{display:block;width:100%;height:var(--score);background:var(--gold);transition:height .35s ease}.route-detail>div:last-child{display:grid;gap:7px}.route-detail small{color:var(--gold);font:500 8px var(--font-mono);letter-spacing:.08em}.route-detail strong{font-size:17px}.route-detail p{margin:0;color:#b5c0c7;font-size:12px;line-height:1.5} .route-detail{display:grid;grid-template-columns:100px 1fr;gap:22px;align-items:center;padding:22px;color:var(--paper);background:var(--deep)}.route-meter{display:grid;align-items:end;width:76px;height:76px;padding:7px;border:1px solid #496274}.route-meter span{display:block;width:100%;height:100%;background:var(--gold);transform:scaleY(var(--score));transform-origin:bottom;transition:transform .35s ease-out}.route-detail>div:last-child{display:grid;gap:7px}.route-detail small{color:var(--gold);font:500 8px var(--font-mono);letter-spacing:.08em}.route-detail strong{font-size:17px}.route-detail p{margin:0;color:#b5c0c7;font-size:12px;line-height:1.5}
.skill-explorer{display:grid;grid-template-columns:.9fr 1.1fr;min-height:290px;background:var(--blue)}.skill-package button{display:grid;grid-template-columns:1fr 1fr;gap:15px;padding:17px 20px;border:0;border-bottom:1px solid #ffffff40;color:var(--paper);background:transparent;text-align:left;cursor:pointer}.skill-package button:hover,.skill-package button.active{background:#315f80}.skill-package button.active{box-shadow:inset 4px 0 0 var(--gold)}.skill-package button code{font:12px var(--font-mono)}.skill-package button small{opacity:.7} .skill-explorer{display:grid;grid-template-columns:.9fr 1.1fr;min-height:290px;background:var(--blue)}.skill-package button{display:grid;grid-template-columns:1fr 1fr;gap:15px;padding:17px 20px;border:0;border-bottom:1px solid #ffffff40;color:var(--paper);background:transparent;text-align:left;cursor:pointer}.skill-package button:hover,.skill-package button.active{background:#315f80}.skill-package button.active{box-shadow:inset 4px 0 0 var(--gold)}.skill-package button code{font:12px var(--font-mono)}.skill-package button small{opacity:.7}
.skill-detail{display:grid;grid-template-columns:auto 1fr;gap:18px;align-content:center;padding:28px;color:var(--paper);background:#244760}.skill-detail>span{color:var(--gold);font:42px Georgia,serif}.skill-detail>div{display:grid;gap:13px}.skill-detail strong{font:600 15px var(--font-mono)}.skill-detail p{margin:0;color:#c4cdd3;font-size:12px;line-height:1.65}.skill-detail small{color:var(--gold);font:500 8px var(--font-mono);letter-spacing:.06em;text-transform:uppercase} .skill-detail{display:grid;grid-template-columns:auto 1fr;gap:18px;align-content:center;padding:28px;color:var(--paper);background:#244760}.skill-detail>span{color:var(--gold);font:42px Georgia,serif}.skill-detail>div{display:grid;gap:13px}.skill-detail strong{font:600 15px var(--font-mono)}.skill-detail p{margin:0;color:#c4cdd3;font-size:12px;line-height:1.65}.skill-detail small{color:var(--gold);font:500 8px var(--font-mono);letter-spacing:.06em;text-transform:uppercase}
+6 -2
View File
@@ -437,10 +437,14 @@ function previewMarkup(entry, available) {
function renderDetail() { function renderDetail() {
const entry = state.selected; const entry = state.selected;
const available = packageFiles(entry); const available = packageFiles(entry);
$('#detail').innerHTML = const detailPanel = $('#detail');
detailPanel.innerHTML =
`<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div id="vote-widget"></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`; `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div id="vote-widget"></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`;
detailPanel.classList.remove('is-swapping');
void detailPanel.offsetWidth;
detailPanel.classList.add('is-swapping');
renderVoteWidget($('#vote-widget'), entry.id); renderVoteWidget($('#vote-widget'), entry.id);
$('#detail') detailPanel
.querySelectorAll('[data-file]') .querySelectorAll('[data-file]')
.forEach((button) => .forEach((button) =>
button.addEventListener('click', () => { button.addEventListener('click', () => {
+1
View File
@@ -15,3 +15,4 @@
.markdown-preview{max-height:540px;overflow:auto;padding:24px;color:#d6e1e4;background:#0c1a25}.markdown-preview>:first-child{margin-top:0}.markdown-preview h1,.markdown-preview h2,.markdown-preview h3,.markdown-preview h4,.markdown-preview h5,.markdown-preview h6{margin:1.5em 0 .5em;color:#fff;line-height:1.15}.markdown-preview h1{font-size:1.8em}.markdown-preview h2{font-size:1.45em}.markdown-preview h3{font-size:1.2em}.markdown-preview p,.markdown-preview li{max-width:78ch}.markdown-preview li+li{margin-top:.35em}.markdown-preview a{color:var(--gold)}.markdown-preview code{padding:.12em .3em;color:#fff;background:#29455a;white-space:break-spaces}.markdown-preview pre{max-height:none;margin:1em 0;padding:14px;border:1px solid #486175}.markdown-preview pre code{padding:0;background:transparent}.markdown-preview blockquote{margin:1em 0;padding:.3em 1em;border-left:3px solid var(--gold);color:#b9c8d0}.markdown-preview hr{border:0;border-top:1px solid #486175}.markdown-frontmatter{display:grid;grid-template-columns:max-content 1fr;gap:3px 14px;margin:0 0 24px;padding:12px;border:1px solid #486175;font:11px/1.5 var(--font-mono)}.markdown-frontmatter dt{color:var(--gold)}.markdown-frontmatter dd{margin:0}.markdown-table-wrap{max-width:100%;overflow:auto;margin:1em 0;border:1px solid #486175}.markdown-preview table{width:100%;min-width:460px;border-collapse:collapse;font-size:13px}.markdown-preview th,.markdown-preview td{padding:9px 11px;border:1px solid #486175;text-align:left;vertical-align:top}.markdown-preview th{color:var(--gold);background:#173046} .markdown-preview{max-height:540px;overflow:auto;padding:24px;color:#d6e1e4;background:#0c1a25}.markdown-preview>:first-child{margin-top:0}.markdown-preview h1,.markdown-preview h2,.markdown-preview h3,.markdown-preview h4,.markdown-preview h5,.markdown-preview h6{margin:1.5em 0 .5em;color:#fff;line-height:1.15}.markdown-preview h1{font-size:1.8em}.markdown-preview h2{font-size:1.45em}.markdown-preview h3{font-size:1.2em}.markdown-preview p,.markdown-preview li{max-width:78ch}.markdown-preview li+li{margin-top:.35em}.markdown-preview a{color:var(--gold)}.markdown-preview code{padding:.12em .3em;color:#fff;background:#29455a;white-space:break-spaces}.markdown-preview pre{max-height:none;margin:1em 0;padding:14px;border:1px solid #486175}.markdown-preview pre code{padding:0;background:transparent}.markdown-preview blockquote{margin:1em 0;padding:.3em 1em;border-left:3px solid var(--gold);color:#b9c8d0}.markdown-preview hr{border:0;border-top:1px solid #486175}.markdown-frontmatter{display:grid;grid-template-columns:max-content 1fr;gap:3px 14px;margin:0 0 24px;padding:12px;border:1px solid #486175;font:11px/1.5 var(--font-mono)}.markdown-frontmatter dt{color:var(--gold)}.markdown-frontmatter dd{margin:0}.markdown-table-wrap{max-width:100%;overflow:auto;margin:1em 0;border:1px solid #486175}.markdown-preview table{width:100%;min-width:460px;border-collapse:collapse;font-size:13px}.markdown-preview th,.markdown-preview td{padding:9px 11px;border:1px solid #486175;text-align:left;vertical-align:top}.markdown-preview th{color:var(--gold);background:#173046}
.markdown-toc{margin:0 0 24px;padding:12px 14px;border:1px solid #486175;background:#102b3a}.markdown-toc>span{color:var(--gold);font:700 10px var(--font-mono);letter-spacing:.1em}.markdown-toc ol{display:flex;flex-wrap:wrap;gap:7px 13px;margin:9px 0 0;padding:0;list-style:none}.markdown-toc li.level-2{margin-left:10px}.markdown-toc li.level-3{margin-left:20px}.markdown-toc a{font:12px/1.3 var(--font-sans);text-decoration:none}.markdown-toc a:hover{text-decoration:underline} .markdown-toc{margin:0 0 24px;padding:12px 14px;border:1px solid #486175;background:#102b3a}.markdown-toc>span{color:var(--gold);font:700 10px var(--font-mono);letter-spacing:.1em}.markdown-toc ol{display:flex;flex-wrap:wrap;gap:7px 13px;margin:9px 0 0;padding:0;list-style:none}.markdown-toc li.level-2{margin-left:10px}.markdown-toc li.level-3{margin-left:20px}.markdown-toc a{font:12px/1.3 var(--font-sans);text-decoration:none}.markdown-toc a:hover{text-decoration:underline}
.detail.is-swapping > * { /* purpose: review-desk detail swap on selection (state change) */ animation: detail-swap 200ms cubic-bezier(.2,0,0,1) both; } @keyframes detail-swap { from { opacity: 0.15; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } .vote-buttons button b { display: inline-block; } .vote-buttons button b.is-updating { /* purpose: vote widget tally update (feedback) */ animation: tally-pop 200ms cubic-bezier(.2,0,0,1) both; } @keyframes tally-pop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { .detail.is-swapping > *, .vote-buttons button b.is-updating { animation: none; } }
+4 -4
View File
@@ -19,15 +19,15 @@ async function api(path, options = {}) {
return response.json(); return response.json();
} }
function widgetMarkup(skillId, tally, you, unavailable) { function widgetMarkup(skillId, tally, you, unavailable, isUpdate = false) {
const total = (tally.original || 0) + (tally.improved || 0); const total = (tally.original || 0) + (tally.improved || 0);
const share = (count) => total ? Math.round((count / total) * 100) : 0; const share = (count) => total ? Math.round((count / total) * 100) : 0;
if (unavailable) return `<section class="vote-widget" aria-label="Vote unavailable"><span>READER VOTE</span><p>Voting is offline right now — the vote service is not configured or unreachable.</p></section>`; if (unavailable) return `<section class="vote-widget" aria-label="Vote unavailable"><span>READER VOTE</span><p>Voting is offline right now — the vote service is not configured or unreachable.</p></section>`;
return `<section class="vote-widget" aria-label="Vote on this review" data-skill="${escape(skillId)}"> return `<section class="vote-widget" aria-label="Vote on this review" data-skill="${escape(skillId)}">
<span>WHICH DRAFT WOULD YOU SHIP?</span> <span>WHICH DRAFT WOULD YOU SHIP?</span>
<div class="vote-buttons" role="group" aria-label="Cast your vote"> <div class="vote-buttons" role="group" aria-label="Cast your vote">
<button data-vote="original" aria-pressed="${you === 'original'}">Original<b>${tally.original || 0} · ${share(tally.original || 0)}%</b></button> <button data-vote="original" aria-pressed="${you === 'original'}">Original<b${isUpdate ? ' class="is-updating"' : ''}>${tally.original || 0} · ${share(tally.original || 0)}%</b></button>
<button data-vote="improved" aria-pressed="${you === 'improved'}">Improved draft<b>${tally.improved || 0} · ${share(tally.improved || 0)}%</b></button> <button data-vote="improved" aria-pressed="${you === 'improved'}">Improved draft<b${isUpdate ? ' class="is-updating"' : ''}>${tally.improved || 0} · ${share(tally.improved || 0)}%</b></button>
</div> </div>
<p class="vote-note">${you ? `You voted ${you === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.` : 'One vote per visitor, tracked by network source.'}</p> <p class="vote-note">${you ? `You voted ${you === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.` : 'One vote per visitor, tracked by network source.'}</p>
</section>`; </section>`;
@@ -40,7 +40,7 @@ export async function renderVoteWidget(container, skillId) {
container.innerHTML = widgetMarkup(skillId, {}, null, false); container.innerHTML = widgetMarkup(skillId, {}, null, false);
try { try {
const result = await api('/api/votes', { method: 'POST', body: JSON.stringify({ skillId, choice }) }); const result = await api('/api/votes', { method: 'POST', body: JSON.stringify({ skillId, choice }) });
container.innerHTML = widgetMarkup(skillId, { original: result.original, improved: result.improved }, result.you, false); container.innerHTML = widgetMarkup(skillId, { original: result.original, improved: result.improved }, result.you, false, true);
bind(); bind();
} catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); } } catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); }
}; };
+2 -1
View File
@@ -137,7 +137,8 @@ const isDiff = mode === 'diff';
.skill-diff { .skill-diff {
border: 1px solid var(--ink); border: 1px solid var(--ink);
color: var(--paper); color: var(--paper);
animation: lens-enter 0.28s ease both; /* purpose: change lens reveal (state change) */
animation: lens-enter 200ms cubic-bezier(0.2, 0, 0, 1) both;
} }
.change-lens { .change-lens {
+23
View File
@@ -139,4 +139,27 @@ const share = (count: number) => (total ? Math.round((count / total) * 100) : 0)
flex-direction: column; flex-direction: column;
} }
} }
.vote-buttons button b {
display: inline-block;
}
:global(.is-updating) {
/* purpose: vote widget tally update (feedback) */
animation: tally-pop 200ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes tally-pop {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
:global(.is-updating) {
animation: none;
}
}
</style> </style>
+25 -1
View File
@@ -99,7 +99,11 @@ const { rootSelector, data } = Astro.props;
function replace(target, markup) { function replace(target, markup) {
const panel = root.querySelector(target); const panel = root.querySelector(target);
if (panel) panel.innerHTML = markup; if (!panel) return;
panel.innerHTML = markup;
panel.classList.remove('is-swapping');
void panel.offsetWidth;
panel.classList.add('is-swapping');
} }
// Names deliberately match legacy functions until task 19 updates its checks. // Names deliberately match legacy functions until task 19 updates its checks.
@@ -291,4 +295,24 @@ const { rootSelector, data } = Astro.props;
outline: 3px solid var(--gold); outline: 3px solid var(--gold);
outline-offset: -3px; outline-offset: -3px;
} }
:global(.is-swapping > *) {
/* purpose: tab panel change in the guide (state change) */
animation: guide-swap 200ms cubic-bezier(0.2, 0, 0, 1) both;
}
@keyframes guide-swap {
from {
opacity: 0.15;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
:global(.is-swapping > *) {
animation: none;
}
}
</style> </style>
+1 -1
View File
@@ -42,7 +42,7 @@ const { target = '.reading-progress span' } = Astro.props;
function update() { function update() {
const height = document.documentElement.scrollHeight - window.innerHeight; const height = document.documentElement.scrollHeight - window.innerHeight;
span.style.width = (height > 0 ? (window.scrollY / height) * 100 : 0) + '%'; span.style.transform = `scaleX(${height > 0 ? window.scrollY / height : 0})`;
} }
// `{ passive: true }` is non-negotiable. The legacy app.js binds // `{ passive: true }` is non-negotiable. The legacy app.js binds
@@ -235,7 +235,8 @@ const packageFiles: PackageFile[] = [
} }
.package-preview.is-swapping { .package-preview.is-swapping {
animation: package-preview-in 0.34s ease both; /* purpose: review-desk detail swap on selection (state change) */
animation: package-preview-in 200ms cubic-bezier(0.2, 0, 0, 1) both;
} }
@keyframes package-preview-in { @keyframes package-preview-in {
+1 -1
View File
File diff suppressed because one or more lines are too long