From 806f01f6c1001491fd55c95f4ec9809dab780e91 Mon Sep 17 00:00:00 2001 From: Lenoux01 <60329596+Lenoux01@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:26:38 +0200 Subject: [PATCH 1/4] feat(views): add ledger and bands view modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two modes for the same projection, both additive: `phases`, `list` and `compact` emit byte-identical markup to before, pinned by a test. - `ledger` — one column, phase order intact, three fixed cells per item (glyph, title+summary, status) so status stops competing with the title for the eye. Shipped phases collapse to a single line carrying gets_you, which no view rendered until now despite every phase defining it: for a prospect it is the payoff, and it says more in one line than a card of feature names. - `bands` — one full-width band per status in urgency order, each item tagged with the phase it came from. `in-progress` and `blocked` keep their band when empty, because an empty one is the news. `view-model.ts` gains the grouping (`statusGroups`, `STATUS_ORDER`) and a phase back-reference on `ItemView`, per the rule that the view model decides what to show; both renderers stay thin and mirror each other. The status column is `max-content`, not a fixed width: German "vorgeschlagen" overflows an 8rem cell, and a right-aligned nowrap cell overflows leftward over its neighbour. Co-Authored-By: Claude Opus 5 --- render-html.ts | 64 ++++++++++++++++++++++++++++++---- styles.ts | 73 +++++++++++++++++++++++++++++++++++++++ ui-react/index.tsx | 83 +++++++++++++++++++++++++++++++++++++++------ ui-react/styles.css | 73 +++++++++++++++++++++++++++++++++++++++ view-model.test.ts | 69 +++++++++++++++++++++++++++++++++++++ view-model.ts | 37 ++++++++++++++++++++ 6 files changed, 382 insertions(+), 17 deletions(-) diff --git a/render-html.ts b/render-html.ts index a11d246..e214c28 100644 --- a/render-html.ts +++ b/render-html.ts @@ -6,7 +6,16 @@ import type { ItemView, MilestoneView, PhaseView, RoadmapView } from "./view-model"; -export type ViewMode = "phases" | "list" | "compact"; +/** + * `phases`, `list` and `compact` share one markup and differ only by the + * `is-view-*` class. `ledger` and `bands` do not: a ledger needs a status cell + * of its own so the glyph, the title and the status word can hold three fixed + * columns, and bands walk `statusGroups` instead of `phases`. Both are + * additive — the three original modes emit exactly what they emitted before. + */ +export type ViewMode = "phases" | "list" | "compact" | "ledger" | "bands"; + +const CELL_MODES: ReadonlySet = new Set(["ledger", "bands"]); export function esc(s: string): string { return s.replace( @@ -19,16 +28,28 @@ function badge(label: string, cls: string): string { return `${esc(label)}`; } -function renderItem(it: ItemView, view: RoadmapView): string { +function renderItem(it: ItemView, view: RoadmapView, mode: ViewMode = "phases"): string { + const cells = CELL_MODES.has(mode); const parts: string[] = []; parts.push( `
  • ` ); - parts.push(`
    ${esc(it.title)}${badge(it.statusLabel, it.status)}`); + // The glyph is decorative: the status is already written out beside it. + if (cells) parts.push(''); + parts.push( + `
    ${esc(it.title)}${cells ? "" : badge(it.statusLabel, it.status)}` + ); if (it.internal && it.internal.visibility === "internal") parts.push(badge("internal", "internal")); parts.push("
    "); if (it.summary) parts.push(`

    ${esc(it.summary)}

    `); + if (cells) { + if (mode === "bands") + parts.push( + `${esc(it.phaseId)} · ${esc(it.phaseTitle)}` + ); + parts.push(`${esc(it.statusLabel)}`); + } if (it.links.length > 0) { parts.push('
    "); + return parts.join(""); + } parts.push('
    '); - for (const p of view.phases) parts.push(renderPhase(p, view)); + for (const p of view.phases) parts.push(renderPhase(p, view, mode)); parts.push("
    "); return parts.join(""); } diff --git a/styles.ts b/styles.ts index 23ac5fe..e48f1e8 100644 --- a/styles.ts +++ b/styles.ts @@ -40,6 +40,7 @@ export const STYLES = ` .nextup-horizon { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--nextup-fg-muted); white-space: nowrap; } .nextup-phase.is-current .nextup-horizon { color: var(--nextup-now); } .nextup-goal { margin: 4px 0 0; color: var(--nextup-fg-muted); font-size: 0.9rem; } +.nextup-gets { display: none; } .nextup-meta { display: flex; gap: 10px; font-size: 0.8rem; color: var(--nextup-fg-muted); margin: 6px 0 10px; } .nextup-progress { height: 4px; background: var(--nextup-border); border-radius: 2px; overflow: hidden; margin-bottom: 10px; } .nextup-progress > span { display: block; height: 100%; background: var(--nextup-accent); } @@ -75,6 +76,78 @@ export const STYLES = ` .nextup-roadmap.is-view-compact .nextup-milestones, .nextup-roadmap.is-view-compact .nextup-links { display: none; } .nextup-empty { color: var(--nextup-fg-muted); font-style: italic; } +/* ── ledger ────────────────────────────────────────────────────────── + The roadmap as a statement: one column, phase order intact, and three + fixed cells per item (glyph, title+summary, status) so status never + competes with the title for the eye. Shipped phases collapse to a line + and carry gets_you as their whole content — the payoff, not the + feature list. */ +.nextup-roadmap.is-view-ledger .nextup-phases { grid-template-columns: 1fr; gap: 0; } +.nextup-roadmap.is-view-ledger .nextup-phase { border: 0; border-bottom: 1px solid var(--nextup-border); border-radius: 0; padding: 14px 0; background: none; opacity: 1; } +.nextup-roadmap.is-view-ledger .nextup-phase.is-current { box-shadow: none; border-left: 4px solid var(--nextup-now); padding-left: 12px; } +.nextup-roadmap.is-view-ledger .nextup-phase-head h3 { font-size: 1.35rem; letter-spacing: -0.01em; } +.nextup-roadmap.is-view-ledger .nextup-gets { display: block; margin: 6px 0 0; font-size: 1.05rem; font-weight: 500; max-width: 40ch; } +.nextup-roadmap.is-view-ledger .nextup-goal { max-width: 62ch; } +.nextup-roadmap.is-view-ledger .nextup-progress { display: none; } +.nextup-roadmap.is-view-ledger .nextup-items { gap: 0; margin-top: 10px; } +.nextup-roadmap.is-view-ledger .nextup-item { display: grid; grid-template-columns: 28px minmax(0, 1fr) max-content; align-items: baseline; column-gap: 12px; border-left: 0; border-top: 1px solid var(--nextup-border); padding: 8px 0; } +.nextup-roadmap.is-view-ledger .nextup-item-title { grid-column: 2; font-size: 0.95rem; } +.nextup-roadmap.is-view-ledger .nextup-item-summary { grid-column: 2; } +.nextup-item-status { grid-row: 1; text-align: right; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nextup-fg-muted); white-space: nowrap; } +/* Ledger is glyph/body/status; bands insert the phase tag before the status. */ +.nextup-roadmap.is-view-ledger .nextup-item-status { grid-column: 3; } +.nextup-roadmap.is-view-bands .nextup-item-status { grid-column: 4; } +.nextup-roadmap.is-view-ledger .nextup-links, +.nextup-roadmap.is-view-ledger .nextup-internal { grid-column: 2; } + +/* Shipped phases are one line: title, payoff, horizon. */ +.nextup-roadmap.is-view-ledger .nextup-phase.is-done { display: grid; grid-template-columns: minmax(0, 14rem) minmax(0, 1fr) max-content; align-items: baseline; column-gap: 12px; padding: 8px 0; } +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-phase-head { display: contents; } +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-phase-head h3 { grid-column: 1; font-size: 0.95rem; font-weight: 500; } +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-gets { grid-column: 2; grid-row: 1; margin: 0; font-size: 0.9rem; font-weight: 400; color: var(--nextup-fg-muted); max-width: none; } +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-horizon { grid-column: 3; grid-row: 1; text-align: right; } +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-goal, +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-meta, +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-items, +.nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-milestones { display: none; } + +/* The glyph column. Decorative, hence content in CSS. */ +.nextup-mark { grid-column: 1; grid-row: 1; font-variant-numeric: tabular-nums; color: var(--nextup-fg-muted); text-align: center; } +.nextup-mark::before { content: "·"; } +.nextup-item.is-planned .nextup-mark::before { content: "□"; } +.nextup-item.is-in-progress .nextup-mark::before { content: "▶"; } +.nextup-item.is-blocked .nextup-mark::before { content: "×"; } +.nextup-item.is-done .nextup-mark::before { content: "■"; } +.nextup-item.is-dropped .nextup-mark::before { content: "–"; } +.nextup-item.is-in-progress .nextup-mark { color: var(--nextup-now); } +.nextup-item.is-blocked .nextup-mark { color: var(--nextup-danger); } +.nextup-item.is-done .nextup-mark { color: var(--nextup-accent); } +.nextup-item.is-blocked .nextup-item-status { color: var(--nextup-danger); } +.nextup-item.is-in-progress .nextup-item-status { color: var(--nextup-fg); } + +/* ── bands ─────────────────────────────────────────────────────────── + Status first, phase order second: one full-width band per status in + urgency order, each item tagged with the phase it belongs to. in-progress + and blocked keep their band when empty, because an empty + one is the news. */ +.nextup-bands { display: grid; gap: 0; } +.nextup-band { border-bottom: 1px solid var(--nextup-border); } +.nextup-band-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--nextup-border); } +.nextup-band-head h3 { margin: 0; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; } +.nextup-count { font-size: 0.78rem; color: var(--nextup-fg-muted); font-variant-numeric: tabular-nums; } +.nextup-band.is-in-progress .nextup-band-head { border-bottom: 2px solid var(--nextup-now); } +.nextup-band.is-in-progress .nextup-band-head h3 { color: var(--nextup-now); } +.nextup-band.is-blocked .nextup-band-head { border-bottom: 2px solid var(--nextup-danger); } +.nextup-band.is-blocked .nextup-band-head h3 { color: var(--nextup-danger); } +.nextup-band .nextup-items { gap: 0; } +.nextup-roadmap.is-view-bands .nextup-item { display: grid; grid-template-columns: 28px minmax(0, 1fr) minmax(0, 14rem) max-content; align-items: baseline; column-gap: 12px; border-left: 0; border-bottom: 1px solid var(--nextup-border); padding: 8px 0; } +.nextup-roadmap.is-view-bands .nextup-item-title { grid-column: 2; } +.nextup-roadmap.is-view-bands .nextup-item-summary { grid-column: 2; } +.nextup-item-phase { grid-column: 3; grid-row: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 10px; font-size: 0.75rem; color: var(--nextup-fg-muted); text-transform: uppercase; letter-spacing: 0.06em; } +.nextup-band.is-done .nextup-item-title { font-weight: 500; color: var(--nextup-fg-muted); } +.nextup-band.is-done .nextup-item-summary { display: none; } +.nextup-band.is-proposed .nextup-item-title { font-weight: 500; } + @media (prefers-color-scheme: dark) { .nextup-roadmap { --nextup-fg: #ececec; diff --git a/ui-react/index.tsx b/ui-react/index.tsx index 231fe4e..0a5155b 100644 --- a/ui-react/index.tsx +++ b/ui-react/index.tsx @@ -14,12 +14,17 @@ import { type MilestoneView, type PhaseView, type RoadmapView, + type StatusGroupView, } from "../view-model"; -export type { AnyRoadmap, ItemView, MilestoneView, PhaseView, RoadmapView }; +export type { AnyRoadmap, ItemView, MilestoneView, PhaseView, RoadmapView, StatusGroupView }; export { buildViewModel }; -export type RoadmapViewMode = "phases" | "list" | "compact"; +/** Mirrors `ViewMode` in `render-html.ts`; see the note there on why the two + * new modes need markup of their own. */ +export type RoadmapViewMode = "phases" | "list" | "compact" | "ledger" | "bands"; + +const CELL_MODES: ReadonlySet = new Set(["ledger", "bands"]); export interface RoadmapProps { data: AnyRoadmap; @@ -34,17 +39,32 @@ function Badge({ label, cls }: { label: string; cls: string }) { return {label}; } -function Item({ it, view }: { it: ItemView; view: RoadmapView }) { +function Item({ + it, + view, + mode = "phases", +}: { + it: ItemView; + view: RoadmapView; + mode?: RoadmapViewMode; +}) { const i = it.internal; + const cells = CELL_MODES.has(mode); const pending = i ? i.tasks.filter((t) => t.dispatched === null) : []; return (
  • + {/* Decorative: the status is written out beside it. */} + {cells ?