diff --git a/cli.ts b/cli.ts index 9df0433..67a06d6 100644 --- a/cli.ts +++ b/cli.ts @@ -44,6 +44,7 @@ commands dispatch open issues for an item's tasks (dry run; --apply to create) build write roadmap.public.json / roadmap.internal.json (+ --site) serve loopback dev server for the internal view (--static to serve a build) + /harness inspects every view mode at any width, locale and ground mcp read-only MCP server on stdio flags @@ -647,6 +648,7 @@ async function cmdServe(args: Args): Promise { console.log( `${green("nextup serve")} ${url} ${dim(str(args, "static") ? `(static ${str(args, "static")})` : "(internal view, live)")}` ); + if (!str(args, "static")) console.log(dim(` harness ${url}/harness`)); await new Promise(() => { // run until killed }); diff --git a/harness.test.ts b/harness.test.ts new file mode 100644 index 0000000..5fefcef --- /dev/null +++ b/harness.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + DEVICES, + type HarnessState, + harnessFrameQuery, + type LoadedSkin, + NO_SKIN, + parseHarnessState, + renderHarnessFrame, + renderHarnessPage, + VIEW_MODES, +} from "./harness"; + +const solo = { id: "lexilink", title: "lexilink", locales: ["de", "en"] }; +const projects = [solo, { id: "other", title: "Other", locales: ["en"] }]; +const skins = [{ id: "lexilink", label: "lexilink" }]; +const parse = (q: string) => parseHarnessState(new URLSearchParams(q), { projects, skins }); +const page = (state: HarnessState) => renderHarnessPage({ projects, skins, state }); + +describe("parseHarnessState", () => { + it("defaults to the first project, the ledger view and its first locale", () => { + const s = parse(""); + expect(s.project).toBe("lexilink"); + expect(s.view).toBe("ledger"); + expect(s.locale).toBe("de"); + expect(s.audience).toBe("internal"); + expect(s.theme).toBe("light"); + // A declared skin wins the default; the package look stays selectable. + expect(s.skin).toBe("lexilink"); + expect(parseHarnessState(new URLSearchParams(""), { projects }).skin).toBe(NO_SKIN); + }); + it("falls back rather than trusting the query string", () => { + // An unknown mode must not reach renderRoadmapHTML as a class name. + expect(parse("view=../../etc/passwd").view).toBe("ledger"); + expect(parse("device=nonsense").device).toBe("laptop"); + expect(parse("theme=neon").theme).toBe("light"); + // An undeclared skin must not reach the file reader. + expect(parse("skin=../../etc/passwd").skin).toBe("lexilink"); + expect(parse("skin=nextup").skin).toBe(NO_SKIN); + expect(parse("skin=lexilink").skin).toBe("lexilink"); + // A locale the project does not declare falls back to its first. + expect(parse("project=other&locale=de").locale).toBe("en"); + expect(parse("project=ghost").project).toBe("lexilink"); + }); + it("round-trips every axis it accepts", () => { + const s = parse( + "project=other&view=bands&locale=en&audience=public&device=phone&theme=dark&landscape=1&skin=lexilink" + ); + expect(s).toEqual({ + project: "other", + skin: "lexilink", + view: "bands", + locale: "en", + audience: "public", + device: "phone", + theme: "dark", + landscape: true, + } satisfies HarnessState); + expect(harnessFrameQuery(s)).toContain("view=bands"); + expect(harnessFrameQuery(s)).toContain("theme=dark"); + }); +}); + +describe("renderHarnessPage", () => { + it("offers every view mode and device, and marks the current state", () => { + const html = page(parse("view=bands&device=phone")); + for (const m of VIEW_MODES) expect(html).toContain(`value="${m}"`); + for (const d of DEVICES) expect(html).toContain(`value="${d.id}"`); + expect(html).toContain('`; +} + +export function renderHarnessPage(opts: HarnessOptions): string { + const { projects, skins, state } = opts; + const project = projects.find((p) => p.id === state.project); + const src = `/harness/frame?${harnessFrameQuery(state)}`; + const sel = (name: string, html: string, label: string) => + ``; + + return ` + +nextup harness · ${esc(project?.title ?? "")} +
+
+ ${projects.length > 1 ? sel("project", projects.map((p) => option(p.id, p.title, state.project)).join(""), "Project") : ""} + ${sel("view", VIEW_MODES.map((m) => option(m, m, state.view)).join(""), "View")} + ${sel("locale", (project?.locales ?? ["en"]).map((l) => option(l, l, state.locale)).join(""), "Locale")} + ${sel("audience", ["internal", "public"].map((a) => option(a, a, state.audience)).join(""), "Audience")} + ${sel("device", DEVICES.map((d) => option(d.id, d.width ? `${d.label} · ${d.width}` : d.label, state.device)).join(""), "Device")} + ${sel("theme", ["light", "dark"].map((t) => option(t, t, state.theme)).join(""), "Theme")} + ${skins.length > 0 ? sel("skin", [option(NO_SKIN, "nextup (default)", state.skin), ...skins.map((k) => option(k.id, k.label, state.skin))].join(""), "Skin") : ""} +
+
Orientation
+
+
— · open frame
+
+
+ +`; +} diff --git a/load.ts b/load.ts index fea8dee..958960f 100644 --- a/load.ts +++ b/load.ts @@ -14,7 +14,7 @@ import path from "node:path"; import YAML from "yaml"; import type { z } from "zod"; import { configSchema, estateSchema, roadmapSchema } from "./schema"; -import type { EstateConfig, Finding, NextupConfig, Roadmap } from "./types"; +import type { EstateConfig, Finding, NextupConfig, Roadmap, Skin } from "./types"; export const ROADMAP_FILENAME = "roadmap.yaml"; export const CONFIG_FILENAME = "nextup.config.yaml"; @@ -86,6 +86,8 @@ export interface Located { files: string[]; /** Which file decided the walk. */ via: "config" | "roadmap"; + /** Host skins for the serve harness; empty unless a config declared them. */ + skins: Skin[]; } async function exists(p: string): Promise { @@ -116,12 +118,12 @@ export async function locate(cwd: string): Promise { const parsed = parseConfig(await fs.readFile(cfg, "utf8")); if (parsed.ok) { const files = await expandGlobs(dir, parsed.value.roadmaps); - return { root: dir, files, via: "config" }; + return { root: dir, files, via: "config", skins: parsed.value.skins ?? [] }; } } for (const dir of ancestors) { if (await exists(path.join(dir, ROADMAP_FILENAME))) { - return { root: dir, files: [ROADMAP_FILENAME], via: "roadmap" }; + return { root: dir, files: [ROADMAP_FILENAME], via: "roadmap", skins: [] }; } } return null; 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/schema.ts b/schema.ts index 0e21cea..b72cf72 100644 --- a/schema.ts +++ b/schema.ts @@ -202,9 +202,32 @@ export const roadmapSchema = z.strictObject({ milestones: z.array(milestoneSchema).default([]), }); +/** + * A host's skin, for `serve`'s harness only: the stylesheets a consuming app + * wraps the component in, so the preview shows what that app will show rather + * than the package defaults. Never read by `build` — a projection carries no + * styling, and a skin must not be able to change what is published. + */ +export const skinSchema = z.strictObject({ + id: z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9-]*$/, "lower-case slug"), + label: z.string().min(1).optional(), + /** Applied in order, after the component's own stylesheet. */ + css: z.array(z.string().min(1)).min(1), + /** Class on a wrapper around the roadmap, when the host scopes its tokens. */ + wrapper: z.string().min(1).optional(), + /** Class the host sets for its dark ground, when it does not use the media query. */ + dark: z.string().min(1).optional(), + /** Stylesheet URLs to load first (webfonts). */ + links: z.array(z.url()).optional(), +}); + /** `nextup.config.yaml` at a repo root: which roadmap files `--all` walks. */ export const configSchema = z.strictObject({ roadmaps: z.array(z.string().min(1)).min(1), + skins: z.array(skinSchema).optional(), }); /** Estate config for `build --estate`: roadmaps fetched from GitHub, no checkouts. */ diff --git a/serve.ts b/serve.ts index 4790749..ee6f266 100644 --- a/serve.ts +++ b/serve.ts @@ -18,7 +18,18 @@ import { promises as fs, watch } from "node:fs"; import http from "node:http"; import path from "node:path"; import { type LoadedStatus, loadStatus, packageAsset, projectionFor } from "./build"; +import { + type LoadedSkin, + parseHarnessState, + renderHarnessFrame, + renderHarnessPage, +} from "./harness"; +import { resolveText } from "./i18n"; import { locate } from "./load"; +import { toInternal, toPublic } from "./project"; +import { renderRoadmapHTML } from "./render-html"; +import type { Skin } from "./types"; +import { buildViewModel } from "./view-model"; export interface ServeOptions { port: number; @@ -62,6 +73,35 @@ async function serveFile(root: string, urlPath: string, res: http.ServerResponse } } +/** + * Read a skin's stylesheets. A skin names paths in a config file, so they are + * resolved against the repo root and refused if they escape it: `serve` is a + * dev server, but it should not become a way to read `~/.ssh` through a config + * someone pasted in. Concatenated in declared order — a host's token file + * before the file that maps those tokens onto the component. + */ +async function loadSkin(root: string, skin: Skin): Promise { + const parts: string[] = []; + for (const rel of skin.css) { + const file = path.resolve(root, rel); + if (!file.startsWith(path.resolve(root))) return null; + try { + parts.push(await fs.readFile(file, "utf8")); + } catch { + // A skin that names a moved file should degrade to the defaults, not 500. + return null; + } + } + return { + id: skin.id, + label: skin.label ?? skin.id, + css: parts.join("\n"), + wrapper: skin.wrapper ?? null, + dark: skin.dark ?? null, + links: [...(skin.links ?? [])], + }; +} + export function createServer(options: ServeOptions): http.Server { const ttl = options.ttlMs ?? 5 * 60 * 1000; let cache: { at: number; value: LoadedStatus[] } | null = null; @@ -124,6 +164,51 @@ export function createServer(options: ServeOptions): http.Server { ); return send(res, 200, html, TYPES[".html"]); } + // The harness: one view mode, locale, audience, width and ground at a + // time, with the axes in the query string so a combination is a link. + if (url.pathname === "/harness" || url.pathname === "/harness/frame") { + const all = await statuses(); + const projects = all.map((x) => ({ + id: x.roadmap.meta.project, + // Chrome only, so the file's own first locale is the right one. + title: resolveText(x.roadmap.meta.title, x.roadmap.meta.locales[0] ?? "en", [ + ...x.roadmap.meta.locales, + ]), + locales: [...x.roadmap.meta.locales], + })); + if (projects.length === 0) return send(res, 404, "no roadmap under this directory"); + const located = await locate(options.cwd); + const declared = located?.skins ?? []; + const state = parseHarnessState(url.searchParams, { projects, skins: declared }); + if (url.pathname === "/harness") + return send( + res, + 200, + renderHarnessPage({ + projects, + skins: declared.map((k) => ({ id: k.id, label: k.label ?? k.id })), + state, + }), + TYPES[".html"] + ); + const found = all.find((x) => x.roadmap.meta.project === state.project); + if (!found) return send(res, 404, "no such project"); + // `projectionFor` erases the type for the JSON routes; the harness + // renders it, so it takes the typed projection instead. + const projection = + state.audience === "public" + ? toPublic(found.roadmap, found.rollup) + : toInternal(found.roadmap, found.rollup, found.file); + const vm = buildViewModel(projection, state.locale); + const chosen = declared.find((k) => k.id === state.skin); + const skin = chosen && located ? await loadSkin(located.root, chosen) : null; + return send( + res, + 200, + renderHarnessFrame(renderRoadmapHTML(vm, state.view), state.theme, state.locale, skin), + TYPES[".html"] + ); + } if (url.pathname === "/nextup-roadmap.iife.js") { const bundle = await packageAsset("dist/wc/nextup-roadmap.iife.js"); if (!bundle) return send(res, 500, "bundle not built — run `bun run build:wc`"); diff --git a/styles.ts b/styles.ts index 23ac5fe..57a5d68 100644 --- a/styles.ts +++ b/styles.ts @@ -6,6 +6,32 @@ * fighting selectors. */ +/** The two palettes, declared once and emitted three ways: the light one as + * the element's own defaults, the dark one under both the media query and the + * `data-nextup-theme` attribute. Keeping them here is what stops the forced + * theme from drifting away from the automatic one. */ +const LIGHT_VARS = ` + --nextup-fg: #1a1a1a; + --nextup-fg-muted: #6b6b6b; + --nextup-card-bg: #ffffff; + --nextup-border: #e3e3e3; + --nextup-accent: #0e8a16; + --nextup-now: #1d76db; + --nextup-warn: #b45309; + --nextup-danger: #b60205; +`; + +const DARK_VARS = ` + --nextup-fg: #ececec; + --nextup-fg-muted: #a3a3a3; + --nextup-card-bg: #171717; + --nextup-border: #333; + --nextup-accent: #3fb950; + --nextup-now: #58a6ff; + --nextup-warn: #d29922; + --nextup-danger: #f85149; +`; + export const STYLES = ` .nextup-roadmap { --nextup-fg: #1a1a1a; @@ -26,6 +52,10 @@ export const STYLES = ` line-height: 1.45; } .nextup-roadmap * { box-sizing: border-box; } +/* The cell views below ask a container query, not a media query: this + component is a block on someone else's page and is as likely to be narrow + in a sidebar on a desktop as on a phone. */ +.nextup-roadmap { container-type: inline-size; container-name: nextup; } .nextup-header { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 16px; margin-bottom: var(--nextup-gap); } .nextup-header h2 { margin: 0; font-size: 1.4rem; } .nextup-version { color: var(--nextup-fg-muted); font-size: 0.85rem; } @@ -40,6 +70,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,16 +106,107 @@ 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; } +/* Anything else the item renders belongs in the content column, not in the + glyph gutter that auto-placement would pick. */ +.nextup-roadmap.is-view-ledger .nextup-links, +.nextup-roadmap.is-view-ledger .nextup-internal, +.nextup-roadmap.is-view-bands .nextup-links, +.nextup-roadmap.is-view-bands .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; } +/* ── narrow ────────────────────────────────────────────────────────── + Fixed columns cannot hold at a phone width: the title, the phase tag and + the status word end up on top of each other. Below the breakpoint every + cell view becomes glyph + stacked content, with the metadata on its own + line under the title. */ +@container nextup (max-width: 640px) { + .nextup-roadmap.is-view-ledger .nextup-item, + .nextup-roadmap.is-view-bands .nextup-item { grid-template-columns: 22px minmax(0, 1fr); row-gap: 2px; } + .nextup-roadmap.is-view-ledger .nextup-item-status, + .nextup-roadmap.is-view-bands .nextup-item-status { grid-column: 2; grid-row: auto; text-align: left; } + .nextup-roadmap.is-view-bands .nextup-item-phase { grid-column: 2; grid-row: auto; white-space: normal; padding-right: 0; } + .nextup-roadmap.is-view-ledger .nextup-phase.is-done { grid-template-columns: minmax(0, 1fr); row-gap: 2px; } + .nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-phase-head h3, + .nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-gets, + .nextup-roadmap.is-view-ledger .nextup-phase.is-done .nextup-horizon { grid-column: 1; grid-row: auto; text-align: left; } + .nextup-roadmap.is-view-ledger .nextup-phase.is-current { padding-left: 10px; } + .nextup-roadmap.is-view-ledger .nextup-phase-head h3 { font-size: 1.15rem; } + .nextup-roadmap.is-view-ledger .nextup-gets { font-size: 1rem; } +} + @media (prefers-color-scheme: dark) { - .nextup-roadmap { - --nextup-fg: #ececec; - --nextup-fg-muted: #a3a3a3; - --nextup-card-bg: #171717; - --nextup-border: #333; - --nextup-accent: #3fb950; - --nextup-now: #58a6ff; - --nextup-warn: #d29922; - --nextup-danger: #f85149; - } + .nextup-roadmap { ${DARK_VARS} } } +/* The same palette as an attribute, so a host with its own theme toggle (or a + preview harness that must show both grounds side by side) can force it + without waiting on the OS. Set data-nextup-theme on any ancestor. */ +[data-nextup-theme="dark"] .nextup-roadmap { ${DARK_VARS} } +[data-nextup-theme="light"] .nextup-roadmap { ${LIGHT_VARS} } `; diff --git a/types.ts b/types.ts index 128fd0b..e87a2a5 100644 --- a/types.ts +++ b/types.ts @@ -26,6 +26,7 @@ import type { publicPhaseSchema, publicRoadmapSchema, roadmapSchema, + skinSchema, sourceKindSchema, sourceSchema, taskSchema, @@ -54,6 +55,7 @@ export type Roadmap = z.infer; export type RoadmapInput = z.input; export type NextupConfig = z.infer; +export type Skin = z.infer; export type EstateConfig = z.infer; export type PublicPhase = z.infer; 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 ?