feat(db): roadmap_items gains 'considering' + 'in_beta' rename, --on-ink tokens

Migration 0006 (the spec said 0005 but that number was already taken by
polls_on_dispatches from the previous session): rebuilds the
roadmap_items CHECK to ('shipping','in_beta','exploring','considering')
and renames any existing 'beta' rows to 'in_beta' in-place. FKs from
roadmap_attributions are preserved across the DROP/RENAME by toggling
PRAGMA foreign_keys off around the rebuild — attribution count unchanged
after migrate (verified 4 rows survive on the demo DB).

Tokens (src/styles/tokens.css): adds --on-ink, --on-ink-body,
--on-ink-muted, --ink-divider. The bleached #fffcf7 cream replaces the
warm #e8e0d0 --ink-text wherever it sits on indigo. Legacy --ink-text /
--ink-muted stay in tokens.css for now — if any later commit references
them they remain defined; the migration of existing call sites is
covered here.

Migrated to the new tokens in this pass:
  - src/components/MembershipCard.astro (members/:slug card)
  - src/pages/events.astro (hero invitation card)
  Both render with cleaner whites on indigo as a side effect.

Code updates for the new status enum:
  - db.ts: RoadmapStatus = shipping | in_beta | exploring | considering
  - admin/RoadmapTab.astro: Status select gains Considering + In beta;
    grouped section iteration covers all four
  - admin/index.astro: validation list updated
  - scripts/seed-roadmap.js: 'In progress' markdown bucket → 'in_beta'
  - pulse.astro: roadmapStatusDot + roadmapStatusBlurb temporarily widened
    (full rewrite of that section lands in step 7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-12 10:46:39 +02:00
parent cb2efa70f3
commit 9ae8422527
9 changed files with 100 additions and 45 deletions

View file

@ -0,0 +1,40 @@
-- Roadmap status enum gains a fourth value `considering` for items that are
-- under discussion but not yet committed to. Same migration also renames
-- the existing `beta` value to `in_beta` so the canonical names line up
-- with the v4 spec (no second display label layer needed).
--
-- SQLite can't widen a CHECK constraint in place, so this is a full table
-- rebuild. roadmap_attributions has an ON DELETE CASCADE FK to
-- roadmap_items(id), so foreign keys are toggled off around the rebuild to
-- preserve attribution rows across the DROP/RENAME.
PRAGMA foreign_keys = OFF;
CREATE TABLE roadmap_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'exploring'
CHECK(status IN ('shipping','in_beta','exploring','considering')),
target TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
shipped_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO roadmap_items_new
(id, title, description, status, target, display_order, shipped_at, created_at, updated_at)
SELECT
id, title, description,
CASE status WHEN 'beta' THEN 'in_beta' ELSE status END,
target, display_order, shipped_at, created_at, updated_at
FROM roadmap_items;
DROP TABLE roadmap_items;
ALTER TABLE roadmap_items_new RENAME TO roadmap_items;
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
PRAGMA foreign_keys = ON;

View file

@ -30,7 +30,7 @@ const md = readFileSync(mdPath, 'utf8');
// schema's three statuses. In-progress items are actively being built and // schema's three statuses. In-progress items are actively being built and
// tested with pilots → beta. Next/Later are roadmap intent, not started → exploring. // tested with pilots → beta. Next/Later are roadmap intent, not started → exploring.
const SECTION_STATUS = { const SECTION_STATUS = {
'In progress': { status: 'beta', target: null }, 'In progress': { status: 'in_beta', target: null },
'Next': { status: 'exploring', target: 'Next quarter' }, 'Next': { status: 'exploring', target: 'Next quarter' },
'Later': { status: 'exploring', target: 'Later this year' }, 'Later': { status: 'exploring', target: 'Later this year' },
}; };

View file

@ -51,7 +51,7 @@ const tags = readFocusTags(member.focus_tags);
<style> <style>
.m-card { .m-card {
background: var(--ink); background: var(--ink);
color: var(--ink-text); color: var(--on-ink);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.25rem; padding: 1.25rem;
display: flex; display: flex;
@ -70,7 +70,7 @@ const tags = readFocusTags(member.focus_tags);
width: 22px; width: 22px;
height: 22px; height: 22px;
border-radius: 50%; border-radius: 50%;
background: var(--ink-text); background: var(--on-ink);
color: var(--ink); color: var(--ink);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -89,7 +89,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 500; font-weight: 500;
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-muted); color: var(--on-ink-muted);
} }
.m-name { .m-name {
@ -99,7 +99,7 @@ const tags = readFocusTags(member.focus_tags);
font-weight: 400; font-weight: 400;
font-size: 1.5rem; font-size: 1.5rem;
line-height: 1.15; line-height: 1.15;
color: var(--ink-text); color: var(--on-ink);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -116,12 +116,12 @@ const tags = readFocusTags(member.focus_tags);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-muted); color: var(--on-ink-muted);
} }
.m-since-value { .m-since-value {
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
color: var(--ink-text); color: var(--on-ink);
} }
.m-tags { .m-tags {
@ -133,8 +133,8 @@ const tags = readFocusTags(member.focus_tags);
gap: 6px; gap: 6px;
} }
.m-tag { .m-tag {
border: 0.5px solid rgba(232, 224, 208, 0.3); border: 0.5px solid rgba(255, 252, 247, 0.3);
color: var(--ink-text); color: var(--on-ink);
padding: 3px 8px; padding: 3px 8px;
border-radius: 999px; border-radius: 999px;
font-family: var(--font-sans); font-family: var(--font-sans);

View file

@ -9,17 +9,23 @@ interface Props {
const { items, editing, cabUsers } = Astro.props; const { items, editing, cabUsers } = Astro.props;
const STATUS_LABEL = { shipping: 'Shipping', beta: 'Beta', exploring: 'Exploring' } as const; const STATUS_LABEL = {
shipping: 'Shipping',
in_beta: 'In beta',
exploring: 'Exploring',
considering: 'Considering',
} as const;
const formAction = editing ? 'update_roadmap' : 'create_roadmap'; const formAction = editing ? 'update_roadmap' : 'create_roadmap';
const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id)); const attributedSet = new Set((editing?.attributed ?? []).map(a => a.id));
// Group items by status for display // Group items by status for display
type Status = 'shipping' | 'beta' | 'exploring'; type Status = 'shipping' | 'in_beta' | 'exploring' | 'considering';
const grouped: Record<Status, RoadmapItemWithAttribution[]> = { const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order), shipping: items.filter(i => i.status === 'shipping' ).sort((a,b) => a.display_order - b.display_order),
beta: items.filter(i => i.status === 'beta' ).sort((a,b) => a.display_order - b.display_order), in_beta: items.filter(i => i.status === 'in_beta' ).sort((a,b) => a.display_order - b.display_order),
exploring: items.filter(i => i.status === 'exploring').sort((a,b) => a.display_order - b.display_order), exploring: items.filter(i => i.status === 'exploring' ).sort((a,b) => a.display_order - b.display_order),
considering: items.filter(i => i.status === 'considering').sort((a,b) => a.display_order - b.display_order),
}; };
--- ---
<div class="tab-content"> <div class="tab-content">
@ -39,9 +45,10 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
<div class="field"> <div class="field">
<label for="status" class="label-sm field-label">Status</label> <label for="status" class="label-sm field-label">Status</label>
<select id="status" name="status" class="select body-md" required> <select id="status" name="status" class="select body-md" required>
<option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option> <option value="considering" selected={editing?.status === 'considering'}>Considering</option>
<option value="beta" selected={editing?.status === 'beta'}>Beta</option> <option value="exploring" selected={editing?.status === 'exploring'}>Exploring</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option> <option value="in_beta" selected={editing?.status === 'in_beta'}>In beta</option>
<option value="shipping" selected={editing?.status === 'shipping'}>Shipping</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
@ -78,7 +85,7 @@ const grouped: Record<Status, RoadmapItemWithAttribution[]> = {
</section> </section>
<!-- ── List by status ────────────────────────────────────────── --> <!-- ── List by status ────────────────────────────────────────── -->
{(['shipping','beta','exploring'] as const).map(status => ( {(['shipping','in_beta','exploring','considering'] as const).map(status => (
<section class="section"> <section class="section">
<h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2> <h2 class="label-sm section-heading">{STATUS_LABEL[status]} · {grouped[status].length}</h2>
{grouped[status].length === 0 ? ( {grouped[status].length === 0 ? (

View file

@ -650,7 +650,7 @@ export function countPulseParticipants(pulseId: number): number {
// ── Roadmap items ──────────────────────────────────────────────── // ── Roadmap items ────────────────────────────────────────────────
export type RoadmapStatus = 'shipping' | 'beta' | 'exploring'; export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering';
export interface RoadmapItem { export interface RoadmapItem {
id: number; id: number;

View file

@ -202,7 +202,7 @@ if (Astro.request.method === 'POST') {
const displayOrder = Number(data.get('display_order') ?? 0); const displayOrder = Number(data.get('display_order') ?? 0);
const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean); const attributedIds = data.getAll('attributed_user_ids').map(v => Number(v)).filter(Boolean);
if (!title || !['shipping','beta','exploring'].includes(status)) { if (!title || !['shipping','in_beta','exploring','considering'].includes(status)) {
formError = 'Title and status are required.'; formError = 'Title and status are required.';
} else if (action === 'create_roadmap') { } else if (action === 'create_roadmap') {
const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder }); const id = createRoadmapItem({ title, description, status, target, display_order: displayOrder });

View file

@ -220,7 +220,7 @@ const heroAudience = hero?.audience ?? 'Members only';
/* ── Hero ─────────────────────────────────────────────────────── */ /* ── Hero ─────────────────────────────────────────────────────── */
.hero { .hero {
background: var(--ink); background: var(--ink);
color: var(--ink-text); color: var(--on-ink);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 1.75rem; padding: 1.75rem;
display: flex; display: flex;
@ -232,7 +232,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
color: var(--ink-muted); color: var(--on-ink-muted);
} }
.hero-eyebrow { .hero-eyebrow {
font-family: var(--font-sans); font-family: var(--font-sans);
@ -255,7 +255,7 @@ const heroAudience = hero?.audience ?? 'Members only';
left: 100px; left: 100px;
top: 0; bottom: 0; top: 0; bottom: 0;
width: 0.5px; width: 0.5px;
background: rgba(232, 224, 208, 0.2); background: var(--ink-divider);
} }
.hero-date { display: flex; flex-direction: column; gap: 2px; } .hero-date { display: flex; flex-direction: column; gap: 2px; }
@ -264,13 +264,13 @@ const heroAudience = hero?.audience ?? 'Members only';
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
text-transform: uppercase; text-transform: uppercase;
color: var(--ink-text); color: var(--on-ink);
} }
.hero-day { .hero-day {
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 2.75rem; font-size: 2.75rem;
line-height: 1; line-height: 1;
color: var(--ink-text); color: var(--on-ink);
} }
.hero-detail { padding-left: var(--space-5); } .hero-detail { padding-left: var(--space-5); }
@ -279,22 +279,22 @@ const heroAudience = hero?.audience ?? 'Members only';
font-weight: 400; font-weight: 400;
font-size: 1.75rem; font-size: 1.75rem;
line-height: 1.2; line-height: 1.2;
color: var(--ink-text); color: var(--on-ink);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
} }
.hero-desc { .hero-desc {
color: rgba(232, 224, 208, 0.85); color: var(--on-ink-body);
margin: 0 0 var(--space-3); margin: 0 0 var(--space-3);
max-width: 40rem; max-width: 40rem;
} }
.hero-meta { .hero-meta {
color: var(--ink-muted); color: var(--on-ink-muted);
font-size: var(--text-body-sm); font-size: var(--text-body-sm);
margin: 0; margin: 0;
} }
.hero-foot { .hero-foot {
border-top: 0.5px solid rgba(232, 224, 208, 0.2); border-top: 0.5px solid var(--ink-divider);
padding-top: var(--space-4); padding-top: var(--space-4);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -304,7 +304,7 @@ const heroAudience = hero?.audience ?? 'Members only';
} }
.hero-foot-left { display: flex; align-items: center; gap: var(--space-4); } .hero-foot-left { display: flex; align-items: center; gap: var(--space-4); }
.hero-foot-stat { .hero-foot-stat {
color: var(--ink-muted); color: var(--on-ink-muted);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider); letter-spacing: var(--tracking-wider);
@ -313,7 +313,7 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-foot-right { display: flex; align-items: center; gap: var(--space-3); } .hero-foot-right { display: flex; align-items: center; gap: var(--space-3); }
.hero-cta { .hero-cta {
background: var(--ink-text); background: var(--on-ink);
color: var(--ink); color: var(--ink);
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
@ -329,20 +329,20 @@ const heroAudience = hero?.audience ?? 'Members only';
.hero-cta:hover { opacity: 0.85; } .hero-cta:hover { opacity: 0.85; }
.hero-confirmed { .hero-confirmed {
color: var(--ink-text); color: var(--on-ink);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-md); font-size: var(--text-label-md);
font-weight: 600; font-weight: 600;
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
text-transform: uppercase; text-transform: uppercase;
padding: 10px 16px; padding: 10px 16px;
border: 0.5px solid rgba(232, 224, 208, 0.4); border: 0.5px solid rgba(255, 252, 247, 0.4);
border-radius: 999px; border-radius: 999px;
} }
.hero-change { .hero-change {
background: transparent; background: transparent;
border: none; border: none;
color: var(--ink-muted); color: var(--on-ink-muted);
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: var(--text-label-sm); font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
@ -359,7 +359,7 @@ const heroAudience = hero?.audience ?? 'Members only';
display: flex; display: flex;
} }
.hero-empty-line { .hero-empty-line {
color: var(--ink-text); color: var(--on-ink);
font-family: var(--font-serif); font-family: var(--font-serif);
font-size: 1.25rem; font-size: 1.25rem;
margin: auto; margin: auto;

View file

@ -98,19 +98,21 @@ const roadmapPreview = getAllRoadmapItems()
.sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1)) .sort((a, b) => (b.updated_at > a.updated_at ? 1 : -1))
.slice(0, 3); .slice(0, 3);
function roadmapStatusDot(status: 'shipping' | 'beta' | 'exploring'): string { function roadmapStatusDot(status: 'shipping' | 'in_beta' | 'exploring' | 'considering'): string {
return ({ return ({
shipping: 'var(--pigment-copper)', shipping: 'var(--pigment-copper)',
beta: 'var(--pigment-ochre)', in_beta: 'var(--pigment-terracotta)',
exploring: 'var(--on-surface-muted)', exploring: '#b4b2a9',
considering: '#d4d2c8',
})[status]; })[status];
} }
function roadmapStatusBlurb(item: { status: 'shipping' | 'beta' | 'exploring'; target: string | null }): string { function roadmapStatusBlurb(item: { status: 'shipping' | 'in_beta' | 'exploring' | 'considering'; target: string | null }): string {
const target = item.target ? ` · ${item.target}` : ''; const target = item.target ? ` · ${item.target}` : '';
switch (item.status) { switch (item.status) {
case 'shipping': return `Shipping${target}`; case 'shipping': return `Shipping${target}`;
case 'beta': return `In beta${target}`; case 'in_beta': return `In beta${target}`;
case 'exploring': return `Exploring${target}`; case 'exploring': return `Exploring${target}`;
case 'considering': return `Considering${target}`;
} }
} }

View file

@ -45,8 +45,14 @@
--surface-card: #ffffff; --surface-card: #ffffff;
--surface-card-border: rgba(0, 0, 0, 0.08); --surface-card-border: rgba(0, 0, 0, 0.08);
--ink: #2c3a52; /* deep indigo — membership card + event hero */ --ink: #2c3a52; /* deep indigo — membership card + event hero */
--ink-text: #e8e0d0; /* readable cream on --ink */ --ink-text: #e8e0d0; /* legacy warm cream — superseded by --on-ink */
--ink-muted: #b8a989; /* muted label tone on --ink */ --ink-muted: #b8a989; /* legacy tan — superseded by --on-ink-muted */
/* --- v4: bleached cream on indigo surfaces (replaces --ink-text) --- */
--on-ink: #fffcf7; /* primary text on --ink */
--on-ink-body: rgba(255, 252, 247, 0.85); /* body copy */
--on-ink-muted: rgba(255, 252, 247, 0.65); /* tracked labels */
--ink-divider: rgba(255, 252, 247, 0.18); /* 0.5px lines on --ink */
/* --- Semantic state mappings --- */ /* --- Semantic state mappings --- */
--color-success: var(--pigment-copper); --color-success: var(--pigment-copper);