766 lines
36 KiB
HTML
766 lines
36 KiB
HTML
<!-- 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">Low–medium</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 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 ~2–4 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"> </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 s, which is too long to block. A hybrid (wait up to 4 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 plan’s 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>
|