- Open questions
-
All questions →
+
diff --git a/packages/wherefore-dashboard/src/pages/plan.astro b/packages/wherefore-dashboard/src/pages/plan.astro
new file mode 100644
index 0000000..f26aedb
--- /dev/null
+++ b/packages/wherefore-dashboard/src/pages/plan.astro
@@ -0,0 +1,158 @@
+---
+import { getCollection } from 'astro:content';
+import Base from '../layouts/Base.astro';
+import PlanRow from '../components/PlanRow.astro';
+import { taskProgress, isBlocked } from '../lib/plan';
+
+const allPlan = await getCollection('plan');
+const allQuestions = await getCollection('questions');
+
+const questionStatusById = new Map(allQuestions.map(q => [q.id, q.data.status]));
+
+const cards = allPlan.map(p => ({
+ id: p.id,
+ data: p.data,
+ blocked: isBlocked({ status: p.data.status, questionRef: p.data.questionRef }, questionStatusById),
+ progress: taskProgress(p.body ?? ''),
+}));
+type Card = (typeof cards)[number];
+
+// Newest first within a section: `updated` when present (bumped on any write),
+// else `created`. IDs are sequential too, so this also reads as newest-item-first.
+const recency = (c: Card) => c.data.updated ?? c.data.created;
+const byRecency = (a: Card, b: Card) => recency(b).localeCompare(recency(a));
+
+const inFlight = cards.filter(c => c.data.status === 'doing' && !c.blocked).sort(byRecency);
+const blocked = cards.filter(c => c.blocked).sort(byRecency);
+const upNext = cards.filter(c => c.data.status === 'todo' && !c.blocked).sort(byRecency);
+const done = cards.filter(c => c.data.status === 'done').sort(byRecency);
+const dropped = cards.filter(c => c.data.status === 'dropped').sort(byRecency);
+
+const nonDropped = cards.filter(c => c.data.status !== 'dropped');
+const overallPct = nonDropped.length ? Math.round((done.length / nonDropped.length) * 100) : 0;
+
+const allAreas = [...new Set(allPlan.map(p => p.data.area).filter((a): a is string => !!a))].sort();
+
+const sections = [
+ { key: 'in-flight', head: 'In flight', items: inFlight, dropped: false },
+ { key: 'blocked', head: 'Blocked', items: blocked, dropped: false },
+ { key: 'up-next', head: 'Up next', items: upNext, dropped: false },
+ { key: 'done', head: 'Done', items: done, dropped: false },
+ { key: 'dropped', head: 'Dropped', items: dropped, dropped: true },
+].filter(s => s.items.length > 0);
+---
+
+ {allPlan.length === 0 ? (
+
No plan items yet.
+ ) : (
+ <>
+
+ {inFlight.length} doing
+ {blocked.length} blocked
+ {upNext.length} todo
+ {done.length} done
+ {dropped.length > 0 && (
+ {dropped.length} dropped
+ )}
+
+
+
+
+ {done.length} of {nonDropped.length} done · dropped excluded
+
+
+
+ {allAreas.length > 0 && (
+
+ area: all
+ {allAreas.map(a => {a} )}
+
+ )}
+
+ {dropped.length > 0 && (
+ <>
+
+
+
+ show dropped
+
+ >
+ )}
+
+
+
No matching plan items.
+
+ {sections.map(section => (
+
+ {section.head}
+
+ {section.items.map(c => (
+
+ ))}
+
+
+ ))}
+ >
+ )}
+
+
+
diff --git a/packages/wherefore-dashboard/src/pages/plan/[slug].astro b/packages/wherefore-dashboard/src/pages/plan/[slug].astro
new file mode 100644
index 0000000..231f7c1
--- /dev/null
+++ b/packages/wherefore-dashboard/src/pages/plan/[slug].astro
@@ -0,0 +1,108 @@
+---
+import { getCollection, render } from 'astro:content';
+import Base from '../../layouts/Base.astro';
+import { taskProgress, isBlocked } from '../../lib/plan';
+
+export async function getStaticPaths() {
+ const items = await getCollection('plan');
+ return items.map(e => ({ params: { slug: e.id }, props: { entry: e } }));
+}
+
+const { entry } = Astro.props;
+const { Content } = await render(entry);
+
+const allLog = await getCollection('log');
+const allQuestions = await getCollection('questions');
+const logTitleById = new Map(allLog.map(e => [e.id, e.data.title]));
+const questionById = new Map(allQuestions.map(q => [q.id, q]));
+const questionStatusById = new Map(allQuestions.map(q => [q.id, q.data.status]));
+
+const d = entry.data;
+const blocked = isBlocked({ status: d.status, questionRef: d.questionRef }, questionStatusById);
+const progress = taskProgress(entry.body ?? '');
+const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
+
+const dotClass = blocked ? 'is-blocked' : `is-${d.status}`;
+const label = blocked ? 'blocked' : d.status;
+
+const decisions = d.decisionRefs.map(slug => ({ slug, title: logTitleById.get(slug) ?? slug }));
+const blockingQuestion = d.questionRef ? questionById.get(d.questionRef) ?? null : null;
+const answeredQuestion = d.answers ? questionById.get(d.answers) ?? null : null;
+const decisionLabel = d.status === 'dropped' ? 'Retired by' : 'Decision';
+---
+
+
← back to plan
+
+ {d.status === 'dropped' && (
+
+ Dropped{d.updated ? ` on ${d.updated}` : ''}. Kept for history, not current.
+
+ )}
+
+ {blocked && (
+
+ Blocked on
{d.questionRef} {blockingQuestion ? `: ${blockingQuestion.data.question}` : ''}
+
+ )}
+
+
+
+
+
{entry.data.title}
+ {label}
+
+
+
+ {d.area && {d.area} }
+ {d.topics.map(t => {t} )}
+ {d.milestone && {d.milestone} }
+ created {d.created}{d.updated ? ` · updated ${d.updated}` : ''}
+
+
+ {progress.total > 0 && (
+
+
+ {progress.done} of {progress.total} done
+
+ )}
+
+ {entry.body?.trim() && (
+
+ )}
+
+ {d.droppedReason && (
+
+
Dropped reason
+
{d.droppedReason}
+
+ )}
+
+ {(decisions.length > 0 || blockingQuestion || answeredQuestion || d.questionRef || d.answers) && (
+
+
References
+
+ {decisions.map(dec => (
+
+ {decisionLabel}
+ {dec.title}
+
+ ))}
+ {d.questionRef && (
+
+ Blocked by
+ {d.questionRef}
+ {blockingQuestion && {blockingQuestion.data.question} }
+
+ )}
+ {d.answers && (
+
+ Answers
+ {d.answers}
+ {answeredQuestion && {answeredQuestion.data.question} }
+
+ )}
+
+
+ )}
+
+
diff --git a/packages/wherefore-dashboard/src/styles/global.css b/packages/wherefore-dashboard/src/styles/global.css
index e38a541..d1c3ea1 100644
--- a/packages/wherefore-dashboard/src/styles/global.css
+++ b/packages/wherefore-dashboard/src/styles/global.css
@@ -883,3 +883,290 @@ html[data-theme="light"] .theme-toggle::before { content: '●'; }
padding:3px 8px; border-radius:6px;
}
.wf-q .txt { font-size:14px; line-height:1.55; color:var(--wf-soft); }
+
+/* ============================================================
+ Plan collection (/plan browse, /plan/[slug] detail, now view)
+ Extends the brand tokens above with plan-status colors; the
+ status vocabulary (todo/doing/done/dropped + derived blocked)
+ is separate from the log's active/superseded/obsolete.
+ ============================================================ */
+
+:root {
+ --plan-doing: var(--teal-bright);
+ --plan-blocked: #c48a6a; /* warm, desaturated marker (not a 2nd brand color) */
+ --plan-blocked-tint: rgba(196,138,106,.12);
+ --plan-blocked-bd: rgba(196,138,106,.32);
+ --plan-track: #0f1014; /* progress-bar track */
+}
+
+@media (prefers-color-scheme: light) {
+ html:not([data-theme="dark"]) {
+ --plan-doing: var(--teal-dk);
+ --plan-blocked: #a8623f;
+ --plan-blocked-tint: rgba(168,98,63,.12);
+ --plan-blocked-bd: rgba(168,98,63,.34);
+ --plan-track: #ece9e2;
+ }
+}
+
+html[data-theme="light"] {
+ --plan-doing: var(--teal-dk);
+ --plan-blocked: #a8623f;
+ --plan-blocked-tint: rgba(168,98,63,.12);
+ --plan-blocked-bd: rgba(168,98,63,.34);
+ --plan-track: #ece9e2;
+}
+
+/* ── Count strip (browse header + now-view counts) ───────────── */
+.count-strip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 18px;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--faint);
+ margin-bottom: 16px;
+}
+.count { display: inline-flex; align-items: center; gap: 6px; }
+.count b { color: var(--soft); font-weight: 500; }
+.count.is-dropped { opacity: .6; }
+.now-counts { margin-bottom: 24px; }
+
+/* ── Status dot ──────────────────────────────────────────────── */
+.status-dot {
+ width: 8px; height: 8px; border-radius: 50%;
+ flex: none; display: inline-block;
+ background: var(--faint2);
+}
+.status-dot.is-doing { background: var(--plan-doing); }
+.status-dot.is-blocked { background: var(--plan-blocked); }
+.status-dot.is-todo { background: transparent; box-shadow: inset 0 0 0 1.5px var(--faint); }
+.status-dot.is-done { background: var(--faint); }
+.status-dot.is-dropped { background: transparent; box-shadow: inset 0 0 0 1.5px var(--faint2); }
+.status-dot.is-open { background: var(--muted); }
+
+/* ── Overall progress bar (browse header) ────────────────────── */
+.overall { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
+.overall-track {
+ flex: 1; max-width: 320px; height: 5px; border-radius: 3px;
+ background: var(--plan-track); overflow: hidden;
+}
+.overall-fill { display: block; height: 100%; background: var(--plan-doing); border-radius: 3px; }
+.overall-label { font-family: var(--mono); font-size: 11.5px; color: var(--faint); }
+
+/* ── Sections + list ─────────────────────────────────────────── */
+.plan-section { margin-top: 26px; }
+.plan-section[hidden] { display: none; }
+.plan-list { list-style: none; }
+
+/* ── List row (density unit) ─────────────────────────────────── */
+.plan-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 0;
+ border-top: 1px solid var(--border-soft);
+}
+.plan-list > .plan-row:first-child { border-top: none; }
+.plan-row[hidden] { display: none; }
+
+.plan-row-title {
+ flex: 1 1 auto;
+ min-width: 0;
+ font-family: var(--display);
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--text);
+ letter-spacing: -.01em;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.plan-row-title:hover { color: var(--teal-tag); }
+
+.row-dropped { opacity: .5; }
+.row-dropped .plan-row-title {
+ text-decoration: line-through;
+ text-decoration-color: var(--faint2);
+ color: var(--muted);
+}
+
+/* ── Milestone tag ───────────────────────────────────────────── */
+.milestone-tag {
+ flex: none;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--muted);
+ background: var(--surface-faint);
+ border-radius: 4px;
+ padding: 2px 7px;
+}
+
+/* ── Progress bar (row + card) ───────────────────────────────── */
+.plan-progress { flex: none; display: inline-flex; align-items: center; gap: 8px; }
+.plan-progress-track {
+ width: 64px; height: 4px; border-radius: 2px;
+ background: var(--plan-track); overflow: hidden;
+}
+.plan-progress-fill { display: block; height: 100%; background: var(--plan-doing); border-radius: 2px; }
+.progress-count { font-family: var(--mono); font-size: 11px; color: var(--faint); }
+
+/* ── Status label (right-aligned on rows) ────────────────────── */
+.plan-status-label {
+ flex: none;
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: .04em;
+ color: var(--faint);
+ min-width: 58px;
+ text-align: right;
+}
+.plan-status-label.is-doing { color: var(--plan-doing); }
+.plan-status-label.is-blocked { color: var(--plan-blocked); }
+
+/* ── In-flight cards (now view) ──────────────────────────────── */
+.plan-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
+.plan-card {
+ border: 1px solid var(--border-soft);
+ border-left: 2px solid var(--plan-doing);
+ border-radius: var(--radius);
+ background: var(--surface-faint);
+ padding: 16px 18px;
+}
+.plan-card-eyebrow {
+ display: flex; align-items: center; gap: 8px;
+ font-family: var(--mono); font-size: 10.5px;
+ text-transform: uppercase; letter-spacing: .12em;
+ color: var(--plan-doing);
+ margin-bottom: 8px;
+}
+.plan-card-title {
+ display: block;
+ font-family: var(--display);
+ font-size: 16px; font-weight: 500;
+ color: var(--text); letter-spacing: -.01em;
+}
+.plan-card-title:hover { color: var(--teal-tag); }
+
+.detail-progress { display: flex; align-items: center; gap: 10px; margin: 12px 0; }
+.detail-progress .plan-progress-track { width: 140px; }
+
+.plan-body { max-width: none; }
+.plan-card-body { margin-top: 6px; }
+.plan-card-body p, .plan-card-body li { font-size: 14px; }
+
+/* GFM task lists rendered from plan bodies.
+ Each item is a normal block with a hanging indent and the checkbox in the left
+ margin, so text, inline code, and links flow as one wrapped line. (A flex row here
+ makes every text run and code chip its own shrinking flex item and shreds the step
+ into columns.) */
+.plan-body ul.contains-task-list { list-style: none; padding-left: 0; }
+.plan-body ul.contains-task-list ul.contains-task-list { padding-left: 1.5em; margin: 4px 0; }
+.plan-body .task-list-item {
+ display: block;
+ position: relative;
+ padding-left: 1.7em;
+ margin: 7px 0;
+ line-height: 1.6;
+}
+.plan-body .task-list-item input[type="checkbox"] {
+ position: absolute;
+ left: 0;
+ top: 0.34em;
+ margin: 0;
+ accent-color: var(--teal);
+}
+.plan-body .task-list-item:has(input:checked) {
+ color: var(--faint);
+ text-decoration: line-through;
+ text-decoration-color: var(--faint2);
+}
+
+/* Calmer inline code inside plan bodies: neutral, not the teal accent, so a step
+ dense with code spans stays scannable. Later than .entry-body code, so it wins. */
+.plan-body code {
+ color: var(--soft);
+ background: var(--surface-faint);
+ font-size: 0.86em;
+ padding: 0.5px 4px;
+ border-radius: 4px;
+}
+
+/* "Checklist" divider above the task list, detail page only (scoped off the compact
+ now-view cards and off nested lists; shows only when a task list exists). */
+.plan-detail .plan-body > ul.contains-task-list::before {
+ content: "Checklist";
+ display: block;
+ font-family: var(--mono);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--faint);
+ margin: 4px 0 10px;
+}
+
+/* ── Blocked cards (now view) ────────────────────────────────── */
+.blocked-cards { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
+.blocked-card {
+ border: 1px solid var(--border-soft);
+ border-left: 2px solid var(--plan-blocked);
+ border-radius: var(--radius);
+ padding: 14px 16px;
+}
+.blocked-card-head { display: flex; align-items: center; gap: 10px; }
+.blocked-q { display: flex; gap: 10px; margin-top: 10px; padding: 10px 12px; background: var(--surface-faint); border-radius: 7px; }
+.q-marker { flex: none; font-family: var(--mono); font-weight: 600; color: var(--plan-blocked); }
+.q-text-inline { font-size: 14px; color: var(--soft); line-height: 1.5; }
+.q-meta-inline { font-family: var(--mono); font-size: 11px; color: var(--faint); margin-top: 3px; }
+
+/* ── Now-view footer (two columns) ───────────────────────────── */
+.now-footer { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
+
+/* ── Plan detail page ────────────────────────────────────────── */
+.plan-detail { max-width: 660px; }
+.plan-detail-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
+.plan-detail-head .detail-title { margin-bottom: 0; }
+.plan-detail.is-dropped { opacity: .72; }
+.plan-detail.is-dropped .detail-title {
+ text-decoration: line-through;
+ text-decoration-color: var(--faint2);
+ color: var(--muted);
+}
+
+.callout-blocked {
+ border-color: var(--plan-blocked-bd);
+ background: var(--plan-blocked-tint);
+ color: var(--plan-blocked);
+}
+.callout-blocked a { color: var(--text); text-decoration: underline; }
+
+.detail-sub-label {
+ font-family: var(--mono); font-size: 11px;
+ text-transform: uppercase; letter-spacing: .08em;
+ color: var(--faint); margin: 22px 0 6px;
+}
+.dropped-reason { margin-top: 20px; padding: 12px 16px; background: var(--surface-faint); border-radius: var(--radius); }
+.dropped-reason p { font-size: 14px; color: var(--soft); line-height: 1.55; }
+
+.cross-refs { margin-top: 26px; }
+.ref-list { list-style: none; margin-top: 10px; }
+.ref-list li {
+ display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
+ padding: 8px 0;
+ border-top: 1px solid var(--border-soft);
+ font-size: 14px;
+}
+.ref-kind {
+ font-family: var(--mono); font-size: 11px;
+ text-transform: uppercase; letter-spacing: .06em;
+ color: var(--faint); min-width: 84px;
+}
+.ref-list a { color: var(--teal-tag); }
+.ref-list a:hover { text-decoration: underline; }
+.ref-text { color: var(--soft); }
+
+/* ── Plan responsive ─────────────────────────────────────────── */
+@media (max-width: 640px) {
+ .plan-cards, .now-footer { grid-template-columns: 1fr; }
+ .plan-row-title { white-space: normal; }
+}
diff --git a/packages/wherefore-dashboard/tests/fixture-check.test.mjs b/packages/wherefore-dashboard/tests/fixture-check.test.mjs
index 9651c7b..13dac6f 100644
--- a/packages/wherefore-dashboard/tests/fixture-check.test.mjs
+++ b/packages/wherefore-dashboard/tests/fixture-check.test.mjs
@@ -77,12 +77,57 @@ test('question fixtures -- status values are valid', () => {
}
});
+const planItemFiles = () =>
+ readdirSync(resolve(FIXTURE, 'plan')).filter(f => f.startsWith('P-') && f.endsWith('.md'));
+
+test('plan fixtures -- required frontmatter keys', () => {
+ const required = ['id', 'title', 'status', 'created'];
+ const files = planItemFiles();
+
+ assert.ok(files.length >= 4, `Expected >= 4 plan items, got ${files.length}`);
+
+ for (const file of files) {
+ const keys = frontmatterKeys(readFileSync(resolve(FIXTURE, 'plan', file), 'utf-8'));
+ for (const key of required) {
+ assert.ok(keys.includes(key), `${file}: missing frontmatter key "${key}"`);
+ }
+ }
+});
+
+test('plan fixtures -- status values are valid', () => {
+ const valid = new Set(['todo', 'doing', 'done', 'dropped']);
+ for (const file of planItemFiles()) {
+ const content = readFileSync(resolve(FIXTURE, 'plan', file), 'utf-8');
+ const match = content.match(/^status:\s*(.+)$/m);
+ assert.ok(match, `${file}: no status line found`);
+ const status = match[1].trim();
+ assert.ok(valid.has(status), `${file}: invalid status "${status}" (expected todo|doing|done|dropped)`);
+ }
+});
+
+test('plan fixtures -- dropped item carries a reason or decision_ref', () => {
+ for (const file of planItemFiles()) {
+ const content = readFileSync(resolve(FIXTURE, 'plan', file), 'utf-8');
+ const status = content.match(/^status:\s*(.+)$/m)?.[1].trim();
+ if (status !== 'dropped') continue;
+ assert.ok(
+ /^dropped_reason:\s*\S/m.test(content) || /^decision_ref:\s*\S/m.test(content),
+ `${file}: a dropped item needs a dropped_reason or decision_ref`,
+ );
+ }
+});
+
test('fixture counts match expected', () => {
const logFiles = readdirSync(resolve(FIXTURE, 'log')).filter(f => f.endsWith('.md'));
const qFiles = readdirSync(resolve(FIXTURE, 'questions')).filter(f => f.endsWith('.md'));
assert.equal(logFiles.length, 4, `Expected 4 log entries`);
assert.equal(qFiles.length, 3, `Expected 3 questions`);
+ assert.equal(planItemFiles().length, 6, `Expected 6 plan items`);
+
+ // plan/README.md exists but must be excluded from the collection by the P-*.md glob.
+ const planAll = readdirSync(resolve(FIXTURE, 'plan')).filter(f => f.endsWith('.md'));
+ assert.ok(planAll.includes('README.md'), 'plan/README.md present (excluded by the P-*.md glob)');
const statuses = logFiles.map(f => {
const content = readFileSync(resolve(FIXTURE, 'log', f), 'utf-8');
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md
new file mode 100644
index 0000000..c5ae052
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-001-checkout-rate-limiter.md
@@ -0,0 +1,17 @@
+---
+id: P-001
+title: Wire up the checkout rate limiter
+status: doing
+created: 2026-01-02
+updated: 2026-01-05
+area: checkout
+topics: [api-design]
+milestone: M1
+---
+
+Rolling out the per-user limiter behind a flag.
+
+- [x] add the token-bucket helper
+- [x] gate it behind a flag
+- [ ] backfill the config for staging
+- [ ] flip it on in production
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md
new file mode 100644
index 0000000..d76000b
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-002-paginate-catalog-search.md
@@ -0,0 +1,10 @@
+---
+id: P-002
+title: Paginate the catalog search endpoint
+status: todo
+created: 2026-01-03
+area: catalog
+topics: [performance]
+---
+
+Cursor pagination for the search results list. Not started yet.
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md
new file mode 100644
index 0000000..118a128
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-003-choose-rate-limit-key.md
@@ -0,0 +1,15 @@
+---
+id: P-003
+title: Choose the checkout rate-limit key
+status: doing
+created: 2026-01-03
+updated: 2026-01-04
+area: checkout
+topics: [api-design]
+question_ref: Q-001
+---
+
+Blocked until we settle per-user vs per-IP.
+
+- [ ] pick the key dimension
+- [ ] document the limits
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md
new file mode 100644
index 0000000..2227df0
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-004-billing-webhook-retries.md
@@ -0,0 +1,16 @@
+---
+id: P-004
+title: Ship the billing webhook retries
+status: done
+created: 2026-01-01
+updated: 2026-01-04
+area: billing
+topics: [auth]
+decision_ref: 2026-01-01-active-example
+---
+
+Exponential backoff on webhook delivery.
+
+- [x] add the retry queue
+- [x] cap attempts at 6
+- [x] alert on the dead-letter queue
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md
new file mode 100644
index 0000000..820bd6a
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-005-per-tenant-schema.md
@@ -0,0 +1,12 @@
+---
+id: P-005
+title: Build a bespoke per-tenant schema
+status: dropped
+created: 2026-01-02
+updated: 2026-01-06
+area: catalog
+decision_ref: 2026-01-03-replacement-example
+dropped_reason: Operationally heavy; row-level security gives the same isolation.
+---
+
+Abandoned in favor of a shared schema with row-level security.
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md
new file mode 100644
index 0000000..0229f15
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/P-006-spike-limit-key.md
@@ -0,0 +1,15 @@
+---
+id: P-006
+title: Spike per-user vs per-IP limiting
+status: doing
+created: 2026-01-04
+updated: 2026-01-05
+area: checkout
+topics: [performance]
+answers: Q-001
+---
+
+A spike to answer Q-001; the checkboxes are the questions to settle, not steps.
+
+- [x] measure the per-IP false-positive rate on shared NAT
+- [ ] measure the per-user cost at the session store
diff --git a/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md
new file mode 100644
index 0000000..34ff68a
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/fixtures/wherefore/plan/README.md
@@ -0,0 +1,4 @@
+# Plan collection (fixture)
+
+Fixture data for the dashboard tests. This file must never become a route: the
+loader globs `P-*.md`, so `README.md` is deliberately excluded.
diff --git a/packages/wherefore-dashboard/tests/plan.test.mjs b/packages/wherefore-dashboard/tests/plan.test.mjs
new file mode 100644
index 0000000..bd4a2f4
--- /dev/null
+++ b/packages/wherefore-dashboard/tests/plan.test.mjs
@@ -0,0 +1,57 @@
+// Unit tests for the pure plan helpers. Imports the TypeScript source directly;
+// Node's native type stripping (>=22.18) handles it, no build.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ taskProgress,
+ isBlocked,
+ planRank,
+ byPlanOrder,
+ PLAN_STATUS_ORDER,
+} from '../src/lib/plan.ts';
+
+test('taskProgress counts GFM task lines (checked / total)', () => {
+ const body = [
+ 'intro prose',
+ '- [x] done one',
+ '- [ ] todo one',
+ '- [X] done two (capital X)',
+ '* [ ] star-bullet todo',
+ '- regular bullet, not a task',
+ ].join('\n');
+ assert.deepEqual(taskProgress(body), { done: 2, total: 4 });
+});
+
+test('taskProgress returns zero for a prose-only body', () => {
+ assert.deepEqual(taskProgress('just prose, no checkboxes here'), { done: 0, total: 0 });
+});
+
+test('isBlocked: only a todo/doing item with a still-open question_ref', () => {
+ const q = new Map([['Q-001', 'open'], ['Q-002', 'resolved']]);
+ assert.equal(isBlocked({ status: 'doing', questionRef: 'Q-001' }, q), true);
+ assert.equal(isBlocked({ status: 'todo', questionRef: 'Q-001' }, q), true);
+ assert.equal(isBlocked({ status: 'doing', questionRef: 'Q-002' }, q), false, 'resolved question does not block');
+ assert.equal(isBlocked({ status: 'done', questionRef: 'Q-001' }, q), false, 'done is never blocked');
+ assert.equal(isBlocked({ status: 'dropped', questionRef: 'Q-001' }, q), false, 'dropped is never blocked');
+ assert.equal(isBlocked({ status: 'doing', questionRef: null }, q), false);
+ assert.equal(isBlocked({ status: 'doing', questionRef: 'Q-404' }, q), false, 'unknown question does not block');
+});
+
+test('planRank / byPlanOrder: doing -> blocked -> todo -> done -> dropped', () => {
+ const facets = [
+ { status: 'dropped', blocked: false },
+ { status: 'done', blocked: false },
+ { status: 'todo', blocked: false },
+ { status: 'doing', blocked: true }, // blocked outranks a plain todo
+ { status: 'doing', blocked: false },
+ ];
+ const ordered = [...facets].sort(byPlanOrder).map(f => (f.blocked ? 'blocked' : f.status));
+ assert.deepEqual(ordered, ['doing', 'blocked', 'todo', 'done', 'dropped']);
+ assert.equal(planRank({ status: 'doing', blocked: false }), 0);
+ assert.equal(planRank({ status: 'doing', blocked: true }), 1);
+});
+
+test('PLAN_STATUS_ORDER excludes the derived blocked state', () => {
+ assert.deepEqual(PLAN_STATUS_ORDER, ['doing', 'todo', 'done', 'dropped']);
+});
diff --git a/packages/wherefore-dashboard/tests/render.test.mjs b/packages/wherefore-dashboard/tests/render.test.mjs
index 78222d4..e2d2d59 100644
--- a/packages/wherefore-dashboard/tests/render.test.mjs
+++ b/packages/wherefore-dashboard/tests/render.test.mjs
@@ -38,9 +38,12 @@ test('build emits no duplicate-id warnings', () => {
// ---- static render ---------------------------------------------------------
-test('index: summary math and active-only lists', () => {
+test('index (now view): counts and active/open-only footers', () => {
const d = parse('index.html');
- assert.equal(text(d.querySelector('.summary-line')), '2 decisions · 2 retired · 1 open questions');
+
+ // Header count strip: in flight (doing not blocked)=2, blocked=1, queued (todo)=1, open=1
+ const counts = [...d.querySelectorAll('.now-counts .count')].map(text);
+ assert.deepEqual(counts, ['2 in flight', '1 blocked', '1 queued', '1 open']);
const titles = [...d.querySelectorAll('.entry-list .entry-title')].map(text);
assert.ok(titles.includes('Active example decision'));
@@ -53,6 +56,22 @@ test('index: summary math and active-only lists', () => {
assert.ok(!qids.some((t) => t.startsWith('Q-002')), 'resolved question must not be listed');
});
+test('index (now view): in-flight cards and blocked card with inline question', () => {
+ const d = parse('index.html');
+
+ const cardTitles = [...d.querySelectorAll('.plan-card-title')].map(text);
+ assert.ok(cardTitles.includes('Wire up the checkout rate limiter'), 'doing item is in flight');
+ assert.ok(cardTitles.includes('Spike per-user vs per-IP limiting'), 'spike (answers) is in flight, not blocked');
+ assert.ok(cardTitles.includes('Choose the checkout rate-limit key'), 'blocked item shown in its own card');
+
+ const blockedCard = d.querySelector('.blocked-card');
+ assert.match(text(blockedCard), /Choose the checkout rate-limit key/);
+ assert.match(text(blockedCard.querySelector('.blocked-q')), /rate-limit the checkout API/, 'blocking question rendered inline');
+
+ const upNext = [...d.querySelectorAll('.plan-list .plan-row-title')].map(text);
+ assert.ok(upNext.includes('Paginate the catalog search endpoint'), 'todo item queued under Up next');
+});
+
test('log entry page: superseded callout links to its replacement', () => {
const d = parse('log/2026-01-02-superseded-example/index.html');
const callout = d.querySelector('.callout');
@@ -144,6 +163,86 @@ test('tags page: counts exclude retired, zero-count tags shown', () => {
assert.deepEqual(countIn('.tags-col-topics', 'auth'), { count: 0, zero: true });
});
+// ---- plan collection -------------------------------------------------------
+
+test('plan browse: count strip, overall progress, and section membership', () => {
+ const d = parse('plan/index.html');
+
+ const counts = [...d.querySelectorAll('.count-strip .count')].map(text);
+ assert.deepEqual(counts, ['2 doing', '1 blocked', '1 todo', '1 done', '1 dropped']);
+
+ // Overall progress excludes dropped: 1 done of the 5 non-dropped items.
+ assert.match(text(d.querySelector('.overall-label')), /1 of 5 done/);
+
+ const sectionTitles = (key) => {
+ const sec = d.querySelector(`.plan-section[data-section="${key}"]`);
+ return [...sec.querySelectorAll('.plan-row-title')].map(text).sort();
+ };
+ assert.deepEqual(sectionTitles('in-flight'),
+ ['Spike per-user vs per-IP limiting', 'Wire up the checkout rate limiter']);
+ assert.deepEqual(sectionTitles('blocked'), ['Choose the checkout rate-limit key']);
+ assert.deepEqual(sectionTitles('up-next'), ['Paginate the catalog search endpoint']);
+ assert.deepEqual(sectionTitles('done'), ['Ship the billing webhook retries']);
+ assert.deepEqual(sectionTitles('dropped'), ['Build a bespoke per-tenant schema']);
+});
+
+test('plan browse: blocked derived from open question_ref; answers does not block', () => {
+ const d = parse('plan/index.html');
+ const rowByTitle = (t) =>
+ [...d.querySelectorAll('.plan-row')].find((r) => text(r.querySelector('.plan-row-title')) === t);
+
+ // P-003: status doing but question_ref -> open Q-001, so it derives blocked.
+ const p3 = rowByTitle('Choose the checkout rate-limit key');
+ assert.equal(p3.dataset.blocked, 'true');
+ assert.ok(p3.querySelector('.status-dot').classList.contains('is-blocked'));
+ assert.equal(text(p3.querySelector('.plan-status-label')), 'blocked');
+
+ // P-006: answers Q-001 (a spike) is the opposite relation, not blocked.
+ const p6 = rowByTitle('Spike per-user vs per-IP limiting');
+ assert.equal(p6.dataset.blocked, 'false');
+ assert.ok(p6.querySelector('.status-dot').classList.contains('is-doing'));
+
+ // Checklist progress renders n/m from the body.
+ assert.equal(text(rowByTitle('Wire up the checkout rate limiter').querySelector('.progress-count')), '2/4');
+});
+
+test('plan browse: README is excluded by the P-*.md glob', () => {
+ const d = parse('plan/index.html');
+ const rows = [...d.querySelectorAll('.plan-row')];
+ assert.equal(rows.length, 6, 'exactly the 6 P-*.md items, plan/README.md not collected');
+ assert.ok(!rows.map((r) => text(r.querySelector('.plan-row-title'))).some((t) => /readme|fixture/i.test(t)));
+});
+
+test('plan detail: progress, metadata, and decision cross-reference', () => {
+ const d = parse('plan/P-004/index.html');
+ assert.equal(text(d.querySelector('.detail-title')), 'Ship the billing webhook retries');
+ assert.match(text(d.querySelector('.progress-count')), /3 of 3 done/);
+ assert.ok(d.querySelector('.plan-detail-head .status-dot').classList.contains('is-done'));
+
+ const ref = d.querySelector('.ref-list a');
+ assert.equal(ref.getAttribute('href'), '/log/2026-01-01-active-example');
+ assert.equal(text(ref), 'Active example decision', 'decision_ref resolves to the log title');
+});
+
+test('plan detail: blocked item callout links its open question', () => {
+ const d = parse('plan/P-003/index.html');
+ const callout = d.querySelector('.callout-blocked');
+ assert.ok(callout, 'blocked callout present');
+ assert.match(text(callout), /Q-001/);
+ assert.match(text(callout), /rate-limit the checkout API/, 'question text surfaced inline');
+});
+
+test('plan detail: dropped item shown (not hidden) with reason and retired-by link', () => {
+ const d = parse('plan/P-005/index.html');
+ assert.ok(d.querySelector('.plan-detail.is-dropped'), 'dropped items are dimmed, not removed');
+ assert.ok(d.querySelector('.callout.callout-obs'), 'dropped callout');
+ assert.match(text(d.querySelector('.dropped-reason')), /row-level security/);
+
+ const kinds = [...d.querySelectorAll('.ref-kind')].map(text);
+ assert.ok(kinds.includes('Retired by'), 'decision_ref reads as "Retired by" on a dropped item');
+ assert.equal(d.querySelector('.ref-list a').getAttribute('href'), '/log/2026-01-03-replacement-example');
+});
+
// ---- client-side filters (scripts executed by jsdom) -----------------------
test('questions filter: resolved tab and no-results search', () => {
@@ -208,3 +307,23 @@ test('log filter: ?area= URL param pre-selects and actually filters', () => {
assert.ok(shown[0].dataset.areas.split(',').includes('catalog'));
assert.equal(shown[0].dataset.status, 'active');
});
+
+test('plan filter: dropped hidden by default, shown via toggle, and area narrows', () => {
+ const { window } = new JSDOM(html('plan/index.html'), { runScripts: 'dangerously' });
+ const d = window.document;
+
+ const droppedSection = d.querySelector('.plan-section[data-section="dropped"]');
+ assert.ok(droppedSection.hidden, 'dropped section hidden on load');
+
+ const toggle = d.getElementById('dropped-toggle');
+ toggle.checked = true;
+ toggle.dispatchEvent(new window.Event('input', { bubbles: true }));
+ assert.ok(!droppedSection.hidden, 'dropped section shown after toggling show-dropped');
+
+ const area = d.getElementById('plan-area');
+ area.value = 'billing';
+ area.dispatchEvent(new window.Event('input', { bubbles: true }));
+ const shown = visible([...d.querySelectorAll('.plan-row')]);
+ assert.equal(shown.length, 1, 'only the billing item matches');
+ assert.equal(text(shown[0].querySelector('.plan-row-title')), 'Ship the billing webhook retries');
+});
diff --git a/wherefore/ROADMAP.md b/wherefore/ROADMAP.md
index 622d800..b7f971c 100644
--- a/wherefore/ROADMAP.md
+++ b/wherefore/ROADMAP.md
@@ -33,11 +33,10 @@ Status: done. Serves: G2.
0.1.0 published (MIT) and verified working from a clean external install.
### M2: 0.1.1 polish patch
-Status: active. Serves: G2.
+Status: done. Serves: G2.
README rewrite, build-command preview-locally hint, Vite `server.fs.allow` fix for the
-cross-directory dev errors, tsconfig plus @types/node (done). Deferred: blurry 16px
-favicon. Confirm against `npm view` whether this has already shipped, and flip to done
-if so.
+cross-directory dev errors, tsconfig plus @types/node. Shipped: 0.1.1 published to npm
+(0.1.2 and 0.2.0 followed). Deferred: blurry 16px favicon.
### M3: Thin launcher published
Status: planned. Serves: G1.
@@ -46,10 +45,11 @@ runs the real tool while `@dustinvk/wherefore-dashboard` stays the versioned sou
truth.
### M4: Plan layer shipped
-Status: active. Serves: G2.
-The `P-NNN` plan collection, the `/wherefore:slate` verb, dashboard rendering of plan
-items, and this roadmap. Currently dogfooding the frontmatter contract by hand before
-wiring the loader and the skill.
+Status: done. Serves: G2.
+The `P-NNN` plan collection, the `/wherefore:slate` verb, and dashboard rendering of
+plan items all shipped (slate in plugin 0.2.0; plan views on the current dashboard),
+plus this roadmap as the milestone layer. Deferred: milestone grouping in the dashboard
+(P-011), picked up once items carry milestones.
### M5: Live demo at wherefore.dev
Status: planned. Serves: G3.
diff --git a/wherefore/log/2026-07-19-dashboard-plan-collection.md b/wherefore/log/2026-07-19-dashboard-plan-collection.md
new file mode 100644
index 0000000..554875a
--- /dev/null
+++ b/wherefore/log/2026-07-19-dashboard-plan-collection.md
@@ -0,0 +1,46 @@
+---
+date: 2026-07-19
+title: "Dashboard plan views on the shipped schema"
+areas: [dashboard]
+topics: [ui, data-model, visual-identity]
+stories: []
+status: active
+supersedes:
+superseded_by:
+superseded_date:
+---
+
+## Summary
+Built the dashboard's read-side views for the plan collection (P-002), driven by the
+`design_handoff_wherefore_dashboard/` hifi design. The design was drawn against a generic,
+idealized schema, so the work was as much reconciliation as rendering: map the design onto
+the shipped `wherefore/plan/` contract, and keep the plan files the single source of truth.
+
+## Decisions / outcomes
+- Build against the shipped `wherefore/plan/` schema, not the handoff's idealized one. Map the design's `decision`, `blocks`, and `retired_by` to the real `decision_ref`, `question_ref`, and `dropped_reason` plus `decision_ref`; decision links point at existing `log/` entries, since there is no separate ADR collection.
+- Derive `blocked` at build time by joining plan to questions. An item is blocked when its `question_ref` points at a still-open question; it is never stored.
+- Group the Plan browse by status, not milestone. Sections run doing, blocked, todo, done, with dropped behind a toggle. Milestone grouping is deferred until items carry milestones.
+- Extend the existing dashboard token system rather than adopt the handoff's palette and IBM Plex Mono. Add plan-status tokens (teal doing, warm blocked, muted todo/done/dropped) on top of the current brand tokens and fonts.
+- Mirror the questions collection for the loader: glob `plan/P-*.md` so `README.md` is excluded, key identity off the `id:` frontmatter, and treat `area` as a single string.
+- Rebuild the home page into a now view: in flight, blocked, up next, then open questions and recent decisions.
+
+## Why
+The plan collection is already dogfooded (P-001 through P-010) and specified in
+`plan/README.md`, so its schema was fixed before this work. The handoff predates that
+schema; shipping its literal fields would have forked a second, incompatible contract.
+Deriving blocked keeps the plan files honest: it stays a view over `question_ref` plus the
+referenced question's status, never a stored flag that drifts after a resolve, matching the
+slate skill's read rule. Status grouping fits the current data, where almost no item sets a
+milestone, so milestone sections would collapse to a single backlog group. Extending the
+existing tokens keeps the four pages native to the shipped dashboard and avoids a site-wide
+restyle; the handoff's full hifi system stays available if a later pass wants pixel fidelity.
+
+## Alternatives considered
+- Implement the handoff schema verbatim (a separate `decisions/` ADR collection, `blocks`/`retired_by`). Rejected: it forks the data contract from the shipped `wherefore/plan/` spec.
+- Group the browse by milestone as the design shows. Rejected for now: real items rarely set a milestone, so it collapses to one backlog group.
+- Adopt the handoff's ground/ink palette and IBM Plex Mono across the pages. Rejected for this pass: a full re-theme is out of scope. See Q-012.
+
+## Open questions / follow-ups
+- Q-012: Should the dashboard adopt the handoff's full visual system (dark-default palette plus IBM Plex Mono UI), or is extending the existing tokens the durable choice?
+- Milestone grouping and a per-milestone roadmap view are deferred until plan items carry milestones.
+- Implements P-002 and the dashboard side of 2026-07-03-plan-directory; extends 2026-06-24-dashboard-schema-and-ui.
diff --git a/wherefore/log/2026-07-19-roadmap-m2-m4-done.md b/wherefore/log/2026-07-19-roadmap-m2-m4-done.md
new file mode 100644
index 0000000..d0926f4
--- /dev/null
+++ b/wherefore/log/2026-07-19-roadmap-m2-m4-done.md
@@ -0,0 +1,44 @@
+---
+date: 2026-07-19
+title: "Flip roadmap M2 and M4 to done"
+areas: [dashboard, plugin]
+topics: [docs, publishing]
+stories: []
+status: active
+supersedes:
+superseded_by:
+superseded_date:
+---
+
+## Summary
+A drift review after the plan layer landed found the roadmap trailing reality. M2 (0.1.1
+polish patch) and M4 (plan layer shipped) were still `active` though both had shipped, and
+the slate rename had left stale "plan skill" references in `plan/README.md`. Reconciled all
+three.
+
+## Decisions / outcomes
+- M2 flipped to `done`. `npm view @dustinvk/wherefore-dashboard` shows 0.1.0, 0.1.1, 0.1.2,
+ and 0.2.0 published, so the 0.1.1 patch shipped. The blurry 16px favicon stays deferred as
+ a carried note, not a milestone blocker.
+- M4 flipped to `done`. The `P-NNN` collection, the `/wherefore:slate` verb, and the
+ dashboard plan views all shipped (slate in plugin 0.2.0). Milestone grouping (P-011) is a
+ deferred follow-on, not core delivery, so it does not hold the milestone open.
+- `plan/README.md` now names the `slate` skill in all five spots. The rename decision moved
+ the verb to `slate` but the collection's own README still said `plan` skill.
+
+## Why
+The roadmap is the source of truth for milestone status, so a milestone reading `active`
+after it shipped is exactly the rot the dashboard is meant to surface. Verifying M2 against
+npm rather than flipping it on assumption keeps the record honest. Treating a deferred
+enhancement (favicon, P-011 grouping) as a carried note rather than a blocker matches how M1
+already handles its follow-ups: a milestone is done when its core delivery ships, with
+deferrals tracked as their own items.
+
+## Alternatives considered
+- Leave M4 `active` until milestone grouping (P-011) lands. Rejected: it gates the whole
+ milestone on a deferred enhancement whose own trigger is "once items carry milestones,"
+ which would keep M4 open indefinitely while its substance has shipped.
+
+## Open questions / follow-ups
+- None. P-011 tracks the remaining milestone-grouping work; Q-012 tracks the visual-system
+ question, both independent of this reconciliation.
diff --git a/wherefore/plan/P-001-ship-0-1-1-patch.md b/wherefore/plan/P-001-ship-0-1-1-patch.md
index 067d506..878de91 100644
--- a/wherefore/plan/P-001-ship-0-1-1-patch.md
+++ b/wherefore/plan/P-001-ship-0-1-1-patch.md
@@ -1,9 +1,9 @@
---
id: P-001
title: Ship 0.1.1 patch
-status: doing
+status: done
created: 2026-06-20
-updated: 2026-07-03
+updated: 2026-07-19
area: dashboard
topics: [release]
---
diff --git a/wherefore/plan/P-002-add-plan-collection.md b/wherefore/plan/P-002-add-plan-collection.md
index b50ed85..c5b1d7a 100644
--- a/wherefore/plan/P-002-add-plan-collection.md
+++ b/wherefore/plan/P-002-add-plan-collection.md
@@ -1,11 +1,12 @@
---
id: P-002
title: Add plan collection to the dashboard
-status: todo
+status: done
created: 2026-07-03
+updated: 2026-07-19
area: dashboard
topics: [astro, schema]
-decision_ref: 2026-07-03-companion-plan-collection
+decision_ref: 2026-07-03-plan-directory, 2026-07-19-dashboard-plan-collection
---
Astro loader plus route for wherefore/plan/, mirroring the questions collection. Derive
diff --git a/wherefore/plan/P-011-milestone-grouping.md b/wherefore/plan/P-011-milestone-grouping.md
new file mode 100644
index 0000000..49d9026
--- /dev/null
+++ b/wherefore/plan/P-011-milestone-grouping.md
@@ -0,0 +1,20 @@
+---
+id: P-011
+title: Group the Plan browse by milestone
+status: todo
+created: 2026-07-19
+area: dashboard
+topics: [ui]
+decision_ref: 2026-07-19-dashboard-plan-collection
+---
+
+Milestone grouping was deferred when the Plan views shipped with status-section grouping,
+because almost no live item carries a milestone yet. Pick this up once items do. The design
+in `design_handoff_wherefore_dashboard/` specifies the target shape.
+
+- [ ] load milestone IDs and titles from `wherefore/ROADMAP.md` at build time
+- [ ] group the Plan browse by milestone, collecting un-milestoned items in a `backlog` group
+- [ ] add a per-group header: milestone title, progress summary (n/m done, doing/blocked counts), collapse chevron
+- [ ] order items within a group by status priority (doing, blocked, todo, done, dropped)
+- [ ] decide how milestone grouping composes with the status sections (toggle between them, or status filter chips on top)
+- [ ] extend fixtures and render tests to cover milestone grouping and the backlog group
diff --git a/wherefore/plan/README.md b/wherefore/plan/README.md
index 30e2c33..118474d 100644
--- a/wherefore/plan/README.md
+++ b/wherefore/plan/README.md
@@ -35,7 +35,7 @@ superseded / obsolete here, and do not put todo / doing / done / dropped on deci
| `answers` | no | `Q-NNN` | a single question this item is the work of answering (spike)|
| `dropped_reason`| no | string | lightweight why, used when `status: dropped` |
-Body: freeform. The `plan` skill breaks the work into `- [ ]` checkboxes by default
+Body: freeform. The `slate` skill breaks the work into `- [ ]` checkboxes by default
(steps concrete enough to check off); prose stays valid, and older items may be
prose-only.
@@ -70,14 +70,14 @@ the link.
## Boundaries
-- The `plan` skill writes only `wherefore/plan/*`. Never `log/`, never `questions/`,
+- The `slate` skill writes only `wherefore/plan/*`. Never `log/`, never `questions/`,
never `ROADMAP.md`.
- Durable why belongs in the decision layer: when a plan item's rationale is really a
decision, that is a `capture`; when a direction changes for a reason worth keeping,
that is a `supersede` on the decision, and the plan item flips to `dropped` carrying
`decision_ref` to it.
- `capture` never creates plan items and never writes plan status directly; it may hand
- off to the `plan` skill. The `plan` skill records that something changed; the decision
+ off to the `slate` skill. The `slate` skill records that something changed; the decision
layer owns the durable why.
-Maintained by the [wherefore](https://github.com/DustinVK/wherefore) plan skill.
+Maintained by the [wherefore](https://github.com/DustinVK/wherefore) slate skill.
diff --git a/wherefore/questions/Q-001-astro-build-external-dirs.md b/wherefore/questions/Q-001-astro-build-external-dirs.md
index 3d700e0..d2a7181 100644
--- a/wherefore/questions/Q-001-astro-build-external-dirs.md
+++ b/wherefore/questions/Q-001-astro-build-external-dirs.md
@@ -1,12 +1,12 @@
---
id: Q-001
question: "Will Astro build cleanly with content base and output dir both outside the Astro project root?"
-status: open
+status: resolved
areas: [dashboard]
asked_date: 2026-06-24
asked_slug: 2026-06-24-dashboard-build-tool
-resolution:
-resolution_slug:
+resolution: "Yes. The dashboard builds cleanly with the content base and output dir both outside the Astro root; reconfirmed by an out-of-repo build with external --src and --out."
+resolution_slug: 2026-07-19-dashboard-plan-collection
---
## Context
diff --git a/wherefore/questions/Q-012-adopt-handoff-visual-system.md b/wherefore/questions/Q-012-adopt-handoff-visual-system.md
new file mode 100644
index 0000000..9e51faf
--- /dev/null
+++ b/wherefore/questions/Q-012-adopt-handoff-visual-system.md
@@ -0,0 +1,10 @@
+---
+id: Q-012
+question: "Should the dashboard adopt the handoff's full visual system (dark-default palette plus IBM Plex Mono UI), or is extending the existing tokens the durable choice?"
+status: open
+areas: [dashboard]
+asked_date: 2026-07-19
+asked_slug: 2026-07-19-dashboard-plan-collection
+resolution:
+resolution_slug:
+---