Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> to serve a build)
/harness inspects every view mode at any width, locale and ground
mcp read-only MCP server on stdio

flags
Expand Down Expand Up @@ -647,6 +648,7 @@ async function cmdServe(args: Args): Promise<number> {
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
});
Expand Down
125 changes: 125 additions & 0 deletions harness.test.ts
Original file line number Diff line number Diff line change
@@ -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('<option value="bands" selected>');
expect(html).toContain("/harness/frame?");
// The project picker is hidden when there is nothing to pick.
expect(renderHarnessPage({ projects: [solo], skins, state: parse("") })).not.toContain(
'name="project"'
);
// No skins declared, no skin picker.
expect(renderHarnessPage({ projects, skins: [], state: parse("") })).not.toContain(
'name="skin"'
);
expect(html).toContain('name="skin"');
});
});

describe("renderHarnessFrame", () => {
it("forces the ground with the attribute the stylesheet understands", () => {
const dark = renderHarnessFrame("<p>x</p>", "dark", "de");
expect(dark).toContain('data-nextup-theme="dark"');
expect(dark).toContain('lang="de"');
expect(dark).toContain("color-scheme: dark");
expect(renderHarnessFrame("<p>x</p>", "light", "en")).toContain('data-nextup-theme="light"');
});
});

describe("renderHarnessFrame with a skin", () => {
const skin: LoadedSkin = {
id: "lexilink",
label: "lexilink",
css: ".roadmap-ledger .nextup-roadmap { --nextup-accent: #f3821d; }",
wrapper: "roadmap-ledger",
dark: "dark",
links: ["https://fonts.googleapis.com/css2?family=Geist"],
};

it("wraps the roadmap, loads the host fonts, and puts the skin last", () => {
const html = renderHarnessFrame("<p>x</p>", "light", "de", skin);
expect(html).toContain('<div class="roadmap-ledger"><p>x</p></div>');
expect(html).toContain(
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist">'
);
// Last wins: the skin's mapping must override the package defaults.
expect(html.indexOf("--nextup-accent: #f3821d")).toBeGreaterThan(
html.indexOf(".nextup-roadmap {")
);
});

it("sets the host's dark class only on the dark ground", () => {
expect(renderHarnessFrame("<p>x</p>", "dark", "de", skin)).toContain('class="dark"');
expect(renderHarnessFrame("<p>x</p>", "light", "de", skin)).not.toContain('class="dark"');
});

it("renders unwrapped and unskinned when no skin is chosen", () => {
const html = renderHarnessFrame("<p>x</p>", "light", "de", null);
expect(html).toContain("<body><p>x</p></body>");
expect(html).not.toContain("roadmap-ledger");
});
});
242 changes: 242 additions & 0 deletions harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
/**
* `nextup serve` → `/harness` — the inspection surface for the views.
*
* A roadmap has to survive more than one shape: five view modes, every locale
* the file declares, both audiences, a phone and a desktop, and both grounds.
* That is a combinatorial space nobody checks by hand, so this renders one
* combination in an iframe and puts the axes in a toolbar above it. The state
* lives in the query string, which makes any single combination a link — the
* useful currency when the answer is "look at THIS one".
*
* Pure: HTML strings in and out, no DOM and no data loading (`serve.ts` owns
* both), so every axis can be asserted under node.
*/

import { esc, type ViewMode } from "./render-html";
import { STYLES } from "./styles";

export const VIEW_MODES: readonly ViewMode[] = [
"phases",
"list",
"compact",
"ledger",
"bands",
] as const;

export type Theme = "light" | "dark";
export type Audience = "public" | "internal";

export interface Device {
id: string;
label: string;
width: number;
/** null means "as tall as the viewport allows". */
height: number | null;
}

/** Widths that actually change a layout decision, not a catalogue of handsets. */
export const DEVICES: readonly Device[] = [
{ id: "phone", label: "Phone", width: 390, height: 844 },
{ id: "phone-l", label: "Phone L", width: 430, height: 932 },
{ id: "tablet", label: "Tablet", width: 768, height: 1024 },
{ id: "laptop", label: "Laptop", width: 1280, height: null },
{ id: "desktop", label: "Desktop", width: 1600, height: null },
{ id: "fit", label: "Fit", width: 0, height: null },
] as const;

/** A skin as the harness needs it: the CSS already read off disk. */
export interface LoadedSkin {
id: string;
label: string;
css: string;
wrapper: string | null;
dark: string | null;
links: string[];
}

export interface HarnessState {
project: string;
skin: string;
view: ViewMode;
locale: string;
audience: Audience;
device: string;
theme: Theme;
landscape: boolean;
}

export interface HarnessOptions {
projects: { id: string; title: string; locales: string[] }[];
/** Empty when no config declared any; the picker then offers only defaults. */
skins: { id: string; label: string }[];
state: HarnessState;
}

/** The package's own look, always offered so a skin can be compared against it. */
export const NO_SKIN = "nextup";

export function parseHarnessState(
params: URLSearchParams,
opts: { projects: HarnessOptions["projects"]; skins?: { id: string }[] }
): HarnessState {
const first = opts.projects[0];
const project = params.get("project") ?? first?.id ?? "";
const known = opts.projects.find((p) => p.id === project) ?? first;
const view = params.get("view") as ViewMode | null;
const device = params.get("device") ?? "laptop";
const theme = params.get("theme");
const locale = params.get("locale");
// A declared skin is the point of previewing: default to the first one and
// let the package's own look be an explicit choice, not the thing you land on.
const skins = opts.skins ?? [];
const skin = params.get("skin") ?? skins[0]?.id ?? NO_SKIN;
return {
project: known?.id ?? "",
skin: skin === NO_SKIN || skins.some((x) => x.id === skin) ? skin : (skins[0]?.id ?? NO_SKIN),
view: view && VIEW_MODES.includes(view) ? view : "ledger",
locale: locale && known?.locales.includes(locale) ? locale : (known?.locales[0] ?? "en"),
audience: params.get("audience") === "public" ? "public" : "internal",
device: DEVICES.some((d) => d.id === device) ? device : "laptop",
theme: theme === "dark" ? "dark" : "light",
landscape: params.get("landscape") === "1",
};
}

export function harnessFrameQuery(s: HarnessState): string {
return new URLSearchParams({
project: s.project,
view: s.view,
locale: s.locale,
audience: s.audience,
theme: s.theme,
skin: s.skin,
}).toString();
}

/**
* The framed document: the roadmap and nothing else, so what the iframe
* measures is the component's own behaviour at that width rather than the
* harness chrome's.
*/
export function renderHarnessFrame(
body: string,
theme: Theme,
locale: string,
skin?: LoadedSkin | null
): string {
// A host that scopes its tokens needs its wrapper, and one that toggles dark
// with a class needs that class — otherwise the skin renders half-applied and
// the preview lies in a way that is worse than showing the defaults.
const wrapped = skin?.wrapper ? `<div class="${esc(skin.wrapper)}">${body}</div>` : body;
const htmlClass = skin?.dark && theme === "dark" ? ` class="${esc(skin.dark)}"` : "";
const links = (skin?.links ?? []).map((u) => `<link rel="stylesheet" href="${esc(u)}">`).join("");
// The skin comes last so its variable mapping wins over the defaults above,
// including the forced-ground block, which is the point of a skin.
return `<!doctype html><html lang="${esc(locale)}" data-nextup-theme="${esc(theme)}"${htmlClass}><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">${links}
<style>
${STYLES}
html { color-scheme: ${theme}; }
body { margin: 0; padding: 20px; background: ${theme === "dark" ? "#0d0d0d" : "#ffffff"};
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
${skin ? `/* skin: ${skin.id} */\n${skin.css}` : ""}
</style></head><body>${wrapped}</body></html>`;
}

function option(value: string, label: string, selected: string): string {
return `<option value="${esc(value)}"${value === selected ? " selected" : ""}>${esc(label)}</option>`;
}

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) =>
`<label class="f"><span>${esc(label)}</span><select name="${esc(name)}">${html}</select></label>`;

return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>nextup harness · ${esc(project?.title ?? "")}</title><style>
:root { color-scheme: light dark; --bg:#fbfbfb; --fg:#1a1a1a; --mut:#6b6b6b; --line:#e0e0e0; --chrome:#fff; }
@media (prefers-color-scheme: dark) { :root { --bg:#151515; --fg:#ececec; --mut:#a0a0a0; --line:#303030; --chrome:#1d1d1d; } }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif;
display:flex; flex-direction:column; height:100vh; }
header { display:flex; flex-wrap:wrap; gap:14px 18px; align-items:flex-end; padding:10px 16px;
background:var(--chrome); border-bottom:1px solid var(--line); }
.f { display:flex; flex-direction:column; gap:3px; }
.f > span { font-size:10px; text-transform:uppercase; letter-spacing:.09em; color:var(--mut); }
select { font:inherit; padding:4px 6px; background:var(--chrome); color:var(--fg);
border:1px solid var(--line); border-radius:4px; min-width:104px; }
.seg { display:flex; border:1px solid var(--line); border-radius:4px; overflow:hidden; }
.seg a { padding:5px 10px; text-decoration:none; color:var(--fg); background:var(--chrome); font-size:12px; }
.seg a + a { border-left:1px solid var(--line); }
.seg a.on { background:var(--fg); color:var(--chrome); }
.grow { flex:1; }
.dims { font-variant-numeric:tabular-nums; color:var(--mut); white-space:nowrap; }
.dims b { color:var(--fg); font-weight:600; }
main { flex:1; overflow:auto; display:flex; justify-content:center; padding:18px; }
.stage { background:var(--chrome); border:1px solid var(--line); border-radius:6px; overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.07); height:fit-content; }
iframe { display:block; border:0; width:100%; height:100%; }
noscript { color:var(--mut); }
</style></head><body>
<header>
<form id="ctl" class="f" style="flex-direction:row; gap:14px; align-items:flex-end; margin:0;">
${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") : ""}
</form>
<div class="f"><span>Orientation</span><div class="seg">
<a href="#" data-set="landscape=0" class="${state.landscape ? "" : "on"}">Portrait</a>
<a href="#" data-set="landscape=1" class="${state.landscape ? "on" : ""}">Landscape</a>
</div></div>
<div class="grow"></div>
<div class="dims"><b id="wh">—</b> · <a href="${esc(src)}" target="_blank" rel="noopener">open frame</a></div>
</header>
<main><div class="stage" id="stage"><iframe id="f" src="${esc(src)}" title="roadmap preview"></iframe></div></main>
<script>
const DEVICES = ${JSON.stringify(DEVICES)};
const state = ${JSON.stringify(state)};
function apply() {
const d = DEVICES.find((x) => x.id === state.device) || DEVICES[3];
const stage = document.getElementById("stage");
const main = document.querySelector("main");
const avail = main.clientHeight - 36;
let w = d.width, h = d.height;
if (state.landscape && d.height) { w = d.height; h = d.width; }
if (!d.width) { w = main.clientWidth - 36; h = avail; }
stage.style.width = w + "px";
stage.style.height = (h ? Math.min(h, avail) : avail) + "px";
document.getElementById("wh").textContent = w + " × " + stage.clientHeight;
}
function go(patch) {
const q = new URLSearchParams(location.search);
for (const [k, v] of Object.entries(patch)) q.set(k, v);
location.search = q.toString();
}
document.getElementById("ctl").addEventListener("change", (e) => {
go({ [e.target.name]: e.target.value });
});
for (const a of document.querySelectorAll("[data-set]")) {
a.addEventListener("click", (e) => {
e.preventDefault();
const [k, v] = a.dataset.set.split("=");
go({ [k]: v });
});
}
addEventListener("resize", apply);
apply();
// The server already streams reloads when a roadmap file changes; reload only
// the frame, so the toolbar keeps its scroll position and focus.
new EventSource("/events").addEventListener("reload", () => {
const f = document.getElementById("f");
f.src = f.src;
});
</script>
</body></html>`;
}
Loading
Loading