mirror of
https://github.com/ksyasuda/dotfiles.git
synced 2026-07-23 16:49:46 -07:00
update
This commit is contained in:
+454
@@ -0,0 +1,454 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debounced search — three approaches</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
line-height: 1.55;
|
||||
padding: 56px 32px 96px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1360px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
|
||||
header.page-head {
|
||||
margin-bottom: 48px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
line-height: 1.15;
|
||||
color: var(--slate);
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.prompt-box {
|
||||
background: var(--gray-150);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
font-size: 14.5px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.prompt-box .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---------- approach grid ---------- */
|
||||
|
||||
.approaches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 28px;
|
||||
margin-bottom: 56px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.approaches { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.approach {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.approach-head h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 21px;
|
||||
color: var(--slate);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.approach-head .num {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--oat);
|
||||
color: var(--slate);
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
margin-right: 8px;
|
||||
vertical-align: 3px;
|
||||
}
|
||||
|
||||
.approach-head p {
|
||||
font-size: 14px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* ---------- code panel ---------- */
|
||||
|
||||
.code {
|
||||
background: var(--slate);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: #E8E6DE;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.code .kw { color: var(--clay); } /* keywords */
|
||||
.code .str { color: var(--olive); } /* strings */
|
||||
.code .cm { color: var(--gray-500); } /* comments */
|
||||
.code .fn { color: #C9B98A; } /* identifiers, subtle warm */
|
||||
|
||||
/* ---------- tradeoffs table ---------- */
|
||||
|
||||
.tradeoffs {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tradeoffs .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.tradeoffs .row + .row {
|
||||
border-top: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.tradeoffs .cell {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.tradeoffs .cell:first-child {
|
||||
border-right: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.tradeoffs .head {
|
||||
background: var(--gray-150);
|
||||
font-weight: 600;
|
||||
color: var(--slate);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tradeoffs .pro::before,
|
||||
.tradeoffs .con::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
vertical-align: 2px;
|
||||
}
|
||||
.tradeoffs .pro::before { background: var(--olive); }
|
||||
.tradeoffs .con::before { background: var(--clay); }
|
||||
|
||||
/* ---------- chip footer ---------- */
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
background: var(--gray-150);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
color: var(--gray-700);
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip strong { color: var(--slate); font-weight: 600; }
|
||||
|
||||
/* ---------- recommendation ---------- */
|
||||
|
||||
.reco {
|
||||
border-left: 4px solid var(--clay);
|
||||
background: var(--white);
|
||||
border-radius: 0 12px 12px 0;
|
||||
padding: 24px 28px;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
.reco h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
color: var(--slate);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.reco p {
|
||||
font-size: 15px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reco code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92em;
|
||||
background: var(--gray-150);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header class="page-head">
|
||||
<div class="eyebrow">Exploration · Acme web client</div>
|
||||
<h1>Three ways to implement debounced search</h1>
|
||||
<div class="prompt-box">
|
||||
<span class="label">Prompt</span>
|
||||
Show me three different ways to implement debounced search for the task
|
||||
filter input in our React codebase, with tradeoffs for each.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section class="approaches">
|
||||
|
||||
<!-- ---------- Approach 1 ---------- -->
|
||||
<article class="approach">
|
||||
<header class="approach-head">
|
||||
<h2><span class="num">01</span>Inline useEffect + setTimeout</h2>
|
||||
<p>Debounce logic lives directly inside the component that owns the input.</p>
|
||||
</header>
|
||||
|
||||
<div class="code"><pre><span class="kw">export function</span> <span class="fn">TaskSearch</span>() {
|
||||
<span class="kw">const</span> [draft, setDraft] = <span class="fn">useState</span>(<span class="str">''</span>);
|
||||
<span class="kw">const</span> [query, setQuery] = <span class="fn">useState</span>(<span class="str">''</span>);
|
||||
|
||||
<span class="fn">useEffect</span>(() <span class="kw">=></span> {
|
||||
<span class="kw">const</span> id = <span class="fn">setTimeout</span>(() <span class="kw">=></span> setQuery(draft), <span class="str">300</span>);
|
||||
<span class="kw">return</span> () <span class="kw">=></span> <span class="fn">clearTimeout</span>(id);
|
||||
}, [draft]);
|
||||
|
||||
<span class="kw">const</span> { data } = <span class="fn">useTasks</span>({ search: query });
|
||||
|
||||
<span class="kw">return</span> (
|
||||
<<span class="fn">input</span>
|
||||
value={draft}
|
||||
onChange={(e) <span class="kw">=></span> setDraft(e.target.value)}
|
||||
placeholder=<span class="str">"Filter tasks…"</span>
|
||||
/>
|
||||
);
|
||||
}</pre></div>
|
||||
|
||||
<div class="tradeoffs">
|
||||
<div class="row head">
|
||||
<div class="cell">Pro</div>
|
||||
<div class="cell">Con</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Zero new abstractions to learn</div>
|
||||
<div class="cell con">Logic duplicated everywhere search exists</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Easy to step through in devtools</div>
|
||||
<div class="cell con">Two pieces of state for one conceptual value</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">No dependency or bundle change</div>
|
||||
<div class="cell con">Delay constant is buried in component body</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip">Bundle impact: <strong>+0 kb</strong></span>
|
||||
<span class="chip">Testability: <strong>medium</strong></span>
|
||||
<span class="chip">Reuse: <strong>low</strong></span>
|
||||
<span class="chip">SSR safe: <strong>yes</strong></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- ---------- Approach 2 ---------- -->
|
||||
<article class="approach">
|
||||
<header class="approach-head">
|
||||
<h2><span class="num">02</span>Custom useDebounce hook</h2>
|
||||
<p>Extract the timer into a shared hook under <code style="font-family:var(--mono)">src/hooks/</code>.</p>
|
||||
</header>
|
||||
|
||||
<div class="code"><pre><span class="cm">// src/hooks/useDebounce.ts</span>
|
||||
<span class="kw">export function</span> <span class="fn">useDebounce</span><T>(value: T, ms = <span class="str">300</span>): T {
|
||||
<span class="kw">const</span> [debounced, setDebounced] = <span class="fn">useState</span>(value);
|
||||
<span class="fn">useEffect</span>(() <span class="kw">=></span> {
|
||||
<span class="kw">const</span> id = <span class="fn">setTimeout</span>(() <span class="kw">=></span> setDebounced(value), ms);
|
||||
<span class="kw">return</span> () <span class="kw">=></span> <span class="fn">clearTimeout</span>(id);
|
||||
}, [value, ms]);
|
||||
<span class="kw">return</span> debounced;
|
||||
}
|
||||
|
||||
<span class="cm">// TaskSearch.tsx</span>
|
||||
<span class="kw">const</span> [draft, setDraft] = <span class="fn">useState</span>(<span class="str">''</span>);
|
||||
<span class="kw">const</span> query = <span class="fn">useDebounce</span>(draft, <span class="str">300</span>);
|
||||
<span class="kw">const</span> { data } = <span class="fn">useTasks</span>({ search: query });</pre></div>
|
||||
|
||||
<div class="tradeoffs">
|
||||
<div class="row head">
|
||||
<div class="cell">Pro</div>
|
||||
<div class="cell">Con</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Single import reused across filter, command bar, board search</div>
|
||||
<div class="cell con">One more file to maintain and document</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Trivial to unit test with fake timers</div>
|
||||
<div class="cell con">Generic <code style="font-family:var(--mono)">T</code> hides intent slightly</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Delay is a visible, tunable argument</div>
|
||||
<div class="cell con">Still re-renders on every keystroke</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip">Bundle impact: <strong>+0.2 kb</strong></span>
|
||||
<span class="chip">Testability: <strong>high</strong></span>
|
||||
<span class="chip">Reuse: <strong>high</strong></span>
|
||||
<span class="chip">SSR safe: <strong>yes</strong></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- ---------- Approach 3 ---------- -->
|
||||
<article class="approach">
|
||||
<header class="approach-head">
|
||||
<h2><span class="num">03</span>Tiny external library</h2>
|
||||
<p>Adopt <code style="font-family:var(--mono)">use-debounce</code> for both values and callbacks.</p>
|
||||
</header>
|
||||
|
||||
<div class="code"><pre><span class="kw">import</span> { useDebouncedCallback }
|
||||
<span class="kw">from</span> <span class="str">'use-debounce'</span>;
|
||||
|
||||
<span class="kw">export function</span> <span class="fn">TaskSearch</span>() {
|
||||
<span class="kw">const</span> [query, setQuery] = <span class="fn">useState</span>(<span class="str">''</span>);
|
||||
|
||||
<span class="kw">const</span> onChange = <span class="fn">useDebouncedCallback</span>(
|
||||
(next: <span class="kw">string</span>) <span class="kw">=></span> setQuery(next),
|
||||
<span class="str">300</span>,
|
||||
{ leading: <span class="kw">false</span>, maxWait: <span class="str">1000</span> },
|
||||
);
|
||||
|
||||
<span class="kw">const</span> { data } = <span class="fn">useTasks</span>({ search: query });
|
||||
|
||||
<span class="kw">return</span> (
|
||||
<<span class="fn">input</span>
|
||||
defaultValue=<span class="str">""</span>
|
||||
onChange={(e) <span class="kw">=></span> onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}</pre></div>
|
||||
|
||||
<div class="tradeoffs">
|
||||
<div class="row head">
|
||||
<div class="cell">Pro</div>
|
||||
<div class="cell">Con</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">leading / trailing / maxWait handled for us</div>
|
||||
<div class="cell con">New runtime dependency to audit and update</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Callback form skips intermediate re-renders</div>
|
||||
<div class="cell con">Uncontrolled input diverges from Acme form patterns</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell pro">Well-tested edge cases (unmount, flush, cancel)</div>
|
||||
<div class="cell con">~1.4 kb gzipped for something we could own</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<span class="chip">Bundle impact: <strong>+1.4 kb</strong></span>
|
||||
<span class="chip">Testability: <strong>high</strong></span>
|
||||
<span class="chip">Reuse: <strong>high</strong></span>
|
||||
<span class="chip">SSR safe: <strong>yes</strong></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<aside class="reco">
|
||||
<h2>Recommendation</h2>
|
||||
<p>
|
||||
Go with <strong>approach 02, the custom <code>useDebounce</code> hook</strong>.
|
||||
Acme already has three places that hand-roll the inline pattern
|
||||
(task filter, command palette, member picker), so extracting one
|
||||
shared hook removes duplication without taking on a new dependency.
|
||||
</p>
|
||||
<p>
|
||||
Revisit approach 03 only if we later need <code>maxWait</code> or
|
||||
<code>flush()</code> semantics — the library earns its bundle cost
|
||||
once the requirements outgrow a ten-line hook.
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+516
@@ -0,0 +1,516 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Empty state — four visual directions</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding-bottom: 96px;
|
||||
}
|
||||
|
||||
/* ---------- sticky toolbar ---------- */
|
||||
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--ivory);
|
||||
border-bottom: 1.5px solid var(--gray-300);
|
||||
padding: 14px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar .title {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--gray-500);
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.toolbar .field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toolbar .field > span {
|
||||
color: var(--gray-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.seg label {
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--gray-700);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.seg label + label {
|
||||
border-left: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.seg input { display: none; }
|
||||
|
||||
.seg input:checked + span {
|
||||
color: var(--slate);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.seg label:has(input:checked) {
|
||||
background: var(--oat);
|
||||
}
|
||||
|
||||
/* ---------- page header ---------- */
|
||||
|
||||
.page {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 0;
|
||||
}
|
||||
|
||||
header.page-head {
|
||||
max-width: 720px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
line-height: 1.15;
|
||||
color: var(--slate);
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.prompt-box {
|
||||
background: var(--gray-150);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.prompt-box .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---------- artboard grid ---------- */
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.board { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.artboard {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tag {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.02em;
|
||||
background: var(--oat);
|
||||
color: var(--slate);
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.stage {
|
||||
height: 280px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
background: var(--ivory);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage.dark {
|
||||
background: var(--slate);
|
||||
border-color: var(--slate);
|
||||
}
|
||||
|
||||
.rationale {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
Empty-state variants — each scoped under .es-*
|
||||
Theme tokens flip when .stage.dark is present.
|
||||
========================================================= */
|
||||
|
||||
.stage { --fg: var(--slate); --muted: var(--gray-500); --line: var(--gray-300); --panel: var(--white); }
|
||||
.stage.dark { --fg: #F0EEE6; --muted: #9C9A93; --line: #3D3D3A; --panel: #1F1E1B; }
|
||||
|
||||
/* ---- A · Minimal ---- */
|
||||
|
||||
.es-a {
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
.es-a h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.es-a p {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.es-a .btn {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ---- B · Illustrated ---- */
|
||||
|
||||
.es-b {
|
||||
text-align: center;
|
||||
max-width: 320px;
|
||||
}
|
||||
.es-b svg {
|
||||
display: block;
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
.es-b h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.es-b p {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.es-b .btn {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--white);
|
||||
background: var(--clay);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.stage.dark .es-b .btn { color: var(--slate); background: var(--oat); }
|
||||
|
||||
/* ---- C · Playful ---- */
|
||||
|
||||
.es-c {
|
||||
text-align: center;
|
||||
max-width: 300px;
|
||||
}
|
||||
.es-c .float {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 20px;
|
||||
position: relative;
|
||||
animation: bob 3.2s ease-in-out infinite;
|
||||
}
|
||||
.es-c .float .card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
border: 1.5px solid var(--line);
|
||||
}
|
||||
.es-c .float .card:nth-child(1) { transform: rotate(-8deg) translate(-6px, 4px); opacity: 0.5; }
|
||||
.es-c .float .card:nth-child(2) { transform: rotate( 6deg) translate( 6px, 2px); opacity: 0.75; }
|
||||
.es-c .float .card:nth-child(3) { background: var(--oat); border-color: var(--oat); }
|
||||
.stage.dark .es-c .float .card:nth-child(3) { background: var(--clay); border-color: var(--clay); }
|
||||
.es-c .shadow {
|
||||
width: 44px;
|
||||
height: 8px;
|
||||
margin: -8px auto 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0,0,0,0.12);
|
||||
filter: blur(2px);
|
||||
animation: shadow 3.2s ease-in-out infinite;
|
||||
}
|
||||
.stage.dark .es-c .shadow { background: rgba(0,0,0,0.5); }
|
||||
.es-c h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
color: var(--fg);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.es-c p {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
@keyframes shadow {
|
||||
0%, 100% { transform: scaleX(1); opacity: 0.9; }
|
||||
50% { transform: scaleX(0.8); opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ---- D · Instructional ---- */
|
||||
|
||||
.es-d {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
.es-d h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
color: var(--fg);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.es-d ol {
|
||||
list-style: none;
|
||||
counter-reset: step;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.es-d li {
|
||||
counter-increment: step;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: var(--panel);
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.es-d li::before {
|
||||
content: counter(step);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
flex: 0 0 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--oat);
|
||||
color: var(--slate);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.es-d li span { color: var(--muted); display: block; font-size: 12px; margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="title">Acme · design exploration</div>
|
||||
<div class="field">
|
||||
<span>Background:</span>
|
||||
<div class="seg" id="bg-seg">
|
||||
<label><input type="radio" name="bg" value="light" checked><span>Light</span></label>
|
||||
<label><input type="radio" name="bg" value="dark"><span>Dark</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="page-head">
|
||||
<div class="eyebrow">Exploration · Empty states</div>
|
||||
<h1>Four visual directions for the “no tasks yet” state</h1>
|
||||
<div class="prompt-box">
|
||||
<span class="label">Prompt</span>
|
||||
Explore four visual directions for our empty-state component. Render each
|
||||
live so we can compare tone, density, and how well they hold up on light
|
||||
and dark surfaces.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="board">
|
||||
|
||||
<!-- ================= A — Minimal ================= -->
|
||||
<article class="artboard">
|
||||
<span class="tag">A — Minimal</span>
|
||||
<div class="stage">
|
||||
<div class="es-a">
|
||||
<h3>No tasks yet</h3>
|
||||
<p>When you create a task it will show up here.</p>
|
||||
<span class="btn">New task</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rationale">
|
||||
Pure typography, single quiet action. Reads as calm and confident;
|
||||
assumes the surrounding UI already carries enough personality.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- ================= B — Illustrated ================= -->
|
||||
<article class="artboard">
|
||||
<span class="tag">B — Illustrated</span>
|
||||
<div class="stage">
|
||||
<div class="es-b">
|
||||
<svg width="120" height="90" viewBox="0 0 120 90" aria-hidden="true">
|
||||
<rect x="14" y="20" width="72" height="54" rx="8"
|
||||
fill="var(--panel)" stroke="var(--line)" stroke-width="1.5"/>
|
||||
<rect x="34" y="10" width="72" height="54" rx="8"
|
||||
fill="var(--oat)"/>
|
||||
<line x1="46" y1="26" x2="92" y2="26"
|
||||
stroke="var(--slate)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="46" y1="38" x2="80" y2="38"
|
||||
stroke="var(--slate)" stroke-width="2" stroke-linecap="round" opacity="0.5"/>
|
||||
<circle cx="98" cy="60" r="12" fill="var(--clay)"/>
|
||||
<path d="M98 55 v10 M93 60 h10"
|
||||
stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<h3>Start your first list</h3>
|
||||
<p>Group related work and watch progress roll up automatically.</p>
|
||||
<span class="btn">Create a task</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rationale">
|
||||
A small geometric spot illustration anchors the eye and explains the
|
||||
object model (lists contain tasks) without a wall of copy.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- ================= C — Playful ================= -->
|
||||
<article class="artboard">
|
||||
<span class="tag">C — Playful</span>
|
||||
<div class="stage">
|
||||
<div class="es-c">
|
||||
<div class="float">
|
||||
<div class="card"></div>
|
||||
<div class="card"></div>
|
||||
<div class="card"></div>
|
||||
</div>
|
||||
<div class="shadow"></div>
|
||||
<h3>Nothing on your plate</h3>
|
||||
<p>Enjoy the quiet, or add something to get moving.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rationale">
|
||||
A gently bobbing stack adds life to an otherwise static screen. Motion
|
||||
is subtle enough to loop indefinitely without drawing complaints.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- ================= D — Instructional ================= -->
|
||||
<article class="artboard">
|
||||
<span class="tag">D — Instructional</span>
|
||||
<div class="stage">
|
||||
<div class="es-d">
|
||||
<h3>Set up this project</h3>
|
||||
<ol>
|
||||
<li>
|
||||
Create your first task
|
||||
<span>Give it a name and an owner.</span>
|
||||
</li>
|
||||
<li>
|
||||
Add a due date
|
||||
<span>Acme will surface it on the timeline.</span>
|
||||
</li>
|
||||
<li>
|
||||
Invite a teammate
|
||||
<span>Shared projects stay in sync automatically.</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<p class="rationale">
|
||||
Treats the empty state as onboarding. Higher density, but every line is
|
||||
actionable — best when the user is new to the product, not just the view.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const stages = document.querySelectorAll('.stage');
|
||||
document.getElementById('bg-seg').addEventListener('change', (e) => {
|
||||
const dark = e.target.value === 'dark';
|
||||
stages.forEach((s) => s.classList.toggle('dark', dark));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,639 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PR #247 — Review Summary</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--rust: #B04A3F;
|
||||
--gray-150: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
header.pr-head {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 28px 32px;
|
||||
background: #fff;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.repo-line {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--gray-500);
|
||||
letter-spacing: 0.01em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
line-height: 1.25;
|
||||
color: var(--slate);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--oat);
|
||||
color: var(--slate);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
.author-name { font-weight: 500; color: var(--slate); }
|
||||
.author-sub { font-size: 12px; color: var(--gray-500); }
|
||||
|
||||
.branch {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--gray-700);
|
||||
background: var(--gray-150);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.branch .arrow { color: var(--gray-500); margin: 0 6px; }
|
||||
|
||||
.stat {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
.stat .add { color: var(--olive); font-weight: 600; }
|
||||
.stat .del { color: var(--rust); font-weight: 600; }
|
||||
.stat .files { color: var(--gray-500); margin-left: 10px; }
|
||||
|
||||
/* ---------- Section blocks ---------- */
|
||||
section { margin-bottom: 40px; }
|
||||
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 21px;
|
||||
color: var(--slate);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.prose li {
|
||||
position: relative;
|
||||
padding-left: 22px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.prose li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 9px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--gray-500);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ---------- Risk map ---------- */
|
||||
.risk-map {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--slate);
|
||||
text-decoration: none;
|
||||
background: #fff;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.chip:hover { transform: translateY(-1px); }
|
||||
|
||||
.chip .dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip.safe { background: rgba(120,140,93,0.10); border-color: rgba(120,140,93,0.45); }
|
||||
.chip.safe .dot { background: var(--olive); }
|
||||
.chip.medium { background: var(--oat); }
|
||||
.chip.medium .dot { background: #B89B6E; }
|
||||
.chip.attention { background: rgba(217,119,87,0.12); border-color: rgba(217,119,87,0.55); }
|
||||
.chip.attention .dot { background: var(--clay); }
|
||||
|
||||
.legend {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.legend .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
|
||||
/* ---------- File sections ---------- */
|
||||
.file-card {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
scroll-margin-top: 20px;
|
||||
}
|
||||
|
||||
.file-head {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1.5px solid var(--gray-150);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.file-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 13.5px;
|
||||
color: var(--slate);
|
||||
}
|
||||
.file-delta {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.file-delta .add { color: var(--olive); }
|
||||
.file-delta .del { color: var(--rust); }
|
||||
|
||||
.risk-tag {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.risk-tag.safe { background: rgba(120,140,93,0.15); color: var(--olive); }
|
||||
.risk-tag.medium { background: var(--oat); color: #7A6A4F; }
|
||||
.risk-tag.attention { background: rgba(217,119,87,0.15); color: var(--clay); }
|
||||
|
||||
/* ---------- Diff block ---------- */
|
||||
.diff {
|
||||
background: var(--slate);
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.diff-row {
|
||||
display: grid;
|
||||
grid-template-columns: 48px 18px 1fr;
|
||||
align-items: baseline;
|
||||
padding: 0 14px 0 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.diff-row .ln {
|
||||
text-align: right;
|
||||
padding-right: 14px;
|
||||
color: var(--gray-500);
|
||||
user-select: none;
|
||||
}
|
||||
.diff-row .mark {
|
||||
text-align: center;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.diff-row .code { color: #E8E6DC; }
|
||||
.diff-row.ctx .code { color: #B8B6AC; }
|
||||
.diff-row.add { background: rgba(120,140,93,0.15); }
|
||||
.diff-row.add .mark { color: var(--olive); }
|
||||
.diff-row.del { background: rgba(176,74,63,0.15); }
|
||||
.diff-row.del .mark { color: var(--rust); }
|
||||
.diff-row.hunk {
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.diff-row.hunk .code { color: var(--gray-500); }
|
||||
|
||||
/* ---------- Review comments ---------- */
|
||||
.comments {
|
||||
padding: 18px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: var(--gray-150);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-left-width: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px 12px 16px;
|
||||
max-width: 680px;
|
||||
}
|
||||
.bubble.blocking { border-left-color: var(--clay); }
|
||||
.bubble.nit { border-left-color: var(--gray-300); }
|
||||
|
||||
.bubble::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -9px;
|
||||
top: 16px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border-left: 1.5px solid var(--gray-300);
|
||||
border-bottom: 1.5px solid var(--gray-300);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.bubble.blocking::before { border-left-color: var(--clay); border-bottom-color: var(--clay); }
|
||||
|
||||
.bubble .anchor {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.bubble .label {
|
||||
display: inline-block;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 700;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.bubble.blocking .label { color: var(--clay); }
|
||||
.bubble.nit .label { color: var(--gray-500); }
|
||||
.bubble p { font-size: 13.5px; color: var(--gray-700); }
|
||||
.bubble code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--gray-150);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Collapsed files ---------- */
|
||||
details.file-collapsed {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
margin-bottom: 14px;
|
||||
scroll-margin-top: 20px;
|
||||
}
|
||||
details.file-collapsed summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
details.file-collapsed summary::-webkit-details-marker { display: none; }
|
||||
details.file-collapsed summary::after {
|
||||
content: "+";
|
||||
font-family: var(--mono);
|
||||
color: var(--gray-500);
|
||||
font-size: 16px;
|
||||
}
|
||||
details.file-collapsed[open] summary::after { content: "−"; }
|
||||
details.file-collapsed .body {
|
||||
padding: 0 20px 16px;
|
||||
font-size: 13.5px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
footer.next-steps {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
.checklist { list-style: none; padding: 0; }
|
||||
.checklist li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.checklist input[type="checkbox"] {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--olive);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checklist label { cursor: pointer; flex: 1; }
|
||||
.checklist code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--gray-150);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header class="pr-head">
|
||||
<div class="repo-line">acme/web · Pull Request #247</div>
|
||||
<h1>Add optimistic updates to task list mutations</h1>
|
||||
<div class="meta-row">
|
||||
<div class="author">
|
||||
<div class="avatar">MO</div>
|
||||
<div>
|
||||
<div class="author-name">Mira Okafor</div>
|
||||
<div class="author-sub">opened 2 days ago</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="branch">
|
||||
mo/optimistic-tasks <span class="arrow">→</span> main
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="add">+142</span> / <span class="del">−38</span>
|
||||
<span class="files">6 files changed</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="prose">
|
||||
<h2>What this PR does</h2>
|
||||
<ul>
|
||||
<li>Replaces the await-then-refetch pattern in <code style="font-family:var(--mono);font-size:12.5px;background:var(--gray-150);padding:1px 5px;border-radius:4px">TaskList</code> with optimistic cache writes, so toggling or reordering a task feels instant instead of waiting ~300ms for the round-trip.</li>
|
||||
<li>Introduces a small <code style="font-family:var(--mono);font-size:12.5px;background:var(--gray-150);padding:1px 5px;border-radius:4px">useOptimisticTasks</code> hook that wraps the mutation, snapshots the previous list, and rolls back on error.</li>
|
||||
<li>Extends the API client to accept an idempotency key per mutation and adds a toast when a rollback fires.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Risk map</h2>
|
||||
<div class="risk-map">
|
||||
<a class="chip attention" href="#file-hook"><span class="dot"></span>useOptimisticTasks.ts</a>
|
||||
<a class="chip medium" href="#file-tasklist"><span class="dot"></span>TaskList.tsx</a>
|
||||
<a class="chip medium" href="#file-api"><span class="dot"></span>api/tasks.ts</a>
|
||||
<a class="chip safe" href="#file-toast"><span class="dot"></span>Toast.tsx</a>
|
||||
<a class="chip safe" href="#file-types"><span class="dot"></span>types/task.ts</a>
|
||||
<a class="chip safe" href="#file-test"><span class="dot"></span>TaskList.test.tsx</a>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><span class="dot" style="background:var(--olive)"></span> safe</span>
|
||||
<span><span class="dot" style="background:#B89B6E"></span> worth a look</span>
|
||||
<span><span class="dot" style="background:var(--clay)"></span> needs attention</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Files</h2>
|
||||
|
||||
<!-- File 1: hook (attention) -->
|
||||
<article class="file-card" id="file-hook">
|
||||
<div class="file-head">
|
||||
<div>
|
||||
<div class="file-path">src/hooks/useOptimisticTasks.ts</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="risk-tag attention">needs attention</span>
|
||||
<span class="file-delta"><span class="add">+58</span> <span class="del">−0</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff">
|
||||
<div class="diff-row hunk"><span class="ln"></span><span class="mark"></span><span class="code">@@ -0,0 +1,58 @@</span></div>
|
||||
<div class="diff-row add"><span class="ln">1</span><span class="mark">+</span><span class="code">import { useMutation, useQueryClient } from '@tanstack/react-query';</span></div>
|
||||
<div class="diff-row add"><span class="ln">2</span><span class="mark">+</span><span class="code">import { updateTask, TaskPatch } from '../api/tasks';</span></div>
|
||||
<div class="diff-row add"><span class="ln">3</span><span class="mark">+</span><span class="code">import type { Task } from '../types/task';</span></div>
|
||||
<div class="diff-row add"><span class="ln">4</span><span class="mark">+</span><span class="code"> </span></div>
|
||||
<div class="diff-row add"><span class="ln">5</span><span class="mark">+</span><span class="code">export function useOptimisticTasks(boardId: string) {</span></div>
|
||||
<div class="diff-row add"><span class="ln">6</span><span class="mark">+</span><span class="code"> const qc = useQueryClient();</span></div>
|
||||
<div class="diff-row add"><span class="ln">7</span><span class="mark">+</span><span class="code"> const key = ['tasks', boardId];</span></div>
|
||||
<div class="diff-row add"><span class="ln">8</span><span class="mark">+</span><span class="code"> </span></div>
|
||||
<div class="diff-row add"><span class="ln">9</span><span class="mark">+</span><span class="code"> return useMutation({</span></div>
|
||||
<div class="diff-row add"><span class="ln">10</span><span class="mark">+</span><span class="code"> mutationFn: (patch: TaskPatch) => updateTask(patch),</span></div>
|
||||
<div class="diff-row add"><span class="ln">11</span><span class="mark">+</span><span class="code"> onMutate: async (patch) => {</span></div>
|
||||
<div class="diff-row add"><span class="ln">12</span><span class="mark">+</span><span class="code"> const prev = qc.getQueryData<Task[]>(key);</span></div>
|
||||
<div class="diff-row add"><span class="ln">13</span><span class="mark">+</span><span class="code"> qc.setQueryData<Task[]>(key, (old = []) =></span></div>
|
||||
<div class="diff-row add"><span class="ln">14</span><span class="mark">+</span><span class="code"> old.map(t => t.id === patch.id ? { ...t, ...patch } : t)</span></div>
|
||||
<div class="diff-row add"><span class="ln">15</span><span class="mark">+</span><span class="code"> );</span></div>
|
||||
<div class="diff-row add"><span class="ln">16</span><span class="mark">+</span><span class="code"> return { prev };</span></div>
|
||||
<div class="diff-row add"><span class="ln">17</span><span class="mark">+</span><span class="code"> },</span></div>
|
||||
<div class="diff-row add"><span class="ln">18</span><span class="mark">+</span><span class="code"> onError: (_e, _p, ctx) => qc.setQueryData(key, ctx?.prev),</span></div>
|
||||
<div class="diff-row add"><span class="ln">19</span><span class="mark">+</span><span class="code"> });</span></div>
|
||||
<div class="diff-row add"><span class="ln">20</span><span class="mark">+</span><span class="code">}</span></div>
|
||||
</div>
|
||||
<div class="comments">
|
||||
<div class="bubble blocking">
|
||||
<div class="anchor">line 11</div>
|
||||
<p><span class="label">Blocking</span><code>onMutate</code> doesn't call <code>qc.cancelQueries(key)</code> first. If a background refetch lands between the optimistic write and the server response, it will clobber the optimistic state and the UI will flicker back to the old value.</p>
|
||||
</div>
|
||||
<div class="bubble nit">
|
||||
<div class="anchor">line 18</div>
|
||||
<p><span class="label">Nit</span>Rollback restores the list but never surfaces the error. Consider wiring the existing <code>pushToast</code> here so users know the toggle didn't stick.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- File 2: TaskList (medium) -->
|
||||
<article class="file-card" id="file-tasklist">
|
||||
<div class="file-head">
|
||||
<div>
|
||||
<div class="file-path">src/components/TaskList.tsx</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="risk-tag medium">worth a look</span>
|
||||
<span class="file-delta"><span class="add">+31</span> <span class="del">−24</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff">
|
||||
<div class="diff-row hunk"><span class="ln"></span><span class="mark"></span><span class="code">@@ -42,14 +42,17 @@ export function TaskList({ boardId }: Props) {</span></div>
|
||||
<div class="diff-row ctx"><span class="ln">42</span><span class="mark"> </span><span class="code"> const { data: tasks } = useTasks(boardId);</span></div>
|
||||
<div class="diff-row del"><span class="ln">43</span><span class="mark">-</span><span class="code"> const [pending, setPending] = useState<string | null>(null);</span></div>
|
||||
<div class="diff-row del"><span class="ln">44</span><span class="mark">-</span><span class="code"> </span></div>
|
||||
<div class="diff-row del"><span class="ln">45</span><span class="mark">-</span><span class="code"> async function toggle(task: Task) {</span></div>
|
||||
<div class="diff-row del"><span class="ln">46</span><span class="mark">-</span><span class="code"> setPending(task.id);</span></div>
|
||||
<div class="diff-row del"><span class="ln">47</span><span class="mark">-</span><span class="code"> await updateTask({ id: task.id, done: !task.done });</span></div>
|
||||
<div class="diff-row del"><span class="ln">48</span><span class="mark">-</span><span class="code"> await refetch();</span></div>
|
||||
<div class="diff-row del"><span class="ln">49</span><span class="mark">-</span><span class="code"> setPending(null);</span></div>
|
||||
<div class="diff-row del"><span class="ln">50</span><span class="mark">-</span><span class="code"> }</span></div>
|
||||
<div class="diff-row add"><span class="ln">43</span><span class="mark">+</span><span class="code"> const { mutate, isPending } = useOptimisticTasks(boardId);</span></div>
|
||||
<div class="diff-row add"><span class="ln">44</span><span class="mark">+</span><span class="code"> </span></div>
|
||||
<div class="diff-row add"><span class="ln">45</span><span class="mark">+</span><span class="code"> const toggle = (task: Task) =></span></div>
|
||||
<div class="diff-row add"><span class="ln">46</span><span class="mark">+</span><span class="code"> mutate({ id: task.id, done: !task.done });</span></div>
|
||||
<div class="diff-row ctx"><span class="ln">47</span><span class="mark"> </span><span class="code"> </span></div>
|
||||
<div class="diff-row ctx"><span class="ln">48</span><span class="mark"> </span><span class="code"> return (</span></div>
|
||||
<div class="diff-row ctx"><span class="ln">49</span><span class="mark"> </span><span class="code"> <ul className="tasks"></span></div>
|
||||
<div class="diff-row del"><span class="ln">50</span><span class="mark">-</span><span class="code"> {tasks?.map(t => <TaskRow key={t.id} task={t} busy={pending === t.id} />)}</span></div>
|
||||
<div class="diff-row add"><span class="ln">50</span><span class="mark">+</span><span class="code"> {tasks?.map(t => <TaskRow key={t.id} task={t} onToggle={toggle} />)}</span></div>
|
||||
<div class="diff-row ctx"><span class="ln">51</span><span class="mark"> </span><span class="code"> </ul></span></div>
|
||||
</div>
|
||||
<div class="comments">
|
||||
<div class="bubble nit">
|
||||
<div class="anchor">line 43</div>
|
||||
<p><span class="label">Nit</span><code>isPending</code> is destructured but never read. Either drop it or pass it to <code>TaskRow</code> so the checkbox can dim while the request is in flight.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- File 3: api/tasks (medium) -->
|
||||
<article class="file-card" id="file-api">
|
||||
<div class="file-head">
|
||||
<div>
|
||||
<div class="file-path">src/api/tasks.ts</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<span class="risk-tag medium">worth a look</span>
|
||||
<span class="file-delta"><span class="add">+19</span> <span class="del">−6</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="diff">
|
||||
<div class="diff-row hunk"><span class="ln"></span><span class="mark"></span><span class="code">@@ -12,10 +12,15 @@ export type TaskPatch = Partial<Task> & { id: string };</span></div>
|
||||
<div class="diff-row ctx"><span class="ln">12</span><span class="mark"> </span><span class="code"> </span></div>
|
||||
<div class="diff-row del"><span class="ln">13</span><span class="mark">-</span><span class="code">export async function updateTask(patch: TaskPatch) {</span></div>
|
||||
<div class="diff-row del"><span class="ln">14</span><span class="mark">-</span><span class="code"> return http.patch(`/tasks/${patch.id}`, patch);</span></div>
|
||||
<div class="diff-row add"><span class="ln">13</span><span class="mark">+</span><span class="code">export async function updateTask(</span></div>
|
||||
<div class="diff-row add"><span class="ln">14</span><span class="mark">+</span><span class="code"> patch: TaskPatch,</span></div>
|
||||
<div class="diff-row add"><span class="ln">15</span><span class="mark">+</span><span class="code"> key = crypto.randomUUID(),</span></div>
|
||||
<div class="diff-row add"><span class="ln">16</span><span class="mark">+</span><span class="code">) {</span></div>
|
||||
<div class="diff-row add"><span class="ln">17</span><span class="mark">+</span><span class="code"> return http.patch(`/tasks/${patch.id}`, patch, {</span></div>
|
||||
<div class="diff-row add"><span class="ln">18</span><span class="mark">+</span><span class="code"> headers: { 'Idempotency-Key': key },</span></div>
|
||||
<div class="diff-row add"><span class="ln">19</span><span class="mark">+</span><span class="code"> });</span></div>
|
||||
<div class="diff-row ctx"><span class="ln">20</span><span class="mark"> </span><span class="code">}</span></div>
|
||||
</div>
|
||||
<div class="comments">
|
||||
<div class="bubble blocking">
|
||||
<div class="anchor">line 15</div>
|
||||
<p><span class="label">Blocking</span>Generating the idempotency key as a default parameter means retries from the mutation layer get a <em>new</em> key each time, which defeats the purpose. The key should be minted once in <code>onMutate</code> and threaded through.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Collapsed files -->
|
||||
<details class="file-collapsed" id="file-toast">
|
||||
<summary>
|
||||
<span class="file-path">src/components/Toast.tsx</span>
|
||||
<span style="display:flex;align-items:center;gap:12px">
|
||||
<span class="risk-tag safe">safe</span>
|
||||
<span class="file-delta"><span class="add">+14</span> <span class="del">−2</span></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="body">Adds a <code style="font-family:var(--mono)">variant="warning"</code> style and exports <code style="font-family:var(--mono)">pushToast</code>. Purely additive, no behaviour change for existing call sites.</div>
|
||||
</details>
|
||||
|
||||
<details class="file-collapsed" id="file-types">
|
||||
<summary>
|
||||
<span class="file-path">src/types/task.ts</span>
|
||||
<span style="display:flex;align-items:center;gap:12px">
|
||||
<span class="risk-tag safe">safe</span>
|
||||
<span class="file-delta"><span class="add">+6</span> <span class="del">−2</span></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="body">Widens <code style="font-family:var(--mono)">Task.status</code> to include <code style="font-family:var(--mono)">"archived"</code> and adds an optional <code style="font-family:var(--mono)">updatedAt</code> timestamp. Type-only change.</div>
|
||||
</details>
|
||||
|
||||
<details class="file-collapsed" id="file-test">
|
||||
<summary>
|
||||
<span class="file-path">src/components/__tests__/TaskList.test.tsx</span>
|
||||
<span style="display:flex;align-items:center;gap:12px">
|
||||
<span class="risk-tag safe">safe</span>
|
||||
<span class="file-delta"><span class="add">+14</span> <span class="del">−4</span></span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="body">Adds a test asserting the row updates synchronously after click, and one asserting rollback when the mocked request rejects. Both pass locally.</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<footer class="next-steps">
|
||||
<h2>Suggested next steps</h2>
|
||||
<ul class="checklist">
|
||||
<li>
|
||||
<input type="checkbox" id="step1">
|
||||
<label for="step1">Add <code>await qc.cancelQueries(key)</code> at the top of <code>onMutate</code> in <code>useOptimisticTasks.ts</code>.</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="checkbox" id="step2">
|
||||
<label for="step2">Move idempotency-key generation into the mutation context so retries reuse the same key.</label>
|
||||
</li>
|
||||
<li>
|
||||
<input type="checkbox" id="step3">
|
||||
<label for="step3">Either consume <code>isPending</code> in <code>TaskRow</code> or remove it from the destructure to keep lint clean.</label>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Briefly highlight a file card when reached via the risk-map anchors.
|
||||
document.querySelectorAll('.risk-map a').forEach(function (a) {
|
||||
a.addEventListener('click', function () {
|
||||
var target = document.querySelector(a.getAttribute('href'));
|
||||
if (!target) return;
|
||||
target.style.transition = 'box-shadow 180ms ease';
|
||||
target.style.boxShadow = '0 0 0 3px rgba(217,119,87,0.35)';
|
||||
setTimeout(function () { target.style.boxShadow = 'none'; }, 1400);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,492 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>How authentication flows through acme/web</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--rust: #B04A3F;
|
||||
--gray-150: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
padding: 48px 24px 80px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.page { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
header {
|
||||
grid-column: 1 / -1;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.repo-line {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
line-height: 1.25;
|
||||
color: var(--slate);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
max-width: 760px;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
.summary code { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
color: var(--slate);
|
||||
margin: 36px 0 16px;
|
||||
}
|
||||
|
||||
/* ---------- Diagram ---------- */
|
||||
.diagram-panel {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
svg.flow { display: block; max-width: 100%; }
|
||||
.flow text {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
fill: var(--slate);
|
||||
}
|
||||
.flow .sub { font-size: 10px; fill: var(--gray-500); }
|
||||
.flow .box { fill: #fff; stroke: var(--gray-300); stroke-width: 1.5; }
|
||||
.flow .box.hot { fill: rgba(217,119,87,0.10); stroke: var(--clay); }
|
||||
.flow .arrow { stroke: var(--gray-500); stroke-width: 1.5; fill: none; }
|
||||
|
||||
/* ---------- Walkthrough ---------- */
|
||||
.step {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 18px;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1.5px solid var(--gray-150);
|
||||
}
|
||||
.step:last-of-type { border-bottom: none; }
|
||||
|
||||
.badge {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--oat);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
color: var(--slate);
|
||||
font-size: 14px;
|
||||
}
|
||||
.step.hot .badge {
|
||||
background: rgba(217,119,87,0.14);
|
||||
border-color: var(--clay);
|
||||
color: var(--clay);
|
||||
}
|
||||
|
||||
.step-loc {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--slate);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.step-loc .range { color: var(--gray-500); }
|
||||
|
||||
.step-body p { margin-bottom: 10px; }
|
||||
|
||||
details.snippet { margin-top: 6px; }
|
||||
details.snippet summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: var(--gray-500);
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
user-select: none;
|
||||
}
|
||||
details.snippet summary::-webkit-details-marker { display: none; }
|
||||
details.snippet summary::before {
|
||||
content: "▸";
|
||||
font-size: 10px;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
details.snippet[open] summary::before { transform: rotate(90deg); }
|
||||
|
||||
pre.code {
|
||||
background: var(--slate);
|
||||
color: #E8E6DC;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
pre.code .dim { color: var(--gray-500); }
|
||||
pre.code .kw { color: #C9B98A; }
|
||||
pre.code .str { color: #A8BC8C; }
|
||||
|
||||
/* ---------- Sidebar ---------- */
|
||||
aside {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.key-files { list-style: none; padding: 0; }
|
||||
.key-files li { margin-bottom: 12px; }
|
||||
.key-files li:last-child { margin-bottom: 0; }
|
||||
.key-files .path {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--slate);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.key-files .desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--gray-500);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.gotchas {
|
||||
border: 1.5px solid var(--clay);
|
||||
border-radius: 12px;
|
||||
background: rgba(217,119,87,0.06);
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.gotchas h3 {
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--clay);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.gotchas ul { list-style: none; padding: 0; }
|
||||
.gotchas li {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.gotchas li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 8px;
|
||||
width: 5px; height: 5px;
|
||||
background: var(--clay);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.gotchas code { font-family: var(--mono); font-size: 11.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<div class="repo-line">acme/web · architecture note</div>
|
||||
<h1>How authentication flows through the codebase</h1>
|
||||
<p class="summary">
|
||||
Acme uses cookie-based sessions: the browser never holds a bearer token directly. Every authenticated request hits <code>/api/*</code>, passes through a single <code>verifyToken()</code> middleware, and resolves to a <code>Session</code> row that downstream handlers read off <code>req.ctx</code>. The middleware is the only place that talks to the session store, which is the only place that talks to the <code>sessions</code> table — so there's exactly one trust boundary to reason about.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h2 style="margin-top:0">Request path</h2>
|
||||
<div class="diagram-panel">
|
||||
<svg class="flow" viewBox="0 0 720 280" width="720" height="280" role="img" aria-label="Authentication flow diagram">
|
||||
<defs>
|
||||
<marker id="arrowHead" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#87867F"></path>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<g>
|
||||
<rect class="box" x="30" y="40" width="150" height="64" rx="10"></rect>
|
||||
<text x="105" y="68" text-anchor="middle">Browser</text>
|
||||
<text class="sub" x="105" y="84" text-anchor="middle">acme.app</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect class="box" x="245" y="40" width="150" height="64" rx="10"></rect>
|
||||
<text x="320" y="68" text-anchor="middle">/api/session</text>
|
||||
<text class="sub" x="320" y="84" text-anchor="middle">route handler</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect class="box hot" x="460" y="40" width="180" height="64" rx="10"></rect>
|
||||
<text x="550" y="68" text-anchor="middle">verifyToken()</text>
|
||||
<text class="sub" x="550" y="84" text-anchor="middle">middleware/auth.ts</text>
|
||||
</g>
|
||||
|
||||
<!-- Row 2 -->
|
||||
<g>
|
||||
<rect class="box" x="460" y="170" width="180" height="64" rx="10"></rect>
|
||||
<text x="550" y="198" text-anchor="middle">SessionStore</text>
|
||||
<text class="sub" x="550" y="214" text-anchor="middle">lib/sessionStore.ts</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect class="box" x="245" y="170" width="150" height="64" rx="10"></rect>
|
||||
<text x="320" y="198" text-anchor="middle">Postgres</text>
|
||||
<text class="sub" x="320" y="214" text-anchor="middle">sessions table</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrows -->
|
||||
<line class="arrow" x1="180" y1="72" x2="245" y2="72" marker-end="url(#arrowHead)"></line>
|
||||
<line class="arrow" x1="395" y1="72" x2="460" y2="72" marker-end="url(#arrowHead)"></line>
|
||||
<line class="arrow" x1="550" y1="104" x2="550" y2="170" marker-end="url(#arrowHead)"></line>
|
||||
<line class="arrow" x1="460" y1="202" x2="395" y2="202" marker-end="url(#arrowHead)"></line>
|
||||
|
||||
<text class="sub" x="212" y="62" text-anchor="middle">cookie</text>
|
||||
<text class="sub" x="560" y="140" text-anchor="start">lookup</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2>Callstack walkthrough</h2>
|
||||
|
||||
<!-- Step 1 -->
|
||||
<div class="step">
|
||||
<div class="badge">1</div>
|
||||
<div class="step-body">
|
||||
<div class="step-loc">src/app/providers/AuthProvider.tsx<span class="range"> :22-48</span></div>
|
||||
<p>On mount, the React provider issues a <code style="font-family:var(--mono);font-size:12.5px">GET /api/session</code> with <code style="font-family:var(--mono);font-size:12.5px">credentials: 'include'</code> so the <code style="font-family:var(--mono);font-size:12.5px">fw_sid</code> cookie rides along. The response either hydrates <code style="font-family:var(--mono);font-size:12.5px">currentUser</code> into context or leaves it <code style="font-family:var(--mono);font-size:12.5px">null</code>, which the router treats as "show the sign-in screen".</p>
|
||||
<details class="snippet">
|
||||
<summary>show source</summary>
|
||||
<pre class="code"><span class="dim">// src/app/providers/AuthProvider.tsx</span>
|
||||
<span class="kw">export function</span> AuthProvider({ children }: Props) {
|
||||
<span class="kw">const</span> [user, setUser] = useState<User | <span class="kw">null</span>>(<span class="kw">null</span>);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(<span class="str">'/api/session'</span>, { credentials: <span class="str">'include'</span> })
|
||||
.then(r => r.ok ? r.json() : <span class="kw">null</span>)
|
||||
.then(setUser);
|
||||
}, []);
|
||||
|
||||
<span class="kw">return</span> <AuthCtx.Provider value={{ user }}>{children}</AuthCtx.Provider>;
|
||||
}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="step">
|
||||
<div class="badge">2</div>
|
||||
<div class="step-body">
|
||||
<div class="step-loc">src/server/routes/session.ts<span class="range"> :9-27</span></div>
|
||||
<p>The route itself is thin: it just returns whatever <code style="font-family:var(--mono);font-size:12.5px">req.ctx.session</code> the middleware attached. If the middleware short-circuited with a 401, this handler never runs — so there's no auth logic duplicated here.</p>
|
||||
<details class="snippet">
|
||||
<summary>show source</summary>
|
||||
<pre class="code"><span class="dim">// src/server/routes/session.ts</span>
|
||||
router.get(<span class="str">'/session'</span>, verifyToken, (req, res) => {
|
||||
<span class="kw">const</span> { session } = req.ctx;
|
||||
res.json({
|
||||
id: session.userId,
|
||||
email: session.email,
|
||||
role: session.role,
|
||||
exp: session.expiresAt,
|
||||
});
|
||||
});</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="step hot">
|
||||
<div class="badge">3</div>
|
||||
<div class="step-body">
|
||||
<div class="step-loc">src/middleware/auth.ts<span class="range"> :14-31</span></div>
|
||||
<p>This is the trust boundary. <code style="font-family:var(--mono);font-size:12.5px">verifyToken</code> reads the signed <code style="font-family:var(--mono);font-size:12.5px">fw_sid</code> cookie, asks <code style="font-family:var(--mono);font-size:12.5px">SessionStore</code> to resolve it, and either populates <code style="font-family:var(--mono);font-size:12.5px">req.ctx.session</code> or responds 401. Every protected route in the app is mounted behind this function, so changing its behaviour changes auth globally.</p>
|
||||
<details class="snippet" open>
|
||||
<summary>show source</summary>
|
||||
<pre class="code"><span class="dim">// src/middleware/auth.ts</span>
|
||||
<span class="kw">export async function</span> verifyToken(req, res, next) {
|
||||
<span class="kw">const</span> raw = req.signedCookies[<span class="str">'fw_sid'</span>];
|
||||
<span class="kw">if</span> (!raw) <span class="kw">return</span> res.status(401).end();
|
||||
|
||||
<span class="kw">const</span> session = <span class="kw">await</span> SessionStore.get(raw);
|
||||
<span class="kw">if</span> (!session || session.expiresAt < Date.now()) {
|
||||
<span class="kw">return</span> res.status(401).end();
|
||||
}
|
||||
|
||||
req.ctx = { session };
|
||||
next();
|
||||
}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4 -->
|
||||
<div class="step">
|
||||
<div class="badge">4</div>
|
||||
<div class="step-body">
|
||||
<div class="step-loc">src/lib/sessionStore.ts<span class="range"> :8-52</span></div>
|
||||
<p><code style="font-family:var(--mono);font-size:12.5px">SessionStore</code> is a small read-through cache: it checks an in-process LRU first, then falls back to Postgres. Writes (<code style="font-family:var(--mono);font-size:12.5px">create</code>, <code style="font-family:var(--mono);font-size:12.5px">revoke</code>) always go straight to the DB and invalidate the cache entry so other workers don't serve a stale session.</p>
|
||||
<details class="snippet">
|
||||
<summary>show source</summary>
|
||||
<pre class="code"><span class="dim">// src/lib/sessionStore.ts</span>
|
||||
<span class="kw">const</span> cache = <span class="kw">new</span> LRU<string, Session>({ max: 5000, ttl: 60_000 });
|
||||
|
||||
<span class="kw">export const</span> SessionStore = {
|
||||
<span class="kw">async</span> get(id: string) {
|
||||
<span class="kw">const</span> hit = cache.get(id);
|
||||
<span class="kw">if</span> (hit) <span class="kw">return</span> hit;
|
||||
<span class="kw">const</span> row = <span class="kw">await</span> db.one(SELECT_SESSION, [id]);
|
||||
<span class="kw">if</span> (row) cache.set(id, row);
|
||||
<span class="kw">return</span> row ?? <span class="kw">null</span>;
|
||||
},
|
||||
<span class="dim">/* create, revoke, touch ... */</span>
|
||||
};</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5 -->
|
||||
<div class="step">
|
||||
<div class="badge">5</div>
|
||||
<div class="step-body">
|
||||
<div class="step-loc">db/migrations/004_sessions.sql<span class="range"> :1-18</span></div>
|
||||
<p>The <code style="font-family:var(--mono);font-size:12.5px">sessions</code> table is keyed on a random 32-byte id (the cookie value) with a covering index on <code style="font-family:var(--mono);font-size:12.5px">user_id</code> for "sign out everywhere". Expiry is enforced both here (<code style="font-family:var(--mono);font-size:12.5px">expires_at</code>) and again in the middleware as defence in depth.</p>
|
||||
<details class="snippet">
|
||||
<summary>show source</summary>
|
||||
<pre class="code"><span class="dim">-- db/migrations/004_sessions.sql</span>
|
||||
<span class="kw">create table</span> sessions (
|
||||
id <span class="kw">text primary key</span>,
|
||||
user_id <span class="kw">uuid not null references</span> users(id),
|
||||
created_at <span class="kw">timestamptz default</span> now(),
|
||||
expires_at <span class="kw">timestamptz not null</span>,
|
||||
ip <span class="kw">inet</span>,
|
||||
user_agent <span class="kw">text</span>
|
||||
);
|
||||
<span class="kw">create index</span> sessions_user_id_idx <span class="kw">on</span> sessions(user_id);</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside>
|
||||
<div class="panel">
|
||||
<h3>Key files</h3>
|
||||
<ul class="key-files">
|
||||
<li>
|
||||
<span class="path">src/middleware/auth.ts</span>
|
||||
<span class="desc">Single entry point for request authentication.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="path">src/lib/sessionStore.ts</span>
|
||||
<span class="desc">LRU + Postgres session lookup; only DB caller.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="path">src/server/routes/session.ts</span>
|
||||
<span class="desc">Returns the current session to the client.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="path">src/server/routes/login.ts</span>
|
||||
<span class="desc">Exchanges credentials for a cookie via SessionStore.create.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="path">src/app/providers/AuthProvider.tsx</span>
|
||||
<span class="desc">Client-side context that mirrors the server session.</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="path">db/migrations/004_sessions.sql</span>
|
||||
<span class="desc">Schema for the sessions table and indexes.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="gotchas">
|
||||
<h3>Gotchas</h3>
|
||||
<ul>
|
||||
<li>The LRU in <code>SessionStore</code> is per-process. Revoking a session only clears the local cache — other workers may serve it for up to 60s until their TTL lapses.</li>
|
||||
<li><code>verifyToken</code> compares <code>expiresAt</code> against <code>Date.now()</code>, but the column is <code>timestamptz</code>. The driver returns a <code>Date</code>, so the comparison works, but don't refactor it to a raw string without adjusting the check.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Keep at most one snippet open at a time so the walkthrough stays scannable.
|
||||
var snippets = document.querySelectorAll('details.snippet');
|
||||
snippets.forEach(function (d) {
|
||||
d.addEventListener('toggle', function () {
|
||||
if (!d.open) return;
|
||||
snippets.forEach(function (other) {
|
||||
if (other !== d) other.open = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,630 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Acme — Design System Reference</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-100: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, serif;
|
||||
--sans: system-ui, -apple-system, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
--radius-panel: 12px;
|
||||
--radius-row: 8px;
|
||||
--border: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 56px 24px 96px;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 40px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
header .sub {
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
margin: 0 0 48px;
|
||||
}
|
||||
header .sub code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--gray-100);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
section { margin-bottom: 64px; }
|
||||
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 26px;
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
hr.rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-300);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
/* ---------- Color ---------- */
|
||||
.swatch-group {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.swatch-group-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.swatch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 20px 16px;
|
||||
}
|
||||
.swatch {}
|
||||
.swatch .chip {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-row);
|
||||
border: var(--border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swatch .chip.no-border { border-color: transparent; }
|
||||
.swatch .hex {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-700);
|
||||
display: block;
|
||||
}
|
||||
.swatch .token {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- Typography ---------- */
|
||||
.type-scale {
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
background: var(--white);
|
||||
overflow: hidden;
|
||||
}
|
||||
.type-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
.type-row:last-child { border-bottom: none; }
|
||||
.type-specimen {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--slate);
|
||||
}
|
||||
.type-meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.type-meta .name {
|
||||
color: var(--gray-700);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.t-display { font-family: var(--serif); font-size: 48px; line-height: 1.1; font-weight: 500; letter-spacing: -0.02em; }
|
||||
.t-h1 { font-family: var(--serif); font-size: 32px; line-height: 1.2; font-weight: 500; letter-spacing: -0.01em; }
|
||||
.t-h2 { font-family: var(--serif); font-size: 24px; line-height: 1.3; font-weight: 500; }
|
||||
.t-body { font-family: var(--sans); font-size: 16px; line-height: 1.55; font-weight: 430; }
|
||||
.t-small { font-family: var(--sans); font-size: 14px; line-height: 1.5; font-weight: 430; }
|
||||
.t-caption { font-family: var(--sans); font-size: 12px; line-height: 1.4; font-weight: 500; color: var(--gray-500); }
|
||||
|
||||
/* ---------- Spacing ---------- */
|
||||
.spacing-ruler {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 28px;
|
||||
padding: 28px 24px;
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.space-token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.space-bar {
|
||||
background: var(--clay);
|
||||
border-radius: 3px;
|
||||
height: 14px;
|
||||
}
|
||||
.space-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-700);
|
||||
text-align: center;
|
||||
}
|
||||
.space-label span {
|
||||
display: block;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* ---------- Radius & Elevation ---------- */
|
||||
.token-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.radius-card {
|
||||
width: 120px;
|
||||
height: 88px;
|
||||
background: var(--oat);
|
||||
border: var(--border);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.radius-card .lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.radius-card .lbl span { color: var(--gray-500); display: block; }
|
||||
|
||||
.shadow-card {
|
||||
width: 160px;
|
||||
height: 96px;
|
||||
background: var(--white);
|
||||
border-radius: var(--radius-panel);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.shadow-card .lbl {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.shadow-card .lbl span { color: var(--gray-500); display: block; }
|
||||
|
||||
/* ---------- Core components ---------- */
|
||||
.component {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.component-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.component-stage {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-row);
|
||||
border: 1.5px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.btn-primary { background: var(--clay); color: var(--white); }
|
||||
.btn-primary:hover { background: #C7684C; }
|
||||
.btn-secondary { background: var(--white); color: var(--slate); border-color: var(--gray-300); }
|
||||
.btn-secondary:hover { background: var(--gray-100); }
|
||||
.btn-ghost { background: transparent; color: var(--gray-700); }
|
||||
.btn-ghost:hover { background: var(--gray-100); }
|
||||
.btn-danger { background: #B04A4A; color: var(--white); }
|
||||
.btn-danger:hover { background: #9A3F3F; }
|
||||
|
||||
/* Input */
|
||||
.input {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
color: var(--slate);
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-row);
|
||||
width: 260px;
|
||||
outline: none;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.input::placeholder { color: var(--gray-500); }
|
||||
.input:focus {
|
||||
border-color: var(--clay);
|
||||
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.15);
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
.checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.checkbox input {
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--white);
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.checkbox input:checked {
|
||||
background: var(--clay);
|
||||
border-color: var(--clay);
|
||||
}
|
||||
.checkbox input:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px; top: 1px;
|
||||
width: 5px; height: 10px;
|
||||
border: solid var(--white);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.badge-neutral { background: var(--gray-100); color: var(--gray-700); }
|
||||
.badge-accent { background: rgba(217, 119, 87, 0.14); color: var(--clay); }
|
||||
.badge-success { background: rgba(120, 140, 93, 0.16); color: var(--olive); }
|
||||
.badge-warning { background: rgba(199, 142, 63, 0.16); color: #A06A2A; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Acme design system</h1>
|
||||
<p class="sub">
|
||||
Generated from <code>src/styles/tokens.ts</code> and <code>src/components/</code> — use as a portable reference when prompting.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ===================== COLOR ===================== -->
|
||||
<section id="color">
|
||||
<h2>Color</h2>
|
||||
<hr class="rule">
|
||||
|
||||
<div class="swatch-group">
|
||||
<div class="swatch-group-label">Primary</div>
|
||||
<div class="swatch-grid">
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#D97757"></div>
|
||||
<span class="hex">#D97757</span>
|
||||
<span class="token">--clay</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#141413"></div>
|
||||
<span class="hex">#141413</span>
|
||||
<span class="token">--slate</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip" style="background:#FAF9F5"></div>
|
||||
<span class="hex">#FAF9F5</span>
|
||||
<span class="token">--ivory</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#E3DACC"></div>
|
||||
<span class="hex">#E3DACC</span>
|
||||
<span class="token">--oat</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="swatch-group">
|
||||
<div class="swatch-group-label">Neutral</div>
|
||||
<div class="swatch-grid">
|
||||
<div class="swatch">
|
||||
<div class="chip" style="background:#FFFFFF"></div>
|
||||
<span class="hex">#FFFFFF</span>
|
||||
<span class="token">--white</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip" style="background:#F0EEE6"></div>
|
||||
<span class="hex">#F0EEE6</span>
|
||||
<span class="token">--gray-100</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#D1CFC5"></div>
|
||||
<span class="hex">#D1CFC5</span>
|
||||
<span class="token">--gray-300</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#87867F"></div>
|
||||
<span class="hex">#87867F</span>
|
||||
<span class="token">--gray-500</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#3D3D3A"></div>
|
||||
<span class="hex">#3D3D3A</span>
|
||||
<span class="token">--gray-700</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="swatch-group">
|
||||
<div class="swatch-group-label">Semantic</div>
|
||||
<div class="swatch-grid">
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#788C5D"></div>
|
||||
<span class="hex">#788C5D</span>
|
||||
<span class="token">--success</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#C78E3F"></div>
|
||||
<span class="hex">#C78E3F</span>
|
||||
<span class="token">--warning</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#B04A4A"></div>
|
||||
<span class="hex">#B04A4A</span>
|
||||
<span class="token">--danger</span>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="chip no-border" style="background:#5C7CA3"></div>
|
||||
<span class="hex">#5C7CA3</span>
|
||||
<span class="token">--info</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== TYPOGRAPHY ===================== -->
|
||||
<section id="typography">
|
||||
<h2>Typography</h2>
|
||||
<hr class="rule">
|
||||
|
||||
<div class="type-scale">
|
||||
<div class="type-row">
|
||||
<div class="type-specimen t-display">Plan the week ahead</div>
|
||||
<div class="type-meta">
|
||||
<span class="name">Display</span>
|
||||
48 / 1.1 / 500
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-row">
|
||||
<div class="type-specimen t-h1">Plan the week ahead</div>
|
||||
<div class="type-meta">
|
||||
<span class="name">Heading 1</span>
|
||||
32 / 1.2 / 500
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-row">
|
||||
<div class="type-specimen t-h2">Plan the week ahead</div>
|
||||
<div class="type-meta">
|
||||
<span class="name">Heading 2</span>
|
||||
24 / 1.3 / 500
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-row">
|
||||
<div class="type-specimen t-body">Review milestones, assign owners, and surface blockers before they cascade.</div>
|
||||
<div class="type-meta">
|
||||
<span class="name">Body</span>
|
||||
16 / 1.55 / 430
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-row">
|
||||
<div class="type-specimen t-small">Review milestones, assign owners, and surface blockers before they cascade.</div>
|
||||
<div class="type-meta">
|
||||
<span class="name">Small</span>
|
||||
14 / 1.5 / 430
|
||||
</div>
|
||||
</div>
|
||||
<div class="type-row">
|
||||
<div class="type-specimen t-caption">UPDATED 2 HOURS AGO</div>
|
||||
<div class="type-meta">
|
||||
<span class="name">Caption</span>
|
||||
12 / 1.4 / 500
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== SPACING ===================== -->
|
||||
<section id="spacing">
|
||||
<h2>Spacing</h2>
|
||||
<hr class="rule">
|
||||
|
||||
<div class="spacing-ruler">
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:4px"></div>
|
||||
<div class="space-label">4<span>--sp-1</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:8px"></div>
|
||||
<div class="space-label">8<span>--sp-2</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:12px"></div>
|
||||
<div class="space-label">12<span>--sp-3</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:16px"></div>
|
||||
<div class="space-label">16<span>--sp-4</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:24px"></div>
|
||||
<div class="space-label">24<span>--sp-5</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:32px"></div>
|
||||
<div class="space-label">32<span>--sp-6</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:48px"></div>
|
||||
<div class="space-label">48<span>--sp-7</span></div>
|
||||
</div>
|
||||
<div class="space-token">
|
||||
<div class="space-bar" style="width:64px"></div>
|
||||
<div class="space-label">64<span>--sp-8</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== RADIUS & ELEVATION ===================== -->
|
||||
<section id="shape">
|
||||
<h2>Radius & Elevation</h2>
|
||||
<hr class="rule">
|
||||
|
||||
<div class="token-row">
|
||||
<div class="radius-card" style="border-radius:4px">
|
||||
<div class="lbl">4px<span>--r-xs</span></div>
|
||||
</div>
|
||||
<div class="radius-card" style="border-radius:8px">
|
||||
<div class="lbl">8px<span>--r-sm</span></div>
|
||||
</div>
|
||||
<div class="radius-card" style="border-radius:12px">
|
||||
<div class="lbl">12px<span>--r-md</span></div>
|
||||
</div>
|
||||
<div class="radius-card" style="border-radius:20px">
|
||||
<div class="lbl">20px<span>--r-lg</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="token-row">
|
||||
<div class="shadow-card" style="box-shadow:0 1px 2px rgba(20,20,19,0.06)">
|
||||
<div class="lbl">--shadow-sm<span>0 1px 2px / 6%</span></div>
|
||||
</div>
|
||||
<div class="shadow-card" style="box-shadow:0 4px 10px rgba(20,20,19,0.08)">
|
||||
<div class="lbl">--shadow-md<span>0 4px 10px / 8%</span></div>
|
||||
</div>
|
||||
<div class="shadow-card" style="box-shadow:0 12px 28px rgba(20,20,19,0.12)">
|
||||
<div class="lbl">--shadow-lg<span>0 12px 28px / 12%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== CORE COMPONENTS ===================== -->
|
||||
<section id="components">
|
||||
<h2>Core components</h2>
|
||||
<hr class="rule">
|
||||
|
||||
<div class="component">
|
||||
<div class="component-name"><Button /></div>
|
||||
<div class="component-stage">
|
||||
<button class="btn btn-primary">Create task</button>
|
||||
<button class="btn btn-secondary">Cancel</button>
|
||||
<button class="btn btn-ghost">Skip</button>
|
||||
<button class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<div class="component-name"><Input /></div>
|
||||
<div class="component-stage">
|
||||
<input class="input" type="text" placeholder="Search tasks…">
|
||||
<input class="input" type="text" value="Weekly planning">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<div class="component-name"><Checkbox /></div>
|
||||
<div class="component-stage">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox">
|
||||
Notify assignees
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" checked>
|
||||
Archive on complete
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component">
|
||||
<div class="component-name"><Badge /></div>
|
||||
<div class="component-stage">
|
||||
<span class="badge badge-neutral">Draft</span>
|
||||
<span class="badge badge-accent">In review</span>
|
||||
<span class="badge badge-success">Done</span>
|
||||
<span class="badge badge-warning">Overdue</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,606 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Acme — Card Variant Matrix</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-100: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, serif;
|
||||
--sans: system-ui, -apple-system, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* live-controlled tokens */
|
||||
--card-pad: 20px;
|
||||
--card-border: 1.5px solid var(--gray-300);
|
||||
--card-shadow: 0 4px 14px rgba(20, 20, 19, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 28px 80px;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 48px 0 20px;
|
||||
}
|
||||
header h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 36px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
header p {
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* ---------- Toolbar ---------- */
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0 36px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.control-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.control-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
appearance: none;
|
||||
width: 140px;
|
||||
height: 4px;
|
||||
background: var(--gray-300);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 16px; height: 16px;
|
||||
background: var(--clay);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px; height: 16px;
|
||||
background: var(--clay);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: inline-flex;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.radio-group label {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--gray-700);
|
||||
border-right: 1px solid var(--gray-300);
|
||||
}
|
||||
.radio-group label:last-child { border-right: none; }
|
||||
.radio-group input { display: none; }
|
||||
.radio-group input:checked + span {
|
||||
color: var(--slate);
|
||||
}
|
||||
.radio-group label:has(input:checked) {
|
||||
background: var(--gray-100);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.check input {
|
||||
appearance: none;
|
||||
width: 16px; height: 16px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.check input:checked {
|
||||
background: var(--clay);
|
||||
border-color: var(--clay);
|
||||
}
|
||||
.check input:checked::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px; top: 0;
|
||||
width: 4px; height: 9px;
|
||||
border: solid var(--white);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
/* ---------- Grid ---------- */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.cell {}
|
||||
.variant-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
background: var(--gray-100);
|
||||
border-radius: 999px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.variant-note {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ---------- Card base ---------- */
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
padding: var(--card-pad);
|
||||
transition: padding 0.15s ease, box-shadow 0.15s ease, border 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card:hover { outline: 2px solid var(--clay); outline-offset: 2px; }
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.avatar {
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--oat);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.card-titles { min-width: 0; }
|
||||
.card-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 2px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.card-sub {
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
border-radius: 999px;
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.chip.olive { background: rgba(120,140,93,0.16); color: var(--olive); }
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gray-700);
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ghost-btn:hover { background: var(--gray-100); }
|
||||
|
||||
/* ---------- Variant treatments ---------- */
|
||||
.v-flat {
|
||||
background: var(--white);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.v-outlined {
|
||||
background: var(--white);
|
||||
border: var(--card-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.v-elevated {
|
||||
background: var(--white);
|
||||
border: none;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.v-stripe {
|
||||
background: var(--white);
|
||||
border: var(--card-border);
|
||||
box-shadow: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.v-stripe::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 4px;
|
||||
background: var(--clay);
|
||||
}
|
||||
.v-inset {
|
||||
background: var(--oat);
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.v-inset .chip { background: rgba(255,255,255,0.6); }
|
||||
.v-inset .ghost-btn { border-color: rgba(20,20,19,0.15); }
|
||||
.v-inset .avatar { background: var(--white); }
|
||||
|
||||
.v-horizontal {
|
||||
background: var(--white);
|
||||
border: var(--card-border);
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.v-horizontal .card-head { margin: 0; flex: 1; min-width: 0; }
|
||||
.v-horizontal .chips { margin: 0; }
|
||||
.v-horizontal .card-sub { display: none; }
|
||||
.v-horizontal .ghost-btn { flex-shrink: 0; }
|
||||
|
||||
/* ---------- Snippet panel ---------- */
|
||||
.snippet-panel {
|
||||
margin-top: 40px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.snippet-head {
|
||||
padding: 10px 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
border-bottom: 1px solid var(--gray-300);
|
||||
}
|
||||
pre#snippet {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
min-height: 120px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Card variant matrix</h1>
|
||||
<p>Six structural treatments of the Acme <code style="font-family:var(--mono);font-size:13px"><Card /></code> component. Adjust density and emphasis with the controls, hover a variant to see its prop combo.</p>
|
||||
</header>
|
||||
|
||||
<!-- ---------- Toolbar ---------- -->
|
||||
<div class="toolbar">
|
||||
<div class="control">
|
||||
<span class="control-label">Padding</span>
|
||||
<input id="ctl-pad" type="range" min="12" max="32" value="20" step="2">
|
||||
<span class="control-value" id="pad-out">20px</span>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<span class="control-label">Border</span>
|
||||
<div class="radio-group">
|
||||
<label><input type="radio" name="border" value="none"><span>none</span></label>
|
||||
<label><input type="radio" name="border" value="hairline" checked><span>hairline</span></label>
|
||||
<label><input type="radio" name="border" value="solid"><span>solid</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<label class="check">
|
||||
<input id="ctl-shadow" type="checkbox" checked>
|
||||
Show shadow
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ---------- Grid ---------- -->
|
||||
<div class="grid" id="grid">
|
||||
|
||||
<!-- A: Flat -->
|
||||
<div class="cell">
|
||||
<span class="variant-label">A · Flat</span>
|
||||
<div class="card v-flat"
|
||||
data-snippet='<Card variant="flat" padding={PAD}>
|
||||
<Card.Header avatar title="Weekly planning" subtitle="12 tasks · due Friday" />
|
||||
<Card.Meta tags={["Q2", "Roadmap"]} />
|
||||
<Button variant="ghost">Open</Button>
|
||||
</Card>'>
|
||||
<div class="card-head">
|
||||
<div class="avatar">WP</div>
|
||||
<div class="card-titles">
|
||||
<p class="card-title">Weekly planning</p>
|
||||
<p class="card-sub">12 tasks · due Friday</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Q2</span>
|
||||
<span class="chip olive">Roadmap</span>
|
||||
</div>
|
||||
<button class="ghost-btn">Open</button>
|
||||
</div>
|
||||
<p class="variant-note">best for: dense lists on tinted backgrounds</p>
|
||||
</div>
|
||||
|
||||
<!-- B: Outlined -->
|
||||
<div class="cell">
|
||||
<span class="variant-label">B · Outlined</span>
|
||||
<div class="card v-outlined"
|
||||
data-snippet='<Card variant="outlined" padding={PAD} border="BORDER">
|
||||
<Card.Header avatar title="Weekly planning" subtitle="12 tasks · due Friday" />
|
||||
<Card.Meta tags={["Q2", "Roadmap"]} />
|
||||
<Button variant="ghost">Open</Button>
|
||||
</Card>'>
|
||||
<div class="card-head">
|
||||
<div class="avatar">WP</div>
|
||||
<div class="card-titles">
|
||||
<p class="card-title">Weekly planning</p>
|
||||
<p class="card-sub">12 tasks · due Friday</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Q2</span>
|
||||
<span class="chip olive">Roadmap</span>
|
||||
</div>
|
||||
<button class="ghost-btn">Open</button>
|
||||
</div>
|
||||
<p class="variant-note">best for: default content cards on ivory</p>
|
||||
</div>
|
||||
|
||||
<!-- C: Elevated -->
|
||||
<div class="cell">
|
||||
<span class="variant-label">C · Elevated</span>
|
||||
<div class="card v-elevated"
|
||||
data-snippet='<Card variant="elevated" padding={PAD} shadow="md">
|
||||
<Card.Header avatar title="Weekly planning" subtitle="12 tasks · due Friday" />
|
||||
<Card.Meta tags={["Q2", "Roadmap"]} />
|
||||
<Button variant="ghost">Open</Button>
|
||||
</Card>'>
|
||||
<div class="card-head">
|
||||
<div class="avatar">WP</div>
|
||||
<div class="card-titles">
|
||||
<p class="card-title">Weekly planning</p>
|
||||
<p class="card-sub">12 tasks · due Friday</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Q2</span>
|
||||
<span class="chip olive">Roadmap</span>
|
||||
</div>
|
||||
<button class="ghost-btn">Open</button>
|
||||
</div>
|
||||
<p class="variant-note">best for: draggable items, popovers</p>
|
||||
</div>
|
||||
|
||||
<!-- D: Accent stripe -->
|
||||
<div class="cell">
|
||||
<span class="variant-label">D · Accent stripe</span>
|
||||
<div class="card v-stripe"
|
||||
data-snippet='<Card variant="outlined" accent="clay" padding={PAD} border="BORDER">
|
||||
<Card.Header avatar title="Weekly planning" subtitle="12 tasks · due Friday" />
|
||||
<Card.Meta tags={["Q2", "Roadmap"]} />
|
||||
<Button variant="ghost">Open</Button>
|
||||
</Card>'>
|
||||
<div class="card-head">
|
||||
<div class="avatar">WP</div>
|
||||
<div class="card-titles">
|
||||
<p class="card-title">Weekly planning</p>
|
||||
<p class="card-sub">12 tasks · due Friday</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Q2</span>
|
||||
<span class="chip olive">Roadmap</span>
|
||||
</div>
|
||||
<button class="ghost-btn">Open</button>
|
||||
</div>
|
||||
<p class="variant-note">best for: pinned or priority items</p>
|
||||
</div>
|
||||
|
||||
<!-- E: Inset / filled -->
|
||||
<div class="cell">
|
||||
<span class="variant-label">E · Inset</span>
|
||||
<div class="card v-inset"
|
||||
data-snippet='<Card variant="filled" tone="oat" padding={PAD}>
|
||||
<Card.Header avatar title="Weekly planning" subtitle="12 tasks · due Friday" />
|
||||
<Card.Meta tags={["Q2", "Roadmap"]} />
|
||||
<Button variant="ghost">Open</Button>
|
||||
</Card>'>
|
||||
<div class="card-head">
|
||||
<div class="avatar">WP</div>
|
||||
<div class="card-titles">
|
||||
<p class="card-title">Weekly planning</p>
|
||||
<p class="card-sub">12 tasks · due Friday</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Q2</span>
|
||||
<span class="chip olive">Roadmap</span>
|
||||
</div>
|
||||
<button class="ghost-btn">Open</button>
|
||||
</div>
|
||||
<p class="variant-note">best for: nested cards inside white panels</p>
|
||||
</div>
|
||||
|
||||
<!-- F: Horizontal / compact -->
|
||||
<div class="cell">
|
||||
<span class="variant-label">F · Horizontal</span>
|
||||
<div class="card v-horizontal"
|
||||
data-snippet='<Card variant="outlined" layout="row" padding={PAD} border="BORDER">
|
||||
<Card.Header avatar title="Weekly planning" />
|
||||
<Card.Meta tags={["Q2", "Roadmap"]} />
|
||||
<Button variant="ghost">Open</Button>
|
||||
</Card>'>
|
||||
<div class="card-head">
|
||||
<div class="avatar">WP</div>
|
||||
<div class="card-titles">
|
||||
<p class="card-title">Weekly planning</p>
|
||||
<p class="card-sub">12 tasks · due Friday</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip">Q2</span>
|
||||
<span class="chip olive">Roadmap</span>
|
||||
</div>
|
||||
<button class="ghost-btn">Open</button>
|
||||
</div>
|
||||
<p class="variant-note">best for: compact row lists, sidebars</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ---------- Snippet ---------- -->
|
||||
<div class="snippet-panel">
|
||||
<div class="snippet-head">JSX — hover a variant above</div>
|
||||
<pre id="snippet">// hover a card to preview its props</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
var pad = document.getElementById('ctl-pad');
|
||||
var padOut = document.getElementById('pad-out');
|
||||
var shadow = document.getElementById('ctl-shadow');
|
||||
var snippet = document.getElementById('snippet');
|
||||
var currentBorder = 'hairline';
|
||||
|
||||
var borderMap = {
|
||||
none: 'none',
|
||||
hairline: '1px solid var(--gray-300)',
|
||||
solid: '1.5px solid var(--gray-300)'
|
||||
};
|
||||
|
||||
function applyPad() {
|
||||
root.style.setProperty('--card-pad', pad.value + 'px');
|
||||
padOut.textContent = pad.value + 'px';
|
||||
}
|
||||
function applyBorder(v) {
|
||||
currentBorder = v;
|
||||
root.style.setProperty('--card-border', borderMap[v]);
|
||||
}
|
||||
function applyShadow() {
|
||||
root.style.setProperty('--card-shadow',
|
||||
shadow.checked ? '0 4px 14px rgba(20,20,19,0.08)' : 'none');
|
||||
}
|
||||
|
||||
pad.addEventListener('input', applyPad);
|
||||
shadow.addEventListener('change', applyShadow);
|
||||
document.querySelectorAll('input[name="border"]').forEach(function (r) {
|
||||
r.addEventListener('change', function () { applyBorder(r.value); });
|
||||
});
|
||||
|
||||
document.querySelectorAll('.card').forEach(function (card) {
|
||||
card.addEventListener('mouseenter', function () {
|
||||
var raw = card.getAttribute('data-snippet') || '';
|
||||
snippet.textContent = raw
|
||||
.replace(/PAD/g, pad.value)
|
||||
.replace(/BORDER/g, currentBorder);
|
||||
});
|
||||
});
|
||||
|
||||
applyPad(); applyBorder('hairline'); applyShadow();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,456 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Acme — Task completed micro-interaction</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-50: #F0EEE6;
|
||||
--gray-200: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-800: #3D3D3A;
|
||||
--white: #ffffff;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
/* swapped at runtime by the easing panel */
|
||||
--ease: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 64px;
|
||||
}
|
||||
header { margin-bottom: 28px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.sub {
|
||||
color: var(--gray-500);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.bench {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.stage {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
height: 360px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
bottom: 14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* ---------- the task row ---------- */
|
||||
.task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
background: var(--white);
|
||||
width: 360px;
|
||||
max-height: 64px;
|
||||
position: relative;
|
||||
transition: max-height 280ms var(--ease) 600ms,
|
||||
opacity 280ms var(--ease) 600ms,
|
||||
padding 280ms var(--ease) 600ms,
|
||||
border-color 200ms var(--ease);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.task:hover { border-color: var(--gray-500); }
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--gray-500);
|
||||
background: var(--white);
|
||||
position: relative;
|
||||
transition: background 180ms var(--ease),
|
||||
border-color 180ms var(--ease),
|
||||
transform 260ms var(--ease);
|
||||
}
|
||||
.check svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
.check path {
|
||||
stroke: var(--white);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
stroke-dasharray: 20;
|
||||
stroke-dashoffset: 20;
|
||||
transition: stroke-dashoffset 220ms var(--ease) 80ms;
|
||||
}
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
font-size: 15px;
|
||||
color: var(--slate);
|
||||
transition: color 200ms var(--ease) 120ms;
|
||||
}
|
||||
.label::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 52%;
|
||||
height: 1.5px;
|
||||
width: 0;
|
||||
background: var(--gray-500);
|
||||
transition: width 240ms var(--ease) 120ms;
|
||||
}
|
||||
|
||||
.due {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-50);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
padding: 3px 8px;
|
||||
border-radius: 8px;
|
||||
transition: opacity 200ms var(--ease) 120ms;
|
||||
}
|
||||
|
||||
/* confetti — six particles, css-only, driven by .done on the row */
|
||||
.confetti {
|
||||
position: absolute;
|
||||
left: 22px;
|
||||
top: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.confetti.c1 { background: var(--clay); }
|
||||
.confetti.c2 { background: var(--olive); }
|
||||
.confetti.c3 { background: var(--oat); }
|
||||
.confetti.c4 { background: var(--clay); }
|
||||
.confetti.c5 { background: var(--olive); }
|
||||
.confetti.c6 { background: var(--gray-800); }
|
||||
|
||||
/* ---------- done state ---------- */
|
||||
.task.done .check {
|
||||
background: var(--olive);
|
||||
border-color: var(--olive);
|
||||
transform: scale(1.12);
|
||||
animation: settle 380ms var(--ease) forwards;
|
||||
}
|
||||
@keyframes settle {
|
||||
0% { transform: scale(0.8); background: var(--clay); border-color: var(--clay); }
|
||||
55% { transform: scale(1.18); }
|
||||
100% { transform: scale(1); background: var(--olive); border-color: var(--olive); }
|
||||
}
|
||||
.task.done .check path { stroke-dashoffset: 0; }
|
||||
.task.done .label { color: var(--gray-500); }
|
||||
.task.done .label::after { width: 100%; }
|
||||
.task.done .due { opacity: 0.35; }
|
||||
.task.done {
|
||||
max-height: 44px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.task.done .confetti { animation: pop 520ms var(--ease) 200ms forwards; }
|
||||
.task.done .confetti.c1 { --dx: -28px; --dy: -22px; --rot: 40deg; }
|
||||
.task.done .confetti.c2 { --dx: -6px; --dy: -34px; --rot: -60deg; }
|
||||
.task.done .confetti.c3 { --dx: 18px; --dy: -26px; --rot: 90deg; }
|
||||
.task.done .confetti.c4 { --dx: 30px; --dy: -4px; --rot: -30deg; }
|
||||
.task.done .confetti.c5 { --dx: -24px; --dy: 14px; --rot: 20deg; }
|
||||
.task.done .confetti.c6 { --dx: 12px; --dy: 22px; --rot: -80deg; }
|
||||
@keyframes pop {
|
||||
0% { opacity: 0; transform: translate(0, 0) rotate(0) scale(0.6); }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translate(var(--dx), var(--dy)) rotate(var(--rot)) scale(1); }
|
||||
}
|
||||
|
||||
/* ---------- easing panel ---------- */
|
||||
.panel {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.panel h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.panel p {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.ease-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--gray-50);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
color: var(--slate);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ease-btn:hover { border-color: var(--gray-500); }
|
||||
.ease-btn.active {
|
||||
border-color: var(--clay);
|
||||
background: #FBF1EC;
|
||||
}
|
||||
.ease-btn code {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---------- timeline ---------- */
|
||||
.timeline {
|
||||
margin-top: 28px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 22px 22px 30px;
|
||||
}
|
||||
.timeline h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.track {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
background: var(--gray-200);
|
||||
margin: 20px 8px 0;
|
||||
}
|
||||
.key {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--clay);
|
||||
border: 1.5px solid var(--white);
|
||||
box-shadow: 0 0 0 1.5px var(--clay);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.key span {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
.key em {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.k1 { left: 0%; }
|
||||
.k2 { left: 13%; }
|
||||
.k3 { left: 20%; }
|
||||
.k4 { left: 33%; }
|
||||
.k5 { left: 100%; background: var(--olive); box-shadow: 0 0 0 1.5px var(--olive); }
|
||||
|
||||
/* ---------- snippet ---------- */
|
||||
.snippet {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.snippet h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 16px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
pre {
|
||||
background: var(--gray-800);
|
||||
color: var(--gray-50);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
pre .c { color: var(--gray-500); }
|
||||
pre .k { color: var(--clay); }
|
||||
pre .v { color: #B8C99D; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Acme / prototype / micro-interaction</div>
|
||||
<h1>Task completed</h1>
|
||||
<p class="sub">
|
||||
A single click should feel like a tiny win. Circle fills, check draws,
|
||||
label strikes, a small burst, then the row quietly steps back. Click
|
||||
the row to play; click again to reset.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="bench">
|
||||
|
||||
<div class="stage">
|
||||
<div class="task" id="task">
|
||||
<div class="check">
|
||||
<svg viewBox="0 0 16 16"><path d="M3 8.5 L6.5 12 L13 4.5"/></svg>
|
||||
</div>
|
||||
<span class="label">Send weekly digest</span>
|
||||
<span class="due">Fri</span>
|
||||
<div class="confetti c1"></div>
|
||||
<div class="confetti c2"></div>
|
||||
<div class="confetti c3"></div>
|
||||
<div class="confetti c4"></div>
|
||||
<div class="confetti c5"></div>
|
||||
<div class="confetti c6"></div>
|
||||
</div>
|
||||
<div class="hint">click to toggle</div>
|
||||
</div>
|
||||
|
||||
<aside class="panel">
|
||||
<h2>Easing</h2>
|
||||
<p>Swaps <code>--ease</code> across every transition so you can feel the difference.</p>
|
||||
<button class="ease-btn" data-ease="linear">
|
||||
Linear
|
||||
<code>linear</code>
|
||||
</button>
|
||||
<button class="ease-btn" data-ease="cubic-bezier(0.16, 1, 0.3, 1)">
|
||||
Ease-out
|
||||
<code>cubic-bezier(.16, 1, .3, 1)</code>
|
||||
</button>
|
||||
<button class="ease-btn active" data-ease="cubic-bezier(0.34, 1.56, 0.64, 1)">
|
||||
Spring
|
||||
<code>cubic-bezier(.34, 1.56, .64, 1)</code>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="timeline">
|
||||
<h2>Keyframes</h2>
|
||||
<div class="track">
|
||||
<div class="key k1"><em>fill</em><span>0ms</span></div>
|
||||
<div class="key k2"><em>check</em><span>80ms</span></div>
|
||||
<div class="key k3"><em>strike</em><span>120ms</span></div>
|
||||
<div class="key k4"><em>confetti</em><span>200ms</span></div>
|
||||
<div class="key k5"><em>collapse</em><span>600ms</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="snippet">
|
||||
<h2>Copy-paste CSS</h2>
|
||||
<pre><span class="c">/* circle: clay flash, settle to olive with spring overshoot */</span>
|
||||
<span class="k">.task.done .check</span> {
|
||||
animation: settle 380ms <span class="v">cubic-bezier(.34,1.56,.64,1)</span> forwards;
|
||||
}
|
||||
<span class="k">@keyframes settle</span> {
|
||||
0% { transform: scale(.8); background: <span class="v">#D97757</span>; }
|
||||
55% { transform: scale(1.18); }
|
||||
100% { transform: scale(1); background: <span class="v">#788C5D</span>; }
|
||||
}
|
||||
|
||||
<span class="c">/* checkmark draws via stroke-dashoffset, 80ms delay */</span>
|
||||
<span class="k">.check path</span> { stroke-dasharray: 20; stroke-dashoffset: 20;
|
||||
transition: stroke-dashoffset 220ms var(--ease) 80ms; }
|
||||
<span class="k">.task.done path</span> { stroke-dashoffset: 0; }
|
||||
|
||||
<span class="c">/* strikethrough grows left → right */</span>
|
||||
<span class="k">.label::after</span> { width: 0; transition: width 240ms var(--ease) 120ms; }
|
||||
<span class="k">.task.done .label::after</span> { width: 100%; }
|
||||
|
||||
<span class="c">/* row steps back after the celebration */</span>
|
||||
<span class="k">.task.done</span> { max-height: 44px; opacity: .6;
|
||||
transition-delay: 600ms; }</pre>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var task = document.getElementById('task');
|
||||
task.addEventListener('click', function () {
|
||||
task.classList.toggle('done');
|
||||
});
|
||||
|
||||
var root = document.documentElement;
|
||||
var buttons = document.querySelectorAll('.ease-btn');
|
||||
buttons.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
buttons.forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
root.style.setProperty('--ease', btn.dataset.ease);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,397 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Acme — Sidebar drag-to-reorder</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-50: #F0EEE6;
|
||||
--gray-200: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-800: #3D3D3A;
|
||||
--white: #ffffff;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1040px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 64px;
|
||||
}
|
||||
header { margin-bottom: 28px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.sub {
|
||||
color: var(--gray-500);
|
||||
max-width: 600px;
|
||||
}
|
||||
code.inline {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--gray-50);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.bench {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* ---------- sidebar mock ---------- */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 14px 10px;
|
||||
}
|
||||
.sidebar-title {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
padding: 4px 10px 10px;
|
||||
}
|
||||
.list {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
margin: 2px 0;
|
||||
border-radius: 8px;
|
||||
background: var(--white);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: background 120ms linear, transform 120ms ease-out, opacity 120ms linear;
|
||||
}
|
||||
.item:hover { background: var(--gray-50); }
|
||||
.item:hover .grip i { background: var(--gray-800); }
|
||||
.item.dragging {
|
||||
opacity: 0.35;
|
||||
transform: rotate(2deg);
|
||||
background: var(--gray-50);
|
||||
cursor: grabbing;
|
||||
}
|
||||
.item .label { font-size: 14px; color: var(--slate); }
|
||||
.item .count {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* grip: 2 × 3 dot grid, pure css */
|
||||
.grip {
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 3px);
|
||||
grid-template-rows: repeat(3, 3px);
|
||||
gap: 3px;
|
||||
align-content: center;
|
||||
}
|
||||
.grip i {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-200);
|
||||
transition: background 120ms linear;
|
||||
}
|
||||
|
||||
/* drop indicator line */
|
||||
.indicator {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
height: 2px;
|
||||
background: var(--clay);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.indicator::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--clay);
|
||||
}
|
||||
.indicator.on { display: block; }
|
||||
|
||||
/* ---------- annotation panel ---------- */
|
||||
.notes {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
.notes h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.notes .lede {
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.notes ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.notes li {
|
||||
position: relative;
|
||||
padding: 10px 0 10px 22px;
|
||||
border-top: 1.5px solid var(--gray-50);
|
||||
font-size: 13px;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
.notes li:first-child { border-top: none; }
|
||||
.notes li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 16px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--clay);
|
||||
}
|
||||
.notes li b { color: var(--slate); font-weight: 600; }
|
||||
|
||||
/* ---------- open questions ---------- */
|
||||
.questions {
|
||||
margin-top: 28px;
|
||||
background: var(--oat);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.questions h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 17px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.questions ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.questions li {
|
||||
font-size: 13px;
|
||||
color: var(--gray-800);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.questions li::marker {
|
||||
font-family: var(--mono);
|
||||
color: var(--olive);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Acme / prototype / interaction</div>
|
||||
<h1>Sidebar drag-to-reorder</h1>
|
||||
<p class="sub">
|
||||
Throwaway HTML so we can <em>feel</em> the reorder before porting it to
|
||||
React. Native <code class="inline">dragstart / dragover / drop</code>,
|
||||
~40 lines of JS, no libraries. Grab a row by the dots and move it.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="bench">
|
||||
|
||||
<div>
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-title">Views</div>
|
||||
<ul class="list" id="list">
|
||||
<li class="item" draggable="true">
|
||||
<span class="grip"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<span class="label">Inbox</span>
|
||||
<span class="count">14</span>
|
||||
</li>
|
||||
<li class="item" draggable="true">
|
||||
<span class="grip"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<span class="label">Today</span>
|
||||
<span class="count">3</span>
|
||||
</li>
|
||||
<li class="item" draggable="true">
|
||||
<span class="grip"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<span class="label">Upcoming</span>
|
||||
<span class="count">21</span>
|
||||
</li>
|
||||
<li class="item" draggable="true">
|
||||
<span class="grip"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<span class="label">Projects</span>
|
||||
<span class="count">8</span>
|
||||
</li>
|
||||
<li class="item" draggable="true">
|
||||
<span class="grip"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<span class="label">Archive</span>
|
||||
<span class="count"></span>
|
||||
</li>
|
||||
<li class="item" draggable="true">
|
||||
<span class="grip"><i></i><i></i><i></i><i></i><i></i><i></i></span>
|
||||
<span class="label">Trash</span>
|
||||
<span class="count"></span>
|
||||
</li>
|
||||
<div class="indicator" id="indicator"></div>
|
||||
</ul>
|
||||
</nav>
|
||||
<p class="footnote">Order persists in the DOM only — refresh to reset.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<section class="notes">
|
||||
<h2>What you're feeling</h2>
|
||||
<p class="lede">Design decisions baked into this prototype, so you can push back on them.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<b>Drop indicator snaps to the nearest gap</b>, not the raw cursor Y.
|
||||
It only moves when you cross a row's midpoint — feels more decisive,
|
||||
less jittery.
|
||||
</li>
|
||||
<li>
|
||||
<b>Dragged row stays in place at 35% opacity</b> with a 2° tilt.
|
||||
Keeping the ghost in the list preserves your sense of where you
|
||||
started; the tilt reads as "lifted."
|
||||
</li>
|
||||
<li>
|
||||
<b>Grip dots are the affordance, but the whole row is draggable.</b>
|
||||
Dots darken on hover to teach the gesture without forcing a
|
||||
tiny hit target.
|
||||
</li>
|
||||
<li>
|
||||
<b>No auto-scroll, no drop animation.</b> Left out on purpose so the
|
||||
core feel is easy to judge — say the word and we add them next.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="questions">
|
||||
<h2>Open questions</h2>
|
||||
<ol>
|
||||
<li>Should <b>Trash</b> (and maybe <b>Archive</b>) be pinned to the bottom and excluded from reordering?</li>
|
||||
<li>Do we want rows to <b>slide</b> to their new slot on drop, or is the instant snap acceptable?</li>
|
||||
<li>Keyboard path: is <code class="inline">Alt + Arrow</code> to move the focused row enough for the first ship?</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var list = document.getElementById('list');
|
||||
var indicator = document.getElementById('indicator');
|
||||
var dragging = null;
|
||||
var dropBefore = null; // node to insert before, or null = append
|
||||
|
||||
list.addEventListener('dragstart', function (e) {
|
||||
var item = e.target.closest('.item');
|
||||
if (!item) return;
|
||||
dragging = item;
|
||||
item.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', '');
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', function (e) {
|
||||
if (!dragging) return;
|
||||
e.preventDefault();
|
||||
var siblings = Array.prototype.filter.call(
|
||||
list.children,
|
||||
function (el) { return el.classList && el.classList.contains('item'); }
|
||||
);
|
||||
dropBefore = null;
|
||||
for (var i = 0; i < siblings.length; i++) {
|
||||
var r = siblings[i].getBoundingClientRect();
|
||||
if (e.clientY < r.top + r.height / 2) { dropBefore = siblings[i]; break; }
|
||||
}
|
||||
var listRect = list.getBoundingClientRect();
|
||||
var edge = dropBefore
|
||||
? dropBefore.getBoundingClientRect().top
|
||||
: siblings[siblings.length - 1].getBoundingClientRect().bottom;
|
||||
indicator.style.top = (edge - listRect.top - 1) + 'px';
|
||||
indicator.classList.add('on');
|
||||
});
|
||||
|
||||
list.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
if (!dragging) return;
|
||||
if (dropBefore) list.insertBefore(dragging, dropBefore);
|
||||
else list.insertBefore(dragging, indicator);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
list.addEventListener('dragleave', function (e) {
|
||||
if (!list.contains(e.relatedTarget)) indicator.classList.remove('on');
|
||||
});
|
||||
document.addEventListener('dragend', cleanup);
|
||||
|
||||
function cleanup() {
|
||||
if (dragging) dragging.classList.remove('dragging');
|
||||
dragging = null;
|
||||
dropBefore = null;
|
||||
indicator.classList.remove('on');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,593 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Platform Eng — Week of Mar 10</title>
|
||||
<style>
|
||||
/* ------------------------------------------------------------------
|
||||
Design tokens
|
||||
------------------------------------------------------------------ */
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150:#F0EEE6;
|
||||
--gray-300:#D1CFC5;
|
||||
--gray-500:#87867F;
|
||||
--gray-700:#3D3D3A;
|
||||
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
scroll-snap-type: y mandatory;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide shell
|
||||
------------------------------------------------------------------ */
|
||||
.slide {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8vh 6vw;
|
||||
}
|
||||
|
||||
.slide-inner {
|
||||
width: 100%;
|
||||
max-width: 780px;
|
||||
}
|
||||
|
||||
.slide.invert {
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Typography
|
||||
------------------------------------------------------------------ */
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.invert .eyebrow { color: var(--gray-300); }
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(40px, 6vw, 64px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(30px, 4vw, 42px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.005em;
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-700);
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide 1 — title
|
||||
------------------------------------------------------------------ */
|
||||
.title-slide .slide-inner { text-align: left; }
|
||||
|
||||
.ornament {
|
||||
display: block;
|
||||
margin: 0 0 40px 0;
|
||||
}
|
||||
|
||||
.byline {
|
||||
margin-top: 56px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide 2 — shipped list
|
||||
------------------------------------------------------------------ */
|
||||
.ship-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.ship-item {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto;
|
||||
column-gap: 20px;
|
||||
align-items: baseline;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
}
|
||||
.ship-item:last-child { border-bottom: none; }
|
||||
|
||||
.ship-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--olive);
|
||||
margin-top: 8px;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.ship-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.ship-desc {
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.ship-ref {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
white-space: nowrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide 3 — in progress
|
||||
------------------------------------------------------------------ */
|
||||
.prog-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.prog-item .prog-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prog-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prog-pct {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.prog-track {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: var(--gray-150);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.prog-fill {
|
||||
height: 100%;
|
||||
background: var(--clay);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.prog-note {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide 4 — metrics
|
||||
------------------------------------------------------------------ */
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 56px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: var(--serif);
|
||||
font-size: 52px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.metric-delta {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.metric-delta.down { color: var(--olive); }
|
||||
.metric-delta.up { color: var(--clay); }
|
||||
|
||||
.sparkline-wrap {
|
||||
border-top: 1px solid var(--gray-300);
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
.sparkline-caption {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide 5 — decision (inverted)
|
||||
------------------------------------------------------------------ */
|
||||
.decision-card {
|
||||
border: 1.5px solid var(--clay);
|
||||
border-radius: 14px;
|
||||
padding: 36px 38px;
|
||||
background: rgba(217, 119, 87, 0.06);
|
||||
}
|
||||
|
||||
.decision-q {
|
||||
font-family: var(--serif);
|
||||
font-size: 24px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.decision-context {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-300);
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--gray-700);
|
||||
color: var(--ivory);
|
||||
background: transparent;
|
||||
}
|
||||
.chip.lean {
|
||||
border-color: var(--clay);
|
||||
color: var(--clay);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide 6 — next week
|
||||
------------------------------------------------------------------ */
|
||||
.next-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.next-list li {
|
||||
font-family: var(--serif);
|
||||
font-size: 21px;
|
||||
line-height: 1.45;
|
||||
padding-left: 36px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.next-list li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.55em;
|
||||
width: 18px;
|
||||
height: 1.5px;
|
||||
background: var(--clay);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin-top: 56px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Slide counter
|
||||
------------------------------------------------------------------ */
|
||||
#counter {
|
||||
position: fixed;
|
||||
bottom: 22px;
|
||||
right: 28px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
user-select: none;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.metrics { grid-template-columns: 1fr; gap: 36px; }
|
||||
.ship-item { grid-template-columns: 14px 1fr; }
|
||||
.ship-ref { grid-column: 2; padding-top: 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide 1 — Title
|
||||
============================================================ -->
|
||||
<section class="slide title-slide" id="s1">
|
||||
<div class="slide-inner">
|
||||
<svg class="ornament" width="56" height="56" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<circle cx="28" cy="28" r="22" fill="none" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<path d="M16 28 H40 M28 16 V40" stroke="#D97757" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<circle cx="28" cy="28" r="3" fill="#141413"/>
|
||||
</svg>
|
||||
<h1>Platform Eng<br>— Week of Mar 10</h1>
|
||||
<p class="subtitle">
|
||||
What shipped, what's moving, and one decision we need from the
|
||||
room before the Acme 2.4 cut.
|
||||
</p>
|
||||
<div class="byline">
|
||||
<span>Friday demo</span>
|
||||
<span>6 slides</span>
|
||||
<span>~4 min</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide 2 — Shipped
|
||||
============================================================ -->
|
||||
<section class="slide" id="s2">
|
||||
<div class="slide-inner">
|
||||
<div class="eyebrow">Shipped this week</div>
|
||||
<h2>Three things out the door</h2>
|
||||
<ul class="ship-list">
|
||||
<li class="ship-item">
|
||||
<span class="ship-dot"></span>
|
||||
<div>
|
||||
<div class="ship-title">Bulk task import</div>
|
||||
<div class="ship-desc">
|
||||
CSV and JSON uploads now land straight into a board with
|
||||
column mapping — no more paste-and-pray.
|
||||
</div>
|
||||
</div>
|
||||
<span class="ship-ref">#4211</span>
|
||||
</li>
|
||||
<li class="ship-item">
|
||||
<span class="ship-dot"></span>
|
||||
<div>
|
||||
<div class="ship-title">Webhook retries v2</div>
|
||||
<div class="ship-desc">
|
||||
Exponential backoff with jitter; dead-letter queue surfaces
|
||||
in the workspace admin panel.
|
||||
</div>
|
||||
</div>
|
||||
<span class="ship-ref">#4188 #4203</span>
|
||||
</li>
|
||||
<li class="ship-item">
|
||||
<span class="ship-dot"></span>
|
||||
<div>
|
||||
<div class="ship-title">Postgres 16 migration</div>
|
||||
<div class="ship-desc">
|
||||
All read replicas cut over Tuesday night; zero customer-facing
|
||||
downtime, ~9% faster aggregate queries.
|
||||
</div>
|
||||
</div>
|
||||
<span class="ship-ref">#4179</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide 3 — In progress
|
||||
============================================================ -->
|
||||
<section class="slide" id="s3">
|
||||
<div class="slide-inner">
|
||||
<div class="eyebrow">In progress</div>
|
||||
<h2>Carrying into next week</h2>
|
||||
<ul class="prog-list">
|
||||
<li class="prog-item">
|
||||
<div class="prog-head">
|
||||
<span class="prog-title">Recurring tasks engine</span>
|
||||
<span class="prog-pct">~70%</span>
|
||||
</div>
|
||||
<div class="prog-track"><div class="prog-fill" style="width:70%"></div></div>
|
||||
<p class="prog-note">
|
||||
Scheduler and RRULE parsing are done; remaining work is the
|
||||
timezone edge cases and the "skip holidays" toggle.
|
||||
</p>
|
||||
</li>
|
||||
<li class="prog-item">
|
||||
<div class="prog-head">
|
||||
<span class="prog-title">Audit log export</span>
|
||||
<span class="prog-pct">~35%</span>
|
||||
</div>
|
||||
<div class="prog-track"><div class="prog-fill" style="width:35%"></div></div>
|
||||
<p class="prog-note">
|
||||
Streaming NDJSON endpoint is up behind a flag; still wiring
|
||||
the S3 destination picker and retention policy UI.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide 4 — Metrics
|
||||
============================================================ -->
|
||||
<section class="slide" id="s4">
|
||||
<div class="slide-inner">
|
||||
<div class="eyebrow">Metrics</div>
|
||||
<h2>Numbers we watch</h2>
|
||||
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">API p95 latency</div>
|
||||
<div class="metric-value">184<span style="font-size:28px">ms</span></div>
|
||||
<div class="metric-delta down">↓ 12% wk/wk</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Background job error rate</div>
|
||||
<div class="metric-value">0.21<span style="font-size:28px">%</span></div>
|
||||
<div class="metric-delta down">↓ 0.08pp</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sparkline-wrap">
|
||||
<svg width="100%" height="64" viewBox="0 0 780 64" preserveAspectRatio="none" aria-hidden="true">
|
||||
<polyline
|
||||
points="0,22 110,30 220,18 330,34 440,26 550,40 660,24 780,12"
|
||||
fill="none"
|
||||
stroke="#788C5D"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"/>
|
||||
<circle cx="780" cy="12" r="3.5" fill="#788C5D"/>
|
||||
<line x1="0" y1="63" x2="780" y2="63" stroke="#D1CFC5" stroke-width="1"/>
|
||||
</svg>
|
||||
<div class="sparkline-caption">
|
||||
p95 latency, trailing 8 days — lower is better
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide 5 — Decision needed (inverted)
|
||||
============================================================ -->
|
||||
<section class="slide invert" id="s5">
|
||||
<div class="slide-inner">
|
||||
<div class="eyebrow">Decision needed</div>
|
||||
<h2>One call to make</h2>
|
||||
|
||||
<div class="decision-card">
|
||||
<p class="decision-q">
|
||||
Do we ship recurring tasks behind a workspace flag in 2.4, or
|
||||
hold one more week for the timezone fixes?
|
||||
</p>
|
||||
<p class="decision-context">
|
||||
Flagged rollout gets it to design partners Friday but means two
|
||||
code paths for ~2 weeks. Holding keeps a single path but slips
|
||||
the partner promise.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="options">
|
||||
<span class="chip lean">A — Flag it, ship Friday</span>
|
||||
<span class="chip">B — Hold for 2.5</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide 6 — Next week
|
||||
============================================================ -->
|
||||
<section class="slide" id="s6">
|
||||
<div class="slide-inner">
|
||||
<div class="eyebrow">Next week</div>
|
||||
<h2>On deck</h2>
|
||||
<ul class="next-list">
|
||||
<li>Finish recurring tasks — timezone matrix tests, then dogfood on the internal ops board.</li>
|
||||
<li>Audit log export to private beta — three workspaces lined up.</li>
|
||||
<li>Start scoping rate-limit headers for the public API; RFC draft by Thursday.</li>
|
||||
</ul>
|
||||
<div class="footnote">
|
||||
Questions → drop them in the platform channel or grab anyone after standup.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================
|
||||
Slide counter
|
||||
============================================================ -->
|
||||
<div id="counter">1 / 6</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const slides = Array.from(document.querySelectorAll('.slide'));
|
||||
const counter = document.getElementById('counter');
|
||||
let current = 0;
|
||||
|
||||
function go(i) {
|
||||
current = Math.max(0, Math.min(slides.length - 1, i));
|
||||
slides[current].scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') { e.preventDefault(); go(current + 1); }
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); go(current - 1); }
|
||||
});
|
||||
|
||||
const obs = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (en) {
|
||||
if (en.isIntersecting) {
|
||||
current = slides.indexOf(en.target);
|
||||
counter.textContent = (current + 1) + ' / ' + slides.length;
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.6 });
|
||||
|
||||
slides.forEach(function (s) { obs.observe(s); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,493 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Background jobs — header illustrations</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150:#F0EEE6;
|
||||
--gray-300:#D1CFC5;
|
||||
--gray-500:#87867F;
|
||||
--gray-700:#3D3D3A;
|
||||
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding: 64px 24px 96px;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header { margin-bottom: 56px; }
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-700);
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
figure {
|
||||
margin: 0 0 72px 0;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--ivory);
|
||||
}
|
||||
|
||||
.canvas svg { display: block; width: 100%; height: auto; }
|
||||
|
||||
figcaption {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px 4px 0;
|
||||
}
|
||||
|
||||
.cap-text { flex: 1; }
|
||||
|
||||
.cap-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.cap-sub {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.dl {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--slate);
|
||||
background: var(--gray-150);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 6px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dl:hover { background: var(--oat); }
|
||||
.dl:active { transform: translateY(1px); }
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Palette reference strip
|
||||
---------------------------------------------------------------- */
|
||||
.palette {
|
||||
border-top: 1px solid var(--gray-300);
|
||||
padding-top: 36px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.palette h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.swatch-chip {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.swatch-meta {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.swatch-name {
|
||||
font-size: 12px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.swatch-hex {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.notes {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--gray-700);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notes li {
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.notes li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.65em;
|
||||
width: 8px;
|
||||
height: 1px;
|
||||
background: var(--gray-500);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
|
||||
<header>
|
||||
<h1>Background jobs — header illustrations</h1>
|
||||
<p class="lead">
|
||||
Three 720×320 hand-drawn SVGs for the Acme docs section on
|
||||
background jobs. Flat fills, 1.5–2px strokes, palette-locked. Each
|
||||
exports standalone via the button below it.
|
||||
</p>
|
||||
<div class="meta">720 × 320 · inline SVG · no external assets</div>
|
||||
</header>
|
||||
|
||||
<!-- ============================================================
|
||||
A — Queue
|
||||
============================================================ -->
|
||||
<figure>
|
||||
<div class="canvas">
|
||||
<svg id="ill-queue" xmlns="http://www.w3.org/2000/svg" width="720" height="320" viewBox="0 0 720 320">
|
||||
<defs>
|
||||
<style>
|
||||
.m { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; }
|
||||
.s { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; }
|
||||
</style>
|
||||
<marker id="qa" markerWidth="10" markerHeight="10" refX="8" refY="4" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M0,0 L0,8 L8,4 z" fill="#87867F"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="720" height="320" fill="#FAF9F5"/>
|
||||
|
||||
<!-- queue label -->
|
||||
<text x="60" y="96" class="s" fill="#87867F">queue</text>
|
||||
|
||||
<!-- five job rects -->
|
||||
<g>
|
||||
<rect x="60" y="110" width="70" height="100" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="95" y="164" text-anchor="middle" class="m" fill="#3D3D3A">job 5</text>
|
||||
|
||||
<rect x="140" y="110" width="70" height="100" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="175" y="164" text-anchor="middle" class="m" fill="#3D3D3A">job 4</text>
|
||||
|
||||
<rect x="220" y="110" width="70" height="100" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="255" y="164" text-anchor="middle" class="m" fill="#3D3D3A">job 3</text>
|
||||
|
||||
<rect x="300" y="110" width="70" height="100" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="335" y="164" text-anchor="middle" class="m" fill="#3D3D3A">job 2</text>
|
||||
|
||||
<!-- head of queue, highlighted -->
|
||||
<rect x="380" y="110" width="70" height="100" rx="10" fill="#D97757" stroke="#141413" stroke-width="2"/>
|
||||
<text x="415" y="164" text-anchor="middle" class="m" fill="#FAF9F5">job 1</text>
|
||||
</g>
|
||||
|
||||
<!-- arrow to worker -->
|
||||
<line x1="458" y1="160" x2="522" y2="160" stroke="#87867F" stroke-width="1.5" marker-end="url(#qa)"/>
|
||||
|
||||
<!-- worker -->
|
||||
<rect x="530" y="90" width="130" height="140" rx="10" fill="#E3DACC" stroke="#141413" stroke-width="2"/>
|
||||
<text x="595" y="156" text-anchor="middle" class="m" fill="#141413">worker</text>
|
||||
<text x="595" y="172" text-anchor="middle" class="m" fill="#87867F">pool=4</text>
|
||||
|
||||
<!-- annotations -->
|
||||
<text x="415" y="234" text-anchor="middle" class="s" fill="#87867F">next to run</text>
|
||||
<text x="60" y="258" class="s" fill="#87867F">Jobs are pulled FIFO; the worker leases one at a time.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption>
|
||||
<div class="cap-text">
|
||||
<div class="cap-title">Queue</div>
|
||||
<div class="cap-sub">For "How jobs are picked up" — intro page header.</div>
|
||||
</div>
|
||||
<button class="dl" data-target="ill-queue" data-filename="acme-jobs-queue.svg">Download SVG</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ============================================================
|
||||
B — Retry with backoff
|
||||
============================================================ -->
|
||||
<figure>
|
||||
<div class="canvas">
|
||||
<svg id="ill-retry" xmlns="http://www.w3.org/2000/svg" width="720" height="320" viewBox="0 0 720 320">
|
||||
<defs>
|
||||
<style>
|
||||
.m { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; }
|
||||
.s { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="720" height="320" fill="#FAF9F5"/>
|
||||
|
||||
<!-- timeline axis -->
|
||||
<line x1="60" y1="190" x2="660" y2="190" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="60" y="214" class="s" fill="#87867F">t = 0</text>
|
||||
<text x="660" y="214" text-anchor="end" class="s" fill="#87867F">time →</text>
|
||||
|
||||
<!-- attempts: exponential spacing at x = 100, 170, 310, 590 -->
|
||||
<!-- attempt 1 (fail) -->
|
||||
<circle cx="100" cy="190" r="8" fill="#FAF9F5" stroke="#D97757" stroke-width="2"/>
|
||||
<line x1="96" y1="186" x2="104" y2="194" stroke="#D97757" stroke-width="1.5"/>
|
||||
<line x1="104" y1="186" x2="96" y2="194" stroke="#D97757" stroke-width="1.5"/>
|
||||
<text x="100" y="222" text-anchor="middle" class="m" fill="#3D3D3A">try 1</text>
|
||||
|
||||
<!-- attempt 2 (fail) -->
|
||||
<circle cx="170" cy="190" r="8" fill="#FAF9F5" stroke="#D97757" stroke-width="2"/>
|
||||
<line x1="166" y1="186" x2="174" y2="194" stroke="#D97757" stroke-width="1.5"/>
|
||||
<line x1="174" y1="186" x2="166" y2="194" stroke="#D97757" stroke-width="1.5"/>
|
||||
<text x="170" y="222" text-anchor="middle" class="m" fill="#3D3D3A">try 2</text>
|
||||
|
||||
<!-- attempt 3 (fail) -->
|
||||
<circle cx="310" cy="190" r="8" fill="#FAF9F5" stroke="#D97757" stroke-width="2"/>
|
||||
<line x1="306" y1="186" x2="314" y2="194" stroke="#D97757" stroke-width="1.5"/>
|
||||
<line x1="314" y1="186" x2="306" y2="194" stroke="#D97757" stroke-width="1.5"/>
|
||||
<text x="310" y="222" text-anchor="middle" class="m" fill="#3D3D3A">try 3</text>
|
||||
|
||||
<!-- attempt 4 (success) -->
|
||||
<circle cx="590" cy="190" r="9" fill="#788C5D" stroke="#141413" stroke-width="1.5"/>
|
||||
<path d="M585,190 L589,194 L596,185" fill="none" stroke="#FAF9F5" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<text x="590" y="222" text-anchor="middle" class="m" fill="#3D3D3A">try 4</text>
|
||||
|
||||
<!-- dashed backoff arcs (loop back over the gap) -->
|
||||
<path d="M 100 182 Q 135 130 170 182" fill="none" stroke="#87867F" stroke-width="1.5" stroke-dasharray="4 4"/>
|
||||
<text x="135" y="124" text-anchor="middle" class="m" fill="#87867F">+1s</text>
|
||||
|
||||
<path d="M 170 182 Q 240 110 310 182" fill="none" stroke="#87867F" stroke-width="1.5" stroke-dasharray="4 4"/>
|
||||
<text x="240" y="104" text-anchor="middle" class="m" fill="#87867F">+2s</text>
|
||||
|
||||
<path d="M 310 182 Q 450 80 590 182" fill="none" stroke="#87867F" stroke-width="1.5" stroke-dasharray="4 4"/>
|
||||
<text x="450" y="74" text-anchor="middle" class="m" fill="#87867F">+4s</text>
|
||||
|
||||
<!-- annotation -->
|
||||
<text x="60" y="262" class="s" fill="#87867F">Each failure waits twice as long before re-queuing; jitter not pictured.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption>
|
||||
<div class="cap-text">
|
||||
<div class="cap-title">Retry with backoff</div>
|
||||
<div class="cap-sub">For "Handling failures" — retry policy header.</div>
|
||||
</div>
|
||||
<button class="dl" data-target="ill-retry" data-filename="acme-jobs-retry.svg">Download SVG</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ============================================================
|
||||
C — Fan-out
|
||||
============================================================ -->
|
||||
<figure>
|
||||
<div class="canvas">
|
||||
<svg id="ill-fanout" xmlns="http://www.w3.org/2000/svg" width="720" height="320" viewBox="0 0 720 320">
|
||||
<defs>
|
||||
<style>
|
||||
.m { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; }
|
||||
.s { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; }
|
||||
</style>
|
||||
<marker id="fa" markerWidth="9" markerHeight="9" refX="7" refY="3.5" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M0,0 L0,7 L7,3.5 z" fill="#87867F"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<rect width="720" height="320" fill="#FAF9F5"/>
|
||||
|
||||
<!-- source -->
|
||||
<rect x="60" y="128" width="110" height="64" rx="10" fill="#E3DACC" stroke="#141413" stroke-width="2"/>
|
||||
<text x="115" y="158" text-anchor="middle" class="m" fill="#141413">enqueue</text>
|
||||
<text x="115" y="172" text-anchor="middle" class="m" fill="#87867F">batch()</text>
|
||||
|
||||
<!-- fan-out arrows: from (170,160) to four shard boxes at x=300 -->
|
||||
<line x1="170" y1="160" x2="296" y2="64" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
<line x1="170" y1="160" x2="296" y2="128" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
<line x1="170" y1="160" x2="296" y2="192" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
<line x1="170" y1="160" x2="296" y2="256" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
|
||||
<!-- four shard boxes -->
|
||||
<g>
|
||||
<rect x="300" y="44" width="120" height="40" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="360" y="69" text-anchor="middle" class="m" fill="#3D3D3A">shard 0</text>
|
||||
|
||||
<rect x="300" y="108" width="120" height="40" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="360" y="133" text-anchor="middle" class="m" fill="#3D3D3A">shard 1</text>
|
||||
|
||||
<rect x="300" y="172" width="120" height="40" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="360" y="197" text-anchor="middle" class="m" fill="#3D3D3A">shard 2</text>
|
||||
|
||||
<rect x="300" y="236" width="120" height="40" rx="10" fill="#F0EEE6" stroke="#3D3D3A" stroke-width="1.5"/>
|
||||
<text x="360" y="261" text-anchor="middle" class="m" fill="#3D3D3A">shard 3</text>
|
||||
</g>
|
||||
|
||||
<!-- converge arrows to merge -->
|
||||
<line x1="420" y1="64" x2="546" y2="160" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
<line x1="420" y1="128" x2="546" y2="160" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
<line x1="420" y1="192" x2="546" y2="160" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
<line x1="420" y1="256" x2="546" y2="160" stroke="#87867F" stroke-width="1.5" marker-end="url(#fa)"/>
|
||||
|
||||
<!-- merge box -->
|
||||
<rect x="550" y="128" width="110" height="64" rx="10" fill="#788C5D" stroke="#141413" stroke-width="2"/>
|
||||
<text x="605" y="158" text-anchor="middle" class="m" fill="#FAF9F5">merge</text>
|
||||
<text x="605" y="172" text-anchor="middle" class="m" fill="#E3DACC">await all</text>
|
||||
|
||||
<!-- annotations -->
|
||||
<text x="232" y="42" class="s" fill="#87867F">fan-out</text>
|
||||
<text x="486" y="42" text-anchor="end" class="s" fill="#87867F">fan-in</text>
|
||||
<text x="60" y="300" class="s" fill="#87867F">Parent job spawns N children, then blocks on a completion barrier.</text>
|
||||
</svg>
|
||||
</div>
|
||||
<figcaption>
|
||||
<div class="cap-text">
|
||||
<div class="cap-title">Fan-out / fan-in</div>
|
||||
<div class="cap-sub">For "Batch and parallel work" — fan-out pattern header.</div>
|
||||
</div>
|
||||
<button class="dl" data-target="ill-fanout" data-filename="acme-jobs-fanout.svg">Download SVG</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<!-- ============================================================
|
||||
Palette + usage notes
|
||||
============================================================ -->
|
||||
<section class="palette">
|
||||
<h2>Palette & rules</h2>
|
||||
|
||||
<div class="swatches">
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#FAF9F5"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">ivory</div>
|
||||
<div class="swatch-hex">#FAF9F5</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#141413"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">slate</div>
|
||||
<div class="swatch-hex">#141413</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#D97757"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">clay</div>
|
||||
<div class="swatch-hex">#D97757</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#788C5D"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">olive</div>
|
||||
<div class="swatch-hex">#788C5D</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#E3DACC"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">oat</div>
|
||||
<div class="swatch-hex">#E3DACC</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#F0EEE6"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">gray-150</div>
|
||||
<div class="swatch-hex">#F0EEE6</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#D1CFC5"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">gray-300</div>
|
||||
<div class="swatch-hex">#D1CFC5</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="swatch">
|
||||
<div class="swatch-chip" style="background:#87867F"></div>
|
||||
<div class="swatch-meta">
|
||||
<div class="swatch-name">gray-500</div>
|
||||
<div class="swatch-hex">#87867F</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="notes">
|
||||
<li>Strokes are 1.5px for neutral boxes, 2px for emphasised containers.</li>
|
||||
<li>All rectangles use <code>rx="10"</code>; no drop shadows or gradients.</li>
|
||||
<li>Labels inside boxes are 11px mono; annotations outside are 12px sans, gray-500.</li>
|
||||
<li>Clay marks "the thing in focus"; olive marks "success / done".</li>
|
||||
<li>Each SVG carries its own <code><style></code> block so the download stands alone.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.dl').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var svg = document.getElementById(btn.dataset.target);
|
||||
var src = new XMLSerializer().serializeToString(svg);
|
||||
var blob = new Blob([src], { type: 'image/svg+xml;charset=utf-8' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = btn.dataset.filename || 'illustration.svg';
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,529 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Acme — Engineering Status — Week 11</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--rust: #B04A3F;
|
||||
--gray-100: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, serif;
|
||||
--sans: system-ui, -apple-system, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
--radius-panel: 12px;
|
||||
--radius-row: 8px;
|
||||
--border: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 56px 24px 120px;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
header {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
}
|
||||
.auto-pill {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
border: var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.date-range {
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
}
|
||||
.date-range .repo {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
section {
|
||||
margin-bottom: 52px;
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
hr.rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-300);
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
|
||||
/* ---------- Summary band ---------- */
|
||||
.summary-band {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.summary-band { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 20px 22px 18px;
|
||||
}
|
||||
.stat-card.warn {
|
||||
border-left: 4px solid var(--clay);
|
||||
padding-left: 19px;
|
||||
}
|
||||
.stat-num {
|
||||
font-family: var(--serif);
|
||||
font-size: 44px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--slate);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.stat-label {
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.stat-delta {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.stat-delta.up { color: var(--olive); }
|
||||
.stat-delta.flat { color: var(--gray-500); }
|
||||
|
||||
/* ---------- Highlights ---------- */
|
||||
.highlights {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.highlights li {
|
||||
position: relative;
|
||||
padding: 0 0 14px 26px;
|
||||
font-size: 15px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.highlights li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
top: 8px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 2px;
|
||||
background: var(--clay);
|
||||
}
|
||||
.highlights li strong {
|
||||
color: var(--slate);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Shipped table ---------- */
|
||||
table.shipped {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
table.shipped thead th {
|
||||
text-align: left;
|
||||
font-family: var(--sans);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-100);
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--gray-300);
|
||||
}
|
||||
table.shipped tbody td {
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.shipped tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
table.shipped tbody tr:hover {
|
||||
background: var(--ivory);
|
||||
}
|
||||
.pr-link {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--clay);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted transparent;
|
||||
}
|
||||
.pr-link:hover {
|
||||
border-bottom-color: var(--clay);
|
||||
}
|
||||
.author {
|
||||
color: var(--gray-700);
|
||||
font-size: 13px;
|
||||
}
|
||||
.risk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.risk-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.risk-dot.low { background: var(--olive); }
|
||||
.risk-dot.med { background: var(--clay); }
|
||||
.risk-dot.high { background: var(--rust); }
|
||||
|
||||
/* ---------- Velocity chart ---------- */
|
||||
.chart-panel {
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 24px 28px 18px;
|
||||
}
|
||||
.chart-panel svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.chart-caption {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Carryover ---------- */
|
||||
.carryover {
|
||||
background: var(--oat);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.carry-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.carry-item + .carry-item {
|
||||
border-top: 1px solid rgba(20, 20, 19, 0.08);
|
||||
}
|
||||
.carry-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--gray-700);
|
||||
background: var(--ivory);
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.carry-body {
|
||||
font-size: 14px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.carry-body .who {
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
footer {
|
||||
margin-top: 64px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--gray-300);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<div class="header-top">
|
||||
<h1>Engineering Status — Week 11</h1>
|
||||
<span class="auto-pill">auto-generated</span>
|
||||
</div>
|
||||
<div class="date-range">
|
||||
Mar 10 – Mar 16, 2025 ·
|
||||
<span class="repo">acme/app @ main</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Summary band -->
|
||||
<section>
|
||||
<div class="summary-band">
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">14</div>
|
||||
<div class="stat-label">PRs merged</div>
|
||||
<div class="stat-delta up">+3 vs wk10</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">6</div>
|
||||
<div class="stat-label">Deploys</div>
|
||||
<div class="stat-delta flat">±0</div>
|
||||
</div>
|
||||
<div class="stat-card warn">
|
||||
<div class="stat-num">1</div>
|
||||
<div class="stat-label">Incidents</div>
|
||||
<div class="stat-delta flat">SEV-2 · 47m</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">3</div>
|
||||
<div class="stat-label">Flaky tests fixed</div>
|
||||
<div class="stat-delta up">suite now 99.1%</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Highlights -->
|
||||
<section>
|
||||
<h2>Highlights</h2>
|
||||
<hr class="rule">
|
||||
<ul class="highlights">
|
||||
<li>
|
||||
<strong>Bulk task editing shipped to 100%.</strong> The multi-select
|
||||
toolbar landed behind a flag on Monday and ramped to all workspaces by
|
||||
Thursday with no error-rate regression.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Sync API p95 down 38%.</strong> Replacing per-task auth checks
|
||||
with a scoped batch lookup cut the hot path from 410ms to 255ms on the
|
||||
staging load test.
|
||||
</li>
|
||||
<li>
|
||||
<strong>One SEV-2 on Wednesday</strong> — a config rollout pushed
|
||||
a bad connection-pool limit to the sync workers. Mitigated in 47 minutes;
|
||||
full postmortem linked below.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Shipped -->
|
||||
<section>
|
||||
<h2>Shipped</h2>
|
||||
<hr class="rule">
|
||||
<table class="shipped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 90px;">PR</th>
|
||||
<th>Title</th>
|
||||
<th style="width: 140px;">Author</th>
|
||||
<th style="width: 100px;">Risk</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4871</a></td>
|
||||
<td>Bulk edit toolbar: selection model + keyboard shortcuts</td>
|
||||
<td class="author">Mira Okafor</td>
|
||||
<td><span class="risk"><span class="risk-dot med"></span>Med</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4874</a></td>
|
||||
<td>Batch auth lookup for <code>/v2/sync</code> hot path</td>
|
||||
<td class="author">Devon Park</td>
|
||||
<td><span class="risk"><span class="risk-dot med"></span>Med</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4878</a></td>
|
||||
<td>Fix race in attachment uploader retry loop</td>
|
||||
<td class="author">Sam Reyes</td>
|
||||
<td><span class="risk"><span class="risk-dot low"></span>Low</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4879</a></td>
|
||||
<td>Migrate reminder scheduler to idempotent job keys</td>
|
||||
<td class="author">Priya Anand</td>
|
||||
<td><span class="risk"><span class="risk-dot high"></span>High</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4882</a></td>
|
||||
<td>Board view: collapse empty swimlanes by default</td>
|
||||
<td class="author">Mira Okafor</td>
|
||||
<td><span class="risk"><span class="risk-dot low"></span>Low</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4885</a></td>
|
||||
<td>Quarantine 3 flaky webhook integration tests</td>
|
||||
<td class="author">Jules Tan</td>
|
||||
<td><span class="risk"><span class="risk-dot low"></span>Low</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4888</a></td>
|
||||
<td>Connection pool limits configurable per worker tier</td>
|
||||
<td class="author">Devon Park</td>
|
||||
<td><span class="risk"><span class="risk-dot med"></span>Med</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a class="pr-link" href="#">#4891</a></td>
|
||||
<td>Dark mode pass on settings & billing panels</td>
|
||||
<td class="author">Noor Halabi</td>
|
||||
<td><span class="risk"><span class="risk-dot low"></span>Low</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Velocity -->
|
||||
<section>
|
||||
<h2>Velocity</h2>
|
||||
<hr class="rule">
|
||||
<div class="chart-panel">
|
||||
<svg viewBox="0 0 640 180" role="img" aria-label="PRs merged per day">
|
||||
<!-- gridlines -->
|
||||
<line x1="48" y1="20" x2="620" y2="20" stroke="#F0EEE6" stroke-width="1"/>
|
||||
<line x1="48" y1="60" x2="620" y2="60" stroke="#F0EEE6" stroke-width="1"/>
|
||||
<line x1="48" y1="100" x2="620" y2="100" stroke="#F0EEE6" stroke-width="1"/>
|
||||
<line x1="48" y1="140" x2="620" y2="140" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
|
||||
<!-- y-axis labels -->
|
||||
<text x="40" y="144" text-anchor="end" font-family="system-ui" font-size="11" fill="#87867F">0</text>
|
||||
<text x="40" y="104" text-anchor="end" font-family="system-ui" font-size="11" fill="#87867F">1</text>
|
||||
<text x="40" y="64" text-anchor="end" font-family="system-ui" font-size="11" fill="#87867F">2</text>
|
||||
<text x="40" y="24" text-anchor="end" font-family="system-ui" font-size="11" fill="#87867F">3</text>
|
||||
|
||||
<!-- bars: 7 days, values 2,3,1,4,2,1,1 (sum 14)
|
||||
baseline y=140, unit=40px, bar width=56, gap=24, start x=64 -->
|
||||
<!-- Mon: 2 -->
|
||||
<rect x="64" y="60" width="56" height="80" rx="6" fill="#E3DACC"/>
|
||||
<!-- Tue: 3 -->
|
||||
<rect x="144" y="20" width="56" height="120" rx="6" fill="#E3DACC"/>
|
||||
<!-- Wed: 1 -->
|
||||
<rect x="224" y="100" width="56" height="40" rx="6" fill="#E3DACC"/>
|
||||
<!-- Thu: 4 (peak) -->
|
||||
<rect x="304" y="0" width="56" height="140" rx="6" fill="#D97757"/>
|
||||
<!-- Fri: 2 -->
|
||||
<rect x="384" y="60" width="56" height="80" rx="6" fill="#E3DACC"/>
|
||||
<!-- Sat: 1 -->
|
||||
<rect x="464" y="100" width="56" height="40" rx="6" fill="#E3DACC"/>
|
||||
<!-- Sun: 1 -->
|
||||
<rect x="544" y="100" width="56" height="40" rx="6" fill="#E3DACC"/>
|
||||
|
||||
<!-- value labels -->
|
||||
<text x="92" y="54" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">2</text>
|
||||
<text x="172" y="14" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">3</text>
|
||||
<text x="252" y="94" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">1</text>
|
||||
<text x="332" y="14" text-anchor="middle" font-family="system-ui" font-size="11" fill="#3D3D3A" font-weight="600">4</text>
|
||||
<text x="412" y="54" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">2</text>
|
||||
<text x="492" y="94" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">1</text>
|
||||
<text x="572" y="94" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">1</text>
|
||||
|
||||
<!-- x-axis labels -->
|
||||
<text x="92" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Mon</text>
|
||||
<text x="172" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Tue</text>
|
||||
<text x="252" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Wed</text>
|
||||
<text x="332" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Thu</text>
|
||||
<text x="412" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Fri</text>
|
||||
<text x="492" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Sat</text>
|
||||
<text x="572" y="158" text-anchor="middle" font-family="system-ui" font-size="11" fill="#87867F">Sun</text>
|
||||
</svg>
|
||||
<div class="chart-caption">
|
||||
PRs merged per day. Thursday spike is the bulk-edit feature train (4 PRs landed together).
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Carryover -->
|
||||
<section>
|
||||
<h2>Carryover</h2>
|
||||
<hr class="rule">
|
||||
<div class="carryover">
|
||||
<div class="carry-item">
|
||||
<span class="carry-tag">In review</span>
|
||||
<div class="carry-body">
|
||||
Workspace export to CSV — waiting on pagination review.
|
||||
<span class="who">· Sam Reyes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carry-item">
|
||||
<span class="carry-tag">Blocked</span>
|
||||
<div class="carry-body">
|
||||
SSO group mapping — blocked on staging IdP credentials from IT.
|
||||
<span class="who">· Priya Anand</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carry-item">
|
||||
<span class="carry-tag">Slipped</span>
|
||||
<div class="carry-body">
|
||||
Mobile push reliability dashboard — deprioritized for incident follow-up.
|
||||
<span class="who">· Devon Park</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Sources: git log main..HEAD · CI dashboard · deploy log
|
||||
— generated Mar 16 2025 18:02
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,597 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>INC-2025-0412 — Elevated 502s on task sync</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--rust: #B04A3F;
|
||||
--gray-100: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, serif;
|
||||
--sans: system-ui, -apple-system, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
|
||||
|
||||
--radius-panel: 12px;
|
||||
--radius-row: 8px;
|
||||
--border: 1.5px solid var(--gray-300);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 56px 24px 120px;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.inc-id {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 36px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 18px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 12px;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 5px 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pill .k {
|
||||
font-weight: 400;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.pill.sev {
|
||||
background: var(--clay);
|
||||
color: var(--white);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.pill.resolved {
|
||||
background: var(--olive);
|
||||
color: var(--white);
|
||||
}
|
||||
.pill.neutral {
|
||||
background: var(--gray-100);
|
||||
color: var(--gray-700);
|
||||
border: var(--border);
|
||||
}
|
||||
.pill.neutral .v {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
/* ---------- TL;DR ---------- */
|
||||
.tldr {
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 22px 26px;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.tldr-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--oat);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tldr p {
|
||||
margin: 0;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.tldr code {
|
||||
font-family: var(--mono);
|
||||
font-size: 13.5px;
|
||||
background: rgba(250, 249, 245, 0.12);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Sections ---------- */
|
||||
section {
|
||||
margin-bottom: 52px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
hr.rule {
|
||||
border: none;
|
||||
border-top: 1px solid var(--gray-300);
|
||||
margin: 0 0 22px;
|
||||
}
|
||||
p { margin: 0 0 14px; }
|
||||
code.inline {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
background: var(--gray-100);
|
||||
padding: 1.5px 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ---------- Timeline ---------- */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding: 4px 0 4px 16px;
|
||||
}
|
||||
.timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: var(--gray-300);
|
||||
}
|
||||
.tl-entry {
|
||||
position: relative;
|
||||
padding: 0 0 22px 28px;
|
||||
}
|
||||
.tl-entry:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tl-dot {
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray-500);
|
||||
border: 2px solid var(--ivory);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.tl-dot.impact { background: var(--clay); }
|
||||
.tl-dot.mitigated { background: var(--olive); }
|
||||
.tl-time {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-700);
|
||||
background: var(--gray-100);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tl-body {
|
||||
font-size: 14px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.tl-body strong {
|
||||
color: var(--slate);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- Code diff ---------- */
|
||||
.code-panel {
|
||||
background: var(--slate);
|
||||
color: var(--gray-100);
|
||||
border-radius: var(--radius-panel);
|
||||
padding: 18px 20px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.code-panel .path {
|
||||
color: var(--gray-500);
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
.diff-line { white-space: pre; }
|
||||
.diff-line.ctx { color: var(--gray-300); }
|
||||
.diff-line.del { color: #E0897A; }
|
||||
.diff-line.add { color: #A3B88A; }
|
||||
|
||||
/* ---------- Impact table ---------- */
|
||||
table.impact {
|
||||
width: 100%;
|
||||
max-width: 460px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
table.impact th,
|
||||
table.impact td {
|
||||
text-align: left;
|
||||
padding: 12px 18px;
|
||||
font-size: 14px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
table.impact tr:last-child th,
|
||||
table.impact tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
table.impact th {
|
||||
font-weight: 400;
|
||||
color: var(--gray-500);
|
||||
width: 55%;
|
||||
}
|
||||
table.impact td {
|
||||
font-family: var(--mono);
|
||||
color: var(--slate);
|
||||
}
|
||||
|
||||
/* ---------- Action items ---------- */
|
||||
.actions {
|
||||
background: var(--white);
|
||||
border: var(--border);
|
||||
border-radius: var(--radius-panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ai-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 36px 1fr 96px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
.ai-row:last-child { border-bottom: none; }
|
||||
.ai-check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 5px;
|
||||
background: var(--white);
|
||||
position: relative;
|
||||
}
|
||||
.ai-row.done .ai-check {
|
||||
background: var(--olive);
|
||||
border-color: var(--olive);
|
||||
}
|
||||
.ai-row.done .ai-check::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--white);
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(40deg);
|
||||
}
|
||||
.ai-row.done .ai-desc {
|
||||
color: var(--gray-500);
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--gray-300);
|
||||
}
|
||||
.ai-avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--oat);
|
||||
color: var(--gray-700);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ai-desc {
|
||||
font-size: 14px;
|
||||
color: var(--slate);
|
||||
}
|
||||
.ai-due {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ---------- Fixed TOC ---------- */
|
||||
.toc {
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 1100px) {
|
||||
.toc {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: max(24px, calc(50vw - 410px - 200px));
|
||||
width: 170px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.toc-title {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.toc a {
|
||||
display: block;
|
||||
color: var(--gray-500);
|
||||
text-decoration: none;
|
||||
padding: 5px 0 5px 12px;
|
||||
border-left: 1.5px solid var(--gray-300);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.toc a:hover {
|
||||
color: var(--slate);
|
||||
border-left-color: var(--clay);
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 64px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--gray-300);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="toc">
|
||||
<div class="toc-title">On this page</div>
|
||||
<a href="#tldr">TL;DR</a>
|
||||
<a href="#timeline">Timeline</a>
|
||||
<a href="#root-cause">Root cause</a>
|
||||
<a href="#impact">Impact</a>
|
||||
<a href="#actions">Action items</a>
|
||||
</nav>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<div class="inc-id">INC-2025-0412</div>
|
||||
<h1>Elevated 502s on task sync</h1>
|
||||
<div class="meta-row">
|
||||
<span class="pill sev">SEV-2</span>
|
||||
<span class="pill resolved">Resolved</span>
|
||||
<span class="pill neutral"><span class="k">Duration</span> <span class="v">47 min</span></span>
|
||||
<span class="pill neutral"><span class="k">Detected</span> <span class="v">Apr 12 · 14:07</span></span>
|
||||
<span class="pill neutral"><span class="k">Owner</span> <span class="v">Devon Park</span></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tldr" id="tldr">
|
||||
<div class="tldr-label">TL;DR</div>
|
||||
<p>
|
||||
A config rollout lowered the database connection-pool limit on the
|
||||
<code>sync-worker</code> tier from 64 to 8, exhausting connections under
|
||||
normal afternoon load. The sync API returned 502s for roughly 21% of
|
||||
requests over 47 minutes. We mitigated by reverting the config and
|
||||
cycling the worker fleet; no data was lost.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<section id="timeline">
|
||||
<h2>Timeline</h2>
|
||||
<hr class="rule">
|
||||
<div class="timeline">
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot"></span>
|
||||
<span class="tl-time">14:02</span>
|
||||
<div class="tl-body">
|
||||
Config change <code class="inline">cfg-9a12</code> promoted to
|
||||
production via the standard rollout pipeline.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot impact"></span>
|
||||
<span class="tl-time">14:06</span>
|
||||
<div class="tl-body">
|
||||
<strong>Impact starts.</strong> Sync workers begin queueing on pool
|
||||
checkout; p95 latency climbs past 4s and the load balancer starts
|
||||
returning 502s.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot"></span>
|
||||
<span class="tl-time">14:07</span>
|
||||
<div class="tl-body">
|
||||
Alert fires: <code class="inline">sync_5xx_rate > 2%</code> for 60s.
|
||||
On-call (Devon) acknowledges.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot"></span>
|
||||
<span class="tl-time">14:18</span>
|
||||
<div class="tl-body">
|
||||
Initial hypothesis is a bad deploy of the API service; last two
|
||||
application deploys are rolled back with no effect.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot"></span>
|
||||
<span class="tl-time">14:31</span>
|
||||
<div class="tl-body">
|
||||
Mira joins and notices pool-wait saturation in the worker dashboard.
|
||||
Investigation pivots to infra config rather than app code.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot mitigated"></span>
|
||||
<span class="tl-time">14:44</span>
|
||||
<div class="tl-body">
|
||||
<strong>Mitigated.</strong> <code class="inline">cfg-9a12</code>
|
||||
reverted; worker fleet cycled. 5xx rate drops below 0.2% within
|
||||
three minutes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tl-entry">
|
||||
<span class="tl-dot"></span>
|
||||
<span class="tl-time">14:49</span>
|
||||
<div class="tl-body">
|
||||
Monitors green for 5 minutes. Incident declared resolved; status
|
||||
page updated.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Root cause -->
|
||||
<section id="root-cause">
|
||||
<h2>Root cause</h2>
|
||||
<hr class="rule">
|
||||
<p>
|
||||
PR <code class="inline">#4888</code> made connection-pool limits
|
||||
configurable per worker tier. The default for the new
|
||||
<code class="inline">sync-worker</code> key was meant to inherit the
|
||||
global value (64) but was hard-coded to 8 during a local test and
|
||||
committed. The config linter only validates type, not magnitude, so the
|
||||
change passed CI.
|
||||
</p>
|
||||
<p>
|
||||
Because config rollouts and code deploys go through separate pipelines,
|
||||
the on-call's first instinct — rolling back the most recent
|
||||
application deploys — had no effect and cost roughly 13 minutes of
|
||||
diagnosis time.
|
||||
</p>
|
||||
|
||||
<div class="code-panel">
|
||||
<span class="path">infra/config/workers.yaml</span>
|
||||
<div class="diff-line ctx"> pool:</div>
|
||||
<div class="diff-line ctx"> global_max_connections: 64</div>
|
||||
<div class="diff-line ctx"> tiers:</div>
|
||||
<div class="diff-line del">- sync-worker: { max_connections: 64 }</div>
|
||||
<div class="diff-line add">+ sync-worker: { max_connections: 8 } # debug value, do not ship</div>
|
||||
<div class="diff-line ctx"> webhook-worker: { max_connections: 32 }</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Impact -->
|
||||
<section id="impact">
|
||||
<h2>Impact</h2>
|
||||
<hr class="rule">
|
||||
<table class="impact">
|
||||
<tr>
|
||||
<th>Requests failed (502)</th>
|
||||
<td>~41,200</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Peak error rate</th>
|
||||
<td>21.4%</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Users affected</th>
|
||||
<td>~2,300 workspaces</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Data loss</th>
|
||||
<td>None — clients retried</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>SLA breach</th>
|
||||
<td>No (within monthly budget)</td>
|
||||
</tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Action items -->
|
||||
<section id="actions">
|
||||
<h2>Action items</h2>
|
||||
<hr class="rule">
|
||||
<div class="actions">
|
||||
|
||||
<div class="ai-row done">
|
||||
<span class="ai-check"></span>
|
||||
<span class="ai-avatar" title="Devon Park">DP</span>
|
||||
<span class="ai-desc">Revert cfg-9a12 and restore pool limit to 64</span>
|
||||
<span class="ai-due">Apr 12</span>
|
||||
</div>
|
||||
|
||||
<div class="ai-row">
|
||||
<span class="ai-check"></span>
|
||||
<span class="ai-avatar" title="Mira Okafor">MO</span>
|
||||
<span class="ai-desc">Add config-linter range check for <code class="inline">max_connections</code> (warn < 32)</span>
|
||||
<span class="ai-due">Apr 18</span>
|
||||
</div>
|
||||
|
||||
<div class="ai-row">
|
||||
<span class="ai-check"></span>
|
||||
<span class="ai-avatar" title="Sam Reyes">SR</span>
|
||||
<span class="ai-desc">Surface “recent config rollouts” alongside deploys in the on-call dashboard</span>
|
||||
<span class="ai-due">Apr 25</span>
|
||||
</div>
|
||||
|
||||
<div class="ai-row">
|
||||
<span class="ai-check"></span>
|
||||
<span class="ai-avatar" title="Priya Anand">PA</span>
|
||||
<span class="ai-desc">Canary config changes to one worker AZ for 10 min before fleet-wide promote</span>
|
||||
<span class="ai-due">May 02</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Authored from on-call notes + alert history · reviewed by Devon Park, Mira Okafor
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,396 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Deploy pipeline — annotated flowchart</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--rust: #B04A3F;
|
||||
--gray-150:#F0EEE6;
|
||||
--gray-300:#D1CFC5;
|
||||
--gray-500:#87867F;
|
||||
--gray-700:#3D3D3A;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding: 56px 24px 96px;
|
||||
}
|
||||
.sheet { max-width: 980px; margin: 0 auto; }
|
||||
|
||||
header { margin-bottom: 40px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.lead {
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-700);
|
||||
max-width: 640px;
|
||||
}
|
||||
.lead code { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
/* ── layout ───────────────────────────── */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
gap: 32px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 920px) { .layout { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── chart ────────────────────────────── */
|
||||
.canvas {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 28px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
svg.flow { display: block; width: 100%; height: auto; }
|
||||
|
||||
.flow text {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
fill: var(--slate);
|
||||
pointer-events: none;
|
||||
}
|
||||
.flow .sub { font-size: 10px; fill: var(--gray-500); }
|
||||
.flow .lbl { font-size: 10px; fill: var(--gray-500); }
|
||||
|
||||
.node rect { fill: #fff; stroke: var(--gray-300); stroke-width: 1.5; rx: 8; }
|
||||
.node.term rect { fill: var(--gray-150); rx: 22; }
|
||||
.node.gate path { fill: #fff; stroke: var(--gray-300); stroke-width: 1.5; }
|
||||
.node.ok rect { fill: rgba(120,140,93,0.12); stroke: var(--olive); }
|
||||
.node.bad rect { fill: rgba(176,74,63,0.10); stroke: var(--rust); }
|
||||
|
||||
.node { cursor: pointer; transition: transform 120ms ease; }
|
||||
.node:hover { transform: translateY(-1px); }
|
||||
.node.active rect,
|
||||
.node.active path { stroke: var(--clay); stroke-width: 2; }
|
||||
|
||||
.edge { stroke: var(--gray-500); stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
|
||||
.edge.no { stroke: var(--rust); stroke-dasharray: 4 4; marker-end: url(#arrow-rust); }
|
||||
.edge.yes { stroke: var(--olive); marker-end: url(#arrow-olive); }
|
||||
|
||||
/* ── side panel ───────────────────────── */
|
||||
aside {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 20px 20px 22px;
|
||||
}
|
||||
aside .hint {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
aside h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
aside .meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
aside p {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-700);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
aside pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
background: var(--gray-150);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
white-space: pre-wrap;
|
||||
color: var(--slate);
|
||||
}
|
||||
|
||||
/* ── legend ───────────────────────────── */
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
font-size: 12px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.legend span { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.chip { width: 22px; height: 14px; border: 1.5px solid var(--gray-300); border-radius: 4px; background: #fff; }
|
||||
.chip.gate { transform: rotate(45deg); width: 12px; height: 12px; border-radius: 2px; }
|
||||
.chip.ok { background: rgba(120,140,93,0.12); border-color: var(--olive); }
|
||||
.chip.bad { background: rgba(176,74,63,0.10); border-color: var(--rust); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="sheet">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Illustrations & Diagrams · Flowchart</div>
|
||||
<h1>What happens when you <code>git push</code></h1>
|
||||
<p class="lead">
|
||||
The deploy pipeline for <code>acme/web</code>, drawn from <code>.github/workflows/</code> and the
|
||||
Argo manifests. Click any step to see what runs, how long it usually takes, and where it can short-circuit.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
|
||||
<div>
|
||||
<div class="canvas">
|
||||
<svg class="flow" viewBox="0 0 620 920">
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#87867F"/>
|
||||
</marker>
|
||||
<marker id="arrow-rust" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#B04A3F"/>
|
||||
</marker>
|
||||
<marker id="arrow-olive" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#788C5D"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- edges -->
|
||||
<path class="edge" d="M310,56 L310,92"/>
|
||||
<path class="edge" d="M310,140 L310,176"/>
|
||||
<path class="edge" d="M310,224 L310,262"/>
|
||||
<path class="edge yes" d="M310,326 L310,368"/>
|
||||
<text class="lbl" x="320" y="350">pass</text>
|
||||
<path class="edge no" d="M268,294 C190,294 150,294 150,212 L150,212"/>
|
||||
<text class="lbl" x="138" y="260" fill="#B04A3F">fail → status</text>
|
||||
<path class="edge" d="M310,416 L310,452"/>
|
||||
<path class="edge" d="M310,500 L310,538"/>
|
||||
<path class="edge yes" d="M310,602 L310,644"/>
|
||||
<text class="lbl" x="320" y="626">healthy</text>
|
||||
<path class="edge no" d="M352,570 C470,570 470,690 392,720"/>
|
||||
<text class="lbl" x="448" y="636" fill="#B04A3F">canary fails</text>
|
||||
<path class="edge" d="M310,692 L310,730"/>
|
||||
<path class="edge" d="M310,778 L310,816"/>
|
||||
|
||||
<!-- nodes -->
|
||||
<g class="node term" data-k="push">
|
||||
<rect x="230" y="12" width="160" height="44"/>
|
||||
<text x="310" y="38" text-anchor="middle">git push main</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-k="ci">
|
||||
<rect x="210" y="92" width="200" height="48"/>
|
||||
<text x="310" y="112" text-anchor="middle">CI · lint + typecheck</text>
|
||||
<text class="sub" x="310" y="128" text-anchor="middle">~2 min · ci.yml</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-k="test">
|
||||
<rect x="210" y="176" width="200" height="48"/>
|
||||
<text x="310" y="196" text-anchor="middle">Unit + integration tests</text>
|
||||
<text class="sub" x="310" y="212" text-anchor="middle">~6 min · 3 shards</text>
|
||||
</g>
|
||||
|
||||
<g class="node gate" data-k="gate-tests">
|
||||
<path d="M310,262 L352,294 L310,326 L268,294 Z"/>
|
||||
<text x="310" y="298" text-anchor="middle">pass?</text>
|
||||
</g>
|
||||
|
||||
<g class="node bad" data-k="status">
|
||||
<rect x="60" y="176" width="180" height="48"/>
|
||||
<text x="150" y="196" text-anchor="middle">Post failure status</text>
|
||||
<text class="sub" x="150" y="212" text-anchor="middle">slack #deploys</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-k="build">
|
||||
<rect x="210" y="368" width="200" height="48"/>
|
||||
<text x="310" y="388" text-anchor="middle">Build + push image</text>
|
||||
<text class="sub" x="310" y="404" text-anchor="middle">ghcr.io/acme/web</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-k="canary">
|
||||
<rect x="210" y="452" width="200" height="48"/>
|
||||
<text x="310" y="472" text-anchor="middle">Argo canary 5%</text>
|
||||
<text class="sub" x="310" y="488" text-anchor="middle">10 min soak</text>
|
||||
</g>
|
||||
|
||||
<g class="node gate" data-k="gate-canary">
|
||||
<path d="M310,538 L352,570 L310,602 L268,570 Z"/>
|
||||
<text x="310" y="574" text-anchor="middle">SLO ok?</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-k="promote">
|
||||
<rect x="210" y="644" width="200" height="48"/>
|
||||
<text x="310" y="664" text-anchor="middle">Promote 25 → 50 → 100%</text>
|
||||
<text class="sub" x="310" y="680" text-anchor="middle">~8 min</text>
|
||||
</g>
|
||||
|
||||
<g class="node bad" data-k="rollback">
|
||||
<rect x="392" y="700" width="170" height="48"/>
|
||||
<text x="477" y="720" text-anchor="middle">Auto-rollback</text>
|
||||
<text class="sub" x="477" y="736" text-anchor="middle">revert image tag</text>
|
||||
</g>
|
||||
|
||||
<g class="node" data-k="smoke">
|
||||
<rect x="210" y="730" width="200" height="48"/>
|
||||
<text x="310" y="750" text-anchor="middle">Smoke tests in prod</text>
|
||||
<text class="sub" x="310" y="766" text-anchor="middle">playwright · 90s</text>
|
||||
</g>
|
||||
|
||||
<g class="node ok term" data-k="done">
|
||||
<rect x="230" y="816" width="160" height="44"/>
|
||||
<text x="310" y="843" text-anchor="middle">✅ Deploy complete</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span><i class="chip"></i> process step</span>
|
||||
<span><i class="chip gate"></i> decision</span>
|
||||
<span><i class="chip ok"></i> terminal success</span>
|
||||
<span><i class="chip bad"></i> failure path</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside id="panel">
|
||||
<div class="hint">Click a step in the chart →</div>
|
||||
<h3 id="p-title">git push main</h3>
|
||||
<div class="meta" id="p-meta">trigger · 0s</div>
|
||||
<p id="p-body">A push or merge to <code>main</code> fires the <code>deploy</code> workflow. Pushes to other branches stop after CI and never reach the image build.</p>
|
||||
<pre id="p-code">on:
|
||||
push:
|
||||
branches: [main]</pre>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const DETAIL = {
|
||||
push: {
|
||||
title: "git push main",
|
||||
meta: "trigger · 0s",
|
||||
body: "A push or merge to <code>main</code> fires the <code>deploy</code> workflow. Pushes to other branches stop after CI and never reach the image build.",
|
||||
code: "on:\n push:\n branches: [main]"
|
||||
},
|
||||
ci: {
|
||||
title: "CI · lint + typecheck",
|
||||
meta: "github actions · ~2 min",
|
||||
body: "Runs ESLint and <code>tsc --noEmit</code> in parallel. This is the only job that also runs on pull-request branches, so it stays fast on purpose.",
|
||||
code: "jobs:\n lint:\n run: pnpm lint && pnpm typecheck"
|
||||
},
|
||||
test: {
|
||||
title: "Unit + integration tests",
|
||||
meta: "github actions · ~6 min · 3 shards",
|
||||
body: "Vitest unit suite plus the API integration tests against an ephemeral Postgres. Sharded three ways; the slowest shard gates the pipeline.",
|
||||
code: "strategy:\n matrix:\n shard: [1, 2, 3]\nrun: pnpm test --shard ${{ matrix.shard }}/3"
|
||||
},
|
||||
"gate-tests": {
|
||||
title: "Tests pass?",
|
||||
meta: "decision",
|
||||
body: "Any shard failing short-circuits here. Nothing is built, and a red status is posted back to the PR and to <code>#deploys</code>.",
|
||||
code: "needs: [test]\nif: success()"
|
||||
},
|
||||
status: {
|
||||
title: "Post failure status",
|
||||
meta: "side-effect · ~5s",
|
||||
body: "The <code>notify</code> job posts the failing shard's summary to Slack and sets the commit status to <code>failure</code>. Pipeline ends.",
|
||||
code: "uses: slackapi/slack-github-action@v1\nwith:\n channel: '#deploys'"
|
||||
},
|
||||
build: {
|
||||
title: "Build + push image",
|
||||
meta: "github actions · ~4 min",
|
||||
body: "Docker buildx with layer caching. Tags the image with the short SHA and pushes to GHCR. This is the first step that produces an artifact.",
|
||||
code: "tags: ghcr.io/acme/web:${{ github.sha }}"
|
||||
},
|
||||
canary: {
|
||||
title: "Argo canary 5%",
|
||||
meta: "argo rollouts · 10 min soak",
|
||||
body: "Argo shifts 5% of traffic to the new ReplicaSet and watches the error-rate and p95 SLOs for ten minutes before deciding.",
|
||||
code: "steps:\n - setWeight: 5\n - pause: { duration: 10m }\n - analysis: slo-check"
|
||||
},
|
||||
"gate-canary": {
|
||||
title: "Canary healthy?",
|
||||
meta: "decision · analysis template",
|
||||
body: "The <code>slo-check</code> analysis compares error rate and p95 against the previous revision. Either metric breaching for two consecutive intervals fails the gate.",
|
||||
code: "successCondition: result.error_rate < 0.5\n && result.p95_ms < 800"
|
||||
},
|
||||
promote: {
|
||||
title: "Promote 25 → 50 → 100%",
|
||||
meta: "argo rollouts · ~8 min",
|
||||
body: "Three more weight steps with a short pause between each. The same analysis runs at every step, so a late regression still aborts.",
|
||||
code: "- setWeight: 25\n- pause: { duration: 2m }\n- setWeight: 50\n- pause: { duration: 2m }\n- setWeight: 100"
|
||||
},
|
||||
rollback: {
|
||||
title: "Auto-rollback",
|
||||
meta: "argo rollouts · ~30s",
|
||||
body: "On a failed analysis Argo flips traffic back to the stable ReplicaSet and marks the rollout <code>Degraded</code>. A page goes to the on-call.",
|
||||
code: "abort:\n scaleDownDelaySeconds: 30"
|
||||
},
|
||||
smoke: {
|
||||
title: "Smoke tests in prod",
|
||||
meta: "github actions · ~90s",
|
||||
body: "A tiny Playwright suite hits the public URL: load the homepage, sign in with a synthetic account, create and delete one record.",
|
||||
code: "run: pnpm playwright test smoke/\nenv:\n BASE_URL: https://acme.app"
|
||||
},
|
||||
done: {
|
||||
title: "Deploy complete",
|
||||
meta: "terminal · ~30 min total",
|
||||
body: "Commit status flips to <code>success</code>, the rollout is marked <code>Healthy</code>, and the SHA is recorded as the new stable revision.",
|
||||
code: "✓ web@a1b9e3f live on prod"
|
||||
}
|
||||
};
|
||||
|
||||
const nodes = document.querySelectorAll(".node");
|
||||
const T = document.getElementById("p-title");
|
||||
const M = document.getElementById("p-meta");
|
||||
const B = document.getElementById("p-body");
|
||||
const C = document.getElementById("p-code");
|
||||
|
||||
nodes.forEach(n => {
|
||||
n.addEventListener("click", () => {
|
||||
nodes.forEach(x => x.classList.remove("active"));
|
||||
n.classList.add("active");
|
||||
const d = DETAIL[n.dataset.k];
|
||||
if (!d) return;
|
||||
T.textContent = d.title;
|
||||
M.textContent = d.meta;
|
||||
B.innerHTML = d.body;
|
||||
C.textContent = d.code;
|
||||
});
|
||||
});
|
||||
document.querySelector('.node[data-k="push"]').classList.add("active");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>How rate limiting works in acme/api</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150:#F0EEE6;
|
||||
--gray-300:#D1CFC5;
|
||||
--gray-500:#87867F;
|
||||
--gray-700:#3D3D3A;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding: 56px 24px 120px;
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr);
|
||||
gap: 48px;
|
||||
}
|
||||
@media (max-width: 920px) { .page { grid-template-columns: 1fr; } nav { display: none; } }
|
||||
|
||||
/* ── nav ──────────────────────────────── */
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 32px;
|
||||
align-self: start;
|
||||
font-size: 13px;
|
||||
}
|
||||
nav .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
nav a {
|
||||
display: block;
|
||||
padding: 5px 0 5px 12px;
|
||||
border-left: 2px solid var(--gray-300);
|
||||
color: var(--gray-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a:hover { color: var(--slate); border-color: var(--slate); }
|
||||
nav a.l2 { padding-left: 24px; font-size: 12.5px; color: var(--gray-500); }
|
||||
nav .files {
|
||||
margin-top: 28px;
|
||||
border-top: 1px solid var(--gray-300);
|
||||
padding-top: 16px;
|
||||
}
|
||||
nav .files code {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
/* ── header ───────────────────────────── */
|
||||
header { margin-bottom: 12px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 32px;
|
||||
color: var(--slate);
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.tldr {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-left: 3px solid var(--clay);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tldr b { color: var(--slate); }
|
||||
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
color: var(--slate);
|
||||
margin: 40px 0 14px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
p { margin-bottom: 12px; max-width: 680px; }
|
||||
code { font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
/* ── collapsible ─────────────────────── */
|
||||
details {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
margin: 14px 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 14px 16px;
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
color: var(--slate);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before {
|
||||
content: "▸";
|
||||
color: var(--clay);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
transition: transform 120ms;
|
||||
}
|
||||
details[open] summary::before { transform: rotate(90deg); }
|
||||
summary .where {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
margin-left: auto;
|
||||
}
|
||||
details .body { padding: 0 16px 16px; }
|
||||
details .body p { font-size: 14px; }
|
||||
|
||||
/* ── tabs ─────────────────────────────── */
|
||||
.tabs {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
margin: 16px 0 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tabbar {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--gray-300);
|
||||
background: var(--gray-150);
|
||||
}
|
||||
.tabbar button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
border-right: 1px solid var(--gray-300);
|
||||
}
|
||||
.tabbar button.on {
|
||||
background: #fff;
|
||||
color: var(--slate);
|
||||
border-bottom: 2px solid var(--clay);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.tabs pre {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--slate);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tabs pre.on { display: block; }
|
||||
.hl { color: var(--clay); }
|
||||
.cm { color: var(--gray-500); }
|
||||
|
||||
/* ── callout ──────────────────────────── */
|
||||
.callout {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border: 1.5px solid var(--oat);
|
||||
background: rgba(227,218,204,0.35);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
margin: 18px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.callout .ico { color: var(--clay); font-weight: 600; }
|
||||
|
||||
/* ── faq ──────────────────────────────── */
|
||||
dl.faq { margin-top: 8px; }
|
||||
dl.faq dt {
|
||||
font-family: var(--serif);
|
||||
font-size: 16px;
|
||||
color: var(--slate);
|
||||
margin-top: 18px;
|
||||
}
|
||||
dl.faq dd { font-size: 14px; margin: 4px 0 0; max-width: 640px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ── side nav ── -->
|
||||
<nav>
|
||||
<div class="label">On this page</div>
|
||||
<a href="#tldr">TL;DR</a>
|
||||
<a href="#path">Request path</a>
|
||||
<a href="#path" class="l2">1. Identify</a>
|
||||
<a href="#path" class="l2">2. Bucket lookup</a>
|
||||
<a href="#path" class="l2">3. Consume</a>
|
||||
<a href="#path" class="l2">4. Reject</a>
|
||||
<a href="#config">Configuring a route</a>
|
||||
<a href="#gotchas">Gotchas</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<div class="files">
|
||||
<div class="label">Files read</div>
|
||||
<code>middleware/ratelimit.ts</code>
|
||||
<code>lib/tokenBucket.ts</code>
|
||||
<code>config/limits.yaml</code>
|
||||
<code>routes/*.ts</code>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- ── main ── -->
|
||||
<main>
|
||||
<header>
|
||||
<div class="eyebrow">Research & Learning · feature summary</div>
|
||||
<h1>How rate limiting works in <code>acme/api</code></h1>
|
||||
<div class="tldr" id="tldr">
|
||||
<b>TL;DR</b> — Every request passes through <code>rateLimit()</code> middleware, which resolves the
|
||||
caller to a <em>bucket key</em>, fetches a token-bucket from Redis, and either consumes one token or
|
||||
returns <code>429</code>. Limits are declared per-route in <code>config/limits.yaml</code>; routes
|
||||
without an entry inherit the <code>default</code> tier (100 req/min per API key).
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h2 id="path">The request path, step by step</h2>
|
||||
<p>Expand each step to see what runs and where it lives. The whole path is ~40 lines and adds about
|
||||
0.4 ms p50 to every request.</p>
|
||||
|
||||
<details open>
|
||||
<summary>1 · Identify the caller <span class="where">middleware/ratelimit.ts:21</span></summary>
|
||||
<div class="body">
|
||||
<p>The middleware first reduces the request to a <code>bucketKey</code>: API key if an
|
||||
<code>Authorization</code> header is present, otherwise the client IP (via the
|
||||
<code>x-forwarded-for</code> chain, trusting only our own LB). Anonymous IP traffic gets a much
|
||||
lower default tier.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>2 · Look up the bucket <span class="where">lib/tokenBucket.ts:9</span></summary>
|
||||
<div class="body">
|
||||
<p>The route name plus bucket key map to a Redis hash (<code>rl:{route}:{key}</code>) holding
|
||||
<code>tokens</code> and <code>updatedAt</code>. If the key is missing it's created lazily at full
|
||||
capacity — there's no warm-up.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>3 · Refill and consume <span class="where">lib/tokenBucket.ts:31</span></summary>
|
||||
<div class="body">
|
||||
<p>Refill is computed from elapsed time (<code>rate × Δt</code>, capped at <code>burst</code>), then
|
||||
one token is subtracted. The whole read-modify-write runs as a single Lua script so concurrent
|
||||
requests can't double-spend.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>4 · Reject when empty <span class="where">middleware/ratelimit.ts:48</span></summary>
|
||||
<div class="body">
|
||||
<p>If the script returns <code>tokens < 0</code> the middleware short-circuits with
|
||||
<code>429 Too Many Requests</code> and sets <code>Retry-After</code> to the seconds until one token
|
||||
refills. Successful responses always carry <code>X-RateLimit-Remaining</code>.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<h2 id="config">Configuring a limit on your route</h2>
|
||||
<p>You don't touch the middleware. Add an entry to <code>config/limits.yaml</code> keyed by route name,
|
||||
and (optionally) tag the route so the middleware can find it.</p>
|
||||
|
||||
<div class="tabs" data-tabs>
|
||||
<div class="tabbar">
|
||||
<button class="on" data-t="0">limits.yaml</button>
|
||||
<button data-t="1">route.ts</button>
|
||||
<button data-t="2">client response</button>
|
||||
</div>
|
||||
<pre class="on"><span class="cm"># config/limits.yaml</span>
|
||||
default:
|
||||
rate: 100/min
|
||||
burst: 120
|
||||
|
||||
<span class="hl">search.query</span>:
|
||||
rate: 20/min
|
||||
burst: 40
|
||||
key: api_key <span class="cm"># or: ip</span></pre>
|
||||
<pre><span class="cm">// routes/search.ts</span>
|
||||
router.post(
|
||||
"/search",
|
||||
<span class="hl">rateLimit("search.query")</span>,
|
||||
handler,
|
||||
);</pre>
|
||||
<pre>HTTP/1.1 429 Too Many Requests
|
||||
Retry-After: 17
|
||||
X-RateLimit-Limit: 20
|
||||
X-RateLimit-Remaining: 0
|
||||
|
||||
{ "error": "rate_limited", "retry_after": 17 }</pre>
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<span class="ico">★</span>
|
||||
<div>If you only need the default tier, you don't need a YAML entry at all — just wrap the handler in
|
||||
<code>rateLimit()</code> with no argument. The route name is inferred from the path.</div>
|
||||
</div>
|
||||
|
||||
<h2 id="gotchas">Gotchas worth knowing</h2>
|
||||
<ul style="padding-left:20px; max-width:680px;">
|
||||
<li style="margin-bottom:8px;"><b>Limits are per-process in dev.</b> The Redis client falls back to an
|
||||
in-memory map when <code>REDIS_URL</code> is unset, so local testing won't reflect real cluster
|
||||
behaviour.</li>
|
||||
<li style="margin-bottom:8px;"><b>Burst ≠ rate.</b> <code>burst</code> is the bucket capacity; a caller
|
||||
idle for a minute can fire <code>burst</code> requests instantly even if <code>rate</code> is low.</li>
|
||||
<li><b>Streaming responses count once.</b> The token is consumed at request start; a 30-second SSE
|
||||
stream still costs one token.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="faq">FAQ</h2>
|
||||
<dl class="faq">
|
||||
<dt>How do I exempt internal traffic?</dt>
|
||||
<dd>Set <code>x-acme-internal: 1</code> from the caller; the middleware checks it against the
|
||||
mTLS peer name and skips the bucket entirely.</dd>
|
||||
|
||||
<dt>Where do I see who's getting limited?</dt>
|
||||
<dd>Every <code>429</code> emits a <code>ratelimit.rejected</code> metric tagged with route and key
|
||||
type. There's a Grafana panel under <em>API → Health</em>.</dd>
|
||||
|
||||
<dt>Can a single user have a higher limit?</dt>
|
||||
<dd>Yes — add their API key under <code>overrides:</code> in the YAML. Overrides are reloaded without
|
||||
a deploy.</dd>
|
||||
</dl>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll("[data-tabs]").forEach(box => {
|
||||
const btns = box.querySelectorAll("button");
|
||||
const panes = box.querySelectorAll("pre");
|
||||
btns.forEach(b => b.addEventListener("click", () => {
|
||||
btns.forEach(x => x.classList.remove("on"));
|
||||
panes.forEach(x => x.classList.remove("on"));
|
||||
b.classList.add("on");
|
||||
panes[+b.dataset.t].classList.add("on");
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+369
@@ -0,0 +1,369 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Consistent hashing — an interactive explainer</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--sky: #6A8CAF;
|
||||
--gray-150:#F0EEE6;
|
||||
--gray-300:#D1CFC5;
|
||||
--gray-500:#87867F;
|
||||
--gray-700:#3D3D3A;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
font-family: var(--sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.65;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
padding: 56px 24px 120px;
|
||||
}
|
||||
.page {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 240px;
|
||||
gap: 48px;
|
||||
}
|
||||
@media (max-width: 960px) { .page { grid-template-columns: 1fr; } aside { order: 2; position: static; } }
|
||||
|
||||
/* ── header ───────────────────────────── */
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 33px;
|
||||
color: var(--slate);
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.lead { max-width: 640px; margin-bottom: 8px; }
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
color: var(--slate);
|
||||
margin: 40px 0 12px;
|
||||
}
|
||||
p { margin-bottom: 12px; max-width: 680px; }
|
||||
code { font-family: var(--mono); font-size: 13px; }
|
||||
.term {
|
||||
border-bottom: 1.5px dotted var(--clay);
|
||||
cursor: help;
|
||||
color: var(--slate);
|
||||
}
|
||||
|
||||
/* ── demo panel ───────────────────────── */
|
||||
.demo {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 760px) { .demo-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
svg.ring { display: block; width: 100%; max-width: 320px; }
|
||||
.ring .track { fill: none; stroke: var(--gray-300); stroke-width: 14; }
|
||||
.ring .arc { fill: none; stroke-width: 14; transition: stroke-dasharray 300ms ease; }
|
||||
.ring .node { stroke: #fff; stroke-width: 2; transition: cx 300ms, cy 300ms, opacity 200ms; }
|
||||
.ring .key { fill: var(--slate); transition: cx 300ms, cy 300ms; }
|
||||
.ring .lbl { font-family: var(--mono); font-size: 10px; fill: var(--gray-500); }
|
||||
|
||||
.controls { font-size: 13px; }
|
||||
.controls .row { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||
.controls label { width: 64px; color: var(--gray-500); font-family: var(--mono); font-size: 11px; }
|
||||
.controls .val { font-family: var(--mono); font-size: 12px; color: var(--slate); width: 28px; }
|
||||
.controls input[type=range] { flex: 1; accent-color: var(--clay); }
|
||||
.controls button {
|
||||
appearance: none;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
background: var(--gray-150);
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.controls button:hover { background: var(--oat); }
|
||||
|
||||
.readout {
|
||||
border-top: 1px solid var(--gray-300);
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.readout b { color: var(--slate); }
|
||||
.moved { color: var(--clay); font-family: var(--mono); }
|
||||
|
||||
/* ── compare table ────────────────────── */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
font-size: 13.5px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--gray-300); }
|
||||
th { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--gray-500); font-weight: 500; }
|
||||
td.bad { color: #B04A3F; }
|
||||
td.good { color: var(--olive); }
|
||||
|
||||
/* ── glossary ─────────────────────────── */
|
||||
aside {
|
||||
position: sticky;
|
||||
top: 32px;
|
||||
align-self: start;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
padding: 18px 18px 8px;
|
||||
}
|
||||
aside .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
aside dl dt {
|
||||
font-family: var(--serif);
|
||||
font-size: 15px;
|
||||
color: var(--slate);
|
||||
margin-top: 0;
|
||||
}
|
||||
aside dl dd {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--gray-700);
|
||||
margin: 2px 0 14px;
|
||||
}
|
||||
aside dl dt.hl, aside dl dt.hl + dd { background: rgba(217,119,87,0.10); margin-left: -8px; margin-right: -8px; padding-left: 8px; padding-right: 8px; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<main>
|
||||
<div class="eyebrow">Research & Learning · concept explainer</div>
|
||||
<h1>Consistent hashing, in one ring</h1>
|
||||
<p class="lead">
|
||||
You have <em>K</em> keys spread across <em>N</em> cache servers. A server dies, or you add one. How many
|
||||
keys have to move? With naive <code>hash(key) mod N</code> the answer is "almost all of them." Consistent
|
||||
hashing gets it down to roughly <code>K / N</code>. Here's why.
|
||||
</p>
|
||||
|
||||
<h2>The trick: hash onto a circle, not a line</h2>
|
||||
<p>
|
||||
Map both <span class="term" data-term="node">nodes</span> and keys onto the same
|
||||
<span class="term" data-term="ring">ring</span> (the hash output space, wrapped around). A key belongs to
|
||||
the first node found by walking clockwise from the key's position. When a node leaves, only the keys in
|
||||
its <span class="term" data-term="arc">arc</span> reassign — to the next node round — and everything else
|
||||
stays put.
|
||||
</p>
|
||||
|
||||
<div class="demo">
|
||||
<div class="demo-grid">
|
||||
<svg class="ring" id="ring" viewBox="0 0 260 260"></svg>
|
||||
<div class="controls">
|
||||
<div class="row">
|
||||
<label>nodes</label>
|
||||
<input id="nSlider" type="range" min="2" max="8" value="4">
|
||||
<span class="val" id="nVal">4</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>keys</label>
|
||||
<input id="kSlider" type="range" min="10" max="60" value="32" step="2">
|
||||
<span class="val" id="kVal">32</span>
|
||||
</div>
|
||||
<div>
|
||||
<button id="rm">remove a node</button>
|
||||
<button id="add">add a node</button>
|
||||
<button id="reset">reset</button>
|
||||
</div>
|
||||
<div class="readout" id="readout">
|
||||
<b>4</b> nodes · <b>32</b> keys · <span class="moved">—</span> moved on last change
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:13px; color:var(--gray-500); max-width:640px;">
|
||||
Colored arcs show ownership. Removing a node hands its arc to its clockwise neighbor; every dot outside
|
||||
that arc keeps its color. That's the whole idea.
|
||||
</p>
|
||||
|
||||
<h2>Versus <code>mod N</code></h2>
|
||||
<table>
|
||||
<thead><tr><th></th><th>hash mod N</th><th>consistent hashing</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Keys moved when N→N+1</td><td class="bad">~ (N−1)/N of all keys</td><td class="good">~ 1/(N+1)</td></tr>
|
||||
<tr><td>Hot-spot risk</td><td class="good">even by construction</td><td>uneven — fix with <span class="term" data-term="vnode">virtual nodes</span></td></tr>
|
||||
<tr><td>Lookup cost</td><td class="good">O(1)</td><td>O(log N) (binary search on ring)</td></tr>
|
||||
<tr><td>Used by</td><td>array sharding, simple LB</td><td>Dynamo, Cassandra, Memcached clients, Envoy</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Where you'll meet it</h2>
|
||||
<p>
|
||||
Any time you're spreading state across a pool that changes size: cache fleets, partitioned queues, object
|
||||
storage, request routing with sticky sessions. The
|
||||
<span class="term" data-term="vnode">virtual-node</span> variant (each physical node owns many small arcs
|
||||
instead of one big one) is what production systems actually run, because it smooths out load and makes
|
||||
rebalancing even gentler.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<!-- glossary -->
|
||||
<aside>
|
||||
<div class="label">Glossary</div>
|
||||
<dl id="gloss">
|
||||
<dt data-g="ring">Ring</dt>
|
||||
<dd>The hash function's output range, treated as a circle so the value after <code>max</code> is <code>0</code>.</dd>
|
||||
<dt data-g="node">Node</dt>
|
||||
<dd>A server placed on the ring at <code>hash(node_id)</code>. Owns every key between it and its anticlockwise neighbor.</dd>
|
||||
<dt data-g="arc">Arc</dt>
|
||||
<dd>The stretch of ring a node owns. Removing a node merges its arc into the next node's.</dd>
|
||||
<dt data-g="vnode">Virtual node</dt>
|
||||
<dd>Placing each physical node at many ring positions so arcs are small and evenly sized.</dd>
|
||||
<dt data-g="successor">Successor</dt>
|
||||
<dd>The first node clockwise from a given point — the owner of any key landing there.</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── tiny deterministic hash so the demo is stable ── */
|
||||
function h(s) {
|
||||
let x = 2166136261;
|
||||
for (let i = 0; i < s.length; i++) { x ^= s.charCodeAt(i); x = (x * 16777619) >>> 0; }
|
||||
return x / 4294967296;
|
||||
}
|
||||
const COLORS = ["#D97757", "#788C5D", "#6A8CAF", "#C2A83E", "#B04A3F", "#87867F", "#3D6E6E", "#A67C52"];
|
||||
const CX = 130, CY = 130, R = 100, RKEY = 78;
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
const ring = document.getElementById("ring");
|
||||
const nSlider = document.getElementById("nSlider");
|
||||
const kSlider = document.getElementById("kSlider");
|
||||
const nVal = document.getElementById("nVal");
|
||||
const kVal = document.getElementById("kVal");
|
||||
const readout = document.getElementById("readout");
|
||||
|
||||
let nodes = [], keys = [], lastOwner = {};
|
||||
|
||||
function pt(r, t) { return [CX + r * Math.sin(t * TAU), CY - r * Math.cos(t * TAU)]; }
|
||||
|
||||
function ownerOf(t) {
|
||||
const sorted = [...nodes].sort((a, b) => a.t - b.t);
|
||||
for (const n of sorted) if (n.t >= t) return n;
|
||||
return sorted[0];
|
||||
}
|
||||
|
||||
function buildNodes(n) {
|
||||
nodes = [];
|
||||
for (let i = 0; i < n; i++) nodes.push({ id: "n" + i, t: h("node-" + i), color: COLORS[i % COLORS.length] });
|
||||
}
|
||||
function buildKeys(k) {
|
||||
keys = [];
|
||||
for (let i = 0; i < k; i++) keys.push({ id: "k" + i, t: h("key-" + i) });
|
||||
}
|
||||
|
||||
function arcPath(t0, t1) {
|
||||
const [x0, y0] = pt(R, t0), [x1, y1] = pt(R, t1);
|
||||
let d = t1 - t0; if (d <= 0) d += 1;
|
||||
const large = d > 0.5 ? 1 : 0;
|
||||
return `M ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1}`;
|
||||
}
|
||||
|
||||
function render(moved) {
|
||||
const sorted = [...nodes].sort((a, b) => a.t - b.t);
|
||||
let svg = `<circle class="track" cx="${CX}" cy="${CY}" r="${R}"/>`;
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const cur = sorted[i];
|
||||
const prev = sorted[(i - 1 + sorted.length) % sorted.length];
|
||||
svg += `<path class="arc" stroke="${cur.color}" d="${arcPath(prev.t, cur.t)}"/>`;
|
||||
}
|
||||
for (const k of keys) {
|
||||
const o = ownerOf(k.t);
|
||||
const [x, y] = pt(RKEY, k.t);
|
||||
svg += `<circle class="key" r="3.5" cx="${x}" cy="${y}" fill="${o.color}"/>`;
|
||||
}
|
||||
for (const n of sorted) {
|
||||
const [x, y] = pt(R, n.t);
|
||||
svg += `<circle class="node" r="9" cx="${x}" cy="${y}" fill="${n.color}"/>`;
|
||||
}
|
||||
svg += `<text class="lbl" x="${CX}" y="18" text-anchor="middle">0</text>`;
|
||||
ring.innerHTML = svg;
|
||||
|
||||
readout.innerHTML =
|
||||
`<b>${nodes.length}</b> nodes · <b>${keys.length}</b> keys · ` +
|
||||
`<span class="moved">${moved == null ? "—" : moved + " (" + Math.round(moved / keys.length * 100) + "%)"}</span> moved on last change`;
|
||||
}
|
||||
|
||||
function diffAndRender() {
|
||||
let moved = 0;
|
||||
for (const k of keys) {
|
||||
const o = ownerOf(k.t).id;
|
||||
if (lastOwner[k.id] && lastOwner[k.id] !== o) moved++;
|
||||
lastOwner[k.id] = o;
|
||||
}
|
||||
render(moved);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
buildNodes(+nSlider.value);
|
||||
buildKeys(+kSlider.value);
|
||||
lastOwner = {};
|
||||
for (const k of keys) lastOwner[k.id] = ownerOf(k.t).id;
|
||||
nVal.textContent = nSlider.value;
|
||||
kVal.textContent = kSlider.value;
|
||||
render(null);
|
||||
}
|
||||
|
||||
nSlider.oninput = () => { nVal.textContent = nSlider.value; buildNodes(+nSlider.value); diffAndRender(); };
|
||||
kSlider.oninput = () => { kVal.textContent = kSlider.value; buildKeys(+kSlider.value); lastOwner = {}; for (const k of keys) lastOwner[k.id] = ownerOf(k.t).id; render(null); };
|
||||
document.getElementById("rm").onclick = () => { if (nodes.length > 2) { nodes.splice(Math.floor(Math.random() * nodes.length), 1); diffAndRender(); } };
|
||||
document.getElementById("add").onclick = () => { if (nodes.length < 12) { const i = nodes.length; nodes.push({ id: "n" + Date.now(), t: Math.random(), color: COLORS[i % COLORS.length] }); diffAndRender(); } };
|
||||
document.getElementById("reset").onclick = reset;
|
||||
|
||||
/* glossary highlight */
|
||||
document.querySelectorAll(".term").forEach(el => {
|
||||
const g = el.dataset.term;
|
||||
el.addEventListener("mouseenter", () => document.querySelector(`dt[data-g="${g}"]`)?.classList.add("hl"));
|
||||
el.addEventListener("mouseleave", () => document.querySelector(`dt[data-g="${g}"]`)?.classList.remove("hl"));
|
||||
});
|
||||
|
||||
reset();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,703 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Implementation plan — Comment threads on task cards</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
line-height: 1.55;
|
||||
padding: 56px 32px 120px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page { max-width: 1120px; margin: 0 auto; }
|
||||
|
||||
/* ---------- header ---------- */
|
||||
|
||||
header.page-head { margin-bottom: 48px; max-width: 820px; }
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 38px;
|
||||
line-height: 1.15;
|
||||
color: var(--slate);
|
||||
margin-bottom: 18px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.prompt-box {
|
||||
background: var(--gray-150);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
font-size: 14.5px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.prompt-box .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---------- section chrome ---------- */
|
||||
|
||||
section { margin-bottom: 64px; }
|
||||
|
||||
.sec-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 14px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sec-head .num {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--oat);
|
||||
color: var(--slate);
|
||||
padding: 3px 9px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sec-head h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 26px;
|
||||
color: var(--slate);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.sec-intro {
|
||||
font-size: 14.5px;
|
||||
color: var(--gray-500);
|
||||
max-width: 720px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* ---------- summary strip ---------- */
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 64px;
|
||||
}
|
||||
@media (max-width: 900px) { .summary { grid-template-columns: repeat(2, 1fr); } }
|
||||
.summary .cell {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.summary .k {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.summary .v {
|
||||
font-size: 17px;
|
||||
color: var(--slate);
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary .v.accent { color: var(--clay); }
|
||||
|
||||
/* ---------- milestone timeline ---------- */
|
||||
|
||||
.milestones { display: flex; flex-direction: column; gap: 0; }
|
||||
.milestone {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 28px 1fr;
|
||||
gap: 0 18px;
|
||||
position: relative;
|
||||
}
|
||||
.milestone .when {
|
||||
text-align: right;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.milestone .dot-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.milestone .dot {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--white);
|
||||
border: 3px solid var(--clay);
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.milestone .dot.done { background: var(--olive); border-color: var(--olive); }
|
||||
.milestone .line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
background: var(--gray-300);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.milestone:last-child .line { display: none; }
|
||||
.milestone .body { padding-bottom: 36px; }
|
||||
.milestone .body h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
color: var(--slate);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.milestone .body p { font-size: 14px; color: var(--gray-500); margin-bottom: 10px; max-width: 620px; }
|
||||
.milestone .tags { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.milestone .tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
background: var(--gray-150);
|
||||
border: 1px solid var(--gray-300);
|
||||
border-radius: 6px;
|
||||
padding: 3px 8px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
/* ---------- data flow diagram ---------- */
|
||||
|
||||
.diagram {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 28px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.diagram svg { display: block; min-width: 760px; }
|
||||
.diagram text { font-family: var(--mono); }
|
||||
.caption { font-size: 13px; color: var(--gray-500); margin-top: 12px; }
|
||||
|
||||
/* ---------- mockups ---------- */
|
||||
|
||||
.mocks {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 28px;
|
||||
}
|
||||
@media (max-width: 900px) { .mocks { grid-template-columns: 1fr; } }
|
||||
.mock {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mock .mock-label {
|
||||
padding: 12px 18px;
|
||||
border-bottom: 1.5px solid var(--gray-300);
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-150);
|
||||
}
|
||||
.mock .mock-body { padding: 22px; }
|
||||
|
||||
/* task card mock */
|
||||
.card-mock {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
background: var(--ivory);
|
||||
}
|
||||
.card-mock .cm-title { font-weight: 600; font-size: 15px; color: var(--slate); margin-bottom: 4px; }
|
||||
.card-mock .cm-meta { font-size: 12px; color: var(--gray-500); margin-bottom: 14px; }
|
||||
.card-mock .cm-divider { border-top: 1px dashed var(--gray-300); margin: 14px 0; }
|
||||
.thread { display: flex; flex-direction: column; gap: 12px; }
|
||||
.comment { display: flex; gap: 10px; }
|
||||
.comment .avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--oat);
|
||||
flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 600; color: var(--slate);
|
||||
}
|
||||
.comment .avatar.alt { background: var(--olive); color: var(--white); }
|
||||
.comment .bubble { flex: 1; }
|
||||
.comment .author { font-size: 12px; font-weight: 600; color: var(--slate); }
|
||||
.comment .author .time { font-weight: 400; color: var(--gray-500); margin-left: 6px; }
|
||||
.comment .text { font-size: 13px; color: var(--gray-700); margin-top: 2px; }
|
||||
.comment .reply-link { font-size: 11.5px; color: var(--clay); margin-top: 4px; cursor: default; }
|
||||
.comment.nested { margin-left: 36px; }
|
||||
.composer {
|
||||
display: flex; gap: 10px; align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.composer .field {
|
||||
flex: 1;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
background: var(--white);
|
||||
}
|
||||
.composer .send {
|
||||
background: var(--clay);
|
||||
color: var(--white);
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* sidebar digest mock */
|
||||
.digest { display: flex; flex-direction: column; gap: 10px; }
|
||||
.digest .row {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
background: var(--ivory);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.digest .row.unread { border-left: 3px solid var(--clay); }
|
||||
.digest .mini-av {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--oat);
|
||||
flex-shrink: 0;
|
||||
font-size: 10px; font-weight: 600; color: var(--slate);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.digest .txt { font-size: 12.5px; color: var(--gray-700); line-height: 1.45; }
|
||||
.digest .txt strong { color: var(--slate); }
|
||||
.digest .txt .on { color: var(--gray-500); }
|
||||
|
||||
/* ---------- code panel ---------- */
|
||||
|
||||
.code-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
@media (max-width: 980px) { .code-grid { grid-template-columns: 1fr; } }
|
||||
.code-block { display: flex; flex-direction: column; gap: 10px; }
|
||||
.code-block .file-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.code {
|
||||
background: var(--slate);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
overflow-x: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.code pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: #E8E6DE;
|
||||
white-space: pre;
|
||||
}
|
||||
.code .kw { color: var(--clay); }
|
||||
.code .str { color: var(--olive); }
|
||||
.code .cm { color: var(--gray-500); }
|
||||
.code .fn { color: #C9B98A; }
|
||||
|
||||
/* ---------- risk table ---------- */
|
||||
|
||||
.risks {
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--white);
|
||||
}
|
||||
.risks .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 90px 1.6fr;
|
||||
gap: 0;
|
||||
}
|
||||
@media (max-width: 780px) { .risks .row { grid-template-columns: 1fr; } }
|
||||
.risks .row + .row { border-top: 1.5px solid var(--gray-300); }
|
||||
.risks .cell { padding: 14px 18px; font-size: 13.5px; }
|
||||
.risks .cell + .cell { border-left: 1.5px solid var(--gray-300); }
|
||||
@media (max-width: 780px) { .risks .cell + .cell { border-left: none; border-top: 1px dashed var(--gray-300); } }
|
||||
.risks .head { background: var(--gray-150); font-weight: 600; color: var(--slate); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.sev {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sev.high { background: #F3D9CC; color: #8A3B1E; }
|
||||
.sev.med { background: var(--oat); color: var(--slate); }
|
||||
.sev.low { background: #E4E9DC; color: #4B5C39; }
|
||||
|
||||
/* ---------- open questions ---------- */
|
||||
|
||||
.open-q { display: flex; flex-direction: column; gap: 14px; max-width: 820px; }
|
||||
.q {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-left: 4px solid var(--clay);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.q .qt { font-weight: 600; font-size: 15px; color: var(--slate); margin-bottom: 4px; }
|
||||
.q .qd { font-size: 13.5px; color: var(--gray-500); }
|
||||
.q .owner {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header class="page-head">
|
||||
<div class="eyebrow">Implementation plan · Acme web client</div>
|
||||
<h1>Comment threads on task cards</h1>
|
||||
<div class="prompt-box">
|
||||
<span class="label">Prompt</span>
|
||||
Create a thorough implementation plan for adding threaded comments to
|
||||
task cards. Include mockups, the data flow from client to persistence,
|
||||
the key code I'll need to write, and a risk table. Make it easy to
|
||||
skim on a phone — I'm going to pass this to the implementer as-is.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<div class="summary">
|
||||
<div class="cell"><div class="k">Effort</div><div class="v accent">~2 weeks</div></div>
|
||||
<div class="cell"><div class="k">Surfaces touched</div><div class="v">3 packages</div></div>
|
||||
<div class="cell"><div class="k">New tables</div><div class="v">2</div></div>
|
||||
<div class="cell"><div class="k">Feature flag</div><div class="v">task_comments_v1</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section>
|
||||
<div class="sec-head"><span class="num">01</span><h2>Milestones</h2></div>
|
||||
<p class="sec-intro">Ship in four slices, each independently reviewable and each behind the flag. Nothing is user-visible until slice 4.</p>
|
||||
|
||||
<div class="milestones">
|
||||
<div class="milestone">
|
||||
<div class="when">Week 1 · Mon–Tue</div>
|
||||
<div class="dot-col"><span class="dot done"></span><span class="line"></span></div>
|
||||
<div class="body">
|
||||
<h3>Schema & API contract</h3>
|
||||
<p>New <code>comments</code> and <code>comment_reads</code> tables, migrations, and the tRPC router stubs. No UI. Contract reviewed before anything else lands.</p>
|
||||
<div class="tags"><span class="tag">packages/db</span><span class="tag">packages/api</span><span class="tag">migration 0042</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="milestone">
|
||||
<div class="when">Week 1 · Wed–Fri</div>
|
||||
<div class="dot-col"><span class="dot"></span><span class="line"></span></div>
|
||||
<div class="body">
|
||||
<h3>Thread component & composer</h3>
|
||||
<p>Static <code><CommentThread></code> rendered from fixtures. Optimistic insert on submit, rollback on failure, one level of nesting only.</p>
|
||||
<div class="tags"><span class="tag">apps/web</span><span class="tag">storybook</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="milestone">
|
||||
<div class="when">Week 2 · Mon–Wed</div>
|
||||
<div class="dot-col"><span class="dot"></span><span class="line"></span></div>
|
||||
<div class="body">
|
||||
<h3>Realtime fan-out & unread state</h3>
|
||||
<p>Subscribe the open card to its comment channel. Track per-user read cursors so the sidebar can show an unread count without a second query.</p>
|
||||
<div class="tags"><span class="tag">packages/realtime</span><span class="tag">apps/web</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="milestone">
|
||||
<div class="when">Week 2 · Thu–Fri</div>
|
||||
<div class="dot-col"><span class="dot"></span><span class="line"></span></div>
|
||||
<div class="body">
|
||||
<h3>Notifications, flag ramp, docs</h3>
|
||||
<p>Mention detection → notification row, email digest fallback, ramp <code>task_comments_v1</code> to internal, then 10% → 100% over three days.</p>
|
||||
<div class="tags"><span class="tag">packages/notify</span><span class="tag">growthbook</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section>
|
||||
<div class="sec-head"><span class="num">02</span><h2>Data flow</h2></div>
|
||||
<p class="sec-intro">Optimistic write path on the left, fan-out on the right. The read cursor update is fire-and-forget — we never block the thread render on it.</p>
|
||||
|
||||
<div class="diagram">
|
||||
<svg viewBox="0 0 860 340" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#87867F"/>
|
||||
</marker>
|
||||
<marker id="arrowClay" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#D97757"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- boxes -->
|
||||
<g font-size="12" fill="#141413">
|
||||
<rect x="20" y="20" width="180" height="54" rx="10" fill="#FFFFFF" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="110" y="43" text-anchor="middle" font-weight="600"><CommentComposer></text>
|
||||
<text x="110" y="60" text-anchor="middle" fill="#87867F" font-size="10.5">apps/web</text>
|
||||
|
||||
<rect x="20" y="150" width="180" height="54" rx="10" fill="#FFFFFF" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="110" y="173" text-anchor="middle" font-weight="600">React Query cache</text>
|
||||
<text x="110" y="190" text-anchor="middle" fill="#87867F" font-size="10.5">optimistic insert</text>
|
||||
|
||||
<rect x="340" y="150" width="180" height="54" rx="10" fill="#FFFFFF" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="430" y="173" text-anchor="middle" font-weight="600">comments.create</text>
|
||||
<text x="430" y="190" text-anchor="middle" fill="#87867F" font-size="10.5">tRPC · packages/api</text>
|
||||
|
||||
<rect x="340" y="266" width="180" height="54" rx="10" fill="#141413" stroke="#141413"/>
|
||||
<text x="430" y="289" text-anchor="middle" font-weight="600" fill="#FAF9F5">comments table</text>
|
||||
<text x="430" y="306" text-anchor="middle" fill="#C9B98A" font-size="10.5">postgres · packages/db</text>
|
||||
|
||||
<rect x="660" y="150" width="180" height="54" rx="10" fill="#FFFFFF" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="750" y="173" text-anchor="middle" font-weight="600">realtime channel</text>
|
||||
<text x="750" y="190" text-anchor="middle" fill="#87867F" font-size="10.5">task:{id}:comments</text>
|
||||
|
||||
<rect x="660" y="20" width="180" height="54" rx="10" fill="#FFFFFF" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="750" y="43" text-anchor="middle" font-weight="600">Other viewers</text>
|
||||
<text x="750" y="60" text-anchor="middle" fill="#87867F" font-size="10.5">subscribed cards</text>
|
||||
|
||||
<rect x="660" y="266" width="180" height="54" rx="10" fill="#FFFFFF" stroke="#D1CFC5" stroke-width="1.5"/>
|
||||
<text x="750" y="289" text-anchor="middle" font-weight="600">notify worker</text>
|
||||
<text x="750" y="306" text-anchor="middle" fill="#87867F" font-size="10.5">@mentions → queue</text>
|
||||
</g>
|
||||
|
||||
<!-- arrows -->
|
||||
<g stroke="#87867F" stroke-width="1.5" fill="none">
|
||||
<path d="M110 74 L110 150" marker-end="url(#arrow)"/>
|
||||
<path d="M200 177 L340 177" marker-end="url(#arrow)"/>
|
||||
<path d="M430 204 L430 266" marker-end="url(#arrow)"/>
|
||||
<path d="M520 293 L660 293" marker-end="url(#arrow)"/>
|
||||
</g>
|
||||
<g stroke="#D97757" stroke-width="1.5" fill="none" stroke-dasharray="5 4">
|
||||
<path d="M520 177 L660 177" marker-end="url(#arrowClay)"/>
|
||||
<path d="M750 150 L750 74" marker-end="url(#arrowClay)"/>
|
||||
<path d="M660 162 C 540 120, 280 120, 205 162" marker-end="url(#arrowClay)"/>
|
||||
</g>
|
||||
|
||||
<!-- edge labels -->
|
||||
<g font-size="10.5" fill="#87867F">
|
||||
<text x="118" y="118">submit (id=temp)</text>
|
||||
<text x="240" y="170">mutate</text>
|
||||
<text x="438" y="240">INSERT + read cursor</text>
|
||||
<text x="548" y="286">enqueue</text>
|
||||
<text x="548" y="172" fill="#D97757">broadcast row</text>
|
||||
<text x="758" y="118" fill="#D97757">live append</text>
|
||||
<text x="360" y="112" fill="#D97757">reconcile temp id → real id</text>
|
||||
</g>
|
||||
</svg>
|
||||
<p class="caption">Solid = request/response path. Dashed clay = realtime fan-out. The composer never waits on the dashed path.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section>
|
||||
<div class="sec-head"><span class="num">03</span><h2>Mockups</h2></div>
|
||||
<p class="sec-intro">Not pixel-final — just enough that the reviewer and I agree on nesting depth, composer placement, and what the sidebar digest looks like.</p>
|
||||
|
||||
<div class="mocks">
|
||||
<div class="mock">
|
||||
<div class="mock-label">A · Thread inside an open task card</div>
|
||||
<div class="mock-body">
|
||||
<div class="card-mock">
|
||||
<div class="cm-title">Ship onboarding empty-state rewrite</div>
|
||||
<div class="cm-meta">BIR-1142 · Assigned to Priya · Due Fri</div>
|
||||
<div class="cm-divider"></div>
|
||||
<div class="thread">
|
||||
<div class="comment">
|
||||
<div class="avatar">JM</div>
|
||||
<div class="bubble">
|
||||
<div class="author">Jonah M. <span class="time">2h ago</span></div>
|
||||
<div class="text">Should the illustration swap when the workspace already has one project? Feels odd to show the "start here" art twice.</div>
|
||||
<div class="reply-link">Reply</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment nested">
|
||||
<div class="avatar alt">PS</div>
|
||||
<div class="bubble">
|
||||
<div class="author">Priya S. <span class="time">40m ago</span></div>
|
||||
<div class="text">Good catch — I'll gate it on <code>projects.count > 0</code> and fall back to the minimal variant.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<div class="field">Add a comment…</div>
|
||||
<div class="send">Post</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mock">
|
||||
<div class="mock-label">B · Sidebar unread digest</div>
|
||||
<div class="mock-body">
|
||||
<div class="digest">
|
||||
<div class="row unread">
|
||||
<div class="mini-av">JM</div>
|
||||
<div class="txt"><strong>Jonah</strong> commented <span class="on">on BIR-1142</span> — "Should the illustration swap when…"</div>
|
||||
</div>
|
||||
<div class="row unread">
|
||||
<div class="mini-av">AK</div>
|
||||
<div class="txt"><strong>Aiko</strong> mentioned you <span class="on">on BIR-1098</span> — "@priya can you confirm the copy here?"</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="mini-av">RW</div>
|
||||
<div class="txt"><strong>Rowan</strong> replied <span class="on">on BIR-0971</span> — "Merged, thanks for the quick turnaround."</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section>
|
||||
<div class="sec-head"><span class="num">04</span><h2>Key code</h2></div>
|
||||
<p class="sec-intro">The two pieces most likely to be done wrong: the migration (soft deletes, read cursors) and the optimistic mutation (temp-id reconciliation).</p>
|
||||
|
||||
<div class="code-grid">
|
||||
<div class="code-block">
|
||||
<div class="file-label">packages/db/migrations/0042_comments.sql</div>
|
||||
<div class="code"><pre><span class="kw">create table</span> <span class="fn">comments</span> (
|
||||
id uuid <span class="kw">primary key default</span> gen_random_uuid(),
|
||||
task_id uuid <span class="kw">not null references</span> tasks(id),
|
||||
parent_id uuid <span class="kw">references</span> comments(id), <span class="cm">-- one level only,</span>
|
||||
<span class="cm">-- enforced in API</span>
|
||||
author_id uuid <span class="kw">not null references</span> users(id),
|
||||
body text <span class="kw">not null</span>,
|
||||
created_at timestamptz <span class="kw">not null default</span> now(),
|
||||
deleted_at timestamptz <span class="cm">-- soft delete</span>
|
||||
);
|
||||
|
||||
<span class="kw">create table</span> <span class="fn">comment_reads</span> (
|
||||
task_id uuid <span class="kw">not null references</span> tasks(id),
|
||||
user_id uuid <span class="kw">not null references</span> users(id),
|
||||
read_up_to timestamptz <span class="kw">not null</span>,
|
||||
<span class="kw">primary key</span> (task_id, user_id)
|
||||
);
|
||||
|
||||
<span class="kw">create index</span> <span class="fn">comments_task_created</span>
|
||||
<span class="kw">on</span> comments (task_id, created_at);</pre></div>
|
||||
</div>
|
||||
|
||||
<div class="code-block">
|
||||
<div class="file-label">apps/web/hooks/useAddComment.ts</div>
|
||||
<div class="code"><pre><span class="kw">export function</span> <span class="fn">useAddComment</span>(taskId: string) {
|
||||
<span class="kw">const</span> qc = useQueryClient();
|
||||
<span class="kw">return</span> trpc.comments.create.<span class="fn">useMutation</span>({
|
||||
<span class="fn">onMutate</span>: <span class="kw">async</span> (input) => {
|
||||
<span class="kw">const</span> temp = { ...input, id: <span class="str">`temp-${nanoid()}`</span>,
|
||||
createdAt: <span class="kw">new</span> Date(), pending: <span class="kw">true</span> };
|
||||
qc.<span class="fn">setQueryData</span>(key(taskId), (prev) =>
|
||||
[...(prev ?? []), temp]);
|
||||
<span class="kw">return</span> { tempId: temp.id };
|
||||
},
|
||||
<span class="fn">onSuccess</span>: (row, _v, ctx) => {
|
||||
<span class="cm">// reconcile temp id → real id so the</span>
|
||||
<span class="cm">// realtime append doesn't duplicate it</span>
|
||||
qc.<span class="fn">setQueryData</span>(key(taskId), (prev) =>
|
||||
prev.map((c) => c.id === ctx.tempId ? row : c));
|
||||
},
|
||||
<span class="fn">onError</span>: (_e, _v, ctx) => {
|
||||
qc.<span class="fn">setQueryData</span>(key(taskId), (prev) =>
|
||||
prev.filter((c) => c.id !== ctx.tempId));
|
||||
},
|
||||
});
|
||||
}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section>
|
||||
<div class="sec-head"><span class="num">05</span><h2>Risks & mitigations</h2></div>
|
||||
<div class="risks">
|
||||
<div class="row">
|
||||
<div class="cell head">Risk</div>
|
||||
<div class="cell head">Sev</div>
|
||||
<div class="cell head">Mitigation</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">Realtime duplicate: socket append races with the HTTP response and the temp-id reconcile.</div>
|
||||
<div class="cell"><span class="sev high">HIGH</span></div>
|
||||
<div class="cell">Dedupe on server-assigned <code>id</code> in the cache updater; socket payload carries the real id, temp rows are filtered on reconcile.</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">Unread counts go stale when a user reads the thread on another device.</div>
|
||||
<div class="cell"><span class="sev med">MED</span></div>
|
||||
<div class="cell">Broadcast <code>comment_reads</code> upserts on the same channel; client treats its own cursor as max(local, remote).</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="cell">Mention detection false-positives on pasted markdown (<code>@media</code>, <code>@2x</code>).</div>
|
||||
<div class="cell"><span class="sev low">LOW</span></div>
|
||||
<div class="cell">Resolve mentions against workspace members only, at write time, and store the resolved user ids — never re-parse on read.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================================================= -->
|
||||
|
||||
<section>
|
||||
<div class="sec-head"><span class="num">06</span><h2>Open questions</h2></div>
|
||||
<div class="open-q">
|
||||
<div class="q">
|
||||
<div class="qt">Do we allow editing, or only delete-and-repost?</div>
|
||||
<div class="qd">Editing needs an <code>edited_at</code> column and an "edited" affordance. Delete-and-repost is simpler but loses the reply anchor. Leaning toward delete-only for v1.</div>
|
||||
<div class="owner">Decide with · design, before slice 2</div>
|
||||
</div>
|
||||
<div class="q">
|
||||
<div class="qt">Email digest cadence when a user has the app closed</div>
|
||||
<div class="qd">Immediate-per-mention will be noisy. Proposal: batch on a 15-minute window, collapse to one email per task, and respect quiet hours from the existing settings table.</div>
|
||||
<div class="owner">Decide with · platform, before slice 4</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,596 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PR #312 — Move notification delivery onto a queue</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-150: #F0EEE6;
|
||||
--gray-300: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-700: #3D3D3A;
|
||||
--white: #FFFFFF;
|
||||
|
||||
--serif: ui-serif, Georgia, 'Times New Roman', serif;
|
||||
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
background: var(--ivory);
|
||||
color: var(--gray-700);
|
||||
line-height: 1.55;
|
||||
padding: 56px 32px 120px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page { max-width: 1040px; margin: 0 auto; }
|
||||
|
||||
/* ---------- header ---------- */
|
||||
|
||||
header.page-head { margin-bottom: 44px; max-width: 820px; }
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 36px;
|
||||
line-height: 1.15;
|
||||
color: var(--slate);
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.pr-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.pr-meta .stat strong { color: var(--slate); font-weight: 600; }
|
||||
.pr-meta .add { color: var(--olive); }
|
||||
.pr-meta .del { color: var(--clay); }
|
||||
|
||||
.prompt-box {
|
||||
background: var(--gray-150);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
font-size: 14.5px;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.prompt-box .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 48px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } .toc { display: none; } }
|
||||
|
||||
section { margin-bottom: 56px; scroll-margin-top: 24px; }
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 25px;
|
||||
color: var(--slate);
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
p.lede { font-size: 15.5px; max-width: 680px; margin-bottom: 12px; }
|
||||
p + p.lede { margin-top: 12px; }
|
||||
|
||||
.toc {
|
||||
position: sticky;
|
||||
top: 32px;
|
||||
border-left: 1.5px solid var(--gray-300);
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.toc .t-head {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.toc a { display: block; color: var(--gray-700); text-decoration: none; padding: 4px 0; }
|
||||
.toc a:hover { color: var(--clay); }
|
||||
.toc .sub { padding-left: 12px; color: var(--gray-500); font-size: 12.5px; }
|
||||
|
||||
/* ---------- TL;DR ---------- */
|
||||
|
||||
.tldr {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-left: 4px solid var(--clay);
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 48px;
|
||||
max-width: 760px;
|
||||
}
|
||||
.tldr .k {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.tldr p { font-size: 15px; color: var(--slate); }
|
||||
|
||||
/* ---------- before/after ---------- */
|
||||
|
||||
.ba {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
margin: 22px 0 4px;
|
||||
}
|
||||
@media (max-width: 720px) { .ba { grid-template-columns: 1fr; } }
|
||||
.ba .panel {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
.ba .panel.after { border-color: var(--olive); }
|
||||
.ba .k {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ba .after .k { color: var(--olive); }
|
||||
.ba ul { list-style: none; display: flex; flex-direction: column; gap: 8px; font-size: 13.5px; }
|
||||
.ba li::before { content: '·'; color: var(--gray-500); margin-right: 8px; }
|
||||
.ba .after li::before { color: var(--olive); }
|
||||
|
||||
/* ---------- file tour ---------- */
|
||||
|
||||
.file-tour { display: flex; flex-direction: column; gap: 22px; }
|
||||
.file {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.file summary {
|
||||
padding: 14px 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
background: var(--gray-150);
|
||||
border-bottom: 1.5px solid var(--gray-300);
|
||||
}
|
||||
.file[open] summary { border-bottom-color: var(--gray-300); }
|
||||
.file:not([open]) summary { border-bottom: none; }
|
||||
.file summary::-webkit-details-marker { display: none; }
|
||||
.file summary .chev {
|
||||
width: 8px; height: 8px;
|
||||
border-right: 2px solid var(--gray-500);
|
||||
border-bottom: 2px solid var(--gray-500);
|
||||
transform: rotate(-45deg);
|
||||
transition: transform .15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file[open] summary .chev { transform: rotate(45deg); }
|
||||
.file summary .path {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--slate);
|
||||
flex: 1;
|
||||
}
|
||||
.file summary .badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge.new { background: #E4E9DC; color: #4B5C39; }
|
||||
.badge.mod { background: var(--oat); color: var(--slate); }
|
||||
.badge.del { background: #F3D9CC; color: #8A3B1E; }
|
||||
.file summary .stat { font-family: var(--mono); font-size: 12px; }
|
||||
.file summary .stat .add { color: var(--olive); }
|
||||
.file summary .stat .del { color: var(--clay); }
|
||||
.file .file-body { padding: 18px 20px 22px; }
|
||||
.file .why {
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 14px;
|
||||
max-width: 680px;
|
||||
}
|
||||
.file .why strong { color: var(--slate); }
|
||||
|
||||
/* ---------- code ---------- */
|
||||
|
||||
.code {
|
||||
background: var(--slate);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code pre {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: #E8E6DE;
|
||||
white-space: pre;
|
||||
}
|
||||
.code .kw { color: var(--clay); }
|
||||
.code .str { color: var(--olive); }
|
||||
.code .cm { color: var(--gray-500); }
|
||||
.code .fn { color: #C9B98A; }
|
||||
.code .add { background: rgba(120,140,93,.22); display: inline-block; width: 100%; }
|
||||
.code .del { background: rgba(217,119,87,.18); display: inline-block; width: 100%; text-decoration: line-through; text-decoration-color: rgba(217,119,87,.6); }
|
||||
|
||||
/* ---------- review focus ---------- */
|
||||
|
||||
.focus { display: flex; flex-direction: column; gap: 14px; max-width: 760px; }
|
||||
.focus .item {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.focus .n {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
background: var(--clay);
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.focus .t { font-weight: 600; color: var(--slate); font-size: 15px; margin-bottom: 2px; }
|
||||
.focus .d { font-size: 13.5px; color: var(--gray-500); }
|
||||
.focus .d code { font-size: 12.5px; }
|
||||
|
||||
/* ---------- test plan ---------- */
|
||||
|
||||
.tests { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
|
||||
.test {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
border-radius: 10px;
|
||||
padding: 13px 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.test .check {
|
||||
width: 18px; height: 18px;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
border: 1.5px solid var(--gray-300);
|
||||
}
|
||||
.test.done .check {
|
||||
background: var(--olive);
|
||||
border-color: var(--olive);
|
||||
position: relative;
|
||||
}
|
||||
.test.done .check::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px; top: 2px;
|
||||
width: 5px; height: 9px;
|
||||
border-right: 2px solid var(--white);
|
||||
border-bottom: 2px solid var(--white);
|
||||
transform: rotate(40deg);
|
||||
}
|
||||
.test .label { color: var(--slate); }
|
||||
.test .note { color: var(--gray-500); font-size: 13px; margin-top: 2px; }
|
||||
|
||||
/* ---------- rollout ---------- */
|
||||
|
||||
.rollout { display: flex; gap: 0; max-width: 780px; }
|
||||
@media (max-width: 720px) { .rollout { flex-direction: column; } }
|
||||
.rollout .step {
|
||||
flex: 1;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-300);
|
||||
padding: 16px 18px;
|
||||
position: relative;
|
||||
}
|
||||
.rollout .step:first-child { border-radius: 12px 0 0 12px; }
|
||||
.rollout .step:last-child { border-radius: 0 12px 12px 0; }
|
||||
.rollout .step + .step { border-left: none; }
|
||||
@media (max-width: 720px) {
|
||||
.rollout .step, .rollout .step:first-child, .rollout .step:last-child { border-radius: 12px; }
|
||||
.rollout .step + .step { border-left: 1.5px solid var(--gray-300); margin-top: 10px; }
|
||||
}
|
||||
.rollout .step .pct {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--clay);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.rollout .step .d { font-size: 13px; color: var(--gray-500); }
|
||||
.rollout .step .when { font-family: var(--mono); font-size: 11px; color: var(--gray-500); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header class="page-head">
|
||||
<div class="eyebrow">Pull request · Acme</div>
|
||||
<h1>#312 — Move notification delivery onto a queue</h1>
|
||||
<div class="pr-meta">
|
||||
<span class="stat"><strong>9</strong> files</span>
|
||||
<span class="stat"><span class="add">+418</span> / <span class="del">−190</span></span>
|
||||
<span class="stat">branch <strong>notify-queue</strong> → <strong>main</strong></span>
|
||||
<span class="stat">author <strong>@priya</strong></span>
|
||||
</div>
|
||||
<div class="prompt-box">
|
||||
<span class="label">Prompt</span>
|
||||
Write up PR #312 for my reviewers. Explain the motivation, walk them
|
||||
through the change file by file with the <em>why</em> for each, show
|
||||
before/after behavior, and tell them exactly where to focus. They
|
||||
haven't touched the notification code in six months.
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<main>
|
||||
|
||||
<div class="tldr">
|
||||
<div class="k">TL;DR</div>
|
||||
<p>Notification sends were happening inline in the request path. Under load they added 200–800 ms to mutation latency and silently dropped emails when the SMTP pool was exhausted. This PR moves delivery onto the existing <code>pg-boss</code> queue so the API returns immediately and failed sends retry with backoff.</p>
|
||||
</div>
|
||||
|
||||
<!-- ======================= WHY ======================= -->
|
||||
|
||||
<section id="why">
|
||||
<h2>Why</h2>
|
||||
<p class="lede">When we added @mentions in comments last quarter, every mention started triggering up to three sends (in-app, email, Slack) inside the same transaction that saved the comment. That was fine at launch. It is not fine now that a single task update can fan out to forty watchers.</p>
|
||||
|
||||
<div class="ba">
|
||||
<div class="panel">
|
||||
<div class="k">Before</div>
|
||||
<ul>
|
||||
<li>Sends run inline in the mutation handler</li>
|
||||
<li>SMTP timeout = 500 error for the <em>comment</em></li>
|
||||
<li>No retries — a dropped email is gone</li>
|
||||
<li>p99 on <code>comments.create</code>: <strong>1.4 s</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel after">
|
||||
<div class="k">After</div>
|
||||
<ul>
|
||||
<li>Handler enqueues one job per recipient, returns</li>
|
||||
<li>Worker retries 3× with exponential backoff</li>
|
||||
<li>Dead-letter table for inspection after exhaustion</li>
|
||||
<li>p99 on <code>comments.create</code>: <strong>180 ms</strong> (staging)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= FILE TOUR ======================= -->
|
||||
|
||||
<section id="tour">
|
||||
<h2>File-by-file</h2>
|
||||
<p class="lede">Ordered for reading, not alphabetically. Start at the worker — it's the new thing — then the enqueue call site, then the plumbing.</p>
|
||||
|
||||
<div class="file-tour">
|
||||
|
||||
<details class="file" open>
|
||||
<summary>
|
||||
<span class="chev"></span>
|
||||
<span class="path">packages/notify/src/worker.ts</span>
|
||||
<span class="badge new">new</span>
|
||||
<span class="stat"><span class="add">+126</span></span>
|
||||
</summary>
|
||||
<div class="file-body">
|
||||
<p class="why"><strong>The heart of the PR.</strong> A <code>pg-boss</code> subscriber that pulls <code>notify.deliver</code> jobs, resolves the user's channel preferences, and calls the right adapter. Retries are configured per-channel — email gets three attempts, Slack gets one because its API is already idempotent on our side.</p>
|
||||
<div class="code"><pre>boss.<span class="fn">work</span>(<span class="str">'notify.deliver'</span>, { batchSize: 20 }, <span class="kw">async</span> (jobs) => {
|
||||
<span class="kw">for</span> (<span class="kw">const</span> job <span class="kw">of</span> jobs) {
|
||||
<span class="kw">const</span> { userId, event, channel } = job.data;
|
||||
<span class="kw">const</span> prefs = <span class="kw">await</span> <span class="fn">getPrefs</span>(userId);
|
||||
<span class="kw">if</span> (!prefs[channel]) <span class="kw">return</span>; <span class="cm">// user muted this channel</span>
|
||||
|
||||
<span class="kw">try</span> {
|
||||
<span class="kw">await</span> adapters[channel].<span class="fn">send</span>(userId, event);
|
||||
} <span class="kw">catch</span> (err) {
|
||||
<span class="kw">if</span> (job.retryCount >= MAX_RETRY[channel]) {
|
||||
<span class="kw">await</span> <span class="fn">deadLetter</span>(job, err); <span class="cm">// don't throw — ack & park</span>
|
||||
<span class="kw">return</span>;
|
||||
}
|
||||
<span class="kw">throw</span> err; <span class="cm">// pg-boss reschedules</span>
|
||||
}
|
||||
}
|
||||
});</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="file" open>
|
||||
<summary>
|
||||
<span class="chev"></span>
|
||||
<span class="path">packages/api/src/routers/comments.ts</span>
|
||||
<span class="badge mod">mod</span>
|
||||
<span class="stat"><span class="add">+14</span> <span class="del">−62</span></span>
|
||||
</summary>
|
||||
<div class="file-body">
|
||||
<p class="why"><strong>Where the win shows up.</strong> The mutation used to call <code>sendEmail</code>, <code>sendSlack</code>, and <code>createInApp</code> directly. Now it inserts the comment, computes recipients, and enqueues. The try/catch soup is gone.</p>
|
||||
<div class="code"><pre> <span class="kw">const</span> comment = <span class="kw">await</span> db.comments.<span class="fn">insert</span>(input);
|
||||
<span class="kw">const</span> recipients = <span class="kw">await</span> <span class="fn">resolveWatchers</span>(input.taskId, input.mentions);
|
||||
|
||||
<span class="del"> <span class="kw">for</span> (<span class="kw">const</span> r <span class="kw">of</span> recipients) {</span>
|
||||
<span class="del"> <span class="kw">await</span> <span class="fn">sendEmail</span>(r, comment); <span class="cm">// blocked the response</span></span>
|
||||
<span class="del"> <span class="kw">await</span> <span class="fn">sendSlack</span>(r, comment);</span>
|
||||
<span class="del"> }</span>
|
||||
<span class="add"> <span class="kw">await</span> boss.<span class="fn">insert</span>(recipients.flatMap((r) =></span>
|
||||
<span class="add"> CHANNELS.map((ch) => ({</span>
|
||||
<span class="add"> name: <span class="str">'notify.deliver'</span>,</span>
|
||||
<span class="add"> data: { userId: r.id, channel: ch, event: toEvent(comment) },</span>
|
||||
<span class="add"> singletonKey: <span class="str">`${comment.id}:${r.id}:${ch}`</span>, <span class="cm">// idempotent</span></span>
|
||||
<span class="add"> }))));</span>
|
||||
<span class="kw">return</span> comment;</pre></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="file">
|
||||
<summary>
|
||||
<span class="chev"></span>
|
||||
<span class="path">packages/db/migrations/0051_dead_letter.sql</span>
|
||||
<span class="badge new">new</span>
|
||||
<span class="stat"><span class="add">+22</span></span>
|
||||
</summary>
|
||||
<div class="file-body">
|
||||
<p class="why">Table for jobs that exhaust their retries. Deliberately <em>not</em> auto-pruned — we want to look at these weekly until we trust the new path. Has the full job payload and the last error string.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="file">
|
||||
<summary>
|
||||
<span class="chev"></span>
|
||||
<span class="path">packages/notify/src/adapters/{email,slack,inapp}.ts</span>
|
||||
<span class="badge mod">mod</span>
|
||||
<span class="stat"><span class="add">+88</span> <span class="del">−74</span></span>
|
||||
</summary>
|
||||
<div class="file-body">
|
||||
<p class="why">Mostly moves. Each adapter now implements a shared <code>Adapter</code> interface and throws a typed <code>RetryableError</code> or <code>PermanentError</code> so the worker knows whether to retry. The email adapter also drops its internal retry loop — the queue owns retries now, double-retrying was how we got duplicate emails in April.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="file">
|
||||
<summary>
|
||||
<span class="chev"></span>
|
||||
<span class="path">apps/worker/src/index.ts, infra/fly.toml</span>
|
||||
<span class="badge mod">mod</span>
|
||||
<span class="stat"><span class="add">+31</span> <span class="del">−4</span></span>
|
||||
</summary>
|
||||
<div class="file-body">
|
||||
<p class="why">Registers the new subscriber in the existing worker process and bumps its concurrency from 5 → 20. No new deploy unit.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="file">
|
||||
<summary>
|
||||
<span class="chev"></span>
|
||||
<span class="path">packages/notify/src/__tests__/worker.test.ts</span>
|
||||
<span class="badge new">new</span>
|
||||
<span class="stat"><span class="add">+137</span></span>
|
||||
</summary>
|
||||
<div class="file-body">
|
||||
<p class="why">Covers the retry boundary, the dead-letter path, channel muting, and the singleton key dedupe. Uses a real pg-boss against the test database — we got burned last quarter when mocked queue tests passed but prod ordering broke.</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= FOCUS ======================= -->
|
||||
|
||||
<section id="focus">
|
||||
<h2>Where to focus your review</h2>
|
||||
<div class="focus">
|
||||
<div class="item">
|
||||
<div class="n">1</div>
|
||||
<div>
|
||||
<div class="t">The retry / dead-letter boundary</div>
|
||||
<div class="d"><code>worker.ts:31–44</code>. I catch, check <code>retryCount</code>, and either park or rethrow. If this logic is wrong we either retry forever or drop messages — the two failure modes this PR exists to fix.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="n">2</div>
|
||||
<div>
|
||||
<div class="t">The singleton key</div>
|
||||
<div class="d"><code>comments.ts:28</code>. <code>${commentId}:${userId}:${channel}</code> should make re-enqueues idempotent if the API handler retries. Sanity-check that this can't collide across tasks.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="n">3</div>
|
||||
<div>
|
||||
<div class="t">What I deliberately did not do</div>
|
||||
<div class="d">No per-user digest batching, no delivery receipts, no priority lanes. All of those layer on top of this cleanly; bundling them would make this unreviewable.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= TEST PLAN ======================= -->
|
||||
|
||||
<section id="tests">
|
||||
<h2>Test plan</h2>
|
||||
<div class="tests">
|
||||
<div class="test done"><span class="check"></span><div><div class="label">Unit: retry → dead-letter path, channel mute, singleton dedupe</div><div class="note">packages/notify — 14 cases, real pg-boss on test db</div></div></div>
|
||||
<div class="test done"><span class="check"></span><div><div class="label">Integration: create comment with 3 watchers, assert 9 jobs enqueued and drained</div></div></div>
|
||||
<div class="test done"><span class="check"></span><div><div class="label">Staging load: 500 rps on comments.create for 10 min, p99 = 180 ms</div><div class="note">was 1.4 s before — dashboard linked in the PR description</div></div></div>
|
||||
<div class="test"><span class="check"></span><div><div class="label">Manual: kill SMTP mid-burst, confirm jobs land in dead-letter and nothing 500s</div><div class="note">will do during the 10% ramp</div></div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ======================= ROLLOUT ======================= -->
|
||||
|
||||
<section id="rollout">
|
||||
<h2>Rollout</h2>
|
||||
<p class="lede">Behind <code>notify_queue_v2</code>. The old inline path stays in the codebase, dead but dormant, for one release in case we need to flip back.</p>
|
||||
<div class="rollout">
|
||||
<div class="step"><div class="when">Day 0</div><div class="pct">internal</div><div class="d">Acme team only. Watch dead-letter table + worker error rate.</div></div>
|
||||
<div class="step"><div class="when">Day 2</div><div class="pct">10%</div><div class="d">Random sample. Alert if dead-letter rate > 0.5% of sends.</div></div>
|
||||
<div class="step"><div class="when">Day 4</div><div class="pct">100%</div><div class="d">Ramp fully, delete the inline path in a follow-up PR next week.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<nav class="toc">
|
||||
<div class="t-head">In this PR</div>
|
||||
<a href="#why">Why</a>
|
||||
<a href="#tour">File-by-file</a>
|
||||
<a href="#tour" class="sub">worker.ts</a>
|
||||
<a href="#tour" class="sub">comments.ts</a>
|
||||
<a href="#tour" class="sub">adapters</a>
|
||||
<a href="#focus">Where to focus</a>
|
||||
<a href="#tests">Test plan</a>
|
||||
<a href="#rollout">Rollout</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,574 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Acme — Cycle 14 triage</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--clay-d: #B85C3E;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-50: #F0EEE6;
|
||||
--gray-200: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-800: #3D3D3A;
|
||||
--white: #ffffff;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 64px;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header { margin-bottom: 22px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.sub {
|
||||
color: var(--gray-500);
|
||||
max-width: 620px;
|
||||
margin: 0;
|
||||
}
|
||||
.hintline {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.hintline::before {
|
||||
content: "›";
|
||||
color: var(--clay);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ---------- toolbar ---------- */
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: var(--ivory);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0 12px;
|
||||
margin-bottom: 14px;
|
||||
border-bottom: 1.5px solid var(--gray-200);
|
||||
}
|
||||
.summary {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--gray-800);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.summary b { color: var(--slate); font-weight: 600; }
|
||||
.summary .dot { color: var(--gray-200); margin: 0 4px; }
|
||||
.spacer { flex: 1; }
|
||||
.filter-active {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--clay-d);
|
||||
background: #F5E6DE;
|
||||
border: 1.5px solid #E8C9BA;
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
.filter-active.on { display: inline-block; }
|
||||
.filter-active:hover { border-color: var(--clay); }
|
||||
button.primary {
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
border: 1.5px solid var(--slate);
|
||||
border-radius: 999px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
button.primary:hover { background: var(--gray-800); }
|
||||
button.primary.copied { background: var(--olive); border-color: var(--olive); }
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--gray-800);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.ghost:hover { border-color: var(--gray-500); }
|
||||
|
||||
/* ---------- board ---------- */
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.col {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 200px;
|
||||
}
|
||||
.col[data-col="now"] { border-top: 3px solid var(--clay); }
|
||||
.col[data-col="next"] { border-top: 3px solid var(--olive); }
|
||||
.col[data-col="later"] { border-top: 3px solid var(--gray-500); }
|
||||
.col[data-col="cut"] { border-top: 3px solid var(--gray-200); }
|
||||
.col.dragover {
|
||||
outline: 2px dashed var(--clay);
|
||||
outline-offset: -6px;
|
||||
background: #FBF6F2;
|
||||
}
|
||||
.col-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--white);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1.5px solid var(--gray-50);
|
||||
}
|
||||
.col-head h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 17px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.col-head .count {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-50);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
.col-body {
|
||||
padding: 10px 10px 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-height: 60px;
|
||||
}
|
||||
.col-foot {
|
||||
border-top: 1.5px solid var(--gray-50);
|
||||
padding: 8px 14px 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.col-foot b { color: var(--gray-800); font-weight: 600; }
|
||||
|
||||
/* ---------- ticket card ---------- */
|
||||
.ticket {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
padding: 10px 11px 9px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
.ticket:hover { border-color: var(--gray-500); box-shadow: 0 1px 3px rgba(20,20,19,0.06); }
|
||||
.ticket:active { cursor: grabbing; }
|
||||
.ticket.dragging { opacity: .4; }
|
||||
.ticket.dim { opacity: .25; }
|
||||
.ticket-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.tid {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px 2px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag:hover { filter: brightness(0.96); }
|
||||
.tag-bug { background: #F5E2D8; color: var(--clay-d); border-color: #E8C9BA; }
|
||||
.tag-feat { background: #E8EDE0; color: #5C6F44; border-color: #CFDAC0; }
|
||||
.tag-chore { background: var(--gray-50); color: var(--gray-800); border-color: var(--gray-200); }
|
||||
.tag-debt { background: var(--gray-50); color: var(--gray-800); border-color: var(--gray-200); }
|
||||
.est {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--gray-500);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.ttitle {
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: var(--slate);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.ticket-bot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.owner {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--oat);
|
||||
color: var(--gray-800);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.owner.o2 { background: #DCE4D2; }
|
||||
.owner.o3 { background: #ECD9CE; }
|
||||
.owner.o4 { background: var(--gray-50); }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.board { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Acme / editor / triage</div>
|
||||
<h1>Cycle 14 triage</h1>
|
||||
<p class="sub">
|
||||
Twenty-four open Linear tickets, pre-sorted into a best guess. Drag them
|
||||
across Now / Next / Later / Cut until the cut feels right, then copy the
|
||||
result back into the planning doc as markdown.
|
||||
</p>
|
||||
<div class="hintline">drag tickets between columns · click a tag to filter</div>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="summary" id="summary"></div>
|
||||
<div class="spacer"></div>
|
||||
<button class="filter-active" id="filterBadge"></button>
|
||||
<button class="ghost" id="resetBtn">Reset</button>
|
||||
<button class="primary" id="copyBtn">Copy as markdown</button>
|
||||
</div>
|
||||
|
||||
<div class="board" id="board"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ---------- data ----------
|
||||
var COLUMNS = [
|
||||
{ key: 'now', label: 'Now', rationale: 'Blocking the v2.4 release or actively losing user data.' },
|
||||
{ key: 'next', label: 'Next', rationale: 'High-leverage and well-scoped — start the moment Now clears.' },
|
||||
{ key: 'later', label: 'Later', rationale: 'Real, but can ride to a future cycle without anyone noticing.' },
|
||||
{ key: 'cut', label: 'Cut', rationale: 'Not this quarter — close, dedupe, or push back to the requester.' }
|
||||
];
|
||||
|
||||
var EST_PTS = { S: 1, M: 2, L: 3 };
|
||||
|
||||
// tag, est, owner, col
|
||||
var INITIAL = [
|
||||
{ id: 'BIR-241', title: 'Fix sync conflict toast firing twice on reconnect', tag: 'bug', est: 'M', owner: 'AK', col: 'now' },
|
||||
{ id: 'BIR-238', title: 'Comments lost when editing offline then reloading', tag: 'bug', est: 'L', owner: 'JM', col: 'now' },
|
||||
{ id: 'BIR-252', title: 'Billing webhook 500s on annual → monthly downgrade', tag: 'bug', est: 'M', owner: 'RS', col: 'now' },
|
||||
{ id: 'BIR-219', title: 'Migrate workspace permissions to new ACL table', tag: 'debt', est: 'L', owner: 'AK', col: 'now' },
|
||||
{ id: 'BIR-260', title: 'SSO login loop on Safari 17 with strict cookies', tag: 'bug', est: 'S', owner: 'TN', col: 'now' },
|
||||
|
||||
{ id: 'BIR-244', title: 'Inline @-mention picker in doc comments', tag: 'feat', est: 'M', owner: 'JM', col: 'next' },
|
||||
{ id: 'BIR-231', title: 'Bulk-archive completed projects from the sidebar', tag: 'feat', est: 'S', owner: 'EL', col: 'next' },
|
||||
{ id: 'BIR-247', title: 'Activity feed: collapse repeated edit events', tag: 'feat', est: 'M', owner: 'TN', col: 'next' },
|
||||
{ id: 'BIR-228', title: 'Notification preferences per project', tag: 'feat', est: 'L', owner: 'EL', col: 'next' },
|
||||
{ id: 'BIR-255', title: 'Keyboard shortcut cheat-sheet overlay (⌘/)', tag: 'feat', est: 'S', owner: 'RS', col: 'next' },
|
||||
{ id: 'BIR-236', title: 'Empty-state illustrations for new workspaces', tag: 'chore', est: 'S', owner: 'EL', col: 'next' },
|
||||
{ id: 'BIR-249', title: 'Audit log export to CSV for admins', tag: 'feat', est: 'M', owner: 'AK', col: 'next' },
|
||||
|
||||
{ id: 'BIR-213', title: 'Dark mode pass on settings + billing pages', tag: 'chore', est: 'M', owner: 'EL', col: 'later' },
|
||||
{ id: 'BIR-258', title: 'Slack integration: post on milestone close', tag: 'feat', est: 'M', owner: 'RS', col: 'later' },
|
||||
{ id: 'BIR-221', title: 'Replace moment.js with date-fns across web', tag: 'debt', est: 'L', owner: 'TN', col: 'later' },
|
||||
{ id: 'BIR-263', title: 'Drag-to-reorder columns on the board view', tag: 'feat', est: 'M', owner: 'JM', col: 'later' },
|
||||
{ id: 'BIR-209', title: 'Upgrade Postgres minor + reindex search vectors', tag: 'chore', est: 'M', owner: 'AK', col: 'later' },
|
||||
{ id: 'BIR-251', title: 'Per-doc read receipts in the share dialog', tag: 'feat', est: 'L', owner: 'JM', col: 'later' },
|
||||
{ id: 'BIR-240', title: 'Onboarding checklist resurfaces after dismissal', tag: 'bug', est: 'S', owner: 'TN', col: 'later' },
|
||||
{ id: 'BIR-265', title: 'Add request-id to client error reports', tag: 'debt', est: 'S', owner: 'RS', col: 'later' },
|
||||
|
||||
{ id: 'BIR-198', title: 'Custom emoji reactions on comments', tag: 'feat', est: 'M', owner: 'EL', col: 'cut' },
|
||||
{ id: 'BIR-204', title: 'Native Windows desktop wrapper', tag: 'feat', est: 'L', owner: 'TN', col: 'cut' },
|
||||
{ id: 'BIR-217', title: 'AI summary of long comment threads', tag: 'feat', est: 'L', owner: 'JM', col: 'cut' },
|
||||
{ id: 'BIR-233', title: 'Public roadmap page with voting', tag: 'feat', est: 'L', owner: 'RS', col: 'cut' }
|
||||
];
|
||||
|
||||
var OWNER_CLASS = { AK: 'o1', JM: 'o2', RS: 'o3', TN: 'o4', EL: 'o1' };
|
||||
|
||||
var tickets = []; // live state
|
||||
var activeFilter = null; // tag string or null
|
||||
var dragId = null;
|
||||
|
||||
// ---------- build DOM ----------
|
||||
var board = document.getElementById('board');
|
||||
var colBodies = {};
|
||||
var colCounts = {};
|
||||
var colFoots = {};
|
||||
|
||||
COLUMNS.forEach(function (c) {
|
||||
var col = document.createElement('section');
|
||||
col.className = 'col';
|
||||
col.dataset.col = c.key;
|
||||
|
||||
var head = document.createElement('div');
|
||||
head.className = 'col-head';
|
||||
var h2 = document.createElement('h2');
|
||||
h2.textContent = c.label;
|
||||
var count = document.createElement('span');
|
||||
count.className = 'count';
|
||||
head.appendChild(h2);
|
||||
head.appendChild(count);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'col-body';
|
||||
|
||||
var foot = document.createElement('div');
|
||||
foot.className = 'col-foot';
|
||||
|
||||
col.appendChild(head);
|
||||
col.appendChild(body);
|
||||
col.appendChild(foot);
|
||||
board.appendChild(col);
|
||||
|
||||
colBodies[c.key] = body;
|
||||
colCounts[c.key] = count;
|
||||
colFoots[c.key] = foot;
|
||||
|
||||
// drop targets
|
||||
col.addEventListener('dragover', function (e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
col.classList.add('dragover');
|
||||
});
|
||||
col.addEventListener('dragleave', function (e) {
|
||||
if (!col.contains(e.relatedTarget)) col.classList.remove('dragover');
|
||||
});
|
||||
col.addEventListener('drop', function (e) {
|
||||
e.preventDefault();
|
||||
col.classList.remove('dragover');
|
||||
if (!dragId) return;
|
||||
var t = tickets.find(function (x) { return x.id === dragId; });
|
||||
if (t && t.col !== c.key) { t.col = c.key; render(); }
|
||||
});
|
||||
});
|
||||
|
||||
function makeCard(t) {
|
||||
var card = document.createElement('article');
|
||||
card.className = 'ticket';
|
||||
card.draggable = true;
|
||||
card.dataset.id = t.id;
|
||||
if (activeFilter && t.tag !== activeFilter) card.classList.add('dim');
|
||||
|
||||
var top = document.createElement('div');
|
||||
top.className = 'ticket-top';
|
||||
var tid = document.createElement('span');
|
||||
tid.className = 'tid';
|
||||
tid.textContent = t.id;
|
||||
var tag = document.createElement('button');
|
||||
tag.type = 'button';
|
||||
tag.className = 'tag tag-' + t.tag;
|
||||
tag.textContent = t.tag;
|
||||
tag.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
activeFilter = (activeFilter === t.tag) ? null : t.tag;
|
||||
render();
|
||||
});
|
||||
var est = document.createElement('span');
|
||||
est.className = 'est';
|
||||
est.textContent = t.est;
|
||||
top.appendChild(tid);
|
||||
top.appendChild(tag);
|
||||
top.appendChild(est);
|
||||
|
||||
var title = document.createElement('div');
|
||||
title.className = 'ttitle';
|
||||
title.textContent = t.title;
|
||||
|
||||
var bot = document.createElement('div');
|
||||
bot.className = 'ticket-bot';
|
||||
var owner = document.createElement('span');
|
||||
owner.className = 'owner ' + (OWNER_CLASS[t.owner] || 'o1');
|
||||
owner.textContent = t.owner;
|
||||
bot.appendChild(owner);
|
||||
|
||||
card.appendChild(top);
|
||||
card.appendChild(title);
|
||||
card.appendChild(bot);
|
||||
|
||||
card.addEventListener('dragstart', function (e) {
|
||||
dragId = t.id;
|
||||
card.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', t.id);
|
||||
});
|
||||
card.addEventListener('dragend', function () {
|
||||
dragId = null;
|
||||
card.classList.remove('dragging');
|
||||
document.querySelectorAll('.col.dragover').forEach(function (el) {
|
||||
el.classList.remove('dragover');
|
||||
});
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function render() {
|
||||
COLUMNS.forEach(function (c) { colBodies[c.key].innerHTML = ''; });
|
||||
var counts = {};
|
||||
var pts = {};
|
||||
COLUMNS.forEach(function (c) { counts[c.key] = 0; pts[c.key] = 0; });
|
||||
|
||||
tickets.forEach(function (t) {
|
||||
colBodies[t.col].appendChild(makeCard(t));
|
||||
counts[t.col] += 1;
|
||||
pts[t.col] += EST_PTS[t.est] || 0;
|
||||
});
|
||||
|
||||
COLUMNS.forEach(function (c) {
|
||||
colCounts[c.key].textContent = counts[c.key];
|
||||
colFoots[c.key].innerHTML = 'estimate <b>' + pts[c.key] + ' pt' + (pts[c.key] === 1 ? '' : 's') + '</b>';
|
||||
});
|
||||
|
||||
var summary = document.getElementById('summary');
|
||||
summary.innerHTML = COLUMNS.map(function (c) {
|
||||
return '<b>' + counts[c.key] + '</b> ' + c.key;
|
||||
}).join('<span class="dot">·</span>');
|
||||
|
||||
var badge = document.getElementById('filterBadge');
|
||||
if (activeFilter) {
|
||||
badge.textContent = 'filter: ' + activeFilter + ' ×';
|
||||
badge.classList.add('on');
|
||||
} else {
|
||||
badge.classList.remove('on');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('filterBadge').addEventListener('click', function () {
|
||||
activeFilter = null;
|
||||
render();
|
||||
});
|
||||
|
||||
// ---------- export ----------
|
||||
function buildMarkdown() {
|
||||
var lines = [];
|
||||
lines.push('# Acme — Cycle 14 triage');
|
||||
lines.push('');
|
||||
COLUMNS.forEach(function (c) {
|
||||
var rows = tickets.filter(function (t) { return t.col === c.key; });
|
||||
var pts = rows.reduce(function (s, t) { return s + (EST_PTS[t.est] || 0); }, 0);
|
||||
lines.push('## ' + c.label + ' (' + rows.length + ' · ' + pts + ' pts)');
|
||||
lines.push('');
|
||||
lines.push('_' + c.rationale + '_');
|
||||
lines.push('');
|
||||
rows.forEach(function (t) {
|
||||
lines.push('- **' + t.id + '** ' + t.title + ' — ' + t.tag + ', ' + t.est + ', ' + t.owner);
|
||||
});
|
||||
lines.push('');
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
var copyBtn = document.getElementById('copyBtn');
|
||||
var copyTimer = null;
|
||||
copyBtn.addEventListener('click', function () {
|
||||
var md = buildMarkdown();
|
||||
function flash() {
|
||||
copyBtn.textContent = 'Copied ✓';
|
||||
copyBtn.classList.add('copied');
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(function () {
|
||||
copyBtn.textContent = 'Copy as markdown';
|
||||
copyBtn.classList.remove('copied');
|
||||
}, 1200);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(md).then(flash, flash);
|
||||
} else {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = md;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
flash();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('resetBtn').addEventListener('click', function () {
|
||||
tickets = INITIAL.map(function (t) { return Object.assign({}, t); });
|
||||
activeFilter = null;
|
||||
render();
|
||||
});
|
||||
|
||||
// ---------- init ----------
|
||||
tickets = INITIAL.map(function (t) { return Object.assign({}, t); });
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,664 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Acme — flags.production.json</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--clay-d: #B85C3E;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-50: #F0EEE6;
|
||||
--gray-200: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-800: #3D3D3A;
|
||||
--white: #ffffff;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 64px;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header { margin-bottom: 28px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.sub {
|
||||
color: var(--gray-500);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
/* ---------- layout ---------- */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.sidebar { position: static !important; }
|
||||
}
|
||||
|
||||
/* ---------- warning banner ---------- */
|
||||
.banner {
|
||||
grid-column: 1 / -1;
|
||||
display: none;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid #E8C2AE;
|
||||
background: #F8E9E0;
|
||||
color: var(--clay-d);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.banner.show { display: flex; }
|
||||
.banner .glyph { font-size: 13px; line-height: 1; transform: translateY(1px); }
|
||||
|
||||
/* ---------- groups / panels ---------- */
|
||||
.form-col { display: flex; flex-direction: column; gap: 16px; }
|
||||
.group {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.group-head {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
padding: 12px 18px 11px;
|
||||
border-bottom: 1.5px solid var(--gray-50);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.group-head .name { color: var(--gray-800); }
|
||||
.group-head .meta { letter-spacing: 0.04em; }
|
||||
|
||||
/* ---------- flag rows ---------- */
|
||||
.flag {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--gray-50);
|
||||
border-left: 3px solid transparent;
|
||||
transition: border-left-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
.flag:last-child { border-bottom: none; }
|
||||
.flag.warn {
|
||||
border-left-color: var(--clay);
|
||||
background: #FBF3EE;
|
||||
}
|
||||
|
||||
.flag-info { flex: 1; min-width: 0; }
|
||||
.flag-key {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--gray-800);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.dot {
|
||||
display: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--clay);
|
||||
flex: none;
|
||||
}
|
||||
.flag.changed .dot { display: inline-block; }
|
||||
.rollout {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
padding: 1px 7px 2px;
|
||||
}
|
||||
.flag-desc {
|
||||
color: var(--gray-500);
|
||||
font-size: 12.5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.req-chip {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
padding: 2px 9px 3px;
|
||||
}
|
||||
.req-chip .warn-glyph { display: none; }
|
||||
.flag.warn .req-chip {
|
||||
color: var(--clay-d);
|
||||
background: #F8E1D5;
|
||||
border-color: #E8C2AE;
|
||||
}
|
||||
.flag.warn .req-chip .warn-glyph { display: inline; }
|
||||
|
||||
/* ---------- toggle switch ---------- */
|
||||
.toggle {
|
||||
position: relative;
|
||||
flex: none;
|
||||
width: 38px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.toggle input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle .track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--gray-200);
|
||||
border-radius: 999px;
|
||||
transition: background 160ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toggle .track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--white);
|
||||
box-shadow: 0 1px 2px rgba(20,20,19,0.18);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
.toggle input:checked + .track { background: var(--olive); }
|
||||
.toggle input:checked + .track::after { transform: translateX(16px); }
|
||||
.toggle input:focus-visible + .track {
|
||||
outline: 2px solid var(--slate);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------- sidebar ---------- */
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.side-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.side-count {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.side-count .warn-n { color: var(--clay-d); }
|
||||
.side-count .sep { color: var(--gray-200); margin: 0 4px; }
|
||||
.diff {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
background: var(--gray-50);
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.diff .minus { color: var(--gray-500); }
|
||||
.diff .plus { color: var(--olive); }
|
||||
.diff .empty { color: var(--gray-500); }
|
||||
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
padding: 9px 16px;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 140ms ease, color 140ms ease, border-color 140ms ease;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
}
|
||||
.btn.primary:hover:not(:disabled) { background: var(--gray-800); }
|
||||
.btn.primary:disabled {
|
||||
background: var(--gray-200);
|
||||
color: var(--gray-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.primary.copied { background: var(--olive); }
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--gray-800);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
}
|
||||
.btn.ghost:hover { border-color: var(--gray-500); }
|
||||
.btn.ghost.copied { border-color: var(--olive); color: var(--olive); }
|
||||
.btn-stack { display: flex; flex-direction: column; gap: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Acme / editor / feature-flags</div>
|
||||
<h1>flags.production.json</h1>
|
||||
<p class="sub">A form-based editor for the production feature-flag config.
|
||||
Toggle flags, fix dependency warnings as they surface, then copy out
|
||||
only the lines that changed.</p>
|
||||
</header>
|
||||
|
||||
<div class="layout">
|
||||
<div class="banner" id="banner">
|
||||
<span class="glyph">⚠</span>
|
||||
<span id="bannerText">1 flag is enabled without its prerequisite</span>
|
||||
</div>
|
||||
|
||||
<div class="form-col" id="formCol"><!-- groups injected --></div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div>
|
||||
<div class="side-label">Pending changes</div>
|
||||
<div class="side-count" id="sideCount">0 changed</div>
|
||||
</div>
|
||||
<pre class="diff" id="diff"><span class="empty">// no changes yet</span></pre>
|
||||
<div class="btn-stack">
|
||||
<button class="btn primary" id="copyDiff" disabled>Copy diff</button>
|
||||
<button class="btn ghost" id="copyJson">Copy full JSON</button>
|
||||
<button class="btn ghost" id="reset">Reset</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// data — the production flag set, as it exists right now
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GROUPS = [
|
||||
{
|
||||
id: "onboarding",
|
||||
label: "Onboarding",
|
||||
flags: [
|
||||
{ key: "onboarding.checklist_v2",
|
||||
desc: "New three-step setup checklist replacing the modal tour.",
|
||||
on: true },
|
||||
{ key: "onboarding.invite_nudge",
|
||||
desc: "Nudge owners to invite teammates after creating their first project.",
|
||||
on: true },
|
||||
{ key: "onboarding.workspace_templates",
|
||||
desc: "Offer prebuilt workspace templates during signup.",
|
||||
on: false, requires: "onboarding.checklist_v2" },
|
||||
{ key: "onboarding.skip_email_verify",
|
||||
desc: "Let SSO-domain users in before email verification completes.",
|
||||
on: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
label: "Sync engine",
|
||||
flags: [
|
||||
{ key: "sync.delta_compression",
|
||||
desc: "Send field-level deltas instead of full document snapshots.",
|
||||
on: true },
|
||||
{ key: "sync.offline_queue_v2",
|
||||
desc: "Persist offline edits to IndexedDB and replay on reconnect.",
|
||||
on: false, requires: "sync.delta_compression" },
|
||||
{ key: "sync.presence_cursors",
|
||||
desc: "Show live collaborator cursors in board and doc views.",
|
||||
on: true },
|
||||
{ key: "sync.conflict_banner",
|
||||
desc: "Surface a merge banner instead of silently last-write-wins.",
|
||||
on: false, requires: "sync.offline_queue_v2" },
|
||||
{ key: "sync.binary_ws_frames",
|
||||
desc: "Switch the realtime channel to binary WebSocket frames.",
|
||||
on: false, rollout: 10 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: "Billing",
|
||||
flags: [
|
||||
{ key: "billing.usage_meter",
|
||||
desc: "Show per-seat usage meter on the workspace billing page.",
|
||||
on: false },
|
||||
{ key: "billing.annual_discount_banner",
|
||||
desc: "Promote the annual-plan discount in the upgrade flow.",
|
||||
on: true },
|
||||
{ key: "billing.proration_preview",
|
||||
desc: "Preview the prorated charge before confirming a plan change.",
|
||||
on: false, requires: "billing.usage_meter" },
|
||||
{ key: "billing.dunning_emails_v3",
|
||||
desc: "Use the rewritten dunning sequence with a 14-day grace window.",
|
||||
on: true, rollout: 25 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "internal",
|
||||
label: "Internal",
|
||||
flags: [
|
||||
{ key: "internal.shadow_traffic",
|
||||
desc: "Mirror 1% of API traffic to the staging cluster for diffing.",
|
||||
on: false },
|
||||
{ key: "internal.query_tracing",
|
||||
desc: "Attach OpenTelemetry spans to every Postgres query.",
|
||||
on: true },
|
||||
{ key: "internal.kill_switch_ui",
|
||||
desc: "Expose the emergency kill-switch panel in the admin console.",
|
||||
on: true, requires: "internal.query_tracing" }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// flat lookup, plus a frozen copy of initial state
|
||||
const FLAGS = {};
|
||||
const INITIAL = {};
|
||||
GROUPS.forEach(g => g.flags.forEach(f => {
|
||||
FLAGS[f.key] = f;
|
||||
INITIAL[f.key] = f.on;
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render the form once; state lives in the checkboxes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const formCol = document.getElementById("formCol");
|
||||
const banner = document.getElementById("banner");
|
||||
const bannerTxt = document.getElementById("bannerText");
|
||||
const sideCount = document.getElementById("sideCount");
|
||||
const diffEl = document.getElementById("diff");
|
||||
const copyDiffBtn = document.getElementById("copyDiff");
|
||||
const copyJsonBtn = document.getElementById("copyJson");
|
||||
const resetBtn = document.getElementById("reset");
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
}
|
||||
|
||||
function buildForm() {
|
||||
formCol.innerHTML = "";
|
||||
for (const g of GROUPS) {
|
||||
const panel = document.createElement("section");
|
||||
panel.className = "group";
|
||||
panel.dataset.group = g.id;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "group-head";
|
||||
head.innerHTML =
|
||||
'<span class="name">' + esc(g.label) + '</span>' +
|
||||
'<span class="meta" data-meta></span>';
|
||||
panel.appendChild(head);
|
||||
|
||||
for (const f of g.flags) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "flag";
|
||||
row.dataset.key = f.key;
|
||||
|
||||
const rolloutBadge = (typeof f.rollout === "number")
|
||||
? '<span class="rollout">' + f.rollout + '%</span>'
|
||||
: "";
|
||||
|
||||
const reqChip = f.requires
|
||||
? '<div class="req-chip"><span class="warn-glyph">⚠</span>' +
|
||||
'<span>requires ' + esc(f.requires) + '</span></div>'
|
||||
: "";
|
||||
|
||||
row.innerHTML =
|
||||
'<label class="toggle">' +
|
||||
'<input type="checkbox" data-key="' + esc(f.key) + '"' +
|
||||
(f.on ? " checked" : "") +
|
||||
' aria-label="' + esc(f.key) + '">' +
|
||||
'<span class="track"></span>' +
|
||||
'</label>' +
|
||||
'<div class="flag-info">' +
|
||||
'<div class="flag-key"><span class="dot"></span>' +
|
||||
'<span>' + esc(f.key) + '</span>' + rolloutBadge +
|
||||
'</div>' +
|
||||
'<div class="flag-desc">' + esc(f.desc) + '</div>' +
|
||||
reqChip +
|
||||
'</div>';
|
||||
|
||||
panel.appendChild(row);
|
||||
}
|
||||
formCol.appendChild(panel);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// state helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function currentState() {
|
||||
const state = {};
|
||||
formCol.querySelectorAll("input[type=checkbox]").forEach(cb => {
|
||||
state[cb.dataset.key] = cb.checked;
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
function computeDiff(state) {
|
||||
const lines = [];
|
||||
for (const g of GROUPS) {
|
||||
for (const f of g.flags) {
|
||||
if (state[f.key] !== INITIAL[f.key]) {
|
||||
lines.push({ key: f.key, from: INITIAL[f.key], to: state[f.key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function diffText(lines) {
|
||||
return lines.map(l =>
|
||||
'- "' + l.key + '": ' + l.from + '\n' +
|
||||
'+ "' + l.key + '": ' + l.to
|
||||
).join("\n");
|
||||
}
|
||||
|
||||
function fullJson(state) {
|
||||
// grouped, ordered, pretty-printed — what you'd actually paste into the file
|
||||
let out = "{\n";
|
||||
GROUPS.forEach((g, gi) => {
|
||||
g.flags.forEach((f, fi) => {
|
||||
const last = gi === GROUPS.length - 1 && fi === g.flags.length - 1;
|
||||
out += ' "' + f.key + '": ' + state[f.key] + (last ? "" : ",") + "\n";
|
||||
});
|
||||
});
|
||||
out += "}";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// the live update pass
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function update() {
|
||||
const state = currentState();
|
||||
let warnCount = 0;
|
||||
|
||||
// per-row decorations
|
||||
for (const g of GROUPS) {
|
||||
let onCount = 0;
|
||||
for (const f of g.flags) {
|
||||
const row = formCol.querySelector('.flag[data-key="' + CSS.escape(f.key) + '"]');
|
||||
const on = state[f.key];
|
||||
if (on) onCount++;
|
||||
|
||||
const changed = on !== INITIAL[f.key];
|
||||
row.classList.toggle("changed", changed);
|
||||
|
||||
let warn = false;
|
||||
if (f.requires && on && !state[f.requires]) warn = true;
|
||||
row.classList.toggle("warn", warn);
|
||||
if (warn) warnCount++;
|
||||
}
|
||||
const meta = formCol.querySelector(
|
||||
'.group[data-group="' + g.id + '"] [data-meta]');
|
||||
meta.textContent = g.flags.length + " flags, " + onCount + " on";
|
||||
}
|
||||
|
||||
// diff + sidebar
|
||||
const lines = computeDiff(state);
|
||||
if (lines.length === 0) {
|
||||
diffEl.innerHTML = '<span class="empty">// no changes yet</span>';
|
||||
copyDiffBtn.disabled = true;
|
||||
} else {
|
||||
diffEl.innerHTML = lines.map(l =>
|
||||
'<span class="minus">- "' + esc(l.key) + '": ' + l.from + '</span>\n' +
|
||||
'<span class="plus">+ "' + esc(l.key) + '": ' + l.to + '</span>'
|
||||
).join("\n");
|
||||
copyDiffBtn.disabled = false;
|
||||
}
|
||||
|
||||
sideCount.innerHTML =
|
||||
lines.length + " changed" +
|
||||
'<span class="sep">·</span>' +
|
||||
'<span class="' + (warnCount ? "warn-n" : "") + '">' +
|
||||
warnCount + " warning" + (warnCount === 1 ? "" : "s") +
|
||||
"</span>";
|
||||
|
||||
// banner
|
||||
if (warnCount > 0) {
|
||||
bannerTxt.textContent =
|
||||
warnCount === 1
|
||||
? "1 flag is enabled without its prerequisite"
|
||||
: warnCount + " flags are enabled without their prerequisites";
|
||||
banner.classList.add("show");
|
||||
} else {
|
||||
banner.classList.remove("show");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clipboard + buttons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function flash(btn, label) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = label;
|
||||
btn.classList.add("copied");
|
||||
btn.disabled = true;
|
||||
setTimeout(() => {
|
||||
btn.textContent = orig;
|
||||
btn.classList.remove("copied");
|
||||
btn.disabled = false;
|
||||
update(); // restore disabled state on copyDiff if needed
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function writeClipboard(text) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
// fallback for file:// contexts without clipboard permission
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
copyDiffBtn.addEventListener("click", () => {
|
||||
const lines = computeDiff(currentState());
|
||||
if (!lines.length) return;
|
||||
writeClipboard(diffText(lines)).then(() => flash(copyDiffBtn, "Copied ✓"));
|
||||
});
|
||||
|
||||
copyJsonBtn.addEventListener("click", () => {
|
||||
writeClipboard(fullJson(currentState())).then(() => flash(copyJsonBtn, "Copied ✓"));
|
||||
});
|
||||
|
||||
resetBtn.addEventListener("click", () => {
|
||||
formCol.querySelectorAll("input[type=checkbox]").forEach(cb => {
|
||||
cb.checked = INITIAL[cb.dataset.key];
|
||||
});
|
||||
update();
|
||||
});
|
||||
|
||||
formCol.addEventListener("change", e => {
|
||||
if (e.target.matches("input[type=checkbox]")) update();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
buildForm();
|
||||
update();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,723 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Acme — Support reply prompt tuner</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--clay-d: #B85C3E;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--gray-50: #F0EEE6;
|
||||
--gray-200: #D1CFC5;
|
||||
--gray-500: #87867F;
|
||||
--gray-800: #3D3D3A;
|
||||
--white: #ffffff;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 64px;
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header { margin-bottom: 22px; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.sub {
|
||||
color: var(--gray-500);
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
/* ---------- toolbar ---------- */
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--ivory);
|
||||
padding: 10px 0 14px;
|
||||
margin-bottom: 6px;
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
.toolbar .spacer { flex: 1; }
|
||||
.toolbar .hint {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
button {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
border: 1.5px solid var(--slate);
|
||||
border-radius: 999px;
|
||||
padding: 9px 16px;
|
||||
transition: background 140ms ease, transform 80ms ease;
|
||||
}
|
||||
.btn-primary:hover { background: var(--gray-800); }
|
||||
.btn-primary:active { transform: translateY(1px); }
|
||||
.btn-primary.copied { background: var(--olive); border-color: var(--olive); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--gray-800);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
padding: 8px 15px;
|
||||
transition: border-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
.btn-ghost:hover { border-color: var(--gray-500); color: var(--slate); }
|
||||
|
||||
/* ---------- two-column layout ---------- */
|
||||
.cols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
margin-top: 18px;
|
||||
}
|
||||
@media (max-width: 880px) {
|
||||
.cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- left: editor ---------- */
|
||||
.ed-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1.5px solid var(--gray-200);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
.ed-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ed-toolbar .spacer { flex: 1; }
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-800);
|
||||
background: var(--white);
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.editor {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
padding: 18px;
|
||||
min-height: 360px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--white);
|
||||
color: var(--slate);
|
||||
outline: none;
|
||||
caret-color: var(--clay);
|
||||
tab-size: 2;
|
||||
}
|
||||
.editor:focus-visible { outline: none; }
|
||||
.slot {
|
||||
background: var(--oat);
|
||||
color: var(--clay-d);
|
||||
border-radius: 4px;
|
||||
padding: 0 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.slot.warn {
|
||||
background: rgba(217, 119, 87, 0.14);
|
||||
color: var(--clay-d);
|
||||
text-decoration: underline dashed var(--clay) 1.5px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
border-top: 1.5px solid var(--gray-200);
|
||||
padding: 12px 16px 14px;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
.legend-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.legend-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
background: var(--oat);
|
||||
color: var(--clay-d);
|
||||
border-radius: 4px;
|
||||
padding: 2px 7px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---------- right: preview ---------- */
|
||||
.preview-panel { padding: 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.preview-head {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--gray-500);
|
||||
padding: 0 2px 2px;
|
||||
}
|
||||
.sample {
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sample-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 9px 13px;
|
||||
border-bottom: 1.5px solid var(--gray-200);
|
||||
background: var(--gray-50);
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.cust {
|
||||
font-family: var(--serif);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.sample-head .spacer { flex: 1; }
|
||||
.plan {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
border: 1.5px solid var(--gray-200);
|
||||
color: var(--gray-800);
|
||||
background: var(--white);
|
||||
}
|
||||
.plan.team { background: var(--oat); border-color: var(--oat); color: var(--clay-d); }
|
||||
.plan.studio { background: var(--olive); border-color: var(--olive); color: var(--ivory); }
|
||||
.rendered {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-800);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
padding: 12px 13px 14px;
|
||||
}
|
||||
.filled {
|
||||
background: var(--gray-50);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
box-shadow: inset 0 0 0 1px var(--gray-200);
|
||||
}
|
||||
.missing {
|
||||
background: rgba(217, 119, 87, 0.12);
|
||||
color: var(--clay-d);
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Acme / editor / prompt-tuner</div>
|
||||
<h1>Support reply draft prompt</h1>
|
||||
<p class="sub">Edit the system prompt on the left and watch three sample tickets re-render the filled template on the right, live as you type. When it reads well across all three moods, copy the template out.</p>
|
||||
</header>
|
||||
|
||||
<div class="toolbar">
|
||||
<button id="copyBtn" class="btn-primary">Copy prompt</button>
|
||||
<button id="resetBtn" class="btn-ghost">Reset</button>
|
||||
<div class="spacer"></div>
|
||||
<div class="hint">slots use <strong>{{double_brace}}</strong> syntax</div>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
|
||||
<!-- left -->
|
||||
<section class="panel">
|
||||
<div class="ed-toolbar">
|
||||
<span class="ed-label">Template</span>
|
||||
<div class="spacer"></div>
|
||||
<span id="counter" class="counter">0 chars · ~0 tokens</span>
|
||||
</div>
|
||||
<div id="editor" class="editor" contenteditable="true" spellcheck="false" autocapitalize="off" autocorrect="off"></div>
|
||||
<div class="legend">
|
||||
<div class="legend-label">Available slots</div>
|
||||
<div id="legendChips" class="legend-chips"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- right -->
|
||||
<section class="panel preview-panel">
|
||||
<div class="preview-head">Live preview · 3 sample tickets</div>
|
||||
<div id="samples"></div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>Highlighted slots fill from each sample's ticket fields. Anything underlined in dashed clay isn't a known field and will pass through unfilled.</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var DEFAULT_TEMPLATE =
|
||||
'You are a support agent for Acme, a project workspace for small teams.\n' +
|
||||
'\n' +
|
||||
'A customer named {{customer_name}} on the {{plan_tier}} plan wrote in about:\n' +
|
||||
'"{{ticket_subject}}"\n' +
|
||||
'\n' +
|
||||
'Their message:\n' +
|
||||
'{{ticket_body}}\n' +
|
||||
'\n' +
|
||||
'Write a reply that is {{tone}}, no more than 120 words, and ends with a single concrete next step.\n' +
|
||||
'Never promise a refund without escalating. Sign off as "The Acme team."';
|
||||
|
||||
var SAMPLES = [
|
||||
{
|
||||
label: 'SAMPLE 1',
|
||||
planClass: 'free',
|
||||
data: {
|
||||
customer_name: 'Priya N.',
|
||||
plan_tier: 'Free',
|
||||
ticket_subject: 'Where did my board go?',
|
||||
ticket_body: 'Hi — I made a board yesterday called "Spring launch" with my coworker and today I can’t find it anywhere. I’m new to Acme and I’m not sure if I deleted it by accident or if I’m just looking in the wrong place. Can you help?',
|
||||
tone: 'warm and patient'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'SAMPLE 2',
|
||||
planClass: 'team',
|
||||
data: {
|
||||
customer_name: 'Marcus D.',
|
||||
plan_tier: 'Team',
|
||||
ticket_subject: 'Sync keeps dropping comments',
|
||||
ticket_body: 'This is the third time this week. I leave comments on cards from my laptop, switch to my phone on the train, and they’re gone. My team thinks I’m ignoring them. We pay for 14 seats and this is genuinely making us look bad to a client.',
|
||||
tone: 'direct and apologetic'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'SAMPLE 3',
|
||||
planClass: 'studio',
|
||||
data: {
|
||||
customer_name: 'Lena K.',
|
||||
plan_tier: 'Studio',
|
||||
ticket_subject: 'Update billing entity on next invoice',
|
||||
ticket_body: 'Hey folks! We just spun up a new holding company (ACME Corp Oy) and finance would love the May invoice to use that name + our new VAT ID, FI00000000. No rush at all, just want it sorted before the cycle runs. Thanks a million.',
|
||||
tone: 'brisk and friendly'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
var KNOWN_SLOTS = ['customer_name', 'plan_tier', 'ticket_subject', 'ticket_body', 'tone'];
|
||||
var SLOT_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DOM refs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var editor = document.getElementById('editor');
|
||||
var counterEl = document.getElementById('counter');
|
||||
var samplesEl = document.getElementById('samples');
|
||||
var legendEl = document.getElementById('legendChips');
|
||||
var copyBtn = document.getElementById('copyBtn');
|
||||
var resetBtn = document.getElementById('resetBtn');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// Read plain text out of the contenteditable. We rely on white-space:pre-wrap
|
||||
// and only ever insert text + inline <span>s, so textContent is faithful as
|
||||
// long as the browser hasn't injected block elements or <br>s. Normalize both.
|
||||
function getPlainText() {
|
||||
var out = '';
|
||||
var walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null);
|
||||
var node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
out += node.nodeValue;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
var tag = node.tagName;
|
||||
if (tag === 'BR') out += '\n';
|
||||
// Some browsers wrap new lines in <div>; emit a newline before a div
|
||||
// that isn't the first child and doesn't already start after one.
|
||||
if (tag === 'DIV' && node !== editor.firstChild && out.length && out[out.length - 1] !== '\n') {
|
||||
out += '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Caret save/restore by character offset ----
|
||||
|
||||
function getCaretOffset() {
|
||||
var sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return null;
|
||||
var range = sel.getRangeAt(0);
|
||||
if (!editor.contains(range.startContainer)) return null;
|
||||
|
||||
var pre = range.cloneRange();
|
||||
pre.selectNodeContents(editor);
|
||||
pre.setEnd(range.startContainer, range.startOffset);
|
||||
|
||||
// Count characters by walking text nodes within the pre-range. We mirror
|
||||
// getPlainText's newline handling so offsets line up.
|
||||
var frag = pre.cloneContents();
|
||||
var off = 0;
|
||||
var walker = document.createTreeWalker(frag, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, null);
|
||||
var node, lastChar = '';
|
||||
var first = true;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
off += node.nodeValue.length;
|
||||
if (node.nodeValue.length) lastChar = node.nodeValue[node.nodeValue.length - 1];
|
||||
} else {
|
||||
if (node.tagName === 'BR') { off += 1; lastChar = '\n'; }
|
||||
if (node.tagName === 'DIV' && !first && lastChar !== '\n') { off += 1; lastChar = '\n'; }
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
return off;
|
||||
}
|
||||
|
||||
function setCaretOffset(offset) {
|
||||
if (offset == null) return;
|
||||
var sel = window.getSelection();
|
||||
if (!sel) return;
|
||||
|
||||
var walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT, null);
|
||||
var node;
|
||||
var remaining = offset;
|
||||
var lastNode = null;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
lastNode = node;
|
||||
var len = node.nodeValue.length;
|
||||
if (remaining <= len) {
|
||||
var range = document.createRange();
|
||||
range.setStart(node, remaining);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return;
|
||||
}
|
||||
remaining -= len;
|
||||
}
|
||||
|
||||
// Past the end — place at end of last text node, or at end of editor.
|
||||
var range2 = document.createRange();
|
||||
if (lastNode) {
|
||||
range2.setStart(lastNode, lastNode.nodeValue.length);
|
||||
} else {
|
||||
range2.selectNodeContents(editor);
|
||||
range2.collapse(false);
|
||||
}
|
||||
range2.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range2);
|
||||
}
|
||||
|
||||
// ---- Highlighting ----
|
||||
|
||||
function highlightHtml(text) {
|
||||
var html = '';
|
||||
var last = 0;
|
||||
SLOT_RE.lastIndex = 0;
|
||||
var m;
|
||||
while ((m = SLOT_RE.exec(text)) !== null) {
|
||||
html += escapeHtml(text.slice(last, m.index));
|
||||
var name = m[1];
|
||||
var cls = KNOWN_SLOTS.indexOf(name) !== -1 ? 'slot' : 'slot warn';
|
||||
html += '<span class="' + cls + '">' + escapeHtml(m[0]) + '</span>';
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
html += escapeHtml(text.slice(last));
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderEditor(text, caret) {
|
||||
editor.innerHTML = highlightHtml(text);
|
||||
if (caret != null && document.activeElement === editor) {
|
||||
setCaretOffset(caret);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Preview ----
|
||||
|
||||
function fillHtml(text, data) {
|
||||
var html = '';
|
||||
var last = 0;
|
||||
SLOT_RE.lastIndex = 0;
|
||||
var m;
|
||||
while ((m = SLOT_RE.exec(text)) !== null) {
|
||||
html += escapeHtml(text.slice(last, m.index));
|
||||
var name = m[1];
|
||||
if (Object.prototype.hasOwnProperty.call(data, name)) {
|
||||
html += '<span class="filled">' + escapeHtml(String(data[name])) + '</span>';
|
||||
} else {
|
||||
html += '<span class="missing">' + escapeHtml(m[0]) + '</span>';
|
||||
}
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
html += escapeHtml(text.slice(last));
|
||||
return html;
|
||||
}
|
||||
|
||||
var sampleNodes = [];
|
||||
|
||||
function buildSamples() {
|
||||
samplesEl.innerHTML = '';
|
||||
sampleNodes = [];
|
||||
SAMPLES.forEach(function (s) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'sample';
|
||||
|
||||
var head = document.createElement('div');
|
||||
head.className = 'sample-head';
|
||||
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = s.label;
|
||||
|
||||
var cust = document.createElement('span');
|
||||
cust.className = 'cust';
|
||||
cust.textContent = s.data.customer_name;
|
||||
|
||||
var spacer = document.createElement('span');
|
||||
spacer.className = 'spacer';
|
||||
|
||||
var plan = document.createElement('span');
|
||||
plan.className = 'plan ' + s.planClass;
|
||||
plan.textContent = s.data.plan_tier;
|
||||
|
||||
head.appendChild(badge);
|
||||
head.appendChild(cust);
|
||||
head.appendChild(spacer);
|
||||
head.appendChild(plan);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'rendered';
|
||||
|
||||
card.appendChild(head);
|
||||
card.appendChild(body);
|
||||
samplesEl.appendChild(card);
|
||||
sampleNodes.push({ body: body, data: s.data });
|
||||
});
|
||||
samplesEl.style.display = 'flex';
|
||||
samplesEl.style.flexDirection = 'column';
|
||||
samplesEl.style.gap = '14px';
|
||||
}
|
||||
|
||||
function buildLegend() {
|
||||
legendEl.innerHTML = '';
|
||||
KNOWN_SLOTS.forEach(function (name) {
|
||||
var c = document.createElement('span');
|
||||
c.className = 'chip';
|
||||
c.textContent = '{{' + name + '}}';
|
||||
legendEl.appendChild(c);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Counter ----
|
||||
|
||||
function updateCounter(text) {
|
||||
var chars = text.length;
|
||||
var tokens = Math.round(chars / 4.2);
|
||||
counterEl.textContent = chars + ' chars · ~' + tokens + ' tokens';
|
||||
}
|
||||
|
||||
// ---- Master refresh ----
|
||||
|
||||
function refresh(opts) {
|
||||
opts = opts || {};
|
||||
var text = opts.text != null ? opts.text : getPlainText();
|
||||
var caret = opts.preserveCaret ? getCaretOffset() : null;
|
||||
|
||||
renderEditor(text, caret);
|
||||
updateCounter(text);
|
||||
sampleNodes.forEach(function (sn) {
|
||||
sn.body.innerHTML = fillHtml(text, sn.data);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var raf = null;
|
||||
editor.addEventListener('input', function () {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(function () {
|
||||
raf = null;
|
||||
refresh({ preserveCaret: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Force plain-text paste so we never inherit foreign HTML.
|
||||
editor.addEventListener('paste', function (e) {
|
||||
e.preventDefault();
|
||||
var txt = (e.clipboardData || window.clipboardData).getData('text/plain');
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
var range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(document.createTextNode(txt));
|
||||
range.collapse(false);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
refresh({ preserveCaret: true });
|
||||
});
|
||||
|
||||
// Insert literal newline on Enter so we don't get nested <div>s.
|
||||
editor.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var sel = window.getSelection();
|
||||
if (!sel || !sel.rangeCount) return;
|
||||
var range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
var nl = document.createTextNode('\n');
|
||||
range.insertNode(nl);
|
||||
range.setStartAfter(nl);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
refresh({ preserveCaret: true });
|
||||
}
|
||||
});
|
||||
|
||||
var copyTimer = null;
|
||||
copyBtn.addEventListener('click', function () {
|
||||
var text = getPlainText();
|
||||
var done = function () {
|
||||
copyBtn.textContent = 'Copied ✓';
|
||||
copyBtn.classList.add('copied');
|
||||
if (copyTimer) clearTimeout(copyTimer);
|
||||
copyTimer = setTimeout(function () {
|
||||
copyBtn.textContent = 'Copy prompt';
|
||||
copyBtn.classList.remove('copied');
|
||||
}, 1200);
|
||||
};
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () {
|
||||
fallbackCopy(text); done();
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text); done();
|
||||
}
|
||||
});
|
||||
|
||||
function fallbackCopy(text) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) { /* ignore */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
resetBtn.addEventListener('click', function () {
|
||||
refresh({ text: DEFAULT_TEMPLATE });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
buildLegend();
|
||||
buildSamples();
|
||||
refresh({ text: DEFAULT_TEMPLATE });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
- Focusing on what is best for the overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the project maintainers at **opensource@anthropic.com**. All
|
||||
complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.1, available at
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Anthropic PBC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,41 @@
|
||||
# The unreasonable effectiveness of HTML — examples
|
||||
|
||||
> **Sample code. Not maintained and not accepting contributions.**
|
||||
|
||||
A gallery of standalone HTML examples that accompany the blog post on using HTML
|
||||
as a flexible output format. Each file is a self-contained `.html` page (no build
|
||||
step, no dependencies) demonstrating a different use case — from code review and
|
||||
design systems to slide decks, status reports, and small interactive editors.
|
||||
|
||||
Open [`index.html`](index.html) for the full, categorized index, or open any
|
||||
numbered file directly in a browser.
|
||||
|
||||
## Contents
|
||||
|
||||
| Category | Examples |
|
||||
|---|---|
|
||||
| Exploration | code approaches, visual designs |
|
||||
| Code | review, understanding, design systems, component variants |
|
||||
| Prototyping | animation, interaction |
|
||||
| Communication | slide deck, status report, incident report, PR write-up |
|
||||
| Diagrams & research | flowchart, feature/concept explainers |
|
||||
| Custom editing UIs | triage board, feature flags, prompt tuner |
|
||||
|
||||
## Running
|
||||
|
||||
There is nothing to install or build. Clone the repo and open `index.html` (or
|
||||
any individual file) in a web browser.
|
||||
|
||||
## A note on sample data
|
||||
|
||||
All product names, data, and scenarios in these examples are fictional and used
|
||||
only for illustration. The placeholder brand "Acme" and any figures shown are
|
||||
not real.
|
||||
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for how to report a vulnerability.
|
||||
|
||||
## License
|
||||
|
||||
Released under the [Apache License 2.0](LICENSE).
|
||||
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
If you believe you have found a security vulnerability in this repository,
|
||||
please report it responsibly.
|
||||
|
||||
- **Do not** open a public issue for security problems.
|
||||
- Email **security@anthropic.com** with a description of the issue and steps to
|
||||
reproduce it.
|
||||
|
||||
We will acknowledge your report, investigate, and keep you informed of the
|
||||
resolution.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository contains static, self-contained HTML example files with no
|
||||
server-side components, no build pipeline, and no third-party runtime
|
||||
dependencies. The most likely relevant reports are issues such as unsafe inline
|
||||
script behavior or content that could mislead a reader. General questions about
|
||||
the accompanying blog post are not security issues — please use normal issues
|
||||
for those.
|
||||
@@ -0,0 +1,823 @@
|
||||
<!-- Copyright 2026 Anthropic PBC · SPDX-License-Identifier: Apache-2.0 -->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>The unreasonable effectiveness of HTML — examples</title>
|
||||
<style>
|
||||
:root {
|
||||
--ivory: #FAF9F5;
|
||||
--paper: #FFFFFF;
|
||||
--slate: #141413;
|
||||
--clay: #D97757;
|
||||
--clay-d: #B85C3E;
|
||||
--oat: #E3DACC;
|
||||
--olive: #788C5D;
|
||||
--g100: #F0EEE6;
|
||||
--g200: #E6E3DA;
|
||||
--g300: #D1CFC5;
|
||||
--g500: #87867F;
|
||||
--g700: #3D3D3A;
|
||||
--serif: ui-serif, Georgia, "Times New Roman", Times, serif;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--ivory);
|
||||
color: var(--slate);
|
||||
font-family: var(--sans);
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 32px 140px; }
|
||||
|
||||
/* ── masthead ─────────────────────────── */
|
||||
|
||||
header.masthead {
|
||||
padding: 80px 0 56px;
|
||||
border-bottom: 1.5px solid var(--g300);
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.eyebrow::before {
|
||||
content: "";
|
||||
width: 24px; height: 1.5px;
|
||||
background: var(--clay);
|
||||
}
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(38px, 5.4vw, 62px);
|
||||
line-height: 1.06;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0 0 8px;
|
||||
max-width: 17ch;
|
||||
}
|
||||
h1 em {
|
||||
font-style: italic;
|
||||
color: var(--clay);
|
||||
}
|
||||
.intro {
|
||||
font-size: 16.5px;
|
||||
color: var(--g700);
|
||||
margin: 22px 0 0;
|
||||
max-width: 620px;
|
||||
}
|
||||
.intro a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
.intro a:hover { text-decoration-color: var(--clay); }
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 340px;
|
||||
gap: 48px;
|
||||
align-items: end;
|
||||
}
|
||||
@media (max-width: 880px) { .hero-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* hero figure — markdown vs html */
|
||||
.hero-fig {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
align-items: end;
|
||||
}
|
||||
@media (max-width: 880px) { .hero-fig { max-width: 400px; margin-top: 28px; } }
|
||||
.hero-fig .pane {
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--g300);
|
||||
background: var(--paper);
|
||||
padding: 14px;
|
||||
aspect-ratio: 4/5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
position: relative;
|
||||
}
|
||||
.hero-fig .pane.md {
|
||||
background: var(--g100);
|
||||
transform: rotate(-2.5deg) translateY(6px);
|
||||
}
|
||||
.hero-fig .pane.html {
|
||||
transform: rotate(1.5deg);
|
||||
border-color: var(--slate);
|
||||
box-shadow: 0 12px 32px rgba(20,20,19,.10);
|
||||
}
|
||||
.hero-fig .tag {
|
||||
position: absolute;
|
||||
top: -10px; left: 12px;
|
||||
font-family: var(--mono);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
background: var(--ivory);
|
||||
padding: 2px 7px;
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 6px;
|
||||
color: var(--g500);
|
||||
}
|
||||
.hero-fig .pane.html .tag { border-color: var(--slate); color: var(--slate); }
|
||||
.hero-fig .l { height: 6px; border-radius: 3px; background: var(--g300); }
|
||||
.hero-fig .l.w90 { width: 90%; } .hero-fig .l.w75 { width: 75%; }
|
||||
.hero-fig .l.w60 { width: 60%; } .hero-fig .l.w82 { width: 82%; }
|
||||
.hero-fig .l.w70 { width: 70%; } .hero-fig .l.w50 { width: 50%; }
|
||||
.hero-fig .pane.html .l { background: var(--g200); }
|
||||
.hero-fig .pane.html .blk {
|
||||
border-radius: 5px;
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, var(--oat), #ECE1CF);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-fig .pane.html .blk::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18%; right: 18%; top: 28%;
|
||||
height: 40%;
|
||||
border: 2px solid var(--clay);
|
||||
border-radius: 50%;
|
||||
opacity: .7;
|
||||
}
|
||||
.hero-fig .pane.html .row {
|
||||
display: flex; gap: 6px; align-items: flex-end;
|
||||
}
|
||||
.hero-fig .pane.html .bar { flex: 1; border-radius: 3px 3px 0 0; }
|
||||
.hero-fig .pane.html .bar.b1 { height: 14px; background: var(--olive); }
|
||||
.hero-fig .pane.html .bar.b2 { height: 26px; background: var(--clay); }
|
||||
.hero-fig .pane.html .bar.b3 { height: 18px; background: var(--oat); }
|
||||
.hero-fig .pane.html .bar.b4 { height: 30px; background: var(--slate); }
|
||||
|
||||
/* ── toc pills ────────────────────────── */
|
||||
|
||||
nav.toc {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 26px 0 0;
|
||||
}
|
||||
nav.toc a {
|
||||
font-size: 12.5px;
|
||||
padding: 7px 14px;
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: var(--g700);
|
||||
background: var(--paper);
|
||||
transition: border-color 120ms, color 120ms, background 120ms;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
nav.toc a .n {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--g500);
|
||||
}
|
||||
nav.toc a:hover { border-color: var(--slate); color: var(--slate); }
|
||||
nav.toc a:hover .n { color: var(--clay); }
|
||||
|
||||
/* ── section ──────────────────────────── */
|
||||
|
||||
section {
|
||||
margin-top: 72px;
|
||||
scroll-margin-top: 28px;
|
||||
}
|
||||
.sec-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sec-head .idx {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
color: var(--clay);
|
||||
font-weight: 600;
|
||||
width: 34px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sec-head h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 27px;
|
||||
margin: 0;
|
||||
letter-spacing: -0.012em;
|
||||
}
|
||||
.sec-head .count {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--g500);
|
||||
background: var(--g100);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.sec-intro {
|
||||
font-size: 14.5px;
|
||||
color: var(--g700);
|
||||
max-width: 700px;
|
||||
margin: 0 0 24px 50px;
|
||||
}
|
||||
@media (max-width: 640px) { .sec-intro { margin-left: 0; } }
|
||||
|
||||
/* ── cards ────────────────────────────── */
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(316px, 1fr));
|
||||
gap: 20px;
|
||||
margin-left: 50px;
|
||||
}
|
||||
@media (max-width: 640px) { .grid { margin-left: 0; } }
|
||||
|
||||
a.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
a.card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 10px 30px rgba(20, 20, 19, 0.10);
|
||||
border-color: var(--slate);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
height: 132px;
|
||||
background: var(--g100);
|
||||
border-bottom: 1.5px solid var(--g200);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
a.card:hover .thumb { background: var(--oat); }
|
||||
.thumb svg {
|
||||
width: 100%; height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
.thumb svg .st { stroke: var(--g500); fill: none; stroke-width: 2.5; }
|
||||
.thumb svg .fl { fill: var(--g300); }
|
||||
.thumb svg .cl { fill: var(--clay); }
|
||||
.thumb svg .ol { fill: var(--olive); }
|
||||
.thumb svg .oa { fill: var(--oat); stroke: var(--g500); stroke-width: 2.5; }
|
||||
.thumb svg .sl { fill: var(--slate); }
|
||||
.thumb svg .wh { fill: var(--paper); stroke: var(--g500); stroke-width: 2.5; }
|
||||
.thumb svg .ln { stroke: var(--g500); stroke-width: 2.5; fill: none; stroke-linecap: round; }
|
||||
.thumb svg .lc { stroke: var(--clay); stroke-width: 2.5; fill: none; stroke-linecap: round; }
|
||||
.thumb svg .da { stroke-dasharray: 4 4; }
|
||||
a.card:hover .thumb svg .fl { fill: var(--g500); }
|
||||
a.card:hover .thumb svg .oa { fill: var(--paper); }
|
||||
|
||||
.body { padding: 18px 20px 16px; display: flex; flex-direction: column; flex: 1; }
|
||||
.title {
|
||||
font-family: var(--serif);
|
||||
font-size: 19px;
|
||||
font-weight: 500;
|
||||
line-height: 1.22;
|
||||
color: var(--slate);
|
||||
margin-bottom: 7px;
|
||||
letter-spacing: -0.008em;
|
||||
}
|
||||
.desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--g700);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
flex: 1;
|
||||
}
|
||||
.file {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--g500);
|
||||
border-top: 1px solid var(--g100);
|
||||
padding-top: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.file .arrow { transition: transform 150ms ease; color: var(--g300); }
|
||||
a.card:hover .file { color: var(--clay); }
|
||||
a.card:hover .file .arrow { transform: translateX(3px); color: var(--clay); }
|
||||
|
||||
/* ── footer ───────────────────────────── */
|
||||
|
||||
footer {
|
||||
margin-top: 100px;
|
||||
border-top: 1.5px solid var(--g300);
|
||||
padding-top: 36px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
color: var(--g500);
|
||||
}
|
||||
footer .k { font-family: var(--serif); font-style: italic; color: var(--g700); font-size: 15px; }
|
||||
footer a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="masthead">
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<div class="eyebrow">Companion to the blog post</div>
|
||||
<h1>The unreasonable <em>effectiveness</em> of HTML</h1>
|
||||
<p class="intro">
|
||||
Twenty self-contained <code>.html</code> files an agent produced instead of a wall of markdown.
|
||||
Each one trades a document you'd skim for one you'd actually read — open any of them directly in
|
||||
a browser. Grouped by the kind of work they replace.
|
||||
</p>
|
||||
<nav class="toc">
|
||||
<a href="#exploration">Exploration & Planning <span class="n">3</span></a>
|
||||
<a href="#code-review">Code Review <span class="n">3</span></a>
|
||||
<a href="#design">Design <span class="n">2</span></a>
|
||||
<a href="#prototyping">Prototyping <span class="n">2</span></a>
|
||||
<a href="#illustrations">Diagrams <span class="n">2</span></a>
|
||||
<a href="#decks">Decks <span class="n">1</span></a>
|
||||
<a href="#research">Research <span class="n">2</span></a>
|
||||
<a href="#reports">Reports <span class="n">2</span></a>
|
||||
<a href="#editors">Custom Editors <span class="n">3</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="hero-fig" aria-hidden="true">
|
||||
<div class="pane md">
|
||||
<span class="tag">.md</span>
|
||||
<span class="l w90"></span><span class="l w75"></span><span class="l w82"></span>
|
||||
<span class="l w60"></span><span class="l w90"></span><span class="l w70"></span>
|
||||
<span class="l w82"></span><span class="l w50"></span><span class="l w75"></span>
|
||||
<span class="l w90"></span><span class="l w60"></span>
|
||||
</div>
|
||||
<div class="pane html">
|
||||
<span class="tag">.html</span>
|
||||
<span class="l w60"></span>
|
||||
<span class="blk"></span>
|
||||
<span class="row"><span class="bar b1"></span><span class="bar b2"></span><span class="bar b3"></span><span class="bar b4"></span></span>
|
||||
<span class="l w75"></span><span class="l w50"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============================== Exploration & Planning ============================== -->
|
||||
<section id="exploration">
|
||||
<div class="sec-head"><span class="idx">01</span><h2>Exploration & Planning</h2><span class="count">3 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
When you're not sure what you want yet. Ask the agent to fan out across several directions and lay
|
||||
them next to each other so you can point at one — instead of reading three sequential walls of text
|
||||
and trying to hold them all in your head. And once you've picked, turn the pick into a plan the
|
||||
implementer can actually read.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="01-exploration-code-approaches.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="4" y="10" width="32" height="60" rx="5"/>
|
||||
<rect class="wh" x="44" y="10" width="32" height="60" rx="5"/>
|
||||
<rect class="oa" x="84" y="10" width="32" height="60" rx="5"/>
|
||||
<line class="ln" x1="10" y1="26" x2="30" y2="26"/><line class="ln" x1="10" y1="36" x2="26" y2="36"/>
|
||||
<line class="ln" x1="50" y1="26" x2="70" y2="26"/><line class="ln" x1="50" y1="36" x2="66" y2="36"/>
|
||||
<line class="lc" x1="90" y1="26" x2="110" y2="26"/><line class="lc" x1="90" y1="36" x2="106" y2="36"/>
|
||||
<circle class="cl" cx="100" cy="56" r="6"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Three code approaches</div>
|
||||
<div class="desc">Side-by-side comparison of three ways to solve the same problem, with trade-offs called out inline.</div>
|
||||
<div class="file"><span>01-exploration-code-approaches.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="02-exploration-visual-designs.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="6" y="8" width="50" height="30" rx="4"/>
|
||||
<rect class="oa" x="64" y="8" width="50" height="30" rx="4"/>
|
||||
<rect class="oa" x="6" y="44" width="50" height="30" rx="4"/>
|
||||
<rect class="wh" x="64" y="44" width="50" height="30" rx="4"/>
|
||||
<circle class="cl" cx="18" cy="20" r="5"/><rect class="fl" x="28" y="16" width="22" height="4" rx="2"/>
|
||||
<rect class="ol" x="72" y="16" width="34" height="6" rx="3"/>
|
||||
<rect class="sl" x="14" y="54" width="34" height="6" rx="3"/>
|
||||
<circle class="ol" cx="102" cy="58" r="6"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Visual design directions</div>
|
||||
<div class="desc">A handful of layout and palette options rendered live so you can react to them, not imagine them.</div>
|
||||
<div class="file"><span>02-exploration-visual-designs.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="16-implementation-plan.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<line class="ln" x1="14" y1="12" x2="14" y2="68"/>
|
||||
<circle class="cl" cx="14" cy="16" r="5"/><circle class="ol" cx="14" cy="40" r="5"/><circle class="fl" cx="14" cy="64" r="5"/>
|
||||
<rect class="fl" x="28" y="12" width="48" height="4" rx="2"/><rect class="fl" x="28" y="21" width="34" height="4" rx="2"/>
|
||||
<rect class="fl" x="28" y="36" width="42" height="4" rx="2"/><rect class="fl" x="28" y="45" width="28" height="4" rx="2"/>
|
||||
<rect class="fl" x="28" y="60" width="44" height="4" rx="2"/>
|
||||
<rect class="oa" x="86" y="12" width="28" height="24" rx="4"/>
|
||||
<rect class="wh" x="86" y="44" width="28" height="24" rx="4"/>
|
||||
<line class="lc" x1="92" y1="56" x2="108" y2="56"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Implementation plan</div>
|
||||
<div class="desc">Milestones on a timeline, a data-flow diagram, inline mockups, the risky code, and a risk table — the plan you hand off.</div>
|
||||
<div class="file"><span>16-implementation-plan.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Code Review & Understanding ============================== -->
|
||||
<section id="code-review">
|
||||
<div class="sec-head"><span class="idx">02</span><h2>Code Review & Understanding</h2><span class="count">3 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
Diffs and call-graphs are spatial information; markdown flattens them. Let the agent render the
|
||||
change as an annotated diff, draw the module as boxes and arrows, or write the PR description your
|
||||
reviewers actually want — so the shape of the code is visible at a glance.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="03-code-review-pr.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="6" y="8" width="72" height="64" rx="5"/>
|
||||
<line class="ln" x1="14" y1="20" x2="66" y2="20"/>
|
||||
<rect class="ol" x="14" y="28" width="56" height="7" rx="2" opacity=".5"/>
|
||||
<rect class="cl" x="14" y="38" width="48" height="7" rx="2" opacity=".5"/>
|
||||
<line class="ln" x1="14" y1="52" x2="58" y2="52"/><line class="ln" x1="14" y1="62" x2="50" y2="62"/>
|
||||
<rect class="oa" x="86" y="22" width="28" height="20" rx="4"/>
|
||||
<line class="lc da" x1="78" y1="32" x2="86" y2="32"/>
|
||||
<circle class="cl" cx="100" cy="58" r="7"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Annotated pull request</div>
|
||||
<div class="desc">A diff rendered with margin notes, severity tags and jump links — easier to scan than scrolling a terminal.</div>
|
||||
<div class="file"><span>03-code-review-pr.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="17-pr-writeup.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="oa" x="6" y="8" width="48" height="28" rx="4"/>
|
||||
<rect class="wh" x="6" y="44" width="48" height="28" rx="4"/>
|
||||
<line class="ln" x1="14" y1="20" x2="46" y2="20"/><line class="ln" x1="14" y1="28" x2="38" y2="28"/>
|
||||
<line class="lc" x1="14" y1="56" x2="46" y2="56"/><line class="ln" x1="14" y1="64" x2="38" y2="64"/>
|
||||
<rect class="wh" x="64" y="8" width="50" height="64" rx="5"/>
|
||||
<circle class="cl" cx="74" cy="22" r="5"/><rect class="fl" x="84" y="19" width="24" height="5" rx="2"/>
|
||||
<circle class="ol" cx="74" cy="42" r="5"/><rect class="fl" x="84" y="39" width="22" height="5" rx="2"/>
|
||||
<circle class="fl" cx="74" cy="62" r="5"/><rect class="fl" x="84" y="59" width="26" height="5" rx="2"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">PR writeup for reviewers</div>
|
||||
<div class="desc">The author's side: motivation, before/after, a file-by-file tour with the <em>why</em>, and where to focus the review.</div>
|
||||
<div class="file"><span>17-pr-writeup.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="04-code-understanding.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="44" y="8" width="32" height="20" rx="4"/>
|
||||
<rect class="oa" x="8" y="52" width="32" height="20" rx="4"/>
|
||||
<rect class="cl" x="44" y="52" width="32" height="20" rx="4"/>
|
||||
<rect class="wh" x="80" y="52" width="32" height="20" rx="4"/>
|
||||
<line class="ln" x1="50" y1="28" x2="26" y2="52"/>
|
||||
<line class="lc" x1="60" y1="28" x2="60" y2="52"/>
|
||||
<line class="ln" x1="70" y1="28" x2="94" y2="52"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Module map</div>
|
||||
<div class="desc">An unfamiliar package drawn as boxes and arrows, with the hot path highlighted and entry points listed.</div>
|
||||
<div class="file"><span>04-code-understanding.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Design ============================== -->
|
||||
<section id="design">
|
||||
<div class="sec-head"><span class="idx">03</span><h2>Design</h2><span class="count">2 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
HTML <em>is</em> the medium your design system ships in, so it's the natural format for talking about
|
||||
it. Tokens become swatches, components become contact sheets, and the artifact can be fed straight
|
||||
back into the next prompt.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="05-design-system.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="cl" x="8" y="10" width="22" height="22" rx="4"/>
|
||||
<rect class="ol" x="36" y="10" width="22" height="22" rx="4"/>
|
||||
<rect class="oa" x="64" y="10" width="22" height="22" rx="4"/>
|
||||
<rect class="sl" x="92" y="10" width="22" height="22" rx="4"/>
|
||||
<rect class="fl" x="8" y="42" width="80" height="8" rx="2"/>
|
||||
<rect class="fl" x="8" y="56" width="56" height="6" rx="2"/>
|
||||
<rect class="fl" x="8" y="68" width="40" height="5" rx="2"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Living design system</div>
|
||||
<div class="desc">Colors, type scale and spacing tokens pulled from a repo and rendered as swatches you can copy from.</div>
|
||||
<div class="file"><span>05-design-system.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="06-component-variants.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="cl" x="10" y="14" width="44" height="14" rx="7"/>
|
||||
<rect class="wh" x="10" y="34" width="44" height="14" rx="7"/>
|
||||
<rect class="oa" x="10" y="54" width="44" height="14" rx="7"/>
|
||||
<rect class="cl" x="66" y="14" width="34" height="11" rx="5.5"/>
|
||||
<rect class="wh" x="66" y="32" width="34" height="11" rx="5.5"/>
|
||||
<rect class="oa" x="66" y="50" width="34" height="11" rx="5.5"/>
|
||||
<circle class="cl" cx="110" cy="20" r="4"/><circle class="fl" cx="110" cy="37" r="4"/><circle class="ol" cx="110" cy="56" r="4"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Component variants</div>
|
||||
<div class="desc">Every size, state and intent of one component laid out on a single sheet for review.</div>
|
||||
<div class="file"><span>06-component-variants.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Prototyping ============================== -->
|
||||
<section id="prototyping">
|
||||
<div class="sec-head"><span class="idx">04</span><h2>Prototyping</h2><span class="count">2 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
Motion and interaction can't be described, only felt. A throwaway page with the real easing curve or
|
||||
the real click-through tells you in five seconds what a paragraph of prose never could.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="07-prototype-animation.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="8" y="10" width="104" height="34" rx="5"/>
|
||||
<path class="lc" d="M16 38 C 40 38, 56 16, 104 16" fill="none"/>
|
||||
<circle class="cl" cx="72" cy="23" r="4.5"/>
|
||||
<line class="ln" x1="12" y1="58" x2="108" y2="58"/><circle class="cl" cx="44" cy="58" r="6"/>
|
||||
<line class="ln" x1="12" y1="70" x2="108" y2="70"/><circle class="ol" cx="82" cy="70" r="6"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Animation sandbox</div>
|
||||
<div class="desc">The transition in isolation with sliders for duration and easing, so you can tune it before wiring it in.</div>
|
||||
<div class="file"><span>07-prototype-animation.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="08-prototype-interaction.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="6" y="22" width="28" height="36" rx="4"/>
|
||||
<rect class="oa" x="46" y="22" width="28" height="36" rx="4"/>
|
||||
<rect class="wh" x="86" y="22" width="28" height="36" rx="4"/>
|
||||
<line class="lc" x1="34" y1="40" x2="46" y2="40"/>
|
||||
<line class="lc" x1="74" y1="40" x2="86" y2="40"/>
|
||||
<circle class="cl" cx="20" cy="50" r="4"/><rect class="fl" x="52" y="30" width="16" height="4" rx="2"/><rect class="ol" x="92" y="46" width="16" height="6" rx="3"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Clickable flow</div>
|
||||
<div class="desc">Four screens linked together — enough fidelity to feel whether the interaction is right.</div>
|
||||
<div class="file"><span>08-prototype-interaction.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Illustrations & Diagrams ============================== -->
|
||||
<section id="illustrations">
|
||||
<div class="sec-head"><span class="idx">05</span><h2>Illustrations & Diagrams</h2><span class="count">2 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
Inline SVG gives the agent a real pen. Ask for the figures for a post or a flowchart of a process and
|
||||
get vector art you can tweak by hand or paste straight into the final document.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="10-svg-illustrations.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<circle class="oa" cx="32" cy="40" r="24"/>
|
||||
<circle class="cl" cx="32" cy="40" r="11"/>
|
||||
<path class="lc" d="M68 60 L86 20 L104 60" fill="none"/>
|
||||
<line class="ln" x1="66" y1="66" x2="106" y2="66"/>
|
||||
<circle class="ol" cx="86" cy="20" r="5"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">SVG figure sheet</div>
|
||||
<div class="desc">The diagrams for a blog post, drawn inline so they can be tweaked and copied out one by one.</div>
|
||||
<div class="file"><span>10-svg-illustrations.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="13-flowchart-diagram.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="10" y="10" width="30" height="16" rx="4"/>
|
||||
<path class="oa" d="M60 34 L80 46 L60 58 L40 46 Z"/>
|
||||
<rect class="wh" x="82" y="10" width="30" height="16" rx="4"/>
|
||||
<rect class="cl" x="82" y="56" width="30" height="16" rx="4"/>
|
||||
<line class="ln" x1="25" y1="26" x2="25" y2="46"/><line class="ln" x1="25" y1="46" x2="40" y2="46"/>
|
||||
<line class="lc" x1="80" y1="46" x2="97" y2="46"/><line class="lc" x1="97" y1="46" x2="97" y2="56"/>
|
||||
<line class="ln" x1="60" y1="34" x2="60" y2="22"/><line class="ln" x1="60" y1="22" x2="82" y2="22"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Annotated flowchart</div>
|
||||
<div class="desc">A deploy pipeline drawn as a real flowchart — click any step to see what runs, timings, and failure paths.</div>
|
||||
<div class="file"><span>13-flowchart-diagram.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Decks ============================== -->
|
||||
<section id="decks">
|
||||
<div class="sec-head"><span class="idx">06</span><h2>Decks</h2><span class="count">1 demo</span></div>
|
||||
<p class="sec-intro">
|
||||
A handful of <code><section></code> tags and twenty lines of JS is a slide deck. Point the agent at a
|
||||
Slack thread or a design doc and get something you can arrow-key through in a meeting — no Keynote,
|
||||
no export step.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="09-slide-deck.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="oa" x="8" y="18" width="70" height="44" rx="5"/>
|
||||
<rect class="wh" x="22" y="12" width="76" height="50" rx="5"/>
|
||||
<rect class="cl" x="32" y="24" width="36" height="7" rx="3"/>
|
||||
<rect class="fl" x="32" y="36" width="52" height="4" rx="2"/><rect class="fl" x="32" y="44" width="44" height="4" rx="2"/>
|
||||
<circle class="fl" cx="50" cy="70" r="3"/><circle class="cl" cx="60" cy="70" r="3"/><circle class="fl" cx="70" cy="70" r="3"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Arrow-key slide deck</div>
|
||||
<div class="desc">A short presentation as one HTML file. Left and right to navigate, no build step.</div>
|
||||
<div class="file"><span>09-slide-deck.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Research & Learning ============================== -->
|
||||
<section id="research">
|
||||
<div class="sec-head"><span class="idx">07</span><h2>Research & Learning</h2><span class="count">2 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
An explainer with collapsible sections, tabbed code samples and a glossary in the margin reads very
|
||||
differently from the same words dumped linearly. The agent can build the scaffolding that makes a new
|
||||
topic navigable.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="14-research-feature-explainer.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="cl" x="8" y="8" width="104" height="20" rx="4" opacity=".35"/>
|
||||
<rect class="fl" x="14" y="15" width="60" height="6" rx="3"/>
|
||||
<rect class="wh" x="8" y="34" width="104" height="14" rx="4"/>
|
||||
<rect class="wh" x="8" y="52" width="104" height="14" rx="4"/>
|
||||
<path class="ln" d="M16 39 l4 4 l-4 4"/><rect class="fl" x="28" y="38" width="46" height="5" rx="2.5"/>
|
||||
<path class="ln" d="M16 57 l4 4 l-4 4"/><rect class="fl" x="28" y="56" width="38" height="5" rx="2.5"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">How a feature works</div>
|
||||
<div class="desc">"Explain rate limiting in this repo" — TL;DR box, collapsible request-path steps, tabbed config snippets, and an FAQ.</div>
|
||||
<div class="file"><span>14-research-feature-explainer.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="15-research-concept-explainer.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<circle class="st" cx="42" cy="40" r="26"/>
|
||||
<circle class="cl" cx="42" cy="14" r="5"/><circle class="ol" cx="68" cy="40" r="5"/>
|
||||
<circle class="fl" cx="42" cy="66" r="5"/><circle class="fl" cx="16" cy="40" r="5"/>
|
||||
<rect class="fl" x="82" y="20" width="30" height="5" rx="2.5"/><rect class="fl" x="82" y="32" width="24" height="5" rx="2.5"/>
|
||||
<rect class="fl" x="82" y="44" width="28" height="5" rx="2.5"/><rect class="fl" x="82" y="56" width="20" height="5" rx="2.5"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Concept explainer</div>
|
||||
<div class="desc">Consistent hashing taught with a live ring you can add/remove nodes from, a comparison table, and a hover-linked glossary.</div>
|
||||
<div class="file"><span>15-research-concept-explainer.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Reports ============================== -->
|
||||
<section id="reports">
|
||||
<div class="sec-head"><span class="idx">08</span><h2>Reports</h2><span class="count">2 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
Recurring documents — status updates, post-mortems — benefit most from a bit of structure and color.
|
||||
A small chart and a colored timeline turn something people skim into something they actually read.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="11-status-report.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="fl" x="8" y="10" width="54" height="6" rx="3"/>
|
||||
<rect class="ol" x="8" y="24" width="36" height="8" rx="3" opacity=".6"/>
|
||||
<rect class="oa" x="8" y="38" width="28" height="8" rx="3"/>
|
||||
<rect class="cl" x="8" y="52" width="20" height="8" rx="3" opacity=".6"/>
|
||||
<rect class="wh" x="72" y="16" width="40" height="48" rx="5"/>
|
||||
<rect class="ol" x="80" y="44" width="6" height="14"/>
|
||||
<rect class="cl" x="90" y="34" width="6" height="24"/>
|
||||
<rect class="fl" x="100" y="50" width="6" height="8"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Weekly status</div>
|
||||
<div class="desc">What shipped, what slipped, and a small chart — formatted for a quick skim on Monday morning.</div>
|
||||
<div class="file"><span>11-status-report.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="12-incident-report.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<line class="ln" x1="18" y1="12" x2="18" y2="68"/>
|
||||
<circle class="fl" cx="18" cy="16" r="5"/><circle class="cl" cx="18" cy="34" r="5"/>
|
||||
<circle class="cl" cx="18" cy="50" r="5"/><circle class="ol" cx="18" cy="66" r="5"/>
|
||||
<rect class="fl" x="32" y="13" width="40" height="5" rx="2.5"/>
|
||||
<rect class="fl" x="32" y="31" width="56" height="5" rx="2.5"/>
|
||||
<rect class="fl" x="32" y="47" width="48" height="5" rx="2.5"/>
|
||||
<rect class="fl" x="32" y="63" width="36" height="5" rx="2.5"/>
|
||||
<rect class="oa" x="94" y="26" width="20" height="30" rx="4"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Incident timeline</div>
|
||||
<div class="desc">A post-mortem with a minute-by-minute timeline, log excerpts and the follow-up checklist.</div>
|
||||
<div class="file"><span>12-incident-report.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== Custom Editing Interfaces ============================== -->
|
||||
<section id="editors">
|
||||
<div class="sec-head"><span class="idx">09</span><h2>Custom Editing Interfaces</h2><span class="count">3 demos</span></div>
|
||||
<p class="sec-intro">
|
||||
Sometimes it's hard to describe what you want in a text box. Ask for a throwaway editor for the exact
|
||||
thing you're working on — and always end with an export button that turns whatever you did in the UI
|
||||
back into something you can paste into the agent or commit. You stay in the loop; the loop gets tighter.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="18-editor-triage-board.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="6" y="10" width="24" height="60" rx="4"/>
|
||||
<rect class="wh" x="34" y="10" width="24" height="60" rx="4"/>
|
||||
<rect class="wh" x="62" y="10" width="24" height="60" rx="4"/>
|
||||
<rect class="wh" x="90" y="10" width="24" height="60" rx="4"/>
|
||||
<rect class="cl" x="10" y="18" width="16" height="9" rx="2"/>
|
||||
<rect class="fl" x="10" y="31" width="16" height="9" rx="2"/>
|
||||
<rect class="fl" x="10" y="44" width="16" height="9" rx="2"/>
|
||||
<rect class="fl" x="38" y="18" width="16" height="9" rx="2"/>
|
||||
<rect class="ol" x="38" y="31" width="16" height="9" rx="2"/>
|
||||
<rect class="fl" x="66" y="18" width="16" height="9" rx="2"/>
|
||||
<rect class="oa" x="48" y="50" width="20" height="11" rx="2"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Ticket triage board</div>
|
||||
<div class="desc">Drag thirty tickets across Now / Next / Later / Cut, then copy the final ordering out as markdown.</div>
|
||||
<div class="file"><span>18-editor-triage-board.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="19-editor-feature-flags.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="8" y="10" width="104" height="14" rx="4"/>
|
||||
<rect class="wh" x="8" y="30" width="104" height="14" rx="4"/>
|
||||
<rect class="wh" x="8" y="50" width="104" height="14" rx="4"/>
|
||||
<rect class="ol" x="88" y="13" width="18" height="8" rx="4"/><circle class="wh" cx="100" cy="17" r="3"/>
|
||||
<rect class="fl" x="88" y="33" width="18" height="8" rx="4"/><circle class="wh" cx="93" cy="37" r="3"/>
|
||||
<rect class="cl" x="88" y="53" width="18" height="8" rx="4"/><circle class="wh" cx="100" cy="57" r="3"/>
|
||||
<rect class="fl" x="14" y="14" width="34" height="5" rx="2.5"/>
|
||||
<rect class="fl" x="14" y="34" width="42" height="5" rx="2.5"/>
|
||||
<rect class="fl" x="14" y="54" width="28" height="5" rx="2.5"/>
|
||||
<line class="lc da" x1="62" y1="24" x2="62" y2="50"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Feature flag editor</div>
|
||||
<div class="desc">Toggles grouped by area, dependency warnings when a prerequisite is off, and a "copy diff" button for just the changed keys.</div>
|
||||
<div class="file"><span>19-editor-feature-flags.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="20-editor-prompt-tuner.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80">
|
||||
<rect class="wh" x="6" y="10" width="56" height="60" rx="4"/>
|
||||
<rect class="fl" x="12" y="18" width="40" height="4" rx="2"/>
|
||||
<rect class="cl" x="12" y="26" width="22" height="4" rx="2"/>
|
||||
<rect class="fl" x="12" y="34" width="44" height="4" rx="2"/>
|
||||
<rect class="ol" x="12" y="42" width="18" height="4" rx="2"/>
|
||||
<rect class="fl" x="12" y="50" width="36" height="4" rx="2"/>
|
||||
<rect class="oa" x="68" y="10" width="46" height="18" rx="4"/>
|
||||
<rect class="oa" x="68" y="32" width="46" height="18" rx="4"/>
|
||||
<rect class="oa" x="68" y="54" width="46" height="18" rx="4"/>
|
||||
</svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Prompt tuner</div>
|
||||
<div class="desc">Editable template on the left with variable slots highlighted; three sample inputs on the right re-render live as you type.</div>
|
||||
<div class="file"><span>20-editor-prompt-tuner.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span class="k">Everything on this page is itself a single <code>.html</code> file.</span>
|
||||
<span>View the repo · <a href="https://thariqs.github.io/html-effectiveness/">thariqs.github.io/html-effectiveness</a></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user