From dadc373c8ab93578d8f0f059b6a67fe4e55f0db9 Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:21:31 +0200 Subject: [PATCH 01/34] refactor: extract colorizeReport into shared report-colors module The /try/ page needs the report colorizer client-side; keep one standalone, dependency-free module the build and the browser both import. Co-Authored-By: Claude Fable 5 --- src/examples.mjs | 37 ++----------------------------------- src/landing.mjs | 3 ++- src/report-colors.mjs | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 src/report-colors.mjs diff --git a/src/examples.mjs b/src/examples.mjs index d6b1548..c1fdad1 100644 --- a/src/examples.mjs +++ b/src/examples.mjs @@ -1,39 +1,6 @@ // Curated interactive examples: real inputs and terminal output captured from -// actual `flashtrace` runs (v0.7.0). No in-browser execution - the CLI needs -// node:fs and git, so v1 ships pre-computed, trustworthy captures. -import { esc } from './layout.mjs'; - -// Re-create the CLI's coloring (src/report.mjs in the tool repo) as HTML -// spans on the captured plain-text output. -export function colorizeReport(text) { - let s = esc(text); - // status line: colored mark + bold item ID - s = s.replace(/^([✔✘~]) (\S+)/gm, (m, mark, id) => { - const cls = mark === '✔' ? 't-green' : mark === '✘' ? 't-red' : 't-yellow'; - return `${mark} ${id}`; - }); - s = s.replace(/✘ missing/g, '✘ missing'); - s = s.replace(/\((→ [^)]*)\)/g, '($1)'); - s = s.replace(/(?)✔/g, ''); - s = s.replace(/(?)✘/g, ''); - s = s.replace(/→/g, ''); - s = s.replace(/⚠/g, ''); - s = s.replace(/•/g, ''); - s = s.replace(/\[deep-covered\]/g, '[deep-covered]'); - s = s.replace(/\[shallow-covered\]/g, '[shallow-covered]'); - s = s.replace(/\[defective\]/g, '[defective]'); - s = s.replace(/"[^&]*"/g, (m) => `${m}`); - s = s.replace(/(^|\s)([\w./-]+\.(?:md|markdown|ts|js|mjs|cjs|tsx|py|rb|go|rs|java|cs|sql|lua|html|vue)(?::\d+)?)(?=\s|$)/gm, '$1$2'); - s = s.replace(/^( )(needs|covers|wanted by)( )/gm, '$1$2$3'); - s = s.replace(/^Summary$/m, 'Summary'); - s = s.replace(/^( items +\d+ )(\(.*\))$/m, '$1$2'); - s = s.replace(/^( ok +)(\d+)$/m, '$1$2'); - s = s.replace(/^( defective +)([1-9]\d*)$/m, '$1$2'); - s = s.replace(/^( )(of the ok items.*)$/m, '$1$2'); - s = s.replace(/^ok$/m, 'ok'); - s = s.replace(/^not ok$/m, 'not ok'); - return s; -} +// actual `flashtrace` runs (v0.7.0). The landing page ships these pre-computed +// captures; the /try/ page runs the same CLI live in the browser. export const heroTerminal = { command: 'npx flashtrace', diff --git a/src/landing.mjs b/src/landing.mjs index 358dc4d..e7ddf68 100644 --- a/src/landing.mjs +++ b/src/landing.mjs @@ -1,7 +1,8 @@ // The landing page: hero with a CSS-built IDE mock, how-it-works, features, // curated interactive examples, install snippets. All static HTML. -import { colorizeReport, examples, heroTerminal } from './examples.mjs'; +import { examples, heroTerminal } from './examples.mjs'; import { esc, EXT_ATTRS, GITHUB_URL, highlightTokens, pageShell } from './layout.mjs'; +import { colorizeReport } from './report-colors.mjs'; // --- hero IDE mock ----------------------------------------------------------- diff --git a/src/report-colors.mjs b/src/report-colors.mjs new file mode 100644 index 0000000..7bad88c --- /dev/null +++ b/src/report-colors.mjs @@ -0,0 +1,43 @@ +// Re-create the CLI's report coloring (src/report.mjs in the tool repo) as +// HTML spans over plain-text output. Shared between the build (landing page +// captures) and the browser (/try/ terminal), so it must stay dependency-free +// and standalone - `esc` lives here instead of importing layout.mjs, which +// would drag the whole page-shell module into the client bundle. + +export function esc(s) { + return String(s) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +export function colorizeReport(text) { + let s = esc(text); + // status line: colored mark + bold item ID + s = s.replace(/^([✔✘~]) (\S+)/gm, (m, mark, id) => { + const cls = mark === '✔' ? 't-green' : mark === '✘' ? 't-red' : 't-yellow'; + return `${mark} ${id}`; + }); + s = s.replace(/✘ missing/g, '✘ missing'); + s = s.replace(/\((→ [^)]*)\)/g, '($1)'); + s = s.replace(/(?)✔/g, ''); + s = s.replace(/(?)✘/g, ''); + s = s.replace(/→/g, ''); + s = s.replace(/⚠/g, ''); + s = s.replace(/•/g, ''); + s = s.replace(/\[deep-covered\]/g, '[deep-covered]'); + s = s.replace(/\[shallow-covered\]/g, '[shallow-covered]'); + s = s.replace(/\[defective\]/g, '[defective]'); + s = s.replace(/"[^&]*"/g, (m) => `${m}`); + s = s.replace(/(^|\s)([\w./-]+\.(?:md|markdown|ts|js|mjs|cjs|tsx|py|rb|go|rs|java|cs|sql|lua|html|vue)(?::\d+)?)(?=\s|$)/gm, '$1$2'); + s = s.replace(/^( )(needs|covers|wanted by)( )/gm, '$1$2$3'); + s = s.replace(/^Summary$/m, 'Summary'); + s = s.replace(/^( items +\d+ )(\(.*\))$/m, '$1$2'); + s = s.replace(/^( ok +)(\d+)$/m, '$1$2'); + s = s.replace(/^( defective +)([1-9]\d*)$/m, '$1$2'); + s = s.replace(/^( )(of the ok items.*)$/m, '$1$2'); + s = s.replace(/^ok$/m, 'ok'); + s = s.replace(/^not ok$/m, 'not ok'); + return s; +} From a5869f7ef15ab72ac0a6a123a4362fcdd14fb38d Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:21:51 +0200 Subject: [PATCH 02/34] feat: run the real flashtrace release build in the browser on /try/ - shim the five node builtins the bundle imports (in-memory fs over a seeded Map, posix path, throwing exit signal, url pathname mapping, unreachable child_process) and rewrite the specifiers at build time, asserting the builtin set so upstream drift fails the build loudly - module worker executes the bundle per run against the editor buffers and derives structured items/problems from the exported library surface - bare /try/ page: large editable IDE mock (spec + code textareas, live terminal with Run and Ctrl+Enter, 5s watchdog, exit code display) - "Try it" CTA button in the top bar, /try/ in the sitemap, .mjs MIME type for the dev server Verified in Node against the landing "uncovered defect" example: byte-identical report, exit code 1. Co-Authored-By: Claude Fable 5 --- build.mjs | 46 +++++++++ serve.mjs | 1 + src/layout.mjs | 1 + src/scripts/tutorial-worker.js | 144 +++++++++++++++++++++++++++ src/scripts/tutorial.js | 98 ++++++++++++++++++ src/styles/site.css | 100 +++++++++++++++++++ src/tutorial.mjs | 58 +++++++++++ src/tutorial/shims/child_process.mjs | 10 ++ src/tutorial/shims/fs.mjs | 47 +++++++++ src/tutorial/shims/path.mjs | 61 ++++++++++++ src/tutorial/shims/process.mjs | 32 ++++++ src/tutorial/shims/url.mjs | 13 +++ 12 files changed, 611 insertions(+) create mode 100644 src/scripts/tutorial-worker.js create mode 100644 src/scripts/tutorial.js create mode 100644 src/tutorial.mjs create mode 100644 src/tutorial/shims/child_process.mjs create mode 100644 src/tutorial/shims/fs.mjs create mode 100644 src/tutorial/shims/path.mjs create mode 100644 src/tutorial/shims/process.mjs create mode 100644 src/tutorial/shims/url.mjs diff --git a/build.mjs b/build.mjs index ed897f0..512fbb2 100644 --- a/build.mjs +++ b/build.mjs @@ -11,6 +11,7 @@ import { docShell, esc, EXT_ATTRS, GITHUB_URL, highlightTokens, SITE_URL } from import { renderLanding } from './src/landing.mjs'; import { renderImpressum } from './src/impressum.mjs'; import { renderLicense } from './src/license.mjs'; +import { renderTutorial } from './src/tutorial.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); @@ -174,6 +175,41 @@ function renderDoc(page) { }); } +// --- /try/ runner: the release bundle, node builtins rewritten to shims ----- + +// CI checks out the full tool repo, so dist/flashtrace.mjs sits next to the +// docs the build already consumes. Locally the clone provides it the same way. +const bundlePath = path.join(docsDir, '..', 'dist', 'flashtrace.mjs'); +if (!existsSync(bundlePath)) { + console.error( + `error: flashtrace bundle not found at ${bundlePath} - the /try/ page runs the release build in the browser and needs it. ` + + 'Make sure the tool repo checkout includes dist/.', + ); + process.exit(1); +} + +// The exact builtin set the shims in src/tutorial/shims/ cover. A release +// that imports anything else (or drops one) must fail the build here, never +// silently ship a broken /try/ page. +const SHIMMED_BUILTINS = ['child_process', 'fs', 'path', 'process', 'url']; + +function rewriteBundle(source) { + const found = new Set(); + const rewritten = source.replace(/from "node:([a-z_]+)"/g, (m, name) => { + found.add(name); + return `from "./shims/${name}.mjs"`; + }); + const actual = [...found].sort(); + if (actual.join(',') !== SHIMMED_BUILTINS.join(',')) { + console.error( + `error: flashtrace.mjs imports node builtins [${actual.join(', ')}] but the /try/ shims cover exactly [${SHIMMED_BUILTINS.join(', ')}].\n` + + 'Align src/tutorial/shims/ (and this assertion) with the release bundle.', + ); + process.exit(1); + } + return rewritten; +} + // --- emit -------------------------------------------------------------------- rmSync(dist, { recursive: true, force: true }); @@ -193,9 +229,19 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } +const tryDir = path.join(dist, 'try'); +mkdirSync(tryDir, { recursive: true }); +writeFileSync(path.join(tryDir, 'index.html'), renderTutorial({ version })); +writeFileSync(path.join(tryDir, 'flashtrace.mjs'), rewriteBundle(readFileSync(bundlePath, 'utf8'))); +cpSync(path.join(root, 'src', 'tutorial', 'shims'), path.join(tryDir, 'shims'), { recursive: true }); +cpSync(path.join(root, 'src', 'scripts', 'tutorial.js'), path.join(tryDir, 'tutorial.js')); +cpSync(path.join(root, 'src', 'scripts', 'tutorial-worker.js'), path.join(tryDir, 'tutorial-worker.js')); +cpSync(path.join(root, 'src', 'report-colors.mjs'), path.join(tryDir, 'report-colors.mjs')); + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), + '/try/', '/license/', '/impressum/', ]; diff --git a/serve.mjs b/serve.mjs index 55fccd7..b0ddb67 100644 --- a/serve.mjs +++ b/serve.mjs @@ -14,6 +14,7 @@ const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', diff --git a/src/layout.mjs b/src/layout.mjs index 72a790f..9b3a74a 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -59,6 +59,7 @@ function topBar({ version, active, withSidebar }) { ${link('/docs/', 'Docs', 'docs')}
+ Try it ${githubIcon} +
+
$ npx flashtrace - press Run to trace the two files
+ + + + +`; + + return pageShell({ + title: 'Try flashtrace - interactive tutorial', + description: + 'Run the real flashtrace release build in your browser: edit a Markdown spec and a code file, trace them live.', + path: '/try/', + version, + active: 'tutorial', + body, + bodyClass: 'page-tutorial', + }); +} diff --git a/src/tutorial/shims/child_process.mjs b/src/tutorial/shims/child_process.mjs new file mode 100644 index 0000000..bbf39ad --- /dev/null +++ b/src/tutorial/shims/child_process.mjs @@ -0,0 +1,10 @@ +// node:child_process replacement. Never reached in practice: the bundle only +// spawns git after findGit() finds a binary via existsSync probes, which the +// in-memory fs answers false. Throwing (instead of silently returning nothing) +// still lands in the CLI's try/catch around the git call, which falls back to +// the plain directory walk - and makes any future unexpected use visible. +export function execFileSync() { + throw new Error('child_process is not available in the browser sandbox'); +} + +export default { execFileSync }; diff --git a/src/tutorial/shims/fs.mjs b/src/tutorial/shims/fs.mjs new file mode 100644 index 0000000..49ddcf3 --- /dev/null +++ b/src/tutorial/shims/fs.mjs @@ -0,0 +1,47 @@ +// node:fs over an in-memory file map - the flashtrace bundle's specifiers are +// rewritten to this module at build time. The tutorial worker seeds +// globalThis.__ftVfs (Map) before importing the +// bundle. Only the surface the bundle actually touches is provided; the +// build asserts the set of node builtins so upstream drift fails loudly. +const vfs = () => globalThis.__ftVfs ?? new Map(); +const norm = (p) => (String(p) === '/' ? '/' : String(p).replace(/\/+$/, '')); +const isDir = (p) => p === '/' || [...vfs().keys()].some((f) => f.startsWith(norm(p) + '/')); + +export const realpathSync = norm; + +// findGit() probes fixed absolute git paths through this - answering false +// sends the CLI down its documented no-git directory walk. +export const existsSync = (p) => vfs().has(norm(p)) || isDir(p); + +export function readFileSync(p) { + if (!vfs().has(norm(p))) throw Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' }); + return vfs().get(norm(p)); +} + +export const promises = { + async readFile(p) { + return readFileSync(p); + }, + async readdir(p, opts) { + const prefix = norm(p) === '/' ? '/' : norm(p) + '/'; + const names = new Map(); + for (const f of vfs().keys()) { + if (!f.startsWith(prefix)) continue; + const rest = f.slice(prefix.length); + names.set(rest.split('/')[0], !rest.includes('/')); + } + const entries = [...names].map(([name, file]) => ({ + name, + isFile: () => file, + isDirectory: () => !file, + })); + return opts?.withFileTypes ? entries : entries.map((e) => e.name); + }, + async stat(p) { + if (vfs().has(norm(p))) return { isFile: () => true, isDirectory: () => false }; + if (isDir(p)) return { isFile: () => false, isDirectory: () => true }; + throw Object.assign(new Error(`ENOENT: ${p}`), { code: 'ENOENT' }); + }, +}; + +export default { realpathSync, existsSync, readFileSync, promises }; diff --git a/src/tutorial/shims/path.mjs b/src/tutorial/shims/path.mjs new file mode 100644 index 0000000..d9b924e --- /dev/null +++ b/src/tutorial/shims/path.mjs @@ -0,0 +1,61 @@ +// Minimal posix-only node:path replacement - exactly the functions the +// flashtrace bundle calls (join, resolve, extname, relative) plus dirname and +// sep for safety. Relative inputs resolve against the vfs project root. +export const sep = '/'; + +const PROJECT_ROOT = '/project'; + +function normalizeSegments(parts) { + const out = []; + for (const part of parts) { + if (part === '' || part === '.') continue; + if (part === '..') { + if (out.length > 0 && out[out.length - 1] !== '..') out.pop(); + else out.push('..'); + } else { + out.push(part); + } + } + return out; +} + +export function join(...parts) { + const joined = parts.filter(Boolean).join('/'); + const abs = joined.startsWith('/'); + const segs = normalizeSegments(joined.split('/')).join('/'); + if (abs) return '/' + segs; + return segs === '' ? '.' : segs; +} + +export function resolve(...parts) { + let joined = ''; + for (const part of parts) { + joined = String(part).startsWith('/') ? String(part) : joined + '/' + String(part); + } + if (!joined.startsWith('/')) joined = PROJECT_ROOT + '/' + joined; + return '/' + normalizeSegments(joined.split('/')).join('/'); +} + +export function extname(p) { + const base = String(p).slice(String(p).lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + return dot > 0 ? base.slice(dot) : ''; +} + +export function relative(from, to) { + const f = normalizeSegments(resolve(from).split('/')); + const t = normalizeSegments(resolve(to).split('/')); + let common = 0; + while (common < f.length && common < t.length && f[common] === t[common]) common++; + return [...Array(f.length - common).fill('..'), ...t.slice(common)].join('/'); +} + +export function dirname(p) { + const s = String(p).replace(/\/+$/, ''); + const idx = s.lastIndexOf('/'); + if (idx < 0) return '.'; + if (idx === 0) return '/'; + return s.slice(0, idx); +} + +export default { sep, join, resolve, extname, relative, dirname }; diff --git a/src/tutorial/shims/process.mjs b/src/tutorial/shims/process.mjs new file mode 100644 index 0000000..a2466cf --- /dev/null +++ b/src/tutorial/shims/process.mjs @@ -0,0 +1,32 @@ +// node:process replacement. argv is seeded by the tutorial worker via +// globalThis.__ftArgv; exit() throws an ExitSignal - the bundle's CLI path +// always terminates through process.exit(), so the worker treats the first +// signal it observes as the run's exit code. +export class ExitSignal extends Error { + constructor(code) { + super(`flashtrace exited with code ${code}`); + this.name = 'ExitSignal'; + this.code = code; + } +} + +// The worker identifies signals through this global rather than importing the +// (build-time copied) shim module a second time. +globalThis.__FtExitSignal = ExitSignal; + +const processShim = { + get argv() { + return globalThis.__ftArgv ?? ['node', '/flashtrace.mjs', '/project']; + }, + cwd: () => '/project', + env: {}, + // 'linux' keeps findGit() probing /usr/bin/git & /bin/git, which the vfs + // answers false - the CLI then takes its documented no-git directory walk. + platform: 'linux', + stdout: { isTTY: false }, // disables ANSI coloring; the page colorizes as HTML + exit(code = 0) { + throw new ExitSignal(code); + }, +}; + +export default processShim; diff --git a/src/tutorial/shims/url.mjs b/src/tutorial/shims/url.mjs new file mode 100644 index 0000000..8163f6e --- /dev/null +++ b/src/tutorial/shims/url.mjs @@ -0,0 +1,13 @@ +// node:url replacement. The bundle only uses these two in runAsCli(), where +// argv[1] (seeded to the served bundle's pathname) is compared against +// import.meta.url (an https: URL in the browser) - mapping any URL to its +// decoded pathname makes that comparison come out true, so runCli() starts. +export function fileURLToPath(url) { + return decodeURIComponent(new URL(url).pathname); +} + +export function pathToFileURL(path) { + return new URL(String(path), 'file:///'); +} + +export default { fileURLToPath, pathToFileURL }; From 3a9dc571067a1f054c09794e1a131d1f49600338 Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:26:41 +0200 Subject: [PATCH 03/34] fix: seed the process global before the worker imports the bundle esbuild keeps bare `process` references (files.mjs GIT_LOCATIONS) pointing at the global, which Node provides but a module worker does not. Assign the node:process shim to globalThis.process so both reference styles resolve to the same object. Found by the headless browser check; the Node-based PoC masked it because Node has the global. Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial-worker.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/scripts/tutorial-worker.js b/src/scripts/tutorial-worker.js index b76bf8c..a359405 100644 --- a/src/scripts/tutorial-worker.js +++ b/src/scripts/tutorial-worker.js @@ -7,6 +7,13 @@ // result message out. The page spawns a fresh worker per run (fresh module // graph, no state leaks) and terminates it afterwards. +import processShim from './shims/process.mjs'; + +// The bundle references the `process` global directly in one place (esbuild +// keeps globals as-is); Node provides it, a worker does not - seed it with +// the same shim the rewritten `node:process` imports resolve to. +globalThis.process = processShim; + const BUNDLE_URL = new URL('./flashtrace.mjs', import.meta.url); const output = []; From 56f75a133ae3ed842aa0ff7b1859c5a21af88fcf Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:44:04 +0200 Subject: [PATCH 04/34] feat: add the 11 tutorial chapters with build-time verification - chapters.mjs: full v1 curriculum continuing the login/auth story (foundations 1-4, features 5-8, extras 9-11); language-specifics live in per-chapter variants (js in v1), checks/done are serializable pure functions over the structured run result - chapter-utils.mjs: shared pure helpers (patch application, tags parsing, structured analysis via the bundle's exports) used by the browser worker and the build; the worker now imports them instead of inlining - verify.mjs: every chapter's start state and each step's cumulative patched state runs through the rewritten release bundle at build time; asserts start clean/defective expectations, monotonic step checks, the done condition and exit 0 on the solved state - a behavioral change in a flashtrace release fails the deploy instead of shipping a broken lesson. Also computes the sha256-12 content-hash chapter identity (rev) and the client payload. Co-Authored-By: Claude Fable 5 --- build.mjs | 16 +- src/scripts/tutorial-worker.js | 71 +-- src/tutorial/chapter-utils.mjs | 133 ++++++ src/tutorial/chapters.mjs | 833 +++++++++++++++++++++++++++++++++ src/tutorial/verify.mjs | 227 +++++++++ 5 files changed, 1209 insertions(+), 71 deletions(-) create mode 100644 src/tutorial/chapter-utils.mjs create mode 100644 src/tutorial/chapters.mjs create mode 100644 src/tutorial/verify.mjs diff --git a/build.mjs b/build.mjs index 512fbb2..ae633ea 100644 --- a/build.mjs +++ b/build.mjs @@ -12,6 +12,8 @@ import { renderLanding } from './src/landing.mjs'; import { renderImpressum } from './src/impressum.mjs'; import { renderLicense } from './src/license.mjs'; import { renderTutorial } from './src/tutorial.mjs'; +import { chapters } from './src/tutorial/chapters.mjs'; +import { verifyChapters } from './src/tutorial/verify.mjs'; const root = path.dirname(fileURLToPath(import.meta.url)); const dist = path.join(root, 'dist'); @@ -231,13 +233,25 @@ for (const page of pages) { const tryDir = path.join(dist, 'try'); mkdirSync(tryDir, { recursive: true }); -writeFileSync(path.join(tryDir, 'index.html'), renderTutorial({ version })); writeFileSync(path.join(tryDir, 'flashtrace.mjs'), rewriteBundle(readFileSync(bundlePath, 'utf8'))); cpSync(path.join(root, 'src', 'tutorial', 'shims'), path.join(tryDir, 'shims'), { recursive: true }); +cpSync(path.join(root, 'src', 'tutorial', 'chapter-utils.mjs'), path.join(tryDir, 'chapter-utils.mjs')); cpSync(path.join(root, 'src', 'scripts', 'tutorial.js'), path.join(tryDir, 'tutorial.js')); cpSync(path.join(root, 'src', 'scripts', 'tutorial-worker.js'), path.join(tryDir, 'tutorial-worker.js')); cpSync(path.join(root, 'src', 'report-colors.mjs'), path.join(tryDir, 'report-colors.mjs')); +// Every chapter's start state and each step's cumulative patched state runs +// through the exact bundle the browser executes; behavioral drift in a +// flashtrace release fails the deploy here instead of shipping a broken lesson. +let verified; +try { + verified = await verifyChapters(chapters, path.join(tryDir, 'flashtrace.mjs'), 'js'); +} catch (err) { + console.error(`error: tutorial chapter verification failed.\n${err.message}`); + process.exit(1); +} +writeFileSync(path.join(tryDir, 'index.html'), renderTutorial({ version, verified })); + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), diff --git a/src/scripts/tutorial-worker.js b/src/scripts/tutorial-worker.js index a359405..cf321f0 100644 --- a/src/scripts/tutorial-worker.js +++ b/src/scripts/tutorial-worker.js @@ -7,6 +7,7 @@ // result message out. The page spawns a fresh worker per run (fresh module // graph, no state leaks) and terminates it afterwards. +import { analyzeBuffers } from './chapter-utils.mjs'; import processShim from './shims/process.mjs'; // The bundle references the `process` global directly in one place (esbuild @@ -54,76 +55,6 @@ self.addEventListener('unhandledrejection', (event) => { } }); -// Mirrors the CLI's -t/--tags parsing closely enough for the structured -// analysis to match what the run just reported. -function parseTags(argv) { - let tags = null; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - let value = null; - if (arg === '-t' || arg === '--tags') value = argv[i + 1]; - else if (arg.startsWith('--tags=')) value = arg.slice('--tags='.length); - if (value != null) { - tags = value - .split(',') - .map((s) => s.trim()) - .filter(Boolean); - } - } - return tags; -} - -// Re-run the parse/analyze pipeline through the bundle's public exports to get -// items and problems as data. Chapter checks assert on these instead of -// regex-matching terminal text. -function analyzeBuffers(mod, files, argv) { - const problems = []; - const forwards = []; - let items = []; - for (const name of Object.keys(files).sort()) { - const file = '/project/' + name; - const isMarkdown = /\.(md|markdown)$/i.test(name); - const parse = isMarkdown ? mod.parseMarkdown : mod.parseCode; - items.push(...parse(file, files[name], problems, forwards)); - } - const tags = parseTags(argv); - if (tags) { - const wantUntagged = tags.includes('_'); - items = items.filter( - (item) => - item.origin === 'code' || - item.tags.some((tag) => tags.includes(tag)) || - (wantUntagged && item.tags.length === 0), - ); - } - mod.analyze(items, forwards, problems); - const errorCount = problems.filter((p) => p.severity === 'error').length; - const clean = errorCount === 0 && items.every((item) => item.defects.length === 0); - const stripRoot = (file) => String(file).replace('/project/', ''); - return { - clean, - items: items.map((item) => ({ - id: item.id, - origin: item.origin, - file: stripRoot(item.file), - line: item.line, - title: item.title ?? null, - tags: item.tags, - needs: item.needs, - covers: item.covers, - defects: item.defects, - deepCovered: Boolean(item.deepCovered), - forwardsTo: item.forwardsTo ?? null, - })), - problems: problems.map((p) => ({ - severity: p.severity, - file: stripRoot(p.file), - line: p.line, - message: p.message, - })), - }; -} - self.onmessage = async (event) => { const { files, argv = [] } = event.data; diff --git a/src/tutorial/chapter-utils.mjs b/src/tutorial/chapter-utils.mjs new file mode 100644 index 0000000..4730e53 --- /dev/null +++ b/src/tutorial/chapter-utils.mjs @@ -0,0 +1,133 @@ +// Pure helpers shared by the tutorial worker (browser), the /try/ page script +// (browser) and the build-time chapter verification (Node). No imports, no +// side effects - this module is copied verbatim into dist/try/. + +// Mirrors the CLI's -t/--tags parsing closely enough for the structured +// analysis to match what a run with the same argv reports. +export function parseTags(argv) { + let tags = null; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + let value = null; + if (arg === '-t' || arg === '--tags') value = argv[i + 1]; + else if (arg.startsWith('--tags=')) value = arg.slice('--tags='.length); + if (value != null) { + tags = value + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + } + return tags; +} + +// Re-run the parse/analyze pipeline through the bundle's public exports to get +// items and problems as plain data. Chapter checks assert on these instead of +// regex-matching terminal text. +export function analyzeBuffers(mod, files, argv) { + const problems = []; + const forwards = []; + let items = []; + for (const name of Object.keys(files).sort()) { + const file = '/project/' + name; + const isMarkdown = /\.(md|markdown)$/i.test(name); + const parse = isMarkdown ? mod.parseMarkdown : mod.parseCode; + items.push(...parse(file, files[name], problems, forwards)); + } + const tags = parseTags(argv); + if (tags) { + const wantUntagged = tags.includes('_'); + items = items.filter( + (item) => + item.origin === 'code' || + item.tags.some((tag) => tags.includes(tag)) || + (wantUntagged && item.tags.length === 0), + ); + } + mod.analyze(items, forwards, problems); + const errorCount = problems.filter((p) => p.severity === 'error').length; + const clean = errorCount === 0 && items.every((item) => item.defects.length === 0); + const stripRoot = (file) => String(file).replace('/project/', ''); + return { + clean, + items: items.map((item) => ({ + id: item.id, + origin: item.origin, + file: stripRoot(item.file), + line: item.line, + title: item.title ?? null, + description: item.description ?? [], + tags: item.tags, + needs: item.needs, + covers: item.covers, + defects: item.defects, + deepCovered: Boolean(item.deepCovered), + forwardsTo: item.forwardsTo ?? null, + })), + problems: problems.map((p) => ({ + severity: p.severity, + file: stripRoot(p.file), + line: p.line, + message: p.message, + })), + }; +} + +// Apply one step's patch to the buffer state { spec, code, argv }. +// Returns the new state plus `changed` ({ pane, start, end } of the edited +// region, or { pane: 'argv' }); `failed: true` means the anchor did not match +// (the buffers were edited away from what the step expects). +// +// Conventions (see chapters.mjs): anchors are regex sources compiled with 'm'; +// insertAfter/append prefix the snippet with '\n', insertBefore suffixes it, +// so multi-line snippets and blank separator lines are encoded in the snippet. +export function applyStep(chapter, lang, step, state) { + const patch = step.patch; + if (!patch) return { ...state, changed: null }; + if (patch.op === 'setArgv') { + return { ...state, argv: [...patch.argv], changed: { pane: 'argv' } }; + } + const pane = step.pane; + const pack = pane === 'spec' ? chapter.spec : chapter.variants[lang]; + const text = state[pane]; + const snippet = patch.snippet === undefined ? '' : pack.snippets[patch.snippet]; + let start; + let end; + let insert; + if (patch.op === 'append') { + start = text.length; + end = text.length; + insert = '\n' + snippet; + } else { + const anchorKey = patch.anchor ?? step.anchor; + const anchorSource = pack.anchors[anchorKey]; + if (anchorSource === undefined) return { ...state, changed: null, failed: true }; + const match = new RegExp(anchorSource, 'm').exec(text); + if (!match) return { ...state, changed: null, failed: true }; + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + if (patch.op === 'insertBefore') { + start = text.lastIndexOf('\n', matchStart - 1) + 1; + end = start; + insert = snippet + '\n'; + } else if (patch.op === 'insertAfter') { + const lineEnd = text.indexOf('\n', matchEnd); + start = lineEnd === -1 ? text.length : lineEnd; + end = start; + insert = '\n' + snippet; + } else if (patch.op === 'replaceLine') { + start = text.lastIndexOf('\n', matchStart - 1) + 1; + const lineEnd = text.indexOf('\n', matchEnd); + end = lineEnd === -1 ? text.length : lineEnd; + insert = snippet; + } else if (patch.op === 'replaceMatch') { + start = matchStart; + end = matchEnd; + insert = snippet; + } else { + return { ...state, changed: null, failed: true }; + } + } + const next = text.slice(0, start) + insert + text.slice(end); + return { ...state, [pane]: next, changed: { pane, start, end: start + insert.length } }; +} diff --git a/src/tutorial/chapters.mjs b/src/tutorial/chapters.mjs new file mode 100644 index 0000000..8ac7dd4 --- /dev/null +++ b/src/tutorial/chapters.mjs @@ -0,0 +1,833 @@ +// The tutorial curriculum: 11 chapters continuing the login/auth story of the +// landing page. Ids are stable forever (they key the progress map); order is +// the curriculum order. Content semantics must match the tool repo's docs - +// the build runs every chapter state through the real CLI and fails on drift. +// +// Serialization rules (the definitions travel to the browser as JSON and feed +// the build-time identity hash): +// - `anchors` hold regex *sources* (compiled with the 'm' flag at use time) +// - `check`/`done` are pure self-contained arrows over the run result +// `r = { items, problems, clean, exitCode, argv, spec, code }`; they are +// shipped as their source text, so they must not close over anything +// - `patch` ops: insertBefore | insertAfter | replaceLine | replaceMatch +// (anchor + snippet key), append (snippet key), setArgv (argv array); +// insertAfter/append prefix the snippet with '\n', so a snippet with its +// own leading '\n' produces a blank separator line +// - language-specifics live in `variants` (v1: js); `spec` is neutral + +export const chapters = [ + // ------------------------------------------------------------ foundations + { + id: 'first-item', + title: 'Your first item', + group: 'Foundations', + intro: + 'flashtrace reads ordinary Markdown. An item is born on a line holding nothing but its ID in backticks - the heading above becomes its title, the paragraph below its description. Build one from scratch and watch the tracer pick it up.', + goal: 'Turn the prose into a traceable item: heading, ID line, description.', + docs: [ + { label: 'Markdown items', href: '/docs/markdown-items/' }, + { label: 'Item IDs', href: '/docs/item-ids/' }, + ], + argv: [], + startsClean: true, + spec: { + file: 'spec.md', + body: `# Auth spec + +Nothing in this file is an item yet - flashtrace only picks up a line +holding nothing but an ID in backticks.`, + anchors: {}, + snippets: { + heading: '\n## Login requirement', + idLine: '\n\`req:auth/login#1\`', + description: '\nUsers must be able to log in with email and password.', + }, + }, + variants: { + js: { + file: 'login.js', + body: `export function login(email, password) { + return session.open(email, password); +}`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + explain: + 'Start with a heading at the end of the spec: "## Login requirement". It is not an item yet - but the heading directly above an ID line becomes that item\'s title.', + pane: 'spec', + anchor: null, + patch: { op: 'append', snippet: 'heading' }, + check: (r) => /^## Login requirement$/m.test(r.spec), + }, + { + explain: + 'Now the item itself: a line containing nothing but `req:auth/login#1` in backticks. Run it - the summary jumps from 0 items to 1, titled by your heading.', + pane: 'spec', + anchor: null, + patch: { op: 'append', snippet: 'idLine' }, + check: (r) => r.items.some((i) => i.id === 'req:auth/login#1' && i.title === 'Login requirement'), + }, + { + explain: + 'Give the item a description: the paragraph right below the ID line, up to the next blank line. Everything after that stays informative prose.', + pane: 'spec', + anchor: null, + patch: { op: 'append', snippet: 'description' }, + check: (r) => r.items.some((i) => i.id === 'req:auth/login#1' && i.description.length > 0), + }, + ], + done: (r) => + r.clean && + r.items.some( + (i) => i.id === 'req:auth/login#1' && i.title === 'Login requirement' && i.description.length > 0, + ), + }, + + { + id: 'cover-a-need', + title: 'Cover a requirement', + group: 'Foundations', + intro: + 'An item without Needs asks for nothing - the trace stays green but toothless. Demand an implementation with a Needs: list, watch the run go red, then satisfy it with a tag in an ordinary code comment.', + goal: 'Make req:auth/login#1 demand an implementation, then provide it.', + docs: [ + { label: 'Markdown items', href: '/docs/markdown-items/' }, + { label: 'Code tags', href: '/docs/code-tags/' }, + ], + argv: [], + startsClean: true, + spec: { + file: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password.`, + anchors: { + description: '^Users must be able to log in with email and password\\.$', + }, + snippets: { + needsLine: '\nNeeds: impl:auth/login#1', + }, + }, + variants: { + js: { + file: 'login.js', + body: `export function login(email, password) { + return session.open(email, password); +}`, + anchors: { + loginFn: '^export function login', + }, + snippets: { + implTag: '// [impl:auth/login#1]', + }, + }, + }, + steps: [ + { + explain: + 'Add "Needs: impl:auth/login#1" below the description. Run it: the requirement turns defective - uncovered - because nothing defines that ID yet. That red is the whole point of tracing.', + pane: 'spec', + anchor: 'description', + patch: { op: 'insertAfter', snippet: 'needsLine' }, + check: (r) => r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.includes('impl:auth/login#1')), + }, + { + explain: + 'Cover it from the code: put the comment "// [impl:auth/login#1]" directly above the login function. A tag in a comment defines the item that satisfies the need.', + pane: 'code', + anchor: 'loginFn', + patch: { op: 'insertBefore', snippet: 'implTag' }, + check: (r) => r.items.some((i) => i.id === 'impl:auth/login#1' && i.origin === 'code'), + }, + ], + done: (r) => + r.clean && + r.items.some((i) => i.id === 'impl:auth/login#1') && + r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.includes('impl:auth/login#1')), + }, + + { + id: 'read-the-report', + title: 'Read the report', + group: 'Foundations', + intro: + 'This trace is broken on purpose. Each defective item gets one ✘ block: its ID, location and defect bullets. Two of the three blocks here share a single root cause - a typo - and the run exits 1 until everything is fixed. Read first, then fix.', + goal: 'Clear all three defect blocks and bring the exit code to 0.', + docs: [{ label: 'Command line', href: '/docs/command-line/' }], + argv: [], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Logout requirement + +\`req:auth/logout#1\` + +A signed-in user can end their session on every device. + +Needs: impl:auth/logout#1`, + anchors: { + needsLine: '^Needs: impl:auth/logout#1$', + }, + snippets: { + needsBoth: 'Needs: impl:auth/logout#1, impl:auth/session#1', + }, + }, + variants: { + js: { + file: 'logout.js', + body: `// [impl:auth/logut#1] +export function logout(session) { + return session.close(); +} + +// [impl:auth/session#1] +export function endAllSessions(user) { + return sessions.of(user).closeAll(); +}`, + anchors: { + typoTag: 'impl:auth/logut#1', + }, + snippets: { + fixedId: 'impl:auth/logout#1', + }, + }, + }, + steps: [ + { + explain: + 'The report pairs an "uncovered: needs impl:auth/logout#1" with an "unwanted: no item needs impl:auth/logut#1" - spot the missing letter. Fix the typo in the code tag and both blocks disappear together.', + pane: 'code', + anchor: 'typoTag', + patch: { op: 'replaceMatch', anchor: 'typoTag', snippet: 'fixedId' }, + check: (r) => + r.items.some((i) => i.id === 'impl:auth/logout#1') && !r.items.some((i) => i.id === 'impl:auth/logut#1'), + }, + { + explain: + 'impl:auth/session#1 is still unwanted: it exists, but no item needs it. Coverage nobody asked for is a defect too. Extend the Needs list so the requirement demands it.', + pane: 'spec', + anchor: 'needsLine', + patch: { op: 'replaceLine', anchor: 'needsLine', snippet: 'needsBoth' }, + check: (r) => { + const s = r.items.find((i) => i.id === 'impl:auth/session#1'); + return s !== undefined && s.defects.length === 0; + }, + }, + ], + done: (r) => r.clean && r.exitCode === 0, + }, + + { + id: 'covers-and-orphans', + title: 'Covers: & orphans', + group: 'Foundations', + intro: + 'Coverage can also be declared from the covering side: a Covers: entry names the item this one covers. It is only valid when the target exists and needs the coverer back - otherwise you get an orphaned or unwanted defect. Repair a broken Covers chain.', + goal: 'Make req:auth/login#1 validly cover the auth feature.', + docs: [ + { label: 'Markdown items', href: '/docs/markdown-items/' }, + { label: 'Coverage rules', href: '/docs/coverage-rules/' }, + ], + argv: [], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Auth feature + +\`feat:auth#1\` + +Everything a user needs to prove who they are. + +## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Covers: feat:auth#2`, + anchors: { + coversLine: '^Covers: feat:auth#2$', + featDescription: '^Everything a user needs to prove who they are\\.$', + }, + snippets: { + coversFixed: 'Covers: feat:auth#1', + featNeeds: '\nNeeds: req:auth/login#1', + }, + }, + variants: { + js: { + file: 'login.js', + body: `export function login(email, password) { + return session.open(email, password); +}`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + explain: + 'The requirement covers feat:auth#2 - which does not exist. The report even hints that feat:auth exists at revision 1. An orphaned Covers points into the void; fix the revision.', + pane: 'spec', + anchor: 'coversLine', + patch: { op: 'replaceLine', anchor: 'coversLine', snippet: 'coversFixed' }, + check: (r) => { + const q = r.items.find((i) => i.id === 'req:auth/login#1'); + return q !== undefined && q.covers.includes('feat:auth#1'); + }, + }, + { + explain: + 'Still not ok: now the Covers is unwanted, because feat:auth#1 never asked for it. A Covers entry is only valid when the target lists the coverer in its own Needs. Close the loop on the feature item.', + pane: 'spec', + anchor: 'featDescription', + patch: { op: 'insertAfter', snippet: 'featNeeds' }, + check: (r) => { + const f = r.items.find((i) => i.id === 'feat:auth#1'); + return f !== undefined && f.needs.includes('req:auth/login#1'); + }, + }, + ], + done: (r) => r.clean && r.items.some((i) => i.id === 'feat:auth#1' && i.needs.includes('req:auth/login#1')), + }, + + // --------------------------------------------------------------- features + { + id: 'revisions', + title: 'Revisions & wildcards', + group: 'Features', + intro: + 'The #revision is part of an item\'s identity, and matching is exact - 2 never resolves to 2.1. The login implementation was reworked for one-time codes and bumped to #2.1, so the spec\'s reference went stale. Chase it, then learn when to stop chasing.', + goal: 'Re-cover the requirement against the reworked implementation.', + docs: [{ label: 'Revisions', href: '/docs/revisions/' }], + argv: [], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password, +now with an optional one-time code. + +Needs: impl:auth/login#1`, + anchors: { + needRef: 'impl:auth/login#1', + needRefRev2: 'impl:auth/login#2$', + }, + snippets: { + needRev2: 'impl:auth/login#2', + needWildcard: 'impl:auth/login#2.x', + }, + }, + variants: { + js: { + file: 'login.js', + body: `// [impl:auth/login#2.1] +export function login(email, password, oneTimeCode) { + return session.open(email, password, { oneTimeCode }); +}`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + explain: + 'The report hints: "revision mismatch: existing revision(s) of impl:auth/login: 2.1". Bump the need to #2 and run again - still uncovered! 2 and 2.1 are different identities; matching never loosens by itself.', + pane: 'spec', + anchor: 'needRef', + patch: { op: 'replaceMatch', anchor: 'needRef', snippet: 'needRev2' }, + check: (r) => + r.items.some( + (i) => + i.id === 'req:auth/login#1' && + !i.needs.includes('impl:auth/login#1') && + i.needs.some((n) => n.startsWith('impl:auth/login#2')), + ), + }, + { + explain: + 'When you genuinely mean "any 2.x rework", opt in explicitly with a wildcard: impl:auth/login#2.x accepts 2.0, 2.1, 2.99 - but never 2 or 2.1.3, since a wildcard keeps the layer count.', + pane: 'spec', + anchor: 'needRefRev2', + patch: { op: 'replaceMatch', anchor: 'needRefRev2', snippet: 'needWildcard' }, + check: (r) => + r.clean && r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.includes('impl:auth/login#2.x')), + }, + ], + done: (r) => + r.clean && + r.exitCode === 0 && + r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.includes('impl:auth/login#2.x')), + }, + + { + id: 'deep-coverage', + title: 'Demands & deep coverage', + group: 'Features', + intro: + 'Code can demand coverage too: a [>>utest:...] tag attaches a need to the nearest preceding item tag. The chain req → impl → test is traced transitively - this run uses -v, so every item shows its status: ✔ deep-covered, ~ shallow (a dependency further down is broken), ✘ defective.', + goal: 'Complete the chain so every item shows ✔ deep-covered.', + docs: [{ label: 'Coverage rules', href: '/docs/coverage-rules/' }], + argv: ['-v'], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Needs: impl:auth/login#1`, + anchors: {}, + snippets: {}, + }, + variants: { + js: { + file: 'login.js', + body: `// [impl:auth/login#1] +// [>>utest:auth/login#1] +export function login(email, password) { + return session.open(email, password); +}`, + anchors: { + testFn: '^test\\(', + }, + snippets: { + testCode: `\ntest('login opens a session', () => { + expect(login('ada@example.com', 'pw')).toBeTruthy(); +});`, + utestTag: '// [utest:auth/login#1]', + }, + }, + }, + steps: [ + { + explain: + 'The demand tag makes the implementation uncovered, which leaves the requirement at ~ shallow-covered: its own need is met, but the chain below is broken. Write the test - then run it. Still not ok: code alone means nothing to the tracer.', + pane: 'code', + anchor: null, + patch: { op: 'append', snippet: 'testCode' }, + check: (r) => /test\(/.test(r.code), + }, + { + explain: + 'Only the tag counts: put "// [utest:auth/login#1]" above the test. It defines the demanded item, the chain closes, and every mark flips to ✔ deep-covered.', + pane: 'code', + anchor: 'testFn', + patch: { op: 'insertBefore', snippet: 'utestTag' }, + check: (r) => r.items.some((i) => i.id === 'utest:auth/login#1'), + }, + ], + done: (r) => + r.clean && r.items.some((i) => i.id === 'utest:auth/login#1') && r.items.every((i) => i.deepCovered), + }, + + { + id: 'forwarding', + title: 'Forwarding', + group: 'Features', + intro: + 'Sometimes a requirement should not name an implementation itself - the details belong to a design. A forwarding tag [source --> target] delegates the coverage obligation: the source is covered exactly as deeply as its target. The -v report draws the delegation as a → edge.', + goal: 'Delegate req:login#1 to the auth design instead of a dead-end need.', + docs: [{ label: 'Forwarding', href: '/docs/forwarding/' }], + argv: ['-v'], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Login + +\`req:login#1\` + +Login is specified in detail by the auth design. + +Needs: impl:login#1 + +## Auth design + +\`dsn:auth#2\` + +Sessions are opened through the central auth service. + +Needs: impl:auth#1`, + anchors: { + deadEndNeeds: '^Needs: impl:login#1$', + }, + snippets: { + forwardTag: '\`[req:login#1 --> dsn:auth#2]\`', + }, + }, + variants: { + js: { + file: 'auth.js', + body: `// [impl:auth#1] +export function openSession(token) { + return sessions.issue(token); +}`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + explain: + 'There is no impl:login#1 and there never will be - the auth design owns the details. Replace the Needs line with the forwarding tag [req:login#1 --> dsn:auth#2]: the requirement now follows the design\'s coverage, shown as a → edge in the report.', + pane: 'spec', + anchor: 'deadEndNeeds', + patch: { op: 'replaceLine', anchor: 'deadEndNeeds', snippet: 'forwardTag' }, + check: (r) => { + const q = r.items.find((i) => i.id === 'req:login#1'); + return q !== undefined && Boolean(q.forwardsTo); + }, + }, + ], + done: (r) => r.clean && r.items.some((i) => i.id === 'req:login#1' && Boolean(i.forwardsTo) && i.deepCovered), + }, + + { + id: 'tags-and-filtering', + title: 'Tags & scoped runs', + group: 'Features', + intro: + 'Tags: lines group items, and -t scopes a run to matching Markdown items - code items always come along. The reporting requirement fails here only because its code lives in another repository slice. This time you fix the command, not the files.', + goal: 'Scope the run to the auth work so the trace of this slice is clean.', + docs: [{ label: 'Command line', href: '/docs/command-line/' }], + argv: [], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Needs: impl:auth/login#1 + +Tags: auth + +## Weekly report + +\`req:report/weekly#1\` + +Ops can inspect weekly usage numbers. + +Needs: impl:report/weekly#1 + +Tags: reporting`, + anchors: {}, + snippets: {}, + }, + variants: { + js: { + file: 'login.js', + body: `// [impl:auth/login#1] +export function login(email, password) { + return session.open(email, password); +}`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + explain: + 'Add "-t auth" to the command line in the terminal bar: only Markdown items tagged auth are imported, the reporting item stays out, and this slice traces clean. (Add "_" to a -t list to also include untagged items.)', + pane: 'argv', + anchor: null, + patch: { op: 'setArgv', argv: ['-t', 'auth'] }, + check: (r) => + r.items.some((i) => i.id === 'req:auth/login#1') && !r.items.some((i) => i.id === 'req:report/weekly#1'), + }, + ], + done: (r) => + r.clean && + r.items.some((i) => i.id === 'req:auth/login#1') && + !r.items.some((i) => i.id === 'req:report/weekly#1'), + }, + + // ----------------------------------------------------------------- extras + { + id: 'capstone', + title: 'Fix a messy trace', + group: 'Extras', + intro: + 'No new syntax - one messy trace. This project shows every defect class at once: a parse-level problem, a revision mismatch, an orphaned Covers, a duplicated ID and a missing test. Work top to bottom through the report with everything you have learned.', + goal: 'Take the trace from five findings to a clean exit 0.', + docs: [{ label: 'Coverage rules', href: '/docs/coverage-rules/' }], + argv: [], + startsClean: false, + spec: { + file: 'spec.md', + body: `## Auth feature + +\`feat:auth#1\` + +Everything a user needs to prove who they are. + +Needs: req:auth/login#1 + +## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Needs: impl:auth/login#1, utest:auth/login#1 + +Covers: feat:auth#2 + +## Session limit + +\`req:auth/session-limit#1\` + +A user may hold at most five concurrent sessions. + +Needs: impl:auth/sessions#1 + +## Session limit (copy) + +\`req:auth/session-limit#1\` + +Left behind by a merge - the tracer sees the ID twice.`, + anchors: { + coversLine: '^Covers: feat:auth#2$', + duplicateBlock: + '\\n\\n## Session limit \\(copy\\)\\n\\n\`req:auth/session-limit#1\`\\n\\nLeft behind by a merge - the tracer sees the ID twice\\.$', + }, + snippets: { + coversFixed: 'Covers: feat:auth#1', + nothing: '', + }, + }, + variants: { + js: { + file: 'auth.js', + body: `// [>>utest:auth/sessions#1] +// TODO: session tests still need an owner + +// [impl:auth/login#1] +export function login(email, password) { + return session.open(email, password); +} + +// [impl:auth/sessions#2] +export function limitSessions(user) { + return sessions.of(user).cap(5); +}`, + anchors: { + strayDemand: '^// \\[>>utest:auth/sessions#1\\]\\n// TODO: session tests still need an owner\\n\\n', + wrongSessionsRev: 'impl:auth/sessions#2', + }, + snippets: { + nothing: '', + sessionsFixed: 'impl:auth/sessions#1', + loginTest: `\n// [utest:auth/login#1] +test('login opens a session', () => { + expect(login('ada@example.com', 'pw')).toBeTruthy(); +});`, + }, + }, + }, + steps: [ + { + explain: + 'Start with the problem: a [>>...] demand tag with no item tag anywhere above it is an error - it has nothing to attach to. This stray TODO block has to go.', + pane: 'code', + anchor: 'strayDemand', + patch: { op: 'replaceMatch', anchor: 'strayDemand', snippet: 'nothing' }, + check: (r) => r.problems.length === 0, + }, + { + explain: + 'The session-limit requirement needs impl:auth/sessions#1, but the code tag says #2 - and #2 is unwanted on top. The spec is right here; fix the code tag\'s revision.', + pane: 'code', + anchor: 'wrongSessionsRev', + patch: { op: 'replaceMatch', anchor: 'wrongSessionsRev', snippet: 'sessionsFixed' }, + check: (r) => r.items.some((i) => i.id === 'impl:auth/sessions#1'), + }, + { + explain: 'An old friend: the Covers entry names feat:auth#2, but the feature lives at revision 1.', + pane: 'spec', + anchor: 'coversLine', + patch: { op: 'replaceLine', anchor: 'coversLine', snippet: 'coversFixed' }, + check: (r) => r.items.every((i) => i.defects.every((d) => !d.startsWith('orphaned'))), + }, + { + explain: + 'req:auth/session-limit#1 is defined twice - every full ID must be unique. Delete the leftover copy at the bottom of the spec.', + pane: 'spec', + anchor: 'duplicateBlock', + patch: { op: 'replaceMatch', anchor: 'duplicateBlock', snippet: 'nothing' }, + check: (r) => r.items.every((i) => i.defects.every((d) => !d.startsWith('duplicate'))), + }, + { + explain: 'One uncovered need left: the login test. You wrote this one in the deep-coverage chapter.', + pane: 'code', + anchor: null, + patch: { op: 'append', snippet: 'loginTest' }, + check: (r) => r.items.some((i) => i.id === 'utest:auth/login#1'), + }, + ], + done: (r) => r.clean && r.exitCode === 0, + }, + + { + id: 'keyword-tables', + title: 'Needs & Covers from tables', + group: 'Extras', + intro: + 'Keyword lists do not have to be one-liners: a Needs:/Covers:/Tags: keyword also accepts a bullet list on the following lines, and a table column headed by the bare keyword contributes each row\'s cell. The trace stays identical - only the Markdown gets friendlier.', + goal: 'Restructure the spec with a bullet list and a keyword table - staying green.', + docs: [{ label: 'Markdown items', href: '/docs/markdown-items/' }], + argv: [], + startsClean: true, + spec: { + file: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password. + +Needs: impl:auth/login#1, utest:auth/login#1`, + anchors: { + needsInline: '^Needs: impl:auth/login#1, utest:auth/login#1$', + }, + snippets: { + needsBullets: `Needs: +- impl:auth/login#1 +- utest:auth/login#1`, + tableItem: `\n## Auth deliverables + +\`feat:auth#1\` + +| Deliverable | Needs | Tags | +|-------------|------------------|------| +| Login | req:auth/login#1 | auth |`, + }, + }, + variants: { + js: { + file: 'login.js', + body: `// [impl:auth/login#1] +export function login(email, password) { + return session.open(email, password); +} + +// [utest:auth/login#1] +test('login opens a session', () => { + expect(login('ada@example.com', 'pw')).toBeTruthy(); +});`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + explain: + 'Rewrite the inline Needs list as a bullet list: "Needs:" on its own line, one "- id" per line below it. Run it - the trace is unchanged; both styles mean the same (just never mix them within one keyword).', + pane: 'spec', + anchor: 'needsInline', + patch: { op: 'replaceLine', anchor: 'needsInline', snippet: 'needsBullets' }, + check: (r) => + /^- impl:auth\/login#1$/m.test(r.spec) && + r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.length === 2), + }, + { + explain: + 'Add the feature as a table-driven item: a column headed "Needs" (no colon) contributes each row\'s cell as one entry, and the "Tags" column tags the item - here with auth, ready for a -t run.', + pane: 'spec', + anchor: null, + patch: { op: 'append', snippet: 'tableItem' }, + check: (r) => { + const f = r.items.find((i) => i.id === 'feat:auth#1'); + return f !== undefined && f.needs.includes('req:auth/login#1') && f.tags.includes('auth'); + }, + }, + ], + done: (r) => + r.clean && + /^- utest:auth\/login#1$/m.test(r.spec) && + r.items.some((i) => i.id === 'feat:auth#1' && i.needs.includes('req:auth/login#1') && i.tags.includes('auth')), + }, + + { + id: 'anchored-demands', + title: 'Anchored demand tags', + group: 'Extras', + intro: + 'A subtle one to finish: the run is green, and still wrong. An implicit [>>...] demand attaches to the nearest preceding item tag - and the audit tag slipped in between. The -v report holds the evidence: read who *wants* the login test. The explicit [source >> target] form pins the attachment.', + goal: 'Attach the test demand to the login implementation - explicitly.', + docs: [{ label: 'Code tags', href: '/docs/code-tags/' }], + argv: ['-v'], + startsClean: true, + spec: { + file: 'spec.md', + body: `## Login requirement + +\`req:auth/login#1\` + +Users must be able to log in with email and password, +and every login attempt is written to the audit log. + +Needs: impl:auth/login#1, impl:auth/audit#1`, + anchors: {}, + snippets: {}, + }, + variants: { + js: { + file: 'login.js', + body: `// [impl:auth/login#1] +export function login(email, password) { + // [impl:auth/audit#1] + audit(email); + // [>>utest:auth/login#1] + return session.open(email, password); +} + +// [utest:auth/login#1] +test('login opens a session', () => { + expect(login('ada@example.com', 'pw')).toBeTruthy(); +});`, + anchors: { + implicitDemand: '^ // \\[>>utest:auth/login#1\\]$', + }, + snippets: { + explicitDemand: ' // [impl:auth/login#1 >> utest:auth/login#1]', + }, + }, + }, + steps: [ + { + explain: + 'Under utest:auth/login#1 the -v report says "wanted by impl:auth/audit#1" - the audit tag stole the demand, so the *audit* code claims the login test. Replace the implicit tag with the explicit form [impl:auth/login#1 >> utest:auth/login#1]; it stays attached no matter what is inserted above.', + pane: 'code', + anchor: 'implicitDemand', + patch: { op: 'replaceLine', anchor: 'implicitDemand', snippet: 'explicitDemand' }, + check: (r) => { + const login = r.items.find((i) => i.id === 'impl:auth/login#1'); + const audit = r.items.find((i) => i.id === 'impl:auth/audit#1'); + return ( + login !== undefined && + audit !== undefined && + login.needs.includes('utest:auth/login#1') && + !audit.needs.includes('utest:auth/login#1') + ); + }, + }, + ], + done: (r) => + r.clean && + r.items.some((i) => i.id === 'impl:auth/login#1' && i.needs.includes('utest:auth/login#1')) && + !r.items.some((i) => i.id === 'impl:auth/audit#1' && i.needs.includes('utest:auth/login#1')), + }, +]; diff --git a/src/tutorial/verify.mjs b/src/tutorial/verify.mjs new file mode 100644 index 0000000..7abb85d --- /dev/null +++ b/src/tutorial/verify.mjs @@ -0,0 +1,227 @@ +// Build-time chapter verification (Node only). Runs every chapter's start +// state and each step's cumulative patched state through the real, rewritten +// release bundle - the exact artifact the browser executes - and asserts the +// expected outcomes. A flashtrace release that changes behavior a chapter +// relies on fails the build here instead of shipping a broken lesson. +// +// Also computes each chapter's content-hash identity (`rev`) and the +// JSON-safe payload embedded into the /try/ page. +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +import { analyzeBuffers, applyStep } from './chapter-utils.mjs'; + +// --- chapter identity + client payload -------------------------------------- + +function stableStringify(value) { + if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']'; + if (value !== null && typeof value === 'object') { + return ( + '{' + + Object.keys(value) + .sort() + .map((key) => JSON.stringify(key) + ':' + stableStringify(value[key])) + .join(',') + + '}' + ); + } + if (typeof value === 'function') return JSON.stringify(value.toString()); + return JSON.stringify(value) ?? 'null'; +} + +// The chapter as it travels to the browser: functions as source text, only +// the active language variant. Doubles as the identity-hash input, so *any* +// content change - even a typo in a step text - yields a new `rev`. +export function chapterPayload(chapter, lang) { + return { + id: chapter.id, + title: chapter.title, + group: chapter.group, + intro: chapter.intro, + goal: chapter.goal, + docs: chapter.docs, + argv: chapter.argv, + spec: chapter.spec, + variant: { lang, ...chapter.variants[lang] }, + steps: chapter.steps.map((step) => ({ + explain: step.explain, + pane: step.pane, + anchor: step.anchor, + patch: step.patch, + check: step.check.toString(), + })), + done: chapter.done.toString(), + }; +} + +export function chapterRev(chapter, lang) { + return createHash('sha256').update(stableStringify(chapterPayload(chapter, lang))).digest('hex').slice(0, 12); +} + +// --- running the rewritten bundle in-process --------------------------------- + +class VerificationError extends Error {} + +const isExitSignal = (value) => + globalThis.__FtExitSignal !== undefined && value instanceof globalThis.__FtExitSignal; + +let rejectionHookInstalled = false; +let currentRun = null; // { recordExit } of the run in flight + +function installRejectionHook() { + if (rejectionHookInstalled) return; + rejectionHookInstalled = true; + process.on('unhandledRejection', (reason) => { + // ExitSignals are always ours: runCli()'s catch handler rethrows a second + // exit(2) that can surface after the run already resolved on the first + // signal - swallow it in every case. + if (isExitSignal(reason)) { + if (currentRun) currentRun.recordExit(reason.code); + return; + } + throw reason; + }); +} + +let importCounter = 0; + +// Same contract as the browser worker: seed the vfs + argv globals, import a +// fresh instance of the bundle (unique query string -> fresh module graph, so +// runCli() fires again), capture console output, resolve on the first +// ExitSignal. Returns { output, exitCode, mod }. +async function runBundle(bundleUrl, files, argv) { + installRejectionHook(); + + const vfs = new Map(); + for (const [name, text] of Object.entries(files)) vfs.set('/project/' + name, text); + globalThis.__ftVfs = vfs; + const pathname = decodeURIComponent(new URL(bundleUrl).pathname); + globalThis.__ftArgv = ['node', pathname, '/project', ...argv]; + + const output = []; + let exitCode = null; + let exitSeen; + const exited = new Promise((resolve) => { + exitSeen = resolve; + }); + currentRun = { + recordExit(code) { + if (exitCode === null) exitCode = code; + exitSeen(); + }, + }; + + const realLog = console.log; + const realError = console.error; + const capture = (args) => { + const signal = args.find(isExitSignal); + if (signal) { + currentRun.recordExit(signal.code); + return; + } + output.push(args.map(String).join(' ')); + }; + console.log = (...args) => capture(args); + console.error = (...args) => capture(args); + + try { + const mod = await import(`${bundleUrl}?run=${++importCounter}`); + await Promise.race([ + exited, + new Promise((_, reject) => + setTimeout(() => reject(new VerificationError('bundle run timed out')), 10_000).unref(), + ), + ]); + return { output: output.join('\n'), exitCode, mod }; + } finally { + console.log = realLog; + console.error = realError; + currentRun = null; + } +} + +// --- verification ------------------------------------------------------------ + +function assertThat(condition, chapter, context, detail) { + if (condition) return; + throw new VerificationError( + `chapter "${chapter.id}" failed verification at ${context}: ${detail}`, + ); +} + +async function runState(bundleUrl, chapter, lang, state) { + const files = { + [chapter.spec.file]: state.spec, + [chapter.variants[lang].file]: state.code, + }; + const { output, exitCode, mod } = await runBundle(bundleUrl, files, state.argv); + const analysis = analyzeBuffers(mod, files, state.argv); + return { + output, + exitCode, + r: { ...analysis, exitCode, argv: state.argv, spec: state.spec, code: state.code }, + }; +} + +// Verifies all chapters against the rewritten bundle at `bundlePath` and +// returns { id -> { rev, startOutput, startExit, solvedOutput, solvedExit } }. +export async function verifyChapters(chapters, bundlePath, lang) { + const bundleUrl = pathToFileURL(bundlePath).href; + const results = {}; + for (const chapter of chapters) { + let state = { + spec: chapter.spec.body, + code: chapter.variants[lang].body, + argv: [...chapter.argv], + }; + const start = await runState(bundleUrl, chapter, lang, state); + assertThat( + (start.exitCode === 0) === Boolean(chapter.startsClean), + chapter, + 'start state', + `expected startsClean=${Boolean(chapter.startsClean)}, got exit ${start.exitCode}\n--- output ---\n${start.output}`, + ); + assertThat( + chapter.done(start.r) === false, + chapter, + 'start state', + 'the done condition already passes on the seed buffers - the chapter would complete on the first run', + ); + + for (let i = 0; i < chapter.steps.length; i++) { + state = applyStep(chapter, lang, chapter.steps[i], state); + assertThat(!state.failed, chapter, `step ${i + 1} patch`, 'the anchor did not match the patched buffers'); + const after = await runState(bundleUrl, chapter, lang, state); + for (let j = 0; j <= i; j++) { + assertThat( + chapter.steps[j].check(after.r) === true, + chapter, + `step ${i + 1}`, + `check of step ${j + 1} does not pass after applying steps 1..${i + 1}\n--- output ---\n${after.output}`, + ); + } + if (i === chapter.steps.length - 1) { + assertThat( + chapter.done(after.r) === true, + chapter, + 'solved state', + `the done condition does not pass after all steps\n--- output ---\n${after.output}`, + ); + assertThat( + after.exitCode === 0, + chapter, + 'solved state', + `expected exit 0, got ${after.exitCode}\n--- output ---\n${after.output}`, + ); + results[chapter.id] = { + rev: chapterRev(chapter, lang), + startOutput: start.output, + startExit: start.exitCode, + solvedOutput: after.output, + solvedExit: after.exitCode, + }; + } + } + } + return results; +} From 31308bd6f1f8314b45b5ab6d36cd9ae35067d434 Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:44:26 +0200 Subject: [PATCH 05/34] feat: chapter engine, rail and persistence on /try/ - chapter rail (docs sidebar pattern incl. mobile drawer) with completion marks - a distinct mark for assisted completions - and a reset control - head section, IDE chrome, pane tabs and editable command-line arguments all switch per chapter; deep-linkable via #chapter-id - progress map ft-tutorial-progress: write-once detailed entries (title, content-hash rev, lang, completedAt, assist counters), keyed only by stable chapter id so inserted chapters can never auto-check; corrupt or foreign-versioned JSON degrades to no progress - buffers ft-tutorial-buffers: debounced per-chapter save, cleared on completion; returning to in-flight work opens a resume/start-fresh with an updated-since note on rev mismatch - current step = first failing check, recomputed after every run and (via silent background runs) after edits; completion only ever triggers on a real Run - no-JS fallback: all 11 chapters render read-only with their inputs and the build-time captured real reports Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 416 ++++++++++++++++++++++++++++++++++++---- src/styles/site.css | 218 ++++++++++++++++++++- src/tutorial.mjs | 176 ++++++++++++++--- 3 files changed, 747 insertions(+), 63 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 2672c40..c0fd072 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -1,7 +1,9 @@ -// /try/ page script: wires the two editors and the Run button to a fresh -// module worker per run that executes the real flashtrace release build -// against the buffers. Loaded as a module; older browsers without module -// support simply keep the static page. +// /try/ page script: chapter engine + runner. Each Run spawns a fresh module +// worker that executes the real flashtrace release build against the editor +// buffers; chapter checks evaluate the worker's structured results. Progress +// and in-flight buffers persist in localStorage (ft-tutorial-*), keyed by +// stable chapter ids - never by index. Loaded as a module; browsers without +// module support keep the server-rendered read-only fallback. import { colorizeReport, esc } from './report-colors.mjs'; const ide = document.getElementById('ide'); @@ -9,35 +11,360 @@ const specEditor = document.getElementById('ed-spec'); const codeEditor = document.getElementById('ed-code'); const terminal = document.getElementById('term-out'); const runButton = document.getElementById('run-btn'); +const argvInput = document.getElementById('argv-input'); + +const PROGRESS_KEY = 'ft-tutorial-progress'; +const BUFFERS_KEY = 'ft-tutorial-buffers'; +const RUN_TIMEOUT_MS = 5000; if (ide && specEditor && codeEditor && terminal && runButton) init(); +// --- defensive localStorage stores ------------------------------------------ + +function loadStore(key) { + // unparseable or foreign-versioned data degrades to "empty" without crashing + try { + const parsed = JSON.parse(localStorage.getItem(key)); + if (parsed && parsed.v === 1 && parsed.chapters && typeof parsed.chapters === 'object') { + return parsed; + } + } catch { + /* fall through */ + } + return { v: 1, chapters: {} }; +} + +function saveStore(key, store) { + try { + localStorage.setItem(key, JSON.stringify(store)); + } catch { + /* private mode etc. - persistence is best-effort */ + } +} + +function removeStores() { + try { + localStorage.removeItem(PROGRESS_KEY); + localStorage.removeItem(BUFFERS_KEY); + } catch { + /* ignore */ + } +} + +// --- chapter data ------------------------------------------------------------ + +function loadChapterData() { + try { + const el = document.getElementById('tutorial-data'); + const data = JSON.parse(el.textContent); + if (data.v !== 1 || !Array.isArray(data.chapters) || data.chapters.length === 0) return null; + // checks/done ship as source text of pure, closure-free arrows + const compile = (src) => new Function('return (' + src + ')')(); + for (const chapter of data.chapters) { + chapter.done = compile(chapter.done); + for (const step of chapter.steps) step.check = compile(step.check); + } + return data; + } catch (err) { + console.warn('tutorial: chapter data unavailable, running as a plain editor -', err); + return null; + } +} + +// --- engine state ------------------------------------------------------------ + +let data = null; +let current = null; // active chapter object +let currentIndex = 0; +let assists = { help: 0, auto: 0 }; // in-memory per chapter visit +let lastResult = null; // r-context of the latest (also silent) run +let active = null; // { worker, watchdog, silent } of the run in flight +let saveTimer = null; +let checkTimer = null; + function init() { - runButton.addEventListener('click', run); + runButton.addEventListener('click', () => run(false)); for (const editor of [specEditor, codeEditor]) { editor.addEventListener('keydown', (event) => { if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); - run(); + run(false); } }); } + if (argvInput) { + argvInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + run(false); + } + }); + } + + data = loadChapterData(); + if (!data) return; // bare runner: Run still works on the visible buffers + + for (const editor of [specEditor, codeEditor]) { + editor.addEventListener('input', () => { + clearTimeout(saveTimer); + saveTimer = setTimeout(saveBuffers, 400); + scheduleSilentRun(); + }); + } + if (argvInput) argvInput.addEventListener('input', scheduleSilentRun); + + document.querySelectorAll('[data-chapter]').forEach((link) => { + link.addEventListener('click', (event) => { + event.preventDefault(); + openChapter(link.getAttribute('data-chapter')); + }); + }); + + const resetProgress = document.getElementById('reset-progress'); + if (resetProgress) { + resetProgress.addEventListener('click', () => { + if (window.confirm('Reset all tutorial progress and saved buffers?')) { + removeStores(); + window.location.reload(); + } + }); + } + const resetFiles = document.getElementById('reset-files'); + if (resetFiles) { + resetFiles.addEventListener('click', () => { + if (!current) return; + seedBuffers(); + deleteBufferEntry(current.id); + scheduleSilentRun(); + }); + } + + renderRailMarks(); + const fromHash = window.location.hash.slice(1); + const initial = + data.chapters.find((c) => c.id === fromHash) ?? + data.chapters.find((c) => !progressEntry(c.id)) ?? + data.chapters[0]; + openChapter(initial.id); } -const RUN_TIMEOUT_MS = 5000; -let active = null; // { worker, watchdog } of the run in flight +function progressEntry(id) { + return loadStore(PROGRESS_KEY).chapters[id]; +} -function currentArgv() { +// --- chapter switching ------------------------------------------------------- + +function chapterById(id) { + return data.chapters.find((c) => c.id === id) ?? null; +} + +function seedBuffers() { + specEditor.value = current.spec.body; + codeEditor.value = current.variant.body; + if (argvInput) argvInput.value = current.argv.join(' '); +} + +function openChapter(id) { + const chapter = chapterById(id); + if (!chapter) return; + if (current) saveBuffers(); + current = chapter; + currentIndex = data.chapters.indexOf(chapter); + assists = { help: 0, auto: 0 }; + lastResult = null; + + // rail + url + document.querySelectorAll('[data-chapter]').forEach((link) => { + const on = link.getAttribute('data-chapter') === id; + link.classList.toggle('is-current', on); + if (on) link.setAttribute('aria-current', 'page'); + else link.removeAttribute('aria-current'); + }); + history.replaceState(null, '', '#' + id); + + // head + ide chrome + const no = currentIndex + 1; + setText('ch-kicker', 'Chapter ' + no + ' · ' + chapter.group); + setText('ch-title', chapter.title); + setText('ch-intro', chapter.intro); + const docsEl = document.getElementById('ch-docs'); + if (docsEl) { + docsEl.innerHTML = chapter.docs + .map((d) => '' + esc(d.label) + '') + .join(' · '); + } + setText('ide-title', 'chapter ' + no + ' · ' + chapter.title); + setText('spec-tab', chapter.spec.file); + setText('code-tab', chapter.variant.file); + setText('goal-text', chapter.goal); + setText('step-progress', 'Run to check your progress.'); + terminal.innerHTML = 'press Run to trace the project'; + + // buffers: stored work-in-progress -> resume/start-fresh modal + const stored = loadStore(BUFFERS_KEY).chapters[id]; + if (stored && (stored.spec !== chapter.spec.body || stored.code !== chapter.variant.body)) { + offerResume(chapter, stored); + } else { + seedBuffers(); + scheduleSilentRun(); + } +} + +function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function offerResume(chapter, stored) { + const modal = document.getElementById('resume-modal'); + const resume = () => { + specEditor.value = String(stored.spec); + codeEditor.value = String(stored.code); + if (argvInput) argvInput.value = chapter.argv.join(' '); + scheduleSilentRun(); + }; + if (!modal || typeof modal.showModal !== 'function') { + resume(); + return; + } + setText('resume-text', 'You have work in progress from ' + relativeTime(stored.savedAt) + ' - resume it, or start fresh?'); + const note = document.getElementById('resume-note'); + if (note) note.hidden = stored.rev === chapter.rev; + const yes = document.getElementById('resume-yes'); + const fresh = document.getElementById('resume-fresh'); + const close = (starter) => () => { + modal.close(); + yes.onclick = null; + fresh.onclick = null; + starter(); + }; + yes.onclick = close(resume); + fresh.onclick = close(() => { + deleteBufferEntry(chapter.id); + seedBuffers(); + scheduleSilentRun(); + }); + modal.oncancel = () => close(resume)(); // Esc counts as resume - never discards + modal.showModal(); +} + +function relativeTime(iso) { + const then = Date.parse(iso); + if (Number.isNaN(then)) return 'earlier'; + const minutes = Math.round((Date.now() - then) / 60000); + if (minutes < 2) return 'moments ago'; + if (minutes < 90) return minutes + ' minutes ago'; + const hours = Math.round(minutes / 60); + if (hours < 36) return hours + ' hours ago'; + return Math.round(hours / 24) + ' days ago'; +} + +// --- persistence ------------------------------------------------------------- + +function saveBuffers() { + if (!current) return; + const store = loadStore(BUFFERS_KEY); + const pristine = + specEditor.value === current.spec.body && codeEditor.value === current.variant.body; + if (pristine) { + delete store.chapters[current.id]; + } else { + store.chapters[current.id] = { + rev: current.rev, + lang: data.lang, + savedAt: new Date().toISOString(), + spec: specEditor.value, + code: codeEditor.value, + }; + } + saveStore(BUFFERS_KEY, store); +} + +function deleteBufferEntry(id) { + const store = loadStore(BUFFERS_KEY); + delete store.chapters[id]; + saveStore(BUFFERS_KEY, store); +} + +// Written exactly once per chapter: when its done-condition first passes +// after a real (non-silent) Run in this browser. +function completeChapter(chapter) { + const store = loadStore(PROGRESS_KEY); + if (store.chapters[chapter.id]) return; + store.chapters[chapter.id] = { + title: chapter.title, + rev: chapter.rev, + lang: data.lang, + completedAt: new Date().toISOString(), + assists: { help: assists.help, auto: assists.auto }, + }; + saveStore(PROGRESS_KEY, store); + deleteBufferEntry(chapter.id); + renderRailMarks(); +} + +function renderRailMarks() { + const progress = loadStore(PROGRESS_KEY); + let any = false; + document.querySelectorAll('.ch-mark[data-mark]').forEach((mark) => { + const entry = progress.chapters[mark.getAttribute('data-mark')]; + const assisted = entry && entry.assists && entry.assists.help + entry.assists.auto > 0; + mark.classList.toggle('is-done', Boolean(entry) && !assisted); + mark.classList.toggle('is-assisted', Boolean(assisted)); + if (entry) { + any = true; + mark.closest('a').title = assisted ? 'completed with assists' : 'completed'; + } + }); + const reset = document.getElementById('reset-progress'); + if (reset) reset.hidden = !any; +} + +// --- step evaluation --------------------------------------------------------- + +function safeCheck(fn, r) { try { - const argv = JSON.parse(ide.dataset.argv || '[]'); - return Array.isArray(argv) ? argv.map(String) : []; + return fn(r) === true; } catch { - return []; + return false; } } -function commandLine(argv) { - return ['npx flashtrace', ...argv].join(' '); +function updateStepUi(fromRealRun) { + if (!current || !lastResult || !lastResult.items) return; + const steps = current.steps; + let firstFailing = steps.length; + for (let i = 0; i < steps.length; i++) { + if (!safeCheck(steps[i].check, lastResult)) { + firstFailing = i; + break; + } + } + const solved = safeCheck(current.done, lastResult); + const completed = Boolean(progressEntry(current.id)); + if (solved && (completed || fromRealRun)) { + const next = data.chapters[currentIndex + 1]; + setText( + 'step-progress', + 'Chapter complete ✓' + (next ? ' - up next: ' + (currentIndex + 2) + ' · ' + next.title : ' - that was the last one!'), + ); + } else if (firstFailing >= steps.length) { + setText('step-progress', solved ? 'All steps done - Run to finish the chapter.' : 'All steps done.'); + } else { + setText('step-progress', 'Step ' + (firstFailing + 1) + ' of ' + steps.length); + } + document.dispatchEvent( + new CustomEvent('flashtrace:steps', { + detail: { chapter: current.id, firstFailing, solved, fromRealRun }, + }), + ); +} + +// --- runner ------------------------------------------------------------------ + +function currentArgv() { + const raw = argvInput ? argvInput.value : ''; + return raw.trim().split(/\s+/).filter(Boolean); } function showTerminal(html) { @@ -52,46 +379,69 @@ function finishRun() { runButton.disabled = false; } -function run() { - if (active) return; // one run at a time; the watchdog frees a stuck one +function scheduleSilentRun() { + clearTimeout(checkTimer); + checkTimer = setTimeout(() => run(true), 800); +} + +function run(silent) { + if (active) { + if (silent) return; // a real Run preempts a background check, not vice versa + if (active.silent) finishRun(); + else return; + } if (typeof Worker === 'undefined') { - showTerminal('This browser cannot run workers - the live terminal is unavailable.'); + if (!silent) showTerminal('This browser cannot run workers - the live terminal is unavailable.'); return; } const argv = currentArgv(); - const files = { - [ide.dataset.specFile]: specEditor.value, - [ide.dataset.codeFile]: codeEditor.value, - }; - const prompt = `$ ${esc(commandLine(argv))}\n`; + const specName = current ? current.spec.file : ide.dataset.specFile || 'spec.md'; + const codeName = current ? current.variant.file : ide.dataset.codeFile || 'code.js'; + const files = { [specName]: specEditor.value, [codeName]: codeEditor.value }; + const prompt = '$ ' + esc(['npx flashtrace'].concat(argv).join(' ')) + '\n'; - runButton.disabled = true; - showTerminal(prompt + 'running…'); + if (!silent) { + runButton.disabled = true; + showTerminal(prompt + 'running…'); + } const worker = new Worker(new URL('./tutorial-worker.js', import.meta.url), { type: 'module' }); const watchdog = setTimeout(() => { + const wasSilent = active && active.silent; finishRun(); - showTerminal(prompt + 'the run did not finish within 5 seconds and was stopped'); + if (!wasSilent) showTerminal(prompt + 'the run did not finish within 5 seconds and was stopped'); }, RUN_TIMEOUT_MS); - active = { worker, watchdog }; + active = { worker, watchdog, silent }; worker.onmessage = (event) => { + const wasSilent = active && active.silent; finishRun(); const result = event.data; if (!result.ok) { - showTerminal(prompt + `failed to load the flashtrace bundle: ${esc(result.error)}`); + if (!wasSilent) showTerminal(prompt + 'failed to load the flashtrace bundle: ' + esc(result.error) + ''); return; } - const body = result.output ? colorizeReport(result.output) + '\n' : ''; - showTerminal(prompt + body + `exit ${result.exitCode}`); - document.dispatchEvent( - new CustomEvent('flashtrace:run', { detail: { result, files, argv } }), - ); + if (!wasSilent) { + const body = result.output ? colorizeReport(result.output) + '\n' : ''; + showTerminal(prompt + body + 'exit ' + result.exitCode + ''); + } + lastResult = Object.assign({}, result.analysis, { + exitCode: result.exitCode, + argv, + spec: specEditor.value, + code: codeEditor.value, + }); + if (current && result.analysis && !wasSilent && safeCheck(current.done, lastResult)) { + completeChapter(current); + } + updateStepUi(!wasSilent); + document.dispatchEvent(new CustomEvent('flashtrace:run', { detail: { result, silent: wasSilent } })); }; worker.onerror = (event) => { + const wasSilent = active && active.silent; finishRun(); - showTerminal(prompt + `worker error: ${esc(event.message || 'unknown')}`); + if (!wasSilent) showTerminal(prompt + 'worker error: ' + esc(event.message || 'unknown') + ''); }; worker.postMessage({ files, argv }); diff --git a/src/styles/site.css b/src/styles/site.css index c8f9a5f..d7ad7b6 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1182,7 +1182,8 @@ h3:hover .heading-anchor, } @media (max-width: 860px) { - .doc-layout { + .doc-layout, + .tutorial-layout { grid-template-columns: minmax(0, 1fr); } @@ -1250,9 +1251,17 @@ h3:hover .heading-anchor, } .tutorial-layout { - max-width: 80rem; + max-width: 90rem; margin: 0 auto; - padding: 2rem 1rem 0; + display: grid; + grid-template-columns: 16rem minmax(0, 1fr); + gap: 2rem; + padding: 0 1rem; +} + +.tutorial-main { + padding: 1.5rem 0 3rem; + min-width: 0; } .tutorial-head h1 { @@ -1261,8 +1270,209 @@ h3:hover .heading-anchor, letter-spacing: -0.01em; } -.tutorial-head .section-sub { +.ch-kicker { + margin: 0 0 0.25rem; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--accent-strong); +} + +.ch-intro { + color: var(--muted); max-width: 56rem; + margin: 0 0 0.5rem; +} + +.ch-docs { + font-size: 0.9rem; + color: var(--muted); + margin: 0 0 1.25rem; +} + +/* chapter rail marks */ + +.ch-mark { + display: inline-block; + width: 1.1rem; + color: #3fb950; + font-weight: 700; +} + +.ch-mark.is-done::before { + content: '✓'; +} + +.ch-mark.is-assisted::before { + content: '✓'; + color: var(--accent); +} + +.rail-foot { + margin-top: 1.5rem; + padding: 0.75rem 0.75rem 0; + border-top: 1px solid var(--border); +} + +.rail-legend { + font-size: 0.75rem; + color: var(--muted); + margin: 0 0 0.5rem; +} + +.rail-reset { + border: 1px solid var(--border); + background: var(--bg-raised); + color: var(--muted); + border-radius: 6px; + padding: 0.25rem 0.7rem; + font-size: 0.8rem; + cursor: pointer; +} + +.rail-reset:hover { + color: var(--text); + border-color: var(--accent); +} + +/* ide chrome extras */ + +.ide-reset { + margin-left: auto; + border: 1px solid #44403c; + background: #2a2724; + color: #d4d4d4; + border-radius: 6px; + padding: 0.15rem 0.6rem; + font-size: 0.72rem; + cursor: pointer; +} + +.ide-reset:hover { + border-color: var(--accent); +} + +.term-cmd { + display: inline-flex; + align-items: baseline; + flex: 1; + min-width: 0; + margin-left: 0.75rem; + gap: 0; +} + +.argv-input { + flex: 1; + min-width: 4rem; + margin-left: 0.4rem; + border: none; + border-bottom: 1px dashed #44403c; + background: transparent; + color: #d4d4d4; + font-family: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace; + font-size: 0.75rem; + padding: 0 0.2rem; +} + +.argv-input:focus-visible { + outline: none; + border-bottom-color: var(--accent); +} + +/* assist bar */ + +.assist-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.5rem; + margin-top: 0.9rem; +} + +.assist-goal { + margin: 0; + color: var(--text); + font-size: 0.95rem; +} + +.assist-progress { + margin: 0; + color: var(--muted); + font-size: 0.9rem; +} + +/* resume modal */ + +.resume-modal { + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-raised); + color: var(--text); + padding: 1.5rem; + max-width: 26rem; + box-shadow: var(--shadow); +} + +.resume-modal::backdrop { + background: rgb(0 0 0 / 0.45); +} + +.resume-modal h2 { + margin: 0 0 0.5rem; + font-size: 1.15rem; +} + +.resume-modal p { + margin: 0 0 0.75rem; + color: var(--muted); +} + +.resume-note { + color: var(--accent-strong) !important; + font-size: 0.9rem; +} + +.resume-actions { + display: flex; + gap: 0.75rem; + margin-top: 1rem; +} + +/* no-JS fallback */ + +.tutorial-fallback { + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); +} + +.tutorial-fallback h2 { + font-size: 1.3rem; + margin: 0 0 0.5rem; +} + +.tutorial-fallback > p { + color: var(--muted); + max-width: 56rem; +} + +.tutorial-fallback .ex-files { + margin: 1rem 0; +} + +.tutorial-fallback .mini-window.term { + margin-bottom: 1rem; +} + +.tutorial-fallback details { + margin-bottom: 1rem; +} + +.tutorial-fallback summary { + cursor: pointer; + color: var(--link); + margin-bottom: 0.5rem; } /* the large IDE: hero .ide-mock visuals, sized as the page's centerpiece */ diff --git a/src/tutorial.mjs b/src/tutorial.mjs index 050a6ee..a40c422 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -1,58 +1,182 @@ -// The /try/ page: a large editable IDE mock whose terminal executes the real -// flashtrace release build in a module worker (see src/scripts/tutorial.js). -// Internal naming is `tutorial` throughout - the public button label is -// swappable without touching code or storage keys. -import { examples } from './examples.mjs'; -import { esc, pageShell } from './layout.mjs'; +// The /try/ page: chapter rail + a large editable IDE mock whose terminal +// executes the real flashtrace release build in a module worker (see +// src/scripts/tutorial.js). Server-renders chapter 1 as the initial state and +// a full read-only fallback for browsers without JavaScript. Internal naming +// is `tutorial` throughout - the public button label is swappable without +// touching code or storage keys. +import { chapters } from './tutorial/chapters.mjs'; +import { chapterPayload } from './tutorial/verify.mjs'; +import { esc, highlightTokens, pageShell } from './layout.mjs'; +import { colorizeReport } from './report-colors.mjs'; + +const LANG = 'js'; const dots = ``; -export function renderTutorial({ version }) { - // Stage-1 seed: the landing page's "uncovered defect" example - replaced by - // the chapter engine in stage 2. - const seed = examples.find((e) => e.id === 'uncovered'); - const [spec, code] = seed.files; +function docsLinks(chapter) { + return chapter.docs + .map((d) => `${esc(d.label)}`) + .join(' · '); +} - const body = `
-
-

Try flashtrace

-

Edit the spec and the code, then hit Run - the terminal executes the real flashtrace ${esc(version)} release build right in your browser. Nothing leaves the page.

-
-
+function chapterRail() { + const groups = []; + for (const chapter of chapters) { + let group = groups.find((g) => g.label === chapter.group); + if (!group) { + group = { label: chapter.group, items: [] }; + groups.push(group); + } + group.items.push(chapter); + } + let no = 0; + const groupHtml = groups + .map( + (g) => ``, + ) + .join('\n'); + return ` +`; +} + +function ide(first) { + const variant = first.variants[LANG]; + return `
${dots} - try flashtrace + chapter 1 · ${esc(first.title)} +
-
${esc(spec.name)}
- +
${esc(first.spec.file)}
+
-
${esc(code.name)}
- +
${esc(variant.file)}
+
terminal + $ npx flashtrace
-
$ npx flashtrace - press Run to trace the two files
+
press Run to trace the project
+
+
`; +} + +function resumeDialog() { + return ` +

Work in progress

+

You have unsaved work in this chapter - resume it, or start fresh?

+ +
+ +
+
`; +} + +function miniWindow(name, inner, term = false) { + return `
${dots}${esc(name)}
${inner}
`; +} + +// Read-only chapter rendering for browsers without JavaScript: the inputs and +// the build-time captured real runs, at the fidelity of the landing examples. +function fallbackChapters(verified) { + const sections = chapters + .map((chapter, index) => { + const variant = chapter.variants[LANG]; + const captured = verified[chapter.id]; + const cmd = ['npx flashtrace', ...chapter.argv].join(' '); + const startTerm = + `$ ${esc(cmd)}\n` + + colorizeReport(captured.startOutput) + + `\nexit ${captured.startExit}`; + const solvedTerm = + `$ ${esc(cmd)}\n` + + colorizeReport(captured.solvedOutput) + + `\nexit ${captured.solvedExit}`; + return `
+

${index + 1} · ${esc(chapter.title)}

+

${esc(chapter.intro)}

+
+ ${miniWindow(chapter.spec.file, highlightTokens(esc(chapter.spec.body)))} + ${miniWindow(variant.file, highlightTokens(esc(variant.body)))} +
+ ${miniWindow('terminal', startTerm, true)} +
+ The solved run + ${miniWindow('terminal', solvedTerm, true)} +
+

Read more: ${docsLinks(chapter)}

+
`; + }) + .join('\n'); + return ``; +} + +export function renderTutorial({ version, verified }) { + const first = chapters[0]; + const payloads = chapters.map((chapter) => ({ + ...chapterPayload(chapter, LANG), + rev: verified[chapter.id].rev, + })); + const dataJson = JSON.stringify({ v: 1, lang: LANG, chapters: payloads }).replace(/ +${chapterRail()} +
+
+

Chapter 1 · ${esc(first.group)}

+

${esc(first.title)}

+

${esc(first.intro)}

+

Read more: ${docsLinks(first)}

+
+${ide(first)} +
+

Goal: ${esc(first.goal)}

+

Run to check your progress.

- +${resumeDialog()} +${fallbackChapters(verified)} + -
`; +
+`; return pageShell({ title: 'Try flashtrace - interactive tutorial', description: - 'Run the real flashtrace release build in your browser: edit a Markdown spec and a code file, trace them live.', + 'Learn flashtrace hands-on: guided chapters in an editable IDE that runs the real release build in your browser.', path: '/try/', version, active: 'tutorial', body, bodyClass: 'page-tutorial', + withSidebar: true, }); } From f620abd24160d21ef3e1525e074309a918b6c165 Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:50:05 +0200 Subject: [PATCH 06/34] fix: initialize the tutorial engine after its state declarations init() ran before the module-level let bindings existed (temporal dead zone) and crashed on load. Also follow hash changes to switch chapters, so deep links work from within the page. Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index c0fd072..385d390 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -17,8 +17,6 @@ const PROGRESS_KEY = 'ft-tutorial-progress'; const BUFFERS_KEY = 'ft-tutorial-buffers'; const RUN_TIMEOUT_MS = 5000; -if (ide && specEditor && codeEditor && terminal && runButton) init(); - // --- defensive localStorage stores ------------------------------------------ function loadStore(key) { @@ -139,6 +137,11 @@ function init() { }); } + window.addEventListener('hashchange', () => { + const id = window.location.hash.slice(1); + if (current && id && id !== current.id) openChapter(id); + }); + renderRailMarks(); const fromHash = window.location.hash.slice(1); const initial = @@ -446,3 +449,5 @@ function run(silent) { worker.postMessage({ files, argv }); } + +if (ide && specEditor && codeEditor && terminal && runButton) init(); From de244cbd9221b184ff7ae9bfa1f321ce623fcf9e Mon Sep 17 00:00:00 2001 From: LennarX Date: Fri, 17 Jul 2026 23:55:46 +0200 Subject: [PATCH 07/34] feat: assist buttons - "Help me" and "Do the next step for me" Both operate on the current step (first failing check, so type-ahead, pasted solutions and re-broken steps re-converge): - Help me: spotlight overlay with a punched-out hole over the step's anchor line (pane tab on narrow layouts, the command input for argv steps), plus a focused popup inside the IDE with the step explanation and a docs link; dismiss via close button, Esc or backdrop click with focus restore - Do the next step for me: same highlight, then typewrites the step's patch into the real textarea via setRangeText (undo intact) and triggers a run; instant apply under prefers-reduced-motion; buffers that drifted off the anchor get guidance instead of a broken edit - per-chapter help/auto counters land in the progress entry; assisted completions render the distinct rail mark - anchor lines are located arithmetically over the monospace editors (no wrapping, so no mirror element is needed) Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 253 ++++++++++++++++++++++++++++++++++++++-- src/styles/site.css | 81 +++++++++++++ src/tutorial.mjs | 10 ++ 3 files changed, 336 insertions(+), 8 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 385d390..2fbee1b 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -4,6 +4,7 @@ // and in-flight buffers persist in localStorage (ft-tutorial-*), keyed by // stable chapter ids - never by index. Loaded as a module; browsers without // module support keep the server-rendered read-only fallback. +import { applyStep } from './chapter-utils.mjs'; import { colorizeReport, esc } from './report-colors.mjs'; const ide = document.getElementById('ide'); @@ -61,6 +62,7 @@ function loadChapterData() { for (const chapter of data.chapters) { chapter.done = compile(chapter.done); for (const step of chapter.steps) step.check = compile(step.check); + chapter.variants = { [data.lang]: chapter.variant }; // applyStep's shape } return data; } catch (err) { @@ -202,6 +204,10 @@ function openChapter(id) { setText('goal-text', chapter.goal); setText('step-progress', 'Run to check your progress.'); terminal.innerHTML = 'press Run to trace the project'; + closeAssist(); + for (const btn of [document.getElementById('help-btn'), document.getElementById('auto-btn')]) { + if (btn) btn.disabled = true; // re-enabled once the first analysis lands + } // buffers: stored work-in-progress -> resume/start-fresh modal const stored = loadStore(BUFFERS_KEY).chapters[id]; @@ -333,17 +339,24 @@ function safeCheck(fn, r) { } } +// current step = index of the first failing check; users may type ahead, +// paste solutions or re-break earlier steps - this always re-converges +function firstFailingIndex() { + if (!current || !lastResult || !lastResult.items) return null; + for (let i = 0; i < current.steps.length; i++) { + if (!safeCheck(current.steps[i].check, lastResult)) return i; + } + return current.steps.length; +} + function updateStepUi(fromRealRun) { if (!current || !lastResult || !lastResult.items) return; const steps = current.steps; - let firstFailing = steps.length; - for (let i = 0; i < steps.length; i++) { - if (!safeCheck(steps[i].check, lastResult)) { - firstFailing = i; - break; - } - } + const firstFailing = firstFailingIndex(); const solved = safeCheck(current.done, lastResult); + for (const btn of [document.getElementById('help-btn'), document.getElementById('auto-btn')]) { + if (btn) btn.disabled = false; + } const completed = Boolean(progressEntry(current.id)); if (solved && (completed || fromRealRun)) { const next = data.chapters[currentIndex + 1]; @@ -388,6 +401,7 @@ function scheduleSilentRun() { } function run(silent) { + if (typing) return; // never run against a half-typed patch if (active) { if (silent) return; // a real Run preempts a background check, not vice versa if (active.silent) finishRun(); @@ -450,4 +464,227 @@ function run(silent) { worker.postMessage({ files, argv }); } -if (ide && specEditor && codeEditor && terminal && runButton) init(); +// --- assists: "Help me" / "Do the next step for me" -------------------------- +// +// Both operate on the current step. Help spotlights the step's anchor inside +// the IDE and explains it in a popup; auto additionally typewrites the step's +// patch into the editor (setRangeText, so undo works) and runs. + +let typing = false; // typewriter in flight - runs and assists wait +let assistRestoreFocus = null; + +function initAssists() { + const helpButton = document.getElementById('help-btn'); + const autoButton = document.getElementById('auto-btn'); + const closeButton = document.getElementById('assist-close'); + const spotlight = document.getElementById('spotlight'); + if (!helpButton || !autoButton || !closeButton || !spotlight) return; + helpButton.addEventListener('click', helpAssist); + autoButton.addEventListener('click', autoAssist); + closeButton.addEventListener('click', closeAssist); + spotlight.addEventListener('click', closeAssist); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeAssist(); + }); +} + +function paneEditor(pane) { + return pane === 'spec' ? specEditor : codeEditor; +} + +function rectInIde(el) { + const a = el.getBoundingClientRect(); + const b = ide.getBoundingClientRect(); + return { left: a.left - b.left, top: a.top - b.top, width: a.width, height: a.height }; +} + +// The editors render pre-wrap-free monospace text, so a line's y-offset is +// plain arithmetic over the computed line height - no mirror element needed. +function lineRectInIde(editor, lineIndex) { + const style = getComputedStyle(editor); + const lineHeight = parseFloat(style.lineHeight); + const padTop = parseFloat(style.paddingTop); + editor.scrollTop = Math.max(0, padTop + lineIndex * lineHeight - editor.clientHeight / 2); + const base = rectInIde(editor); + let top = base.top + padTop + lineIndex * lineHeight - editor.scrollTop; + top = Math.min(Math.max(top, base.top + 4), base.top + base.height - lineHeight - 4); + return { left: base.left + 6, top: top - 2, width: base.width - 12, height: lineHeight + 4 }; +} + +// Resolve the step's visual target inside the IDE. Narrow layouts (stacked +// panes) degrade to the pane tab instead of a single line. +function assistTarget(step) { + if (!step) { + const runRect = rectInIde(runButton); + return { rect: runRect }; + } + if (step.pane === 'argv') { + return { rect: rectInIde(argvInput || runButton) }; + } + const editor = paneEditor(step.pane); + const tab = document.getElementById(step.pane === 'spec' ? 'spec-tab' : 'code-tab'); + if (window.innerWidth < 700) return { rect: rectInIde(tab || editor) }; + const pack = step.pane === 'spec' ? current.spec : current.variant; + const anchorKey = (step.patch && step.patch.anchor) || step.anchor; + const source = anchorKey ? pack.anchors[anchorKey] : null; + const text = editor.value; + if (source) { + const match = new RegExp(source, 'm').exec(text); + if (match) { + const line = text.slice(0, match.index).split('\n').length - 1; + return { rect: lineRectInIde(editor, line), editor }; + } + } + // append steps (or unmatched anchors): point at the last line of the pane + return { rect: lineRectInIde(editor, text.split('\n').length - 1), editor }; +} + +function openAssist(step) { + const spotlight = document.getElementById('spotlight'); + const hole = document.getElementById('spotlight-hole'); + const pop = document.getElementById('assist-pop'); + const text = document.getElementById('assist-text'); + const doc = document.getElementById('assist-doc'); + if (!spotlight || !hole || !pop || !text || !doc || !current) return; + + const solvedNotRun = firstFailingIndex() >= current.steps.length; + text.textContent = step + ? step.explain + : solvedNotRun && !progressEntry(current.id) + ? 'Everything checks out - press Run to finish the chapter.' + : 'This chapter is complete. Pick the next one in the rail on the left.'; + const docRef = current.docs[0]; + doc.href = docRef.href; + doc.textContent = 'Read more: ' + docRef.label; + + const { rect } = assistTarget(step); + hole.style.left = rect.left + 'px'; + hole.style.top = rect.top + 'px'; + hole.style.width = rect.width + 'px'; + hole.style.height = rect.height + 'px'; + spotlight.hidden = false; + + // measure invisibly, then place below the target (above when out of space) + pop.hidden = false; + pop.style.visibility = 'hidden'; + const ideBox = ide.getBoundingClientRect(); + const popBox = pop.getBoundingClientRect(); + let top = rect.top + rect.height + 10; + if (top + popBox.height > ideBox.height - 8) top = Math.max(8, rect.top - popBox.height - 10); + const left = Math.min(Math.max(rect.left, 8), Math.max(8, ideBox.width - popBox.width - 8)); + pop.style.top = top + 'px'; + pop.style.left = left + 'px'; + pop.style.visibility = ''; + + assistRestoreFocus = document.activeElement; + pop.focus(); +} + +function closeAssist() { + const spotlight = document.getElementById('spotlight'); + const pop = document.getElementById('assist-pop'); + if (!spotlight || !pop || pop.hidden) return; + spotlight.hidden = true; + pop.hidden = true; + if (assistRestoreFocus && typeof assistRestoreFocus.focus === 'function') assistRestoreFocus.focus(); + assistRestoreFocus = null; +} + +function currentStep() { + const index = firstFailingIndex(); + if (index === null || index >= current.steps.length) return null; + return current.steps[index]; +} + +function helpAssist() { + if (!current || typing) return; + const step = currentStep(); + if (step) assists.help += 1; + openAssist(step); +} + +// shortest edit between the buffer and the patched buffer - typed as one span +function diffRange(oldText, newText) { + let prefix = 0; + while (prefix < oldText.length && prefix < newText.length && oldText[prefix] === newText[prefix]) prefix++; + let oldEnd = oldText.length; + let newEnd = newText.length; + while (oldEnd > prefix && newEnd > prefix && oldText[oldEnd - 1] === newText[newEnd - 1]) { + oldEnd--; + newEnd--; + } + return { start: prefix, oldEnd, insert: newText.slice(prefix, newEnd) }; +} + +function typewriter(editor, start, oldEnd, insert, onDone) { + editor.focus(); + editor.setRangeText('', start, oldEnd, 'end'); // drop the replaced region + const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (reduced || insert.length === 0) { + editor.setRangeText(insert, start, start, 'end'); + editor.dispatchEvent(new Event('input', { bubbles: true })); + onDone(); + return; + } + typing = true; + const chunk = Math.max(1, Math.ceil(insert.length / 60)); + let done = 0; + let pos = start; + const timer = setInterval(() => { + const piece = insert.slice(done, done + chunk); + editor.setRangeText(piece, pos, pos, 'end'); + pos += piece.length; + done += chunk; + if (done >= insert.length) { + clearInterval(timer); + typing = false; + editor.dispatchEvent(new Event('input', { bubbles: true })); + onDone(); + } + }, 18); +} + +function autoAssist() { + if (!current || typing) return; + const index = firstFailingIndex(); + if (index === null) return; + if (index >= current.steps.length) { + run(false); // nothing left to apply - finish with a real run + return; + } + const step = current.steps[index]; + const before = { spec: specEditor.value, code: codeEditor.value, argv: currentArgv() }; + const after = applyStep(current, data.lang, step, before); + if (after.failed) { + openAssist(step); + const text = document.getElementById('assist-text'); + if (text) { + text.textContent = + 'The buffers have drifted too far from this step\'s anchor to apply it automatically. ' + + 'Use "Reset files" to return to the chapter\'s start state, or follow the hint by hand: ' + + step.explain; + } + return; + } + assists.auto += 1; + openAssist(step); + const finish = () => { + closeAssist(); + run(false); + }; + window.setTimeout(() => { + if (step.pane === 'argv') { + if (argvInput) argvInput.value = after.argv.join(' '); + finish(); + return; + } + const editor = paneEditor(step.pane); + const edit = diffRange(before[step.pane], after[step.pane]); + typewriter(editor, edit.start, edit.oldEnd, edit.insert, finish); + }, 600); +} + +if (ide && specEditor && codeEditor && terminal && runButton) { + init(); + initAssists(); +} diff --git a/src/styles/site.css b/src/styles/site.css index d7ad7b6..9295032 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1402,6 +1402,87 @@ h3:hover .heading-anchor, font-size: 0.9rem; } +/* assist spotlight + popup (inside the IDE, which clips the dim shadow) */ + +.ide-lg { + position: relative; +} + +.spotlight { + position: absolute; + inset: 0; + z-index: 5; +} + +.spotlight-hole { + position: absolute; + border-radius: 5px; + box-shadow: 0 0 0 9999px rgb(0 0 0 / 0.55); + outline: 1px solid color-mix(in srgb, var(--accent) 70%, transparent); + pointer-events: none; +} + +.assist-pop { + position: absolute; + z-index: 6; + max-width: 22rem; + padding: 0.9rem 1rem; + border: 1px solid var(--accent); + border-radius: 8px; + background: #26221f; + color: #e7e5e4; + box-shadow: var(--shadow); + font-size: 0.88rem; + line-height: 1.5; +} + +.assist-pop:focus-visible { + outline: 2px solid var(--accent); +} + +.assist-text { + margin: 0 1.25rem 0.5rem 0; +} + +.assist-doc-link { + margin: 0; + font-size: 0.8rem; +} + +.assist-doc-link a { + /* the popup sits in the always-dark IDE - fix the accent to the dark value */ + color: #f9c21d; +} + +.assist-close { + position: absolute; + top: 0.4rem; + right: 0.4rem; + border: none; + background: transparent; + color: #a8a29e; + font-size: 0.85rem; + cursor: pointer; + padding: 0.15rem 0.35rem; + border-radius: 4px; +} + +.assist-close:hover { + color: #e7e5e4; + background: rgb(255 255 255 / 0.08); +} + +.assist-actions { + display: flex; + gap: 0.6rem; + margin-left: auto; +} + +.assist-actions .btn { + padding: 0.35rem 0.9rem; + font-size: 0.85rem; +} + /* resume modal */ .resume-modal { diff --git a/src/tutorial.mjs b/src/tutorial.mjs index a40c422..5da7ba1 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -81,6 +81,12 @@ function ide(first) {
press Run to trace the project
+ + `; } @@ -160,6 +166,10 @@ ${ide(first)}

Goal: ${esc(first.goal)}

Run to check your progress.

+
+ + +
${resumeDialog()} ${fallbackChapters(verified)} From 3b2c07ea59b18082f5413978377d9eae516cc83c Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 00:00:17 +0200 Subject: [PATCH 08/34] feat: polish the tutorial - highlight overlay, mobile, a11y - editors upgrade to a transparent textarea over a scroll-synced, highlightTokens-rendered
 with identical metrics; built at
  runtime, so the no-JS page keeps plain textareas. highlightTokens
  moves into its own dependency-free module (src/highlight.mjs, shipped
  to the browser; layout.mjs re-exports it for the build)
- typewriter refreshes the overlay per chunk so assisted edits stay
  visible while typing
- step progress announces politely (aria-live); the terminal already did
- compact CTA never wraps on narrow viewports

Co-Authored-By: Claude Fable 5 
---
 build.mjs               |  1 +
 src/highlight.mjs       | 22 ++++++++++++++++++++
 src/layout.mjs          | 22 +++-----------------
 src/scripts/tutorial.js | 45 +++++++++++++++++++++++++++++++++++++++++
 src/styles/site.css     | 37 +++++++++++++++++++++++++++++++++
 src/tutorial.mjs        |  2 +-
 6 files changed, 109 insertions(+), 20 deletions(-)
 create mode 100644 src/highlight.mjs

diff --git a/build.mjs b/build.mjs
index ae633ea..6731486 100644
--- a/build.mjs
+++ b/build.mjs
@@ -239,6 +239,7 @@ cpSync(path.join(root, 'src', 'tutorial', 'chapter-utils.mjs'), path.join(tryDir
 cpSync(path.join(root, 'src', 'scripts', 'tutorial.js'), path.join(tryDir, 'tutorial.js'));
 cpSync(path.join(root, 'src', 'scripts', 'tutorial-worker.js'), path.join(tryDir, 'tutorial-worker.js'));
 cpSync(path.join(root, 'src', 'report-colors.mjs'), path.join(tryDir, 'report-colors.mjs'));
+cpSync(path.join(root, 'src', 'highlight.mjs'), path.join(tryDir, 'highlight.mjs'));
 
 // Every chapter's start state and each step's cumulative patched state runs
 // through the exact bundle the browser executes; behavioral drift in a
diff --git a/src/highlight.mjs b/src/highlight.mjs
new file mode 100644
index 0000000..6c3a07e
--- /dev/null
+++ b/src/highlight.mjs
@@ -0,0 +1,22 @@
+// Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be
+// HTML-escaped already. Generic code stays uncolored.
+//
+// Standalone and dependency-free because it is shared between the build
+// (layout.mjs re-exports it) and the browser (/try/ editor highlight overlay).
+//
+// Deliberately language-agnostic: it runs on every fenced block and codespan
+// regardless of its info string, because flashtrace tokens appear inside
+// blocks of any language (md, ts, sql, plain trace output, ...). The cost is
+// that an unrelated string shaped like an ID (foo:bar#1) in, say, a bash or
+// json block also gets colored - acceptable for these docs, where anything
+// ID-shaped in a code block is in practice a flashtrace reference.
+export function highlightTokens(escaped) {
+  return escaped.replace(
+    /(-->)|(>>)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm,
+    (m, fwd, need, id, kw) => {
+      if (fwd || need) return `${m}`;
+      if (id) return `${m}`;
+      return `${kw}`;
+    },
+  );
+}
diff --git a/src/layout.mjs b/src/layout.mjs
index 9b3a74a..6e68887 100644
--- a/src/layout.mjs
+++ b/src/layout.mjs
@@ -16,25 +16,9 @@ export function esc(s) {
     .replaceAll('"', '"');
 }
 
-// Wrap flashtrace's own syntax (IDs, [tags], >>, -->) in spans; input must be
-// HTML-escaped already. Generic code stays uncolored.
-//
-// Deliberately language-agnostic: it runs on every fenced block and codespan
-// regardless of its info string, because flashtrace tokens appear inside
-// blocks of any language (md, ts, sql, plain trace output, ...). The cost is
-// that an unrelated string shaped like an ID (foo:bar#1) in, say, a bash or
-// json block also gets colored - acceptable for these docs, where anything
-// ID-shaped in a code block is in practice a flashtrace reference.
-export function highlightTokens(escaped) {
-  return escaped.replace(
-    /(-->)|(>>)|([A-Za-z]+:[A-Za-z0-9_/.-]*#[0-9xyz]+(?:\.[0-9xyz]+){0,2})|(^ *(?:Needs|Covers|Tags):)/gm,
-    (m, fwd, need, id, kw) => {
-      if (fwd || need) return `${m}`;
-      if (id) return `${m}`;
-      return `${kw}`;
-    },
-  );
-}
+// highlightTokens lives in its own dependency-free module (the /try/ page
+// also ships it to the browser); re-exported here for the build-side callers.
+export { highlightTokens } from './highlight.mjs';
 
 const themeInit = `(function(){try{var t=localStorage.getItem('ft-theme');if(t!=='light'&&t!=='dark'){t=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.dataset.theme=t;}catch(e){document.documentElement.dataset.theme='light';}})();`;
 
diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js
index 2fbee1b..719b117 100644
--- a/src/scripts/tutorial.js
+++ b/src/scripts/tutorial.js
@@ -5,6 +5,7 @@
 // stable chapter ids - never by index. Loaded as a module; browsers without
 // module support keep the server-rendered read-only fallback.
 import { applyStep } from './chapter-utils.mjs';
+import { highlightTokens } from './highlight.mjs';
 import { colorizeReport, esc } from './report-colors.mjs';
 
 const ide = document.getElementById('ide');
@@ -82,7 +83,48 @@ let active = null; // { worker, watchdog, silent } of the run in flight
 let saveTimer = null;
 let checkTimer = null;
 
+// --- editor highlight overlay ------------------------------------------------
+// A transparent textarea over a highlightTokens-rendered 
 with identical
+// metrics, scroll-synced. Built at runtime so the no-JS page keeps plain
+// server-rendered textareas.
+
+const highlightRenderers = [];
+
+function setupHighlight(editor) {
+  const wrap = document.createElement('div');
+  wrap.className = 'editor-wrap';
+  const pre = document.createElement('pre');
+  pre.className = 'editor-highlight';
+  pre.setAttribute('aria-hidden', 'true');
+  const code = document.createElement('code');
+  pre.appendChild(code);
+  editor.parentNode.insertBefore(wrap, editor);
+  wrap.appendChild(pre);
+  wrap.appendChild(editor);
+  editor.classList.add('editor-overlaid');
+  const sync = () => {
+    pre.scrollTop = editor.scrollTop;
+    pre.scrollLeft = editor.scrollLeft;
+  };
+  const render = () => {
+    // trailing newline keeps the pre's last line height in step with the textarea
+    code.innerHTML = highlightTokens(esc(editor.value)) + '\n';
+    sync();
+  };
+  editor.addEventListener('input', render);
+  editor.addEventListener('scroll', sync);
+  highlightRenderers.push(render);
+  render();
+}
+
+// call after every programmatic .value assignment (seeding, resume, reset)
+function refreshHighlights() {
+  for (const render of highlightRenderers) render();
+}
+
 function init() {
+  setupHighlight(specEditor);
+  setupHighlight(codeEditor);
   runButton.addEventListener('click', () => run(false));
   for (const editor of [specEditor, codeEditor]) {
     editor.addEventListener('keydown', (event) => {
@@ -167,6 +209,7 @@ function seedBuffers() {
   specEditor.value = current.spec.body;
   codeEditor.value = current.variant.body;
   if (argvInput) argvInput.value = current.argv.join(' ');
+  refreshHighlights();
 }
 
 function openChapter(id) {
@@ -230,6 +273,7 @@ function offerResume(chapter, stored) {
     specEditor.value = String(stored.spec);
     codeEditor.value = String(stored.code);
     if (argvInput) argvInput.value = chapter.argv.join(' ');
+    refreshHighlights();
     scheduleSilentRun();
   };
   if (!modal || typeof modal.showModal !== 'function') {
@@ -633,6 +677,7 @@ function typewriter(editor, start, oldEnd, insert, onDone) {
   const timer = setInterval(() => {
     const piece = insert.slice(done, done + chunk);
     editor.setRangeText(piece, pos, pos, 'end');
+    refreshHighlights(); // the overlay shows the text; the textarea is transparent
     pos += piece.length;
     done += chunk;
     if (done >= insert.length) {
diff --git a/src/styles/site.css b/src/styles/site.css
index 9295032..7f1d1c1 100644
--- a/src/styles/site.css
+++ b/src/styles/site.css
@@ -1243,6 +1243,7 @@ h3:hover .heading-anchor,
   padding: 0.3rem 0.85rem;
   border-radius: 999px;
   font-size: 0.85rem;
+  white-space: nowrap;
 }
 
 .btn-cta[aria-current='page'] {
@@ -1586,6 +1587,42 @@ h3:hover .heading-anchor,
   box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent);
 }
 
+/* highlight overlay: transparent textarea over an identical-metrics 
;
+   built by tutorial.js, so the no-JS page keeps plain visible textareas */
+
+.editor-wrap {
+  position: relative;
+  border-top: 1px solid #33302c;
+}
+
+.editor-wrap .editor {
+  position: relative;
+  z-index: 1;
+  border-top: none;
+  background: transparent;
+  color: transparent;
+}
+
+.editor-overlaid::selection {
+  background: rgb(86 156 214 / 0.35);
+  color: transparent;
+}
+
+.editor-highlight {
+  position: absolute;
+  inset: 0;
+  margin: 0;
+  padding: 0.9rem 1rem;
+  overflow: hidden;
+  background: var(--code-bg);
+  color: var(--code-text);
+  font-family: ui-monospace, 'Cascadia Code', 'Segoe UI Mono', Menlo, Consolas, monospace;
+  font-size: 0.85rem;
+  line-height: 1.6;
+  white-space: pre;
+  tab-size: 2;
+}
+
 .ide-lg .term-lg {
   min-height: 11rem;
   max-height: 22rem;
diff --git a/src/tutorial.mjs b/src/tutorial.mjs
index 5da7ba1..2e9d31c 100644
--- a/src/tutorial.mjs
+++ b/src/tutorial.mjs
@@ -165,7 +165,7 @@ ${chapterRail()}
 ${ide(first)}
 

Goal: ${esc(first.goal)}

-

Run to check your progress.

+

Run to check your progress.

From 178f9eaa8f497025dea6331afe835f13a6aa3625 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 03:17:34 +0200 Subject: [PATCH 09/34] style: cleanup some button stylings --- src/layout.mjs | 2 +- src/styles/site.css | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/layout.mjs b/src/layout.mjs index 6e68887..7f60ba8 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -43,7 +43,7 @@ function topBar({ version, active, withSidebar }) { ${link('/docs/', 'Docs', 'docs')}
- Try it + Try it out ${githubIcon}
`; } +// Shown when someone lands on #editor without any completed chapter: offer +// the guided route before leaving a newcomer alone with two empty files. +function welcomeDialog() { + return ` +

First time with flashtrace?

+

This is the free editor - a blank project running the real tool. The chapters on the left teach flashtrace hands-on, a few minutes each.

+
+ + +
+
`; +} + function miniWindow(name, inner, term = false) { return `
${dots}${esc(name)}
${inner}
`; } @@ -177,6 +190,7 @@ ${ide(first)} ${resumeDialog()} +${welcomeDialog()} ${fallbackChapters(verified)} From 8b67d5bd735fc925649878f1f75239faf272669b Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 03:30:03 +0200 Subject: [PATCH 12/34] feat: rename /try/ to /learn/ and add a Learn nav item The tutorial page moves to /learn/ (never shipped under /try/, so no redirect needed) and gets a permanent spot in the top-bar nav between Home and Docs. The 'Try it out' CTA keeps pointing at its free editor. Co-Authored-By: Claude Fable 5 --- build.mjs | 32 ++++++++++++++++---------------- src/examples.mjs | 2 +- src/highlight.mjs | 2 +- src/layout.mjs | 5 +++-- src/report-colors.mjs | 2 +- src/scripts/tutorial-worker.js | 2 +- src/scripts/tutorial.js | 2 +- src/styles/site.css | 2 +- src/tutorial.mjs | 6 +++--- src/tutorial/chapter-utils.mjs | 4 ++-- src/tutorial/verify.mjs | 2 +- 11 files changed, 31 insertions(+), 30 deletions(-) diff --git a/build.mjs b/build.mjs index 6731486..80a7679 100644 --- a/build.mjs +++ b/build.mjs @@ -177,14 +177,14 @@ function renderDoc(page) { }); } -// --- /try/ runner: the release bundle, node builtins rewritten to shims ----- +// --- /learn/ runner: the release bundle, node builtins rewritten to shims --- // CI checks out the full tool repo, so dist/flashtrace.mjs sits next to the // docs the build already consumes. Locally the clone provides it the same way. const bundlePath = path.join(docsDir, '..', 'dist', 'flashtrace.mjs'); if (!existsSync(bundlePath)) { console.error( - `error: flashtrace bundle not found at ${bundlePath} - the /try/ page runs the release build in the browser and needs it. ` + + `error: flashtrace bundle not found at ${bundlePath} - the /learn/ page runs the release build in the browser and needs it. ` + 'Make sure the tool repo checkout includes dist/.', ); process.exit(1); @@ -192,7 +192,7 @@ if (!existsSync(bundlePath)) { // The exact builtin set the shims in src/tutorial/shims/ cover. A release // that imports anything else (or drops one) must fail the build here, never -// silently ship a broken /try/ page. +// silently ship a broken /learn/ page. const SHIMMED_BUILTINS = ['child_process', 'fs', 'path', 'process', 'url']; function rewriteBundle(source) { @@ -204,7 +204,7 @@ function rewriteBundle(source) { const actual = [...found].sort(); if (actual.join(',') !== SHIMMED_BUILTINS.join(',')) { console.error( - `error: flashtrace.mjs imports node builtins [${actual.join(', ')}] but the /try/ shims cover exactly [${SHIMMED_BUILTINS.join(', ')}].\n` + + `error: flashtrace.mjs imports node builtins [${actual.join(', ')}] but the /learn/ shims cover exactly [${SHIMMED_BUILTINS.join(', ')}].\n` + 'Align src/tutorial/shims/ (and this assertion) with the release bundle.', ); process.exit(1); @@ -231,32 +231,32 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } -const tryDir = path.join(dist, 'try'); -mkdirSync(tryDir, { recursive: true }); -writeFileSync(path.join(tryDir, 'flashtrace.mjs'), rewriteBundle(readFileSync(bundlePath, 'utf8'))); -cpSync(path.join(root, 'src', 'tutorial', 'shims'), path.join(tryDir, 'shims'), { recursive: true }); -cpSync(path.join(root, 'src', 'tutorial', 'chapter-utils.mjs'), path.join(tryDir, 'chapter-utils.mjs')); -cpSync(path.join(root, 'src', 'scripts', 'tutorial.js'), path.join(tryDir, 'tutorial.js')); -cpSync(path.join(root, 'src', 'scripts', 'tutorial-worker.js'), path.join(tryDir, 'tutorial-worker.js')); -cpSync(path.join(root, 'src', 'report-colors.mjs'), path.join(tryDir, 'report-colors.mjs')); -cpSync(path.join(root, 'src', 'highlight.mjs'), path.join(tryDir, 'highlight.mjs')); +const learnDir = path.join(dist, 'learn'); +mkdirSync(learnDir, { recursive: true }); +writeFileSync(path.join(learnDir, 'flashtrace.mjs'), rewriteBundle(readFileSync(bundlePath, 'utf8'))); +cpSync(path.join(root, 'src', 'tutorial', 'shims'), path.join(learnDir, 'shims'), { recursive: true }); +cpSync(path.join(root, 'src', 'tutorial', 'chapter-utils.mjs'), path.join(learnDir, 'chapter-utils.mjs')); +cpSync(path.join(root, 'src', 'scripts', 'tutorial.js'), path.join(learnDir, 'tutorial.js')); +cpSync(path.join(root, 'src', 'scripts', 'tutorial-worker.js'), path.join(learnDir, 'tutorial-worker.js')); +cpSync(path.join(root, 'src', 'report-colors.mjs'), path.join(learnDir, 'report-colors.mjs')); +cpSync(path.join(root, 'src', 'highlight.mjs'), path.join(learnDir, 'highlight.mjs')); // Every chapter's start state and each step's cumulative patched state runs // through the exact bundle the browser executes; behavioral drift in a // flashtrace release fails the deploy here instead of shipping a broken lesson. let verified; try { - verified = await verifyChapters(chapters, path.join(tryDir, 'flashtrace.mjs'), 'js'); + verified = await verifyChapters(chapters, path.join(learnDir, 'flashtrace.mjs'), 'js'); } catch (err) { console.error(`error: tutorial chapter verification failed.\n${err.message}`); process.exit(1); } -writeFileSync(path.join(tryDir, 'index.html'), renderTutorial({ version, verified })); +writeFileSync(path.join(learnDir, 'index.html'), renderTutorial({ version, verified })); const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), - '/try/', + '/learn/', '/license/', '/impressum/', ]; diff --git a/src/examples.mjs b/src/examples.mjs index c1fdad1..321cd9e 100644 --- a/src/examples.mjs +++ b/src/examples.mjs @@ -1,6 +1,6 @@ // Curated interactive examples: real inputs and terminal output captured from // actual `flashtrace` runs (v0.7.0). The landing page ships these pre-computed -// captures; the /try/ page runs the same CLI live in the browser. +// captures; the /learn/ page runs the same CLI live in the browser. export const heroTerminal = { command: 'npx flashtrace', diff --git a/src/highlight.mjs b/src/highlight.mjs index 6c3a07e..79c02f1 100644 --- a/src/highlight.mjs +++ b/src/highlight.mjs @@ -2,7 +2,7 @@ // HTML-escaped already. Generic code stays uncolored. // // Standalone and dependency-free because it is shared between the build -// (layout.mjs re-exports it) and the browser (/try/ editor highlight overlay). +// (layout.mjs re-exports it) and the browser (/learn/ editor highlight overlay). // // Deliberately language-agnostic: it runs on every fenced block and codespan // regardless of its info string, because flashtrace tokens appear inside diff --git a/src/layout.mjs b/src/layout.mjs index fe96b9c..332bbb1 100644 --- a/src/layout.mjs +++ b/src/layout.mjs @@ -16,7 +16,7 @@ export function esc(s) { .replaceAll('"', '"'); } -// highlightTokens lives in its own dependency-free module (the /try/ page +// highlightTokens lives in its own dependency-free module (the /learn/ page // also ships it to the browser); re-exported here for the build-side callers. export { highlightTokens } from './highlight.mjs'; @@ -40,10 +40,11 @@ function topBar({ version, active, withSidebar }) { ${esc(version)}
- Try it out + Try it out ${githubIcon}
`; @@ -201,7 +201,7 @@ ${fallbackChapters(verified)} title: 'Try flashtrace - interactive tutorial', description: 'Learn flashtrace hands-on: guided chapters in an editable IDE that runs the real release build in your browser.', - path: '/try/', + path: '/learn/', version, active: 'tutorial', body, diff --git a/src/tutorial/chapter-utils.mjs b/src/tutorial/chapter-utils.mjs index 4730e53..ae4ea11 100644 --- a/src/tutorial/chapter-utils.mjs +++ b/src/tutorial/chapter-utils.mjs @@ -1,6 +1,6 @@ -// Pure helpers shared by the tutorial worker (browser), the /try/ page script +// Pure helpers shared by the tutorial worker (browser), the /learn/ page script // (browser) and the build-time chapter verification (Node). No imports, no -// side effects - this module is copied verbatim into dist/try/. +// side effects - this module is copied verbatim into dist/learn/. // Mirrors the CLI's -t/--tags parsing closely enough for the structured // analysis to match what a run with the same argv reports. diff --git a/src/tutorial/verify.mjs b/src/tutorial/verify.mjs index 7abb85d..cbf6135 100644 --- a/src/tutorial/verify.mjs +++ b/src/tutorial/verify.mjs @@ -5,7 +5,7 @@ // relies on fails the build here instead of shipping a broken lesson. // // Also computes each chapter's content-hash identity (`rev`) and the -// JSON-safe payload embedded into the /try/ page. +// JSON-safe payload embedded into the /learn/ page. import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; From 1f1770c88592371c7b7d88311e2ea28c73893f14 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 03:33:06 +0200 Subject: [PATCH 13/34] fix: let [hidden] win over display:flex on the assist bar The free-editor mode hides #assist-bar via the hidden attribute, but the author-level display:flex overrode the UA rule and the bar stayed visible. Caught by the browser e2e smoke. Co-Authored-By: Claude Fable 5 --- src/styles/site.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/styles/site.css b/src/styles/site.css index c8fe222..e7a2d81 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1391,6 +1391,11 @@ h3:hover .heading-anchor, margin-top: 0.9rem; } +/* display: flex would beat the UA [hidden] rule (free-editor mode hides the bar) */ +.assist-bar[hidden] { + display: none; +} + .assist-goal { margin: 0; color: var(--text); From 929adb842a9513e2a4f740e7fb966f66e1b5ca3f Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 12:43:10 +0200 Subject: [PATCH 14/34] fix: caret drift in the /learn/ editor overlays The base stylesheet applies `font-size: 0.9em` to every code element, so the text in the highlight overlay rendered 10% smaller than the transparent textarea above it. The per-character width difference summed up along a line, drifting the caret right of the visible text. Inherit the overlay pre's font on its inner code element instead. Co-Authored-By: Claude Fable 5 --- src/styles/site.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/styles/site.css b/src/styles/site.css index e7a2d81..c2a183e 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1629,6 +1629,12 @@ h3:hover .heading-anchor, tab-size: 2; } +/* the base `code { font-size: 0.9em }` shrinks the highlight text below the + textarea's, desyncing glyph widths and drifting the caret rightwards */ +.editor-highlight code { + font: inherit; +} + .ide-lg .term-lg { min-height: 11rem; max-height: 22rem; From ee503ee879535e0a541dec1d310fd171e1719c21 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 12:43:51 +0200 Subject: [PATCH 15/34] style: replace stock scrollbars with slim themed thumbs Trackless rounded thumbs via ::-webkit-scrollbar with new --scroll-thumb tokens per theme; the chapter/docs rails and toc get a slimmer 8px bar. The always-dark IDE mocks override the tokens locally so their panes keep dark thumbs in light mode. Firefox falls back to scrollbar-color/-width behind @supports, excluded on Blink where the standard properties would disable the pseudo-element styling. Co-Authored-By: Claude Fable 5 --- src/styles/site.css | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/styles/site.css b/src/styles/site.css index c2a183e..c877a86 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -21,6 +21,8 @@ --ok: #15803d; --no: #b91c1c; --info: #0e7490; + --scroll-thumb: #d6d3d1; + --scroll-thumb-hover: #a8a29e; --shadow: 0 1px 3px rgb(0 0 0 / 0.08), 0 8px 24px rgb(0 0 0 / 0.06); --topbar-h: 3.5rem; color-scheme: light; @@ -45,6 +47,8 @@ --ok: #3fb950; --no: #f85149; --info: #56b6c2; + --scroll-thumb: #423c37; + --scroll-thumb-hover: #5a534d; --shadow: 0 1px 3px rgb(0 0 0 / 0.5), 0 8px 24px rgb(0 0 0 / 0.35); color-scheme: dark; } @@ -70,6 +74,8 @@ --ok: #3fb950; --no: #f85149; --info: #56b6c2; + --scroll-thumb: #423c37; + --scroll-thumb-hover: #5a534d; color-scheme: dark; } } @@ -173,6 +179,56 @@ button { } } +/* ---------- scrollbars ---------- */ + +/* WebKit/Blink: trackless rounded thumbs; the transparent border + + padding-box clip keeps a gutter so the thumb floats off the edge */ +::-webkit-scrollbar { + width: 12px; + height: 12px; +} + +::-webkit-scrollbar-track, +::-webkit-scrollbar-corner { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scroll-thumb); + border: 3px solid transparent; + border-radius: 999px; + background-clip: padding-box; +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--scroll-thumb-hover); +} + +/* slimmer bar for inner scroll areas (chapter/docs rails, toc) */ +.sidebar::-webkit-scrollbar, +.toc::-webkit-scrollbar { + width: 8px; +} + +.sidebar::-webkit-scrollbar-thumb, +.toc::-webkit-scrollbar-thumb { + border-width: 2px; +} + +/* Firefox has no ::-webkit-scrollbar - use the standard properties there. + Blink is excluded on purpose: setting scrollbar-color would replace the + nicer pseudo-element styling above with its plain square thumbs. */ +@supports not selector(::-webkit-scrollbar) { + * { + scrollbar-color: var(--scroll-thumb) transparent; + } + + .sidebar, + .toc { + scrollbar-width: thin; + } +} + /* ---------- top bar ---------- */ .topbar { @@ -417,6 +473,8 @@ button { --tk-id: #f9c21d; --tk-kw: #e8964a; --tk-arrow: #56b6c2; + --scroll-thumb: #3f3c3a; + --scroll-thumb-hover: #57534e; border-radius: 10px; overflow: hidden; border: 1px solid #33302c; From a12aa69175d18f8f7ca3aece882b81f91208cd9e Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 13:59:57 +0200 Subject: [PATCH 16/34] feat: blur finished chapters behind a completion card A completed chapter now shows its solved files blurred under a check card stating when and with how many assists it was finished, plus a "Reset chapter" button that forgets the completion and reseeds the start files. Completion keeps the solved buffers (instead of deleting them) so revisits restore them silently under the overlay - the "Work in progress" resume modal no longer appears on finished chapters. Everything inside the IDE goes inert while the overlay is up. Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 97 +++++++++++++++++++++++++++++++++-------- src/styles/site.css | 70 +++++++++++++++++++++++++++++ src/tutorial.mjs | 8 ++++ 3 files changed, 156 insertions(+), 19 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 74ffec8..74bcc55 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -202,6 +202,21 @@ function init() { }); } + const doneReset = document.getElementById('done-reset'); + if (doneReset) { + doneReset.addEventListener('click', () => { + if (!current || current.free) return; + if (!window.confirm("Reset this chapter? Its completion and solved files are forgotten and the start files restored.")) return; + deleteProgressEntry(current.id); + deleteBufferEntry(current.id); + setCompletedOverlay(null); + seedBuffers(); + setText('step-progress', 'Run to check your progress.'); + renderRailMarks(); + scheduleSilentRun(); + }); + } + window.addEventListener('hashchange', () => { const id = window.location.hash.slice(1); if (current && id && id !== current.id) openChapter(id); @@ -304,23 +319,34 @@ function openChapter(id) { if (btn) btn.disabled = true; // re-enabled once the first analysis lands } - // buffers: stored work-in-progress -> resume/start-fresh modal; the free - // editor is a scratchpad with no meaningful start state, so it restores - // silently ("Reset files" clears it) + // buffers: a finished chapter silently restores its solved files behind the + // completed overlay (no resume modal); stored work-in-progress elsewhere -> + // resume/start-fresh modal; the free editor is a scratchpad with no + // meaningful start state, so it restores silently ("Reset files" clears it) + const entry = chapter.free ? null : progressEntry(id); const stored = loadStore(BUFFERS_KEY).chapters[id]; - if (!stored || (stored.spec === chapter.spec.body && stored.code === chapter.variant.body)) { + setCompletedOverlay(entry); + if (entry) { + if (stored) restoreStored(chapter, stored); + else seedBuffers(); // completions before buffers were kept: show the start files + setText('step-progress', chapterCompleteText()); + } else if (!stored || (stored.spec === chapter.spec.body && stored.code === chapter.variant.body)) { seedBuffers(); scheduleSilentRun(); } else if (chapter.free) { - specEditor.value = String(stored.spec); - codeEditor.value = String(stored.code); - if (argvInput) argvInput.value = chapter.argv.join(' '); - refreshHighlights(); + restoreStored(chapter, stored); } else { offerResume(chapter, stored); } } +function restoreStored(chapter, stored) { + specEditor.value = String(stored.spec); + codeEditor.value = String(stored.code); + if (argvInput) argvInput.value = chapter.argv.join(' '); + refreshHighlights(); +} + function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; @@ -329,10 +355,7 @@ function setText(id, text) { function offerResume(chapter, stored) { const modal = document.getElementById('resume-modal'); const resume = () => { - specEditor.value = String(stored.spec); - codeEditor.value = String(stored.code); - if (argvInput) argvInput.value = chapter.argv.join(' '); - refreshHighlights(); + restoreStored(chapter, stored); scheduleSilentRun(); }; if (!modal || typeof modal.showModal !== 'function') { @@ -403,16 +426,51 @@ function deleteBufferEntry(id) { function completeChapter(chapter) { const store = loadStore(PROGRESS_KEY); if (store.chapters[chapter.id]) return; - store.chapters[chapter.id] = { + const entry = { title: chapter.title, rev: chapter.rev, lang: data.lang, completedAt: new Date().toISOString(), assists: { help: assists.help, auto: assists.auto }, }; + store.chapters[chapter.id] = entry; saveStore(PROGRESS_KEY, store); - deleteBufferEntry(chapter.id); + saveBuffers(); // keep the solved files - revisits restore them under the overlay + clearTimeout(checkTimer); renderRailMarks(); + setCompletedOverlay(entry); +} + +function deleteProgressEntry(id) { + const store = loadStore(PROGRESS_KEY); + delete store.chapters[id]; + saveStore(PROGRESS_KEY, store); +} + +// --- completed-chapter overlay ----------------------------------------------- +// A finished chapter shows its solved files blurred behind a check card saying +// when and how it was completed; everything else inside the IDE goes inert +// until "Reset chapter". Pass null to hide the overlay again. + +function setCompletedOverlay(entry) { + const overlay = document.getElementById('done-overlay'); + if (!overlay) return; + overlay.hidden = !entry; + for (const child of ide.children) { + if (child !== overlay) child.inert = Boolean(entry); + } + if (entry) setText('done-text', completionStatement(entry)); +} + +function completionStatement(entry) { + const help = entry.assists ? entry.assists.help : 0; + const auto = entry.assists ? entry.assists.auto : 0; + const how = []; + if (help) how.push(help + (help === 1 ? ' hint' : ' hints')); + if (auto) how.push(auto + (auto === 1 ? ' auto-solved step' : ' auto-solved steps')); + return ( + 'Completed ' + relativeTime(entry.completedAt) + (how.length ? ' with ' + how.join(' and ') : ' without assists') + '.' + ); } function renderRailMarks() { @@ -432,6 +490,11 @@ function renderRailMarks() { if (reset) reset.hidden = !any; } +function chapterCompleteText() { + const next = data.chapters[currentIndex + 1]; + return 'Chapter complete ✓' + (next ? ' - up next: ' + (currentIndex + 2) + ' · ' + next.title : ' - that was the last one!'); +} + // --- step evaluation --------------------------------------------------------- function safeCheck(fn, r) { @@ -462,11 +525,7 @@ function updateStepUi(fromRealRun) { } const completed = Boolean(progressEntry(current.id)); if (solved && (completed || fromRealRun)) { - const next = data.chapters[currentIndex + 1]; - setText( - 'step-progress', - 'Chapter complete ✓' + (next ? ' - up next: ' + (currentIndex + 2) + ' · ' + next.title : ' - that was the last one!'), - ); + setText('step-progress', chapterCompleteText()); } else if (firstFailing >= steps.length) { setText('step-progress', solved ? 'All steps done - Run to finish the chapter.' : 'All steps done.'); } else { diff --git a/src/styles/site.css b/src/styles/site.css index c877a86..558ee02 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1547,6 +1547,76 @@ h3:hover .heading-anchor, font-size: 0.85rem; } +/* completed-chapter overlay: the solved IDE stays visible but blurred */ + +.done-overlay { + position: absolute; + inset: 0; + z-index: 7; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgb(20 18 16 / 0.35); + backdrop-filter: blur(5px); + -webkit-backdrop-filter: blur(5px); +} + +/* display: flex would beat the UA [hidden] rule */ +.done-overlay[hidden] { + display: none; +} + +.done-card { + max-width: 24rem; + padding: 1.6rem 2rem; + border: 1px solid #44403c; + border-radius: 10px; + background: #26221f; + color: #e7e5e4; + box-shadow: var(--shadow); + text-align: center; +} + +.done-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.6rem; + height: 2.6rem; + border-radius: 50%; + background: #3fb950; + color: #1e1e1e; + font-size: 1.5rem; + font-weight: 700; +} + +.done-title { + margin: 0.75rem 0 0.25rem; + font-size: 1.1rem; + font-weight: 650; +} + +.done-text { + margin: 0 0 1rem; + font-size: 0.9rem; + color: #a8a29e; +} + +/* the card sits in the always-dark IDE - fix the button to dark values */ +.done-card .btn-secondary { + border-color: #57534e; + background: #2a2724; + color: #d4d4d4; + font-size: 0.85rem; + padding: 0.45rem 1rem; +} + +.done-card .btn-secondary:hover { + border-color: var(--accent); + background: #2a2724; +} + /* resume modal */ .resume-modal { diff --git a/src/tutorial.mjs b/src/tutorial.mjs index e435de5..4d8ecfc 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -86,6 +86,14 @@ function ide(first) {
press Run to trace the project
+ `; } +// Step accordion between the chapter intro and the IDE: one
per +// step, the current one unfolded, finished ones greyed out with a check. +// Server-renders chapter 1's start state (step 1 current); tutorial.js +// re-renders the items on every chapter switch and re-syncs the open/done +// state after every run with the same structure. +function stepListItems(steps, currentIndex) { + return steps + .map((step, i) => { + let state = ''; + if (i < currentIndex) state = ' class="is-done"'; + else if (i === currentIndex) state = ' class="is-current"'; + return ` + ${i + 1} · ${esc(step.title)} +

${esc(step.explain)}

+
`; + }) + .join('\n'); +} + +function stepList(first) { + return `
+
    +${stepListItems(first.steps, 0)} +
+
`; +} + function resumeDialog() { return `

Work in progress

@@ -151,6 +178,9 @@ function fallbackChapters(verified) { return `

${index + 1} · ${esc(chapter.title)}

${esc(chapter.intro)}

+
    + ${chapter.steps.map((step) => `
  1. ${esc(step.title)}. ${esc(step.explain)}
  2. `).join('\n ')} +
${miniWindow(chapter.spec.file, highlightTokens(esc(chapter.spec.body)))} ${miniWindow(variant.file, highlightTokens(esc(variant.body)))} @@ -165,7 +195,7 @@ function fallbackChapters(verified) { }) .join('\n'); return ``; @@ -188,6 +218,7 @@ ${chapterRail()}

${esc(first.intro)}

Read more: ${docsLinks(first)}

+${stepList(first)} ${ide(first)}

Goal: ${esc(first.goal)}

diff --git a/src/tutorial/chapters.mjs b/src/tutorial/chapters.mjs index 8ac7dd4..2ab7428 100644 --- a/src/tutorial/chapters.mjs +++ b/src/tutorial/chapters.mjs @@ -55,6 +55,7 @@ holding nothing but an ID in backticks.`, }, steps: [ { + title: 'Add a heading', explain: 'Start with a heading at the end of the spec: "## Login requirement". It is not an item yet - but the heading directly above an ID line becomes that item\'s title.', pane: 'spec', @@ -63,6 +64,7 @@ holding nothing but an ID in backticks.`, check: (r) => /^## Login requirement$/m.test(r.spec), }, { + title: 'Lay down the ID line', explain: 'Now the item itself: a line containing nothing but `req:auth/login#1` in backticks. Run it - the summary jumps from 0 items to 1, titled by your heading.', pane: 'spec', @@ -71,6 +73,7 @@ holding nothing but an ID in backticks.`, check: (r) => r.items.some((i) => i.id === 'req:auth/login#1' && i.title === 'Login requirement'), }, { + title: 'Describe the item', explain: 'Give the item a description: the paragraph right below the ID line, up to the next blank line. Everything after that stays informative prose.', pane: 'spec', @@ -129,6 +132,7 @@ Users must be able to log in with email and password.`, }, steps: [ { + title: 'Demand an implementation', explain: 'Add "Needs: impl:auth/login#1" below the description. Run it: the requirement turns defective - uncovered - because nothing defines that ID yet. That red is the whole point of tracing.', pane: 'spec', @@ -137,6 +141,7 @@ Users must be able to log in with email and password.`, check: (r) => r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.includes('impl:auth/login#1')), }, { + title: 'Cover it from the code', explain: 'Cover it from the code: put the comment "// [impl:auth/login#1]" directly above the login function. A tag in a comment defines the item that satisfies the need.', pane: 'code', @@ -199,6 +204,7 @@ export function endAllSessions(user) { }, steps: [ { + title: 'Fix the typo in the code tag', explain: 'The report pairs an "uncovered: needs impl:auth/logout#1" with an "unwanted: no item needs impl:auth/logut#1" - spot the missing letter. Fix the typo in the code tag and both blocks disappear together.', pane: 'code', @@ -208,6 +214,7 @@ export function endAllSessions(user) { r.items.some((i) => i.id === 'impl:auth/logout#1') && !r.items.some((i) => i.id === 'impl:auth/logut#1'), }, { + title: 'Extend the Needs list', explain: 'impl:auth/session#1 is still unwanted: it exists, but no item needs it. Coverage nobody asked for is a defect too. Extend the Needs list so the requirement demands it.', pane: 'spec', @@ -271,6 +278,7 @@ Covers: feat:auth#2`, }, steps: [ { + title: 'Fix the orphaned Covers', explain: 'The requirement covers feat:auth#2 - which does not exist. The report even hints that feat:auth exists at revision 1. An orphaned Covers points into the void; fix the revision.', pane: 'spec', @@ -282,6 +290,7 @@ Covers: feat:auth#2`, }, }, { + title: 'Close the loop', explain: 'Still not ok: now the Covers is unwanted, because feat:auth#1 never asked for it. A Covers entry is only valid when the target lists the coverer in its own Needs. Close the loop on the feature item.', pane: 'spec', @@ -339,6 +348,7 @@ export function login(email, password, oneTimeCode) { }, steps: [ { + title: 'Chase the revision', explain: 'The report hints: "revision mismatch: existing revision(s) of impl:auth/login: 2.1". Bump the need to #2 and run again - still uncovered! 2 and 2.1 are different identities; matching never loosens by itself.', pane: 'spec', @@ -353,6 +363,7 @@ export function login(email, password, oneTimeCode) { ), }, { + title: 'Opt into a wildcard', explain: 'When you genuinely mean "any 2.x rework", opt in explicitly with a wildcard: impl:auth/login#2.x accepts 2.0, 2.1, 2.99 - but never 2 or 2.1.3, since a wildcard keeps the layer count.', pane: 'spec', @@ -411,6 +422,7 @@ export function login(email, password) { }, steps: [ { + title: 'Write the test', explain: 'The demand tag makes the implementation uncovered, which leaves the requirement at ~ shallow-covered: its own need is met, but the chain below is broken. Write the test - then run it. Still not ok: code alone means nothing to the tracer.', pane: 'code', @@ -419,6 +431,7 @@ export function login(email, password) { check: (r) => /test\(/.test(r.code), }, { + title: 'Tag the test', explain: 'Only the tag counts: put "// [utest:auth/login#1]" above the test. It defines the demanded item, the chain closes, and every mark flips to ✔ deep-covered.', pane: 'code', @@ -478,6 +491,7 @@ export function openSession(token) { }, steps: [ { + title: 'Delegate to the design', explain: 'There is no impl:login#1 and there never will be - the auth design owns the details. Replace the Needs line with the forwarding tag [req:login#1 --> dsn:auth#2]: the requirement now follows the design\'s coverage, shown as a → edge in the report.', pane: 'spec', @@ -539,6 +553,7 @@ export function login(email, password) { }, steps: [ { + title: 'Scope the run with -t', explain: 'Add "-t auth" to the command line in the terminal bar: only Markdown items tagged auth are imported, the reporting item stays out, and this slice traces clean. (Add "_" to a -t list to also include untagged items.)', pane: 'argv', @@ -639,6 +654,7 @@ test('login opens a session', () => { }, steps: [ { + title: 'Drop the stray demand', explain: 'Start with the problem: a [>>...] demand tag with no item tag anywhere above it is an error - it has nothing to attach to. This stray TODO block has to go.', pane: 'code', @@ -647,6 +663,7 @@ test('login opens a session', () => { check: (r) => r.problems.length === 0, }, { + title: 'Fix the sessions revision', explain: 'The session-limit requirement needs impl:auth/sessions#1, but the code tag says #2 - and #2 is unwanted on top. The spec is right here; fix the code tag\'s revision.', pane: 'code', @@ -655,6 +672,7 @@ test('login opens a session', () => { check: (r) => r.items.some((i) => i.id === 'impl:auth/sessions#1'), }, { + title: 'Repair the Covers entry', explain: 'An old friend: the Covers entry names feat:auth#2, but the feature lives at revision 1.', pane: 'spec', anchor: 'coversLine', @@ -662,6 +680,7 @@ test('login opens a session', () => { check: (r) => r.items.every((i) => i.defects.every((d) => !d.startsWith('orphaned'))), }, { + title: 'Delete the duplicate item', explain: 'req:auth/session-limit#1 is defined twice - every full ID must be unique. Delete the leftover copy at the bottom of the spec.', pane: 'spec', @@ -670,6 +689,7 @@ test('login opens a session', () => { check: (r) => r.items.every((i) => i.defects.every((d) => !d.startsWith('duplicate'))), }, { + title: 'Write the login test', explain: 'One uncovered need left: the login test. You wrote this one in the deep-coverage chapter.', pane: 'code', anchor: null, @@ -733,6 +753,7 @@ test('login opens a session', () => { }, steps: [ { + title: 'Bullet the Needs list', explain: 'Rewrite the inline Needs list as a bullet list: "Needs:" on its own line, one "- id" per line below it. Run it - the trace is unchanged; both styles mean the same (just never mix them within one keyword).', pane: 'spec', @@ -743,6 +764,7 @@ test('login opens a session', () => { r.items.some((i) => i.id === 'req:auth/login#1' && i.needs.length === 2), }, { + title: 'Add a keyword table', explain: 'Add the feature as a table-driven item: a column headed "Needs" (no colon) contributes each row\'s cell as one entry, and the "Tags" column tags the item - here with auth, ready for a -t run.', pane: 'spec', @@ -808,6 +830,7 @@ test('login opens a session', () => { }, steps: [ { + title: 'Pin the demand explicitly', explain: 'Under utest:auth/login#1 the -v report says "wanted by impl:auth/audit#1" - the audit tag stole the demand, so the *audit* code claims the login test. Replace the implicit tag with the explicit form [impl:auth/login#1 >> utest:auth/login#1]; it stays attached no matter what is inserted above.', pane: 'code', diff --git a/src/tutorial/verify.mjs b/src/tutorial/verify.mjs index cbf6135..d3d9a9d 100644 --- a/src/tutorial/verify.mjs +++ b/src/tutorial/verify.mjs @@ -44,6 +44,7 @@ export function chapterPayload(chapter, lang) { spec: chapter.spec, variant: { lang, ...chapter.variants[lang] }, steps: chapter.steps.map((step) => ({ + title: step.title, explain: step.explain, pane: step.pane, anchor: step.anchor, From 08c57aad9fb92011fad96f462ad7d5a086ab9f56 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 14:27:04 +0200 Subject: [PATCH 18/34] feat: add and delete file tabs in the /learn/ editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each pane gets a tab strip over its own files: click to switch, ✕ to delete behind a confirmation dialog, + to create via a name + type dialog. The type field autocompletes from datalists whose code list is parsed out of docs/code-tags.md at build time; unsupported extensions block creation and offer the tool's issue tracker, markdown is routed to the spec pane and code the other way. Chapters can set 'locked: true' (now on all four Foundations chapters) to forbid file management - tabs show padlocks and a disabled +, both with explanatory tooltips. All files of both panes feed every run, and checks see r.spec/r.code as the pane-joined text plus the run's file map as r.files, so steps stay satisfiable when work spans added files. Assists patch the chapter's seeded files by name and leave user tabs alone. Buffers persist per pane with the active tab; legacy two-string entries still restore. Co-Authored-By: Claude Fable 5 --- build.mjs | 19 +- src/scripts/tutorial.js | 375 ++++++++++++++++++++++++++++++++++++-- src/styles/site.css | 140 ++++++++++++++ src/tutorial.mjs | 55 +++++- src/tutorial/chapters.mjs | 12 +- src/tutorial/verify.mjs | 3 +- 6 files changed, 577 insertions(+), 27 deletions(-) diff --git a/build.mjs b/build.mjs index 80a7679..6314b45 100644 --- a/build.mjs +++ b/build.mjs @@ -79,6 +79,23 @@ const pages = [ { title: 'Overview', slug: '', file: 'index.md' }, ...specPages, ]; + +// --- supported code extensions: from docs/code-tags.md's comment-family table, +// so a language added upstream flows into the /learn/ new-file validation on +// the next rebuild. Only table rows are scanned - prose mentions `.git` etc. +const codeTagsMd = readFileSync(path.join(docsDir, 'code-tags.md'), 'utf8'); +const codeExtensions = [ + ...new Set( + codeTagsMd + .split('\n') + .filter((line) => line.startsWith('|')) + .flatMap((line) => [...line.matchAll(/`(\.[a-z0-9]+)`/g)].map((m) => m[1])), + ), +]; +if (codeExtensions.length === 0) { + console.error('error: no extensions found in docs/code-tags.md - the table format changed?'); + process.exit(1); +} const slugByName = new Map(pages.map((p) => [p.file.replace(/\.md$/, ''), p.slug])); // --- markdown rendering ------------------------------------------------------ @@ -251,7 +268,7 @@ try { console.error(`error: tutorial chapter verification failed.\n${err.message}`); process.exit(1); } -writeFileSync(path.join(learnDir, 'index.html'), renderTutorial({ version, verified })); +writeFileSync(path.join(learnDir, 'index.html'), renderTutorial({ version, verified, codeExtensions })); const sitePaths = [ '/', diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 737e34e..1ff7ff8 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -14,6 +14,8 @@ const codeEditor = document.getElementById('ed-code'); const terminal = document.getElementById('term-out'); const runButton = document.getElementById('run-btn'); const argvInput = document.getElementById('argv-input'); +const specTabs = document.getElementById('spec-tabs'); +const codeTabs = document.getElementById('code-tabs'); const PROGRESS_KEY = 'ft-tutorial-progress'; const BUFFERS_KEY = 'ft-tutorial-buffers'; @@ -98,6 +100,12 @@ const FREE_EDITOR = { let data = null; let current = null; // active chapter object let currentIndex = 0; +// Editable files, one list per pane: the left pane holds the Markdown spec +// side, the right the code side. Each pane shows its active file in the +// textarea; the editors' input listeners keep the active body in sync, so the +// model is always current. Chapters marked `locked` render padlocks instead +// of close buttons and a disabled "+". +let panes = null; // { spec: { files: [{ name, body }], active }, code: { ... } } let assists = { help: 0, auto: 0 }; // in-memory per chapter visit let lastResult = null; // r-context of the latest (also silent) run let active = null; // { worker, watchdog, silent } of the run in flight @@ -144,8 +152,22 @@ function refreshHighlights() { } function init() { + // seed the pane model from the server-rendered buffers; openChapter replaces + // it, and the bare runner (no chapter data) keeps working on exactly this + panes = { + spec: { files: [{ name: ide.dataset.specFile || 'spec.md', body: specEditor.value }], active: 0 }, + code: { files: [{ name: ide.dataset.codeFile || 'code.js', body: codeEditor.value }], active: 0 }, + }; setupHighlight(specEditor); setupHighlight(codeEditor); + specEditor.addEventListener('input', () => { + activeFile('spec').body = specEditor.value; + }); + codeEditor.addEventListener('input', () => { + activeFile('code').body = codeEditor.value; + }); + initTabs(); + renderAllTabs(); runButton.addEventListener('click', () => run(false)); for (const editor of [specEditor, codeEditor]) { editor.addEventListener('keydown', (event) => { @@ -266,11 +288,247 @@ function chapterById(id) { return data.chapters.find((c) => c.id === id) ?? null; } +// --- file management --------------------------------------------------------- +// Each pane owns a tab strip over its files: click to switch, ✕ to delete +// (confirmed), + to create (name + type dialog validated against the +// extensions the datalists ship). Locked chapters show padlocks instead. + +const LOCK_TIP = 'This chapter does not allow file management'; + +function isLocked() { + return Boolean(current && current.locked); +} + +function tabStrip(pane) { + return pane === 'spec' ? specTabs : codeTabs; +} + +function activeFile(pane) { + const state = panes[pane]; + return state.files[state.active]; +} + +// push both active files into their textareas after any model change +function syncEditors() { + specEditor.value = activeFile('spec').body; + codeEditor.value = activeFile('code').body; + refreshHighlights(); + renderAllTabs(); +} + +function renderTabs(pane) { + const strip = tabStrip(pane); + if (!strip) return; + const state = panes[pane]; + const locked = isLocked(); + const tab = (file, i) => { + const on = i === state.active; + const trailer = locked + ? '' + : state.files.length > 1 + ? '' + : ''; + return ( + '
' + + '' + + trailer + + '
' + ); + }; + const addAttrs = locked ? ' disabled title="' + LOCK_TIP + '"' : ' title="New file"'; + strip.innerHTML = + state.files.map(tab).join('') + + ''; +} + +function renderAllTabs() { + renderTabs('spec'); + renderTabs('code'); +} + +function selectTab(pane, index) { + const state = panes[pane]; + if (!state.files[index] || index === state.active) return; + state.active = index; + syncEditors(); +} + +function initTabs() { + for (const pane of ['spec', 'code']) { + const strip = tabStrip(pane); + if (!strip) continue; + strip.addEventListener('click', (event) => { + const button = event.target.closest('button'); + if (!button || button.disabled) return; + if (button.classList.contains('tab-name')) selectTab(pane, Number(button.dataset.index)); + else if (button.classList.contains('tab-close')) confirmDeleteFile(pane, Number(button.dataset.index)); + else if (button.classList.contains('tab-add')) openNewFileDialog(pane); + }); + } + initNewFileDialog(); + initDeleteFileDialog(); +} + +// --- deleting ---------------------------------------------------------------- + +let deleteFileContext = null; // { pane, index } while the confirm dialog is up + +function initDeleteFileDialog() { + const modal = document.getElementById('delfile-modal'); + const yes = document.getElementById('delfile-yes'); + const cancel = document.getElementById('delfile-cancel'); + if (!modal || !yes || !cancel) return; + yes.addEventListener('click', () => { + modal.close(); + if (deleteFileContext) deleteFile(deleteFileContext.pane, deleteFileContext.index); + deleteFileContext = null; + }); + cancel.addEventListener('click', () => { + modal.close(); + deleteFileContext = null; + }); + modal.addEventListener('cancel', () => { + deleteFileContext = null; + }); +} + +function confirmDeleteFile(pane, index) { + const state = panes[pane]; + const file = state.files[index]; + if (isLocked() || !file || state.files.length < 2) return; + const modal = document.getElementById('delfile-modal'); + if (!modal || typeof modal.showModal !== 'function') { + if (window.confirm('Delete ' + file.name + '? Its content is lost.')) deleteFile(pane, index); + return; + } + deleteFileContext = { pane, index }; + setText('delfile-text', 'Delete ' + file.name + '? Its content is lost - this cannot be undone.'); + modal.showModal(); +} + +function deleteFile(pane, index) { + const state = panes[pane]; + if (!state.files[index] || state.files.length < 2) return; + state.files.splice(index, 1); + if (state.active > index) state.active -= 1; + else if (state.active >= state.files.length) state.active = state.files.length - 1; + syncEditors(); + saveBuffers(); + scheduleSilentRun(); +} + +// --- creating ---------------------------------------------------------------- + +let newFilePane = null; // pane the new-file dialog was opened for + +function supportedExtensions(pane) { + const list = document.getElementById(pane === 'spec' ? 'ext-spec' : 'ext-code'); + return list ? Array.from(list.querySelectorAll('option'), (o) => o.value) : []; +} + +// Resolve the dialog's two fields into a final file name or a user-facing +// error; an extension typed into the name itself wins over the type field. +// `issue: true` marks "unsupported anywhere" - the dialog then offers the +// tool's issue tracker for a new-language request. +function resolveNewFile(pane) { + const name = (document.getElementById('newfile-name').value || '').trim(); + const typed = (document.getElementById('newfile-ext').value || '').trim().toLowerCase(); + if (!name) return { error: '' }; // nothing typed yet - just keep Create disabled + if (!/^[A-Za-z0-9._-]+$/.test(name)) return { error: 'Use letters, digits, dots, dashes and underscores only.' }; + const own = /\.[a-z0-9]+$/i.exec(name); + const ext = own ? own[0].toLowerCase() : typed ? (typed.startsWith('.') ? typed : '.' + typed) : ''; + if (!ext) return { error: '' }; // waiting for a type + const full = own ? name : name + ext; + if (full.toLowerCase() === ext) return { error: 'Give the file a name before its extension.' }; + const here = supportedExtensions(pane); + if (!here.includes(ext)) { + const other = supportedExtensions(pane === 'spec' ? 'code' : 'spec'); + if (other.includes(ext)) { + return { + error: + pane === 'spec' + ? 'The left pane holds the Markdown spec - create ' + ext + ' files with the + on the right.' + : 'Markdown belongs in the spec pane - use the + on the left.', + }; + } + return { error: 'flashtrace sadly does not support ' + ext + ' files yet.', issue: true }; + } + const taken = [...panes.spec.files, ...panes.code.files].some((f) => f.name.toLowerCase() === full.toLowerCase()); + if (taken) return { error: 'A file named ' + full + ' already exists.' }; + return { name: full }; +} + +function initNewFileDialog() { + const modal = document.getElementById('newfile-modal'); + const name = document.getElementById('newfile-name'); + const ext = document.getElementById('newfile-ext'); + const create = document.getElementById('newfile-create'); + const cancel = document.getElementById('newfile-cancel'); + const error = document.getElementById('newfile-error'); + const errorText = document.getElementById('newfile-error-text'); + const issue = document.getElementById('newfile-issue'); + if (!modal || !name || !ext || !create || !cancel) return; + const validate = () => { + const resolved = resolveNewFile(newFilePane); + create.disabled = !resolved.name; + if (error) error.hidden = !resolved.error; + if (errorText) errorText.textContent = resolved.error || ''; + if (issue) issue.hidden = !resolved.issue; + return resolved; + }; + const submit = () => { + const resolved = validate(); + if (!resolved.name) return; + modal.close(); + createFile(newFilePane, resolved.name); + }; + name.addEventListener('input', validate); + ext.addEventListener('input', validate); + create.addEventListener('click', submit); + cancel.addEventListener('click', () => modal.close()); + for (const field of [name, ext]) { + field.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + }); + } +} + +function openNewFileDialog(pane) { + const modal = document.getElementById('newfile-modal'); + const name = document.getElementById('newfile-name'); + const ext = document.getElementById('newfile-ext'); + if (isLocked() || !modal || !name || !ext || typeof modal.showModal !== 'function') return; + newFilePane = pane; + name.value = ''; + ext.value = pane === 'spec' ? '.md' : '.js'; + ext.setAttribute('list', pane === 'spec' ? 'ext-spec' : 'ext-code'); + const error = document.getElementById('newfile-error'); + if (error) error.hidden = true; + document.getElementById('newfile-create').disabled = true; + modal.showModal(); + name.focus(); +} + +function createFile(pane, fileName) { + const state = panes[pane]; + state.files.push({ name: fileName, body: '' }); + state.active = state.files.length - 1; + syncEditors(); + saveBuffers(); + scheduleSilentRun(); + paneEditor(pane).focus(); +} + function seedBuffers() { - specEditor.value = current.spec.body; - codeEditor.value = current.variant.body; + panes = { + spec: { files: [{ name: current.spec.file, body: current.spec.body }], active: 0 }, + code: { files: [{ name: current.variant.file, body: current.variant.body }], active: 0 }, + }; + syncEditors(); if (argvInput) argvInput.value = current.argv.join(' '); - refreshHighlights(); } function openChapter(id) { @@ -313,8 +571,6 @@ function openChapter(id) { .map((d) => '' + esc(d.label) + '') .join(' · '); } - setText('spec-tab', chapter.spec.file); - setText('code-tab', chapter.variant.file); terminal.innerHTML = 'press Run to trace the project'; closeAssist(); for (const btn of [document.getElementById('help-btn'), document.getElementById('auto-btn')]) { @@ -343,11 +599,33 @@ function openChapter(id) { } } +// Rebuild the pane model from a stored entry. Entries written before the +// multi-file model carry plain `spec`/`code` strings; they map onto the +// chapter's seeded file names. function restoreStored(chapter, stored) { - specEditor.value = String(stored.spec); - codeEditor.value = String(stored.code); + const sane = (p, fallback) => { + const files = Array.isArray(p && p.files) + ? p.files + .filter((f) => f && typeof f.name === 'string' && typeof f.body === 'string') + .map((f) => ({ name: f.name, body: f.body })) + : []; + if (files.length === 0) files.push({ name: fallback.file, body: fallback.body }); + const active = Math.min(Math.max(0, Math.trunc(Number(p && p.active)) || 0), files.length - 1); + return { files, active }; + }; + if (stored.panes) { + panes = { + spec: sane(stored.panes.spec, chapter.spec), + code: sane(stored.panes.code, chapter.variant), + }; + } else { + panes = { + spec: { files: [{ name: chapter.spec.file, body: String(stored.spec) }], active: 0 }, + code: { files: [{ name: chapter.variant.file, body: String(stored.code) }], active: 0 }, + }; + } + syncEditors(); if (argvInput) argvInput.value = chapter.argv.join(' '); - refreshHighlights(); } function setText(id, text) { @@ -399,20 +677,36 @@ function relativeTime(iso) { // --- persistence ------------------------------------------------------------- +// Pristine entries are deleted on save, so under the multi-file shape a +// stored entry always means real work; only legacy string entries can still +// equal the seeds. +function storedPristine(chapter, stored) { + if (stored.panes) return false; + return stored.spec === chapter.spec.body && stored.code === chapter.variant.body; +} + function saveBuffers() { - if (!current) return; + if (!current || !panes) return; const store = loadStore(BUFFERS_KEY); + const only = (pane) => (panes[pane].files.length === 1 ? panes[pane].files[0] : null); + const spec = only('spec'); + const code = only('code'); const pristine = - specEditor.value === current.spec.body && codeEditor.value === current.variant.body; + spec && code && + spec.name === current.spec.file && spec.body === current.spec.body && + code.name === current.variant.file && code.body === current.variant.body; if (pristine) { delete store.chapters[current.id]; } else { + const snapshot = (pane) => ({ + files: panes[pane].files.map((f) => ({ name: f.name, body: f.body })), + active: panes[pane].active, + }); store.chapters[current.id] = { rev: current.rev, lang: data.lang, savedAt: new Date().toISOString(), - spec: specEditor.value, - code: codeEditor.value, + panes: { spec: snapshot('spec'), code: snapshot('code') }, }; } saveStore(BUFFERS_KEY, store); @@ -615,9 +909,10 @@ function run(silent) { } const argv = currentArgv(); - const specName = current ? current.spec.file : ide.dataset.specFile || 'spec.md'; - const codeName = current ? current.variant.file : ide.dataset.codeFile || 'code.js'; - const files = { [specName]: specEditor.value, [codeName]: codeEditor.value }; + const files = {}; + for (const pane of ['spec', 'code']) { + for (const file of panes[pane].files) files[file.name] = file.body; + } const prompt = '$ ' + esc(['npx flashtrace'].concat(argv).join(' ')) + '\n'; if (!silent) { @@ -645,11 +940,16 @@ function run(silent) { const body = result.output ? colorizeReport(result.output) + '\n' : ''; showTerminal(prompt + body + 'exit ' + result.exitCode + ''); } + // checks read r.spec / r.code as raw text: with several files per pane + // they see the pane's files joined, so work spread over added files still + // satisfies text-based checks; r.files carries the run's input snapshot + const paneText = (pane) => panes[pane].files.map((f) => f.body).join('\n'); lastResult = Object.assign({}, result.analysis, { exitCode: result.exitCode, argv, - spec: specEditor.value, - code: codeEditor.value, + files, + spec: paneText('spec'), + code: paneText('code'), }); if (current && !current.free && result.analysis && !wasSilent && safeCheck(current.done, lastResult)) { completeChapter(current); @@ -694,6 +994,23 @@ function paneEditor(pane) { return pane === 'spec' ? specEditor : codeEditor; } +// Steps patch the chapter's seeded files by name; tabs the user added are +// theirs alone. In an unlocked chapter the seed can have been deleted - +// callers handle the -1. +function seedFileIndex(pane) { + const name = pane === 'spec' ? current.spec.file : current.variant.file; + return panes[pane].files.findIndex((f) => f.name === name); +} + +// Bring the seeded file of the step's pane into view; false if it is gone. +function focusSeedTab(step) { + if (!step || step.pane === 'argv') return true; + const index = seedFileIndex(step.pane); + if (index === -1) return false; + selectTab(step.pane, index); + return true; +} + function rectInIde(el) { const a = el.getBoundingClientRect(); const b = ide.getBoundingClientRect(); @@ -724,7 +1041,8 @@ function assistTarget(step) { return { rect: rectInIde(argvInput || runButton) }; } const editor = paneEditor(step.pane); - const tab = document.getElementById(step.pane === 'spec' ? 'spec-tab' : 'code-tab'); + const strip = tabStrip(step.pane); + const tab = strip ? strip.querySelector('.pane-tab.is-active') : null; if (window.innerWidth < 700) return { rect: rectInIde(tab || editor) }; const pack = step.pane === 'spec' ? current.spec : current.variant; const anchorKey = (step.patch && step.patch.anchor) || step.anchor; @@ -802,6 +1120,7 @@ function helpAssist() { if (!current || typing) return; const step = currentStep(); if (step) assists.help += 1; + focusSeedTab(step); // anchor resolution needs the seeded file visible openAssist(step); } @@ -856,7 +1175,25 @@ function autoAssist() { return; } const step = current.steps[index]; - const before = { spec: specEditor.value, code: codeEditor.value, argv: currentArgv() }; + if (!focusSeedTab(step)) { + // the seeded file this step patches was deleted (unlocked chapters allow it) + openAssist(step); + const text = document.getElementById('assist-text'); + if (text) { + const seedFile = step.pane === 'spec' ? current.spec.file : current.variant.file; + text.textContent = + 'This step edits ' + seedFile + ', which no longer exists. ' + + 'Use "Reset files" to restore the chapter\'s start files, or recreate it and follow the hint by hand: ' + + step.explain; + } + return; + } + // applyStep patches the seeded files; extra tabs the user added stay untouched + const seedBody = (pane) => { + const seedIndex = seedFileIndex(pane); + return seedIndex === -1 ? '' : panes[pane].files[seedIndex].body; + }; + const before = { spec: seedBody('spec'), code: seedBody('code'), argv: currentArgv() }; const after = applyStep(current, data.lang, step, before); if (after.failed) { openAssist(step); diff --git a/src/styles/site.css b/src/styles/site.css index 69b1630..c540820 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1727,6 +1727,44 @@ h3:hover .heading-anchor, margin-top: 1rem; } +/* file dialogs (new file / delete file) */ + +.file-modal .file-fields { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.6rem 0.75rem; + align-items: center; + margin-top: 1rem; +} + +.file-modal label { + font-size: 0.85rem; + color: var(--muted); +} + +.file-modal input { + min-width: 0; + padding: 0.4rem 0.6rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; +} + +.file-modal input:focus-visible { + outline: none; + border-color: var(--accent); +} + +/* the generic .resume-modal p rule would win on color */ +.file-error { + margin-top: 0.9rem; + color: var(--accent-strong) !important; + font-size: 0.9rem; +} + /* no-JS fallback */ .tutorial-fallback { @@ -1835,6 +1873,108 @@ h3:hover .heading-anchor, font: inherit; } +/* pane tab strips: one file list per pane - click to switch, ✕ to delete, + + to add; locked chapters show padlocks and a disabled + instead */ + +.pane-tabs { + display: flex; + align-items: stretch; + overflow-x: auto; + background: #191715; +} + +.pane-tabs::-webkit-scrollbar { + height: 6px; +} + +.pane-tabs::-webkit-scrollbar-thumb { + border-width: 2px; +} + +.pane-tabs .pane-tab { + display: inline-flex; + align-items: center; + flex: none; + padding: 0; + color: #8b8b8b; + background: transparent; + border-bottom: 2px solid transparent; +} + +.pane-tabs .pane-tab.is-active { + color: #d4d4d4; + background: #1e1e1e; + border-bottom-color: var(--accent); +} + +.tab-name { + padding: 0.35rem 0.5rem 0.35rem 1rem; + border: 0; + background: none; + color: inherit; + font: inherit; + white-space: nowrap; + cursor: pointer; +} + +.tab-name:last-child { + padding-right: 1rem; +} + +.pane-tab:not(.is-active) .tab-name:hover { + color: #d4d4d4; +} + +.tab-close { + flex: none; + margin-right: 0.45rem; + padding: 0.2rem 0.3rem; + border: 0; + border-radius: 4px; + background: none; + color: #6b6762; + font: inherit; + font-size: 0.65rem; + line-height: 1; + cursor: pointer; +} + +.tab-close:hover { + color: #e7e5e4; + background: #33302c; +} + +.tab-lock { + flex: none; + width: 0.6rem; + height: 0.6rem; + margin-right: 0.7rem; + background: currentColor; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3.5 5V3.5a2.5 2.5 0 0 1 5 0V5H9a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h.5Zm1.2 0h2.6V3.5a1.3 1.3 0 0 0-2.6 0V5Z'/%3E%3C/svg%3E") center / contain no-repeat; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M3.5 5V3.5a2.5 2.5 0 0 1 5 0V5H9a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h.5Zm1.2 0h2.6V3.5a1.3 1.3 0 0 0-2.6 0V5Z'/%3E%3C/svg%3E") center / contain no-repeat; +} + +.tab-add { + flex: none; + padding: 0.25rem 0.7rem; + border: 0; + background: none; + color: #8b8b8b; + font: inherit; + font-family: ui-monospace, Consolas, monospace; + font-size: 0.85rem; + cursor: pointer; +} + +.tab-add:hover:not(:disabled) { + color: var(--accent); +} + +.tab-add:disabled { + color: #55524e; + cursor: not-allowed; +} + .ide-lg .term-lg { min-height: 11rem; max-height: 22rem; diff --git a/src/tutorial.mjs b/src/tutorial.mjs index 3c74d8e..bc73e41 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -6,7 +6,7 @@ // touching code or storage keys. import { chapters } from './tutorial/chapters.mjs'; import { chapterPayload } from './tutorial/verify.mjs'; -import { esc, highlightTokens, pageShell } from './layout.mjs'; +import { esc, GITHUB_URL, highlightTokens, pageShell } from './layout.mjs'; import { colorizeReport } from './report-colors.mjs'; const LANG = 'js'; @@ -60,6 +60,19 @@ ${groupHtml} `; } +export const LOCK_TIP = 'This chapter does not allow file management'; + +// Server-rendered initial tab strip (chapter 1); tutorial.js re-renders it on +// every chapter switch and tab operation with the same structure. +function paneTabs(pane, fileName, locked, label) { + const lock = locked ? `` : ''; + const addAttrs = locked ? ` disabled title="${LOCK_TIP}"` : ' title="New file"'; + return `
+
${lock}
+ +
`; +} + function ide(first) { const variant = first.variants[LANG]; return `
@@ -70,11 +83,11 @@ function ide(first) {
-
${esc(first.spec.file)}
+ ${paneTabs('spec', first.spec.file, first.locked, 'Spec files')}
-
${esc(variant.file)}
+ ${paneTabs('code', variant.file, first.locked, 'Code files')}
@@ -142,6 +155,39 @@ function resumeDialog() {
`; } +// Datalists double as the single client-side source of the supported +// extensions: the pickers autocomplete from them and tutorial.js validates +// against them. The code list is parsed from docs/code-tags.md at build time; +// the spec list mirrors what analyzeBuffers routes to parseMarkdown. +function fileDialogs(codeExtensions) { + const specExtensions = ['.md', '.markdown']; + const options = (exts) => exts.map((e) => ``).join(''); + return `${options(specExtensions)} +${options(codeExtensions)} + +

New file

+
+ + + + +
+ +
+ + +
+
+ +

Delete file

+

+
+ + +
+
`; +} + // Shown when someone lands on #editor without any completed chapter: offer // the guided route before leaving a newcomer alone with two empty files. function welcomeDialog() { @@ -201,7 +247,7 @@ ${sections} `; } -export function renderTutorial({ version, verified }) { +export function renderTutorial({ version, verified, codeExtensions }) { const first = chapters[0]; const payloads = chapters.map((chapter) => ({ ...chapterPayload(chapter, LANG), @@ -230,6 +276,7 @@ ${ide(first)} ${resumeDialog()} ${welcomeDialog()} +${fileDialogs(codeExtensions)} ${fallbackChapters(verified)} diff --git a/src/tutorial/chapters.mjs b/src/tutorial/chapters.mjs index 2ab7428..23c37b7 100644 --- a/src/tutorial/chapters.mjs +++ b/src/tutorial/chapters.mjs @@ -7,13 +7,17 @@ // the build-time identity hash): // - `anchors` hold regex *sources* (compiled with the 'm' flag at use time) // - `check`/`done` are pure self-contained arrows over the run result -// `r = { items, problems, clean, exitCode, argv, spec, code }`; they are -// shipped as their source text, so they must not close over anything +// `r = { items, problems, clean, exitCode, argv, files, spec, code }`; they +// are shipped as their source text, so they must not close over anything. +// In the browser `spec`/`code` join all files of the respective pane, so +// text-based checks keep passing when users spread work over added files // - `patch` ops: insertBefore | insertAfter | replaceLine | replaceMatch // (anchor + snippet key), append (snippet key), setArgv (argv array); // insertAfter/append prefix the snippet with '\n', so a snippet with its // own leading '\n' produces a blank separator line // - language-specifics live in `variants` (v1: js); `spec` is neutral +// - `locked: true` disables file management (add/delete tabs) for the chapter; +// its steps then always run against exactly the seeded file pair export const chapters = [ // ------------------------------------------------------------ foundations @@ -21,6 +25,7 @@ export const chapters = [ id: 'first-item', title: 'Your first item', group: 'Foundations', + locked: true, intro: 'flashtrace reads ordinary Markdown. An item is born on a line holding nothing but its ID in backticks - the heading above becomes its title, the paragraph below its description. Build one from scratch and watch the tracer pick it up.', goal: 'Turn the prose into a traceable item: heading, ID line, description.', @@ -93,6 +98,7 @@ holding nothing but an ID in backticks.`, id: 'cover-a-need', title: 'Cover a requirement', group: 'Foundations', + locked: true, intro: 'An item without Needs asks for nothing - the trace stays green but toothless. Demand an implementation with a Needs: list, watch the run go red, then satisfy it with a tag in an ordinary code comment.', goal: 'Make req:auth/login#1 demand an implementation, then provide it.', @@ -160,6 +166,7 @@ Users must be able to log in with email and password.`, id: 'read-the-report', title: 'Read the report', group: 'Foundations', + locked: true, intro: 'This trace is broken on purpose. Each defective item gets one ✘ block: its ID, location and defect bullets. Two of the three blocks here share a single root cause - a typo - and the run exits 1 until everything is fixed. Read first, then fix.', goal: 'Clear all three defect blocks and bring the exit code to 0.', @@ -233,6 +240,7 @@ export function endAllSessions(user) { id: 'covers-and-orphans', title: 'Covers: & orphans', group: 'Foundations', + locked: true, intro: 'Coverage can also be declared from the covering side: a Covers: entry names the item this one covers. It is only valid when the target exists and needs the coverer back - otherwise you get an orphaned or unwanted defect. Repair a broken Covers chain.', goal: 'Make req:auth/login#1 validly cover the auth feature.', diff --git a/src/tutorial/verify.mjs b/src/tutorial/verify.mjs index d3d9a9d..6423dd5 100644 --- a/src/tutorial/verify.mjs +++ b/src/tutorial/verify.mjs @@ -37,6 +37,7 @@ export function chapterPayload(chapter, lang) { id: chapter.id, title: chapter.title, group: chapter.group, + locked: Boolean(chapter.locked), intro: chapter.intro, goal: chapter.goal, docs: chapter.docs, @@ -160,7 +161,7 @@ async function runState(bundleUrl, chapter, lang, state) { return { output, exitCode, - r: { ...analysis, exitCode, argv: state.argv, spec: state.spec, code: state.code }, + r: { ...analysis, exitCode, argv: state.argv, files, spec: state.spec, code: state.code }, }; } From 34d7e91013557bf58f8098502a538bde6cfbe83f Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 14:30:12 +0200 Subject: [PATCH 19/34] fix: keep the step accordion exclusive - one step unfolded at most Manual clicks could leave several steps open between engine syncs. The details elements now share name="chapter-steps", which modern browsers fold exclusively on their own; a capture-phase toggle listener mirrors the behavior where the attribute is unsupported. Folding the open step to none stays allowed, and every analysis still re-syncs the fold to the current step. Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 26 +++++++++++++++++++++++--- src/tutorial.mjs | 2 +- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 1ff7ff8..3d2f0ad 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -186,6 +186,7 @@ function init() { }); } + initStepAccordion(); data = loadChapterData(); if (!data) return; // bare runner: Run still works on the visible buffers @@ -806,8 +807,27 @@ function safeCheck(fn, r) { // The guidance panel between the intro and the IDE: one
per step, // rebuilt on every chapter switch and re-synced after every analysis (same // structure as the server-rendered chapter 1 in tutorial.mjs). Steps behind -// the current one grey out behind a check, the current one unfolds; manual -// unfolding is fine - the next sync restores the canonical state. +// the current one grey out behind a check, the current one unfolds; at most +// one step is ever unfolded (a proper accordion), and manual folding around +// is fine - the next sync restores the canonical state. + +// Modern browsers make same-`name` details mutually exclusive natively; this +// mirrors that where the attribute is unsupported. toggle does not bubble, +// so it is captured at the list. +function initStepAccordion() { + const list = document.getElementById('step-items'); + if (!list || 'name' in document.createElement('details')) return; + list.addEventListener( + 'toggle', + (event) => { + if (!event.target.open) return; + for (const other of list.querySelectorAll('details[open]')) { + if (other !== event.target) other.open = false; + } + }, + true, + ); +} function renderStepList(chapter) { const section = document.getElementById('step-list'); @@ -818,7 +838,7 @@ function renderStepList(chapter) { list.innerHTML = chapter.steps .map( (step, i) => - '
  • ' + + '
  • ' + (i + 1) + ' · ' + esc(step.title) + '

    ' + esc(step.explain) + '

  • ', ) diff --git a/src/tutorial.mjs b/src/tutorial.mjs index bc73e41..3d7d5ce 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -127,7 +127,7 @@ function stepListItems(steps, currentIndex) { let state = ''; if (i < currentIndex) state = ' class="is-done"'; else if (i === currentIndex) state = ' class="is-current"'; - return ` + return `
    ${i + 1} · ${esc(step.title)}

    ${esc(step.explain)}

  • `; From 027b6fe4dd71af56c5c3c94941208421e887ea28 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 14:44:45 +0200 Subject: [PATCH 20/34] fix: keep a user-opened step open when a silent run resolves The step accordion re-folded to the current step on every run, including the debounced silent runs fired by editor input. Clicking a different step to explore while one of those runs was in flight snapped the fold back to the current step - the classic "jumps back the first time, sticks the second time" symptom. syncStepList now only re-folds to the current step when that step index actually changes (chapter switch, real progress) or on an explicit reset; the progress classes still mirror every run. Background silent runs no longer disturb a fold the user opened deliberately. Co-Authored-By: Claude Opus 4.8 --- src/scripts/tutorial.js | 42 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 3d2f0ad..2474093 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -111,6 +111,7 @@ let lastResult = null; // r-context of the latest (also silent) run let active = null; // { worker, watchdog, silent } of the run in flight let saveTimer = null; let checkTimer = null; +let lastSyncedStep = null; // step the accordion was last folded to; guards manual folds // --- editor highlight overlay ------------------------------------------------ // A transparent textarea over a highlightTokens-rendered
     with identical
    @@ -151,6 +152,30 @@ function refreshHighlights() {
       for (const render of highlightRenderers) render();
     }
     
    +// Both editors carry a native vertical resize grip, and a drag writes an inline
    +// height onto that one textarea only. The grid stretches the panes to the
    +// taller side, but a shorter inline height overrides the CSS fill, so dragging
    +// each grip in turn would drift the two windows apart. Mirror whichever grip is
    +// dragged onto the other editor so the left and right windows stay one height.
    +function syncEditorHeights() {
    +  if (typeof ResizeObserver === 'undefined') return;
    +  const editors = [specEditor, codeEditor];
    +  let shared = ''; // the height last propagated to both, as an inline string
    +  const observer = new ResizeObserver(() => {
    +    for (const editor of editors) {
    +      const height = editor.style.height; // set by a resize drag, else ''
    +      if (height && height !== shared) {
    +        shared = height;
    +        for (const other of editors) {
    +          if (other !== editor) other.style.height = height;
    +        }
    +        return; // the mirrored write settles to `shared`, so no feedback loop
    +      }
    +    }
    +  });
    +  for (const editor of editors) observer.observe(editor);
    +}
    +
     function init() {
       // seed the pane model from the server-rendered buffers; openChapter replaces
       // it, and the bare runner (no chapter data) keeps working on exactly this
    @@ -160,6 +185,7 @@ function init() {
       };
       setupHighlight(specEditor);
       setupHighlight(codeEditor);
    +  syncEditorHeights();
       specEditor.addEventListener('input', () => {
         activeFile('spec').body = specEditor.value;
       });
    @@ -235,7 +261,7 @@ function init() {
           setCompletedOverlay(null);
           seedBuffers();
           setText('step-progress', 'Run to check your progress.');
    -      syncStepList(0);
    +      syncStepList(0, true);
           renderRailMarks();
           scheduleSilentRun();
         });
    @@ -589,7 +615,7 @@ function openChapter(id) {
         if (stored) restoreStored(chapter, stored);
         else seedBuffers(); // completions before buffers were kept: show the start files
         setText('step-progress', chapterCompleteText());
    -    syncStepList(chapter.steps.length); // every step folded away behind its check
    +    syncStepList(chapter.steps.length, true); // every step folded away behind its check
       } else if (!stored || storedPristine(chapter, stored)) {
         seedBuffers();
         scheduleSilentRun();
    @@ -843,18 +869,24 @@ function renderStepList(chapter) {
             '

    ' + esc(step.explain) + '

    ', ) .join(''); - syncStepList(0); + syncStepList(0, true); } -function syncStepList(currentIndex) { +// classes always mirror progress; the fold only snaps to the current step when +// that step actually changes (chapter switch, real progress) or on an explicit +// reset (force) - so background silent runs never yank a step the user opened +// to explore back to the current one. +function syncStepList(currentIndex, force) { const list = document.getElementById('step-items'); if (!list) return; + const refold = force || currentIndex !== lastSyncedStep; Array.from(list.children).forEach((item, i) => { item.classList.toggle('is-done', i < currentIndex); item.classList.toggle('is-current', i === currentIndex); const details = item.querySelector('details'); - if (details) details.open = i === currentIndex; + if (details && refold) details.open = i === currentIndex; }); + lastSyncedStep = currentIndex; } // current step = index of the first failing check; users may type ahead, From 959f8a51fe41df08a3dcd9ce9ed4efbc5b46d7ff Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 14:46:19 +0200 Subject: [PATCH 21/34] style: synchronization improvements --- src/styles/site.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/styles/site.css b/src/styles/site.css index c540820..bc5b85f 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -529,6 +529,21 @@ button { .ide-pane { min-width: 0; + /* column so the editor fills the pane; the grid stretches both panes to the + taller one, so whichever side is resized taller drives both to one height */ + display: flex; + flex-direction: column; +} + +/* the editor (no-JS) or its highlight wrap (JS overlay) grows to fill the pane, + keeping the left and right windows the same height */ +.ide-pane > .editor, +.ide-pane > .editor-wrap { + flex: 1 1 auto; +} + +.ide-pane > .editor-wrap > .editor { + height: 100%; } .ide-pane + .ide-pane { From d726ac55e3742d5374524e2beea3f1c6b2523972 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 14:57:10 +0200 Subject: [PATCH 22/34] feat: ring per rail chapter charting its step progress Each chapter link carries an SVG donut split into one arc per step, clockwise from 12 o'clock; solved steps draw bold in the text color while the rest stay thin and muted, and a completed chapter is always a full ring next to its check. Solved-step counts persist per chapter in ft-tutorial-steps (written after every analysis) so rings survive reloads and chapter switches; both reset paths clear them. The rail legend gains a JS-revealed "steps solved" demo ring. Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 67 +++++++++++++++++++++++++++++++++++++++++ src/styles/site.css | 27 +++++++++++++++++ src/tutorial.mjs | 6 ++-- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 2474093..c146ff5 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -19,6 +19,7 @@ const codeTabs = document.getElementById('code-tabs'); const PROGRESS_KEY = 'ft-tutorial-progress'; const BUFFERS_KEY = 'ft-tutorial-buffers'; +const STEPS_KEY = 'ft-tutorial-steps'; const RUN_TIMEOUT_MS = 5000; // --- defensive localStorage stores ------------------------------------------ @@ -48,6 +49,7 @@ function removeStores() { try { localStorage.removeItem(PROGRESS_KEY); localStorage.removeItem(BUFFERS_KEY); + localStorage.removeItem(STEPS_KEY); } catch { /* ignore */ } @@ -258,6 +260,7 @@ function init() { if (!window.confirm("Reset this chapter? Its completion and solved files are forgotten and the start files restored.")) return; deleteProgressEntry(current.id); deleteBufferEntry(current.id); + deleteStepsEntry(current.id); setCompletedOverlay(null); seedBuffers(); setText('step-progress', 'Run to check your progress.'); @@ -797,8 +800,71 @@ function completionStatement(entry) { ); } +// --- rail progress rings ----------------------------------------------------- +// Each chapter link carries a small SVG donut split into one arc per step, +// running clockwise from 12 o'clock: steps solved so far draw bold in the text +// color, the rest stay thin and muted. Solved-step counts persist per chapter +// under STEPS_KEY (written after every analysis) so the rings survive reloads +// and chapter switches; a completed chapter always shows a full ring. + +function ringPoint(radius, deg) { + const rad = (deg * Math.PI) / 180; + return (8 + radius * Math.sin(rad)).toFixed(2) + ' ' + (8 - radius * Math.cos(rad)).toFixed(2); +} + +function ringSvg(total, done) { + const radius = 6.25; + const cls = (i) => 'seg' + (i < done ? ' is-done' : ''); + let body; + if (total === 1) { + body = ''; + } else { + const span = 360 / total; + const gap = Math.min(18, span / 4); + body = Array.from({ length: total }, (_, i) => { + const from = i * span + gap / 2; + const to = (i + 1) * span - gap / 2; + const arc = 'A' + radius + ' ' + radius + ' 0 ' + (to - from > 180 ? 1 : 0) + ' 1 '; + return ''; + }).join(''); + } + return '' + body + ''; +} + +function saveStepProgress(id, done) { + const store = loadStore(STEPS_KEY); + if ((store.chapters[id] || 0) === done) return; + if (done === 0) delete store.chapters[id]; + else store.chapters[id] = done; + saveStore(STEPS_KEY, store); + renderRailMarks(); +} + +function deleteStepsEntry(id) { + const store = loadStore(STEPS_KEY); + delete store.chapters[id]; + saveStore(STEPS_KEY, store); +} + function renderRailMarks() { const progress = loadStore(PROGRESS_KEY); + const steps = loadStore(STEPS_KEY); + document.querySelectorAll('.ch-ring[data-ring]').forEach((ring) => { + const id = ring.getAttribute('data-ring'); + const total = Math.trunc(Number(ring.getAttribute('data-steps'))) || 0; + if (total < 1) return; + // stale counts (e.g. a chapter shrank in a revision) clamp to the ring + const done = progress.chapters[id] + ? total + : Math.min(total, Math.max(0, Math.trunc(Number(steps.chapters[id])) || 0)); + ring.innerHTML = ringSvg(total, done); + }); + const legend = document.querySelector('.legend-steps'); + if (legend) { + legend.hidden = false; + const demo = legend.querySelector('[data-ring-demo]'); + if (demo) demo.innerHTML = ringSvg(3, 1); + } let any = false; document.querySelectorAll('.ch-mark[data-mark]').forEach((mark) => { const entry = progress.chapters[mark.getAttribute('data-mark')]; @@ -904,6 +970,7 @@ function updateStepUi(fromRealRun) { const steps = current.steps; const firstFailing = firstFailingIndex(); const solved = safeCheck(current.done, lastResult); + saveStepProgress(current.id, firstFailing); for (const btn of [document.getElementById('help-btn'), document.getElementById('auto-btn')]) { if (btn) btn.disabled = false; } diff --git a/src/styles/site.css b/src/styles/site.css index bc5b85f..f5324d5 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1439,6 +1439,33 @@ h3:hover .heading-anchor, /* chapter rail marks */ +/* per-chapter step progress: an SVG donut with one arc per step (see + ringSvg in tutorial.js); solved arcs draw bold in the text color */ + +.ch-ring { + display: inline-block; + width: 1.3rem; +} + +.ch-ring svg { + width: 0.85rem; + height: 0.85rem; + vertical-align: -0.1rem; +} + +.ch-ring .seg { + fill: none; + stroke: var(--muted); + stroke-opacity: 0.4; + stroke-width: 1.5; +} + +.ch-ring .seg.is-done { + stroke: var(--text); + stroke-opacity: 1; + stroke-width: 3; +} + .ch-mark { display: inline-block; width: 1.1rem; diff --git a/src/tutorial.mjs b/src/tutorial.mjs index 3d7d5ce..f663d20 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -38,7 +38,7 @@ function chapterRail() { ${g.items .map((ch) => { no += 1; - return `
  • ${no} · ${esc(ch.title)}
  • `; + return `
  • ${no} · ${esc(ch.title)}
  • `; }) .join('\n ')} @@ -48,12 +48,12 @@ function chapterRail() { return ` From 1e9882cc1d7b95e15bae2efc3c0d0ebd13bf3d44 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 16:30:06 +0200 Subject: [PATCH 23/34] feat: link to language support request issue template directly --- src/tutorial.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tutorial.mjs b/src/tutorial.mjs index f663d20..9a2c01e 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -172,7 +172,7 @@ function fileDialogs(codeExtensions) { - +
    From a69a4483bd6683d08af551765d0b87ea11fc0744 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 16:30:44 +0200 Subject: [PATCH 24/34] fix: show one rail symbol per chapter - ring or check The progress ring and completion check shared a chapter link side by side, so a finished chapter drew both a full ring and a check. Collapse them into a single slot: in-progress chapters show the ring, completed ones hand the slot to the check. Co-Authored-By: Claude Opus 4.8 --- src/scripts/tutorial.js | 12 +++++++----- src/styles/site.css | 20 ++++++++++++++++++-- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index c146ff5..1093f78 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -805,7 +805,8 @@ function completionStatement(entry) { // running clockwise from 12 o'clock: steps solved so far draw bold in the text // color, the rest stay thin and muted. Solved-step counts persist per chapter // under STEPS_KEY (written after every analysis) so the rings survive reloads -// and chapter switches; a completed chapter always shows a full ring. +// and chapter switches. Once a chapter is completed its ring gives way to +// the ✓ check, so exactly one symbol shows per chapter. function ringPoint(radius, deg) { const rad = (deg * Math.PI) / 180; @@ -853,10 +854,11 @@ function renderRailMarks() { const id = ring.getAttribute('data-ring'); const total = Math.trunc(Number(ring.getAttribute('data-steps'))) || 0; if (total < 1) return; - // stale counts (e.g. a chapter shrank in a revision) clamp to the ring - const done = progress.chapters[id] - ? total - : Math.min(total, Math.max(0, Math.trunc(Number(steps.chapters[id])) || 0)); + // a completed chapter hands its slot to the check; only in-progress + // chapters draw a ring. stale counts clamp to the ring's step total. + ring.hidden = Boolean(progress.chapters[id]); + if (ring.hidden) return; + const done = Math.min(total, Math.max(0, Math.trunc(Number(steps.chapters[id])) || 0)); ring.innerHTML = ringSvg(total, done); }); const legend = document.querySelector('.legend-steps'); diff --git a/src/styles/site.css b/src/styles/site.css index f5324d5..c922350 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1447,6 +1447,11 @@ h3:hover .heading-anchor, width: 1.3rem; } +/* the completion check takes the ring's slot once a chapter is done */ +.ch-ring[hidden] { + display: none; +} + .ch-ring svg { width: 0.85rem; height: 0.85rem; @@ -1468,11 +1473,16 @@ h3:hover .heading-anchor, .ch-mark { display: inline-block; - width: 1.1rem; + width: 1.3rem; color: #3fb950; font-weight: 700; } +/* only a real completion mark occupies the slot; otherwise the ring shows */ +.ch-mark:not(.is-done):not(.is-assisted) { + display: none; +} + .ch-mark.is-done::before { content: '✓'; } @@ -1771,6 +1781,11 @@ h3:hover .heading-anchor, /* file dialogs (new file / delete file) */ +/* fixed width so the modal doesn't grow when the error line appears */ +.file-modal { + width: min(24rem, calc(100vw - 2rem)); +} + .file-modal .file-fields { display: grid; grid-template-columns: auto 1fr; @@ -1804,7 +1819,8 @@ h3:hover .heading-anchor, .file-error { margin-top: 0.9rem; color: var(--accent-strong) !important; - font-size: 0.9rem; + font-size: 0.8rem; + line-height: 1.4; } /* no-JS fallback */ From 1afddc9932ed58b36ae5712f040a41067aa8f866 Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 17:05:40 +0200 Subject: [PATCH 25/34] feat: color rail rings and step checks by who solved each step Track per step whether it was cleared by the user or with an assist ("Do the next step for me" / a hint). The ring segment and the accordion check now draw green for self-solved steps and amber for assisted ones, so a chapter's ring shows at a glance how much help was used, not just how far along it is. ft-tutorial-steps entries grow from a bare solved count to { done, assisted }; legacy number entries migrate as all-self. Co-Authored-By: Claude Opus 4.8 --- src/scripts/tutorial.js | 67 +++++++++++++++++++++++++++++------------ src/styles/site.css | 10 +++++- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 1093f78..6378f1a 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -109,6 +109,7 @@ let currentIndex = 0; // of close buttons and a disabled "+". let panes = null; // { spec: { files: [{ name, body }], active }, code: { ... } } let assists = { help: 0, auto: 0 }; // in-memory per chapter visit +let assistedSteps = new Set(); // step indices solved with an assist, current chapter let lastResult = null; // r-context of the latest (also silent) run let active = null; // { worker, watchdog, silent } of the run in flight let saveTimer = null; @@ -568,6 +569,7 @@ function openChapter(id) { current = chapter; currentIndex = data.chapters.indexOf(chapter); assists = { help: 0, auto: 0 }; + assistedSteps = new Set(loadStepEntry(loadStore(STEPS_KEY), id).assisted); lastResult = null; // rail + url @@ -802,20 +804,23 @@ function completionStatement(entry) { // --- rail progress rings ----------------------------------------------------- // Each chapter link carries a small SVG donut split into one arc per step, -// running clockwise from 12 o'clock: steps solved so far draw bold in the text -// color, the rest stay thin and muted. Solved-step counts persist per chapter -// under STEPS_KEY (written after every analysis) so the rings survive reloads -// and chapter switches. Once a chapter is completed its ring gives way to -// the ✓ check, so exactly one symbol shows per chapter. +// running clockwise from 12 o'clock: steps solved so far draw bold, the rest +// stay thin and muted. A step the user cleared themselves draws green; one +// finished with an assist ("Do the next step for me" / a hint) draws amber. +// Per-chapter progress persists under STEPS_KEY ({ done, assisted } written +// after every analysis) so the rings survive reloads and chapter switches. +// Once a chapter is completed its ring gives way to the ✓ check, so exactly +// one symbol shows per chapter. function ringPoint(radius, deg) { const rad = (deg * Math.PI) / 180; return (8 + radius * Math.sin(rad)).toFixed(2) + ' ' + (8 - radius * Math.cos(rad)).toFixed(2); } -function ringSvg(total, done) { +function ringSvg(total, done, assisted) { const radius = 6.25; - const cls = (i) => 'seg' + (i < done ? ' is-done' : ''); + const cls = (i) => + 'seg' + (i < done ? (assisted && assisted.has(i) ? ' is-done is-assisted' : ' is-done') : ''); let body; if (total === 1) { body = ''; @@ -832,11 +837,33 @@ function ringSvg(total, done) { return '' + body + ''; } +// normalize a STEPS_KEY entry to { done, assisted }; legacy entries stored a +// bare solved-step count, which maps onto an all-self (no-assist) chapter +function loadStepEntry(store, id) { + const raw = store.chapters[id]; + if (typeof raw === 'number') return { done: Math.max(0, Math.trunc(raw) || 0), assisted: [] }; + if (raw && typeof raw === 'object') { + const done = Math.max(0, Math.trunc(Number(raw.done)) || 0); + const assisted = Array.isArray(raw.assisted) + ? [...new Set(raw.assisted.map((n) => Math.trunc(Number(n))).filter((n) => Number.isFinite(n) && n >= 0))] + : []; + return { done, assisted }; + } + return { done: 0, assisted: [] }; +} + function saveStepProgress(id, done) { const store = loadStore(STEPS_KEY); - if ((store.chapters[id] || 0) === done) return; + const prev = loadStepEntry(store, id); + // only leading solved steps keep an assist mark; a regressed step starts over + const assisted = [...assistedSteps].filter((n) => n < done).sort((a, b) => a - b); + const unchanged = + prev.done === done && + prev.assisted.length === assisted.length && + prev.assisted.every((n, i) => n === assisted[i]); + if (unchanged) return; if (done === 0) delete store.chapters[id]; - else store.chapters[id] = done; + else store.chapters[id] = { done, assisted }; saveStore(STEPS_KEY, store); renderRailMarks(); } @@ -858,8 +885,10 @@ function renderRailMarks() { // chapters draw a ring. stale counts clamp to the ring's step total. ring.hidden = Boolean(progress.chapters[id]); if (ring.hidden) return; - const done = Math.min(total, Math.max(0, Math.trunc(Number(steps.chapters[id])) || 0)); - ring.innerHTML = ringSvg(total, done); + const entry = loadStepEntry(steps, id); + const done = Math.min(total, entry.done); + const assisted = new Set(entry.assisted.filter((n) => n < done)); + ring.innerHTML = ringSvg(total, done, assisted); }); const legend = document.querySelector('.legend-steps'); if (legend) { @@ -950,6 +979,7 @@ function syncStepList(currentIndex, force) { const refold = force || currentIndex !== lastSyncedStep; Array.from(list.children).forEach((item, i) => { item.classList.toggle('is-done', i < currentIndex); + item.classList.toggle('is-assisted', i < currentIndex && assistedSteps.has(i)); item.classList.toggle('is-current', i === currentIndex); const details = item.querySelector('details'); if (details && refold) details.open = i === currentIndex; @@ -1231,16 +1261,14 @@ function closeAssist() { assistRestoreFocus = null; } -function currentStep() { - const index = firstFailingIndex(); - if (index === null || index >= current.steps.length) return null; - return current.steps[index]; -} - function helpAssist() { if (!current || typing) return; - const step = currentStep(); - if (step) assists.help += 1; + const index = firstFailingIndex(); + const step = index !== null && index < current.steps.length ? current.steps[index] : null; + if (step) { + assists.help += 1; + assistedSteps.add(index); // a hint counts this step as assisted once it clears + } focusSeedTab(step); // anchor resolution needs the seeded file visible openAssist(step); } @@ -1328,6 +1356,7 @@ function autoAssist() { return; } assists.auto += 1; + assistedSteps.add(index); // this step was typed in for the user openAssist(step); const finish = () => { closeAssist(); diff --git a/src/styles/site.css b/src/styles/site.css index c922350..dd86b7a 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1425,6 +1425,10 @@ h3:hover .heading-anchor, content: '✓'; } +.step-list li.is-assisted .step-mark { + color: var(--accent); /* step cleared with an assist */ +} + .step-explain { margin: 0; padding: 0 0.9rem 0.65rem 2.4rem; @@ -1466,11 +1470,15 @@ h3:hover .heading-anchor, } .ch-ring .seg.is-done { - stroke: var(--text); + stroke: #3fb950; /* solved by the user */ stroke-opacity: 1; stroke-width: 3; } +.ch-ring .seg.is-done.is-assisted { + stroke: var(--accent); /* solved with an assist */ +} + .ch-mark { display: inline-block; width: 1.3rem; From 18a51392ec18f791eb476dede153f805274cd64d Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 17:15:29 +0200 Subject: [PATCH 26/34] feat: fold the auto-step assist into the help popup "Help me" moves out of the assist bar into the docs row of the chapter head (right-aligned, hidden in the free editor). The "Do the next step for me" button is gone from the main screen; the help popup now offers "Do this step for me" as a follow-up action instead, so auto-solving is reached through asking for help first. The follow-up hides while a step is being applied and on the unapplicable-step error paths. Co-Authored-By: Claude Fable 5 --- src/scripts/tutorial.js | 39 +++++++++++++++++++++++++---------- src/styles/site.css | 45 ++++++++++++++++++++++++++++++++++------- src/tutorial.mjs | 10 ++++----- 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js index 6378f1a..30efbf7 100644 --- a/src/scripts/tutorial.js +++ b/src/scripts/tutorial.js @@ -605,8 +605,10 @@ function openChapter(id) { } terminal.innerHTML = 'press Run to trace the project'; closeAssist(); - for (const btn of [document.getElementById('help-btn'), document.getElementById('auto-btn')]) { - if (btn) btn.disabled = true; // re-enabled once the first analysis lands + const helpBtn = document.getElementById('help-btn'); + if (helpBtn) { + helpBtn.hidden = Boolean(chapter.free); // the sandbox has no steps to help with + helpBtn.disabled = true; // re-enabled once the first analysis lands } // buffers: a finished chapter silently restores its solved files behind the @@ -806,7 +808,7 @@ function completionStatement(entry) { // Each chapter link carries a small SVG donut split into one arc per step, // running clockwise from 12 o'clock: steps solved so far draw bold, the rest // stay thin and muted. A step the user cleared themselves draws green; one -// finished with an assist ("Do the next step for me" / a hint) draws amber. +// finished with an assist ("Do this step for me" / a hint) draws amber. // Per-chapter progress persists under STEPS_KEY ({ done, assisted } written // after every analysis) so the rings survive reloads and chapter switches. // Once a chapter is completed its ring gives way to the ✓ check, so exactly @@ -1003,9 +1005,8 @@ function updateStepUi(fromRealRun) { const firstFailing = firstFailingIndex(); const solved = safeCheck(current.done, lastResult); saveStepProgress(current.id, firstFailing); - for (const btn of [document.getElementById('help-btn'), document.getElementById('auto-btn')]) { - if (btn) btn.disabled = false; - } + const helpBtn = document.getElementById('help-btn'); + if (helpBtn) helpBtn.disabled = false; const completed = Boolean(progressEntry(current.id)); if (solved && (completed || fromRealRun)) { setText('step-progress', chapterCompleteText()); @@ -1117,18 +1118,19 @@ function run(silent) { worker.postMessage({ files, argv }); } -// --- assists: "Help me" / "Do the next step for me" -------------------------- +// --- assists: "Help me" and its "Do this step for me" follow-up -------------- // // Both operate on the current step. Help spotlights the step's anchor inside -// the IDE and explains it in a popup; auto additionally typewrites the step's -// patch into the editor (setRangeText, so undo works) and runs. +// the IDE and explains it in a popup; the popup offers "Do this step for me" +// as a follow-up, which typewrites the step's patch into the editor +// (setRangeText, so undo works) and runs. let typing = false; // typewriter in flight - runs and assists wait let assistRestoreFocus = null; function initAssists() { const helpButton = document.getElementById('help-btn'); - const autoButton = document.getElementById('auto-btn'); + const autoButton = document.getElementById('assist-auto'); const closeButton = document.getElementById('assist-close'); const spotlight = document.getElementById('spotlight'); if (!helpButton || !autoButton || !closeButton || !spotlight) return; @@ -1227,7 +1229,10 @@ function openAssist(step) { const docRef = current.docs[0]; doc.href = docRef.href; doc.textContent = 'Read more: ' + docRef.label; + const auto = document.getElementById('assist-auto'); + if (auto) auto.hidden = !step; // solved chapters leave nothing to apply + const wasOpen = !pop.hidden; const { rect } = assistTarget(step); hole.style.left = rect.left + 'px'; hole.style.top = rect.top + 'px'; @@ -1247,7 +1252,9 @@ function openAssist(step) { pop.style.left = left + 'px'; pop.style.visibility = ''; - assistRestoreFocus = document.activeElement; + // on a follow-up (auto after help) keep the original restore target - the + // active element is then a button inside the popup that is about to hide + if (!wasOpen) assistRestoreFocus = document.activeElement; pop.focus(); } @@ -1315,6 +1322,13 @@ function typewriter(editor, start, oldEnd, insert, onDone) { }, 18); } +// the popup stays open while the step is applied (or explains why it cannot +// be) - either way the follow-up action has been spent, so it hides +function hideAssistAuto() { + const auto = document.getElementById('assist-auto'); + if (auto) auto.hidden = true; +} + function autoAssist() { if (!current || typing) return; const index = firstFailingIndex(); @@ -1327,6 +1341,7 @@ function autoAssist() { if (!focusSeedTab(step)) { // the seeded file this step patches was deleted (unlocked chapters allow it) openAssist(step); + hideAssistAuto(); const text = document.getElementById('assist-text'); if (text) { const seedFile = step.pane === 'spec' ? current.spec.file : current.variant.file; @@ -1346,6 +1361,7 @@ function autoAssist() { const after = applyStep(current, data.lang, step, before); if (after.failed) { openAssist(step); + hideAssistAuto(); const text = document.getElementById('assist-text'); if (text) { text.textContent = @@ -1358,6 +1374,7 @@ function autoAssist() { assists.auto += 1; assistedSteps.add(index); // this step was typed in for the user openAssist(step); + hideAssistAuto(); const finish = () => { closeAssist(); run(false); diff --git a/src/styles/site.css b/src/styles/site.css index dd86b7a..7665d42 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -1365,6 +1365,30 @@ h3:hover .heading-anchor, margin: 0 0 1.25rem; } +/* live head only: docs links left, "Help me" right (the fallback keeps the plain paragraph) */ + +.ch-docs-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1.5rem; + margin: 0 0 1.25rem; +} + +.ch-docs-row .ch-docs { + margin: 0; +} + +.ch-docs-row .btn { + margin-left: auto; + padding: 0.35rem 0.9rem; + font-size: 0.85rem; +} + +.ch-docs-row .btn[hidden] { + display: none; +} + /* step accordion between the intro and the IDE */ .step-list { @@ -1668,15 +1692,22 @@ h3:hover .heading-anchor, background: rgb(255 255 255 / 0.08); } -.assist-actions { - display: flex; - gap: 0.6rem; - margin-left: auto; +/* follow-up action inside the always-dark popup - fixed dark-theme colors */ + +.assist-auto { + margin-top: 0.7rem; + padding: 0.35rem 0.85rem; + border: 1px solid #57534e; + border-radius: 8px; + background: #2a2724; + color: #d4d4d4; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; } -.assist-actions .btn { - padding: 0.35rem 0.9rem; - font-size: 0.85rem; +.assist-auto:hover { + border-color: var(--accent); } /* completed-chapter overlay: the solved IDE stays visible but blurred */ diff --git a/src/tutorial.mjs b/src/tutorial.mjs index 9a2c01e..bf53867 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -112,6 +112,7 @@ function ide(first) {

    +
    `; } @@ -262,17 +263,16 @@ ${chapterRail()}

    Chapter 1 · ${esc(first.group)}

    ${esc(first.title)}

    ${esc(first.intro)}

    -

    Read more: ${docsLinks(first)}

    +
    +

    Read more: ${docsLinks(first)}

    + +
    ${stepList(first)} ${ide(first)}

    Goal: ${esc(first.goal)}

    Run to check your progress.

    -
    - - -
    ${resumeDialog()} ${welcomeDialog()} From 4a354b0c7b09dd4a5a07d50b9892abb8d5548c1c Mon Sep 17 00:00:00 2001 From: LennarX Date: Sat, 18 Jul 2026 17:29:24 +0200 Subject: [PATCH 27/34] feat: style Free editor rail entry as a distinct chip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Free editor is not a progress-tracked chapter, so its rail link no longer reserves the ring/check gutter the chapters use. It now renders as a bordered pill with a ‹› code glyph, setting it apart as a separate tool pinned above the chapter groups. Co-Authored-By: Claude Opus 4.8 --- src/styles/site.css | 27 +++++++++++++++++++++++++++ src/tutorial.mjs | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/styles/site.css b/src/styles/site.css index 7665d42..4388b86 100644 --- a/src/styles/site.css +++ b/src/styles/site.css @@ -915,6 +915,33 @@ button { font-weight: 600; } +/* Free editor is not a progress-tracked chapter, so it drops the ring/check + gutter and reads as a distinct chip pinned above the chapter groups. */ +.nav-group a.fe-pill { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0.25rem 0.25rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); + color: var(--text); + font-weight: 600; +} + +.nav-group a.fe-pill:hover, +.nav-group a.fe-pill.is-current { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-strong); +} + +.fe-ico { + color: var(--accent); + font-weight: 700; +} + .sidebar-backdrop { display: none; } diff --git a/src/tutorial.mjs b/src/tutorial.mjs index bf53867..8db37ed 100644 --- a/src/tutorial.mjs +++ b/src/tutorial.mjs @@ -48,7 +48,7 @@ function chapterRail() { return `