Files
thariqs--html-effectiveness/unknowns/08-implementation-plan.html
T
2026-07-13 12:33:58 +08:00

766 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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 tweakable plan — Know your unknowns</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; }
body {
margin: 0;
background: var(--ivory);
color: var(--slate);
font-family: var(--sans);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px 100px; }
@media (max-width: 480px) { .wrap { padding: 0 16px 80px; } }
/* ── page header ─────────────────────── */
header.page-head { padding: 56px 0 40px; position: relative; }
.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); flex-shrink: 0; }
a.back {
position: absolute; top: 60px; right: 0;
font-family: var(--mono); font-size: 12px;
color: var(--g500); text-decoration: none;
}
a.back:hover { color: var(--clay); }
@media (max-width: 640px) { a.back { position: static; display: inline-block; margin-bottom: 16px; } }
h1 {
font-family: var(--serif);
font-weight: 500;
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
letter-spacing: -0.018em;
margin: 0 0 14px;
}
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; margin: 0 0 32px; }
/* prompt box */
.prompt-card {
background: var(--paper);
border: 1.5px solid var(--g300);
border-radius: 14px;
padding: 22px 26px 24px;
position: relative;
}
.prompt-card .p-label {
font-family: var(--mono); font-size: 12px;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--clay); margin-bottom: 12px;
display: flex; align-items: center; gap: 12px;
}
.prompt-card .p-label::before { content: ""; width: 24px; height: 1.5px; background: var(--clay); }
.prompt-card blockquote {
margin: 0;
font-family: var(--serif);
font-style: italic;
font-size: 17.5px;
line-height: 1.5;
color: var(--slate);
max-width: 62ch;
}
button.copy-btn {
position: absolute; top: 18px; right: 20px;
font-family: var(--mono); font-size: 11.5px;
background: var(--g100); color: var(--g700);
border: 1.5px solid var(--g300); border-radius: 8px;
padding: 5px 12px; cursor: pointer;
transition: background .15s, color .15s, border-color .15s;
}
button.copy-btn:hover { border-color: var(--clay); color: var(--clay-d); }
button.copy-btn.copied { background: var(--olive); border-color: var(--olive); color: #fff; }
@media (max-width: 640px) {
button.copy-btn { position: static; margin-top: 14px; }
}
/* divider row */
.produced {
display: flex; align-items: center; gap: 16px;
margin: 48px 0 36px;
font-family: var(--mono); font-size: 12px;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--g500); white-space: nowrap;
}
.produced::before, .produced::after { content: ""; height: 1px; background: var(--g300); flex: 1; }
/* ══ THE ARTIFACT ══════════════════════ */
.artifact {
background: var(--paper);
border: 1.5px solid var(--g300);
border-radius: 14px;
padding: 34px 38px 40px;
}
@media (max-width: 640px) { .artifact { padding: 22px 18px 28px; } }
.artifact h2.plan-title {
font-family: var(--serif); font-weight: 500;
font-size: clamp(24px, 3.6vw, 32px);
letter-spacing: -0.015em;
margin: 0 0 4px;
}
.plan-sub { font-size: 14px; color: var(--g500); margin: 0 0 20px; }
.plan-sub code { font-family: var(--mono); font-size: 12.5px; background: var(--g100); border-radius: 5px; padding: 1px 6px; }
/* chips */
.chips { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 22px; }
.chip {
display: flex; align-items: baseline; gap: 8px;
font-size: 13px; color: var(--g700);
background: var(--g100); border: 1.5px solid var(--g200);
border-radius: 999px; padding: 6px 16px;
}
.chip .k { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--g500); }
.chip .v { font-weight: 600; color: var(--slate); }
.chip .v.risk { color: var(--clay-d); }
/* sort banner */
.sort-banner {
background: var(--oat);
border: 1.5px solid var(--g300);
border-radius: 12px;
padding: 16px 20px;
margin: 0 0 40px;
font-size: 14.5px;
color: var(--g700);
display: flex; gap: 14px; align-items: flex-start;
}
.sort-banner .glyph {
font-family: var(--mono); font-size: 15px; line-height: 1.35;
color: var(--clay-d); white-space: pre; flex-shrink: 0;
padding-top: 1px;
}
.sort-banner strong { color: var(--slate); }
/* section chrome inside artifact */
.plan-sec { margin: 0 0 46px; }
.plan-sec:last-of-type { margin-bottom: 0; }
.sec-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
.sec-letter {
font-family: var(--mono); font-size: 13px; font-weight: 600;
background: var(--slate); color: var(--ivory);
width: 26px; height: 26px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.sec-letter.soft { background: var(--g300); color: var(--g700); }
.sec-head h3 {
font-family: var(--serif); font-weight: 500;
font-size: 22px; letter-spacing: -0.01em; margin: 0;
}
.sec-head .likelihood {
font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em;
text-transform: uppercase; border-radius: 6px; padding: 3px 9px;
}
.likelihood.hi { background: var(--clay); color: #fff; }
.likelihood.lo { background: var(--g100); color: var(--g500); border: 1px solid var(--g200); }
.sec-intro { font-size: 14px; color: var(--g500); margin: 0 0 22px; max-width: 66ch; }
h4.sub {
font-family: var(--mono); font-size: 12px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--g500); margin: 34px 0 14px;
display: flex; align-items: center; gap: 10px;
}
h4.sub::before { content: ""; width: 16px; height: 1.5px; background: var(--clay); }
h4.sub:first-of-type { margin-top: 0; }
/* ── schema diagram ── */
.schema-wrap { display: grid; grid-template-columns: minmax(0, 340px) 1fr; gap: 22px; align-items: start; }
@media (max-width: 720px) { .schema-wrap { grid-template-columns: 1fr; } }
.table-box {
border: 1.5px solid var(--slate);
border-radius: 10px;
overflow: hidden;
font-family: var(--mono);
font-size: 12.5px;
background: var(--paper);
}
.table-box .t-name {
background: var(--slate); color: var(--ivory);
padding: 8px 14px; font-size: 12.5px; letter-spacing: 0.03em;
}
.table-box .t-name .new-badge {
float: right; background: var(--olive); color: #fff;
font-size: 9.5px; letter-spacing: 0.1em; border-radius: 4px;
padding: 2px 6px; margin-top: 1px;
}
.col-row {
display: flex; justify-content: space-between; gap: 12px;
padding: 6px 14px;
border-top: 1px solid var(--g200);
color: var(--g700);
}
.col-row .cn { color: var(--slate); }
.col-row .ct { color: var(--g500); white-space: nowrap; }
.col-row.flag {
background: #FBF1EC;
border-left: 3px solid var(--clay);
padding-left: 11px;
}
.col-row.flag .cn { color: var(--clay-d); font-weight: 600; }
.col-row.alt-note {
background: var(--g100);
color: var(--g500);
font-style: italic;
border-left: 3px solid var(--g300);
padding-left: 11px;
display: block;
}
.fk { color: var(--g500); font-size: 11px; }
/* variant switching */
[data-variant="main"] .v-alt { display: none; }
[data-variant="alt"] .v-main { display: none; }
.choice-card {
border: 1.5px solid var(--g300);
border-radius: 12px;
padding: 16px 18px 18px;
margin-bottom: 14px;
background: var(--ivory);
}
.choice-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.choice-flag {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em;
background: var(--clay); color: #fff;
border-radius: 6px; padding: 3px 8px;
}
.choice-head .ch-title { font-weight: 600; font-size: 14.5px; }
button.alt-toggle {
margin-left: auto;
font-family: var(--mono); font-size: 11.5px;
background: var(--paper); color: var(--clay-d);
border: 1.5px solid var(--clay); border-radius: 8px;
padding: 5px 12px; cursor: pointer;
transition: background .15s;
}
button.alt-toggle:hover { background: #FBF1EC; }
@media (max-width: 560px) { button.alt-toggle { margin-left: 0; } }
.choice-body { font-size: 13.5px; color: var(--g700); }
.choice-body .pick-label {
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--olive); margin-bottom: 3px;
}
.choice-body .pick-label.alt-l { color: var(--clay-d); }
.choice-body ul { margin: 8px 0 0; padding-left: 18px; }
.choice-body li { margin-bottom: 4px; }
.choice-body li::marker { color: var(--g500); }
.tradeoff { color: var(--g500); font-size: 12.5px; margin-top: 8px; }
.tradeoff strong { color: var(--clay-d); font-weight: 600; }
/* ── code blocks with margin notes ── */
.code-grid { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; margin-bottom: 22px; }
@media (max-width: 720px) { .code-grid { grid-template-columns: 1fr; } }
.code-block {
background: var(--slate);
border-radius: 10px;
padding: 16px 0;
overflow-x: auto;
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.6;
color: var(--g200);
}
.code-block .cl-line { padding: 0 18px; white-space: pre; }
.code-block .cl-line.flagged {
background: rgba(217,119,87,0.16);
border-left: 3px solid var(--clay);
padding-left: 15px;
}
.code-block .cm { color: #8a8a82; }
.code-block .kw { color: #E8B4A0; }
.code-block .ty { color: #C7CBA3; }
.code-block .st { color: #D9C7A7; }
.code-block .fnum {
display: inline-block; font-size: 10px; font-weight: 700;
background: var(--clay); color: #fff; border-radius: 50%;
width: 15px; height: 15px; line-height: 15px; text-align: center;
margin-left: 10px; vertical-align: 1px;
}
.margin-notes { display: flex; flex-direction: column; gap: 12px; }
.m-note {
font-size: 12.5px; color: var(--g700);
background: var(--g100); border: 1px solid var(--g200);
border-radius: 10px; padding: 10px 12px;
}
.m-note .n-num {
display: inline-block; font-family: var(--mono); font-size: 10px; font-weight: 700;
background: var(--clay); color: #fff; border-radius: 50%;
width: 15px; height: 15px; line-height: 15px; text-align: center;
margin-right: 7px; vertical-align: 1px;
}
.m-note code { font-family: var(--mono); font-size: 11.5px; background: var(--paper); border: 1px solid var(--g200); border-radius: 4px; padding: 0 4px; }
/* ── UX flow strip ── */
.flow-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin-bottom: 8px; }
@media (max-width: 720px) { .flow-strip { grid-template-columns: 1fr 1fr; gap: 18px 0; } }
@media (max-width: 420px) { .flow-strip { grid-template-columns: 1fr; } }
.flow-step { position: relative; padding: 0 18px 0 0; }
.flow-step + .flow-step { padding-left: 18px; }
.flow-step:not(:last-child)::after {
content: "→";
position: absolute; right: -1px; top: 34px;
color: var(--g500); font-size: 15px;
transform: translateX(50%);
}
@media (max-width: 720px) {
.flow-step:nth-child(2)::after { display: none; }
.flow-step { padding: 0 14px 0 0; }
}
@media (max-width: 420px) { .flow-step::after { display: none !important; } .flow-step { padding: 0; } }
.fs-num {
font-family: var(--mono); font-size: 11px; color: var(--g500);
margin-bottom: 6px;
}
.fs-card {
border: 1.5px solid var(--g300); border-radius: 10px;
background: var(--ivory); padding: 12px 13px;
min-height: 108px;
}
.fs-card.weak { border: 1.5px dashed var(--clay); background: #FBF1EC; }
.fs-card .fs-t { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
.fs-card .fs-d { font-size: 12px; color: var(--g500); line-height: 1.45; }
.weak-tag {
display: inline-block; font-family: var(--mono); font-size: 10px;
letter-spacing: 0.08em; text-transform: uppercase;
background: var(--clay); color: #fff; border-radius: 5px;
padding: 2px 7px; margin-bottom: 6px;
}
.flow-note { font-size: 13px; color: var(--g700); background: var(--g100); border-left: 3px solid var(--clay); border-radius: 0 8px 8px 0; padding: 10px 14px; margin-top: 16px; max-width: 64ch; }
/* ── sequencing timeline ── */
.seq { list-style: none; margin: 0; padding: 0; }
.seq li {
display: grid; grid-template-columns: 30px 1fr auto; gap: 12px;
align-items: baseline;
padding: 9px 0;
border-bottom: 1px solid var(--g200);
font-size: 14px;
}
.seq li:last-child { border-bottom: none; }
.seq .s-num {
font-family: var(--mono); font-size: 12px; color: var(--paper);
background: var(--g500); border-radius: 6px;
width: 22px; height: 22px; display: inline-flex;
align-items: center; justify-content: center;
position: relative; top: 3px;
}
.seq .s-body strong { font-weight: 600; }
.seq .s-body .dep { color: var(--g500); font-size: 12.5px; }
.seq .s-est { font-family: var(--mono); font-size: 12px; color: var(--g500); white-space: nowrap; }
@media (max-width: 480px) { .seq li { grid-template-columns: 30px 1fr; } .seq .s-est { grid-column: 2; } }
/* ── mechanical details ── */
details.mech {
border: 1.5px solid var(--g300);
border-radius: 12px;
background: var(--g100);
}
details.mech summary {
cursor: pointer;
padding: 14px 18px;
font-size: 14px; font-weight: 600;
list-style: none;
display: flex; align-items: center; gap: 10px;
}
details.mech summary::-webkit-details-marker { display: none; }
details.mech summary::before {
content: "▸"; color: var(--g500); font-size: 13px;
transition: transform .15s; display: inline-block;
}
details.mech[open] summary::before { transform: rotate(90deg); }
details.mech summary .count {
font-family: var(--mono); font-size: 11px; color: var(--g500);
font-weight: 400; margin-left: auto;
}
.mech-list { list-style: none; margin: 0; padding: 4px 18px 16px 40px; }
.mech-list li {
font-size: 13.5px; color: var(--g700);
padding: 6px 0; border-top: 1px solid var(--g200);
}
.mech-list li:first-child { border-top: none; }
.mech-list code { font-family: var(--mono); font-size: 12px; background: var(--paper); border: 1px solid var(--g200); border-radius: 4px; padding: 0 5px; }
.mech-list .why { color: var(--g500); font-size: 12px; }
/* ── quick-reply card ── */
.tweak-card {
margin-top: 44px;
border: 1.5px solid var(--slate);
border-radius: 14px;
background: var(--ivory);
padding: 22px 24px 24px;
}
.tweak-card h3 {
font-family: var(--serif); font-weight: 500; font-size: 21px;
letter-spacing: -0.01em; margin: 0 0 4px;
}
.tweak-card > p { font-size: 13.5px; color: var(--g500); margin: 0 0 16px; }
.tweak-row {
display: flex; gap: 12px; align-items: flex-start;
background: var(--paper); border: 1.5px solid var(--g300);
border-radius: 10px; padding: 12px 14px; margin-bottom: 10px;
}
.tweak-row:last-child { margin-bottom: 0; }
.tweak-row .t-num {
font-family: var(--mono); font-size: 11px; font-weight: 700;
background: var(--oat); color: var(--g700); border-radius: 6px;
padding: 2px 8px; flex-shrink: 0; margin-top: 2px;
}
.tweak-row .t-body { font-size: 13.5px; color: var(--g700); }
.tweak-row .t-body .t-q { font-family: var(--serif); font-style: italic; color: var(--slate); }
.tweak-row button.copy-btn { position: static; flex-shrink: 0; margin: 0 0 0 auto; }
@media (max-width: 560px) { .tweak-row { flex-wrap: wrap; } .tweak-row button.copy-btn { margin-left: 40px; } }
/* footer */
footer.site-foot {
margin-top: 56px; padding-top: 24px;
border-top: 1.5px solid var(--g300);
font-size: 13.5px; color: var(--g500);
}
footer.site-foot a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
footer.site-foot a:hover { text-decoration-color: var(--clay); }
</style>
</head>
<body>
<div class="wrap">
<header class="page-head">
<a class="back" href="index.html">← All examples</a>
<div class="eyebrow">Know your unknowns · Pre-implementation</div>
<h1>The tweakable plan</h1>
<p class="lede">Instead of a plan sorted by execution order, ask for one sorted by how likely <em>you</em> are to change each part — so the decisions worth your attention surface first and the mechanical work sinks to the bottom.</p>
<div class="prompt-card">
<div class="p-label">The prompt</div>
<blockquote id="the-prompt">Write an implementation plan for annotation export as HTML, but lead with the decisions I'm most likely to tweak: data model changes, new type interfaces, and anything user-facing. Bury the mechanical refactoring at the bottom — I trust you on that part.</blockquote>
<button class="copy-btn" onclick="copyText(this, document.getElementById('the-prompt').textContent)">Copy prompt</button>
</div>
</header>
<div class="produced">What Claude produced</div>
<!-- ═══════════ THE ARTIFACT ═══════════ -->
<div class="artifact">
<h2 class="plan-title">Implementation plan: Annotation export</h2>
<p class="plan-sub">Acme · export a review's annotations as a shareable PDF or CSV · branch <code>feat/annotation-export</code></p>
<div class="chips">
<div class="chip"><span class="k">Effort</span><span class="v">~2.5 dev-days</span></div>
<div class="chip"><span class="k">Files touched</span><span class="v">13 (3 new)</span></div>
<div class="chip"><span class="k">Risk</span><span class="v risk">Lowmedium</span></div>
<div class="chip"><span class="k">Migrations</span><span class="v">1, additive</span></div>
</div>
<div class="sort-banner">
<div class="glyph">↑ tweak
↓ trust</div>
<div><strong>This plan is sorted by likelihood-of-tweaking, not execution order.</strong> Read top to bottom = most worth your attention first. Section A holds every judgment call; Section B is the build order; Section C is mechanical and safe to skip entirely.</div>
</div>
<!-- ── SECTION A ─────────────────────── -->
<section class="plan-sec">
<div class="sec-head">
<span class="sec-letter">A</span>
<h3>Decisions you'll probably want to change</h3>
<span class="likelihood hi">High tweak likelihood</span>
</div>
<p class="sec-intro">Three areas where I made a judgment call you might disagree with. Each flagged choice shows the alternative I considered — toggle to compare.</p>
<h4 class="sub">A1 · Data model — new <span style="text-transform:none">annotation_exports</span> table</h4>
<div class="schema-wrap">
<div class="table-box" id="schema" data-variant="main">
<div class="t-name">annotation_exports <span class="new-badge">NEW</span></div>
<div class="col-row"><span class="cn">id</span><span class="ct">uuid pk</span></div>
<div class="col-row"><span class="cn">review_id</span><span class="ct">uuid <span class="fk">→ reviews</span></span></div>
<div class="col-row"><span class="cn">requested_by</span><span class="ct">uuid <span class="fk">→ users</span></span></div>
<div class="col-row"><span class="cn">status</span><span class="ct">enum(queued, rendering, ready, failed)</span></div>
<div class="col-row"><span class="cn">options</span><span class="ct">jsonb</span></div>
<div class="col-row flag v-main"><span class="cn">snapshot ①</span><span class="ct">jsonb</span></div>
<div class="col-row alt-note v-alt">① no snapshot column — renderer JOINs <b>annotations</b> live at render time</div>
<div class="col-row flag v-main2"><span class="cn">artifact_url ②</span><span class="ct">text null</span></div>
<div class="col-row flag v-main2"><span class="cn">rendered_at</span><span class="ct">timestamptz null</span></div>
<div class="col-row alt-note v-alt2">② no stored artifact — <b>GET /exports/:id/download</b> renders on demand</div>
<div class="col-row"><span class="cn">created_at</span><span class="ct">timestamptz</span></div>
</div>
<div>
<div class="choice-card" data-variant="main" id="c1">
<div class="choice-head">
<span class="choice-flag">Choice ①</span>
<span class="ch-title">Snapshot vs. live join</span>
<button class="alt-toggle" onclick="toggleChoice(1)">View alternative →</button>
</div>
<div class="choice-body">
<div class="v-main">
<div class="pick-label">Plan's pick — denormalized snapshot</div>
Copy the annotation payloads into <b>snapshot jsonb</b> when the export is requested. The export is a record of the review <i>at that moment</i>: later edits, resolutions, and deletions don't rewrite history.
<ul>
<li>Export stays valid even if the review is archived.</li>
<li>Renderer reads one row — no N+1 across <b>annotations</b> + <b>annotation_replies</b>.</li>
</ul>
<p class="tradeoff"><strong>Cost:</strong> ~40&nbsp;KB/row for a heavy review; snapshots can go stale relative to the live thread.</p>
</div>
<div class="v-alt">
<div class="pick-label alt-l">Alternative — live join at render time</div>
Skip the snapshot; the renderer joins <b>annotations</b> when the file is generated. Exports always reflect current state.
<ul>
<li>No duplicate data, no staleness question.</li>
<li>Re-downloading the same export can produce a <i>different</i> file — surprising for audit use.</li>
</ul>
<p class="tradeoff"><strong>Pick this if:</strong> exports are working documents, not records. One line to tell me: <i>“use live join.”</i></p>
</div>
</div>
</div>
<div class="choice-card" data-variant="main" id="c2">
<div class="choice-head">
<span class="choice-flag">Choice ②</span>
<span class="ch-title">Stored artifact vs. render-on-demand</span>
<button class="alt-toggle" onclick="toggleChoice(2)">View alternative →</button>
</div>
<div class="choice-body">
<div class="v-main">
<div class="pick-label">Plan's pick — render once, store in blob storage</div>
The worker renders the PDF/CSV once and writes <b>artifact_url</b>. Downloads are a signed-URL redirect — cheap, cacheable, and shareable with reviewers who lack Acme accounts.
<p class="tradeoff"><strong>Cost:</strong> blob lifecycle to manage; I'd add a 30-day TTL sweep (Section C, item 6).</p>
</div>
<div class="v-alt">
<div class="pick-label alt-l">Alternative — render on every download</div>
No stored file: <b>GET /exports/:id/download</b> streams a fresh render. Zero storage, but ~24&nbsp;s per download on large reviews, and no shareable public link.
<p class="tradeoff"><strong>Pick this if:</strong> storage/compliance rules make stored copies awkward.</p>
</div>
</div>
</div>
</div>
</div>
<h4 class="sub">A2 · New type interfaces</h4>
<div class="code-grid">
<div class="code-block"><!--
--><div class="cl-line"><span class="cm">// src/types/exports.ts (new)</span></div><!--
--><div class="cl-line"><span class="kw">export interface</span> <span class="ty">ExportRequest</span> {</div><!--
--><div class="cl-line"> reviewId: <span class="ty">ReviewId</span>;</div><!--
--><div class="cl-line flagged"> format: <span class="st">'pdf'</span> | <span class="st">'csv'</span>;<span class="fnum">1</span></div><!--
--><div class="cl-line flagged"> range: <span class="st">'all'</span> | <span class="st">'unresolved'</span> | <span class="ty">TimecodeRange</span>;<span class="fnum">2</span></div><!--
--><div class="cl-line"> includeReplies: <span class="kw">boolean</span>; <span class="cm">// default true</span></div><!--
--><div class="cl-line flagged"> includeDrawings: <span class="kw">boolean</span>; <span class="cm">// default false</span><span class="fnum">3</span></div><!--
--><div class="cl-line">}</div><!--
--><div class="cl-line">&nbsp;</div><!--
--><div class="cl-line"><span class="kw">export interface</span> <span class="ty">AnnotationSnapshot</span> {</div><!--
--><div class="cl-line"> id: <span class="ty">AnnotationId</span>;</div><!--
--><div class="cl-line"> timecode: <span class="kw">string</span>; <span class="cm">// "00:04:12.08"</span></div><!--
--><div class="cl-line"> author: { id: <span class="ty">UserId</span>; name: <span class="kw">string</span> };</div><!--
--><div class="cl-line"> body: <span class="kw">string</span>;</div><!--
--><div class="cl-line flagged"> state: <span class="st">'open'</span> | <span class="st">'resolved'</span>;<span class="fnum">4</span></div><!--
--><div class="cl-line"> replies: <span class="ty">ReplySnapshot</span>[];</div><!--
--><div class="cl-line">}</div><!--
--></div>
<div class="margin-notes">
<div class="m-note"><span class="n-num">1</span>Started with PDF + CSV. Adding <code>'xlsx'</code> or <code>'srt'</code> later is one union member + one renderer.</div>
<div class="m-note"><span class="n-num">2</span><code>TimecodeRange</code> lets producers export just the notes for one scene. If nobody asked for this, cutting it removes ~½ day.</div>
<div class="m-note"><span class="n-num">3</span>Drawings (frame markups) rasterize into the PDF. Off by default because it triples render time.</div>
<div class="m-note"><span class="n-num">4</span>Deliberately drops the internal 5-state machine to 2 states in the export. Tell me if reviewers need to see <code>'wont_fix'</code>.</div>
</div>
</div>
<h4 class="sub">A3 · UX flow</h4>
<div class="flow-strip">
<div class="flow-step">
<div class="fs-num">Step 1</div>
<div class="fs-card">
<div class="fs-t">Export button</div>
<div class="fs-d">New item in the review toolbar overflow menu, next to “Copy share link”.</div>
</div>
</div>
<div class="flow-step">
<div class="fs-num">Step 2</div>
<div class="fs-card">
<div class="fs-t">Options modal</div>
<div class="fs-d">Format, range, include-replies, include-drawings. Reuses <b>ModalForm</b> from settings.</div>
</div>
</div>
<div class="flow-step">
<div class="fs-num">Step 3</div>
<div class="fs-card weak">
<span class="weak-tag">Weakest part of this plan</span>
<div class="fs-t">Background job + toast</div>
<div class="fs-d">Export renders in a worker; user gets a “we'll notify you” toast and can navigate away.</div>
</div>
</div>
<div class="flow-step">
<div class="fs-num">Step 4</div>
<div class="fs-card">
<div class="fs-t">Ready notification</div>
<div class="fs-d">Bell notification + download link; also listed under review → Exports tab.</div>
</div>
</div>
</div>
<p class="flow-note"><b>Why step 3 is weak:</b> most exports will finish in under 3 seconds, so a fire-and-forget toast may feel like overkill — but a 400-annotation review with drawings takes ~20&nbsp;s, which is too long to block. A hybrid (wait up to 4&nbsp;s, then fall back to notify) is nicer and adds ~half a day. Your call.</p>
</section>
<!-- ── SECTION B ─────────────────────── -->
<section class="plan-sec">
<div class="sec-head">
<span class="sec-letter">B</span>
<h3>Sequencing</h3>
<span class="likelihood lo">Execution order</span>
</div>
<p class="sec-intro">The order I'd actually build it in. Every step lands green on CI; nothing user-visible ships until step 5 flips the flag.</p>
<ol class="seq">
<li><span class="s-num">1</span><span class="s-body"><strong>Migration + model.</strong> <span class="dep">Create <b>annotation_exports</b>, wire the Sequelize model, factory, fixtures.</span></span><span class="s-est">0.5d</span></li>
<li><span class="s-num">2</span><span class="s-body"><strong>Types + request validation.</strong> <span class="dep">Everything in A2, plus zod schemas on <b>POST /api/reviews/:id/exports</b>.</span></span><span class="s-est">0.25d</span></li>
<li><span class="s-num">3</span><span class="s-body"><strong>Renderers.</strong> <span class="dep">CSV first (trivial), then PDF via the existing <b>@acme/render</b> service.</span></span><span class="s-est">1d</span></li>
<li><span class="s-num">4</span><span class="s-body"><strong>Worker job + blob upload.</strong> <span class="dep">Queue on <b>exports.render</b>; retries ×3 then <b>status = failed</b>.</span></span><span class="s-est">0.25d</span></li>
<li><span class="s-num">5</span><span class="s-body"><strong>UI: menu item, modal, toast, Exports tab.</strong> <span class="dep">Behind flag <b>export_annotations</b>.</span></span><span class="s-est">0.5d</span></li>
<li><span class="s-num">6</span><span class="s-body"><strong>Tests, docs, flag rollout to the Acme team.</strong></span><span class="s-est">0.25d</span></li>
</ol>
</section>
<!-- ── SECTION C ─────────────────────── -->
<section class="plan-sec">
<div class="sec-head">
<span class="sec-letter soft">C</span>
<h3>Mechanical work (trust me)</h3>
<span class="likelihood lo">Low tweak likelihood</span>
</div>
<p class="sec-intro">Refactors and plumbing the feature needs. No judgment calls here — collapsed on purpose.</p>
<details class="mech">
<summary>Boring but necessary <span class="count">8 tasks · ~0.5d total, folded into the estimates above</span></summary>
<ul class="mech-list">
<li>Extract <code>AnnotationSerializer</code> from <code>api/annotations.ts</code> into <code>lib/serializers/</code> so the export worker can reuse it. <span class="why">Pure move, no behavior change.</span></li>
<li>Finish the <code>CommentMarker</code><code>AnnotationMarker</code> rename — 3 files still import the deprecated alias.</li>
<li>Add <code>annotation_exports</code> to the fixture loader and the CI database-reset script.</li>
<li>Register <code>exports.render</code> in <code>workers/index.ts</code> and add it to the dead-letter alert list.</li>
<li>Create feature flag <code>export_annotations</code> in <code>flags.yaml</code>, default off.</li>
<li>Add a nightly TTL sweep for expired export blobs to <code>jobs/cleanup.ts</code> (only if Choice ② stays as-is).</li>
<li>Extend <code>openapi.yaml</code> with the two new endpoints and regenerate the client.</li>
<li>Move the timecode-formatting helper from <code>player/utils.ts</code> to <code>lib/time.ts</code> — the PDF renderer needs it and shouldn't import from the player bundle.</li>
</ul>
</details>
</section>
<!-- ── quick replies ─────────────────── -->
<div class="tweak-card">
<h3>Tweak these three things</h3>
<p>The highest-leverage replies you could send. Copy one, edit, send — I'll revise the plan.</p>
<div class="tweak-row">
<span class="t-num"></span>
<div class="t-body"><span class="t-q">“Switch Choice ① to live join — exports here are working docs, not records.”</span></div>
<button class="copy-btn" onclick="copyText(this, 'Switch Choice ① to live join — exports here are working docs, not records.')">Copy</button>
</div>
<div class="tweak-row">
<span class="t-num"></span>
<div class="t-body"><span class="t-q">“Cut TimecodeRange from ExportRequest; nobody has asked for per-scene export.”</span></div>
<button class="copy-btn" onclick="copyText(this, 'Cut TimecodeRange from ExportRequest; nobody has asked for per-scene export.')">Copy</button>
</div>
<div class="tweak-row">
<span class="t-num"></span>
<div class="t-body"><span class="t-q">“Do the hybrid for step 3: wait up to 4s inline, then fall back to notify.”</span></div>
<button class="copy-btn" onclick="copyText(this, 'Do the hybrid for step 3: wait up to 4s inline, then fall back to notify.')">Copy</button>
</div>
</div>
</div><!-- /artifact -->
<footer class="site-foot">
Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.
</footer>
</div>
<script>
function copyText(btn, text) {
var done = function () {
var orig = btn.dataset.orig || btn.textContent;
btn.dataset.orig = orig;
btn.textContent = 'Copied';
btn.classList.add('copied');
setTimeout(function () {
btn.textContent = orig;
btn.classList.remove('copied');
}, 1600);
};
var fallback = function () {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, fallback);
} else {
fallback();
}
}
// Choice toggles: swap both the choice card body and the matching schema rows.
function toggleChoice(n) {
var card = document.getElementById('c' + n);
var toAlt = card.getAttribute('data-variant') === 'main';
var next = toAlt ? 'alt' : 'main';
card.setAttribute('data-variant', next);
card.querySelector('.alt-toggle').textContent = toAlt ? '← Back to plans pick' : 'View alternative →';
// schema rows for choice n use classes v-main/v-alt (n=1) or v-main2/v-alt2 (n=2)
var suffix = n === 1 ? '' : '2';
var schema = document.getElementById('schema');
schema.querySelectorAll('.v-main' + suffix).forEach(function (el) {
el.style.display = toAlt ? 'none' : '';
});
schema.querySelectorAll('.v-alt' + suffix).forEach(function (el) {
el.style.display = toAlt ? 'block' : 'none';
});
}
// Initialize schema alt rows hidden (choice 2 rows aren't covered by the
// [data-variant] CSS on #schema since both choices share one diagram).
document.querySelectorAll('#schema .v-alt, #schema .v-alt2').forEach(function (el) {
el.style.display = 'none';
});
document.querySelectorAll('#schema .v-main2').forEach(function (el) {
el.style.display = '';
});
</script>
</body>
</html>