chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,679 @@
|
||||
<!-- 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>Blindspot pass — 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; }
|
||||
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: 980px; margin: 0 auto; padding: 0 24px 100px; }
|
||||
|
||||
/* ── page header ─────────────────────── */
|
||||
header.page-head {
|
||||
padding: 56px 0 8px;
|
||||
position: relative;
|
||||
}
|
||||
.head-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.eyebrow::before {
|
||||
content: "";
|
||||
width: 24px; height: 1.5px;
|
||||
background: var(--clay);
|
||||
flex: none;
|
||||
}
|
||||
a.back {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--g500);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
a.back:hover { color: var(--clay); }
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
.lede {
|
||||
font-size: 16px;
|
||||
color: var(--g700);
|
||||
max-width: 640px;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
/* ── prompt box ──────────────────────── */
|
||||
.prompt-card {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 22px 26px 24px;
|
||||
position: relative;
|
||||
}
|
||||
.prompt-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--clay);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
blockquote.prompt {
|
||||
margin: 0;
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 17.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--slate);
|
||||
border-left: 3px solid var(--oat);
|
||||
padding-left: 18px;
|
||||
}
|
||||
button.copy-btn {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g700);
|
||||
background: var(--g100);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 8px;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, color 120ms, background 120ms;
|
||||
white-space: nowrap;
|
||||
}
|
||||
button.copy-btn:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
button.copy-btn.copied {
|
||||
background: var(--olive);
|
||||
border-color: var(--olive);
|
||||
color: var(--paper);
|
||||
}
|
||||
|
||||
/* ── divider ─────────────────────────── */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 44px 0 32px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1.5px;
|
||||
background: var(--g300);
|
||||
}
|
||||
|
||||
/* ── artifact frame ──────────────────── */
|
||||
.artifact {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.artifact-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 18px;
|
||||
border-bottom: 1.5px solid var(--g200);
|
||||
background: var(--g100);
|
||||
}
|
||||
.artifact-bar .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--g300); }
|
||||
.artifact-bar .file {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--g500);
|
||||
margin-left: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.artifact-body { padding: 34px 34px 40px; }
|
||||
@media (max-width: 560px) { .artifact-body { padding: 24px 18px 30px; } }
|
||||
|
||||
.artifact-body h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 27px;
|
||||
letter-spacing: -0.014em;
|
||||
line-height: 1.15;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.artifact-body .sub {
|
||||
font-size: 14px;
|
||||
color: var(--g500);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.artifact-body .sub code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--g100);
|
||||
border: 1px solid var(--g200);
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
color: var(--g700);
|
||||
}
|
||||
|
||||
/* asked vs walking into */
|
||||
.vs-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
@media (max-width: 640px) { .vs-grid { grid-template-columns: 1fr; } }
|
||||
.vs-cell {
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
background: var(--ivory);
|
||||
}
|
||||
.vs-cell.actual { border-color: var(--clay); background: #FBF3EF; }
|
||||
.vs-cell .vs-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.vs-cell.actual .vs-tag { color: var(--clay-d); }
|
||||
.vs-cell p { margin: 0; font-size: 14px; color: var(--g700); }
|
||||
.vs-cell p strong { color: var(--slate); }
|
||||
|
||||
/* stat strip */
|
||||
.stat-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 26px;
|
||||
padding: 14px 18px;
|
||||
border: 1.5px solid var(--g200);
|
||||
border-radius: 12px;
|
||||
background: var(--g100);
|
||||
margin-bottom: 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--g700);
|
||||
}
|
||||
.stat-strip b { color: var(--slate); font-weight: 600; }
|
||||
.stat-strip .warn b { color: var(--clay-d); }
|
||||
|
||||
/* section labels inside artifact */
|
||||
.art-eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.art-eyebrow::before {
|
||||
content: "";
|
||||
width: 24px; height: 1.5px;
|
||||
background: var(--clay);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* blindspot cards */
|
||||
.bs-card {
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 12px;
|
||||
background: var(--paper);
|
||||
padding: 20px 22px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.bs-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.badge {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.1em;
|
||||
font-weight: 600;
|
||||
padding: 3px 9px;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.landmine { color: var(--clay-d); border-color: var(--clay); background: #FBF0EA; }
|
||||
.badge.history { color: #8A6A3B; border-color: #C9B48E; background: var(--oat); }
|
||||
.badge.convention { color: var(--g700); border-color: var(--g300); background: var(--g100); }
|
||||
.badge.concept { color: #55663F; border-color: var(--olive); background: #EFF2E9; }
|
||||
.bs-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--g500);
|
||||
}
|
||||
.bs-title {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 18.5px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--slate);
|
||||
margin: 0;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.bs-card p { font-size: 14px; color: var(--g700); margin: 0 0 10px; }
|
||||
.bs-card p:last-of-type { margin-bottom: 0; }
|
||||
.bs-card code, .bite code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
background: var(--g100);
|
||||
border: 1px solid var(--g200);
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
color: var(--g700);
|
||||
word-break: break-word;
|
||||
}
|
||||
.bite {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 13.5px;
|
||||
color: var(--g700);
|
||||
background: var(--ivory);
|
||||
border-left: 3px solid var(--clay);
|
||||
border-radius: 0 8px 8px 0;
|
||||
padding: 10px 14px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.bite .bite-tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--clay-d);
|
||||
flex: none;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* prompt chips */
|
||||
.chip-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.prompt-chip {
|
||||
flex: 1;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--slate);
|
||||
background: var(--g100);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 9px;
|
||||
padding: 10px 13px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.prompt-chip::before {
|
||||
content: "› ";
|
||||
color: var(--clay);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip-row button.copy-btn { align-self: flex-start; margin-top: 2px; }
|
||||
@media (max-width: 480px) {
|
||||
.chip-row { flex-direction: column; }
|
||||
.chip-row button.copy-btn { align-self: flex-end; margin-top: 0; }
|
||||
}
|
||||
|
||||
/* improved prompt card */
|
||||
.final-card {
|
||||
border: 1.5px solid var(--slate);
|
||||
border-radius: 14px;
|
||||
background: var(--paper);
|
||||
padding: 24px 26px 26px;
|
||||
margin-top: 38px;
|
||||
box-shadow: 0 10px 28px rgba(20,20,19,.07);
|
||||
}
|
||||
.final-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.final-head .art-eyebrow { margin: 0; color: var(--clay-d); }
|
||||
.final-card h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 22px;
|
||||
letter-spacing: -0.012em;
|
||||
margin: 8px 0 14px;
|
||||
}
|
||||
.final-prompt {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.65;
|
||||
color: var(--slate);
|
||||
background: var(--ivory);
|
||||
border: 1.5px solid var(--g200);
|
||||
border-radius: 10px;
|
||||
padding: 18px 20px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.final-note {
|
||||
font-size: 13px;
|
||||
color: var(--g500);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* footer */
|
||||
footer {
|
||||
margin-top: 56px;
|
||||
padding-top: 22px;
|
||||
border-top: 1.5px solid var(--g200);
|
||||
font-size: 13px;
|
||||
color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
footer a:hover { text-decoration-color: var(--clay); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="page-head">
|
||||
<div class="head-row">
|
||||
<div class="eyebrow">Know your unknowns · Pre-implementation</div>
|
||||
<a class="back" href="index.html">← All examples</a>
|
||||
</div>
|
||||
<h1>Blindspot pass</h1>
|
||||
<p class="lede">Before touching unfamiliar code, ask Claude to surface what you don't know you don't know — and to tell you how to prompt around each gap.</p>
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="prompt-label">
|
||||
<span>The prompt</span>
|
||||
<button class="copy-btn" data-copy-target="user-prompt">Copy prompt</button>
|
||||
</div>
|
||||
<blockquote class="prompt" id="user-prompt">I'm adding a new SSO auth provider to Acme but I've never touched the auth module. Do a blindspot pass: find my unknown unknowns in this part of the codebase, explain each one, and tell me how to prompt you better for the implementation.</blockquote>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<div class="artifact">
|
||||
<div class="artifact-bar">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="file">blindspot-pass · acme/services/auth · 47 files scanned</span>
|
||||
</div>
|
||||
<div class="artifact-body">
|
||||
|
||||
<h2>Unknown unknowns: the Acme auth module</h2>
|
||||
<p class="sub">Scanned <code>services/auth/</code> (47 files), 214 commits touching it since Jan 2025, 3 migrations in flight, 2 reverted attempts. Seven things you'd have learned the hard way.</p>
|
||||
|
||||
<div class="vs-grid">
|
||||
<div class="vs-cell">
|
||||
<div class="vs-tag">What you asked for</div>
|
||||
<p>"Add a new SSO provider" — which sounds like: implement an interface, register it, write the redirect handlers, done. On a clean codebase, a two-day task.</p>
|
||||
</div>
|
||||
<div class="vs-cell actual">
|
||||
<div class="vs-tag">What you're actually walking into</div>
|
||||
<p>A module <strong>mid-migration on session storage</strong>, with <strong>one provider that quietly bypasses the middleware</strong> everyone assumes runs, a <strong>reverted previous attempt</strong> at exactly this task, and an <strong>identity-linking model</strong> that isn't visible from the provider interface at all.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-strip">
|
||||
<span class="warn"><b>4</b> landmines</span>
|
||||
<span><b>2</b> unwritten conventions</span>
|
||||
<span><b>1</b> missing concept</span>
|
||||
<span><b>1</b> reverted attempt at this exact task (PR #2841)</span>
|
||||
</div>
|
||||
|
||||
<div class="art-eyebrow">The blindspots</div>
|
||||
|
||||
<!-- 1 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge landmine">Landmine</span>
|
||||
<span class="bs-num">01</span>
|
||||
<h3 class="bs-title">Sessions are double-written — the Redis store you'll find first is the wrong one</h3>
|
||||
</div>
|
||||
<p>Acme is mid-migration from Postgres-backed sessions to Redis (<code>MIG-118</code>, stalled since March). Every session write currently goes through <code>SessionBridge.write()</code>, which fans out to <em>both</em> <code>RedisSessionStore</code> and the legacy <code>pg_sessions</code> table. The migration was never finished, so reads still come from Postgres.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>Grepping for "session" surfaces <code>RedisSessionStore</code> first — it looks canonical, has the newest code, and the best docs. Write to it directly and your logins will appear to work in dev (Redis reads are enabled there) and silently fail in prod, where reads still hit Postgres.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-1">When you create sessions for the new provider, route everything through SessionBridge — never RedisSessionStore or pg_sessions directly. Explain the MIG-118 read/write split before writing any session code.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-1">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge landmine">Landmine</span>
|
||||
<span class="bs-num">02</span>
|
||||
<h3 class="bs-title">The SAML provider — your obvious template — bypasses the auth middleware</h3>
|
||||
</div>
|
||||
<p>Every provider except SAML mounts under <code>authPipeline</code>, which runs rate limiting, audit logging, and the device-trust check. <code>saml/handler.ts</code> mounts its ACS endpoint directly on the router because SP-initiated POST callbacks broke the CSRF layer in 2024, and nobody circled back. There's a <code>// TODO(priya): fold back into pipeline</code> from 14 months ago.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>SAML is the most recently added provider, so it's the natural file to copy. Copy it and your new provider ships with no rate limiting and no audit trail — which won't fail any test, because the contract tests don't assert middleware presence.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-2">Use oauth/google.ts as the structural template, not saml/handler.ts — SAML intentionally bypasses authPipeline. Confirm the new provider's routes are mounted inside authPipeline and show me the mount point.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-2">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge history">History</span>
|
||||
<span class="bs-num">03</span>
|
||||
<h3 class="bs-title">Someone already tried this — and it was reverted for a reason that still holds</h3>
|
||||
</div>
|
||||
<p>PR #2841 ("Add Okta OIDC provider", Sept 2025) was merged and reverted nine days later. The revert commit explains why: Acme callback URLs must carry a tenant suffix (<code>/auth/callback/:provider/:workspaceSlug</code>) so workspace-scoped invite links survive the redirect round-trip. Okta's strict redirect-URI matching rejected the dynamic suffix, and invited users landed in the wrong workspace. The fix everyone agreed on — a state-parameter workspace claim signed with <code>AUTH_STATE_SECRET</code> — was designed in the revert thread but never built.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>Nothing in the current code hints at this. You'd re-implement #2841's approach, pass every local test (single-workspace dev env), and rediscover the bug in production the first time an invited user signs in.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-3">Read the revert of PR #2841 first. Implement the signed state-parameter workspace claim it proposed instead of tenant-suffixed callback URLs, and add a test for the workspace-scoped invite flow.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-3">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge concept">Missing concept</span>
|
||||
<span class="bs-num">04</span>
|
||||
<h3 class="bs-title">Identities and accounts are separate things — and the provider interface hides it</h3>
|
||||
</div>
|
||||
<p>Acme splits <code>users</code> from <code>identities</code>: one account can have several linked identities (email+password, Google, SAML), joined in the <code>identities</code> table. The provider interface only returns a profile; the linking decision happens in <code>identity/linker.ts</code>, keyed on verified email. Providers that can't guarantee verified emails (many SSO IdPs don't) must set <code>requiresManualLink: true</code> or the linker will happily merge two different people who share a corporate alias.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>Implement the interface naively and everything works — until a customer's IdP sends an unverified or reassigned email and your provider silently attaches a stranger's identity to an existing account. This is an account-takeover bug, not a login bug.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-4">Walk me through identity/linker.ts before implementing. Decide explicitly whether this IdP guarantees verified emails; if not, set requiresManualLink and build the manual-link confirmation path.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-4">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge landmine">Landmine</span>
|
||||
<span class="bs-num">05</span>
|
||||
<h3 class="bs-title">Token refresh is behind a flag that's on in dev and off in prod</h3>
|
||||
</div>
|
||||
<p>Refresh-token rotation is gated by <code>auth.refresh_rotation</code> in <code>flags.yaml</code>. It defaults to <code>true</code> in dev and staging, <code>false</code> in prod (a paused rollout after a clock-skew incident in May). With the flag off, prod sessions live exactly <code>ACCESS_TOKEN_TTL</code> (12h) and then hard-expire — there is no refresh path.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>Any refresh logic you write will be tested against dev behavior and dead code in prod. Worse: if your provider assumes a refresh happens, prod users get logged out mid-session every 12 hours and the bug report will say "SSO randomly logs me out," pointing at your code.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-5">Implement against auth.refresh_rotation=false (prod reality). Make the provider work with hard 12h expiry, and note separately what changes if the rotation rollout resumes.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-5">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge convention">Convention</span>
|
||||
<span class="bs-num">06</span>
|
||||
<h3 class="bs-title">Registering a provider takes three steps, and only two are discoverable</h3>
|
||||
</div>
|
||||
<p>A provider isn't live when it compiles. It must be (1) registered in <code>providers/registry.ts</code>, (2) inserted into the <code>auth_providers</code> table via a migration — the admin UI reads the DB, not the registry — and (3) added to the fixture list in <code>__tests__/provider-contract.spec.ts</code>, which runs the shared contract suite (state validation, replay protection, logout fan-out) against every registered provider. CI fails if the registry and fixture list disagree.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>Skip the migration and the provider compiles, passes tests, and simply never appears in any workspace's SSO settings. Two engineers have independently lost half a day to this; it's in nobody's docs.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-6">Do all three registration steps: registry.ts, an auth_providers migration, and the provider-contract fixture list. Show me the contract suite passing for the new provider before we call it done.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-6">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7 -->
|
||||
<div class="bs-card">
|
||||
<div class="bs-head">
|
||||
<span class="badge landmine">Landmine</span>
|
||||
<span class="bs-num">07</span>
|
||||
<h3 class="bs-title">Logout isn't a function call — it's an event, and the review player listens</h3>
|
||||
</div>
|
||||
<p>Session revocation publishes <code>auth.session.revoked</code> on the internal event bus. The realtime review player holds its own long-lived playback grants and only releases them when it consumes that event. <code>logout.ts</code> handles this for existing providers, but SSO single-logout (SLO) callbacks arrive on a separate path and must publish the event themselves.</p>
|
||||
<div class="bite"><span class="bite-tag">Why it bites</span><span>Miss the event and logout "works" — the session row is gone — but anyone with an open review tab keeps full playback access to private video until the grant TTL expires (up to 24h). For a company selling review workflows to studios, that's a security incident, not a bug.</span></div>
|
||||
<div class="chip-row">
|
||||
<div class="prompt-chip" id="chip-7">Handle SLO: any revocation path in the new provider must publish auth.session.revoked. Add a test asserting the review player's grant is released on IdP-initiated logout.</div>
|
||||
<button class="copy-btn" data-copy-target="chip-7">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- final -->
|
||||
<div class="final-card">
|
||||
<div class="final-head">
|
||||
<div class="art-eyebrow">Your improved prompt</div>
|
||||
<button class="copy-btn" data-copy-target="final-prompt">Copy prompt</button>
|
||||
</div>
|
||||
<h3>Everything above, folded into the prompt you should actually give me</h3>
|
||||
<pre class="final-prompt" id="final-prompt">Add a new SSO auth provider to Acme. Constraints from the blindspot pass:
|
||||
|
||||
1. Sessions: route all session writes through SessionBridge (MIG-118 is
|
||||
unfinished; prod still reads from pg_sessions). Never touch
|
||||
RedisSessionStore directly.
|
||||
2. Template: base the structure on oauth/google.ts, NOT saml/handler.ts —
|
||||
SAML bypasses authPipeline. Mount all new routes inside authPipeline
|
||||
and show me the mount point.
|
||||
3. History: read the revert of PR #2841 first. Implement the signed
|
||||
state-parameter workspace claim (AUTH_STATE_SECRET) instead of
|
||||
tenant-suffixed callback URLs, and test the workspace-invite flow.
|
||||
4. Identity linking: walk me through identity/linker.ts before coding.
|
||||
If this IdP doesn't guarantee verified emails, set requiresManualLink
|
||||
and build the confirmation path — do not auto-link.
|
||||
5. Flags: implement against auth.refresh_rotation=false (prod). Sessions
|
||||
hard-expire at 12h; no refresh path. Note what changes if rotation
|
||||
ships.
|
||||
6. Registration: all three steps — providers/registry.ts, an
|
||||
auth_providers migration, and the provider-contract fixture list.
|
||||
Show the contract suite green.
|
||||
7. Logout: every revocation path must publish auth.session.revoked, and
|
||||
add a test that the review player's playback grant is released on
|
||||
IdP-initiated logout.
|
||||
|
||||
Work in this order: linker walkthrough → provider skeleton → callback +
|
||||
state claim → registration → SLO + events → contract tests. Stop and
|
||||
show me the plan after the walkthrough before writing code.</pre>
|
||||
<p class="final-note">Seven sentences you couldn't have written this morning — each one bought with someone else's half-day. That's the point of the pass.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function copyText(text, btn) {
|
||||
function done() {
|
||||
var prev = btn.textContent;
|
||||
btn.textContent = "Copied";
|
||||
btn.classList.add("copied");
|
||||
setTimeout(function () {
|
||||
btn.textContent = prev;
|
||||
btn.classList.remove("copied");
|
||||
}, 1600);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { fallback(text); done(); });
|
||||
} else {
|
||||
fallback(text);
|
||||
done();
|
||||
}
|
||||
}
|
||||
function fallback(text) {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
document.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest("button[data-copy-target]");
|
||||
if (!btn) return;
|
||||
var el = document.getElementById(btn.getAttribute("data-copy-target"));
|
||||
if (!el) return;
|
||||
copyText(el.textContent.trim(), btn);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,582 @@
|
||||
<!-- 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>Teach me my unknowns — 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; }
|
||||
|
||||
/* ── header ─────────────────────────── */
|
||||
header { padding: 56px 0 8px; 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: none; }
|
||||
.backlink {
|
||||
position: absolute; top: 60px; right: 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
color: var(--g500); text-decoration: none;
|
||||
}
|
||||
.backlink:hover { color: var(--clay); }
|
||||
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 10px;
|
||||
}
|
||||
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; margin: 0 0 28px; }
|
||||
|
||||
h2, h3 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.012em; }
|
||||
|
||||
.card {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* prompt box */
|
||||
.promptbox { padding: 22px 26px 24px; position: relative; margin-bottom: 44px; }
|
||||
.promptbox .label {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--clay); margin-bottom: 10px;
|
||||
}
|
||||
.promptbox blockquote {
|
||||
margin: 0; padding: 0 0 0 18px;
|
||||
border-left: 3px solid var(--oat);
|
||||
font-family: var(--serif); font-style: italic;
|
||||
font-size: 17.5px; line-height: 1.5; color: var(--slate);
|
||||
}
|
||||
.copybtn {
|
||||
position: absolute; top: 18px; right: 18px;
|
||||
font-family: var(--mono); font-size: 11.5px;
|
||||
background: var(--g100); color: var(--g700);
|
||||
border: 1.5px solid var(--g300); border-radius: 8px;
|
||||
padding: 6px 12px; cursor: pointer;
|
||||
}
|
||||
.copybtn:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
.copybtn.done { background: var(--olive); border-color: var(--olive); color: #fff; }
|
||||
|
||||
/* divider */
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--g500);
|
||||
margin: 0 0 40px;
|
||||
}
|
||||
.divider::before, .divider::after { content: ""; flex: 1; height: 1.5px; background: var(--g300); }
|
||||
|
||||
/* ── artifact ─────────────────────────── */
|
||||
.artifact h2 { font-size: 27px; margin: 0 0 6px; }
|
||||
.artifact .sub { color: var(--g700); font-size: 15px; max-width: 640px; margin: 0 0 26px; }
|
||||
.sec-eyebrow {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--g500);
|
||||
display: flex; align-items: center; gap: 12px; margin: 44px 0 14px;
|
||||
}
|
||||
.sec-eyebrow::before { content: ""; width: 24px; height: 1.5px; background: var(--clay); flex: none; }
|
||||
|
||||
/* mental model strip */
|
||||
.pipeline { display: flex; gap: 0; align-items: stretch; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.pipe-step {
|
||||
flex: 1 1 150px; background: var(--paper); border: 1.5px solid var(--g300);
|
||||
border-radius: 12px; padding: 12px 14px; margin: 0 10px 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
.pipe-step b { display: block; font-size: 14px; }
|
||||
.pipe-step span { font-size: 12.5px; color: var(--g500); }
|
||||
.pipe-step.hot { border-color: var(--clay); }
|
||||
.pipe-step.hot b { color: var(--clay-d); }
|
||||
|
||||
/* vocab ladder */
|
||||
.ladder { display: grid; gap: 12px; }
|
||||
.rung { padding: 16px 20px 15px; display: grid; grid-template-columns: 158px 1fr; gap: 4px 22px; }
|
||||
@media (max-width: 640px) { .rung { grid-template-columns: 1fr; } }
|
||||
.rung .term { font-family: var(--serif); font-size: 17px; font-weight: 500; }
|
||||
.rung .term small { display: block; font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--g500); margin-top: 2px; }
|
||||
.rung .def { font-size: 14.5px; color: var(--g700); }
|
||||
.rung .say {
|
||||
grid-column: 2; font-family: var(--mono); font-size: 12.5px; color: var(--clay-d);
|
||||
background: var(--g100); border-radius: 8px; padding: 7px 11px; margin-top: 6px;
|
||||
}
|
||||
@media (max-width: 640px) { .rung .say { grid-column: 1; } }
|
||||
.rung .say::before { content: "say → "; color: var(--g500); }
|
||||
|
||||
/* grading console */
|
||||
.console { overflow: hidden; }
|
||||
.console-head {
|
||||
display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;
|
||||
padding: 14px 20px; border-bottom: 1.5px solid var(--g200); background: var(--g100);
|
||||
}
|
||||
.console-head .fname { font-family: var(--mono); font-size: 12px; color: var(--g700); }
|
||||
.console-head .fname i { font-style: normal; color: var(--g500); }
|
||||
.presets { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.presets button {
|
||||
font-family: var(--mono); font-size: 11.5px; padding: 6px 12px;
|
||||
background: var(--paper); border: 1.5px solid var(--g300); border-radius: 999px;
|
||||
color: var(--g700); cursor: pointer;
|
||||
}
|
||||
.presets button:hover { border-color: var(--clay); }
|
||||
.presets button.on { background: var(--slate); border-color: var(--slate); color: var(--ivory); }
|
||||
|
||||
.frame-wrap { position: relative; background: var(--slate); user-select: none; }
|
||||
.frame-stack { position: relative; aspect-ratio: 16/9; overflow: hidden; }
|
||||
.frame-stack svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#gradeLayer { position: absolute; inset: 0; }
|
||||
#tempOverlay, #splitTone { position: absolute; inset: 0; pointer-events: none; }
|
||||
#tempOverlay { mix-blend-mode: soft-light; opacity: 0; }
|
||||
#splitTone { mix-blend-mode: soft-light; opacity: 0;
|
||||
background: radial-gradient(circle at 72% 34%, rgba(255,166,87,.95) 0%, rgba(255,166,87,0) 42%),
|
||||
linear-gradient(rgba(38,86,97,.85), rgba(38,86,97,.85)); }
|
||||
.split-handle {
|
||||
position: absolute; top: 0; bottom: 0; width: 2px; background: rgba(250,249,245,.9);
|
||||
box-shadow: 0 0 6px rgba(0,0,0,.5); pointer-events: none;
|
||||
}
|
||||
.split-handle::after {
|
||||
content: "◂ ▸"; position: absolute; top: 50%; left: 50%;
|
||||
transform: translate(-50%,-50%);
|
||||
background: var(--ivory); color: var(--slate); font-size: 10px;
|
||||
padding: 3px 7px; border-radius: 999px; white-space: nowrap;
|
||||
font-family: var(--mono); letter-spacing: 0.05em;
|
||||
}
|
||||
#splitRange {
|
||||
position: absolute; inset: 0; width: 100%; height: 100%;
|
||||
opacity: 0; cursor: ew-resize; margin: 0; -webkit-appearance: none; appearance: none;
|
||||
}
|
||||
.frame-tag {
|
||||
position: absolute; top: 10px; font-family: var(--mono); font-size: 10px;
|
||||
letter-spacing: 0.12em; padding: 3px 8px; border-radius: 4px;
|
||||
background: rgba(20,20,19,.55); color: rgba(250,249,245,.9); pointer-events: none;
|
||||
}
|
||||
.frame-tag.before { left: 10px; }
|
||||
.frame-tag.after { right: 10px; }
|
||||
|
||||
.sliders {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 14px 28px;
|
||||
padding: 18px 20px 20px; border-top: 1.5px solid var(--g200);
|
||||
}
|
||||
@media (max-width: 640px) { .sliders { grid-template-columns: 1fr; } }
|
||||
.sl-row label {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
font-family: var(--mono); font-size: 11.5px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; color: var(--g700); margin-bottom: 4px;
|
||||
}
|
||||
.sl-row label output { color: var(--clay-d); text-transform: none; }
|
||||
.sl-row input[type=range] {
|
||||
-webkit-appearance: none; appearance: none; width: 100%; height: 22px;
|
||||
background: transparent; cursor: pointer; margin: 0;
|
||||
}
|
||||
.sl-row input[type=range]::-webkit-slider-runnable-track {
|
||||
height: 4px; border-radius: 2px; background: var(--g200);
|
||||
}
|
||||
.sl-row input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none; appearance: none; width: 16px; height: 16px; margin-top: -6px;
|
||||
border-radius: 50%; background: var(--clay); border: 2px solid var(--paper);
|
||||
box-shadow: 0 1px 3px rgba(20,20,19,.25);
|
||||
}
|
||||
.sl-row input[type=range]::-moz-range-track {
|
||||
height: 4px; border-radius: 2px; background: var(--g200);
|
||||
}
|
||||
.sl-row input[type=range]::-moz-range-thumb {
|
||||
width: 14px; height: 14px; border-radius: 50%; background: var(--clay);
|
||||
border: 2px solid var(--paper); box-shadow: 0 1px 3px rgba(20,20,19,.25);
|
||||
}
|
||||
.console-foot {
|
||||
font-size: 13px; color: var(--g500); padding: 0 20px 16px;
|
||||
}
|
||||
.console-foot b { color: var(--g700); font-weight: 600; }
|
||||
.resetbtn {
|
||||
font-family: var(--mono); font-size: 11px; background: none; border: none;
|
||||
color: var(--g500); text-decoration: underline; cursor: pointer; padding: 0;
|
||||
}
|
||||
.resetbtn:hover { color: var(--clay); }
|
||||
|
||||
/* checklist */
|
||||
.checklist { padding: 6px 22px 10px; }
|
||||
.checklist ul { list-style: none; margin: 12px 0 14px; padding: 0; display: grid; gap: 10px; }
|
||||
.checklist li { display: flex; gap: 12px; font-size: 14.5px; color: var(--g700); }
|
||||
.checklist li::before {
|
||||
content: "✓"; flex: none; width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--olive); color: #fff; font-size: 12px; line-height: 20px;
|
||||
text-align: center; margin-top: 2px;
|
||||
}
|
||||
.checklist li b { color: var(--slate); font-weight: 600; }
|
||||
.checklist li.warn::before { content: "!"; background: var(--clay); }
|
||||
|
||||
/* prompts card */
|
||||
.prompts-card { border-color: var(--slate); padding: 22px 24px 20px; }
|
||||
.prompts-card h3 { margin: 0 0 4px; font-size: 21px; }
|
||||
.prompts-card .note { font-size: 13.5px; color: var(--g500); margin: 0 0 16px; }
|
||||
.pgrid { display: grid; gap: 10px; }
|
||||
.pgrid .p {
|
||||
font-family: var(--mono); font-size: 13px; line-height: 1.6;
|
||||
background: var(--g100); border-left: 3px solid var(--clay);
|
||||
border-radius: 0 10px 10px 0; padding: 12px 16px; color: var(--g700);
|
||||
}
|
||||
.pgrid .p b { color: var(--slate); font-weight: 600; }
|
||||
|
||||
footer {
|
||||
margin-top: 72px; padding-top: 24px; border-top: 1.5px solid var(--g300);
|
||||
font-size: 13.5px; color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Know your unknowns · Pre-implementation</div>
|
||||
<a class="backlink" href="index.html">← All examples</a>
|
||||
<h1>Teach me my unknowns</h1>
|
||||
<p class="lede">Before doing unfamiliar work, ask Claude to teach you the domain's vocabulary — so your next prompt uses the words a professional would.</p>
|
||||
|
||||
<div class="card promptbox">
|
||||
<div class="label">The prompt</div>
|
||||
<blockquote id="promptText">I don't know what color grading is but I need to grade the Acme launch video. Teach me color grading well enough that I understand my unknown unknowns and can prompt you with real vocabulary.</blockquote>
|
||||
<button class="copybtn" id="copyBtn" type="button">copy prompt</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<main class="artifact">
|
||||
<h2>Color grading in one sitting</h2>
|
||||
<p class="sub">Everything you need to grade <em>acme-launch-v3.mp4</em> — or at least to give precise notes to whoever does. Correction first, then the creative look. Play with the frame below until the sliders feel obvious.</p>
|
||||
|
||||
<!-- mental model -->
|
||||
<div class="sec-eyebrow">The mental model</div>
|
||||
<div class="pipeline">
|
||||
<div class="pipe-step"><b>1 · Ingest</b><span>Footage arrives "flat" (log) — grey and washed out, on purpose. It preserves detail.</span></div>
|
||||
<div class="pipe-step hot"><b>2 · Correct</b><span>Fix exposure & white balance so it looks <em>real</em>. Boring, essential, first.</span></div>
|
||||
<div class="pipe-step hot"><b>3 · Grade</b><span>Add the <em>look</em>: contrast shape, color bias, mood. This is the creative part.</span></div>
|
||||
<div class="pipe-step"><b>4 · Match</b><span>Make every shot in the cut agree, so the video feels like one piece.</span></div>
|
||||
</div>
|
||||
|
||||
<!-- vocab ladder -->
|
||||
<div class="sec-eyebrow">The vocabulary ladder</div>
|
||||
<div class="ladder">
|
||||
<div class="card rung">
|
||||
<div class="term">Exposure<small>overall brightness</small></div>
|
||||
<div class="def">How light or dark the whole image is, measured in "stops" (each stop doubles/halves the light). Wrong exposure = detail lost in blown highlights or murky shadows.</div>
|
||||
<div class="say">"the interview shots look a half-stop underexposed — bring them up"</div>
|
||||
</div>
|
||||
<div class="card rung">
|
||||
<div class="term">White balance<small>color temperature</small></div>
|
||||
<div class="def">Whether white things actually look white. Measured in Kelvin: low = warm/orange (tungsten), high = cool/blue (daylight). Mixed lighting is the classic gotcha.</div>
|
||||
<div class="say">"the office footage reads too warm — cool it toward neutral, protect skin tones"</div>
|
||||
</div>
|
||||
<div class="card rung">
|
||||
<div class="term">Contrast curve<small>tone response</small></div>
|
||||
<div class="def">The S-shaped curve mapping shadows→highlights. Steeper = punchier; flatter = softer, more "filmic". You bend the curve, you don't just crank a slider.</div>
|
||||
<div class="say">"add a gentle S-curve — richer blacks but don't crush shadow detail"</div>
|
||||
</div>
|
||||
<div class="card rung">
|
||||
<div class="term">Lift / Gamma / Gain<small>3-way correction</small></div>
|
||||
<div class="def">Independent color+brightness control over shadows (lift), midtones (gamma), and highlights (gain). This is how pros push "teal in the shadows, warm in the highlights".</div>
|
||||
<div class="say">"push the lift slightly teal and the gain slightly warm — subtle, not music-video"</div>
|
||||
</div>
|
||||
<div class="card rung">
|
||||
<div class="term">Saturation vs vibrance<small>color intensity</small></div>
|
||||
<div class="def">Saturation boosts <em>all</em> colors equally (skin goes orange fast). Vibrance boosts only the muted colors and protects skin. Almost always reach for vibrance first.</div>
|
||||
<div class="say">"use vibrance not saturation — the UI screenshots can pop but faces stay natural"</div>
|
||||
</div>
|
||||
<div class="card rung">
|
||||
<div class="term">LUT<small>look-up table</small></div>
|
||||
<div class="def">A saved color transform file (.cube) that applies a whole look in one step — either technical (log→Rec.709 conversion) or creative (a stylized grade). Apply after correction, then dial its intensity down.</div>
|
||||
<div class="say">"apply the Rec.709 conversion LUT first, then a creative LUT at ~60% strength"</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- interactive console -->
|
||||
<div class="sec-eyebrow">Feel it — grade a frame</div>
|
||||
<div class="card console">
|
||||
<div class="console-head">
|
||||
<span class="fname">acme-launch-v3.mp4 <i>· frame 01:47:12 · drag the split to compare</i></span>
|
||||
<div class="presets">
|
||||
<button type="button" data-preset="flat">Flat / log</button>
|
||||
<button type="button" data-preset="corporate" class="on">Corporate clean</button>
|
||||
<button type="button" data-preset="teal">Cinematic teal-orange</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="frame-wrap">
|
||||
<div class="frame-stack">
|
||||
<!-- scene definition -->
|
||||
<svg viewBox="0 0 640 360" preserveAspectRatio="xMidYMid slice" aria-label="Ungraded landscape frame">
|
||||
<defs>
|
||||
<g id="scene">
|
||||
<rect width="640" height="360" fill="#9DB3B8"/>
|
||||
<circle cx="462" cy="118" r="34" fill="#EFD79A"/>
|
||||
<path d="M0 190 L120 96 L238 190 Z" fill="#77857D"/>
|
||||
<path d="M150 190 L300 66 L470 190 Z" fill="#5F6F66"/>
|
||||
<path d="M400 190 L520 110 L640 190 L640 200 L400 200 Z" fill="#77857D"/>
|
||||
<rect y="190" width="640" height="80" fill="#8AA39E"/>
|
||||
<path d="M300 214 L470 190 L462 152 L448 190 Z" fill="#71898A" opacity="0.5"/>
|
||||
<circle cx="462" cy="216" r="22" fill="#D9C08A" opacity="0.55"/>
|
||||
<rect y="262" width="640" height="98" fill="#55614C"/>
|
||||
<path d="M0 262 C 140 244, 300 276, 640 254 L640 360 L0 360 Z" fill="#4A5542"/>
|
||||
<g fill="#3E4A38">
|
||||
<path d="M70 262 L86 210 L102 262 Z"/><rect x="83" y="258" width="6" height="16"/>
|
||||
<path d="M112 268 L126 224 L140 268 Z"/><rect x="123" y="264" width="5" height="14"/>
|
||||
<path d="M560 258 L578 202 L596 258 Z"/><rect x="575" y="254" width="6" height="18"/>
|
||||
</g>
|
||||
<g fill="#2E2E2A">
|
||||
<circle cx="332" cy="284" r="7"/>
|
||||
<path d="M326 291 C 326 302, 338 302, 338 291 L337 316 L333 316 L332 300 L331 316 L327 316 Z"/>
|
||||
</g>
|
||||
<path d="M0 356 L640 356" stroke="#39422F" stroke-width="8"/>
|
||||
</g>
|
||||
</defs>
|
||||
<use href="#scene"/>
|
||||
</svg>
|
||||
|
||||
<!-- graded copy, clipped to the right of the split -->
|
||||
<div id="gradeLayer">
|
||||
<svg viewBox="0 0 640 360" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<use href="#scene"/>
|
||||
</svg>
|
||||
<div id="tempOverlay"></div>
|
||||
<div id="splitTone"></div>
|
||||
</div>
|
||||
|
||||
<span class="frame-tag before">UNGRADED</span>
|
||||
<span class="frame-tag after">GRADED</span>
|
||||
<div class="split-handle" id="splitHandle"></div>
|
||||
<input type="range" id="splitRange" min="4" max="96" value="42" aria-label="Before/after split position">
|
||||
</div>
|
||||
|
||||
<div class="sliders">
|
||||
<div class="sl-row">
|
||||
<label for="sExp">Exposure <output id="oExp">+0.2 EV</output></label>
|
||||
<input type="range" id="sExp" min="-50" max="50" value="10">
|
||||
</div>
|
||||
<div class="sl-row">
|
||||
<label for="sTemp">Temperature <output id="oTemp">+8 warm</output></label>
|
||||
<input type="range" id="sTemp" min="-50" max="50" value="8">
|
||||
</div>
|
||||
<div class="sl-row">
|
||||
<label for="sCon">Contrast <output id="oCon">+12</output></label>
|
||||
<input type="range" id="sCon" min="-50" max="50" value="12">
|
||||
</div>
|
||||
<div class="sl-row">
|
||||
<label for="sSat">Saturation <output id="oSat">+8</output></label>
|
||||
<input type="range" id="sSat" min="-50" max="50" value="8">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-foot">
|
||||
<span id="presetNote"><b>Corporate clean:</b> slight lift in exposure, near-neutral temperature, modest contrast — trustworthy and bright. The default for product launch videos.</span>
|
||||
· <button class="resetbtn" type="button" id="resetBtn">reset to ungraded</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- checklist -->
|
||||
<div class="sec-eyebrow">What good looks like</div>
|
||||
<div class="card checklist">
|
||||
<ul>
|
||||
<li><b>Skin tones stay believable.</b> Every creative look bends around faces, never through them. If the founder looks seasick or sunburned, the grade is wrong.</li>
|
||||
<li><b>Blacks are rich but not crushed.</b> You can still read detail in dark areas (hair, the navy hoodie at 00:42). A histogram slammed against the left edge means lost information.</li>
|
||||
<li><b>Whites don't clip.</b> The Acme UI screens are mostly white — if they blow out to pure 100%, button borders and text vanish. Keep highlights just under the ceiling.</li>
|
||||
<li><b>Shot-to-shot consistency.</b> Cutting from the office interview to the screen capture shouldn't feel like changing channels. Shot matching is half the job.</li>
|
||||
<li><b>The look serves the story.</b> A launch video wants clean and confident, not moody thriller. If viewers notice the grade before the product, dial it back.</li>
|
||||
<li class="warn"><b>Check on more than one screen.</b> Your laptop lies. Preview on a phone and, ideally, a TV before signing off — and confirm you're exporting in Rec.709 for web delivery.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- prompts you couldn't have written -->
|
||||
<div class="sec-eyebrow">The payoff</div>
|
||||
<div class="card prompts-card">
|
||||
<h3>Prompts you couldn't have written an hour ago</h3>
|
||||
<p class="note">An hour ago the best you had was "make the video look nicer." Now you can say:</p>
|
||||
<div class="pgrid">
|
||||
<div class="p">"The interview footage was shot in <b>log</b> — apply the camera's <b>Rec.709 conversion LUT</b> first, then grade on top of that, not on the raw flat image."</div>
|
||||
<div class="p">"Give the whole cut a restrained <b>teal-orange</b> look: push the <b>lift</b> slightly teal, <b>gain</b> slightly warm, keep <b>gamma</b> neutral so midtone skin stays natural."</div>
|
||||
<div class="p">"Shot 14 is about <b>2/3 of a stop underexposed</b> and too warm versus shot 13 — match its <b>exposure</b> and <b>white balance</b> to shot 13, then A/B them at the cut point."</div>
|
||||
<div class="p">"Add a soft <b>S-curve</b> for contrast and lift color intensity with <b>vibrance rather than saturation</b>, so the product UI pops without turning faces orange."</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ---- copy prompt ----
|
||||
var copyBtn = document.getElementById('copyBtn');
|
||||
var promptText = document.getElementById('promptText').textContent.trim();
|
||||
copyBtn.addEventListener('click', function () {
|
||||
function done() {
|
||||
copyBtn.textContent = 'Copied';
|
||||
copyBtn.classList.add('done');
|
||||
setTimeout(function () {
|
||||
copyBtn.textContent = 'copy prompt';
|
||||
copyBtn.classList.remove('done');
|
||||
}, 1600);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(promptText).then(done, fallback);
|
||||
} else { fallback(); }
|
||||
function fallback() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = promptText;
|
||||
ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- grading console ----
|
||||
var sExp = document.getElementById('sExp'),
|
||||
sTemp = document.getElementById('sTemp'),
|
||||
sCon = document.getElementById('sCon'),
|
||||
sSat = document.getElementById('sSat');
|
||||
var oExp = document.getElementById('oExp'),
|
||||
oTemp = document.getElementById('oTemp'),
|
||||
oCon = document.getElementById('oCon'),
|
||||
oSat = document.getElementById('oSat');
|
||||
var gradeLayer = document.getElementById('gradeLayer');
|
||||
var gradeSvg = gradeLayer.querySelector('svg');
|
||||
var tempOverlay = document.getElementById('tempOverlay');
|
||||
var splitTone = document.getElementById('splitTone');
|
||||
var splitRange = document.getElementById('splitRange');
|
||||
var splitHandle = document.getElementById('splitHandle');
|
||||
var presetNote = document.getElementById('presetNote');
|
||||
var presetBtns = document.querySelectorAll('.presets button');
|
||||
var splitToneOn = false;
|
||||
|
||||
var presets = {
|
||||
flat: {
|
||||
exp: 6, temp: 0, con: -38, sat: -45, split: false,
|
||||
note: '<b>Flat / log:</b> low contrast, low saturation — this is roughly what footage looks like straight off a camera shooting log. Ugly on purpose: it keeps maximum detail for grading.'
|
||||
},
|
||||
corporate: {
|
||||
exp: 10, temp: 8, con: 12, sat: 8, split: false,
|
||||
note: '<b>Corporate clean:</b> slight lift in exposure, near-neutral temperature, modest contrast — trustworthy and bright. The default for product launch videos.'
|
||||
},
|
||||
teal: {
|
||||
exp: 4, temp: -22, con: 26, sat: 14, split: true,
|
||||
note: '<b>Cinematic teal-orange:</b> shadows pushed teal, a warm glow held around the light source, stronger contrast. The blockbuster look — use at maybe 40% of this strength for a launch video.'
|
||||
}
|
||||
};
|
||||
|
||||
function fmtSigned(v) { return (v > 0 ? '+' : '') + v; }
|
||||
|
||||
function update() {
|
||||
var exp = +sExp.value, temp = +sTemp.value, con = +sCon.value, sat = +sSat.value;
|
||||
|
||||
// filter math: sliders are -50..50
|
||||
var brightness = 1 + exp / 100; // 0.5 .. 1.5
|
||||
var contrast = 1 + con / 100; // 0.5 .. 1.5
|
||||
var saturate = 1 + sat / 62.5; // 0.2 .. 1.8
|
||||
var sepia = temp > 0 ? temp / 50 * 0.22 : 0;
|
||||
|
||||
gradeSvg.style.filter =
|
||||
'brightness(' + brightness.toFixed(3) + ') ' +
|
||||
'contrast(' + contrast.toFixed(3) + ') ' +
|
||||
'saturate(' + saturate.toFixed(3) + ')' +
|
||||
(sepia ? ' sepia(' + sepia.toFixed(3) + ')' : '');
|
||||
|
||||
// temperature wash overlay
|
||||
if (temp === 0) {
|
||||
tempOverlay.style.opacity = 0;
|
||||
} else if (temp > 0) {
|
||||
tempOverlay.style.background = '#E8955C';
|
||||
tempOverlay.style.opacity = (temp / 50 * 0.55).toFixed(3);
|
||||
} else {
|
||||
tempOverlay.style.background = '#3E7286';
|
||||
tempOverlay.style.opacity = (-temp / 50 * 0.6).toFixed(3);
|
||||
}
|
||||
|
||||
splitTone.style.opacity = splitToneOn ? 0.75 : 0;
|
||||
|
||||
// readouts
|
||||
oExp.textContent = (exp >= 0 ? '+' : '−') + Math.abs(exp / 50).toFixed(1) + ' EV';
|
||||
oTemp.textContent = temp === 0 ? 'neutral' : fmtSigned(temp) + (temp > 0 ? ' warm' : ' cool');
|
||||
oCon.textContent = fmtSigned(con);
|
||||
oSat.textContent = fmtSigned(sat);
|
||||
}
|
||||
|
||||
[sExp, sTemp, sCon, sSat].forEach(function (s) {
|
||||
s.addEventListener('input', function () {
|
||||
splitToneOn = false; // manual moves drop the split-tone extra
|
||||
presetBtns.forEach(function (b) { b.classList.remove('on'); });
|
||||
update();
|
||||
});
|
||||
});
|
||||
|
||||
presetBtns.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var p = presets[btn.dataset.preset];
|
||||
sExp.value = p.exp; sTemp.value = p.temp; sCon.value = p.con; sSat.value = p.sat;
|
||||
splitToneOn = p.split;
|
||||
presetBtns.forEach(function (b) { b.classList.toggle('on', b === btn); });
|
||||
presetNote.innerHTML = p.note;
|
||||
update();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('resetBtn').addEventListener('click', function () {
|
||||
sExp.value = 0; sTemp.value = 0; sCon.value = 0; sSat.value = 0;
|
||||
splitToneOn = false;
|
||||
presetBtns.forEach(function (b) { b.classList.remove('on'); });
|
||||
presetNote.innerHTML = '<b>Ungraded:</b> all corrections zeroed — the graded side now matches the source frame exactly.';
|
||||
update();
|
||||
});
|
||||
|
||||
// before/after split
|
||||
function updateSplit() {
|
||||
var pct = +splitRange.value;
|
||||
gradeLayer.style.clipPath = 'inset(0 0 0 ' + pct + '%)';
|
||||
splitHandle.style.left = pct + '%';
|
||||
}
|
||||
splitRange.addEventListener('input', updateSplit);
|
||||
|
||||
update();
|
||||
updateSplit();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,798 @@
|
||||
<!-- 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>Mock before you wire — 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; }
|
||||
|
||||
/* ── header ─────────────────────────── */
|
||||
header { padding: 56px 0 8px; 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: none; }
|
||||
.backlink {
|
||||
position: absolute; top: 60px; right: 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
color: var(--g500); text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.backlink:hover { color: var(--clay); }
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; margin: 0 0 30px; }
|
||||
|
||||
/* ── prompt box ─────────────────────── */
|
||||
.promptbox {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px 22px;
|
||||
position: relative;
|
||||
margin-bottom: 44px;
|
||||
}
|
||||
.promptbox .label {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--clay); margin-bottom: 10px;
|
||||
}
|
||||
.promptbox blockquote {
|
||||
margin: 0; padding: 0 0 0 18px;
|
||||
border-left: 3px solid var(--oat);
|
||||
font-family: var(--serif); font-style: italic;
|
||||
font-size: 17.5px; line-height: 1.5; color: var(--slate);
|
||||
max-width: 720px;
|
||||
}
|
||||
.copybtn {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--g100); color: var(--g700);
|
||||
border: 1.5px solid var(--g300); border-radius: 8px;
|
||||
padding: 5px 11px; cursor: pointer;
|
||||
}
|
||||
.copybtn:hover { border-color: var(--g500); }
|
||||
.copybtn.done { background: var(--olive); border-color: var(--olive); color: #fff; }
|
||||
|
||||
/* ── divider ────────────────────────── */
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--g500); margin: 0 0 32px;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: ""; flex: 1; height: 1px; background: var(--g300);
|
||||
}
|
||||
|
||||
/* ═══ THE ARTIFACT ═══════════════════ */
|
||||
.artifact {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 22px;
|
||||
}
|
||||
@media (max-width: 480px) { .artifact { padding: 14px; } }
|
||||
.art-head {
|
||||
display: flex; flex-wrap: wrap; gap: 12px;
|
||||
align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.art-title { font-family: var(--mono); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--g500); }
|
||||
.art-title b { color: var(--slate); font-weight: 600; }
|
||||
|
||||
/* segmented control */
|
||||
.seg {
|
||||
display: inline-flex; flex-wrap: wrap;
|
||||
background: var(--g100);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 10px; padding: 3px; gap: 3px;
|
||||
}
|
||||
.seg button {
|
||||
font-family: var(--sans); font-size: 12.5px; font-weight: 500;
|
||||
border: none; background: transparent; color: var(--g700);
|
||||
padding: 6px 12px; border-radius: 7px; cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.seg button:hover { color: var(--slate); }
|
||||
.seg button.on {
|
||||
background: var(--paper); color: var(--slate);
|
||||
box-shadow: 0 1px 3px rgba(20,20,19,.12);
|
||||
}
|
||||
.seg button.on .dot { background: var(--clay); }
|
||||
.seg .dot {
|
||||
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--g300); margin-right: 7px; vertical-align: 1px;
|
||||
}
|
||||
|
||||
.variant-note {
|
||||
font-size: 13px; color: var(--g500);
|
||||
margin: 0 0 14px; min-height: 20px;
|
||||
}
|
||||
.variant-note b { color: var(--g700); font-weight: 600; }
|
||||
|
||||
/* ── stage ──────────────────────────── */
|
||||
.stagewrap { position: relative; }
|
||||
.stage {
|
||||
position: relative;
|
||||
background: #1C1B18;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 220px;
|
||||
}
|
||||
.stage svg.scene { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
.chip-tc, .chip-paused {
|
||||
position: absolute; top: 10px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
padding: 3px 9px; border-radius: 6px;
|
||||
background: rgba(20,20,19,.72); color: var(--ivory);
|
||||
letter-spacing: 0.06em; z-index: 3;
|
||||
}
|
||||
.chip-tc { left: 10px; }
|
||||
.chip-paused { right: 10px; color: var(--oat); }
|
||||
.chip-paused::before { content: "❚❚ "; font-size: 9px; }
|
||||
|
||||
/* seekbar */
|
||||
.seekbar {
|
||||
position: absolute; left: 0; right: 0; bottom: 0;
|
||||
padding: 26px 14px 10px; z-index: 3;
|
||||
background: linear-gradient(to top, rgba(20,20,19,.78), rgba(20,20,19,0));
|
||||
}
|
||||
.seek-track { position: relative; height: 4px; border-radius: 2px; background: rgba(250,249,245,.25); }
|
||||
.seek-buf { position: absolute; left: 0; top: 0; bottom: 0; width: 62%; border-radius: 2px; background: rgba(250,249,245,.35); }
|
||||
.seek-play { position: absolute; left: 0; top: 0; bottom: 0; width: 34%; border-radius: 2px; background: var(--clay); }
|
||||
.seek-head {
|
||||
position: absolute; left: 34%; top: 50%;
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
background: var(--clay); border: 2px solid var(--ivory);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.seek-pins { position: absolute; left: 14px; right: 14px; bottom: 20px; height: 0; }
|
||||
.seek-pin {
|
||||
position: absolute; bottom: -3px; width: 7px; height: 7px;
|
||||
border-radius: 2px; transform: translateX(-50%) rotate(45deg);
|
||||
background: var(--oat); opacity: .9;
|
||||
}
|
||||
|
||||
/* ── toolbar ────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: rgba(28,27,24,.92);
|
||||
border: 1.5px solid rgba(250,249,245,.16);
|
||||
box-shadow: 0 8px 28px rgba(20,20,19,.35);
|
||||
padding: 8px 12px;
|
||||
z-index: 5;
|
||||
color: var(--ivory);
|
||||
max-width: 100%;
|
||||
}
|
||||
.tb-group { display: flex; align-items: center; gap: 4px; }
|
||||
.tb-sep { width: 1px; align-self: stretch; margin: 2px 0; background: rgba(250,249,245,.18); flex: none; }
|
||||
|
||||
.tool {
|
||||
width: 34px; height: 34px; flex: none;
|
||||
display: grid; place-items: center;
|
||||
border: 1.5px solid transparent; border-radius: 9px;
|
||||
background: transparent; color: var(--g300);
|
||||
cursor: pointer; padding: 0;
|
||||
}
|
||||
.tool:hover { color: var(--ivory); background: rgba(250,249,245,.08); }
|
||||
.tool.on { background: var(--clay); border-color: var(--clay-d); color: #fff; }
|
||||
.tool svg { width: 17px; height: 17px; display: block; }
|
||||
|
||||
.swatches { display: flex; align-items: center; gap: 6px; }
|
||||
.swatch {
|
||||
width: 18px; height: 18px; flex: none;
|
||||
border-radius: 50%; cursor: pointer; padding: 0;
|
||||
border: 2px solid rgba(250,249,245,.25);
|
||||
}
|
||||
.swatch.on { border-color: var(--ivory); box-shadow: 0 0 0 2px rgba(217,119,87,.55); }
|
||||
|
||||
.strokes { display: flex; align-items: center; gap: 4px; }
|
||||
.stroke {
|
||||
width: 26px; height: 26px; flex: none;
|
||||
display: grid; place-items: center;
|
||||
background: transparent; border: 1.5px solid transparent;
|
||||
border-radius: 7px; cursor: pointer; padding: 0;
|
||||
}
|
||||
.stroke i { display: block; border-radius: 50%; background: var(--g300); }
|
||||
.stroke.s1 i { width: 4px; height: 4px; }
|
||||
.stroke.s2 i { width: 7px; height: 7px; }
|
||||
.stroke.s3 i { width: 11px; height: 11px; }
|
||||
.stroke:hover i { background: var(--ivory); }
|
||||
.stroke.on { background: rgba(250,249,245,.12); border-color: rgba(250,249,245,.3); }
|
||||
.stroke.on i { background: var(--ivory); }
|
||||
|
||||
.ann-chip {
|
||||
font-family: var(--mono); font-size: 11px; white-space: nowrap;
|
||||
background: rgba(227,218,204,.16); color: var(--oat);
|
||||
border: 1px solid rgba(227,218,204,.3);
|
||||
border-radius: 999px; padding: 3px 10px;
|
||||
}
|
||||
.cmt-btn {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-family: var(--sans); font-size: 12.5px; font-weight: 500;
|
||||
background: transparent; color: var(--g300);
|
||||
border: 1.5px solid rgba(250,249,245,.22); border-radius: 9px;
|
||||
padding: 6px 11px; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.cmt-btn:hover { color: var(--ivory); border-color: rgba(250,249,245,.45); }
|
||||
.cmt-btn.on { background: var(--oat); color: var(--slate); border-color: var(--oat); }
|
||||
.cmt-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
/* variant: floating pill (default) */
|
||||
.stagewrap.v-pill .toolbar {
|
||||
position: absolute; left: 50%; bottom: 52px;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 999px;
|
||||
flex-wrap: nowrap;
|
||||
width: max-content; max-width: calc(100% - 20px);
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.stagewrap.v-pill .toolbar::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* variant: docked left rail */
|
||||
.stagewrap.v-rail .toolbar {
|
||||
position: absolute; left: 10px; top: 50%;
|
||||
transform: translateY(-50%);
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
padding: 10px 8px;
|
||||
gap: 8px;
|
||||
max-height: calc(100% - 20px);
|
||||
overflow-y: auto; scrollbar-width: none;
|
||||
}
|
||||
.stagewrap.v-rail .toolbar::-webkit-scrollbar { display: none; }
|
||||
.stagewrap.v-rail .tb-group { flex-direction: column; }
|
||||
.stagewrap.v-rail .tb-sep { width: auto; height: 1px; align-self: stretch; margin: 0 2px; }
|
||||
.stagewrap.v-rail .swatches { display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||
.stagewrap.v-rail .strokes { flex-direction: column; }
|
||||
.stagewrap.v-rail .ann-chip { writing-mode: initial; padding: 3px 6px; font-size: 10px; }
|
||||
.stagewrap.v-rail .cmt-btn span { display: none; }
|
||||
.stagewrap.v-rail .cmt-btn { padding: 8px; }
|
||||
.stagewrap.v-rail .tool, .stagewrap.v-rail .stroke { width: 32px; height: 32px; }
|
||||
|
||||
/* variant: docked under seekbar */
|
||||
.stagewrap.v-dock .toolbar {
|
||||
position: static; transform: none;
|
||||
margin-top: 10px;
|
||||
border-radius: 12px;
|
||||
background: var(--g700);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.stagewrap.v-dock .tb-sep { min-height: 22px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.tool { width: 30px; height: 30px; }
|
||||
.tool svg { width: 15px; height: 15px; }
|
||||
.toolbar { gap: 7px; padding: 6px 9px; }
|
||||
.stagewrap.v-pill .toolbar { bottom: 42px; }
|
||||
.cmt-btn span { display: none; }
|
||||
.cmt-btn { padding: 7px; }
|
||||
.stagewrap.v-rail .swatches { grid-template-columns: 1fr; }
|
||||
.stagewrap.v-rail .ann-chip { display: none; }
|
||||
}
|
||||
|
||||
/* ── comment drawer ─────────────────── */
|
||||
.drawer {
|
||||
position: absolute; top: 0; right: 0; bottom: 0;
|
||||
width: min(272px, 78%);
|
||||
background: rgba(250,249,245,.97);
|
||||
border-left: 1.5px solid var(--g300);
|
||||
transform: translateX(105%);
|
||||
transition: transform .28s cubic-bezier(.4,0,.2,1);
|
||||
z-index: 6;
|
||||
display: flex; flex-direction: column;
|
||||
border-radius: 0 12px 12px 0;
|
||||
}
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 14px 10px;
|
||||
border-bottom: 1.5px solid var(--g200);
|
||||
}
|
||||
.drawer-head h3 { margin: 0; font-family: var(--serif); font-weight: 500; font-size: 15px; letter-spacing: -0.01em; }
|
||||
.drawer-head .cnt { font-family: var(--mono); font-size: 11px; color: var(--g500); }
|
||||
.drawer-close {
|
||||
border: none; background: none; cursor: pointer;
|
||||
font-size: 16px; line-height: 1; color: var(--g500); padding: 4px;
|
||||
}
|
||||
.drawer-close:hover { color: var(--slate); }
|
||||
.drawer-body { flex: 1; overflow-y: auto; padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.cmt {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g200);
|
||||
border-radius: 10px;
|
||||
padding: 9px 11px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.cmt .who { display: flex; align-items: baseline; gap: 7px; margin-bottom: 3px; }
|
||||
.cmt .av {
|
||||
width: 18px; height: 18px; border-radius: 50%; flex: none;
|
||||
display: grid; place-items: center; align-self: center;
|
||||
font-size: 9px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.cmt .nm { font-weight: 600; font-size: 12px; }
|
||||
.cmt .tc { font-family: var(--mono); font-size: 10px; color: var(--clay); }
|
||||
.cmt p { margin: 0; color: var(--g700); }
|
||||
.cmt .pin { font-family: var(--mono); font-size: 10px; color: var(--g500); margin-top: 4px; }
|
||||
.drawer-input {
|
||||
margin: 0 12px 12px; padding: 8px 11px;
|
||||
border: 1.5px solid var(--g300); border-radius: 9px;
|
||||
font-size: 12.5px; color: var(--g500); background: var(--paper);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.art-footnote {
|
||||
margin: 14px 2px 0; font-size: 12.5px; color: var(--g500);
|
||||
}
|
||||
.art-footnote code { font-family: var(--mono); font-size: 11.5px; background: var(--g100); padding: 1px 5px; border-radius: 4px; }
|
||||
|
||||
/* ── open questions card ────────────── */
|
||||
.questions {
|
||||
margin-top: 28px;
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 22px 24px;
|
||||
}
|
||||
@media (max-width: 480px) { .questions { padding: 16px; } }
|
||||
.questions .label {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--clay); margin-bottom: 6px;
|
||||
}
|
||||
.questions h2 {
|
||||
font-family: var(--serif); font-weight: 500;
|
||||
font-size: 22px; letter-spacing: -0.012em; margin: 0 0 4px;
|
||||
}
|
||||
.questions .sub { font-size: 13.5px; color: var(--g500); margin: 0 0 18px; }
|
||||
.q { padding: 14px 0; border-top: 1.5px solid var(--g100); }
|
||||
.q:first-of-type { border-top: 1.5px solid var(--g200); }
|
||||
.q-text { font-size: 14.5px; margin: 0 0 9px; }
|
||||
.q-text .qn {
|
||||
font-family: var(--mono); font-size: 11px; color: var(--g500);
|
||||
margin-right: 8px;
|
||||
}
|
||||
.q-chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.chip {
|
||||
font-family: var(--sans); font-size: 12.5px; font-weight: 500;
|
||||
background: var(--g100); color: var(--g700);
|
||||
border: 1.5px solid var(--g300); border-radius: 999px;
|
||||
padding: 5px 13px; cursor: pointer;
|
||||
}
|
||||
.chip:hover { border-color: var(--g500); }
|
||||
.chip.on { background: var(--slate); border-color: var(--slate); color: var(--ivory); }
|
||||
.chip .k {
|
||||
font-family: var(--mono); font-size: 10.5px; color: var(--clay);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.chip.on .k { color: var(--oat); }
|
||||
|
||||
/* ── reply template ─────────────────── */
|
||||
.reply {
|
||||
margin-top: 18px;
|
||||
background: var(--g100);
|
||||
border: 1.5px solid var(--g200);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
position: relative;
|
||||
}
|
||||
.reply .label {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.14em; text-transform: uppercase;
|
||||
color: var(--g500); margin-bottom: 8px;
|
||||
}
|
||||
.reply pre {
|
||||
margin: 0; font-family: var(--mono); font-size: 12px;
|
||||
line-height: 1.7; color: var(--g700);
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.reply pre .picked { color: var(--slate); font-weight: 600; }
|
||||
.reply pre .blank { color: var(--clay); }
|
||||
|
||||
/* ── footer ─────────────────────────── */
|
||||
footer {
|
||||
margin-top: 64px; padding-top: 20px;
|
||||
border-top: 1.5px solid var(--g300);
|
||||
font-size: 13px; color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
footer a:hover { text-decoration-color: var(--clay); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<a class="backlink" href="index.html">← All examples</a>
|
||||
<div class="eyebrow">Know your unknowns · Pre-implementation</div>
|
||||
<h1>Mock before you wire</h1>
|
||||
<p class="lede">Before Claude touches the real codebase, ask for a throwaway HTML mock — you'll find out what you actually want the moment you can click it, not three PRs later.</p>
|
||||
|
||||
<div class="promptbox">
|
||||
<div class="label">The prompt</div>
|
||||
<button class="copybtn" id="copyPrompt">copy prompt</button>
|
||||
<blockquote id="promptText">Before wiring anything up, make a single HTML file mocking Acme's new frame-annotation toolbar with fake data. I want to react to the layout before you touch the real app.</blockquote>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<!-- ═══ ARTIFACT ═══ -->
|
||||
<div class="artifact">
|
||||
<div class="art-head">
|
||||
<div class="art-title"><b>Acme</b> · frame-annotation toolbar · mock, no real code touched</div>
|
||||
<div class="seg" id="variantSeg" role="tablist" aria-label="Toolbar layout variant">
|
||||
<button class="on" data-v="v-pill"><span class="dot"></span>Floating pill</button>
|
||||
<button data-v="v-rail"><span class="dot"></span>Docked left rail</button>
|
||||
<button data-v="v-dock"><span class="dot"></span>Under seekbar</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="variant-note" id="variantNote"><b>Floating pill, bottom-center.</b> Hovers above the seekbar; hides on playback. Closest to what Loom/Frame.io ship.</p>
|
||||
|
||||
<div class="stagewrap v-pill" id="stagewrap">
|
||||
<div class="stage">
|
||||
<!-- fake paused frame -->
|
||||
<svg class="scene" viewBox="0 0 640 360" preserveAspectRatio="xMidYMid slice" aria-label="Paused video frame: sunset ride teaser, 00:41 — logo lockup annotated">
|
||||
<!-- sky -->
|
||||
<rect x="0" y="0" width="640" height="360" fill="#2A2722"/>
|
||||
<rect x="0" y="0" width="640" height="230" fill="#3D3A32"/>
|
||||
<circle cx="470" cy="118" r="46" fill="#D97757"/>
|
||||
<circle cx="470" cy="118" r="46" fill="none" stroke="#E3DACC" stroke-opacity=".25" stroke-width="10"/>
|
||||
<!-- far hills -->
|
||||
<path d="M0 205 Q 110 150 230 198 T 470 192 T 640 178 V 360 H 0 Z" fill="#4A4A3C"/>
|
||||
<!-- near hills -->
|
||||
<path d="M0 250 Q 150 205 300 246 T 640 238 V 360 H 0 Z" fill="#5B6647"/>
|
||||
<path d="M0 292 Q 200 258 400 288 T 640 280 V 360 H 0 Z" fill="#788C5D"/>
|
||||
<!-- road -->
|
||||
<path d="M270 360 L 315 240 L 331 240 L 410 360 Z" fill="#3D3D3A"/>
|
||||
<path d="M322 240 L 322 268 M 328 282 L 332 310 M 336 326 L 342 356" stroke="#E3DACC" stroke-opacity=".5" stroke-width="3" stroke-linecap="round" fill="none"/>
|
||||
<!-- rider silhouette -->
|
||||
<g fill="#141413">
|
||||
<circle cx="330" cy="252" r="9"/>
|
||||
<circle cx="352" cy="252" r="9"/>
|
||||
<path d="M330 252 L 341 238 L 352 252 Z" stroke="#141413" stroke-width="3" fill="none"/>
|
||||
<path d="M341 238 L 338 222 L 346 218" stroke="#141413" stroke-width="4" stroke-linecap="round" fill="none"/>
|
||||
<circle cx="348" cy="212" r="5"/>
|
||||
</g>
|
||||
<!-- title lockup being reviewed -->
|
||||
<g>
|
||||
<rect x="60" y="70" width="176" height="58" rx="6" fill="#141413" fill-opacity=".55"/>
|
||||
<text x="76" y="98" font-family="Georgia, serif" font-size="22" fill="#FAF9F5" font-style="italic">Ridgeline '26</text>
|
||||
<text x="76" y="117" font-family="monospace" font-size="10" letter-spacing="2" fill="#E3DACC">TEASER · CUT 04</text>
|
||||
</g>
|
||||
<!-- existing annotations (fake data) -->
|
||||
<g>
|
||||
<!-- box annotation on lockup -->
|
||||
<rect x="52" y="62" width="192" height="74" rx="8" fill="none" stroke="#D97757" stroke-width="3"/>
|
||||
<circle cx="52" cy="62" r="11" fill="#D97757"/>
|
||||
<text x="52" y="66" text-anchor="middle" font-family="monospace" font-size="11" font-weight="bold" fill="#fff">1</text>
|
||||
<!-- arrow annotation at sun flare -->
|
||||
<path d="M560 220 Q 540 175 505 150" fill="none" stroke="#E3DACC" stroke-width="3"/>
|
||||
<path d="M505 150 L 521 152 M 505 150 L 511 165" stroke="#E3DACC" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="560" cy="220" r="11" fill="#788C5D"/>
|
||||
<text x="560" y="224" text-anchor="middle" font-family="monospace" font-size="11" font-weight="bold" fill="#fff">2</text>
|
||||
<!-- pen scribble on road -->
|
||||
<path d="M380 330 q 14 -12 28 -2 q 14 10 26 -4" fill="none" stroke="#D97757" stroke-width="3" stroke-linecap="round"/>
|
||||
<circle cx="380" cy="330" r="11" fill="#D97757"/>
|
||||
<text x="380" y="334" text-anchor="middle" font-family="monospace" font-size="11" font-weight="bold" fill="#fff">3</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="chip-tc">00:00:41:07</div>
|
||||
<div class="chip-paused">PAUSED</div>
|
||||
|
||||
<!-- seekbar -->
|
||||
<div class="seekbar">
|
||||
<div class="seek-pins">
|
||||
<span class="seek-pin" style="left:34%"></span>
|
||||
<span class="seek-pin" style="left:51%"></span>
|
||||
<span class="seek-pin" style="left:78%"></span>
|
||||
</div>
|
||||
<div class="seek-track">
|
||||
<div class="seek-buf"></div>
|
||||
<div class="seek-play"></div>
|
||||
<div class="seek-head"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- comment drawer -->
|
||||
<aside class="drawer" id="drawer" aria-hidden="true">
|
||||
<div class="drawer-head">
|
||||
<h3>Comments <span class="cnt">@ 00:41</span></h3>
|
||||
<button class="drawer-close" id="drawerClose" aria-label="Close comments">✕</button>
|
||||
</div>
|
||||
<div class="drawer-body">
|
||||
<div class="cmt">
|
||||
<div class="who"><span class="av" style="background:var(--clay)">MO</span><span class="nm">Maya Okafor</span><span class="tc">00:41:07</span></div>
|
||||
<p>Lockup lands too early — we haven't earned the name yet. Can we hold it to the crest at 00:48?</p>
|
||||
<div class="pin">↳ pinned to annotation #1</div>
|
||||
</div>
|
||||
<div class="cmt">
|
||||
<div class="who"><span class="av" style="background:var(--olive)">DP</span><span class="nm">Dev Patel</span><span class="tc">00:41:07</span></div>
|
||||
<p>Flare off the sun is clipping on HDR exports. Flagging for the color pass.</p>
|
||||
<div class="pin">↳ pinned to annotation #2</div>
|
||||
</div>
|
||||
<div class="cmt">
|
||||
<div class="who"><span class="av" style="background:var(--g500)">SR</span><span class="nm">Sam Ruiz</span><span class="tc">00:41:07</span></div>
|
||||
<p>Road markings jitter one frame back. Probably the stabilization crop — see scribble.</p>
|
||||
<div class="pin">↳ pinned to annotation #3</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-input">Reply at 00:41…</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- toolbar (one markup, repositioned per variant) -->
|
||||
<div class="toolbar" id="toolbar" role="toolbar" aria-label="Frame annotation tools">
|
||||
<div class="tb-group" id="tools">
|
||||
<button class="tool on" data-tool="select" title="Select (V)" aria-label="Select tool">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M5 3l14 8-6.5 1.5L9 19 5 3z"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="pen" title="Pen (P)" aria-label="Pen tool">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="arrow" title="Arrow (A)" aria-label="Arrow tool">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19L19 5M19 5h-8M19 5v8"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="box" title="Box (B)" aria-label="Box tool">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="6" width="16" height="12" rx="2"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="text" title="Text (T)" aria-label="Text tool">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 6V4h14v2M12 4v16M9 20h6"/></svg>
|
||||
</button>
|
||||
<button class="tool" data-tool="blur" title="Blur (U)" aria-label="Blur tool">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 3s6 6.5 6 11a6 6 0 01-12 0c0-4.5 6-11 6-11z"/><path d="M9.5 14a2.5 2.5 0 002.5 2.5" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tb-sep"></div>
|
||||
<div class="swatches" id="swatches" aria-label="Annotation color">
|
||||
<button class="swatch on" style="background:#D97757" data-c="clay" title="Clay" aria-label="Clay"></button>
|
||||
<button class="swatch" style="background:#788C5D" data-c="olive" title="Olive" aria-label="Olive"></button>
|
||||
<button class="swatch" style="background:#E3DACC" data-c="oat" title="Oat" aria-label="Oat"></button>
|
||||
<button class="swatch" style="background:#FAF9F5" data-c="white" title="White" aria-label="White"></button>
|
||||
<button class="swatch" style="background:#141413" data-c="ink" title="Ink" aria-label="Ink"></button>
|
||||
</div>
|
||||
<div class="tb-sep"></div>
|
||||
<div class="strokes" id="strokes" aria-label="Stroke width">
|
||||
<button class="stroke s1" data-w="2" title="Thin"><i></i></button>
|
||||
<button class="stroke s2 on" data-w="4" title="Medium"><i></i></button>
|
||||
<button class="stroke s3" data-w="7" title="Thick"><i></i></button>
|
||||
</div>
|
||||
<div class="tb-sep"></div>
|
||||
<span class="ann-chip" title="Annotations on this frame">3 ann.</span>
|
||||
<button class="cmt-btn" id="cmtBtn" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 12a8 8 0 01-8 8H4l2-3.2A8 8 0 1121 12z"/></svg>
|
||||
<span>Comments</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="art-footnote">Everything above is fake data in one static file — nothing reads from <code>apps/player</code> yet. Real wiring would live in <code>apps/player/src/annotate/Toolbar.tsx</code> behind the <code>frame_annotations</code> flag.</p>
|
||||
</div>
|
||||
|
||||
<!-- ═══ OPEN QUESTIONS ═══ -->
|
||||
<div class="questions">
|
||||
<div class="label">Open questions for you</div>
|
||||
<h2>Four layout calls I'd rather not guess</h2>
|
||||
<p class="sub">Tap a chip to record your pick — the reply template below fills itself in.</p>
|
||||
|
||||
<div class="q" data-q="placement">
|
||||
<p class="q-text"><span class="qn">Q1</span>Which placement should ship? The pill stays near the pointer but covers action in the lower third; the rail never occludes footage but is far from the seekbar; under-seekbar steals ~56px of player height.</p>
|
||||
<div class="q-chips">
|
||||
<button class="chip" data-a="Floating pill"><span class="k">A</span>Floating pill</button>
|
||||
<button class="chip" data-a="Docked left rail"><span class="k">B</span>Docked left rail</button>
|
||||
<button class="chip" data-a="Under the seekbar"><span class="k">C</span>Under the seekbar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q" data-q="colorstroke">
|
||||
<p class="q-text"><span class="qn">Q2</span>Color + stroke controls: always visible in the bar (what's mocked), or collapsed behind a popover on the active tool? Inline is one click faster; popover shrinks the pill by ~140px, which matters on 13″ laptops.</p>
|
||||
<div class="q-chips">
|
||||
<button class="chip" data-a="Always inline"><span class="k">A</span>Always inline</button>
|
||||
<button class="chip" data-a="Popover on active tool"><span class="k">B</span>Popover on active tool</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q" data-q="drawer">
|
||||
<p class="q-text"><span class="qn">Q3</span>The comment drawer currently overlays the right edge of the frame (open it via the Comments button). Should it instead push the video left so annotations are never covered while reading?</p>
|
||||
<div class="q-chips">
|
||||
<button class="chip" data-a="Overlay the frame"><span class="k">A</span>Overlay the frame</button>
|
||||
<button class="chip" data-a="Push the video left"><span class="k">B</span>Push the video left</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q" data-q="blur">
|
||||
<p class="q-text"><span class="qn">Q4</span>Blur is the only tool that needs server-side rendering on export. Keep it in the v1 toolbar (with a "processing on export" badge), or hold it for v1.1 so the first release ships all-client-side?</p>
|
||||
<div class="q-chips">
|
||||
<button class="chip" data-a="Ship blur in v1"><span class="k">A</span>Ship in v1</button>
|
||||
<button class="chip" data-a="Hold blur for v1.1"><span class="k">B</span>Hold for v1.1</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reply">
|
||||
<div class="label">My reactions — copy & paste back</div>
|
||||
<button class="copybtn" id="copyReply">copy reply</button>
|
||||
<pre id="replyText"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
/* clipboard helper with fallback */
|
||||
function copyText(text, btn) {
|
||||
function flash() {
|
||||
var old = btn.textContent;
|
||||
btn.textContent = "Copied";
|
||||
btn.classList.add("done");
|
||||
setTimeout(function () { btn.textContent = old; btn.classList.remove("done"); }, 1400);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(flash, function () { legacy(); });
|
||||
} else { legacy(); }
|
||||
function legacy() {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); flash(); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
|
||||
/* copy prompt */
|
||||
var promptBtn = document.getElementById("copyPrompt");
|
||||
promptBtn.addEventListener("click", function () {
|
||||
copyText(document.getElementById("promptText").textContent.trim(), promptBtn);
|
||||
});
|
||||
|
||||
/* layout variant segmented control */
|
||||
var seg = document.getElementById("variantSeg");
|
||||
var stagewrap = document.getElementById("stagewrap");
|
||||
var note = document.getElementById("variantNote");
|
||||
var notes = {
|
||||
"v-pill": "<b>Floating pill, bottom-center.</b> Hovers above the seekbar; hides on playback. Closest to what Loom/Frame.io ship.",
|
||||
"v-rail": "<b>Docked left rail.</b> Never occludes footage; tools stack vertically like an editor. Costs the most horizontal room on small screens.",
|
||||
"v-dock": "<b>Attached under the seekbar.</b> Zero occlusion, always visible — but steals ~56px of player height and sits far from where you draw."
|
||||
};
|
||||
Array.prototype.forEach.call(seg.querySelectorAll("button"), function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
Array.prototype.forEach.call(seg.querySelectorAll("button"), function (x) { x.classList.remove("on"); });
|
||||
b.classList.add("on");
|
||||
stagewrap.className = "stagewrap " + b.dataset.v;
|
||||
note.innerHTML = notes[b.dataset.v];
|
||||
});
|
||||
});
|
||||
|
||||
/* single-select helper for button groups */
|
||||
function group(containerId, selector) {
|
||||
var c = document.getElementById(containerId);
|
||||
Array.prototype.forEach.call(c.querySelectorAll(selector), function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
Array.prototype.forEach.call(c.querySelectorAll(selector), function (x) { x.classList.remove("on"); });
|
||||
b.classList.add("on");
|
||||
});
|
||||
});
|
||||
}
|
||||
group("tools", ".tool");
|
||||
group("swatches", ".swatch");
|
||||
group("strokes", ".stroke");
|
||||
|
||||
/* comment drawer */
|
||||
var drawer = document.getElementById("drawer");
|
||||
var cmtBtn = document.getElementById("cmtBtn");
|
||||
function setDrawer(open) {
|
||||
drawer.classList.toggle("open", open);
|
||||
drawer.setAttribute("aria-hidden", String(!open));
|
||||
cmtBtn.setAttribute("aria-expanded", String(open));
|
||||
cmtBtn.classList.toggle("on", open);
|
||||
}
|
||||
cmtBtn.addEventListener("click", function () { setDrawer(!drawer.classList.contains("open")); });
|
||||
document.getElementById("drawerClose").addEventListener("click", function () { setDrawer(false); });
|
||||
|
||||
/* open-question chips → reply template */
|
||||
var answers = { placement: null, colorstroke: null, drawer: null, blur: null };
|
||||
var labels = {
|
||||
placement: "Placement",
|
||||
colorstroke: "Color/stroke controls",
|
||||
drawer: "Comment drawer",
|
||||
blur: "Blur tool"
|
||||
};
|
||||
var replyEl = document.getElementById("replyText");
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
function renderReply() {
|
||||
var n = 0;
|
||||
var html = Object.keys(answers).map(function (k) {
|
||||
n += 1;
|
||||
var v = answers[k];
|
||||
return n + ". " + esc(labels[k]) + ": " +
|
||||
(v ? '<span class="picked">' + esc(v) + "</span>"
|
||||
: '<span class="blank">___ (tap a chip above)</span>');
|
||||
}).join("\n");
|
||||
html += '\nOther notes: <span class="blank">___</span>';
|
||||
replyEl.innerHTML = html;
|
||||
}
|
||||
function replyPlain() {
|
||||
var n = 0;
|
||||
return Object.keys(answers).map(function (k) {
|
||||
n += 1;
|
||||
return n + ". " + labels[k] + ": " + (answers[k] || "___");
|
||||
}).join("\n") + "\nOther notes: ___";
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll(".q"), function (q) {
|
||||
var key = q.dataset.q;
|
||||
Array.prototype.forEach.call(q.querySelectorAll(".chip"), function (chip) {
|
||||
chip.addEventListener("click", function () {
|
||||
Array.prototype.forEach.call(q.querySelectorAll(".chip"), function (x) { x.classList.remove("on"); });
|
||||
chip.classList.add("on");
|
||||
answers[key] = chip.dataset.a;
|
||||
renderReply();
|
||||
});
|
||||
});
|
||||
});
|
||||
renderReply();
|
||||
|
||||
var copyReplyBtn = document.getElementById("copyReply");
|
||||
copyReplyBtn.addEventListener("click", function () {
|
||||
copyText("My reactions to the toolbar mock:\n" + replyPlain(), copyReplyBtn);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,716 @@
|
||||
<!-- 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>Brainstorm the intervention — 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; }
|
||||
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: 980px; margin: 0 auto; padding: 0 24px 160px; }
|
||||
|
||||
/* ── header ─────────────────────────── */
|
||||
header.page-head {
|
||||
padding: 56px 0 8px;
|
||||
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: none;
|
||||
}
|
||||
a.back {
|
||||
position: absolute;
|
||||
top: 58px; right: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--g500);
|
||||
text-decoration: none;
|
||||
}
|
||||
a.back:hover { color: var(--clay); }
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(32px, 4.6vw, 46px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.lede {
|
||||
font-size: 16px;
|
||||
color: var(--g700);
|
||||
max-width: 640px;
|
||||
margin: 0 0 30px;
|
||||
}
|
||||
|
||||
/* ── prompt box ─────────────────────── */
|
||||
.prompt-card {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 22px 24px 24px;
|
||||
position: relative;
|
||||
}
|
||||
.prompt-label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--clay);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.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: 60ch;
|
||||
}
|
||||
button.copy-prompt {
|
||||
position: absolute;
|
||||
top: 16px; right: 16px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--g700);
|
||||
background: var(--g100);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms, color 120ms;
|
||||
}
|
||||
button.copy-prompt:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
button.copy-prompt.copied { color: var(--olive); border-color: var(--olive); }
|
||||
@media (max-width: 560px) {
|
||||
button.copy-prompt { position: static; margin-top: 14px; }
|
||||
}
|
||||
|
||||
/* ── divider ────────────────────────── */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 46px 0 34px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.divider::before, .divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--g300);
|
||||
}
|
||||
|
||||
/* ── artifact frame ─────────────────── */
|
||||
.artifact {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 30px clamp(16px, 3.5vw, 34px) 34px;
|
||||
}
|
||||
.artifact h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(23px, 3vw, 29px);
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.15;
|
||||
margin: 6px 0 6px;
|
||||
}
|
||||
.art-eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.art-eyebrow::before {
|
||||
content: "";
|
||||
width: 24px; height: 1.5px;
|
||||
background: var(--clay);
|
||||
flex: none;
|
||||
}
|
||||
.art-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--g700);
|
||||
max-width: 66ch;
|
||||
margin: 0 0 26px;
|
||||
}
|
||||
.art-sub code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--g100);
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
/* ── spectrum strip ─────────────────── */
|
||||
.spectrum {
|
||||
margin: 8px 0 34px;
|
||||
}
|
||||
.spec-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.spec-labels .lo { color: var(--olive); }
|
||||
.spec-labels .hi { color: var(--clay-d); }
|
||||
.spec-track {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
}
|
||||
.spec-line {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 27px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--olive), var(--oat) 45%, var(--clay));
|
||||
border-radius: 1px;
|
||||
}
|
||||
.spec-dot {
|
||||
position: absolute;
|
||||
top: 27px;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--paper);
|
||||
border: 2px solid var(--g500);
|
||||
color: var(--g700);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 120ms, background 120ms, color 120ms, transform 120ms;
|
||||
}
|
||||
.spec-dot:hover { border-color: var(--slate); transform: translate(-50%, -50%) scale(1.12); }
|
||||
.spec-dot.on {
|
||||
background: var(--clay);
|
||||
border-color: var(--clay-d);
|
||||
color: #fff;
|
||||
}
|
||||
.spec-ticks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
color: var(--g500);
|
||||
margin-top: 2px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.spec-dot { width: 24px; height: 24px; font-size: 10.5px; }
|
||||
.spec-labels { font-size: 9px; }
|
||||
}
|
||||
|
||||
/* ── intervention cards ─────────────── */
|
||||
.cards { display: flex; flex-direction: column; gap: 14px; }
|
||||
.iv {
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 12px;
|
||||
background: var(--ivory);
|
||||
padding: 18px 20px 16px;
|
||||
scroll-margin-top: 20px;
|
||||
transition: border-color 140ms, background 140ms;
|
||||
}
|
||||
.iv.checked { border-color: var(--clay); background: #FDF6F2; }
|
||||
.iv-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.iv-num {
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--clay-d);
|
||||
flex: none;
|
||||
}
|
||||
.iv h3 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 19px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0;
|
||||
flex: 1 1 auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
.chips { display: flex; gap: 8px; align-items: center; flex: none; }
|
||||
.effort {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
.effort.s { background: #EEF2E6; color: var(--olive); border-color: #C9D4B6; }
|
||||
.effort.m { background: var(--oat); color: #7A6A4F; border-color: var(--g300); }
|
||||
.effort.l { background: #F8E7DE; color: var(--clay-d); border-color: #EBC5B4; }
|
||||
.effort.xl { background: var(--clay); color: #fff; border-color: var(--clay-d); }
|
||||
.path {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
color: var(--g700);
|
||||
background: var(--g100);
|
||||
border: 1px solid var(--g200);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
margin: 8px 0 2px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.iv-body { font-size: 14px; color: var(--g700); margin: 8px 0 0; }
|
||||
.iv-body strong { color: var(--slate); font-weight: 600; }
|
||||
.iv-found {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
}
|
||||
.iv-impact {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
margin-top: 10px;
|
||||
font-size: 13.5px;
|
||||
color: var(--g700);
|
||||
}
|
||||
.iv-impact .tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--olive);
|
||||
flex: none;
|
||||
}
|
||||
.iv-foot {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--g300);
|
||||
}
|
||||
label.resonate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--g700);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
label.resonate input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 18px; height: 18px;
|
||||
border: 1.5px solid var(--g500);
|
||||
border-radius: 5px;
|
||||
background: var(--paper);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
label.resonate input::before {
|
||||
content: "";
|
||||
width: 9px; height: 9px;
|
||||
transform: scale(0);
|
||||
transition: transform 100ms;
|
||||
clip-path: polygon(14% 44%, 0 60%, 40% 100%, 100% 16%, 84% 4%, 38% 71%);
|
||||
background: #fff;
|
||||
}
|
||||
label.resonate input:checked { background: var(--clay); border-color: var(--clay-d); }
|
||||
label.resonate input:checked::before { transform: scale(1.15); }
|
||||
.iv.checked label.resonate { color: var(--clay-d); }
|
||||
|
||||
/* ── sticky reply bar ───────────────── */
|
||||
.replybar {
|
||||
position: fixed;
|
||||
left: 0; right: 0; bottom: 0;
|
||||
background: var(--slate);
|
||||
color: var(--ivory);
|
||||
z-index: 50;
|
||||
box-shadow: 0 -8px 24px rgba(20,20,19,.15);
|
||||
}
|
||||
.replybar-inner {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.rb-count {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--oat);
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rb-count b { color: var(--clay); font-size: 15px; }
|
||||
.rb-reply {
|
||||
flex: 1;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
color: var(--g300);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.rb-reply.live { color: var(--ivory); }
|
||||
button.rb-copy {
|
||||
flex: none;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
background: var(--clay);
|
||||
color: #fff;
|
||||
border: 1.5px solid var(--clay-d);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, opacity 120ms;
|
||||
}
|
||||
button.rb-copy:hover:not(:disabled) { background: var(--clay-d); }
|
||||
button.rb-copy:disabled { opacity: .35; cursor: default; }
|
||||
button.rb-copy.copied { background: var(--olive); border-color: var(--olive); }
|
||||
@media (max-width: 640px) {
|
||||
.replybar-inner { flex-wrap: wrap; gap: 8px; padding: 10px 16px; }
|
||||
.rb-reply { order: 3; flex-basis: 100%; }
|
||||
}
|
||||
|
||||
/* ── footer ─────────────────────────── */
|
||||
footer {
|
||||
margin-top: 56px;
|
||||
font-size: 13px;
|
||||
color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
footer 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>Brainstorm the intervention</h1>
|
||||
<p class="lede">Before committing to a fix, ask for the whole option space — grounded in what actually exists in the code — so the choice you make is informed by alternatives you didn't know you had.</p>
|
||||
|
||||
<div class="prompt-card">
|
||||
<div class="prompt-label">The prompt</div>
|
||||
<blockquote>“Here's my rough problem: Acme users churn after onboarding. Search the codebase and brainstorm 10 places we could intervene, from cheapest to most ambitious. I'll tell you which ones resonate.”</blockquote>
|
||||
<button class="copy-prompt" id="copyPrompt" type="button">copy prompt</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<div class="artifact">
|
||||
<div class="art-eyebrow">Acme · post-onboarding churn</div>
|
||||
<h2>10 places to intervene, cheapest to most ambitious</h2>
|
||||
<p class="art-sub">I searched <code>apps/web</code>, <code>services/</code>, and <code>packages/</code> for onboarding, invite, and first-session code paths. A pattern showed up: <strong>a lot of retention machinery already exists but is disconnected</strong> — the cheapest options below are mostly wiring, not building. Check the ones that resonate and copy the reply at the bottom.</p>
|
||||
|
||||
<div class="spectrum">
|
||||
<div class="spec-labels">
|
||||
<span class="lo">Ship this afternoon</span>
|
||||
<span class="hi">Quarter-long bet</span>
|
||||
</div>
|
||||
<div class="spec-track" id="specTrack">
|
||||
<div class="spec-line"></div>
|
||||
<button class="spec-dot" style="left:3%" data-n="1" type="button" title="Fix the dead-end empty state">1</button>
|
||||
<button class="spec-dot" style="left:10%" data-n="2" type="button" title="Turn on the sample project">2</button>
|
||||
<button class="spec-dot" style="left:19%" data-n="3" type="button" title="Show pending invites in-app">3</button>
|
||||
<button class="spec-dot" style="left:29%" data-n="4" type="button" title="Wire up the milestones checklist">4</button>
|
||||
<button class="spec-dot" style="left:39%" data-n="5" type="button" title="Engineer the first-comment moment">5</button>
|
||||
<button class="spec-dot" style="left:49%" data-n="6" type="button" title="Rescue silently failed first uploads">6</button>
|
||||
<button class="spec-dot" style="left:60%" data-n="7" type="button" title="Watch-together first session">7</button>
|
||||
<button class="spec-dot" style="left:71%" data-n="8" type="button" title="Review workflow templates">8</button>
|
||||
<button class="spec-dot" style="left:84%" data-n="9" type="button" title="Built-in quick screen recording">9</button>
|
||||
<button class="spec-dot" style="left:96%" data-n="10" type="button" title="First-class client review portals">10</button>
|
||||
</div>
|
||||
<div class="spec-ticks"><span>wiring</span><span>new UI</span><span>new lifecycle</span><span>new surface</span></div>
|
||||
</div>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="iv" id="iv1">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#1</span>
|
||||
<h3>Fix the dead-end empty state</h3>
|
||||
<div class="chips"><span class="effort s">S</span></div>
|
||||
</div>
|
||||
<span class="path">apps/web/src/onboarding/EmptyState.tsx</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> The zero-projects dashboard renders the illustration and the copy <em>“No projects yet”</em> — and nothing else. <code>NewProjectButton</code> exists two directories over but was never imported here; the only way forward is the tiny “+” in the top nav. Every churned user's last screen was probably this one.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>100% of new users hit this screen; a real CTA plus a “drop a video anywhere” hint removes the most literal dead end in the product.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="1"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv2">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#2</span>
|
||||
<h3>Turn on the sample project that already exists</h3>
|
||||
<div class="chips"><span class="effort s">S</span></div>
|
||||
</div>
|
||||
<span class="path">apps/web/src/projects/sampleProject.ts</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Someone built a complete seeded demo project — “Product teaser v3” with a 45s clip, four timestamped comments, and one resolved thread — behind the flag <code>sample_project_v1</code>. The flag is still <strong>false for everyone</strong> and the file hasn't been touched in 8 months. The feature is done; it was just never shipped.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>New users see what a live review looks like before they've uploaded anything. Cheapest possible “aha” — flip a flag, verify the seed script.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="2"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv3">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#3</span>
|
||||
<h3>Show pending invites inside the app</h3>
|
||||
<div class="chips"><span class="effort s">S</span></div>
|
||||
</div>
|
||||
<span class="path">services/mailer/templates/invite.mjml · apps/web/src/team/TeamPanel.tsx</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Invites go out as email only. <code>TeamPanel</code> queries <code>team_members</code> but ignores the <code>invites</code> table entirely — an inviter can't see that their teammate never accepted, and there's no resend. For a <strong>collaborative</strong> review tool, a solo user is a churn-in-waiting.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>Surfacing “2 invites pending · resend” turns invisible invite decay into a visible, fixable state.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="3"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv4">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#4</span>
|
||||
<h3>Wire the milestones table to a dashboard checklist</h3>
|
||||
<div class="chips"><span class="effort m">M</span></div>
|
||||
</div>
|
||||
<span class="path">services/api/src/models/userMilestones.ts · apps/web/src/dashboard/Dashboard.tsx</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> The backend already records <code>first_upload</code>, <code>first_comment</code>, <code>first_invite_accepted</code>, and <code>first_approval</code> in <code>user_milestones</code> — but <strong>no client code reads the table</strong>. It exists purely for an analytics dashboard. All the ingredients for a “3 of 4 steps done” checklist are sitting there.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>Checklists reliably lift activation; this one needs one endpoint and one component because tracking already works.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="4"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv5">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#5</span>
|
||||
<h3>Engineer the first-comment moment</h3>
|
||||
<div class="chips"><span class="effort m">M</span></div>
|
||||
</div>
|
||||
<span class="path">apps/web/src/review/CommentComposer.tsx · apps/web/src/review/Timeline.tsx</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Timestamped comments are Acme's core loop, but the affordance is hidden: clicking the timeline seeds the timestamp into the composer, and nothing teaches this. <code>Timeline.tsx</code> has a <code>TODO: first-run hint?</code> from the original author. Add a ghost pin on first play — “click anywhere to comment at this moment”.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>If first-session comment rate is the activation metric (it should be), this is the most direct lever on it.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="5"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv6">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#6</span>
|
||||
<h3>Rescue silently failed first uploads</h3>
|
||||
<div class="chips"><span class="effort m">M</span></div>
|
||||
</div>
|
||||
<span class="path">services/ingest/src/transcode/queue.ts</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> When transcode fails (unsupported codec, oversized ProRes, corrupt moov atom), the job moves to <code>failed</code>, logs to Sentry, and <strong>tells the user nothing</strong> — the project card just shows a spinner forever. There's a retry-with-fallback-preset code path that's only reachable from an internal admin script.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>A user whose first upload hangs forever doesn't file a ticket — they leave. Auto-retry plus an honest error email converts a silent churn path into a support path.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="6"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv7">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#7</span>
|
||||
<h3>Watch-together first session for teams</h3>
|
||||
<div class="chips"><span class="effort l">L</span></div>
|
||||
</div>
|
||||
<span class="path">apps/web/src/review/PresenceProvider.tsx · services/realtime/</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Live presence is fully built — shared playhead, live cursors, “Maya is watching” badges — but it only lights up if two people <em>happen</em> to open the same review. Nothing engineers that coincidence. Add a “review together” scheduling nudge when a teammate accepts an invite, deep-linking both into the same session.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>Teams that experience one live session likely retain at a different rate than solo users; this makes the multiplayer moment deliberate instead of lucky.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="7"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv8">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#8</span>
|
||||
<h3>Ship five review-workflow templates</h3>
|
||||
<div class="chips"><span class="effort l">L</span></div>
|
||||
</div>
|
||||
<span class="path">packages/workflows/src/definitions/</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Approval workflows (rough cut → client review → final sign-off) are fully configurable — and start <strong>completely blank</strong>. The definitions directory contains only <code>__fixtures__</code> for tests. Judging by a comment in <code>WorkflowEditor.tsx</code>, power users copy a shared Notion doc to set theirs up. Ship 5 opinionated templates: agency delivery, YouTube pipeline, client sign-off, internal QA, localization pass.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>Templates convert “configure this abstraction” into “pick your job” — the classic fix for blank-canvas churn in workflow tools.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="8"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv9">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#9</span>
|
||||
<h3>Built-in quick screen & camera recording</h3>
|
||||
<div class="chips"><span class="effort xl">XL</span></div>
|
||||
</div>
|
||||
<span class="path">new surface · services/ingest already accepts chunked webm</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Today Acme's onboarding has a hard external dependency: <strong>you need a finished video before the product is useful</strong>. But <code>services/ingest</code> already accepts chunked webm uploads (built for the mobile app), so a MediaRecorder-based “record your screen, get feedback” flow needs no pipeline work — “just” the capture UI. Removes the biggest reason a trial goes idle: nothing to review yet.</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>Changes who can activate — designers and PMs without an editor can create the first reviewable artifact themselves. Big build, big surface area.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="9"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
<div class="iv" id="iv10">
|
||||
<div class="iv-top">
|
||||
<span class="iv-num">#10</span>
|
||||
<h3>First-class client review portals</h3>
|
||||
<div class="chips"><span class="effort xl">XL</span></div>
|
||||
</div>
|
||||
<span class="path">services/auth/src/guestTokens.ts · apps/web/src/share/</span>
|
||||
<p class="iv-body"><span class="iv-found">Found in code —</span> Guest share links exist but are deliberately crippled: tokens expire after 24h, guests can't comment, and there's a <code>// TODO(sasha): revisit guest perms after launch</code> from two years ago. The users who churn are often the ones whose <em>clients</em> wouldn't sign up. Make guests first-class — persistent portal, comment-with-name, no account — and every external review becomes a retention loop (and an acquisition one).</p>
|
||||
<div class="iv-impact"><span class="tag">Impact</span>Attacks the structural cause: Acme's value depends on people who refuse to create accounts. Quarter-long bet with auth, billing, and abuse-prevention implications.</div>
|
||||
<div class="iv-foot"><label class="resonate"><input type="checkbox" data-n="10"> This resonates</label></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<div class="replybar">
|
||||
<div class="replybar-inner">
|
||||
<span class="rb-count"><b id="rbNum">0</b> resonate</span>
|
||||
<span class="rb-reply" id="rbReply">Check the interventions that resonate — your reply builds here.</span>
|
||||
<button class="rb-copy" id="rbCopy" type="button" disabled>copy reply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var PROMPT = "Here's my rough problem: Acme users churn after onboarding. Search the codebase and brainstorm 10 places we could intervene, from cheapest to most ambitious. I'll tell you which ones resonate.";
|
||||
|
||||
function copyText(text, btn, label, doneLabel) {
|
||||
function done() {
|
||||
btn.classList.add('copied');
|
||||
btn.textContent = doneLabel;
|
||||
setTimeout(function () {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = label;
|
||||
}, 1600);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { fallback(); });
|
||||
} else { fallback(); }
|
||||
function fallback() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
var copyPromptBtn = document.getElementById('copyPrompt');
|
||||
copyPromptBtn.addEventListener('click', function () {
|
||||
copyText(PROMPT, copyPromptBtn, 'copy prompt', 'copied');
|
||||
});
|
||||
|
||||
var boxes = Array.prototype.slice.call(document.querySelectorAll('label.resonate input'));
|
||||
var dots = {};
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.spec-dot'), function (d) {
|
||||
dots[d.getAttribute('data-n')] = d;
|
||||
d.addEventListener('click', function () {
|
||||
var card = document.getElementById('iv' + d.getAttribute('data-n'));
|
||||
if (card) card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
});
|
||||
|
||||
var rbNum = document.getElementById('rbNum');
|
||||
var rbReply = document.getElementById('rbReply');
|
||||
var rbCopy = document.getElementById('rbCopy');
|
||||
var currentReply = '';
|
||||
|
||||
function update() {
|
||||
var picked = boxes
|
||||
.filter(function (b) { return b.checked; })
|
||||
.map(function (b) { return parseInt(b.getAttribute('data-n'), 10); })
|
||||
.sort(function (a, b) { return a - b; });
|
||||
|
||||
boxes.forEach(function (b) {
|
||||
var n = b.getAttribute('data-n');
|
||||
var card = document.getElementById('iv' + n);
|
||||
if (card) card.classList.toggle('checked', b.checked);
|
||||
if (dots[n]) dots[n].classList.toggle('on', b.checked);
|
||||
});
|
||||
|
||||
rbNum.textContent = picked.length;
|
||||
if (picked.length === 0) {
|
||||
currentReply = '';
|
||||
rbReply.textContent = 'Check the interventions that resonate — your reply builds here.';
|
||||
rbReply.classList.remove('live');
|
||||
rbCopy.disabled = true;
|
||||
} else {
|
||||
var list = picked.map(function (n) { return '#' + n; }).join(', ');
|
||||
currentReply = 'These resonate: ' + list + ' — start with #' + picked[0];
|
||||
rbReply.textContent = '“' + currentReply + '”';
|
||||
rbReply.classList.add('live');
|
||||
rbCopy.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
boxes.forEach(function (b) { b.addEventListener('change', update); });
|
||||
rbCopy.addEventListener('click', function () {
|
||||
if (currentReply) copyText(currentReply, rbCopy, 'copy reply', 'copied');
|
||||
});
|
||||
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,644 @@
|
||||
<!-- 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 interview — 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; }
|
||||
|
||||
/* ── header ─────────────────────────── */
|
||||
header { padding: 56px 0 8px; 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: none; }
|
||||
.backlink {
|
||||
position: absolute; top: 60px; right: 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
color: var(--g500); text-decoration: none;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.backlink:hover { color: var(--clay); }
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(34px, 5vw, 50px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; margin: 0 0 30px; }
|
||||
|
||||
/* ── prompt box ─────────────────────── */
|
||||
.promptbox {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 22px 24px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.promptbox .label {
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--clay); margin-bottom: 10px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
}
|
||||
.promptbox .label::before { content: ""; width: 24px; height: 1.5px; background: var(--clay); flex: none; }
|
||||
.promptbox blockquote {
|
||||
margin: 0; padding: 0;
|
||||
font-family: var(--serif); font-style: italic;
|
||||
font-size: 17.5px; line-height: 1.5; color: var(--slate);
|
||||
max-width: 46em;
|
||||
}
|
||||
.copybtn {
|
||||
position: absolute; top: 18px; right: 18px;
|
||||
font-family: var(--mono); font-size: 11.5px;
|
||||
letter-spacing: 0.04em;
|
||||
background: var(--g100); color: var(--g700);
|
||||
border: 1.5px solid var(--g300); border-radius: 7px;
|
||||
padding: 5px 11px; cursor: pointer;
|
||||
}
|
||||
.copybtn:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
.copybtn.done { background: var(--olive); border-color: var(--olive); color: #fff; }
|
||||
|
||||
/* ── divider ────────────────────────── */
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
margin: 44px 0 30px;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--g500); white-space: nowrap;
|
||||
}
|
||||
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--g300); }
|
||||
|
||||
/* ══ THE ARTIFACT ═════════════════════ */
|
||||
.artifact {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.art-head {
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1.5px solid var(--g200);
|
||||
display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
|
||||
background: var(--g100);
|
||||
}
|
||||
.art-head .t { font-family: var(--serif); font-weight: 500; font-size: 17px; letter-spacing: -0.01em; }
|
||||
.art-head .s { font-family: var(--mono); font-size: 11.5px; color: var(--g500); }
|
||||
|
||||
.art-body { display: grid; grid-template-columns: 216px 1fr; min-height: 480px; }
|
||||
@media (max-width: 720px) { .art-body { grid-template-columns: 1fr; min-height: 0; } }
|
||||
|
||||
/* rail */
|
||||
.rail {
|
||||
border-right: 1.5px solid var(--g200);
|
||||
padding: 20px 16px;
|
||||
background: var(--ivory);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.rail { border-right: none; border-bottom: 1.5px solid var(--g200); padding: 14px 16px; }
|
||||
}
|
||||
.rail .rail-label {
|
||||
font-family: var(--mono); font-size: 10.5px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--g500); margin-bottom: 12px;
|
||||
}
|
||||
.rail-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 6px 8px; margin: 0 -8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12.5px; color: var(--g500);
|
||||
cursor: default;
|
||||
border: none; background: none; width: calc(100% + 16px);
|
||||
text-align: left; font-family: var(--sans);
|
||||
}
|
||||
.rail-item.done { color: var(--g700); cursor: pointer; }
|
||||
.rail-item.done:hover { background: var(--g100); }
|
||||
.rail-item.current { color: var(--slate); font-weight: 600; background: var(--g100); }
|
||||
.rail-dot {
|
||||
width: 11px; height: 11px; border-radius: 50%; flex: none;
|
||||
border: 2px solid var(--dotc, var(--g300));
|
||||
background: transparent;
|
||||
}
|
||||
.rail-item.done .rail-dot { background: var(--dotc); }
|
||||
.rail-item.current .rail-dot { box-shadow: 0 0 0 3px var(--g200); }
|
||||
.rail-item .rail-q { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-item .tick { margin-left: auto; font-size: 11px; color: var(--olive); flex: none; }
|
||||
.rail-meta {
|
||||
margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--g200);
|
||||
font-family: var(--mono); font-size: 11px; color: var(--g500); line-height: 1.8;
|
||||
}
|
||||
.rail-meta b { color: var(--g700); font-weight: 600; }
|
||||
@media (max-width: 720px) {
|
||||
.rail .rail-q, .rail .tick, .rail-meta, .rail .rail-label { display: none; }
|
||||
.rail { display: flex; align-items: center; gap: 10px; }
|
||||
.rail-item { width: auto; padding: 4px; margin: 0; }
|
||||
}
|
||||
|
||||
/* stage */
|
||||
.stage { padding: 28px 30px 30px; position: relative; }
|
||||
@media (max-width: 720px) { .stage { padding: 22px 18px 24px; } }
|
||||
.qcard { animation: qin .28s ease both; }
|
||||
.qcard.leaving { animation: qout .18s ease both; }
|
||||
@keyframes qin { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes qout { from { opacity: 1; } to { opacity: 0; transform: translateY(-8px); } }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 3px 9px; border-radius: 5px;
|
||||
color: #fff; background: var(--g500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.qnum { font-family: var(--mono); font-size: 11.5px; color: var(--g500); margin-left: 10px; }
|
||||
.qtext {
|
||||
font-family: var(--serif); font-weight: 500;
|
||||
font-size: clamp(20px, 3vw, 25px); line-height: 1.25;
|
||||
letter-spacing: -0.012em;
|
||||
margin: 10px 0 6px; max-width: 34em;
|
||||
}
|
||||
.why {
|
||||
font-size: 13.5px; color: var(--g500); margin: 0 0 22px;
|
||||
display: flex; gap: 8px; align-items: baseline;
|
||||
}
|
||||
.why::before { content: "↳"; color: var(--clay); flex: none; }
|
||||
|
||||
.opts { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 620px) { .opts { grid-template-columns: 1fr; } }
|
||||
.opt {
|
||||
text-align: left;
|
||||
background: var(--ivory);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 11px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
color: var(--slate);
|
||||
transition: border-color .12s, background .12s, transform .12s;
|
||||
}
|
||||
.opt:hover { border-color: var(--clay); background: var(--paper); transform: translateY(-1px); }
|
||||
.opt.picked { border-color: var(--clay); background: var(--paper); box-shadow: 0 0 0 3px rgba(217,119,87,.15); }
|
||||
.opt .ot { font-weight: 600; font-size: 14.5px; display: block; margin-bottom: 3px; }
|
||||
.opt .od { font-size: 12.5px; color: var(--g500); line-height: 1.45; display: block; }
|
||||
|
||||
.otherrow { display: flex; gap: 8px; margin-top: 12px; }
|
||||
.otherrow input {
|
||||
flex: 1; min-width: 0;
|
||||
font-family: var(--sans); font-size: 14px;
|
||||
padding: 10px 13px;
|
||||
border: 1.5px solid var(--g300); border-radius: 9px;
|
||||
background: var(--ivory); color: var(--slate);
|
||||
}
|
||||
.otherrow input::placeholder { color: var(--g500); }
|
||||
.otherrow input:focus { outline: none; border-color: var(--clay); background: var(--paper); }
|
||||
.otherrow button {
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
padding: 0 16px; border-radius: 9px; cursor: pointer;
|
||||
border: 1.5px solid var(--g300); background: var(--g100); color: var(--g700);
|
||||
}
|
||||
.otherrow button:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
|
||||
.navrow { display: flex; justify-content: space-between; align-items: center; margin-top: 24px; }
|
||||
.backbtn {
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--g500); padding: 6px 0;
|
||||
}
|
||||
.backbtn:hover { color: var(--clay-d); }
|
||||
.backbtn[disabled] { visibility: hidden; }
|
||||
.navhint { font-family: var(--mono); font-size: 11px; color: var(--g500); }
|
||||
|
||||
/* summary */
|
||||
.sum-h { font-family: var(--serif); font-weight: 500; font-size: 24px; letter-spacing: -0.012em; margin: 4px 0 4px; }
|
||||
.sum-sub { font-size: 14px; color: var(--g500); margin: 0 0 20px; }
|
||||
.dtable { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.dtable-scroll { overflow-x: auto; }
|
||||
.dtable th {
|
||||
font-family: var(--mono); font-size: 10.5px;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--g500); font-weight: 500;
|
||||
text-align: left; padding: 8px 12px 8px 0;
|
||||
border-bottom: 1.5px solid var(--g300);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dtable td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--g200); vertical-align: top; }
|
||||
.dtable tr:last-child td { border-bottom: none; }
|
||||
.dtable .pill {
|
||||
font-family: var(--mono); font-size: 10px;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: #fff; padding: 2px 7px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
.dtable .topic { color: var(--g500); white-space: nowrap; }
|
||||
.dtable .dec { font-weight: 600; }
|
||||
.dtable .redo {
|
||||
font-family: var(--mono); font-size: 11px; color: var(--g500);
|
||||
background: none; border: none; cursor: pointer; padding: 0;
|
||||
}
|
||||
.dtable .redo:hover { color: var(--clay-d); }
|
||||
|
||||
.genblock { margin-top: 26px; }
|
||||
.genblock .gb-label {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--clay); margin-bottom: 8px;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.genblock .gb-label::before { content: ""; width: 18px; height: 1.5px; background: var(--clay); flex: none; }
|
||||
.genwrap { position: relative; }
|
||||
.genwrap pre {
|
||||
margin: 0;
|
||||
background: var(--slate);
|
||||
color: var(--g200);
|
||||
border-radius: 11px;
|
||||
padding: 18px 20px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.genwrap .copybtn { top: 12px; right: 12px; background: var(--g700); border-color: var(--g700); color: var(--g300); }
|
||||
.genwrap .copybtn:hover { color: var(--paper); border-color: var(--g500); }
|
||||
.genwrap .copybtn.done { background: var(--olive); border-color: var(--olive); color: #fff; }
|
||||
.restart {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--g500); padding: 0;
|
||||
}
|
||||
.restart:hover { color: var(--clay-d); }
|
||||
|
||||
footer {
|
||||
margin-top: 56px; padding-top: 22px;
|
||||
border-top: 1px solid var(--g300);
|
||||
font-size: 13px; color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
footer a:hover { text-decoration-color: var(--clay); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Know your unknowns · Pre-implementation</div>
|
||||
<a class="backlink" href="index.html">← All examples</a>
|
||||
<h1>The interview</h1>
|
||||
<p class="lede">Instead of guessing at ambiguous requirements, ask Claude to interview you — one question at a time, ordered by how much each answer would change the architecture.</p>
|
||||
|
||||
<div class="promptbox">
|
||||
<div class="label">The prompt</div>
|
||||
<button class="copybtn" id="copyPrompt">copy prompt</button>
|
||||
<blockquote id="promptText">Interview me one question at a time about anything still ambiguous in the annotation-export feature. Prioritize questions where my answer would change the architecture.</blockquote>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<div class="artifact">
|
||||
<div class="art-head">
|
||||
<span class="t">Annotation export — spec interview</span>
|
||||
<span class="s">acme/web · 7 open questions · ordered by blast radius</span>
|
||||
</div>
|
||||
<div class="art-body">
|
||||
<nav class="rail" id="rail" aria-label="Interview progress"></nav>
|
||||
<div class="stage" id="stage"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var RADII = {
|
||||
arch: { label: "Architecture", color: "var(--clay)" },
|
||||
data: { label: "Data model", color: "var(--clay-d)" },
|
||||
ux: { label: "UX", color: "var(--olive)" },
|
||||
pol: { label: "Polish", color: "var(--g500)" }
|
||||
};
|
||||
|
||||
var QUESTIONS = [
|
||||
{
|
||||
id: "runtime",
|
||||
radius: "arch",
|
||||
short: "Where exports run",
|
||||
topic: "Execution model",
|
||||
q: "Where should export generation actually run?",
|
||||
why: "This decides whether we need a job queue, a notification path, and an artifacts bucket — or none of those. Everything else layers on top.",
|
||||
opts: [
|
||||
{ t: "Synchronously in the API request", d: "Simple, but a 400-comment review on a 2-hour cut will blow past the 30s gateway timeout." },
|
||||
{ t: "Background job via the existing sidekiq queue", d: "Reuses jobs/export_worker patterns from proxy transcodes; needs a “ready” notification." },
|
||||
{ t: "Entirely client-side from the annotations API", d: "Zero new backend, but caps us at formats the browser can assemble, and leaks nothing to support later." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "scope",
|
||||
radius: "arch",
|
||||
short: "Export scope",
|
||||
topic: "Scope of one export",
|
||||
q: "What is the unit of a single export?",
|
||||
why: "Project-level export means fan-out across videos, pagination, and a manifest format — roughly 3× the surface of per-video.",
|
||||
opts: [
|
||||
{ t: "One video at a time", d: "Matches the review-page mental model; a project export is just N clicks for now." },
|
||||
{ t: "A whole project (all videos)", d: "What the Meridian Post account asked for; implies zip bundling and per-video manifests." },
|
||||
{ t: "Arbitrary user-picked selection", d: "Most flexible, most UI; needs a selection model that doesn’t exist anywhere yet." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "drawings",
|
||||
radius: "data",
|
||||
short: "Drawings included?",
|
||||
topic: "Drawing annotations",
|
||||
q: "Do frame drawings export, or only text comments?",
|
||||
why: "Drawings live as canvas stroke JSON in annotation_shapes; including them means rasterizing server-side or shipping raw vectors.",
|
||||
opts: [
|
||||
{ t: "Text comments only (v1)", d: "Ships fastest; drawings show as “[drawing on frame 4211]” placeholders." },
|
||||
{ t: "Rasterize drawings to PNG stills", d: "Best for PDF reports; needs a headless render step — the thumbnail worker is close but not identical." },
|
||||
{ t: "Include raw stroke JSON", d: "Lossless and cheap, but only useful to someone re-importing into Acme." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "timecode",
|
||||
radius: "data",
|
||||
short: "Timestamp format",
|
||||
topic: "Timestamp representation",
|
||||
q: "How should annotation timestamps be represented in the export?",
|
||||
why: "We store milliseconds; editors live in SMPTE timecode. Converting needs the source fps, which is null for 3% of legacy uploads.",
|
||||
opts: [
|
||||
{ t: "SMPTE timecode (HH:MM:SS:FF)", d: "What Resolve and Premiere expect; requires an fps backfill for legacy videos." },
|
||||
{ t: "Plain seconds / milliseconds", d: "No conversion risk, but every editor will hand-convert and some will do it wrong." },
|
||||
{ t: "Both columns, always", d: "Slightly wider files; nobody ever has to ask which one is authoritative." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "formats",
|
||||
radius: "ux",
|
||||
short: "v1 formats",
|
||||
topic: "Export formats",
|
||||
q: "Which formats does v1 ship with?",
|
||||
why: "Each format is mostly independent work, but the choice signals who the feature is for — producers, editors, or clients.",
|
||||
opts: [
|
||||
{ t: "CSV only", d: "One afternoon of work once the pipeline exists; producers can pivot it however they like." },
|
||||
{ t: "PDF review report", d: "Client-facing artifact with thumbnails; only meaningful if drawings rasterize." },
|
||||
{ t: "NLE marker file (EDL / Resolve CSV)", d: "The power-user ask from the forum thread; timecode question becomes load-bearing." },
|
||||
{ t: "CSV + NLE markers, PDF later", d: "Covers both internal and editor workflows without blocking on rasterization." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "access",
|
||||
radius: "ux",
|
||||
short: "Who can export",
|
||||
topic: "Export permissions",
|
||||
q: "Who is allowed to export?",
|
||||
why: "Guest reviewers use unauthenticated share links — if they can export, annotations silently leave the permission boundary.",
|
||||
opts: [
|
||||
{ t: "Anyone who can view the video", d: "Includes share-link guests; simplest rule, biggest data-egress surface." },
|
||||
{ t: "Project members only", d: "Guests keep commenting but can’t bulk-extract; needs a disabled-state explanation in the UI." },
|
||||
{ t: "Owner and admins only", d: "Most conservative; likely generates “can you export this for me” busywork." }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "naming",
|
||||
radius: "pol",
|
||||
short: "Files & naming",
|
||||
topic: "File naming & bundling",
|
||||
q: "How are export files named and bundled?",
|
||||
why: "Pure convention, but the first support ticket after launch is always “which file is which cut.”",
|
||||
opts: [
|
||||
{ t: "{project}_{video}_{date}.csv, flat", d: "Predictable and grep-able; long titles get truncated at 80 chars." },
|
||||
{ t: "Zip per export with a manifest.json", d: "One artifact per export regardless of scope; slight friction for single-file cases." },
|
||||
{ t: "Let the user pick at export time", d: "A settings surface for something almost nobody will change." }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
var answers = new Array(QUESTIONS.length).fill(null); // {text, custom}
|
||||
var current = 0; // index, or QUESTIONS.length => summary
|
||||
var rail = document.getElementById("rail");
|
||||
var stage = document.getElementById("stage");
|
||||
|
||||
function answeredCount() {
|
||||
return answers.filter(function (a) { return a !== null; }).length;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderRail() {
|
||||
var html = '<div class="rail-label">Interview progress</div>';
|
||||
QUESTIONS.forEach(function (q, i) {
|
||||
var r = RADII[q.radius];
|
||||
var cls = "rail-item" + (i === current ? " current" : "") + (answers[i] ? " done" : "");
|
||||
html += '<button type="button" class="' + cls + '" data-i="' + i + '" style="--dotc:' + r.color + '"' +
|
||||
(answers[i] ? '' : ' tabindex="-1"') + '>' +
|
||||
'<span class="rail-dot"></span><span class="rail-q">' + esc(q.short) + '</span>' +
|
||||
(answers[i] ? '<span class="tick">✓</span>' : '') + '</button>';
|
||||
});
|
||||
html += '<div class="rail-meta"><b>' + answeredCount() + '</b> of 7 answered<br>' +
|
||||
'<span style="color:var(--clay)">●</span> architecture ' +
|
||||
'<span style="color:var(--clay-d)">●</span> data<br>' +
|
||||
'<span style="color:var(--olive)">●</span> ux ' +
|
||||
'<span style="color:var(--g500)">●</span> polish</div>';
|
||||
rail.innerHTML = html;
|
||||
rail.querySelectorAll(".rail-item.done").forEach(function (el) {
|
||||
el.addEventListener("click", function () { go(parseInt(el.dataset.i, 10)); });
|
||||
});
|
||||
}
|
||||
|
||||
function renderQuestion() {
|
||||
var q = QUESTIONS[current];
|
||||
var r = RADII[q.radius];
|
||||
var picked = answers[current];
|
||||
var html = '<div class="qcard">' +
|
||||
'<span class="badge" style="background:' + r.color + '">' + esc(r.label) + '</span>' +
|
||||
'<span class="qnum">Question ' + (current + 1) + ' of ' + QUESTIONS.length + '</span>' +
|
||||
'<h2 class="qtext">' + esc(q.q) + '</h2>' +
|
||||
'<p class="why">' + esc(q.why) + '</p>' +
|
||||
'<div class="opts">';
|
||||
q.opts.forEach(function (o, oi) {
|
||||
var isPicked = picked && !picked.custom && picked.text === o.t;
|
||||
html += '<button type="button" class="opt' + (isPicked ? ' picked' : '') + '" data-oi="' + oi + '">' +
|
||||
'<span class="ot">' + esc(o.t) + '</span><span class="od">' + esc(o.d) + '</span></button>';
|
||||
});
|
||||
html += '</div>' +
|
||||
'<div class="otherrow">' +
|
||||
'<input type="text" id="otherInput" placeholder="Other — type your own answer…" value="' +
|
||||
(picked && picked.custom ? esc(picked.text) : '') + '">' +
|
||||
'<button type="button" id="otherGo">answer ↵</button></div>' +
|
||||
'<div class="navrow">' +
|
||||
'<button type="button" class="backbtn" id="backBtn"' + (current === 0 ? ' disabled' : '') + '>← previous question</button>' +
|
||||
'<span class="navhint">pick a card to continue</span>' +
|
||||
'</div></div>';
|
||||
stage.innerHTML = html;
|
||||
|
||||
stage.querySelectorAll(".opt").forEach(function (el) {
|
||||
el.addEventListener("click", function () {
|
||||
var o = q.opts[parseInt(el.dataset.oi, 10)];
|
||||
answers[current] = { text: o.t, custom: false };
|
||||
el.classList.add("picked");
|
||||
renderRail();
|
||||
setTimeout(function () { go(current + 1); }, 240);
|
||||
});
|
||||
});
|
||||
var input = stage.querySelector("#otherInput");
|
||||
function submitOther() {
|
||||
var v = input.value.trim();
|
||||
if (!v) { input.focus(); return; }
|
||||
answers[current] = { text: v, custom: true };
|
||||
renderRail();
|
||||
go(current + 1);
|
||||
}
|
||||
stage.querySelector("#otherGo").addEventListener("click", submitOther);
|
||||
input.addEventListener("keydown", function (e) { if (e.key === "Enter") submitOther(); });
|
||||
stage.querySelector("#backBtn").addEventListener("click", function () { go(current - 1); });
|
||||
}
|
||||
|
||||
function buildFollowupPrompt() {
|
||||
var lines = [
|
||||
"Here's what we decided in the interview — implement the",
|
||||
"annotation-export feature with these constraints:",
|
||||
""
|
||||
];
|
||||
QUESTIONS.forEach(function (q, i) {
|
||||
var a = answers[i];
|
||||
lines.push((i + 1) + ". " + q.topic + ": " + (a ? a.text : "(unanswered — use your judgment)") +
|
||||
(a && a.custom ? " [my own wording — ask if unclear]" : ""));
|
||||
});
|
||||
lines.push("");
|
||||
lines.push("Treat the architecture and data-model decisions (1–4) as fixed.");
|
||||
lines.push("If anything in 5–7 conflicts with them, flag it before writing code.");
|
||||
lines.push("Start with a short plan, then implement.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderSummary() {
|
||||
var rows = "";
|
||||
QUESTIONS.forEach(function (q, i) {
|
||||
var r = RADII[q.radius];
|
||||
var a = answers[i];
|
||||
rows += "<tr>" +
|
||||
'<td><span class="pill" style="background:' + r.color + '">' + esc(r.label) + '</span></td>' +
|
||||
'<td class="topic">' + esc(q.topic) + '</td>' +
|
||||
'<td class="dec">' + (a ? esc(a.text) + (a.custom ? ' <span style="font-weight:400;color:var(--g500)">(your wording)</span>' : '') : '<span style="color:var(--g500)">—</span>') + '</td>' +
|
||||
'<td><button type="button" class="redo" data-i="' + i + '">edit</button></td>' +
|
||||
"</tr>";
|
||||
});
|
||||
stage.innerHTML = '<div class="qcard">' +
|
||||
'<span class="badge" style="background:var(--olive)">Interview complete</span>' +
|
||||
'<h2 class="sum-h">Seven decisions, zero assumptions.</h2>' +
|
||||
'<p class="sum-sub">Every answer below would otherwise have been guessed silently during implementation.</p>' +
|
||||
'<div class="dtable-scroll"><table class="dtable"><thead><tr>' +
|
||||
'<th>Radius</th><th>Question</th><th>Decision</th><th></th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table></div>' +
|
||||
'<div class="genblock">' +
|
||||
'<div class="gb-label">Generated follow-up prompt</div>' +
|
||||
'<div class="genwrap"><button type="button" class="copybtn" id="copyGen">copy</button>' +
|
||||
'<pre id="genPre">' + esc(buildFollowupPrompt()) + '</pre></div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="restart" id="restartBtn">↺ start over</button>' +
|
||||
'</div>';
|
||||
|
||||
stage.querySelectorAll(".redo").forEach(function (el) {
|
||||
el.addEventListener("click", function () { go(parseInt(el.dataset.i, 10)); });
|
||||
});
|
||||
wireCopy(stage.querySelector("#copyGen"), function () { return buildFollowupPrompt(); });
|
||||
stage.querySelector("#restartBtn").addEventListener("click", function () {
|
||||
answers = new Array(QUESTIONS.length).fill(null);
|
||||
go(0);
|
||||
});
|
||||
}
|
||||
|
||||
function go(idx) {
|
||||
var card = stage.querySelector(".qcard");
|
||||
var doRender = function () {
|
||||
current = idx;
|
||||
renderRail();
|
||||
if (current >= QUESTIONS.length) renderSummary();
|
||||
else renderQuestion();
|
||||
};
|
||||
if (card) {
|
||||
card.classList.add("leaving");
|
||||
setTimeout(doRender, 160);
|
||||
} else {
|
||||
doRender();
|
||||
}
|
||||
}
|
||||
|
||||
function wireCopy(btn, getText) {
|
||||
btn.addEventListener("click", function () {
|
||||
var text = getText();
|
||||
var done = function () {
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = "copied ✓";
|
||||
btn.classList.add("done");
|
||||
setTimeout(function () { btn.textContent = orig; btn.classList.remove("done"); }, 1600);
|
||||
};
|
||||
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.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
wireCopy(document.getElementById("copyPrompt"), function () {
|
||||
return document.getElementById("promptText").textContent;
|
||||
});
|
||||
|
||||
renderRail();
|
||||
renderQuestion();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,651 @@
|
||||
<!-- 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>Point at a reference — 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; }
|
||||
|
||||
/* ── header ── */
|
||||
header { padding: 56px 0 8px; position: relative; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.eyebrow::before { content: ""; width: 24px; height: 1.5px; background: var(--clay); flex: none; }
|
||||
.backlink {
|
||||
position: absolute; top: 60px; right: 0;
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
color: var(--g500); text-decoration: none;
|
||||
}
|
||||
.backlink:hover { color: var(--clay); }
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(32px, 5vw, 48px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.018em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.012em;
|
||||
font-size: 24px;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
h3 { font-family: var(--serif); font-weight: 500; font-size: 18px; letter-spacing: -0.01em; margin: 0 0 4px; }
|
||||
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; margin: 0 0 32px; }
|
||||
|
||||
/* ── prompt box ── */
|
||||
.card {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
}
|
||||
.promptbox { padding: 22px 26px 24px; margin-bottom: 44px; position: relative; }
|
||||
.promptbox .label {
|
||||
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--clay); margin-bottom: 12px;
|
||||
}
|
||||
.promptbox blockquote {
|
||||
margin: 0; padding: 0 0 0 18px;
|
||||
border-left: 3px solid var(--oat);
|
||||
font-family: var(--serif); font-style: italic;
|
||||
font-size: 17.5px; line-height: 1.5; color: var(--slate);
|
||||
max-width: 760px;
|
||||
}
|
||||
.copybtn {
|
||||
position: absolute; top: 18px; right: 18px;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
padding: 6px 12px; border-radius: 8px;
|
||||
border: 1.5px solid var(--g300); background: var(--ivory);
|
||||
color: var(--g700); cursor: pointer;
|
||||
transition: border-color 120ms, color 120ms;
|
||||
}
|
||||
.copybtn:hover { border-color: var(--clay); color: var(--clay); }
|
||||
.copybtn.done { border-color: var(--olive); color: var(--olive); }
|
||||
|
||||
/* ── divider ── */
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--g500);
|
||||
margin: 0 0 40px;
|
||||
}
|
||||
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--g300); }
|
||||
|
||||
/* ══════════ artifact ══════════ */
|
||||
.artifact { }
|
||||
.art-head {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.art-head h2 { font-size: clamp(24px, 3.4vw, 30px); }
|
||||
.badge {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.08em;
|
||||
text-transform: uppercase; padding: 3px 10px; border-radius: 999px;
|
||||
border: 1.5px solid var(--g300); color: var(--g500); background: var(--paper);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge.await { border-color: var(--clay); color: var(--clay-d); background: #FBF1EC; }
|
||||
.art-sub { color: var(--g700); font-size: 14.5px; margin: 0 0 8px; max-width: 720px; }
|
||||
.art-paths {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--g500);
|
||||
display: flex; flex-wrap: wrap; gap: 6px 18px; margin: 10px 0 36px;
|
||||
}
|
||||
.art-paths span b { color: var(--g700); font-weight: 600; }
|
||||
|
||||
.sec { margin: 0 0 46px; }
|
||||
.sec-eyebrow {
|
||||
font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em;
|
||||
text-transform: uppercase; color: var(--g500);
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 10px;
|
||||
}
|
||||
.sec-eyebrow .num {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--slate); color: var(--ivory);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* section 1 — bullets */
|
||||
.bullets { padding: 20px 24px 8px; }
|
||||
.bullets ul { list-style: none; margin: 0; padding: 0; }
|
||||
.bullets li {
|
||||
display: flex; gap: 14px; align-items: baseline;
|
||||
padding: 10px 0 14px; border-bottom: 1px dashed var(--g200);
|
||||
font-size: 15px; color: var(--g700);
|
||||
}
|
||||
.bullets li:last-child { border-bottom: 0; }
|
||||
.bullets li::before {
|
||||
content: ""; align-self: center;
|
||||
width: 8px; height: 8px; border-radius: 2px;
|
||||
background: var(--clay); flex: none; transform: rotate(45deg);
|
||||
}
|
||||
.bullets li b { color: var(--slate); font-weight: 600; }
|
||||
.bullets code, .prose code, td code, .note code {
|
||||
font-family: var(--mono); font-size: 0.86em;
|
||||
background: var(--g100); border: 1px solid var(--g200);
|
||||
border-radius: 5px; padding: 1px 5px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* section 2 — code pairs */
|
||||
.pair { margin-bottom: 26px; }
|
||||
.pair-title {
|
||||
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.pair-title .pt-n {
|
||||
font-family: var(--mono); font-size: 11px; color: var(--clay-d);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.pair-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 760px) { .pair-grid { grid-template-columns: 1fr; } }
|
||||
.codecard { overflow: hidden; display: flex; flex-direction: column; }
|
||||
.codecard .chead {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
||||
padding: 9px 14px;
|
||||
background: var(--g100); border-bottom: 1.5px solid var(--g300);
|
||||
font-family: var(--mono); font-size: 11px; color: var(--g500);
|
||||
white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.codecard .chead .lang {
|
||||
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
padding: 2px 8px; border-radius: 999px; flex: none;
|
||||
}
|
||||
.lang.rust { background: var(--oat); color: var(--g700); }
|
||||
.lang.ts { background: var(--slate); color: var(--ivory); }
|
||||
.chead .fp { overflow: hidden; text-overflow: ellipsis; }
|
||||
.codecard pre {
|
||||
margin: 0; padding: 14px 16px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono); font-size: 12.5px; line-height: 1.62;
|
||||
color: var(--slate); flex: 1;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.codecard pre code { display: block; min-width: max-content; }
|
||||
/* syntax palette */
|
||||
.kw { color: var(--clay-d); }
|
||||
.ty { color: var(--olive); font-weight: 600; }
|
||||
.fn2 { color: var(--slate); font-weight: 600; }
|
||||
.str { color: var(--olive); }
|
||||
.nu { color: var(--clay-d); }
|
||||
.cm { color: var(--g500); font-style: italic; }
|
||||
/* matched highlight regions */
|
||||
mark.hl {
|
||||
background: #F6E8DF;
|
||||
border-bottom: 2px solid var(--clay);
|
||||
border-radius: 3px; padding: 0 2px; margin: 0 -2px;
|
||||
color: inherit; cursor: default;
|
||||
transition: background 120ms;
|
||||
}
|
||||
mark.hl sup {
|
||||
font-family: var(--mono); font-size: 9px; color: var(--clay-d);
|
||||
margin-left: 1px;
|
||||
}
|
||||
mark.hl.active { background: var(--oat); border-bottom-color: var(--clay-d); }
|
||||
/* margin notes */
|
||||
.notes { margin: 12px 2px 0; display: grid; gap: 8px; }
|
||||
.note {
|
||||
display: flex; gap: 10px; align-items: baseline;
|
||||
font-size: 13.5px; color: var(--g700);
|
||||
}
|
||||
.note .nmark {
|
||||
font-family: var(--mono); font-size: 10px; flex: none;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
border: 1.5px solid var(--clay); color: var(--clay-d);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
align-self: center; cursor: default;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.note.active .nmark { background: var(--clay); color: var(--paper); }
|
||||
.note b { color: var(--slate); font-weight: 600; }
|
||||
|
||||
/* tables */
|
||||
.tbl-scroll { overflow-x: auto; border: 1.5px solid var(--g300); border-radius: 14px; background: var(--paper); }
|
||||
table { border-collapse: collapse; width: 100%; min-width: 660px; font-size: 13.5px; }
|
||||
th {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em;
|
||||
text-transform: uppercase; text-align: left; color: var(--g500);
|
||||
padding: 12px 16px; background: var(--g100);
|
||||
border-bottom: 1.5px solid var(--g300);
|
||||
}
|
||||
td {
|
||||
padding: 12px 16px; vertical-align: top;
|
||||
border-bottom: 1px solid var(--g200); color: var(--g700);
|
||||
}
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
td b { color: var(--slate); font-weight: 600; }
|
||||
th.col-keep { color: var(--olive); }
|
||||
th.col-chg { color: var(--clay-d); }
|
||||
th.col-drop { color: var(--g500); }
|
||||
.pill {
|
||||
display: inline-block; font-family: var(--mono); font-size: 10px;
|
||||
letter-spacing: 0.06em; text-transform: uppercase;
|
||||
padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.pill.same { background: #EDF1E6; color: var(--olive); border: 1px solid #C9D4B8; }
|
||||
.pill.equiv { background: #FBF1EC; color: var(--clay-d); border: 1px solid #EBC9B8; }
|
||||
|
||||
/* three-column preserved/changed/dropped */
|
||||
.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
||||
@media (max-width: 820px) { .three { grid-template-columns: 1fr; } }
|
||||
.col { padding: 18px 18px 12px; }
|
||||
.col .colhead {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; margin-bottom: 12px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.col .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
|
||||
.col.keep .colhead { color: var(--olive); } .col.keep .dot { background: var(--olive); }
|
||||
.col.chg .colhead { color: var(--clay-d); } .col.chg .dot { background: var(--clay); }
|
||||
.col.drop .colhead { color: var(--g500); } .col.drop .dot { background: var(--g300); }
|
||||
.col ul { margin: 0; padding: 0; list-style: none; }
|
||||
.col li {
|
||||
font-size: 13.5px; color: var(--g700);
|
||||
padding: 8px 0; border-top: 1px dashed var(--g200);
|
||||
}
|
||||
.col li:first-child { border-top: 0; }
|
||||
.col li b { color: var(--slate); font-weight: 600; }
|
||||
.col.drop li { color: var(--g500); }
|
||||
.col.drop li b { color: var(--g700); }
|
||||
|
||||
/* confirm card */
|
||||
.confirm {
|
||||
border-color: var(--clay);
|
||||
padding: 26px 28px;
|
||||
display: flex; flex-wrap: wrap; gap: 20px 32px;
|
||||
align-items: center; justify-content: space-between;
|
||||
background: linear-gradient(0deg, #FBF4EF, var(--paper));
|
||||
}
|
||||
.confirm .ctext { max-width: 520px; }
|
||||
.confirm .ctext p { margin: 6px 0 0; font-size: 14px; color: var(--g700); }
|
||||
.cbtns { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.btn {
|
||||
font-family: var(--mono); font-size: 12.5px;
|
||||
padding: 11px 18px; border-radius: 10px; cursor: pointer;
|
||||
border: 1.5px solid var(--slate);
|
||||
transition: transform 100ms, background 120ms, color 120ms, border-color 120ms;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--slate); color: var(--ivory); }
|
||||
.btn.primary:hover { background: var(--clay-d); border-color: var(--clay-d); }
|
||||
.btn.primary.done { background: var(--olive); border-color: var(--olive); }
|
||||
.btn.ghost { background: var(--paper); color: var(--g700); border-color: var(--g300); }
|
||||
.btn.ghost:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
|
||||
footer {
|
||||
margin-top: 80px; padding-top: 24px;
|
||||
border-top: 1.5px solid var(--g300);
|
||||
font-size: 13px; color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay-d); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<div class="eyebrow">Know your unknowns · Pre-implementation</div>
|
||||
<a class="backlink" href="index.html">← All examples</a>
|
||||
<h1>Point at a reference</h1>
|
||||
<p class="lede">When an existing implementation already encodes the behavior you want, make Claude prove it understood the reference — as a reviewable artifact — before a single line gets ported.</p>
|
||||
|
||||
<div class="card promptbox">
|
||||
<div class="label">The prompt</div>
|
||||
<button class="copybtn" id="copyPrompt">copy prompt</button>
|
||||
<blockquote id="promptText">This Rust crate in vendor/rate-limiter implements the exact backoff behavior I want. Read it and reimplement the same semantics in our TypeScript API client — but first show me a semantics map so I can confirm you understood it.</blockquote>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<div class="artifact">
|
||||
|
||||
<div class="art-head">
|
||||
<h2>Semantics map: <em style="font-style:italic;color:var(--clay-d)">rate-limiter</em> → Acme API client</h2>
|
||||
<span class="badge await">awaiting confirmation</span>
|
||||
</div>
|
||||
<p class="art-sub">What the crate does, how each behavior maps to TypeScript, and every place the port cannot be a literal translation. Nothing gets implemented until you sign off on this page.</p>
|
||||
<div class="art-paths">
|
||||
<span><b>source</b> vendor/rate-limiter (Rust, 612 loc, 3 modules)</span>
|
||||
<span><b>target</b> src/api/retry/ in acme-web</span>
|
||||
<span><b>read</b> bucket.rs · backoff.rs · budget.rs · 14 tests</span>
|
||||
</div>
|
||||
|
||||
<!-- ── 1. what it does ── -->
|
||||
<section class="sec">
|
||||
<div class="sec-eyebrow"><span class="num">1</span> What the crate actually does</div>
|
||||
<div class="card bullets">
|
||||
<ul>
|
||||
<li><span><b>Token bucket admission</b> (<code>bucket.rs</code>): every outgoing request withdraws 1 token from a bucket that refills at <code>rate_per_sec = 8</code> up to <code>burst_capacity = 40</code>. The bucket starts <b>full</b>, so a cold client can burst 40 requests instantly.</span></li>
|
||||
<li><span><b>Refill is lazy and integer-truncated</b>: tokens are minted only when a request arrives, from elapsed nanoseconds × rate ÷ 10⁹. Fractional tokens are never stored — instead, <code>last_refill</code> only advances when at least one whole token is minted, so sub-token elapsed time carries forward.</span></li>
|
||||
<li><span><b>Decorrelated jitter backoff</b> (<code>backoff.rs</code>): on retryable failure, the next delay is drawn uniformly from <code>[base, min(prev × 3, cap)]</code> with <code>base = 250ms</code>, <code>cap = 30s</code>. Each draw seeds the next — delays wander rather than doubling in lockstep, which desynchronizes clients after an outage.</span></li>
|
||||
<li><span><b>Retry budget</b> (<code>budget.rs</code>): a second, separate bucket. Every <b>successful first-try</b> request deposits 1; every <b>retry attempt</b> withdraws 10. When the balance can’t cover a withdrawal, retries are refused entirely (first-try requests still go out). This caps retry amplification at ~10% during a sustained outage.</span></li>
|
||||
<li><span><b>Failure classification is the caller’s job</b>: the crate never inspects errors. It exposes <code>acquire()</code>, <code>next_delay()</code>, <code>try_withdraw()</code> and the caller decides what counts as retryable. The TS port keeps that boundary — Acme’s <code>isRetryable()</code> in <code>src/api/errors.ts</code> stays the single source of truth.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. side-by-side ── -->
|
||||
<section class="sec">
|
||||
<div class="sec-eyebrow"><span class="num">2</span> Side-by-side: Rust source → proposed TypeScript</div>
|
||||
<p class="art-sub" style="margin-bottom:20px">Highlighted regions correspond across columns — hover one to light up its counterpart and the gotcha note it maps to.</p>
|
||||
|
||||
<!-- pair A -->
|
||||
<div class="pair">
|
||||
<div class="pair-title"><span class="pt-n">PAIR A / 3</span><h3>Lazy refill — integer truncation must survive the float world</h3></div>
|
||||
<div class="pair-grid">
|
||||
<div class="card codecard">
|
||||
<div class="chead"><span class="fp">vendor/rate-limiter/src/bucket.rs:41</span><span class="lang rust">Rust</span></div>
|
||||
<pre><code><span class="kw">fn</span> <span class="fn2">refill</span>(&<span class="kw">mut</span> <span class="kw">self</span>, now: <span class="ty">Instant</span>) {
|
||||
<span class="kw">let</span> elapsed = now
|
||||
.<mark class="hl" data-p="a1">saturating_duration_since<sup>1</sup></mark>(<span class="kw">self</span>.last_refill);
|
||||
<span class="kw">let</span> new_tokens = <mark class="hl" data-p="a2">elapsed.as_nanos() <span class="kw">as</span> <span class="ty">u64</span>
|
||||
* <span class="kw">self</span>.rate_per_sec <span class="kw">as</span> <span class="ty">u64</span>
|
||||
/ <span class="nu">1_000_000_000</span><sup>2</sup></mark>;
|
||||
<span class="kw">if</span> <mark class="hl" data-p="a3">new_tokens > <span class="nu">0</span><sup>3</sup></mark> {
|
||||
<span class="kw">self</span>.tokens = (<span class="kw">self</span>.tokens + new_tokens)
|
||||
.min(<span class="kw">self</span>.burst_capacity);
|
||||
<mark class="hl" data-p="a3"><span class="kw">self</span>.last_refill = now;</mark>
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="card codecard">
|
||||
<div class="chead"><span class="fp">src/api/retry/tokenBucket.ts (proposed)</span><span class="lang ts">TS</span></div>
|
||||
<pre><code><span class="kw">private</span> <span class="fn2">refill</span>(now: <span class="ty">number</span>): <span class="ty">void</span> {
|
||||
<span class="cm">// now comes from performance.now(), not Date.now()</span>
|
||||
<span class="kw">const</span> elapsedMs = <mark class="hl" data-p="a1"><span class="ty">Math</span>.max(<span class="nu">0</span>, now - <span class="kw">this</span>.lastRefill)<sup>1</sup></mark>;
|
||||
<span class="kw">const</span> newTokens = <mark class="hl" data-p="a2"><span class="ty">Math</span>.floor(
|
||||
(elapsedMs * <span class="kw">this</span>.ratePerSec) / <span class="nu">1000</span>
|
||||
)<sup>2</sup></mark>;
|
||||
<span class="kw">if</span> (<mark class="hl" data-p="a3">newTokens > <span class="nu">0</span><sup>3</sup></mark>) {
|
||||
<span class="kw">this</span>.tokens = <span class="ty">Math</span>.min(
|
||||
<span class="kw">this</span>.tokens + newTokens,
|
||||
<span class="kw">this</span>.burstCapacity
|
||||
);
|
||||
<mark class="hl" data-p="a3"><span class="kw">this</span>.lastRefill = now;</mark>
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes">
|
||||
<div class="note" data-p="a1"><span class="nmark">1</span><span><b>Clock going backwards.</b> <code>saturating_duration_since</code> clamps negative elapsed time to zero. <code>Date.now()</code> can jump backwards under NTP correction; the port uses monotonic <code>performance.now()</code> <em>and</em> keeps <code>Math.max(0, …)</code> as a belt-and-suspenders match.</span></div>
|
||||
<div class="note" data-p="a2"><span class="nmark">2</span><span><b>Integer vs float math.</b> Rust’s <code>u64</code> division truncates; JS division doesn’t. <code>Math.floor</code> restores truncation. Millis instead of nanos is safe: at <code>rate = 8</code>, <code>elapsedMs * 8</code> stays far below <code>2⁵³</code>, so no precision loss.</span></div>
|
||||
<div class="note" data-p="a3"><span class="nmark">3</span><span><b>The load-bearing guard.</b> <code>last_refill</code> only advances when a whole token is minted. Dropping this guard (an easy “simplification”) silently discards sub-token progress on every call — at low rates the bucket would <em>never</em> refill under frequent polling. Preserved exactly, plus a regression test.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pair B -->
|
||||
<div class="pair">
|
||||
<div class="pair-title"><span class="pt-n">PAIR B / 3</span><h3>Decorrelated jitter — inclusive range, wandering seed</h3></div>
|
||||
<div class="pair-grid">
|
||||
<div class="card codecard">
|
||||
<div class="chead"><span class="fp">vendor/rate-limiter/src/backoff.rs:27</span><span class="lang rust">Rust</span></div>
|
||||
<pre><code><span class="kw">fn</span> <span class="fn2">next_delay</span>(&<span class="kw">mut</span> <span class="kw">self</span>) -> <span class="ty">Duration</span> {
|
||||
<span class="kw">let</span> hi = (<mark class="hl" data-p="b1"><span class="kw">self</span>.prev_delay_ms.saturating_mul(<span class="nu">3</span>)<sup>4</sup></mark>)
|
||||
.min(<span class="kw">self</span>.cap_ms);
|
||||
<span class="kw">let</span> lo = <span class="kw">self</span>.base_ms;
|
||||
<span class="kw">let</span> ms = <span class="kw">self</span>.rng
|
||||
.<mark class="hl" data-p="b2">gen_range(lo..=hi.max(lo))<sup>5</sup></mark>;
|
||||
<mark class="hl" data-p="b3"><span class="kw">self</span>.prev_delay_ms = ms;<sup>6</sup></mark>
|
||||
<span class="ty">Duration</span>::from_millis(ms)
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="card codecard">
|
||||
<div class="chead"><span class="fp">src/api/retry/backoff.ts (proposed)</span><span class="lang ts">TS</span></div>
|
||||
<pre><code><span class="fn2">nextDelay</span>(): <span class="ty">number</span> {
|
||||
<span class="kw">const</span> hi = <span class="ty">Math</span>.min(<mark class="hl" data-p="b1"><span class="kw">this</span>.prevDelayMs * <span class="nu">3</span><sup>4</sup></mark>, <span class="kw">this</span>.capMs);
|
||||
<span class="kw">const</span> lo = <span class="kw">this</span>.baseMs;
|
||||
<span class="kw">const</span> span = <span class="ty">Math</span>.max(hi, lo) - lo;
|
||||
<span class="kw">const</span> ms = <mark class="hl" data-p="b2">lo + <span class="ty">Math</span>.floor(
|
||||
<span class="kw">this</span>.random() * (span + <span class="nu">1</span>)
|
||||
)<sup>5</sup></mark>;
|
||||
<mark class="hl" data-p="b3"><span class="kw">this</span>.prevDelayMs = ms;<sup>6</sup></mark>
|
||||
<span class="kw">return</span> ms;
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes">
|
||||
<div class="note" data-p="b1"><span class="nmark">4</span><span><b>Saturating multiply.</b> <code>saturating_mul(3)</code> guards <code>u64</code> overflow. In JS this can’t overflow — <code>capMs = 30_000</code> bounds the product long before <code>2⁵³</code> — so the guard is deliberately dropped (see §3, “dropped” column).</span></div>
|
||||
<div class="note" data-p="b2"><span class="nmark">5</span><span><b>Inclusive vs exclusive range.</b> Rust’s <code>lo..=hi</code> includes both endpoints. Naive <code>lo + random() * (hi - lo)</code> never yields <code>hi</code>. The <code>+ 1</code> inside <code>Math.floor</code> restores inclusivity — a one-character bug magnet, called out so you can veto or bless it.</span></div>
|
||||
<div class="note" data-p="b3"><span class="nmark">6</span><span><b>Stateful seed.</b> Each draw becomes the next iteration’s <code>prev</code> — this is what makes it <em>decorrelated</em> rather than plain expo-backoff-with-jitter. <code>reset()</code> restores <code>prevDelayMs = baseMs</code> on success, matching the crate’s <code>Backoff::reset</code>. <code>this.random</code> is injectable for deterministic tests (crate uses a seeded <code>SmallRng</code> in its tests).</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pair C -->
|
||||
<div class="pair">
|
||||
<div class="pair-title"><span class="pt-n">PAIR C / 3</span><h3>Retry budget — a mutex the event loop doesn’t need (with one trap)</h3></div>
|
||||
<div class="pair-grid">
|
||||
<div class="card codecard">
|
||||
<div class="chead"><span class="fp">vendor/rate-limiter/src/budget.rs:58</span><span class="lang rust">Rust</span></div>
|
||||
<pre><code><span class="kw">pub fn</span> <span class="fn2">try_withdraw</span>(&<span class="kw">self</span>) -> <span class="ty">bool</span> {
|
||||
<span class="kw">let mut</span> b = <mark class="hl" data-p="c1"><span class="kw">self</span>.inner.lock().unwrap()<sup>7</sup></mark>;
|
||||
b.deposit_drip(<span class="ty">Instant</span>::now());
|
||||
<span class="kw">if</span> <mark class="hl" data-p="c2">b.balance >= <span class="ty">WITHDRAW_COST</span> {
|
||||
b.balance -= <span class="ty">WITHDRAW_COST</span>;<sup>8</sup></mark>
|
||||
<span class="kw">true</span>
|
||||
} <span class="kw">else</span> {
|
||||
<mark class="hl" data-p="c3"><span class="kw">false</span> <span class="cm">// refuse retry, don’t queue</span><sup>9</sup></mark>
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
<div class="card codecard">
|
||||
<div class="chead"><span class="fp">src/api/retry/budget.ts (proposed)</span><span class="lang ts">TS</span></div>
|
||||
<pre><code><span class="fn2">tryWithdraw</span>(): <span class="ty">boolean</span> {
|
||||
<span class="cm">// <mark class="hl" data-p="c1">no lock: single-threaded event loop<sup>7</sup></mark> —</span>
|
||||
<span class="cm">// but NO await between check and debit.</span>
|
||||
<span class="kw">this</span>.depositDrip(<span class="kw">this</span>.clock());
|
||||
<span class="kw">if</span> (<mark class="hl" data-p="c2"><span class="kw">this</span>.balance >= <span class="ty">WITHDRAW_COST</span>) {
|
||||
<span class="kw">this</span>.balance -= <span class="ty">WITHDRAW_COST</span>;<sup>8</sup></mark>
|
||||
<span class="kw">return</span> <span class="kw">true</span>;
|
||||
}
|
||||
<span class="kw">return</span> <mark class="hl" data-p="c3"><span class="kw">false</span><sup>9</sup></mark>;
|
||||
}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes">
|
||||
<div class="note" data-p="c1"><span class="nmark">7</span><span><b>Thread-safety → event loop.</b> The crate’s <code>Mutex</code> exists because Rust callers withdraw from worker threads. Acme’s client runs on one event loop, so the lock disappears — <em>but the atomicity it provided must be preserved by convention</em>: <code>tryWithdraw</code> is synchronous end-to-end, and I’ll add an eslint <code>no-await-in-budget</code> boundary comment plus a test that the method never returns a Promise.</span></div>
|
||||
<div class="note" data-p="c2"><span class="nmark">8</span><span><b>Check-then-debit stays fused.</b> Same shape both sides. The failure mode isn’t data races here — it’s a future refactor inserting an <code>await</code> (e.g., to log) between the check and the debit, letting two in-flight retries both pass the check.</span></div>
|
||||
<div class="note" data-p="c3"><span class="nmark">9</span><span><b>Refuse, never queue.</b> On an empty budget the crate returns <code>false</code> immediately — the request either goes out as a first try or fails fast. The port must <em>not</em> “helpfully” enqueue the retry for later; that would rebuild the retry storm the budget exists to prevent.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. preserved / changed / dropped ── -->
|
||||
<section class="sec">
|
||||
<div class="sec-eyebrow"><span class="num">3</span> Behaviors: preserved / deliberately changed / dropped</div>
|
||||
<div class="three">
|
||||
<div class="card col keep">
|
||||
<div class="colhead"><span class="dot"></span>Preserved exactly</div>
|
||||
<ul>
|
||||
<li><b>Refill truncation + guard</b> — whole tokens only; <code>lastRefill</code> advances only on mint</li>
|
||||
<li><b>Jitter formula</b> — uniform over <code>[base, min(prev×3, cap)]</code>, inclusive both ends</li>
|
||||
<li><b>Budget economics</b> — +1 per first-try success, −10 per retry, ceiling 1000</li>
|
||||
<li><b>Bucket starts full</b> — 40-request cold burst is intentional (matches crate test <code>burst_at_t0</code>)</li>
|
||||
<li><b>Caller classifies errors</b> — limiter never inspects failures</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card col chg">
|
||||
<div class="colhead"><span class="dot"></span>Deliberately changed</div>
|
||||
<ul>
|
||||
<li><b><code>Instant</code> → <code>performance.now()</code></b> — both monotonic; ms resolution is sufficient at rate 8/s</li>
|
||||
<li><b><code>u64</code> nanos → <code>number</code> ms</b> — all products provably < 2⁵³; <code>Math.floor</code> replays integer division</li>
|
||||
<li><b><code>Mutex<Budget></code> → plain fields</b> — atomicity by sync-only convention + test</li>
|
||||
<li><b><code>SmallRng</code> → injected <code>random()</code></b> — defaults to <code>Math.random</code>, seeded stub in tests</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card col drop">
|
||||
<div class="colhead"><span class="dot"></span>Dropped (not needed)</div>
|
||||
<ul>
|
||||
<li><b><code>saturating_mul</code> overflow guards</b> — unreachable once cap is applied in ms range</li>
|
||||
<li><b><code>Send + Sync</code> impls, <code>Arc</code> cloning</b> — no threads to share across</li>
|
||||
<li><b><code>tokio</code>/<code>async-std</code> feature flags</b> — port is runtime-agnostic by construction</li>
|
||||
<li><b>Prometheus counters</b> — Acme emits via <code>telemetry.track()</code> instead; hook points kept</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. edge cases ── -->
|
||||
<section class="sec">
|
||||
<div class="sec-eyebrow"><span class="num">4</span> Edge cases: expected behavior on both sides</div>
|
||||
<div class="tbl-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th style="width:22%">Edge case</th><th style="width:31%">Rust crate</th><th style="width:31%">TypeScript port</th><th>Match</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><b>Clock skew</b><br>system clock steps back 5s mid-session</td>
|
||||
<td><code>Instant</code> is monotonic — unaffected. <code>saturating_duration_since</code> is a second fence.</td>
|
||||
<td><code>performance.now()</code> is monotonic — unaffected. <code>Math.max(0,…)</code> kept as the same second fence.</td>
|
||||
<td><span class="pill same">identical</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Burst at t=0</b><br>fresh client fires 45 requests at once</td>
|
||||
<td>First 40 admitted instantly (bucket starts full); 41–45 rejected until refill. Crate test: <code>burst_at_t0</code>.</td>
|
||||
<td>Same: 40 admitted, 5 rejected with <code>RateLimited</code>. Port test replays the crate’s fixture numbers verbatim.</td>
|
||||
<td><span class="pill same">identical</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Budget exhaustion</b><br>sustained 100% failure for 60s</td>
|
||||
<td>Balance drains to 0 in ~10 retries; further retries refused, first-tries continue. Retry rate settles at deposit÷cost = 10% of success rate (0 here).</td>
|
||||
<td>Same economics, same settle point. Difference: refusal surfaces as <code>RetryBudgetExhausted</code> error so Acme’s upload queue can show “reconnecting” instead of failing silently.</td>
|
||||
<td><span class="pill equiv">equivalent*</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Slow drip</b><br>rate 8/s but polled every 20ms (0.16 tokens/poll)</td>
|
||||
<td>Guard carries sub-token time forward; a token mints every ~125ms regardless of poll cadence.</td>
|
||||
<td>Same, via the preserved <code>newTokens > 0</code> guard (Pair A, note 3). Regression test asserts mint cadence at 20ms polling.</td>
|
||||
<td><span class="pill same">identical</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Delay at cap</b><br>10th consecutive failure</td>
|
||||
<td>Draw range collapses toward <code>[250ms, 30s]</code>; delay stays ≤ 30s but still jittered — never a fixed 30s (avoids re-synchronizing clients).</td>
|
||||
<td>Same bounds, same non-degenerate jitter — verified with a seeded RNG replaying the crate’s <code>cap_still_jitters</code> test vector.</td>
|
||||
<td><span class="pill same">identical</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="art-sub" style="margin-top:10px;font-size:13px">* “equivalent” = same decision, different surface. The crate returns bare <code>false</code>; the port wraps it in a typed error because Acme’s UI needs to distinguish “rate limited” from “offline”. Flag this row if you’d rather keep the bare boolean.</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. confirm ── -->
|
||||
<section class="sec">
|
||||
<div class="sec-eyebrow"><span class="num">5</span> Sign-off</div>
|
||||
<div class="card confirm">
|
||||
<div class="ctext">
|
||||
<h3>Ready to port when you are</h3>
|
||||
<p>Reply <b>“semantics confirmed”</b> and I’ll implement <code>tokenBucket.ts</code>, <code>backoff.ts</code>, and <code>budget.ts</code> with the crate’s 14 tests translated first. Or correct any row above — quote its number (e.g. “note 5”, “budget exhaustion row”) and I’ll revise the map before writing code.</p>
|
||||
</div>
|
||||
<div class="cbtns">
|
||||
<button class="btn primary" id="confirmBtn">copy “semantics confirmed”</button>
|
||||
<button class="btn ghost" id="correctBtn">copy correction template</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
function copy(text, btn, doneLabel) {
|
||||
var original = btn.textContent;
|
||||
function done() {
|
||||
btn.textContent = doneLabel;
|
||||
btn.classList.add('done');
|
||||
setTimeout(function () {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('done');
|
||||
}, 1800);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { fallback(); });
|
||||
} else { fallback(); }
|
||||
function fallback() {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('copyPrompt').addEventListener('click', function () {
|
||||
copy(document.getElementById('promptText').textContent.trim(), this, 'Copied');
|
||||
});
|
||||
document.getElementById('confirmBtn').addEventListener('click', function () {
|
||||
copy('semantics confirmed', this, 'Copied — paste it back to Claude');
|
||||
});
|
||||
document.getElementById('correctBtn').addEventListener('click', function () {
|
||||
copy('Correction to the semantics map:\n- section/row: \n- what you got wrong: \n- correct behavior: ', this, 'Copied');
|
||||
});
|
||||
|
||||
// link highlighted regions across columns + their margin notes
|
||||
function setActive(p, on) {
|
||||
var els = document.querySelectorAll('[data-p="' + p + '"]');
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
els[i].classList.toggle('active', on);
|
||||
}
|
||||
}
|
||||
var linked = document.querySelectorAll('mark.hl, .note');
|
||||
for (var i = 0; i < linked.length; i++) {
|
||||
(function (el) {
|
||||
var p = el.getAttribute('data-p');
|
||||
if (!p) return;
|
||||
el.addEventListener('mouseenter', function () { setActive(p, true); });
|
||||
el.addEventListener('mouseleave', function () { setActive(p, false); });
|
||||
})(linked[i]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,765 @@
|
||||
<!-- 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>
|
||||
@@ -0,0 +1,609 @@
|
||||
<!-- 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>Implementation notes — 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; }
|
||||
|
||||
/* ── header ── */
|
||||
header.page { padding: 56px 0 8px; position: relative; }
|
||||
.eyebrow {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.eyebrow::before { content: ""; width: 24px; height: 1.5px; background: var(--clay); flex: none; }
|
||||
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-d); }
|
||||
h1 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.015em;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; margin: 0 0 30px; }
|
||||
|
||||
/* ── prompt box ── */
|
||||
.promptcard {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 22px 26px 24px;
|
||||
position: relative;
|
||||
}
|
||||
.promptcard .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--clay);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.promptcard blockquote {
|
||||
margin: 0;
|
||||
font-family: var(--serif);
|
||||
font-style: italic;
|
||||
font-size: 17.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--slate);
|
||||
max-width: 62ch;
|
||||
}
|
||||
button.copybtn {
|
||||
position: absolute;
|
||||
top: 18px; right: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--g700);
|
||||
background: var(--g100);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.copybtn:hover { border-color: var(--g500); }
|
||||
button.copybtn.done { color: var(--olive); border-color: var(--olive); background: var(--paper); }
|
||||
|
||||
/* ── divider ── */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 48px 0 30px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.divider::before, .divider::after { content: ""; height: 1.5px; background: var(--g300); flex: 1; }
|
||||
|
||||
/* ── artifact: notes document ── */
|
||||
.doc {
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.doc-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 14px;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1.5px solid var(--g200);
|
||||
background: var(--g100);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--g700);
|
||||
}
|
||||
.doc-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--olive); flex: none; }
|
||||
.doc-head .path { overflow-wrap: anywhere; }
|
||||
.doc-head .meta { margin-left: auto; color: var(--g500); }
|
||||
|
||||
.doc-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 22px 22px 0;
|
||||
}
|
||||
.stat {
|
||||
flex: 1 1 130px;
|
||||
background: var(--ivory);
|
||||
border: 1.5px solid var(--g200);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px 10px;
|
||||
}
|
||||
.stat .n { font-family: var(--serif); font-weight: 500; font-size: 30px; line-height: 1.1; letter-spacing: -0.02em; }
|
||||
.stat .t { font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--g500); margin-top: 3px; }
|
||||
.stat.dev .n { color: var(--clay-d); }
|
||||
.stat.you .n { color: var(--slate); }
|
||||
.stat.you { border-color: var(--oat); background: #F6F0E4; }
|
||||
|
||||
/* filter chips */
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 18px 22px 4px;
|
||||
}
|
||||
.chip {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--g300);
|
||||
background: var(--paper);
|
||||
color: var(--g700);
|
||||
cursor: pointer;
|
||||
}
|
||||
.chip:hover { border-color: var(--g500); }
|
||||
.chip.on { background: var(--slate); border-color: var(--slate); color: var(--ivory); }
|
||||
.chip .ct { color: var(--g500); }
|
||||
.chip.on .ct { color: var(--g300); }
|
||||
|
||||
/* timeline */
|
||||
.timeline { padding: 16px 22px 26px; }
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 14px 1fr;
|
||||
gap: 0 14px;
|
||||
position: relative;
|
||||
}
|
||||
.entry .ts {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--g500);
|
||||
padding-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
.entry .rail { position: relative; }
|
||||
.entry .rail::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%; top: 0; bottom: 0;
|
||||
width: 1.5px;
|
||||
background: var(--g200);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.entry.rail-first .rail::before { top: 10px; }
|
||||
.entry.rail-last .rail::before { bottom: auto; height: 10px; }
|
||||
.entry.rail-first.rail-last .rail::before { display: none; }
|
||||
.entry .node {
|
||||
position: absolute;
|
||||
left: 50%; top: 8px;
|
||||
width: 10px; height: 10px;
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--g300);
|
||||
border: 2px solid var(--paper);
|
||||
box-shadow: 0 0 0 1.5px var(--g300);
|
||||
}
|
||||
.entry.t-plan .node { background: var(--olive); box-shadow: 0 0 0 1.5px var(--olive); }
|
||||
.entry.t-dev .node { background: var(--clay); box-shadow: 0 0 0 1.5px var(--clay); }
|
||||
.entry.t-disc .node { background: var(--oat); box-shadow: 0 0 0 1.5px var(--g500); }
|
||||
.entry.t-human .node { background: var(--slate); box-shadow: 0 0 0 1.5px var(--slate); }
|
||||
.entry .body { padding: 0 0 22px; min-width: 0; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 2.5px 9px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.t-plan .badge { color: var(--olive); background: #EEF1E8; border: 1px solid #C9D4B8; }
|
||||
.t-dev .badge { color: #fff; background: var(--clay); border: 1px solid var(--clay); }
|
||||
.t-disc .badge { color: var(--g700); background: var(--g100); border: 1px solid var(--g300); }
|
||||
.t-human .badge { color: var(--ivory); background: var(--slate); border: 1px solid var(--slate); }
|
||||
.entry h3 {
|
||||
font-family: var(--sans);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.entry p { margin: 0; font-size: 14px; color: var(--g700); max-width: 68ch; }
|
||||
.entry code, .devgrid code, .n2 code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--g100);
|
||||
border: 1px solid var(--g200);
|
||||
border-radius: 5px;
|
||||
padding: 1px 5px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.ts-mobile { display: none; }
|
||||
|
||||
/* deviation sub-fields */
|
||||
.devgrid {
|
||||
margin-top: 10px;
|
||||
border: 1.5px solid var(--oat);
|
||||
border-left: 3px solid var(--clay);
|
||||
border-radius: 10px;
|
||||
background: #FBF6EC;
|
||||
overflow: hidden;
|
||||
}
|
||||
.devgrid .row {
|
||||
display: grid;
|
||||
grid-template-columns: 158px 1fr;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13.5px;
|
||||
color: var(--g700);
|
||||
}
|
||||
.devgrid .row + .row { border-top: 1px solid #EFE5D2; }
|
||||
.devgrid .k {
|
||||
font-family: var(--mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--clay-d);
|
||||
padding-top: 2px;
|
||||
}
|
||||
.devgrid .row.chosen .k { color: var(--olive); }
|
||||
.devgrid .row.chosen .v { color: var(--slate); font-weight: 500; }
|
||||
@media (max-width: 560px) {
|
||||
.devgrid .row { grid-template-columns: 1fr; gap: 2px; }
|
||||
.entry { grid-template-columns: 14px 1fr; }
|
||||
.entry .ts { display: none; }
|
||||
.ts-mobile { display: inline; font-family: var(--mono); font-size: 11px; color: var(--g500); margin-left: 8px; }
|
||||
}
|
||||
|
||||
/* attempt #2 block */
|
||||
.next {
|
||||
margin-top: 26px;
|
||||
background: var(--paper);
|
||||
border: 1.5px solid var(--g300);
|
||||
border-radius: 14px;
|
||||
padding: 24px 26px 22px;
|
||||
position: relative;
|
||||
}
|
||||
.next .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--clay);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.next h2 {
|
||||
font-family: var(--serif);
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.next .sub { font-size: 14px; color: var(--g500); margin: 0 0 14px; max-width: 60ch; }
|
||||
.next ol { margin: 0; padding-left: 22px; }
|
||||
.next li { font-size: 14.5px; color: var(--g700); margin-bottom: 10px; max-width: 70ch; }
|
||||
.next li strong { color: var(--slate); font-weight: 600; }
|
||||
|
||||
footer {
|
||||
margin-top: 64px;
|
||||
padding-top: 22px;
|
||||
border-top: 1.5px solid var(--g300);
|
||||
font-size: 13.5px;
|
||||
color: var(--g500);
|
||||
}
|
||||
footer a { color: var(--clay-d); text-decoration-color: var(--oat); text-underline-offset: 3px; }
|
||||
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="page">
|
||||
<div class="eyebrow">Know your unknowns · During implementation</div>
|
||||
<a class="back" href="index.html">← All examples</a>
|
||||
<h1>Implementation notes</h1>
|
||||
<p class="lede">Instead of letting mid-build surprises vanish into the scrollback, ask the agent to log every deviation from the plan as it happens — so the surprises become inputs to your next attempt.</p>
|
||||
|
||||
<div class="promptcard">
|
||||
<div class="label">The prompt</div>
|
||||
<button class="copybtn" id="copyPrompt">copy prompt</button>
|
||||
<blockquote id="promptText">“Keep an implementation-notes file as you build the export feature. If you hit an edge case that forces you to deviate from the plan, pick the conservative option, log it under ‘Deviations’, and keep going.”</blockquote>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<div class="doc">
|
||||
<div class="doc-head">
|
||||
<span class="dot"></span>
|
||||
<span class="path">docs/notes/export-feature-implementation.md</span>
|
||||
<span class="meta">run started 14:02 · last write 16:51 · branch feat/review-export</span>
|
||||
</div>
|
||||
|
||||
<div class="doc-summary">
|
||||
<div class="stat"><div class="n">11</div><div class="t">entries</div></div>
|
||||
<div class="stat dev"><div class="n">4</div><div class="t">deviations</div></div>
|
||||
<div class="stat you"><div class="n">2</div><div class="t">need your judgment</div></div>
|
||||
</div>
|
||||
|
||||
<div class="chips" role="tablist" aria-label="Filter entries">
|
||||
<button class="chip on" data-f="all">All <span class="ct">11</span></button>
|
||||
<button class="chip" data-f="dev">Deviations <span class="ct">4</span></button>
|
||||
<button class="chip" data-f="human">For you <span class="ct">2</span></button>
|
||||
</div>
|
||||
|
||||
<div class="timeline" id="timeline">
|
||||
|
||||
<div class="entry t-plan" data-t="plan">
|
||||
<div class="ts">14:02</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Plan-confirmed</span><span class="ts-mobile">14:02</span>
|
||||
<h3>Step 1 — export job model and queue wiring, as planned</h3>
|
||||
<p>Added <code>ExportJob</code> in <code>apps/api/src/exports/exportJob.ts</code>, enqueued on the existing BullMQ <code>media-tasks</code> queue with a dedicated <code>export</code> job name. Migration <code>0121_export_jobs</code> matches the plan's schema exactly (status enum, <code>requested_by</code>, <code>review_id</code>, <code>format</code>).</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-plan" data-t="plan">
|
||||
<div class="ts">14:18</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Plan-confirmed</span><span class="ts-mobile">14:18</span>
|
||||
<h3>Step 2 — <code>POST /api/v1/reviews/:id/export</code> endpoint</h3>
|
||||
<p>Request validation reuses the zod schemas in <code>apps/api/src/validation/</code> as the plan specified. <code>format</code> accepts <code>mp4-burnin | csv | bundle</code>. Returns <code>202</code> with a job id; polling endpoint deferred to step 6 per plan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-disc" data-t="disc">
|
||||
<div class="ts">14:29</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Discovery</span><span class="ts-mobile">14:29</span>
|
||||
<h3><code>Review.duration_ms</code> is denormalized and occasionally stale</h3>
|
||||
<p>Three fixture reviews have <code>duration_ms</code> that disagrees with the source asset's probe data (looks like re-uploads never refresh it). Export reads duration from <code>MediaAsset.probe.duration</code> instead — same approach <code>playbackManifest.ts</code> already takes. No plan change, just noting the trap for anyone who touches this later.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-dev" data-t="dev">
|
||||
<div class="ts">14:41</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Deviation 1 of 4</span><span class="ts-mobile">14:41</span>
|
||||
<h3>Legacy annotations don't always have frame timestamps</h3>
|
||||
<div class="devgrid">
|
||||
<div class="row"><div class="k">What the plan said</div><div class="v">Every annotation has a <code>frame_ts</code>; burn-in renderer can sort and place them all on the timeline.</div></div>
|
||||
<div class="row"><div class="k">What the code revealed</div><div class="v">~12% of rows in <code>annotations</code> predate migration <code>0087</code> and have <code>frame_ts = NULL</code> — they were general review comments before per-frame annotations existed.</div></div>
|
||||
<div class="row chosen"><div class="k">Conservative choice</div><div class="v">Exclude null-timestamp annotations from the video burn-in; include them in the CSV sidecar with <code>frame_ts</code> left blank and a <code>legacy_comment</code> flag. Nothing is silently dropped from the export bundle.</div></div>
|
||||
<div class="row"><div class="k">Revisit</div><div class="v">Could interpolate a timestamp from <code>created_at</code> offset against the review session — decide whether that's honest enough to show in the video.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-plan" data-t="plan">
|
||||
<div class="ts">15:05</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Plan-confirmed</span><span class="ts-mobile">15:05</span>
|
||||
<h3>Step 3 — annotation burn-in via ffmpeg <code>drawtext</code>/<code>drawbox</code>, as planned</h3>
|
||||
<p>Filtergraph builder in <code>apps/worker/src/exports/burnin.ts</code>. The plan's per-annotation overlay windows (<code>enable='between(t,a,b)'</code>) work; verified against the 40-annotation stress fixture — render time 1.4x realtime on the worker instance class, inside the plan's 2x budget.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-dev" data-t="dev">
|
||||
<div class="ts">15:22</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Deviation 2 of 4</span><span class="ts-mobile">15:22</span>
|
||||
<h3>The queue round-trips <code>Date</code> objects as strings</h3>
|
||||
<div class="devgrid">
|
||||
<div class="row"><div class="k">What the plan said</div><div class="v"><code>ExportJobPayload.requestedAt: Date</code>, passed through to the worker and compared against the review's <code>updated_at</code> to detect stale exports.</div></div>
|
||||
<div class="row"><div class="k">What the code revealed</div><div class="v">BullMQ JSON-serializes payloads into Redis, so the worker receives an ISO string, not a <code>Date</code>. The staleness comparison silently coerced and passed in unit tests, but failed on a redelivered job in the integration run.</div></div>
|
||||
<div class="row chosen"><div class="k">Conservative choice</div><div class="v">Typed the payload as wire format (<code>requestedAt: string</code> ISO-8601), parse once at the worker boundary in <code>exportConsumer.ts</code>. No custom serializer.</div></div>
|
||||
<div class="row"><div class="k">Revisit</div><div class="v">Other <code>media-tasks</code> payloads likely have the same latent bug — a shared zod codec for job payloads would fix the class of problem.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-dev" data-t="dev">
|
||||
<div class="ts">15:48</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Deviation 3 of 4</span><span class="ts-mobile">15:48</span>
|
||||
<h3>Zip streaming already exists — dropped the planned <code>archiver</code> dependency</h3>
|
||||
<div class="devgrid">
|
||||
<div class="row"><div class="k">What the plan said</div><div class="v">Add <code>archiver</code> to build the export bundle (video + CSV + thumbnails) as a zip stream to S3.</div></div>
|
||||
<div class="row"><div class="k">What the code revealed</div><div class="v"><code>packages/media/src/zipStream.ts</code> already does streaming zip with backpressure handling — it powers the bulk asset download feature and has been in production since March.</div></div>
|
||||
<div class="row chosen"><div class="k">Conservative choice</div><div class="v">Reused <code>zipStream.ts</code> as-is. One fewer dependency, and bundle exports inherit the same tested backpressure behavior as bulk downloads.</div></div>
|
||||
<div class="row"><div class="k">Revisit</div><div class="v"><code>zipStream</code> lives in <code>packages/media</code> but is now used by two features — worth promoting to <code>packages/shared</code> in a cleanup pass.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-disc" data-t="disc">
|
||||
<div class="ts">16:07</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Discovery</span><span class="ts-mobile">16:07</span>
|
||||
<h3>A websocket progress channel convention already exists</h3>
|
||||
<p>The transcode pipeline emits <code>media:progress:{assetId}</code> events. Followed the same convention — exports emit <code>export:progress:{jobId}</code> — so the frontend can reuse <code>useProgressChannel()</code> from <code>apps/web/src/hooks/</code> instead of the polling UI the plan sketched. Kept the polling endpoint too, as the fallback.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-dev" data-t="dev">
|
||||
<div class="ts">16:20</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Deviation 4 of 4</span><span class="ts-mobile">16:20</span>
|
||||
<h3>Guest reviewers can view reviews but can't download assets</h3>
|
||||
<div class="devgrid">
|
||||
<div class="row"><div class="k">What the plan said</div><div class="v">Export permission = review-level ACL: anyone who can open the review can export it.</div></div>
|
||||
<div class="row"><div class="k">What the code revealed</div><div class="v">Workspace-level <code>guest_reviewer</code> role grants review access with <code>can_download_assets = false</code> — clients use it to let external stakeholders comment without taking the media offsite. A review-ACL-only check would let guests exfiltrate source video via export.</div></div>
|
||||
<div class="row chosen"><div class="k">Conservative choice</div><div class="v">Export returns <code>403</code> for <code>guest_reviewer</code> across all formats, mirroring the existing asset-download check in <code>assetAccess.ts</code>. Strictest defensible reading.</div></div>
|
||||
<div class="row"><div class="k">Revisit</div><div class="v">Arguably guests should still get the CSV (annotations only, no media). That's a product call, not mine — logged below.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-human" data-t="human">
|
||||
<div class="ts">16:33</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Todo for human</span><span class="ts-mobile">16:33</span>
|
||||
<h3>Decide the guest-reviewer export policy</h3>
|
||||
<p>Current behavior (from Deviation 4): guests get <code>403</code> on every export format. If you want guests to export the annotation CSV without media, it's a ~10-line change in <code>exportPolicy.ts</code> plus one test — but it changes what “can't download assets” means to customers, so it should be your call. Blocking neither merge nor QA.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry t-human" data-t="human">
|
||||
<div class="ts">16:51</div>
|
||||
<div class="rail"><span class="node"></span></div>
|
||||
<div class="body">
|
||||
<span class="badge">Todo for human</span><span class="ts-mobile">16:51</span>
|
||||
<h3>Confirm the retention window for export bundles</h3>
|
||||
<p>The plan didn't specify a TTL for generated bundles. I put them under the <code>exports/</code> S3 prefix with a 7-day lifecycle rule, matching share-link expiry — conservative, and nothing customer-visible promises longer. But if contracts or the data-retention policy say otherwise, the lifecycle rule in <code>infra/s3.tf</code> is the one line to change.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="next">
|
||||
<div class="label">Fold back into the plan</div>
|
||||
<h2>What this changes about attempt #2</h2>
|
||||
<p class="sub">Three lines to paste into the next plan or prompt, so the next run doesn't rediscover today's surprises.</p>
|
||||
<button class="copybtn" id="copyNext">copy bullets</button>
|
||||
<ol id="nextList">
|
||||
<li><strong>State the legacy-data caveat up front:</strong> ~12% of annotations (pre-migration-0087) have null <code>frame_ts</code>. Decide interpolate-vs-sidecar before implementation starts, not at 14:41 mid-build.</li>
|
||||
<li><strong>Spec queue payloads as wire types:</strong> BullMQ round-trips through JSON, so payload interfaces should use ISO strings, never <code>Date</code> — and a shared zod codec for <code>media-tasks</code> payloads would retire the whole bug class.</li>
|
||||
<li><strong>Inventory existing utilities before adding dependencies:</strong> <code>zipStream.ts</code> and <code>useProgressChannel()</code> both existed and both beat the plan's version. Add a “search for prior art” step to the plan template, and settle the guest-reviewer export policy before the next feature touches ACLs.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ── copy buttons ──
|
||||
function wireCopy(btnId, getText) {
|
||||
var btn = document.getElementById(btnId);
|
||||
var original = btn.textContent;
|
||||
btn.addEventListener('click', function () {
|
||||
var text = getText();
|
||||
function done() {
|
||||
btn.textContent = 'Copied';
|
||||
btn.classList.add('done');
|
||||
setTimeout(function () {
|
||||
btn.textContent = original;
|
||||
btn.classList.remove('done');
|
||||
}, 1600);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done, function () { fallback(text); done(); });
|
||||
} else {
|
||||
fallback(text); done();
|
||||
}
|
||||
});
|
||||
}
|
||||
function fallback(text) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
wireCopy('copyPrompt', function () {
|
||||
return "Keep an implementation-notes file as you build the export feature. If you hit an edge case that forces you to deviate from the plan, pick the conservative option, log it under 'Deviations', and keep going.";
|
||||
});
|
||||
|
||||
wireCopy('copyNext', function () {
|
||||
var items = document.querySelectorAll('#nextList li');
|
||||
var out = [];
|
||||
items.forEach(function (li, i) { out.push((i + 1) + '. ' + li.textContent.replace(/\s+/g, ' ').trim()); });
|
||||
return out.join('\n');
|
||||
});
|
||||
|
||||
// ── filter chips ──
|
||||
var chips = document.querySelectorAll('.chip');
|
||||
var entries = document.querySelectorAll('.entry');
|
||||
function markRailEnds() {
|
||||
var visible = Array.prototype.filter.call(entries, function (e) {
|
||||
return !e.classList.contains('hidden');
|
||||
});
|
||||
entries.forEach(function (e) {
|
||||
e.classList.remove('rail-first', 'rail-last');
|
||||
});
|
||||
if (visible.length) {
|
||||
visible[0].classList.add('rail-first');
|
||||
visible[visible.length - 1].classList.add('rail-last');
|
||||
}
|
||||
}
|
||||
chips.forEach(function (chip) {
|
||||
chip.addEventListener('click', function () {
|
||||
chips.forEach(function (c) { c.classList.remove('on'); });
|
||||
chip.classList.add('on');
|
||||
var f = chip.getAttribute('data-f');
|
||||
entries.forEach(function (e) {
|
||||
var t = e.getAttribute('data-t');
|
||||
var show = (f === 'all') || (f === 'dev' && t === 'dev') || (f === 'human' && t === 'human');
|
||||
e.classList.toggle('hidden', !show);
|
||||
});
|
||||
markRailEnds();
|
||||
});
|
||||
});
|
||||
markRailEnds();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,573 @@
|
||||
<!-- 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>The buy-in doc — 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; margin: 0; padding: 0; }
|
||||
body {
|
||||
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 20px; }
|
||||
h1, h2, h3 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em; }
|
||||
|
||||
/* ---------- page header ---------- */
|
||||
header.page { padding: 56px 0 40px; border-bottom: 1.5px solid var(--g300); 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: none; }
|
||||
.backlink {
|
||||
position: absolute; top: 60px; right: 0;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--g500); text-decoration: none;
|
||||
}
|
||||
.backlink:hover { color: var(--clay-d); }
|
||||
header.page h1 { font-size: clamp(34px, 5vw, 52px); line-height: 1.08; margin: 0 0 10px; }
|
||||
.lede { font-size: 16.5px; color: var(--g700); max-width: 640px; }
|
||||
|
||||
/* ---------- prompt box ---------- */
|
||||
.promptcard {
|
||||
background: var(--paper); border: 1.5px solid var(--g300); border-radius: 14px;
|
||||
padding: 22px 26px; margin: 30px 0 0; position: relative;
|
||||
}
|
||||
.promptcard .label {
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--clay); margin-bottom: 12px;
|
||||
}
|
||||
.promptcard blockquote {
|
||||
font-family: var(--serif); font-style: italic; font-size: 17.5px;
|
||||
line-height: 1.5; color: var(--slate); max-width: 720px;
|
||||
}
|
||||
.copybtn {
|
||||
position: absolute; top: 18px; right: 18px;
|
||||
font-family: var(--mono); font-size: 11.5px; letter-spacing: 0.04em;
|
||||
background: var(--g100); color: var(--g700); border: 1.5px solid var(--g300);
|
||||
border-radius: 8px; padding: 6px 12px; cursor: pointer;
|
||||
}
|
||||
.copybtn:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
.copybtn.done { background: var(--olive); border-color: var(--olive); color: #fff; }
|
||||
|
||||
/* ---------- divider ---------- */
|
||||
.divider {
|
||||
display: flex; align-items: center; gap: 16px; margin: 44px 0 36px;
|
||||
font-family: var(--mono); font-size: 12px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--g500);
|
||||
}
|
||||
.divider::before, .divider::after { content: ""; flex: 1; height: 1px; background: var(--g300); }
|
||||
|
||||
/* ================= ARTIFACT ================= */
|
||||
.artifact {
|
||||
background: var(--paper); border: 1.5px solid var(--g300); border-radius: 14px;
|
||||
padding: clamp(22px, 4vw, 44px); margin-bottom: 48px;
|
||||
}
|
||||
.doc-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.doc-eyebrow {
|
||||
font-family: var(--mono); font-size: 11.5px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: var(--g500);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.doc-eyebrow::before { content: ""; width: 24px; height: 1.5px; background: var(--clay); flex: none; }
|
||||
.timechip {
|
||||
font-family: var(--mono); font-size: 11.5px; color: var(--g700);
|
||||
background: var(--oat); border-radius: 999px; padding: 5px 13px; white-space: nowrap;
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.timechip::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--clay); }
|
||||
.artifact h2.doc-title { font-size: clamp(26px, 3.6vw, 36px); line-height: 1.12; margin: 10px 0 4px; }
|
||||
.doc-sub { color: var(--g500); font-size: 14px; margin-bottom: 26px; }
|
||||
.doc-sub strong { color: var(--g700); font-weight: 600; }
|
||||
|
||||
.artifact h3.sec {
|
||||
font-size: 21px; margin: 38px 0 14px; padding-top: 26px;
|
||||
border-top: 1.5px solid var(--g200);
|
||||
display: flex; align-items: baseline; gap: 10px;
|
||||
}
|
||||
.artifact h3.sec .n { font-family: var(--mono); font-size: 12px; color: var(--clay); font-weight: 400; }
|
||||
|
||||
/* ---------- demo player ---------- */
|
||||
.demo-shell { position: relative; }
|
||||
.demo {
|
||||
background: var(--g100); border: 1.5px solid var(--g300); border-radius: 12px;
|
||||
overflow: hidden; user-select: none;
|
||||
}
|
||||
.bl-titlebar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--paper); border-bottom: 1.5px solid var(--g200);
|
||||
padding: 9px 14px;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--g300); flex: none; }
|
||||
.bl-name { font-family: var(--mono); font-size: 11px; color: var(--g500); margin-left: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bl-body { display: grid; grid-template-columns: 1fr 232px; min-height: 268px; }
|
||||
@media (max-width: 620px) { .bl-body { grid-template-columns: 1fr; } .bl-panel { border-left: none !important; border-top: 1.5px solid var(--g200); } }
|
||||
|
||||
/* video pane */
|
||||
.bl-stage { position: relative; padding: 16px 16px 12px; display: flex; flex-direction: column; }
|
||||
.bl-video {
|
||||
flex: 1; min-height: 148px; border-radius: 8px; position: relative;
|
||||
background:
|
||||
radial-gradient(120px 80px at 68% 34%, #4a4b45 0%, transparent 70%),
|
||||
linear-gradient(160deg, #3D3D3A 0%, #232322 100%);
|
||||
border: 1.5px solid var(--g300);
|
||||
}
|
||||
.bl-play {
|
||||
position: absolute; left: 12px; bottom: 10px; width: 0; height: 0;
|
||||
border-left: 11px solid rgba(250,249,245,.85); border-top: 7px solid transparent; border-bottom: 7px solid transparent;
|
||||
}
|
||||
.bl-tc { position: absolute; right: 12px; bottom: 8px; font-family: var(--mono); font-size: 10.5px; color: rgba(250,249,245,.7); }
|
||||
.bl-pin {
|
||||
position: absolute; top: -7px; width: 14px; height: 14px; border-radius: 50% 50% 50% 2px;
|
||||
background: var(--g500); transform: rotate(45deg); transition: background .25s;
|
||||
}
|
||||
.bl-pin.on { background: var(--clay); }
|
||||
.bl-timeline { position: relative; height: 26px; margin-top: 14px; }
|
||||
.bl-track { position: absolute; left: 0; right: 0; top: 11px; height: 4px; border-radius: 2px; background: var(--g300); }
|
||||
.bl-track .fill { width: 34%; height: 100%; border-radius: 2px; background: var(--g500); }
|
||||
|
||||
/* annotation panel */
|
||||
.bl-panel { background: var(--paper); border-left: 1.5px solid var(--g200); padding: 12px 12px 10px; display: flex; flex-direction: column; }
|
||||
.bl-panel-head {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--g500); display: flex; justify-content: space-between; align-items: center; margin-bottom: 9px;
|
||||
}
|
||||
.bl-count { color: var(--clay-d); }
|
||||
.bl-ann {
|
||||
display: flex; gap: 9px; align-items: flex-start; padding: 8px 8px;
|
||||
border-radius: 8px; border: 1.5px solid transparent; transition: background .25s, border-color .25s;
|
||||
}
|
||||
.bl-ann.sel { background: var(--g100); border-color: var(--g300); }
|
||||
.bl-check {
|
||||
width: 15px; height: 15px; border-radius: 4px; border: 1.5px solid var(--g300);
|
||||
background: var(--paper); flex: none; margin-top: 2px; position: relative; transition: background .2s, border-color .2s;
|
||||
}
|
||||
.bl-ann.sel .bl-check { background: var(--clay); border-color: var(--clay); }
|
||||
.bl-ann.sel .bl-check::after {
|
||||
content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px;
|
||||
border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
|
||||
}
|
||||
.bl-ann .who { font-size: 11.5px; font-weight: 600; line-height: 1.35; }
|
||||
.bl-ann .who .t { font-family: var(--mono); font-weight: 400; font-size: 10px; color: var(--g500); margin-left: 5px; }
|
||||
.bl-ann .txt { font-size: 11px; color: var(--g700); line-height: 1.4; }
|
||||
.bl-export {
|
||||
margin-top: auto; align-self: stretch; text-align: center;
|
||||
font-family: var(--sans); font-size: 12.5px; font-weight: 600; color: #fff;
|
||||
background: var(--slate); border: none; border-radius: 8px; padding: 9px 0;
|
||||
transition: background .2s, transform .12s; pointer-events: none;
|
||||
}
|
||||
.bl-export.armed { background: var(--clay); }
|
||||
.bl-export.pressed { background: var(--clay-d); transform: scale(.965); }
|
||||
|
||||
/* progress overlay */
|
||||
.bl-progress {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(20,20,19,.38); opacity: 0; pointer-events: none; transition: opacity .3s;
|
||||
}
|
||||
.bl-progress.show { opacity: 1; }
|
||||
.bl-prog-card {
|
||||
background: var(--paper); border-radius: 12px; padding: 20px 24px; width: min(300px, 84%);
|
||||
box-shadow: 0 12px 32px rgba(20,20,19,.22);
|
||||
}
|
||||
.bl-prog-card .pt { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
|
||||
.bl-prog-card .ps { font-family: var(--mono); font-size: 10.5px; color: var(--g500); margin-bottom: 12px; min-height: 14px; }
|
||||
.bl-bar { height: 6px; border-radius: 3px; background: var(--g200); overflow: hidden; }
|
||||
.bl-bar .b { width: 0%; height: 100%; background: var(--clay); border-radius: 3px; transition: width 1.7s cubic-bezier(.3,.7,.3,1); }
|
||||
|
||||
/* toast */
|
||||
.bl-toast {
|
||||
position: absolute; left: 50%; bottom: 14px; transform: translate(-50%, 16px);
|
||||
background: var(--slate); color: var(--ivory); border-radius: 10px;
|
||||
padding: 10px 14px; display: flex; align-items: center; gap: 10px;
|
||||
opacity: 0; transition: opacity .35s, transform .35s; max-width: 92%;
|
||||
box-shadow: 0 10px 26px rgba(20,20,19,.3);
|
||||
}
|
||||
.bl-toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||
.bl-tick {
|
||||
width: 18px; height: 18px; border-radius: 50%; background: var(--olive); flex: none; position: relative;
|
||||
}
|
||||
.bl-tick::after {
|
||||
content: ""; position: absolute; left: 6px; top: 3.5px; width: 4px; height: 8px;
|
||||
border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
|
||||
}
|
||||
.bl-toast .m { font-size: 12px; white-space: nowrap; }
|
||||
.bl-chip {
|
||||
font-family: var(--mono); font-size: 10.5px; background: rgba(250,249,245,.14);
|
||||
border: 1px solid rgba(250,249,245,.25); border-radius: 6px; padding: 3px 8px; white-space: nowrap;
|
||||
}
|
||||
.demo-caption {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
margin-top: 10px; font-family: var(--mono); font-size: 11px; color: var(--g500);
|
||||
}
|
||||
.replay {
|
||||
font-family: var(--mono); font-size: 11px; background: none; border: 1.5px solid var(--g300);
|
||||
border-radius: 8px; color: var(--g700); padding: 5px 12px; cursor: pointer;
|
||||
}
|
||||
.replay:hover { border-color: var(--clay); color: var(--clay-d); }
|
||||
|
||||
/* ---------- pitch ---------- */
|
||||
.pitch { font-family: var(--serif); font-size: 17.5px; line-height: 1.6; max-width: 46em; }
|
||||
.pitch strong { font-weight: 600; }
|
||||
|
||||
/* ---------- objections ---------- */
|
||||
.obj { border: 1.5px solid var(--g200); border-radius: 10px; margin-bottom: 10px; overflow: hidden; }
|
||||
.obj summary {
|
||||
list-style: none; cursor: pointer; padding: 12px 16px; display: flex; gap: 12px; align-items: baseline;
|
||||
font-weight: 600; font-size: 14.5px;
|
||||
}
|
||||
.obj summary::-webkit-details-marker { display: none; }
|
||||
.obj summary .q { font-family: var(--mono); font-size: 11px; color: var(--clay); flex: none; }
|
||||
.obj summary::after { content: "+"; margin-left: auto; font-family: var(--mono); color: var(--g500); flex: none; }
|
||||
.obj[open] summary::after { content: "–"; }
|
||||
.obj[open] summary { border-bottom: 1.5px solid var(--g200); background: var(--g100); }
|
||||
.obj .a { padding: 13px 16px 14px 16px; font-size: 14px; color: var(--g700); }
|
||||
.obj .ref {
|
||||
display: inline-block; margin-top: 8px; font-family: var(--mono); font-size: 11.5px;
|
||||
color: var(--clay-d); text-decoration: none; border-bottom: 1px dashed var(--g300);
|
||||
}
|
||||
.obj .ref:hover { border-bottom-color: var(--clay-d); }
|
||||
|
||||
/* ---------- spec table ---------- */
|
||||
.tblwrap { overflow-x: auto; border: 1.5px solid var(--g200); border-radius: 10px; }
|
||||
table.spec { width: 100%; border-collapse: collapse; font-size: 13.5px; min-width: 560px; }
|
||||
table.spec th, table.spec td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--g200); vertical-align: top; }
|
||||
table.spec tr:last-child td { border-bottom: none; }
|
||||
table.spec th {
|
||||
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--g500); font-weight: 400; background: var(--g100);
|
||||
}
|
||||
table.spec td:first-child { font-weight: 600; white-space: nowrap; width: 140px; }
|
||||
table.spec code, .a code, .risk code, .need code {
|
||||
font-family: var(--mono); font-size: 0.88em; background: var(--g100);
|
||||
border: 1px solid var(--g200); border-radius: 4px; padding: 1px 5px; white-space: nowrap;
|
||||
}
|
||||
.specref { font-family: var(--mono); font-size: 11px; color: var(--g500); }
|
||||
|
||||
/* ---------- risk + needs cards ---------- */
|
||||
.cardgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
@media (max-width: 720px) { .cardgrid { grid-template-columns: 1fr; } }
|
||||
.risk, .need {
|
||||
border: 1.5px solid var(--g300); border-radius: 12px; padding: 18px 20px; background: var(--ivory);
|
||||
}
|
||||
.risk { border-left: 4px solid var(--clay); }
|
||||
.need { border-left: 4px solid var(--olive); }
|
||||
.risk h4, .need h4 {
|
||||
font-family: var(--mono); font-size: 11.5px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
font-weight: 400; margin-bottom: 12px;
|
||||
}
|
||||
.risk h4 { color: var(--clay-d); }
|
||||
.need h4 { color: var(--olive); }
|
||||
.risk ul { list-style: none; }
|
||||
.risk li { font-size: 13.5px; color: var(--g700); padding-left: 18px; position: relative; margin-bottom: 9px; }
|
||||
.risk li::before { content: ""; position: absolute; left: 2px; top: 8px; width: 7px; height: 2px; background: var(--clay); }
|
||||
.risk li strong { color: var(--slate); }
|
||||
|
||||
.approver { display: flex; gap: 11px; align-items: flex-start; padding: 9px 0; border-bottom: 1px solid var(--g200); }
|
||||
.approver:last-child { border-bottom: none; }
|
||||
.avatar {
|
||||
width: 30px; height: 30px; border-radius: 50%; flex: none;
|
||||
background: var(--oat); color: var(--clay-d);
|
||||
font-family: var(--mono); font-size: 11px; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.approver .info { font-size: 13px; line-height: 1.4; }
|
||||
.approver .info b { display: block; font-size: 13.5px; }
|
||||
.approver .info .role { font-family: var(--mono); font-size: 10.5px; color: var(--g500); }
|
||||
.approver .info .asks { color: var(--g700); margin-top: 2px; }
|
||||
|
||||
.bottomline {
|
||||
margin-top: 34px; padding: 16px 20px; background: var(--oat); border-radius: 10px;
|
||||
font-family: var(--serif); font-size: 16px;
|
||||
}
|
||||
|
||||
footer {
|
||||
padding: 26px 0 56px; font-size: 13px; color: var(--g500);
|
||||
border-top: 1.5px solid var(--g300);
|
||||
}
|
||||
footer a { color: var(--clay-d); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<header class="page">
|
||||
<a class="backlink" href="index.html">← All examples</a>
|
||||
<div class="eyebrow">Know your unknowns · Post-implementation</div>
|
||||
<h1>The buy-in doc</h1>
|
||||
<p class="lede">After the work is done, the last unknown is other people — ask Claude to package the prototype, spec, and notes into one skimmable doc that answers objections before they're raised.</p>
|
||||
|
||||
<div class="promptcard">
|
||||
<div class="label">The prompt</div>
|
||||
<button class="copybtn" id="copyBtn">copy prompt</button>
|
||||
<blockquote id="promptText">“Package the prototype, the spec, and the implementation notes into a single doc I can drop in Slack to get buy-in on shipping annotation export. Lead with the demo.”</blockquote>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="divider">What Claude produced</div>
|
||||
|
||||
<!-- ================= ARTIFACT ================= -->
|
||||
<article class="artifact">
|
||||
<div class="doc-top">
|
||||
<div class="doc-eyebrow">Ship proposal · Acme</div>
|
||||
<span class="timechip">90-second read</span>
|
||||
</div>
|
||||
<h2 class="doc-title">Annotation export is ready to ship</h2>
|
||||
<p class="doc-sub">Prototype, spec, and rollout plan in one place. <strong>Ask:</strong> four sign-offs by Friday, flag ramp starts Monday.</p>
|
||||
|
||||
<!-- 1 · DEMO -->
|
||||
<h3 class="sec"><span class="n">01</span> Watch it work</h3>
|
||||
<div class="demo-shell">
|
||||
<div class="demo" id="demo" aria-label="Animated mock of the annotation export flow in Acme">
|
||||
<div class="bl-titlebar">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="bl-name">Acme — q3_launch_teaser_v4.mp4 · Review</span>
|
||||
</div>
|
||||
<div class="bl-body">
|
||||
<div class="bl-stage">
|
||||
<div class="bl-video">
|
||||
<span class="bl-play"></span>
|
||||
<span class="bl-tc">00:42 / 02:05</span>
|
||||
</div>
|
||||
<div class="bl-timeline">
|
||||
<div class="bl-track"><div class="fill"></div></div>
|
||||
<span class="bl-pin" id="pin1" style="left: 18%;"></span>
|
||||
<span class="bl-pin" id="pin2" style="left: 41%;"></span>
|
||||
<span class="bl-pin" id="pin3" style="left: 73%;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bl-panel">
|
||||
<div class="bl-panel-head"><span>Annotations</span><span class="bl-count" id="selCount">0 selected</span></div>
|
||||
<div class="bl-ann" id="ann1">
|
||||
<span class="bl-check"></span>
|
||||
<div>
|
||||
<div class="who">Maya R. <span class="t">00:23</span></div>
|
||||
<div class="txt">Logo lands too late — pull in 8 frames?</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bl-ann" id="ann2">
|
||||
<span class="bl-check"></span>
|
||||
<div>
|
||||
<div class="who">Devon P. <span class="t">00:51</span></div>
|
||||
<div class="txt">Color shift here vs. the master grade</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bl-ann" id="ann3">
|
||||
<span class="bl-check"></span>
|
||||
<div>
|
||||
<div class="who">Maya R. <span class="t">01:30</span></div>
|
||||
<div class="txt">Approved — final CTA reads great</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="bl-export" id="exportBtn" tabindex="-1">Export selected</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bl-progress" id="progress">
|
||||
<div class="bl-prog-card">
|
||||
<div class="pt">Exporting 3 annotations</div>
|
||||
<div class="ps" id="progStatus">Collecting annotations…</div>
|
||||
<div class="bl-bar"><div class="b" id="progBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bl-toast" id="toast">
|
||||
<span class="bl-tick"></span>
|
||||
<span class="m">Export ready</span>
|
||||
<span class="bl-chip">q3_teaser_notes.srt · 3 cues</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-caption">
|
||||
<span id="demoState">select → export → done · loops automatically</span>
|
||||
<button class="replay" id="replayBtn">↻ replay</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 · PITCH -->
|
||||
<h3 class="sec"><span class="n">02</span> The pitch</h3>
|
||||
<p class="pitch">Reviewers leave feedback in Acme, then retype it into email, Notion, and edit-bay software — <strong>the single most-cited gap in churn interviews (41% of departed teams)</strong>. Annotation export turns any selection of comments and drawings into SRT, CSV, or PDF in one click, using infrastructure we already run. It's built, flagged, load-tested to 12k annotations per session, and reversible in one toggle. The only thing between here and shipped is the four sign-offs below.</p>
|
||||
|
||||
<!-- 3 · OBJECTIONS -->
|
||||
<h3 class="sec"><span class="n">03</span> What reviewers will ask</h3>
|
||||
<details class="obj" open>
|
||||
<summary><span class="q">Q1</span>Can this leak internal-only comments to a client?</summary>
|
||||
<div class="a">No. Export runs through the same visibility resolver as the review page: annotations marked <code>internal</code> are excluded unless the exporter holds <code>workspace:admin</code> and explicitly opts in via a labeled checkbox. Covered by 14 permission-matrix tests.
|
||||
<br><a class="ref" href="#spec">↗ spec §4.2 — Visibility & permissions</a></div>
|
||||
</details>
|
||||
<details class="obj">
|
||||
<summary><span class="q">Q2</span>What happens on a monster session — thousands of annotations?</summary>
|
||||
<div class="a">Export paginates at 500 annotations per batch and streams the file. Stress run against the largest real session in prod (a 12,400-annotation agency review) finished in 3.1s; p95 for typical sessions is 340ms.
|
||||
<br><a class="ref" href="#spec">↗ implementation notes, 06-24 — load test results</a></div>
|
||||
</details>
|
||||
<details class="obj">
|
||||
<summary><span class="q">Q3</span>Why file formats instead of a public API?</summary>
|
||||
<div class="a">SRT + CSV + PDF cover 83% of the 212 support tickets tagged <code>export-request</code> over the last two quarters. An API is the right phase 2 — it needs auth scoping work this doesn't. Files unblock users now without foreclosing the API design.
|
||||
<br><a class="ref" href="#spec">↗ metric — support tag analysis, Q1–Q2</a></div>
|
||||
</details>
|
||||
<details class="obj">
|
||||
<summary><span class="q">Q4</span>Does this add new infrastructure we have to operate?</summary>
|
||||
<div class="a">Zero new services. SRT/CSV render inline in the existing worker pool (<code>services/render-workers</code>); PDF reuses the report service already generating weekly digests. No new queues, no new datastore, no schema migrations.
|
||||
<br><a class="ref" href="#spec">↗ spec §5.1 — Architecture</a></div>
|
||||
</details>
|
||||
<details class="obj">
|
||||
<summary><span class="q">Q5</span>What's the compliance story for exported customer content?</summary>
|
||||
<div class="a">Every export is stamped with workspace ID, requesting user, and timestamp in file metadata, and written to the audit log (<code>audit.annotation_export</code>). Files are served via expiring signed URLs (24h) and never stored beyond that window.
|
||||
<br><a class="ref" href="#spec">↗ spec §6 — Audit & retention</a></div>
|
||||
</details>
|
||||
|
||||
<!-- 4 · SPEC SUMMARY -->
|
||||
<h3 class="sec" id="spec"><span class="n">04</span> Spec at a glance</h3>
|
||||
<div class="tblwrap">
|
||||
<table class="spec">
|
||||
<thead><tr><th>Area</th><th>Decision</th><th>Ref</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Formats</td><td>SRT (timecoded cues), CSV (flat, incl. drawing bounding boxes), PDF (frame thumbnails + comments)</td><td class="specref">§2</td></tr>
|
||||
<tr><td>Entry point</td><td>Annotation panel → multi-select → <b>Export selected</b>; also “Export all” in session menu</td><td class="specref">§3.1</td></tr>
|
||||
<tr><td>Permissions</td><td>Requires <code>session:comment</code> or above; <code>internal</code> annotations excluded by default</td><td class="specref">§4.2</td></tr>
|
||||
<tr><td>Limits</td><td>500 annotations/batch, streamed; 25k hard cap per export with clear error state</td><td class="specref">§5.3</td></tr>
|
||||
<tr><td>Telemetry</td><td><code>export_started</code>, <code>export_completed</code> (format, count, duration), <code>export_failed</code></td><td class="specref">§7</td></tr>
|
||||
<tr><td>Feature flag</td><td><code>annotation_export</code> — off → 5% → 50% → 100% over two weeks</td><td class="specref">§8</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 5 · RISK + NEEDS -->
|
||||
<h3 class="sec"><span class="n">05</span> Risk, rollback, and what I need from you</h3>
|
||||
<div class="cardgrid">
|
||||
<div class="risk">
|
||||
<h4>Risk & rollback</h4>
|
||||
<ul>
|
||||
<li><strong>Rollback is one toggle.</strong> Flip <code>annotation_export</code> off — no schema migrations shipped, nothing to unwind.</li>
|
||||
<li><strong>Blast radius is bounded.</strong> Export runs in the shared worker pool with a 30s per-job timeout; a runaway job cannot starve review playback.</li>
|
||||
<li><strong>Worst credible failure:</strong> orphaned export files if the signed-URL cleanup lags — they self-expire in 24h regardless.</li>
|
||||
<li><strong>Known gap:</strong> drawing annotations flatten to bounding boxes in CSV (full vectors are PDF-only). Documented in-product; phase 2 item.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="need">
|
||||
<h4>What I need from you</h4>
|
||||
<div class="approver">
|
||||
<span class="avatar">PN</span>
|
||||
<div class="info"><b>Priya Natarajan</b><span class="role">Eng lead, Playback & Review</span>
|
||||
<div class="asks">Sign off on reusing the render worker pool (spec §5.1) — the only shared-infra dependency.</div></div>
|
||||
</div>
|
||||
<div class="approver">
|
||||
<span class="avatar">ES</span>
|
||||
<div class="info"><b>Elena Sorokin</b><span class="role">Security</span>
|
||||
<div class="asks">Approve the visibility rules and audit-log shape (spec §4.2, §6).</div></div>
|
||||
</div>
|
||||
<div class="approver">
|
||||
<span class="avatar">MW</span>
|
||||
<div class="info"><b>Marcus Webb</b><span class="role">Design</span>
|
||||
<div class="asks">Final pass on the export modal and error states — prototype above is current.</div></div>
|
||||
</div>
|
||||
<div class="approver">
|
||||
<span class="avatar">DK</span>
|
||||
<div class="info"><b>Dana Kim</b><span class="role">PM, Collaboration</span>
|
||||
<div class="asks">Approve the flag ramp schedule (spec §8) and own the changelog entry.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottomline">Everything above is built and tested behind the flag. Reply with a ✓ on your piece, or grab 15 minutes on my calendar — <b>target: ramp starts Monday.</b></div>
|
||||
</article>
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// ---------- copy prompt ----------
|
||||
var PROMPT = "Package the prototype, the spec, and the implementation notes into a single doc I can drop in Slack to get buy-in on shipping annotation export. Lead with the demo.";
|
||||
var copyBtn = document.getElementById("copyBtn");
|
||||
copyBtn.addEventListener("click", function () {
|
||||
function done() {
|
||||
copyBtn.textContent = "copied";
|
||||
copyBtn.classList.add("done");
|
||||
setTimeout(function () {
|
||||
copyBtn.textContent = "copy prompt";
|
||||
copyBtn.classList.remove("done");
|
||||
}, 1600);
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(PROMPT).then(done, fallback);
|
||||
} else { fallback(); }
|
||||
function fallback() {
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = PROMPT;
|
||||
ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch (e) {}
|
||||
document.body.removeChild(ta);
|
||||
done();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- demo animation ----------
|
||||
var anns = [document.getElementById("ann1"), document.getElementById("ann2"), document.getElementById("ann3")];
|
||||
var pins = [document.getElementById("pin1"), document.getElementById("pin2"), document.getElementById("pin3")];
|
||||
var exportBtn = document.getElementById("exportBtn");
|
||||
var progress = document.getElementById("progress");
|
||||
var progBar = document.getElementById("progBar");
|
||||
var progStatus = document.getElementById("progStatus");
|
||||
var toast = document.getElementById("toast");
|
||||
var selCount = document.getElementById("selCount");
|
||||
var timers = [];
|
||||
|
||||
function at(ms, fn) { timers.push(setTimeout(fn, ms)); }
|
||||
|
||||
function reset() {
|
||||
timers.forEach(clearTimeout);
|
||||
timers = [];
|
||||
anns.forEach(function (a) { a.classList.remove("sel"); });
|
||||
pins.forEach(function (p) { p.classList.remove("on"); });
|
||||
exportBtn.classList.remove("armed", "pressed");
|
||||
progress.classList.remove("show");
|
||||
progBar.style.transition = "none";
|
||||
progBar.style.width = "0%";
|
||||
// force reflow so the next width change animates
|
||||
void progBar.offsetWidth;
|
||||
progBar.style.transition = "";
|
||||
progStatus.textContent = "Collecting annotations…";
|
||||
toast.classList.remove("show");
|
||||
selCount.textContent = "0 selected";
|
||||
}
|
||||
|
||||
function play() {
|
||||
reset();
|
||||
[0, 1, 2].forEach(function (i) {
|
||||
at(500 + i * 450, function () {
|
||||
anns[i].classList.add("sel");
|
||||
pins[i].classList.add("on");
|
||||
selCount.textContent = (i + 1) + " selected";
|
||||
if (i === 2) exportBtn.classList.add("armed");
|
||||
});
|
||||
});
|
||||
at(2100, function () { exportBtn.classList.add("pressed"); });
|
||||
at(2300, function () {
|
||||
exportBtn.classList.remove("pressed");
|
||||
progress.classList.add("show");
|
||||
});
|
||||
at(2450, function () { progBar.style.width = "100%"; });
|
||||
at(3100, function () { progStatus.textContent = "Rendering SRT cues…"; });
|
||||
at(3800, function () { progStatus.textContent = "Signing download URL…"; });
|
||||
at(4300, function () {
|
||||
progress.classList.remove("show");
|
||||
toast.classList.add("show");
|
||||
});
|
||||
at(7000, play); // loop
|
||||
}
|
||||
|
||||
document.getElementById("replayBtn").addEventListener("click", play);
|
||||
play();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,543 @@
|
||||
<!-- 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>Quiz me before I merge — 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;margin:0;padding:0}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
background:var(--ivory); color:var(--slate); font-family:var(--sans);
|
||||
line-height:1.55; font-size:15px; padding:40px 20px 64px;
|
||||
}
|
||||
.page{max-width:980px;margin:0 auto}
|
||||
h1,h2,h3{font-family:var(--serif);font-weight:500;letter-spacing:-0.015em;color:var(--slate)}
|
||||
|
||||
/* ---- page header ---- */
|
||||
.topline{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.eyebrow{
|
||||
font-family:var(--mono);font-size:12px;text-transform:uppercase;letter-spacing:0.12em;
|
||||
color:var(--g500);display:flex;align-items:center;gap:10px;
|
||||
}
|
||||
.eyebrow::before{content:"";display:inline-block;width:24px;height:2px;background:var(--clay);flex:none}
|
||||
.backlink{font-family:var(--mono);font-size:12px;color:var(--g500);text-decoration:none;white-space:nowrap}
|
||||
.backlink:hover{color:var(--clay-d)}
|
||||
h1.pagetitle{font-size:34px;line-height:1.15;margin-bottom:10px}
|
||||
.lede{color:var(--g700);font-size:16px;max-width:64ch;margin-bottom:26px}
|
||||
|
||||
/* ---- prompt box ---- */
|
||||
.promptcard{
|
||||
background:var(--paper);border:1.5px solid var(--g300);border-radius:14px;
|
||||
padding:22px 26px;margin-bottom:36px;position:relative;
|
||||
}
|
||||
.promptcard .label{
|
||||
font-family:var(--mono);font-size:12px;text-transform:uppercase;letter-spacing:0.12em;
|
||||
color:var(--clay);margin-bottom:12px;
|
||||
}
|
||||
.promptcard blockquote{
|
||||
font-family:var(--serif);font-style:italic;font-size:17.5px;line-height:1.55;
|
||||
color:var(--slate);border:none;
|
||||
}
|
||||
.copybtn{
|
||||
position:absolute;top:18px;right:18px;font-family:var(--mono);font-size:11.5px;
|
||||
padding:6px 12px;border:1.5px solid var(--g300);border-radius:8px;background:var(--g100);
|
||||
color:var(--g700);cursor:pointer;transition:all .15s;
|
||||
}
|
||||
.copybtn:hover{border-color:var(--clay);color:var(--clay-d)}
|
||||
.copybtn.copied{background:var(--olive);border-color:var(--olive);color:#fff}
|
||||
|
||||
/* ---- divider ---- */
|
||||
.divider{display:flex;align-items:center;gap:16px;margin:0 0 34px;
|
||||
font-family:var(--mono);font-size:12px;letter-spacing:0.12em;color:var(--g500);text-transform:uppercase;white-space:nowrap}
|
||||
.divider::before,.divider::after{content:"";flex:1;height:1px;background:var(--g300)}
|
||||
|
||||
/* =================== ARTIFACT =================== */
|
||||
.artifact{background:var(--paper);border:1.5px solid var(--g300);border-radius:14px;overflow:hidden}
|
||||
.art-head{padding:28px 34px 24px;border-bottom:1.5px solid var(--g200)}
|
||||
.art-eyebrow{font-family:var(--mono);font-size:11.5px;text-transform:uppercase;letter-spacing:0.12em;color:var(--g500);margin-bottom:8px;display:flex;align-items:center;gap:10px}
|
||||
.art-eyebrow::before{content:"";display:inline-block;width:24px;height:2px;background:var(--clay);flex:none}
|
||||
.art-head h2{font-size:26px;margin-bottom:6px}
|
||||
.art-head .sub{color:var(--g700);font-size:14.5px;max-width:66ch}
|
||||
.branchline{font-family:var(--mono);font-size:12.5px;color:var(--g700);background:var(--g100);
|
||||
border:1.5px solid var(--g300);border-radius:8px;padding:6px 10px;display:inline-block;margin-top:14px;overflow-x:auto;max-width:100%}
|
||||
.branchline .arr{color:var(--g500);margin:0 6px}
|
||||
|
||||
.art-body{padding:30px 34px 38px}
|
||||
@media (max-width:560px){
|
||||
.art-head{padding:22px 18px 18px}
|
||||
.art-body{padding:22px 18px 30px}
|
||||
h1.pagetitle{font-size:27px}
|
||||
}
|
||||
|
||||
/* stat chips */
|
||||
.stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:34px}
|
||||
.statchip{flex:1;min-width:130px;background:var(--g100);border:1.5px solid var(--g300);border-radius:12px;padding:14px 18px}
|
||||
.statchip .num{font-family:var(--serif);font-size:28px;font-weight:500;letter-spacing:-0.02em;line-height:1.1}
|
||||
.statchip .num.add{color:var(--olive)}
|
||||
.statchip .num.del{color:var(--clay-d)}
|
||||
.statchip .cap{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--g500);margin-top:4px}
|
||||
|
||||
.art-section{margin-bottom:38px}
|
||||
.art-section h3{font-size:20px;margin-bottom:6px}
|
||||
.art-section .secnote{color:var(--g500);font-size:13.5px;margin-bottom:16px}
|
||||
.art-section p{color:var(--g700);margin-bottom:12px;max-width:70ch}
|
||||
.art-section p strong{color:var(--slate)}
|
||||
|
||||
/* mental model diagram */
|
||||
.flow-pair{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}
|
||||
@media (max-width:680px){.flow-pair{grid-template-columns:1fr}}
|
||||
.flowcard{border:1.5px solid var(--g300);border-radius:12px;padding:16px 16px 18px;background:var(--ivory)}
|
||||
.flowcard.after{border-color:var(--clay)}
|
||||
.flowcard .fc-label{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:0.1em;margin-bottom:12px}
|
||||
.flowcard.before .fc-label{color:var(--g500)}
|
||||
.flowcard.after .fc-label{color:var(--clay-d)}
|
||||
.fnode{border:1.5px solid var(--g300);background:var(--paper);border-radius:8px;padding:7px 12px;
|
||||
font-family:var(--mono);font-size:12px;color:var(--g700);text-align:center}
|
||||
.fnode.new{border-color:var(--clay);background:#FBF1EC;color:var(--clay-d)}
|
||||
.fnode.oatf{background:var(--oat)}
|
||||
.farrow{display:flex;justify-content:center;align-items:center;height:22px;color:var(--g500);font-size:13px;line-height:1}
|
||||
.farrow.lab{gap:8px;font-family:var(--mono);font-size:10.5px}
|
||||
.fsplit{display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.fsplit .farrow{height:20px}
|
||||
|
||||
/* behavior blocks */
|
||||
.beh{border:1.5px solid var(--g300);border-radius:12px;margin-bottom:14px;overflow:hidden}
|
||||
.beh-head{display:flex;align-items:baseline;gap:12px;padding:14px 18px;background:var(--g100)}
|
||||
.beh-num{font-family:var(--mono);font-size:12px;color:var(--clay-d);font-weight:600;flex:none}
|
||||
.beh-head h4{font-family:var(--serif);font-weight:500;font-size:16.5px;letter-spacing:-0.01em}
|
||||
.beh-body{padding:14px 18px 16px}
|
||||
.beh-row{display:grid;grid-template-columns:58px 1fr;gap:10px;margin-bottom:9px;font-size:14px}
|
||||
.beh-row:last-child{margin-bottom:0}
|
||||
.beh-row .k{font-family:var(--mono);font-size:10.5px;text-transform:uppercase;letter-spacing:0.1em;color:var(--g500);padding-top:3px}
|
||||
.beh-row .v{color:var(--g700)}
|
||||
.fchip{display:inline-block;font-family:var(--mono);font-size:11.5px;background:var(--g100);
|
||||
border:1px solid var(--g300);border-radius:6px;padding:1.5px 7px;color:var(--g700);white-space:nowrap;margin:1px 2px 1px 0}
|
||||
|
||||
/* leans-on callout */
|
||||
.leans{border:1.5px solid var(--g300);border-left:4px solid var(--olive);border-radius:12px;
|
||||
background:var(--ivory);padding:16px 20px}
|
||||
.leans .lab{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--olive);margin-bottom:6px}
|
||||
.leans p{margin-bottom:0;font-size:14px}
|
||||
|
||||
/* ---- quiz ---- */
|
||||
.quiz-rule{display:flex;align-items:center;gap:16px;margin:44px 0 8px;
|
||||
font-family:var(--mono);font-size:11.5px;letter-spacing:0.12em;color:var(--g500);text-transform:uppercase}
|
||||
.quiz-rule::before,.quiz-rule::after{content:"";flex:1;height:1px;background:var(--g300)}
|
||||
.quiz-intro{display:flex;justify-content:space-between;align-items:flex-end;gap:16px;flex-wrap:wrap;margin-bottom:20px}
|
||||
.quiz-intro h3{font-size:22px}
|
||||
.quiz-intro .qsub{color:var(--g500);font-size:13.5px}
|
||||
.scorebox{font-family:var(--mono);font-size:12.5px;color:var(--g700);background:var(--g100);
|
||||
border:1.5px solid var(--g300);border-radius:10px;padding:8px 14px;white-space:nowrap}
|
||||
.scorebox b{color:var(--slate)}
|
||||
|
||||
.q{border:1.5px solid var(--g300);border-radius:12px;padding:18px 20px;margin-bottom:16px;background:var(--paper)}
|
||||
.q.answered-right{border-color:var(--olive)}
|
||||
.q.answered-wrong{border-color:var(--clay)}
|
||||
.q .qnum{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:0.1em;color:var(--g500);margin-bottom:6px}
|
||||
.q .qtext{font-family:var(--serif);font-size:17px;line-height:1.45;margin-bottom:14px;letter-spacing:-0.01em}
|
||||
.q .qtext code{font-family:var(--mono);font-size:14px;background:var(--g100);border:1px solid var(--g300);border-radius:5px;padding:1px 5px}
|
||||
.opt{display:flex;gap:10px;align-items:flex-start;width:100%;text-align:left;
|
||||
border:1.5px solid var(--g300);border-radius:9px;background:var(--ivory);padding:10px 14px;
|
||||
margin-bottom:8px;cursor:pointer;font:inherit;font-size:14px;color:var(--g700);transition:border-color .12s, background .12s}
|
||||
.opt:hover{border-color:var(--g500)}
|
||||
.opt .key{font-family:var(--mono);font-size:11.5px;color:var(--g500);flex:none;padding-top:2px}
|
||||
.opt.sel-right{border-color:var(--olive);background:#F1F4EB;color:var(--slate)}
|
||||
.opt.sel-right .key{color:var(--olive)}
|
||||
.opt.sel-wrong{border-color:var(--clay);background:#FBF1EC;color:var(--slate)}
|
||||
.opt.sel-wrong .key{color:var(--clay-d)}
|
||||
.opt.reveal-right{border-color:var(--olive)}
|
||||
.opt:disabled{cursor:default;opacity:1}
|
||||
.opt.dim{opacity:.55}
|
||||
.fb{border-radius:9px;padding:12px 15px;font-size:13.5px;margin-top:4px;display:none}
|
||||
.fb.show{display:block}
|
||||
.fb.good{background:#F1F4EB;border:1.5px solid var(--olive);color:var(--g700)}
|
||||
.fb.good .fbtag{color:var(--olive)}
|
||||
.fb.bad{background:#FBF1EC;border:1.5px solid var(--clay);color:var(--g700)}
|
||||
.fb.bad .fbtag{color:var(--clay-d)}
|
||||
.fbtag{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:0.1em;font-weight:600;margin-bottom:5px}
|
||||
.fb .excerpt{margin-top:8px;border-left:3px solid var(--g300);padding:6px 12px;background:var(--paper);
|
||||
border-radius:0 7px 7px 0;font-size:13px;color:var(--g700);font-style:italic;font-family:var(--serif)}
|
||||
.fb .exlab{font-family:var(--mono);font-style:normal;font-size:10.5px;text-transform:uppercase;letter-spacing:0.09em;color:var(--g500);display:block;margin-bottom:3px}
|
||||
|
||||
/* result cards */
|
||||
.result{display:none;margin-top:26px}
|
||||
.result.show{display:block}
|
||||
.cleared{border:1.5px solid var(--olive);border-radius:14px;background:#F1F4EB;padding:24px 26px}
|
||||
.cleared .clr-head{display:flex;align-items:center;gap:12px;margin-bottom:6px}
|
||||
.clr-badge{width:34px;height:34px;border-radius:50%;background:var(--olive);color:#fff;display:flex;
|
||||
align-items:center;justify-content:center;font-size:17px;flex:none}
|
||||
.cleared h3{font-size:21px}
|
||||
.cleared .clr-sub{color:var(--g700);font-size:14px;margin-bottom:16px}
|
||||
.mergelist{list-style:none}
|
||||
.mergelist li{display:flex;gap:10px;align-items:baseline;font-family:var(--mono);font-size:12.5px;
|
||||
color:var(--g700);padding:7px 0;border-bottom:1px dashed var(--g300)}
|
||||
.mergelist li:last-child{border-bottom:none}
|
||||
.mergelist .tick{color:var(--olive);font-weight:700;flex:none}
|
||||
.mergelist .pend{color:var(--g500);flex:none}
|
||||
.reread{border:1.5px solid var(--clay);border-radius:14px;background:#FBF1EC;padding:22px 26px}
|
||||
.reread h3{font-size:19px;margin-bottom:4px}
|
||||
.reread p{color:var(--g700);font-size:14px;margin-bottom:12px}
|
||||
.reread ul{list-style:none}
|
||||
.reread li{margin-bottom:7px}
|
||||
.reread a{color:var(--clay-d);font-size:14px;text-decoration:none;border-bottom:1px solid var(--clay)}
|
||||
.reread a:hover{color:var(--slate)}
|
||||
.retry{margin-top:14px;font-family:var(--mono);font-size:12px;padding:8px 16px;border:1.5px solid var(--g300);
|
||||
border-radius:8px;background:var(--paper);color:var(--g700);cursor:pointer}
|
||||
.retry:hover{border-color:var(--clay);color:var(--clay-d)}
|
||||
|
||||
footer{margin-top:44px;text-align:center;font-size:13px;color:var(--g500)}
|
||||
footer a{color:var(--clay-d);text-decoration:none}
|
||||
footer a:hover{text-decoration:underline}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- ============ PAGE HEADER ============ -->
|
||||
<div class="topline">
|
||||
<div class="eyebrow">Know your unknowns · Post-implementation</div>
|
||||
<a class="backlink" href="index.html">← All examples</a>
|
||||
</div>
|
||||
<h1 class="pagetitle">Quiz me before I merge</h1>
|
||||
<p class="lede">Asking for a report <em>with a quiz you must pass</em> turns "I skimmed the diff" into verified understanding — the artifact won't let you feel done until you actually are.</p>
|
||||
|
||||
<div class="promptcard">
|
||||
<div class="label">The Prompt</div>
|
||||
<button class="copybtn" id="copyBtn">Copy prompt</button>
|
||||
<blockquote id="promptText">I want to make sure I understand everything that happened in this change before I merge. Give me an HTML report on the export-feature diff — context, intuition, what was done — with a quiz at the bottom that I must pass.</blockquote>
|
||||
</div>
|
||||
|
||||
<div class="divider">What Claude Produced</div>
|
||||
|
||||
<!-- ============ ARTIFACT ============ -->
|
||||
<div class="artifact">
|
||||
<div class="art-head">
|
||||
<div class="art-eyebrow">Acme · Merge readiness report</div>
|
||||
<h2>Clip export: what changed and why</h2>
|
||||
<p class="sub">A 14-file diff that adds server-side clip export to review threads. Read the three sections below, then pass the quiz. If you can't, you're not ready to merge — and that's the point.</p>
|
||||
<div class="branchline">feature/clip-export<span class="arr">→</span>main · 14 files · 9 commits</div>
|
||||
</div>
|
||||
|
||||
<div class="art-body">
|
||||
|
||||
<!-- stats -->
|
||||
<div class="stats">
|
||||
<div class="statchip"><div class="num">14</div><div class="cap">files changed</div></div>
|
||||
<div class="statchip"><div class="num add">+612</div><div class="cap">lines added</div></div>
|
||||
<div class="statchip"><div class="num del">−148</div><div class="cap">lines removed</div></div>
|
||||
</div>
|
||||
|
||||
<!-- mental model -->
|
||||
<div class="art-section" id="sec-model">
|
||||
<h3>The mental model</h3>
|
||||
<p class="secnote">One diagram is worth 14 files: where an export request goes now vs. before.</p>
|
||||
<p>Before this change, "export a clip" meant the browser stitched frames client-side with <code>MediaRecorder</code> and uploaded the result — slow, tab-locked, and broken on Safari. Now the client only <strong>requests</strong> an export; a new worker renders it server-side from the original media and the client polls a job until a signed download URL appears.</p>
|
||||
<div class="flow-pair">
|
||||
<div class="flowcard before">
|
||||
<div class="fc-label">Before · client-side render</div>
|
||||
<div class="fnode">ReviewPlayer.tsx</div>
|
||||
<div class="farrow">↓</div>
|
||||
<div class="fnode">MediaRecorder capture (in-tab)</div>
|
||||
<div class="farrow lab"><span>↓</span><span>~40–90s, tab locked</span></div>
|
||||
<div class="fnode">PUT /uploads (whole file)</div>
|
||||
<div class="farrow">↓</div>
|
||||
<div class="fnode oatf">S3 exports/ bucket</div>
|
||||
</div>
|
||||
<div class="flowcard after">
|
||||
<div class="fc-label">After · server-side job</div>
|
||||
<div class="fnode">ReviewPlayer.tsx</div>
|
||||
<div class="farrow lab"><span>↓</span><span>POST /api/exports (returns job id)</span></div>
|
||||
<div class="fnode new">export-worker (new, ffmpeg)</div>
|
||||
<div class="fsplit">
|
||||
<div><div class="farrow">↓</div><div class="fnode oatf">S3 exports/ bucket</div></div>
|
||||
<div><div class="farrow">↓</div><div class="fnode new">jobs table (new)</div></div>
|
||||
</div>
|
||||
<div class="farrow lab"><span>↓</span><span>client polls every 2s</span></div>
|
||||
<div class="fnode">GET /api/exports/:id → signed URL</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- non-obvious behaviors -->
|
||||
<div class="art-section" id="sec-behaviors">
|
||||
<h3>Three non-obvious behaviors this introduces</h3>
|
||||
<p class="secnote">The parts a diff skim won't tell you. Each one is deliberate — here's the reasoning.</p>
|
||||
|
||||
<div class="beh" id="beh-1">
|
||||
<div class="beh-head"><span class="beh-num">01</span><h4>Exports render from the original upload, not the proxy the reviewer watched</h4></div>
|
||||
<div class="beh-body">
|
||||
<div class="beh-row"><span class="k">What</span><span class="v">The worker pulls <code class="fchip">media/originals/</code>, never the 720p review proxy. An exported clip can therefore look <em>sharper</em> than what the reviewer saw when they drew their annotations.</span></div>
|
||||
<div class="beh-row"><span class="k">Why</span><span class="v">Editors export clips to hand off to clients; shipping the compressed proxy would embarrass them. Annotation coordinates are stored normalized (0–1), so they re-project onto the full-res frame correctly.</span></div>
|
||||
<div class="beh-row"><span class="k">Where</span><span class="v"><span class="fchip">worker/export/render.ts:41</span> <span class="fchip">worker/export/burn_in.ts:88</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="beh" id="beh-2">
|
||||
<div class="beh-head"><span class="beh-num">02</span><h4>Export jobs survive a worker crash — via a visibility timeout, not retries</h4></div>
|
||||
<div class="beh-body">
|
||||
<div class="beh-row"><span class="k">What</span><span class="v">A job row is claimed by setting <code class="fchip">locked_until = now() + 10min</code>. If the worker dies mid-render, no retry fires; the lock simply expires and the next idle worker picks the job up from scratch.</span></div>
|
||||
<div class="beh-row"><span class="k">Why</span><span class="v">ffmpeg renders aren't idempotent mid-flight, and a retry queue would need dead-letter handling we don't want yet. Lock expiry gives at-least-once semantics with one column. Consequence: a crashed job looks "processing" for up to 10 minutes before recovering.</span></div>
|
||||
<div class="beh-row"><span class="k">Where</span><span class="v"><span class="fchip">db/migrations/0142_export_jobs.sql</span> <span class="fchip">worker/export/claim.ts:19</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="beh" id="beh-3">
|
||||
<div class="beh-head"><span class="beh-num">03</span><h4>Download URLs expire after 24 hours — the export itself lives 7 days</h4></div>
|
||||
<div class="beh-body">
|
||||
<div class="beh-row"><span class="k">What</span><span class="v">The signed S3 URL returned by <code class="fchip">GET /api/exports/:id</code> is valid for 24h, but the underlying object isn't deleted until a 7-day lifecycle rule fires. Re-hitting the endpoint mints a fresh URL with no re-render.</span></div>
|
||||
<div class="beh-row"><span class="k">Why</span><span class="v">Short URLs limit link-forwarding outside the workspace (exports can contain unreleased footage); the longer object TTL means a stale Slack link costs one API call to revive, not a 90-second re-render.</span></div>
|
||||
<div class="beh-row"><span class="k">Where</span><span class="v"><span class="fchip">api/exports/get.ts:57</span> <span class="fchip">infra/s3_lifecycle.tf:23</span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- leans on -->
|
||||
<div class="art-section" id="sec-leans">
|
||||
<div class="leans">
|
||||
<div class="lab">Existing behavior this leans on</div>
|
||||
<p><strong>Workspace-scoped signed-URL auth.</strong> <span class="fchip">GET /api/exports/:id</span> does no permission check of its own — it reuses the <span class="fchip">requireWorkspaceMember</span> middleware that already guards every media route. If that middleware's session handling ever changes (there's an open ticket, BL-2214, about guest reviewer sessions), export downloads change with it. Nothing in this diff would flag that.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ QUIZ ============ -->
|
||||
<div class="quiz-rule">Part 2 · Prove it</div>
|
||||
<div class="quiz-intro">
|
||||
<div>
|
||||
<h3>Six questions before you merge</h3>
|
||||
<div class="qsub">Not trivia — each one is a decision you'd have to make correctly during an incident or a review.</div>
|
||||
</div>
|
||||
<div class="scorebox">Score: <b id="scoreNow">0</b> / <b>6</b> · <span id="answeredNow">0</span> answered</div>
|
||||
</div>
|
||||
|
||||
<div id="quiz"></div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="result" id="resPass">
|
||||
<div class="cleared">
|
||||
<div class="clr-head"><div class="clr-badge">✓</div><h3>Cleared to merge</h3></div>
|
||||
<p class="clr-sub">6/6 — you can explain this change to whoever gets paged for it. Standard checklist below.</p>
|
||||
<ul class="mergelist">
|
||||
<li><span class="tick">✓</span>Understanding verified (this quiz, 6/6)</li>
|
||||
<li><span class="tick">✓</span>CI green on feature/clip-export · 412 tests</li>
|
||||
<li><span class="tick">✓</span>Migration 0142 reviewed — additive, no backfill</li>
|
||||
<li><span class="pend">○</span>Squash-merge with the summary above as the commit body</li>
|
||||
<li><span class="pend">○</span>Watch export-worker dashboards for 30 min post-deploy</li>
|
||||
<li><span class="pend">○</span>Note BL-2214 dependency in the merge comment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result" id="resFail">
|
||||
<div class="reread">
|
||||
<h3 id="failTitle">Not yet — re-read these sections</h3>
|
||||
<p>You missed the questions below. The gaps map straight back to the report:</p>
|
||||
<ul id="failLinks"></ul>
|
||||
<button class="retry" id="retryBtn">Reset quiz & try again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /art-body -->
|
||||
</div><!-- /artifact -->
|
||||
|
||||
<footer>Part of <a href="index.html">Know your unknowns</a> — companion examples to the blog post.</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
// ---- copy prompt ----
|
||||
var copyBtn = document.getElementById('copyBtn');
|
||||
copyBtn.addEventListener('click', function(){
|
||||
var t = document.getElementById('promptText').textContent;
|
||||
function done(){ copyBtn.textContent='Copied'; copyBtn.classList.add('copied');
|
||||
setTimeout(function(){copyBtn.textContent='Copy prompt';copyBtn.classList.remove('copied');},1600); }
|
||||
if (navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(t).then(done, function(){ fallback(); });
|
||||
} else { fallback(); }
|
||||
function fallback(){
|
||||
var ta=document.createElement('textarea'); ta.value=t; document.body.appendChild(ta);
|
||||
ta.select(); try{document.execCommand('copy');}catch(e){} document.body.removeChild(ta); done();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- quiz data ----
|
||||
var QS = [
|
||||
{
|
||||
q:'A reviewer says their exported clip "looks different from what I annotated." Assuming no bug, why?',
|
||||
opts:[
|
||||
'The export re-encodes at a lower bitrate to keep files small',
|
||||
'The export renders from the full-res original, while they reviewed the 720p proxy',
|
||||
'Annotations are dropped when the source is above 1080p',
|
||||
'Safari renders exports differently from Chrome'
|
||||
],
|
||||
correct:1,
|
||||
reinforce:'Right — exports pull media/originals/, so the clip can be sharper than the proxy the reviewer watched. Expected, not a bug.',
|
||||
excerptFrom:'Behavior 01', anchor:'beh-1', anchorLabel:'Behavior 01 — exports render from the original',
|
||||
excerpt:'The worker pulls media/originals/, never the 720p review proxy. An exported clip can therefore look sharper than what the reviewer saw when they drew their annotations.'
|
||||
},
|
||||
{
|
||||
q:'An export-worker pod is OOM-killed halfway through a render. What happens to that job?',
|
||||
opts:[
|
||||
'It moves to a dead-letter queue for manual replay',
|
||||
'It is retried immediately on another worker',
|
||||
'It shows "processing" until its 10-minute lock expires, then another worker restarts it from scratch',
|
||||
'It fails permanently and the user must click Export again'
|
||||
],
|
||||
correct:2,
|
||||
reinforce:'Exactly — no retry queue exists. Recovery is lock expiry: up to 10 minutes of apparent "processing", then a fresh render.',
|
||||
excerptFrom:'Behavior 02', anchor:'beh-2', anchorLabel:'Behavior 02 — crash recovery via lock expiry',
|
||||
excerpt:'If the worker dies mid-render, no retry fires; the lock simply expires and the next idle worker picks the job up from scratch… a crashed job looks "processing" for up to 10 minutes before recovering.'
|
||||
},
|
||||
{
|
||||
q:'A client pastes an export link into Slack; a teammate clicks it 3 days later and gets an S3 "expired" error. Cheapest fix?',
|
||||
opts:[
|
||||
'Re-run the export — the file was deleted after 24 hours',
|
||||
'Hit GET /api/exports/:id again — the object still exists for 7 days, so it just mints a fresh signed URL',
|
||||
'Bump the lifecycle rule in s3_lifecycle.tf to 30 days',
|
||||
'Ask an admin to make the exports/ bucket public-read'
|
||||
],
|
||||
correct:1,
|
||||
reinforce:'Yes — only the URL expired (24h). The object lives 7 days, so a fresh GET returns a new link with zero re-render cost.',
|
||||
excerptFrom:'Behavior 03', anchor:'beh-3', anchorLabel:'Behavior 03 — URL TTL vs object TTL',
|
||||
excerpt:'The signed S3 URL is valid for 24h, but the underlying object isn’t deleted until a 7-day lifecycle rule fires. Re-hitting the endpoint mints a fresh URL with no re-render.'
|
||||
},
|
||||
{
|
||||
q:'Ticket BL-2214 changes how guest reviewer sessions are validated. Why does this diff make that ticket riskier?',
|
||||
opts:[
|
||||
'Guests can now trigger unlimited exports and exhaust the worker pool',
|
||||
'Export downloads have no auth check of their own — they inherit whatever requireWorkspaceMember does, so a session change silently changes who can download',
|
||||
'The jobs table stores guest session tokens in plaintext',
|
||||
'It doesn’t — exports are unrelated to session handling'
|
||||
],
|
||||
correct:1,
|
||||
reinforce:'Correct — GET /api/exports/:id borrows requireWorkspaceMember wholesale. Change the middleware, change export access, with nothing in this diff to flag it.',
|
||||
excerptFrom:'Existing behavior this leans on', anchor:'sec-leans', anchorLabel:'Callout — leans on workspace auth middleware',
|
||||
excerpt:'GET /api/exports/:id does no permission check of its own — it reuses the requireWorkspaceMember middleware… If that middleware’s session handling ever changes (BL-2214), export downloads change with it.'
|
||||
},
|
||||
{
|
||||
q:'After deploy, a user reports "export stuck at processing for 15 minutes." Given the design, what does 15 minutes tell you?',
|
||||
opts:[
|
||||
'Nothing — long renders are normal for big clips',
|
||||
'The client’s 2-second polling loop probably disconnected',
|
||||
'Something is wrong beyond a single crash: one crash self-heals within the 10-minute lock window, so 15+ minutes means repeated failures or a stuck worker',
|
||||
'The S3 lifecycle rule deleted the file mid-render'
|
||||
],
|
||||
correct:2,
|
||||
reinforce:'Right — the 10-minute lock is the self-heal budget. Anything past it means the job is failing repeatedly or a worker is wedged: page-worthy, not wait-worthy.',
|
||||
excerptFrom:'Behavior 02', anchor:'beh-2', anchorLabel:'Behavior 02 — the 10-minute recovery window',
|
||||
excerpt:'Lock expiry gives at-least-once semantics with one column. Consequence: a crashed job looks "processing" for up to 10 minutes before recovering.'
|
||||
},
|
||||
{
|
||||
q:'Why do annotations still land in the right place even though the export renders at a different resolution than the review player?',
|
||||
opts:[
|
||||
'The worker scales the export down to match the proxy resolution first',
|
||||
'Annotation coordinates are stored normalized (0–1) and re-projected onto whatever frame size is rendered',
|
||||
'The client re-sends annotation pixels at export time',
|
||||
'burn_in.ts snaps annotations to a fixed 1080p grid'
|
||||
],
|
||||
correct:1,
|
||||
reinforce:'Yes — normalized coordinates are what make "render from the original" safe. Pixel-space storage would have broken every annotation in this diff.',
|
||||
excerptFrom:'Behavior 01', anchor:'beh-1', anchorLabel:'Behavior 01 — normalized annotation coordinates',
|
||||
excerpt:'Annotation coordinates are stored normalized (0–1), so they re-project onto the full-res frame correctly.'
|
||||
}
|
||||
];
|
||||
|
||||
var KEYS=['A','B','C','D'];
|
||||
var answers=new Array(QS.length).fill(null); // true/false/null
|
||||
var quizEl=document.getElementById('quiz');
|
||||
|
||||
function build(){
|
||||
quizEl.innerHTML='';
|
||||
answers=new Array(QS.length).fill(null);
|
||||
QS.forEach(function(item,qi){
|
||||
var qd=document.createElement('div'); qd.className='q'; qd.id='q'+qi;
|
||||
var h='<div class="qnum">Question '+(qi+1)+' of '+QS.length+'</div>'
|
||||
+'<div class="qtext">'+item.q+'</div>';
|
||||
item.opts.forEach(function(o,oi){
|
||||
h+='<button class="opt" data-q="'+qi+'" data-o="'+oi+'"><span class="key">'+KEYS[oi]+'</span><span>'+o+'</span></button>';
|
||||
});
|
||||
h+='<div class="fb" id="fb'+qi+'"></div>';
|
||||
qd.innerHTML=h;
|
||||
quizEl.appendChild(qd);
|
||||
});
|
||||
updateScore();
|
||||
document.getElementById('resPass').classList.remove('show');
|
||||
document.getElementById('resFail').classList.remove('show');
|
||||
}
|
||||
|
||||
quizEl.addEventListener('click', function(e){
|
||||
var btn=e.target.closest('.opt'); if(!btn||btn.disabled) return;
|
||||
var qi=+btn.dataset.q, oi=+btn.dataset.o, item=QS[qi];
|
||||
if(answers[qi]!==null) return;
|
||||
var right=(oi===item.correct);
|
||||
answers[qi]=right;
|
||||
var qd=document.getElementById('q'+qi);
|
||||
qd.classList.add(right?'answered-right':'answered-wrong');
|
||||
qd.querySelectorAll('.opt').forEach(function(b){
|
||||
b.disabled=true;
|
||||
var boi=+b.dataset.o;
|
||||
if(boi===oi) b.classList.add(right?'sel-right':'sel-wrong');
|
||||
else if(boi===item.correct && !right) b.classList.add('reveal-right');
|
||||
else b.classList.add('dim');
|
||||
});
|
||||
var fb=document.getElementById('fb'+qi);
|
||||
if(right){
|
||||
fb.className='fb good show';
|
||||
fb.innerHTML='<div class="fbtag">✓ Correct</div>'+item.reinforce;
|
||||
} else {
|
||||
fb.className='fb bad show';
|
||||
fb.innerHTML='<div class="fbtag">Not quite — the correct answer is '+KEYS[item.correct]+'</div>'
|
||||
+'From the report:'
|
||||
+'<div class="excerpt"><span class="exlab">'+item.excerptFrom+'</span>'+item.excerpt+'</div>';
|
||||
}
|
||||
updateScore();
|
||||
if(answers.every(function(a){return a!==null;})) finish();
|
||||
});
|
||||
|
||||
function updateScore(){
|
||||
var score=answers.filter(function(a){return a===true;}).length;
|
||||
var done=answers.filter(function(a){return a!==null;}).length;
|
||||
document.getElementById('scoreNow').textContent=score;
|
||||
document.getElementById('answeredNow').textContent=done;
|
||||
}
|
||||
|
||||
function finish(){
|
||||
var score=answers.filter(function(a){return a===true;}).length;
|
||||
if(score===QS.length){
|
||||
document.getElementById('resPass').classList.add('show');
|
||||
document.getElementById('resPass').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
} else {
|
||||
var missed=[]; answers.forEach(function(a,i){ if(a!==true) missed.push(i); });
|
||||
document.getElementById('failTitle').textContent='Not yet: '+score+'/'+QS.length+' — re-read these sections';
|
||||
var seen={}, ul=document.getElementById('failLinks'); ul.innerHTML='';
|
||||
missed.forEach(function(i){
|
||||
var it=QS[i];
|
||||
if(seen[it.anchor]) return; seen[it.anchor]=true;
|
||||
var li=document.createElement('li');
|
||||
li.innerHTML='<a href="#'+it.anchor+'">'+it.anchorLabel+'</a>';
|
||||
ul.appendChild(li);
|
||||
});
|
||||
document.getElementById('resFail').classList.add('show');
|
||||
document.getElementById('resFail').scrollIntoView({behavior:'smooth',block:'nearest'});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('retryBtn').addEventListener('click', function(){
|
||||
build();
|
||||
document.getElementById('quiz').scrollIntoView({behavior:'smooth',block:'start'});
|
||||
});
|
||||
|
||||
build();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,486 @@
|
||||
<!-- 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>Know your unknowns — 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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.backlink {
|
||||
position: absolute;
|
||||
top: 34px; right: 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--g500);
|
||||
text-decoration: none;
|
||||
}
|
||||
.backlink:hover { color: 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: 640px;
|
||||
}
|
||||
.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 — map vs territory quadrants */
|
||||
.hero-fig {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 880px) { .hero-fig { max-width: 360px; margin-top: 28px; } }
|
||||
.hero-fig .q {
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--g300);
|
||||
background: var(--paper);
|
||||
padding: 12px;
|
||||
aspect-ratio: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-fig .q .tag {
|
||||
font-family: var(--mono);
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--g500);
|
||||
}
|
||||
.hero-fig .q.kk { background: var(--g100); }
|
||||
.hero-fig .q.kk::before {
|
||||
content: ""; position: absolute; inset: 12px 12px 34px;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(var(--g300) 0 0) 0 0 / 80% 5px,
|
||||
linear-gradient(var(--g300) 0 0) 0 12px / 60% 5px,
|
||||
linear-gradient(var(--g300) 0 0) 0 24px / 70% 5px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
.hero-fig .q.ku::before {
|
||||
content: "?"; position: absolute; top: 2px; right: 12px;
|
||||
font-family: var(--serif); font-size: 44px; color: var(--oat);
|
||||
}
|
||||
.hero-fig .q.uk::before {
|
||||
content: ""; position: absolute; top: 16px; left: 16px;
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
border: 2.5px solid var(--olive); opacity: .55;
|
||||
}
|
||||
.hero-fig .q.uk::after {
|
||||
content: ""; position: absolute; top: 29px; left: 29px;
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--olive);
|
||||
}
|
||||
.hero-fig .q.uu {
|
||||
border-color: var(--slate);
|
||||
box-shadow: 0 10px 26px rgba(20,20,19,.10);
|
||||
}
|
||||
.hero-fig .q.uu::before {
|
||||
content: ""; position: absolute; top: 14px; left: 14px; right: 14px; height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 2px dashed var(--clay);
|
||||
opacity: .75;
|
||||
}
|
||||
.hero-fig .q.uu .tag { color: 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">
|
||||
<a class="backlink" href="../index.html">← html-effectiveness</a>
|
||||
<div class="hero-grid">
|
||||
<div>
|
||||
<div class="eyebrow">Companion to the blog post</div>
|
||||
<h1>Know your <em>unknowns</em></h1>
|
||||
<p class="intro">
|
||||
The map is not the territory — the gap between them is your unknowns. Eleven
|
||||
self-contained <code>.html</code> artifacts for discovering them before, during, and
|
||||
after implementation. Each page shows the exact prompt at the top and the artifact
|
||||
Claude produced below it: paste the prompt, get something like the page.
|
||||
</p>
|
||||
<nav class="toc">
|
||||
<a href="#pre">Pre-implementation <span class="n">8</span></a>
|
||||
<a href="#during">During implementation <span class="n">1</span></a>
|
||||
<a href="#post">Post-implementation <span class="n">2</span></a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="hero-fig" aria-hidden="true">
|
||||
<div class="q kk"><span class="tag">Known knowns</span></div>
|
||||
<div class="q ku"><span class="tag">Known unknowns</span></div>
|
||||
<div class="q uk"><span class="tag">Unknown knowns</span></div>
|
||||
<div class="q uu"><span class="tag">Unknown unknowns</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="pre">
|
||||
<div class="sec-head"><span class="idx">01</span><h2>Pre-implementation</h2><span class="count">8 demos</span></div>
|
||||
<p class="sec-intro">Before any code is written is the cheapest place to find an unknown. Ask for a blindspot pass when the territory is unfamiliar, brainstorm and prototype when you'll only know it when you see it, let Claude interview you about the rest, and hand it references when words run out.</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="01-blindspot-pass.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="12" y="8" width="68" height="64" rx="6"/><line class="ln" x1="22" y1="20" x2="62" y2="20"/><line class="ln" x1="22" y1="30" x2="70" y2="30"/><line class="ln" x1="22" y1="40" x2="56" y2="40"/><line class="ln" x1="22" y1="50" x2="66" y2="50"/><line class="ln" x1="22" y1="60" x2="48" y2="60"/><circle class="cl" cx="70" cy="40" r="3.5"/><circle class="cl" cx="56" cy="60" r="3.5"/><circle class="lc da" cx="96" cy="32" r="15" fill="none"/><line class="lc" x1="106" y1="43" x2="114" y2="52"/><rect class="oa" x="88" y="58" width="24" height="14" rx="4"/><line class="ln" x1="93" y1="65" x2="107" y2="65"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Blindspot pass</div>
|
||||
<div class="desc">Claude scans an unfamiliar auth module and reports your unknown unknowns — seven blindspot cards, each with a copyable prompt fix, assembled into one better implementation prompt.</div>
|
||||
<div class="file"><span>01-blindspot-pass.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="02-color-grading-explainer.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="8" y="8" width="104" height="46" rx="4"/><rect class="oa" x="8" y="8" width="104" height="20" rx="4"/><circle class="cl" cx="90" cy="20" r="6"/><path class="ol" d="M14 52 L38 28 L60 52 Z"/><path class="ol" d="M48 52 L74 32 L100 52 Z"/><line class="lc da" x1="60" y1="8" x2="60" y2="54"/><line class="ln" x1="16" y1="64" x2="56" y2="64"/><circle class="cl" cx="40" cy="64" r="4"/><line class="ln" x1="66" y1="64" x2="106" y2="64"/><circle class="cl" cx="88" cy="64" r="4"/><line class="ln" x1="16" y1="73" x2="56" y2="73"/><circle class="sl" cx="28" cy="73" r="4"/><line class="ln" x1="66" y1="73" x2="106" y2="73"/><circle class="sl" cx="98" cy="73" r="4"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Teach me my unknowns</div>
|
||||
<div class="desc">An interactive color-grading explainer — vocabulary ladder, live before/after frame with sliders and presets — that turns "make the video nicer" into precise professional prompts.</div>
|
||||
<div class="file"><span>02-color-grading-explainer.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="03-design-directions.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="sl" x="6" y="8" width="50" height="30" rx="4"/><rect class="cl" x="12" y="14" width="12" height="4" rx="2"/><rect class="fl" x="12" y="23" width="38" height="3" rx="1.5"/><rect class="fl" x="12" y="30" width="30" height="3" rx="1.5"/><rect class="wh" x="64" y="8" width="50" height="30" rx="4"/><circle class="cl" cx="77" cy="23" r="7"/><rect class="fl" x="90" y="17" width="18" height="4" rx="2"/><rect class="fl" x="90" y="25" width="13" height="4" rx="2"/><rect class="wh" x="6" y="44" width="50" height="30" rx="4"/><rect class="fl" x="11" y="50" width="12" height="18" rx="2"/><rect class="oa" x="25" y="50" width="12" height="18" rx="2"/><rect class="ol" x="39" y="50" width="12" height="18" rx="2"/><rect class="sl" x="64" y="44" width="50" height="30" rx="4"/><line class="lc" x1="70" y1="52" x2="96" y2="52"/><line class="ln" x1="70" y1="59" x2="104" y2="59"/><line class="ln" x1="70" y1="66" x2="88" y2="66"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Four design directions</div>
|
||||
<div class="desc">The same review queue rendered four wildly different ways — ops console, editorial, kanban, terminal — with steal/skip chips that write your reply for you.</div>
|
||||
<div class="file"><span>03-design-directions.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="04-toolbar-mock.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="oa" x="8" y="10" width="104" height="50" rx="5"/><circle class="cl" cx="90" cy="24" r="7"/><path class="ol" d="M10 48 Q 40 34 68 46 T 110 42 L 110 55 Q 110 58 105 58 L 15 58 Q 10 58 10 55 Z"/><rect class="st da" x="18" y="18" width="28" height="15" rx="2"/><path class="lc" d="M98 46 L 86 34"/><rect class="wh" x="28" y="48" width="64" height="13" rx="6.5"/><circle class="cl" cx="38" cy="54.5" r="2.5"/><circle class="fl" cx="47" cy="54.5" r="2.5"/><circle class="fl" cx="56" cy="54.5" r="2.5"/><circle class="ol" cx="68" cy="54.5" r="2.5"/><circle class="sl" cx="77" cy="54.5" r="2.5"/><line class="ln" x1="14" y1="70" x2="106" y2="70"/><line class="lc" x1="14" y1="70" x2="46" y2="70"/><circle class="cl" cx="46" cy="70" r="3"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Mock before you wire</div>
|
||||
<div class="desc">A clickable throwaway mock of Acme's frame-annotation toolbar — three toggleable placements, A/B questions, and a self-filling reply template — before any real code is touched.</div>
|
||||
<div class="file"><span>04-toolbar-mock.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="05-churn-brainstorm.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><line class="ln" x1="10" y1="16" x2="110" y2="16"/><circle class="wh" cx="20" cy="16" r="5"/><circle class="cl" cx="38" cy="16" r="5"/><circle class="wh" cx="56" cy="16" r="5"/><circle class="cl" cx="76" cy="16" r="5"/><circle class="st" cx="100" cy="16" r="5"/><rect class="wh" x="10" y="30" width="100" height="20" rx="4"/><rect class="cl" x="16" y="36" width="8" height="8" rx="2"/><line class="ln" x1="30" y1="38" x2="86" y2="38"/><line class="ln" x1="30" y1="44" x2="70" y2="44"/><rect class="oa" x="92" y="35" width="12" height="10" rx="3"/><rect class="wh" x="10" y="56" width="100" height="20" rx="4"/><rect class="wh" x="16" y="62" width="8" height="8" rx="2"/><line class="ln" x1="30" y1="64" x2="82" y2="64"/><line class="lc da" x1="30" y1="70" x2="62" y2="70"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Brainstorm the intervention</div>
|
||||
<div class="desc">Ten codebase-grounded churn interventions plotted from ship-this-afternoon to quarter-long bet, with resonate checkboxes that assemble your reply.</div>
|
||||
<div class="file"><span>05-churn-brainstorm.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="06-interview.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><circle class="cl" cx="12" cy="16" r="4"/><circle class="ol" cx="12" cy="30" r="4"/><circle class="st" cx="12" cy="44" r="4"/><circle class="st da" cx="12" cy="58" r="4"/><line class="ln" x1="12" y1="20" x2="12" y2="26"/><line class="ln da" x1="12" y1="34" x2="12" y2="54"/><rect class="wh" x="26" y="10" width="88" height="60" rx="6"/><rect class="cl" x="34" y="18" width="24" height="6" rx="3"/><line class="ln" x1="34" y1="32" x2="104" y2="32"/><rect class="oa" x="34" y="40" width="34" height="22" rx="4"/><rect class="wh" x="72" y="40" width="34" height="22" rx="4"/><line class="lc" x1="78" y1="51" x2="88" y2="51"/><line class="ln" x1="40" y1="51" x2="60" y2="51"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">The interview</div>
|
||||
<div class="desc">Claude interviews you one question at a time about an ambiguous feature, ordered by architectural blast radius, then hands back a decisions table and a ready-to-paste implementation prompt.</div>
|
||||
<div class="file"><span>06-interview.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="07-reference-port.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="6" y="10" width="48" height="60" rx="6"/><rect class="wh" x="66" y="10" width="48" height="60" rx="6"/><rect class="fl" x="13" y="19" width="34" height="4" rx="2"/><rect class="fl" x="13" y="29" width="26" height="4" rx="2"/><rect class="cl" x="13" y="39" width="32" height="5" rx="2.5"/><rect class="fl" x="13" y="50" width="28" height="4" rx="2"/><rect class="fl" x="73" y="19" width="30" height="4" rx="2"/><rect class="fl" x="73" y="29" width="34" height="4" rx="2"/><rect class="cl" x="73" y="39" width="30" height="5" rx="2.5"/><rect class="fl" x="73" y="50" width="24" height="4" rx="2"/><line class="lc da" x1="45" y1="41.5" x2="73" y2="41.5"/><circle class="ol" cx="60" cy="62" r="6"/><path class="ln" d="M57.5 62 l2 2 l3.5 -4" style="stroke:#FAF9F5"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Point at a reference</div>
|
||||
<div class="desc">A semantics map that proves Claude understood a Rust reference implementation — matched code excerpts, gotcha notes, and edge-case tables — before porting it to TypeScript.</div>
|
||||
<div class="file"><span>07-reference-port.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="08-implementation-plan.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="8" y="6" width="104" height="68" rx="6"/><rect class="cl" x="16" y="14" width="5" height="12" rx="2"/><rect class="oa" x="27" y="14" width="60" height="12" rx="3"/><rect class="wh" x="93" y="14" width="12" height="12" rx="3"/><rect class="cl" x="16" y="32" width="5" height="10" rx="2"/><rect class="fl" x="27" y="32" width="48" height="10" rx="3"/><line class="lc" x1="16" y1="50" x2="104" y2="50"/><line class="ln da" x1="16" y1="58" x2="96" y2="58"/><line class="ln da" x1="16" y1="66" x2="72" y2="66"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">The tweakable plan</div>
|
||||
<div class="desc">An implementation plan sorted by likelihood-of-tweaking instead of execution order — flagged schema choices with toggleable alternatives, annotated type interfaces, and the mechanical work collapsed at the bottom.</div>
|
||||
<div class="file"><span>08-implementation-plan.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="during">
|
||||
<div class="sec-head"><span class="idx">02</span><h2>During implementation</h2><span class="count">1 demo</span></div>
|
||||
<p class="sec-intro">No matter how much planning you do, unknowns lurk in the territory. Have Claude keep a running log of every place the code forced a deviation from the plan, so the next attempt starts smarter.</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="09-implementation-notes.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="12" y="4" width="96" height="72" rx="5"/><line class="ln" x1="27" y1="14" x2="27" y2="68"/><circle class="ol" cx="27" cy="14" r="3.5"/><line class="ln" x1="37" y1="14" x2="98" y2="14"/><circle class="cl" cx="27" cy="28" r="3.5"/><line class="lc" x1="37" y1="28" x2="88" y2="28"/><rect class="oa" x="37" y="34" width="52" height="10" rx="2"/><circle class="fl" cx="27" cy="54" r="3.5"/><line class="ln" x1="37" y1="54" x2="94" y2="54"/><circle class="sl" cx="27" cy="66" r="3.5"/><line class="ln" x1="37" y1="66" x2="78" y2="66"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Implementation notes</div>
|
||||
<div class="desc">The running log Claude kept during a 3-hour build — every deviation from the plan captured with the conservative call it made, and three bullets to fold into attempt #2.</div>
|
||||
<div class="file"><span>09-implementation-notes.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="post">
|
||||
<div class="sec-head"><span class="idx">03</span><h2>Post-implementation</h2><span class="count">2 demos</span></div>
|
||||
<p class="sec-intro">Shipping means other people inheriting your unknowns. A pitch doc answers the objections reviewers were about to raise; a quiz proves you actually understand what changed before you merge it.</p>
|
||||
<div class="grid">
|
||||
<a class="card" href="10-pitch-doc.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="6" y="6" width="108" height="68" rx="5"/><rect class="oa" x="14" y="14" width="58" height="32" rx="4"/><path class="cl" d="M38 22 L52 30 L38 38 Z"/><line class="ln" x1="80" y1="20" x2="106" y2="20"/><line class="ln" x1="80" y1="28" x2="106" y2="28"/><line class="lc" x1="80" y1="36" x2="98" y2="36"/><line class="ln" x1="14" y1="54" x2="76" y2="54"/><line class="ln" x1="14" y1="62" x2="60" y2="62"/><rect class="ol" x="88" y="52" width="22" height="14" rx="7"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">The buy-in doc</div>
|
||||
<div class="desc">A ship-it pitch that leads with an animated demo of the export flow, then pre-answers every reviewer objection with evidence and names exactly who needs to sign off on what.</div>
|
||||
<div class="file"><span>10-pitch-doc.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="11-change-quiz.html">
|
||||
<div class="thumb"><svg viewBox="0 0 120 80"><rect class="wh" x="6" y="8" width="108" height="30" rx="5"/><line class="ln" x1="14" y1="16" x2="70" y2="16"/><line class="ln" x1="14" y1="23" x2="92" y2="23"/><line class="ln" x1="14" y1="30" x2="58" y2="30"/><rect class="oa" x="80" y="12" width="26" height="10" rx="3"/><rect class="wh" x="6" y="44" width="108" height="12" rx="4"/><circle class="ol" cx="14" cy="50" r="4"/><line class="ln" x1="24" y1="50" x2="80" y2="50"/><rect class="wh" x="6" y="60" width="108" height="12" rx="4"/><circle class="cl" cx="14" cy="66" r="4"/><line class="ln" x1="24" y1="66" x2="66" y2="66"/><path class="lc" d="M96 62 l4 5 l7 -8" fill="none" stroke-width="2.5"/></svg></div>
|
||||
<div class="body">
|
||||
<div class="title">Quiz me before I merge</div>
|
||||
<div class="desc">A merge-readiness report on a 14-file diff that ends in a six-question quiz you must pass — wrong answers point you back to the exact section you skimmed.</div>
|
||||
<div class="file"><span>11-change-quiz.html</span><span class="arrow">→</span></div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span class="k">Every explainer, brainstorm, interview, and prototype is a cheap way to find out what you didn't know.</span>
|
||||
<span><a href="../index.html">All HTML-effectiveness examples</a></span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user