feat: bench checklist for combined INDX + Gen 2 conversion

Flattens Prusa's three-document route (INDX guide, Gen 2 upgrade guide,
and the combined article) into one linear list of 13 blocks with entry
and stop points. Progress is kept in localStorage; no backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naps62-yolo
2026-08-20 17:03:38 +01:00
commit 511caf1c78
17 changed files with 2452 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.git
+3
View File
@@ -0,0 +1,3 @@
node_modules
dist
.DS_Store
+12
View File
@@ -0,0 +1,12 @@
FROM node:26-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+37
View File
@@ -0,0 +1,37 @@
# indx-checklist
A bench checklist for converting a Prusa CORE One with the **INDX conversion kit** and
the **CORE One+ Gen 2 upgrade** at the same time.
Prusa splits that job across three documents — the INDX guide, the Gen 2 guide, and a
combined article that tells you when to jump between them. This flattens all of it into
one linear list of 13 blocks: which guide you are in, the exact step to enter at, the
exact step to stop after, and the work that only exists in the combined article.
Progress lives in `localStorage`. There is no backend, no database, and nothing leaves
the browser — so the checklist you tick on the phone at the printer is separate from the
one on your desktop.
## Develop
```
pnpm install
pnpm dev # http://0.0.0.0:5180
pnpm build # typecheck + static build into dist/
```
## Deploy
Dockerfile builds the static site and serves it from nginx on port 80. Dokploy watches
`main` on `git.naps.pt/yolo/indx-checklist` and redeploys on push.
## Content
Every step, link, rivet count and warning comes from Prusa's own documentation:
- [Combined article](https://help.prusa3d.com/article/assemblling-the-prusa-indx-core-one-with-the-gen-2-upgrade_1147602)
- [INDX conversion manual](https://help.prusa3d.com/manual/prusa-indx-conversion-kit-for-the-prusa-core-one_2432)
- [CORE One+ to Gen 2 manual](https://help.prusa3d.com/manual/prusa-core-one-to-gen-2-upgrade_2435)
The one paragraph that is not from Prusa is the closing note about which Gen 2 chapters
the route never visits, and it says so on the page.
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>INDX + Gen 2 Bench Order</title>
<meta
name="description"
content="A bench checklist for converting a Prusa CORE One with the INDX kit and the Gen 2 upgrade at the same time. Progress is saved in this browser."
/>
<meta name="theme-color" content="#EDEFF1" media="(prefers-color-scheme: light)" />
<meta name="theme-color" content="#111417" media="(prefers-color-scheme: dark)" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_types text/css application/javascript image/svg+xml;
gzip_min_length 1024;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
}
}
+23
View File
@@ -0,0 +1,23 @@
{
"name": "indx-checklist",
"private": true,
"type": "module",
"packageManager": "pnpm@11.21.0",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19",
"react-dom": "^19"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4",
"typescript": "^5",
"vite": "^6"
}
}
+1145
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="5" fill="#15181C" />
<path d="M7 8h7.5" stroke="#A8480B" stroke-width="3" stroke-linecap="round" />
<path d="M7 16h11" stroke="#1A6076" stroke-width="3" stroke-linecap="round" />
<path d="M7 24h6" stroke="#8B949C" stroke-width="3" stroke-linecap="round" />
<path d="M20 21.5l3 3 6-7" stroke="#E8925A" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

+226
View File
@@ -0,0 +1,226 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ALL_TASK_IDS, LANES, SOURCES, STEPS, type Step } from "./data";
import {
loadChecked,
loadTheme,
saveChecked,
saveTheme,
storageWorks,
type Checked,
type Theme,
} from "./storage";
export default function App() {
const [checked, setChecked] = useState<Checked>(() => loadChecked());
const [theme, setTheme] = useState<Theme>(() => loadTheme());
const [persists] = useState<boolean>(() => storageWorks());
useEffect(() => saveChecked(checked), [checked]);
useEffect(() => {
saveTheme(theme);
const root = document.documentElement;
if (theme === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", theme);
}, [theme]);
const toggle = useCallback((id: string) => {
setChecked((prev) => {
const next = { ...prev };
if (next[id]) delete next[id];
else next[id] = true;
return next;
});
}, []);
const toggleStep = useCallback((step: Step, done: boolean) => {
setChecked((prev) => {
const next = { ...prev };
for (const t of step.tasks) {
if (done) next[t.id] = true;
else delete next[t.id];
}
return next;
});
}, []);
const doneCount = useMemo(
() => ALL_TASK_IDS.filter((id) => checked[id]).length,
[checked],
);
const pct = Math.round((doneCount / ALL_TASK_IDS.length) * 100);
const stepDone = useCallback(
(step: Step) => step.tasks.every((t) => checked[t.id]),
[checked],
);
const currentId = STEPS.find((s) => !stepDone(s))?.id ?? null;
const stepsDone = STEPS.filter(stepDone).length;
const reset = () => {
if (!window.confirm("Clear all progress on this device?")) return;
setChecked({});
};
return (
<div className="wrap">
<header className="mast">
<div className="mast-top">
<div className="eyebrow">Prusa CORE One · combined conversion</div>
<ThemeToggle theme={theme} onChange={setTheme} />
</div>
<h1>INDX + Gen 2 Bench Order</h1>
<p className="standfirst">
One linear pass through three documents. Each block says which guide you are in,
where to enter it, and where to stop. Do not read any guide end to end the stop
points are what keep the two kits from fighting each other.
</p>
<div className="legend">
{(Object.keys(LANES) as Array<keyof typeof LANES>).map((k) => (
<span key={k} className={`key ${k}`}>
{LANES[k].name}
</span>
))}
</div>
</header>
<div className="progress" role="group" aria-label="Progress">
<div className="progress-line">
<span className="progress-figure">{pct}%</span>
<div className="bar" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<div className="bar-fill" style={{ width: `${pct}%` }} />
</div>
<button className="reset" onClick={reset} disabled={doneCount === 0}>
Clear progress
</button>
</div>
<div className="progress-meta">
<span>
{stepsDone} of {STEPS.length} blocks · {doneCount} of {ALL_TASK_IDS.length} checks
</span>
<span>
{persists
? "Saved in this browser only. Nothing leaves the device."
: "This browser is blocking storage, so progress will not survive a reload."}
</span>
</div>
</div>
<ol className="steps">
{STEPS.map((step, i) => {
const done = stepDone(step);
return (
<li
key={step.id}
id={step.id}
className={`step ${step.lane}${done ? " done" : ""}${step.id === currentId ? " current" : ""}`}
>
<button
className="tick"
aria-pressed={done}
aria-label={done ? `Mark "${step.title}" not done` : `Mark "${step.title}" done`}
onClick={() => toggleStep(step, !done)}
/>
<div className="head">
<span className="num">{String(i + 1).padStart(2, "0")}</span>
<span className="lane-tag">{LANES[step.lane].tag}</span>
<h2>{step.title}</h2>
{step.id === currentId && <span className="here-marker">You are here</span>}
</div>
<div className="body">
{step.enter && (
<div className="range">
<div>
<span className="lbl">Enter</span>
<span className="val">
<a href={step.enter.url} target="_blank" rel="noreferrer">
{step.enter.label}
</a>
</span>
</div>
{step.stop && (
<div>
<span className="lbl">Stop</span>
<span className="val">
after <b>{step.stop}</b>
</span>
</div>
)}
</div>
)}
<ul className="acts">
{step.tasks.map((t) => (
<li key={t.id}>
<label className={checked[t.id] ? "task done" : "task"}>
<input
type="checkbox"
checked={!!checked[t.id]}
onChange={() => toggle(t.id)}
/>
<span>{t.text}</span>
</label>
</li>
))}
</ul>
{step.notes?.map((n, ni) => (
<div className="note" key={ni}>
<span className="sig">{n.sig}</span>
<span>{n.body}</span>
</div>
))}
</div>
</li>
);
})}
</ol>
<footer className="tail">
<h3>What you never open</h3>
<p>
You only ever enter three places in the Gen 2 guide: the belts chapter and the two
heatbed entry points above. Gen 2 chapters <b>1. Introduction</b>,{" "}
<b>2. Printer disassembly</b> and <b>5. Preflight check</b> are not part of this
route the INDX guide covers the disassembly and the final checks. That is a
reading of the route rather than something the combined article states outright, so
glance at the Gen 2 preflight chapter at the end to be sure nothing in it is unique
to the new belts.
</p>
<h3>Sources</h3>
<div className="src">
{SOURCES.map((s) => (
<a key={s.url} href={s.url} target="_blank" rel="noreferrer">
{s.label}
</a>
))}
</div>
</footer>
</div>
);
}
function ThemeToggle({ theme, onChange }: { theme: Theme; onChange: (t: Theme) => void }) {
const opts: Array<{ v: Theme; label: string }> = [
{ v: "light", label: "Light" },
{ v: "system", label: "Auto" },
{ v: "dark", label: "Dark" },
];
return (
<div className="theme" role="radiogroup" aria-label="Colour theme">
{opts.map((o) => (
<button
key={o.v}
role="radio"
aria-checked={theme === o.v}
className={theme === o.v ? "on" : ""}
onClick={() => onChange(o.v)}
>
{o.label}
</button>
))}
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
export type Lane = "indx" | "gen2" | "here";
export type Note = { sig: string; body: string };
export type Task = { id: string; text: string };
export type Step = {
id: string;
lane: Lane;
title: string;
enter?: { label: string; url: string };
stop?: string;
tasks: Task[];
notes?: Note[];
};
export const LANES: Record<Lane, { tag: string; name: string }> = {
indx: { tag: "INDX", name: "INDX conversion guide" },
gen2: { tag: "Gen 2", name: "Gen 2 upgrade guide" },
here: { tag: "Here", name: "Combined article — do it on this page" },
};
export const SOURCES = [
{
label: "Combined article — Assembling the Prusa INDX CORE One with the Gen 2 upgrade",
url: "https://help.prusa3d.com/article/assemblling-the-prusa-indx-core-one-with-the-gen-2-upgrade_1147602",
},
{
label: "INDX conversion kit for the Prusa CORE One — full manual",
url: "https://help.prusa3d.com/manual/prusa-indx-conversion-kit-for-the-prusa-core-one_2432",
},
{
label: "Prusa CORE One+ to (Gen 2) upgrade — full manual",
url: "https://help.prusa3d.com/manual/prusa-core-one-to-gen-2-upgrade_2435",
},
];
export const STEPS: Step[] = [
{
id: "s01",
lane: "indx",
title: "Start the INDX conversion",
enter: {
label: "1. Introduction — from the very beginning",
url: "https://help.prusa3d.com/guide/1-introduction_1096223",
},
stop: "Securing the bed spacer — right",
tasks: [{ id: "s01-t1", text: "Worked the INDX guide from the start to the stop point." }],
notes: [
{
sig: "Kit",
body: "If the Gen 2 upgrade came inside the INDX conversion kit, every Gen 2 part — belts, pulleys, spacers, fasteners — is in the UPGRADE box, not the INDX boxes.",
},
],
},
{
id: "s02",
lane: "here",
title: "Unplug the motors, strip the left side",
tasks: [
{ id: "s02-t1", text: "Pressed the safety latch and disconnected the X and Y motor cables from the xBuddy board at the back." },
{ id: "s02-t2", text: "Pulled both cables through for slack, connectors still inside the electronics compartment." },
{ id: "s02-t3", text: "Removed the 5 nylon rivets holding the left top see-through cover, supporting it on the last ones." },
{ id: "s02-t4", text: "Removed the 11 nylon rivets holding the left steel side panel, and took the panel off." },
],
},
{
id: "s03",
lane: "indx",
title: "Z-axis work, first half",
enter: {
label: "Removing the rear bed spacer",
url: "https://help.prusa3d.com/guide/3-z-axis-upgrade_1096239#1098949",
},
stop: "3.19 Securing the offset sensor cable",
tasks: [{ id: "s03-t1", text: "Worked the INDX guide from the entry point to the stop point." }],
notes: [
{
sig: "Photos",
body: "The INDX photos still show the left panel and cover fitted. Yours are off — carry on as written.",
},
],
},
{
id: "s04",
lane: "gen2",
title: "Expansion joints and heatbed spacer",
enter: {
label: "Removing old expansion joints",
url: "https://help.prusa3d.com/guide/4-heatbed-upgrade_1110680#1110931",
},
stop: "Inserting the heatbed spacer",
tasks: [{ id: "s04-t1", text: "Worked the Gen 2 guide from the entry point to the stop point." }],
notes: [
{
sig: "Photos",
body: "Gen 2 shows the heatbed leaning against the back of the printer. Yours is already off, which changes nothing about fitting the expansion joints.",
},
],
},
{
id: "s05",
lane: "indx",
title: "Z-axis work, second half — up to the belts",
enter: {
label: "Removing the bed-cable-cover-bottom",
url: "https://help.prusa3d.com/guide/3-z-axis-upgrade_1096239#1099677",
},
stop: "Releasing the belts",
tasks: [{ id: "s05-t1", text: "Worked the INDX guide from the entry point to the stop point." }],
notes: [
{
sig: "Photos",
body: "One INDX photo still shows the old expansion joints. Yours are already replaced — carry on.",
},
],
},
{
id: "s06",
lane: "here",
title: "Strip the right side, then read the belt notes",
tasks: [
{ id: "s06-t1", text: "Removed the 5 nylon rivets holding the right top see-through cover, and took it off." },
{ id: "s06-t2", text: "Removed the 11 nylon rivets holding the right steel side panel, and took it off." },
{ id: "s06-t3", text: "Lubricated the tip and full thread of both M3x30 belt-tensioning screws, and refitted them into the tensioner." },
],
notes: [
{
sig: "Lube",
body: "The lubricant is the Prusa lubricant in the Fasteners 2/2 bag of the INDX kit. Because you do it now, skip the lubrication step later when it appears in the INDX guide.",
},
{
sig: "Photos",
body: "Two harmless mismatches ahead: the PTFE tube to the filament sensor is gone on your machine but visible in Gen 2 photos; and the Bowden-guide, which Gen 2 refits to the motor assembly, is not needed for the INDX conversion — take it out once it is detached from the motor mount.",
},
],
},
{
id: "s07",
lane: "gen2",
title: "GT1.5 belts and pulleys",
enter: {
label: "Removing the belts",
url: "https://help.prusa3d.com/guide/3-belts-upgrade_1110672#1149188",
},
stop: "Guiding the upper belt (gantry-right)",
tasks: [{ id: "s07-t1", text: "Worked the Gen 2 guide from the entry point to the stop point." }],
},
{
id: "s08",
lane: "indx",
title: "Gantry alignment, toolhead, side filament sensors",
enter: {
label: "Gantry aligner tool: parts preparation",
url: "https://help.prusa3d.com/guide/4-indx-toolhead-side-filament-sensors_1096247#1100758",
},
stop: "Covering the FS — right",
tasks: [{ id: "s08-t1", text: "Worked the INDX guide from the entry point to the stop point." }],
notes: [
{
sig: "Photos",
body: "The INDX photos still show the right panel and cover fitted. Yours are off — carry on as written.",
},
{
sig: "Skip",
body: "The belt-tensioner lubrication step appears in here. You already did it — skip it.",
},
],
},
{
id: "s09",
lane: "here",
title: "Refit both see-through covers",
tasks: [
{ id: "s09-t1", text: "Left top cover back on with 4 nylon rivets, top middle hole left empty for the filament sensor." },
{ id: "s09-t2", text: "Right top cover back on. On the 8-tool INDX, its top-middle rivet is left out too." },
],
},
{
id: "s10",
lane: "indx",
title: "PTFE tubes through the spoolholder chapter",
enter: {
label: "PTFE tubes — left side: parts preparation",
url: "https://help.prusa3d.com/guide/4-indx-toolhead-side-filament-sensors_1096247#1103061",
},
stop: "5.16 Securing the zip ties II",
tasks: [{ id: "s10-t1", text: "Worked the INDX guide from the entry point to the stop point." }],
},
{
id: "s11",
lane: "gen2",
title: "Align the joints, fix the heatbed",
enter: {
label: "Expansion joint aligner",
url: "https://help.prusa3d.com/guide/4-heatbed-upgrade_1110680#1110993",
},
stop: "Fixing the heatbed — the last of Gen 2",
tasks: [{ id: "s11-t1", text: "Worked the Gen 2 guide from the entry point to the stop point." }],
},
{
id: "s12",
lane: "here",
title: "Reconnect the motors, close the left side",
tasks: [
{ id: "s12-t1", text: "Left cable (X motor) into the left xBuddy connector, labelled X." },
{ id: "s12-t2", text: "Right cable (Y motor) into the right xBuddy connector, labelled Y." },
{ id: "s12-t3", text: "Both safety latches clicked home." },
{ id: "s12-t4", text: "Left steel side panel refitted with 3 nylon rivets in the highlighted positions." },
],
},
{
id: "s13",
lane: "indx",
title: "Finish the INDX guide",
enter: {
label: "Electronics covers: parts preparation",
url: "https://help.prusa3d.com/guide/5-spoolholders-tool-dock-assembly_1096255#1105054",
},
stop: "The end of the INDX guide, including its preflight check",
tasks: [{ id: "s13-t1", text: "Worked the INDX guide to the end." }],
},
];
export const ALL_TASK_IDS = STEPS.flatMap((s) => s.tasks.map((t) => t.id));
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+58
View File
@@ -0,0 +1,58 @@
const KEY = "indx-checklist.v1";
const THEME_KEY = "indx-checklist.theme";
export type Checked = Record<string, boolean>;
export type Theme = "light" | "dark" | "system";
export function loadChecked(): Checked {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Checked = {};
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (v === true) out[k] = true;
}
return out;
} catch {
return {};
}
}
export function saveChecked(checked: Checked): void {
try {
localStorage.setItem(KEY, JSON.stringify(checked));
} catch {
/* private mode, quota, or storage disabled — progress just won't survive a reload */
}
}
export function loadTheme(): Theme {
try {
const raw = localStorage.getItem(THEME_KEY);
return raw === "light" || raw === "dark" ? raw : "system";
} catch {
return "system";
}
}
export function saveTheme(theme: Theme): void {
try {
if (theme === "system") localStorage.removeItem(THEME_KEY);
else localStorage.setItem(THEME_KEY, theme);
} catch {
/* ignore */
}
}
export function storageWorks(): boolean {
try {
const probe = "__indx_probe__";
localStorage.setItem(probe, "1");
localStorage.removeItem(probe);
return true;
} catch {
return false;
}
}
+634
View File
@@ -0,0 +1,634 @@
:root {
--ground: #edeff1;
--card: #ffffff;
--card-sunk: #f5f7f8;
--ink: #15181c;
--ink-2: #474f58;
--ink-3: #6c767f;
--rule: #d3d8dc;
--rule-soft: #e3e7ea;
--indx: #a8480b;
--indx-tint: #fbefe6;
--gen2: #1a6076;
--gen2-tint: #e7f1f4;
--here: #3e4750;
--here-tint: #e8ebed;
--warn: #8a6a00;
--warn-tint: #fbf3dc;
--shadow: 0 1px 2px rgba(21, 24, 28, 0.06), 0 8px 24px -16px rgba(21, 24, 28, 0.35);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #111417;
--card: #191d21;
--card-sunk: #14181b;
--ink: #e9edef;
--ink-2: #aeb7be;
--ink-3: #828c94;
--rule: #2c3339;
--rule-soft: #232930;
--indx: #e8925a;
--indx-tint: #2a1b12;
--gen2: #6fc0d8;
--gen2-tint: #10262e;
--here: #aeb7be;
--here-tint: #20262b;
--warn: #d9b451;
--warn-tint: #272012;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px -16px rgba(0, 0, 0, 0.8);
}
}
:root[data-theme="dark"] {
--ground: #111417;
--card: #191d21;
--card-sunk: #14181b;
--ink: #e9edef;
--ink-2: #aeb7be;
--ink-3: #828c94;
--rule: #2c3339;
--rule-soft: #232930;
--indx: #e8925a;
--indx-tint: #2a1b12;
--gen2: #6fc0d8;
--gen2-tint: #10262e;
--here: #aeb7be;
--here-tint: #20262b;
--warn: #d9b451;
--warn-tint: #272012;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px -16px rgba(0, 0, 0, 0.8);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
font-size: 16px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
.wrap {
max-width: 920px;
margin: 0 auto;
padding: 40px 20px 96px;
display: flex;
flex-direction: column;
gap: 28px;
}
/* ---- masthead ---- */
.mast {
display: flex;
flex-direction: column;
gap: 14px;
padding-bottom: 26px;
border-bottom: 2px solid var(--rule);
}
.mast-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-3);
}
h1 {
font-family: "Archivo", ui-sans-serif, system-ui, sans-serif;
font-weight: 700;
font-size: clamp(30px, 5vw, 44px);
line-height: 1.05;
margin: 0;
letter-spacing: -0.02em;
text-wrap: balance;
}
.standfirst {
margin: 0;
max-width: 62ch;
color: var(--ink-2);
font-size: 17px;
}
.theme {
display: inline-flex;
border: 1px solid var(--rule);
border-radius: 3px;
overflow: hidden;
background: var(--card);
}
.theme button {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
background: transparent;
border: 0;
border-right: 1px solid var(--rule-soft);
padding: 6px 11px;
cursor: pointer;
}
.theme button:last-child {
border-right: 0;
}
.theme button.on {
background: var(--here-tint);
color: var(--ink);
}
.theme button:focus-visible {
outline: 2px solid var(--ink-3);
outline-offset: -2px;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.key {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 6px 11px;
border-radius: 2px;
border: 1px solid var(--rule);
background: var(--card);
color: var(--ink-2);
}
.key::before {
content: "";
width: 10px;
height: 10px;
border-radius: 2px;
background: var(--swatch, var(--ink-3));
}
.key.indx {
--swatch: var(--indx);
}
.key.gen2 {
--swatch: var(--gen2);
}
.key.here {
--swatch: var(--here);
}
/* ---- progress ---- */
.progress {
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
margin: 0 -4px;
background: var(--card);
border: 1px solid var(--rule-soft);
border-radius: 3px;
box-shadow: var(--shadow);
}
.progress-line {
display: flex;
align-items: center;
gap: 14px;
}
.progress-figure {
font-family: "Archivo", ui-sans-serif, sans-serif;
font-weight: 700;
font-size: 22px;
font-variant-numeric: tabular-nums;
min-width: 3.4ch;
letter-spacing: -0.02em;
}
.bar {
flex: 1;
height: 8px;
background: var(--card-sunk);
border: 1px solid var(--rule-soft);
border-radius: 999px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--indx), var(--gen2));
transition: width 0.25s ease;
}
.reset {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-3);
background: transparent;
border: 1px solid var(--rule);
border-radius: 2px;
padding: 6px 10px;
cursor: pointer;
}
.reset:hover:not(:disabled) {
color: var(--ink);
border-color: var(--ink-3);
}
.reset:disabled {
opacity: 0.4;
cursor: default;
}
.reset:focus-visible {
outline: 2px solid var(--ink-3);
outline-offset: 2px;
}
.progress-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11.5px;
letter-spacing: 0.03em;
color: var(--ink-3);
}
/* ---- steps ---- */
ol.steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 14px;
}
li.step {
background: var(--card);
border: 1px solid var(--rule-soft);
border-left: 5px solid var(--lane, var(--here));
border-radius: 3px;
box-shadow: var(--shadow);
display: grid;
grid-template-columns: auto 1fr;
gap: 0 18px;
padding: 18px 20px 18px 18px;
scroll-margin-top: 92px;
transition: opacity 0.15s ease;
}
li.step.indx {
--lane: var(--indx);
--lane-tint: var(--indx-tint);
}
li.step.gen2 {
--lane: var(--gen2);
--lane-tint: var(--gen2-tint);
}
li.step.here {
--lane: var(--here);
--lane-tint: var(--here-tint);
}
li.step.done {
opacity: 0.45;
}
li.step.current {
border-color: var(--lane);
box-shadow: var(--shadow), 0 0 0 1px var(--lane);
}
.tick {
grid-row: 1 / span 2;
width: 26px;
height: 26px;
margin-top: 2px;
border: 2px solid var(--rule);
border-radius: 3px;
background: var(--card-sunk);
cursor: pointer;
padding: 0;
display: grid;
place-items: center;
color: var(--lane);
font-family: "IBM Plex Mono", monospace;
font-size: 15px;
font-weight: 600;
}
.tick:hover {
border-color: var(--lane);
}
.tick:focus-visible {
outline: 2px solid var(--lane);
outline-offset: 2px;
}
.tick[aria-pressed="true"]::after {
content: "✓";
}
.head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 10px;
}
.num {
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--ink-3);
letter-spacing: 0.08em;
}
.lane-tag {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--lane);
background: var(--lane-tint);
padding: 3px 8px;
border-radius: 2px;
}
.step h2 {
font-family: "Archivo", ui-sans-serif, sans-serif;
font-weight: 600;
font-size: 19px;
line-height: 1.25;
margin: 0;
letter-spacing: -0.01em;
flex: 1 1 240px;
text-wrap: balance;
}
.here-marker {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--lane);
border: 1px solid currentColor;
border-radius: 999px;
padding: 2px 9px;
}
.body {
grid-column: 2;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.range {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--card-sunk);
border: 1px solid var(--rule-soft);
border-radius: 2px;
padding: 11px 13px;
}
.range > div {
display: flex;
gap: 10px;
align-items: baseline;
flex-wrap: wrap;
}
.range .lbl {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-3);
min-width: 52px;
}
.range .val {
font-weight: 500;
font-size: 15px;
flex: 1 1 200px;
}
.range a {
color: var(--lane);
text-decoration: none;
border-bottom: 1px solid currentColor;
}
.range a:hover {
background: var(--lane-tint);
}
.range a:focus-visible {
outline: 2px solid var(--lane);
outline-offset: 2px;
}
ul.acts {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
label.task {
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
padding: 3px 6px 3px 2px;
border-radius: 2px;
font-size: 15.5px;
color: var(--ink);
}
label.task:hover {
background: var(--card-sunk);
}
label.task input {
appearance: none;
flex: 0 0 auto;
width: 17px;
height: 17px;
margin: 4px 0 0;
border: 1.5px solid var(--rule);
border-radius: 2px;
background: var(--card-sunk);
cursor: pointer;
display: grid;
place-items: center;
}
label.task input:checked {
border-color: var(--lane);
background: var(--lane-tint);
}
label.task input:checked::after {
content: "✓";
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
font-weight: 600;
line-height: 1;
color: var(--lane);
}
label.task input:focus-visible {
outline: 2px solid var(--lane);
outline-offset: 2px;
}
label.task.done span {
color: var(--ink-3);
text-decoration: line-through;
text-decoration-color: var(--rule);
}
.note {
display: flex;
gap: 10px;
font-size: 14.5px;
color: var(--ink-2);
background: var(--warn-tint);
border-left: 3px solid var(--warn);
border-radius: 0 2px 2px 0;
padding: 10px 13px;
}
.note .sig {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--warn);
flex: 0 0 auto;
padding-top: 2px;
}
/* ---- tail ---- */
.tail {
display: flex;
flex-direction: column;
gap: 14px;
padding-top: 26px;
border-top: 2px solid var(--rule);
}
.tail h3 {
font-family: "Archivo", sans-serif;
font-weight: 600;
font-size: 15px;
margin: 0;
letter-spacing: 0.02em;
}
.tail p {
margin: 0;
color: var(--ink-2);
font-size: 15px;
max-width: 66ch;
}
.src {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14.5px;
}
.src a {
color: var(--ink);
text-decoration: none;
border-bottom: 1px solid var(--rule);
align-self: flex-start;
}
.src a:hover {
border-bottom-color: var(--ink);
}
.src a:focus-visible {
outline: 2px solid var(--ink-3);
outline-offset: 2px;
}
@media (max-width: 560px) {
li.step {
grid-template-columns: 1fr;
gap: 0;
}
.tick {
grid-row: auto;
margin-bottom: 10px;
}
.body {
grid-column: 1;
}
.progress-line {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
* {
transition: none !important;
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { host: "0.0.0.0", port: 5180 },
build: { outDir: "dist", emptyOutDir: true },
});