diff --git a/build.mjs b/build.mjs index ed897f0..705407a 100644 --- a/build.mjs +++ b/build.mjs @@ -1,6 +1,6 @@ // Static site generator: renders the flashtrace tool repo's docs/ plus the // hand-written landing page into dist/. Pure Node + marked, no framework. -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -11,6 +11,9 @@ 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'; +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'); @@ -76,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 ------------------------------------------------------ @@ -174,6 +194,41 @@ function renderDoc(page) { }); } +// --- /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 /learn/ 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 /learn/ 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 /learn/ 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 +248,39 @@ for (const page of pages) { writeFileSync(path.join(dir, 'index.html'), renderDoc(page)); } +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')); +// tutorial.js is an entry that imports its engine from sibling ./tutorial-*.mjs +// modules; the browser loads them flat next to it, so copy each one. +for (const name of readdirSync(path.join(root, 'src', 'scripts'))) { + if (/^tutorial-.*\.mjs$/.test(name)) { + cpSync(path.join(root, 'src', 'scripts', name), path.join(learnDir, name)); + } +} +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(learnDir, 'flashtrace.mjs'), 'js'); +} catch (err) { + console.error(`error: tutorial chapter verification failed.\n${err.message}`); + process.exit(1); +} +writeFileSync(path.join(learnDir, 'index.html'), renderTutorial({ version, verified, codeExtensions })); + const sitePaths = [ '/', ...pages.map((p) => (p.slug ? `/docs/${p.slug}/` : '/docs/')), + '/learn/', '/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/examples.mjs b/src/examples.mjs index d6b1548..321cd9e 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 /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 new file mode 100644 index 0000000..79c02f1 --- /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 (/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 +// 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/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/layout.mjs b/src/layout.mjs index 72a790f..332bbb1 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 /learn/ 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';}})();`; @@ -56,9 +40,11 @@ function topBar({ version, active, withSidebar }) { ${esc(version)}
+ Try it out ${githubIcon} ' + : ''; + return ( + '
' + + '' + + trailer + + '
' + ); + }; + const addAttrs = locked ? ' disabled title="' + LOCK_TIP + '"' : ' title="New file"'; + strip.innerHTML = + state.files.map(tab).join('') + + ''; +} + +export function renderAllTabs() { + renderTabs('spec'); + renderTabs('code'); +} + +export function selectTab(pane, index) { + const state = panes[pane]; + if (!state.files[index] || index === state.active) return; + state.active = index; + syncEditors(); +} + +export 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 }; + const text = document.getElementById('delfile-text'); + if (text) text.textContent = '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(); +} + +export function seedBuffers() { + setPanes({ + 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(' '); +} diff --git a/src/scripts/tutorial-highlight.mjs b/src/scripts/tutorial-highlight.mjs new file mode 100644 index 0000000..22e7407 --- /dev/null +++ b/src/scripts/tutorial-highlight.mjs @@ -0,0 +1,64 @@ +// 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.
+import { specEditor, codeEditor } from './tutorial-dom.mjs';
+import { highlightTokens } from './highlight.mjs';
+import { esc } from './report-colors.mjs';
+
+const highlightRenderers = [];
+
+export 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)
+export 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.
+export 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);
+}
diff --git a/src/scripts/tutorial-persistence.mjs b/src/scripts/tutorial-persistence.mjs
new file mode 100644
index 0000000..fb4a26e
--- /dev/null
+++ b/src/scripts/tutorial-persistence.mjs
@@ -0,0 +1,165 @@
+// Persistence for the /learn/ engine: in-flight buffers (BUFFERS_KEY),
+// completions (PROGRESS_KEY) and per-step ring progress (STEPS_KEY), plus the
+// rail marks and the completed-chapter overlay those feed. All three stores
+// are keyed by stable chapter id, never by index.
+import { ide, setText, BUFFERS_KEY, PROGRESS_KEY, STEPS_KEY } from './tutorial-dom.mjs';
+import { loadStore, saveStore, loadStepEntry, progressEntry } from './tutorial-store.mjs';
+import { ringSvg, completionStatement } from './tutorial-util.mjs';
+import {
+  current,
+  currentIndex,
+  data,
+  panes,
+  assists,
+  assistedSteps,
+  checkTimer,
+} from './tutorial-state.mjs';
+
+// 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.
+export function storedPristine(chapter, stored) {
+  if (stored.panes) return false;
+  return stored.spec === chapter.spec.body && stored.code === chapter.variant.body;
+}
+
+export function saveBuffers() {
+  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 =
+    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(),
+      panes: { spec: snapshot('spec'), code: snapshot('code') },
+    };
+  }
+  saveStore(BUFFERS_KEY, store);
+}
+
+export 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.
+export function completeChapter(chapter) {
+  const store = loadStore(PROGRESS_KEY);
+  if (store.chapters[chapter.id]) return;
+  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);
+  saveBuffers(); // keep the solved files - revisits restore them under the overlay
+  clearTimeout(checkTimer);
+  renderRailMarks();
+  setCompletedOverlay(entry);
+}
+
+export function deleteProgressEntry(id) {
+  const store = loadStore(PROGRESS_KEY);
+  delete store.chapters[id];
+  saveStore(PROGRESS_KEY, store);
+}
+
+export function saveStepProgress(id, done) {
+  const store = loadStore(STEPS_KEY);
+  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, assisted };
+  saveStore(STEPS_KEY, store);
+  renderRailMarks();
+}
+
+export function deleteStepsEntry(id) {
+  const store = loadStore(STEPS_KEY);
+  delete store.chapters[id];
+  saveStore(STEPS_KEY, store);
+}
+
+// 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 draws amber. Once a chapter is completed its ring
+// gives way to the ✓ check, so exactly one symbol shows per chapter.
+export 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;
+    // 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 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) {
+    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')];
+    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;
+}
+
+// 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.
+export 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));
+}
+
+export function chapterCompleteText() {
+  const next = data.chapters[currentIndex + 1];
+  return 'Chapter complete ✓' + (next ? ' - up next: ' + (currentIndex + 2) + ' · ' + next.title : ' - that was the last one!');
+}
diff --git a/src/scripts/tutorial-runner.mjs b/src/scripts/tutorial-runner.mjs
new file mode 100644
index 0000000..a7721c0
--- /dev/null
+++ b/src/scripts/tutorial-runner.mjs
@@ -0,0 +1,115 @@
+// The runner: each Run spawns a fresh module worker that executes the real
+// flashtrace release build against the editor buffers; chapter checks then
+// evaluate the worker's structured result. Silent background runs keep the
+// step state live as the user types; a real Run additionally prints output,
+// records `run: true` steps and can complete the chapter.
+import { argvInput, terminal, runButton, RUN_TIMEOUT_MS } from './tutorial-dom.mjs';
+import { colorizeReport, esc } from './report-colors.mjs';
+import { safeCheck } from './tutorial-util.mjs';
+import { recordRealRun, updateStepUi } from './tutorial-steps.mjs';
+import { completeChapter } from './tutorial-persistence.mjs';
+import {
+  typing,
+  active,
+  current,
+  panes,
+  checkTimer,
+  lastResult,
+  setActive,
+  setLastResult,
+  setCheckTimer,
+} from './tutorial-state.mjs';
+
+export function currentArgv() {
+  const raw = argvInput ? argvInput.value : '';
+  return raw.trim().split(/\s+/).filter(Boolean);
+}
+
+function showTerminal(html) {
+  terminal.innerHTML = html;
+}
+
+function finishRun() {
+  if (!active) return;
+  clearTimeout(active.watchdog);
+  active.worker.terminate();
+  setActive(null);
+  runButton.disabled = false;
+}
+
+export function scheduleSilentRun() {
+  if (current && current.free) return; // nothing evaluates sandbox runs in the background
+  clearTimeout(checkTimer);
+  setCheckTimer(setTimeout(() => run(true), 800));
+}
+
+export 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();
+    else return;
+  }
+  if (typeof Worker === 'undefined') {
+    if (!silent) showTerminal('This browser cannot run workers - the live terminal is unavailable.');
+    return;
+  }
+
+  const argv = currentArgv();
+  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) {
+    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();
+    if (!wasSilent) showTerminal(prompt + 'the run did not finish within 5 seconds and was stopped');
+  }, RUN_TIMEOUT_MS);
+  setActive({ worker, watchdog, silent });
+
+  worker.onmessage = (event) => {
+    const wasSilent = active && active.silent;
+    finishRun();
+    const result = event.data;
+    if (!result.ok) {
+      if (!wasSilent) showTerminal(prompt + 'failed to load the flashtrace bundle: ' + esc(result.error) + '');
+      return;
+    }
+    if (!wasSilent) {
+      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');
+    setLastResult(Object.assign({}, result.analysis, {
+      exitCode: result.exitCode,
+      argv,
+      files,
+      spec: paneText('spec'),
+      code: paneText('code'),
+    }));
+    if (!wasSilent) recordRealRun();
+    if (current && !current.free && 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();
+    if (!wasSilent) showTerminal(prompt + 'worker error: ' + esc(event.message || 'unknown') + '');
+  };
+
+  worker.postMessage({ files, argv });
+}
diff --git a/src/scripts/tutorial-state.mjs b/src/scripts/tutorial-state.mjs
new file mode 100644
index 0000000..577100e
--- /dev/null
+++ b/src/scripts/tutorial-state.mjs
@@ -0,0 +1,43 @@
+// Shared engine state for the /learn/ modules. Everything imports these as
+// live bindings, so reads always see the current value; reassignment happens
+// only through the setters below (ES module bindings are read-only to
+// importers). Object/Set members are mutated in place by their owners.
+
+export let data = null; // compiled chapter data, or null in the bare runner
+export let current = null; // active chapter object
+export 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 "+".
+export let panes = null; // { spec: { files: [{ name, body }], active }, code: { ... } }
+export let assists = { help: 0, auto: 0 }; // in-memory per chapter visit
+export let assistedSteps = new Set(); // step indices solved with an assist, current chapter
+// `run: true` steps ask the user to press Run: an index lands here once a
+// real, user-triggered run happened while every step before it passed, so
+// background silent runs never clear one. Seeded from the persisted done
+// count on open, so a revisit does not regress past a run already made.
+export let realRunCleared = new Set();
+export let lastResult = null; // r-context of the latest (also silent) run
+export let active = null; // { worker, watchdog, silent } of the run in flight
+export let saveTimer = null;
+export let checkTimer = null;
+export let lastSyncedStep = null; // step the accordion was last folded to; guards manual folds
+export let typing = false; // typewriter in flight - runs and assists wait
+export let assistRestoreFocus = null;
+
+export function setData(v) { data = v; }
+export function setCurrent(v) { current = v; }
+export function setCurrentIndex(v) { currentIndex = v; }
+export function setPanes(v) { panes = v; }
+export function setAssists(v) { assists = v; }
+export function setAssistedSteps(v) { assistedSteps = v; }
+export function setRealRunCleared(v) { realRunCleared = v; }
+export function setLastResult(v) { lastResult = v; }
+export function setActive(v) { active = v; }
+export function setSaveTimer(v) { saveTimer = v; }
+export function setCheckTimer(v) { checkTimer = v; }
+export function setLastSyncedStep(v) { lastSyncedStep = v; }
+export function setTyping(v) { typing = v; }
+export function setAssistRestoreFocus(v) { assistRestoreFocus = v; }
diff --git a/src/scripts/tutorial-steps.mjs b/src/scripts/tutorial-steps.mjs
new file mode 100644
index 0000000..36914b0
--- /dev/null
+++ b/src/scripts/tutorial-steps.mjs
@@ -0,0 +1,118 @@
+// The step 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; at +// most one step is ever unfolded (a proper accordion). +import { setText } from './tutorial-dom.mjs'; +import { esc } from './report-colors.mjs'; +import { safeCheck } from './tutorial-util.mjs'; +import { progressEntry } from './tutorial-store.mjs'; +import { saveStepProgress, chapterCompleteText } from './tutorial-persistence.mjs'; +import { + current, + lastResult, + assistedSteps, + realRunCleared, + lastSyncedStep, + setLastSyncedStep, +} from './tutorial-state.mjs'; + +// 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. +export 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, + ); +} + +export function renderStepList(chapter) { + const section = document.getElementById('step-list'); + const list = document.getElementById('step-items'); + if (!section || !list) return; + section.hidden = Boolean(chapter.free); + if (section.hidden) return; + list.innerHTML = chapter.steps + .map( + (step, i) => + '
  • ' + + (i + 1) + ' · ' + esc(step.title) + + '

    ' + esc(step.explain) + '

  • ', + ) + .join(''); + syncStepList(0, true); +} + +// 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. +export 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-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; + }); + setLastSyncedStep(currentIndex); +} + +// current step = index of the first failing check; users may type ahead, +// paste solutions or re-break earlier steps - this always re-converges. +// A `run: true` step additionally needs its real run on record. +export function firstFailingIndex() { + if (!current || !lastResult || !lastResult.items) return null; + for (let i = 0; i < current.steps.length; i++) { + const step = current.steps[i]; + if (!safeCheck(step.check, lastResult) || (step.run && !realRunCleared.has(i))) return i; + } + return current.steps.length; +} + +// After a real Run: walk the steps in order and put every `run: true` step +// reached with all its predecessors passing on record - this run was it. +export function recordRealRun() { + if (!current || current.free || !lastResult || !lastResult.items) return; + for (let i = 0; i < current.steps.length; i++) { + const step = current.steps[i]; + if (step.run) realRunCleared.add(i); + if (!safeCheck(step.check, lastResult) || (step.run && !realRunCleared.has(i))) return; + } +} + +export function updateStepUi(fromRealRun) { + if (!current || current.free || !lastResult || !lastResult.items) return; + const steps = current.steps; + const firstFailing = firstFailingIndex(); + const solved = safeCheck(current.done, lastResult); + saveStepProgress(current.id, firstFailing); + 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()); + } 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); + } + syncStepList(firstFailing); + document.dispatchEvent( + new CustomEvent('flashtrace:steps', { + detail: { chapter: current.id, firstFailing, solved, fromRealRun }, + }), + ); +} diff --git a/src/scripts/tutorial-store.mjs b/src/scripts/tutorial-store.mjs new file mode 100644 index 0000000..9720a5d --- /dev/null +++ b/src/scripts/tutorial-store.mjs @@ -0,0 +1,97 @@ +// localStorage-backed stores (ft-tutorial-*) and the server-embedded chapter +// data. Every store degrades to "empty" on unparseable or foreign-versioned +// data so a bad entry never crashes the page. +import { PROGRESS_KEY, BUFFERS_KEY, STEPS_KEY } from './tutorial-dom.mjs'; + +export 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: {} }; +} + +export function saveStore(key, store) { + try { + localStorage.setItem(key, JSON.stringify(store)); + } catch { + /* private mode etc. - persistence is best-effort */ + } +} + +export function removeStores() { + try { + localStorage.removeItem(PROGRESS_KEY); + localStorage.removeItem(BUFFERS_KEY); + localStorage.removeItem(STEPS_KEY); + } catch { + /* ignore */ + } +} + +export function progressEntry(id) { + return loadStore(PROGRESS_KEY).chapters[id]; +} + +// 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 +export 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: [] }; +} + +export 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/solve 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); + if (step.solve) step.solve = compile(step.solve); + } + chapter.variants = { [data.lang]: chapter.variant }; // applyStep's shape + } + return data; + } catch (err) { + console.warn('tutorial: chapter data unavailable, running as a plain editor -', err); + return null; + } +} + +// The free editor: a chapter-shaped sandbox living outside data.chapters. It +// has no steps, goal or completion - just two empty files and the real CLI. +// Its buffers persist under the same store as any chapter, keyed 'editor'. +export const FREE_EDITOR = { + id: 'editor', + free: true, + rev: 0, + title: 'Free editor', + intro: + 'A blank project, all yours: write any spec and code and trace them with the real flashtrace release. No goals, no checks - the chapters in the rail are there whenever you want guidance.', + docs: [ + { label: 'Usage Guide', href: '/docs/usage/' }, + { label: 'Overview', href: '/docs/' }, + ], + argv: [], + spec: { file: 'spec.md', body: '' }, + variant: { file: 'script.js', body: '' }, + steps: [], + done: () => false, +}; diff --git a/src/scripts/tutorial-util.mjs b/src/scripts/tutorial-util.mjs new file mode 100644 index 0000000..c79896b --- /dev/null +++ b/src/scripts/tutorial-util.mjs @@ -0,0 +1,71 @@ +// Pure helpers shared across the /learn/ modules: no DOM, no shared state. + +export 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'; +} + +export function safeCheck(fn, r) { + try { + return fn(r) === true; + } catch { + return false; + } +} + +// shortest edit between the buffer and the patched buffer - typed as one span +export 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) }; +} + +// --- rail progress rings ----------------------------------------------------- + +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); +} + +export function ringSvg(total, done, assisted) { + const radius = 6.25; + const cls = (i) => + 'seg' + (i < done ? (assisted && assisted.has(i) ? ' is-done is-assisted' : ' 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 + ''; +} + +export 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') + '.' + ); +} diff --git a/src/scripts/tutorial-worker.js b/src/scripts/tutorial-worker.js new file mode 100644 index 0000000..5d76a68 --- /dev/null +++ b/src/scripts/tutorial-worker.js @@ -0,0 +1,82 @@ +// Module worker for /learn/: executes the real, release-pinned flashtrace +// bundle (node:* imports rewritten to ./shims/ at build time) against the +// editor buffers as in-memory files, then derives structured results from the +// bundle's exported library surface for the chapter checks. +// +// Protocol: one { files: { name: text }, argv: [extra args] } message in, one +// 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 +// 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 = []; +let exitCode = null; +let exitSeen; +const exited = new Promise((resolve) => { + exitSeen = resolve; +}); + +const isExitSignal = (value) => + globalThis.__FtExitSignal !== undefined && value instanceof globalThis.__FtExitSignal; + +// The CLI ends every path in process.exit(): the shim throws an ExitSignal, +// runCli()'s catch handler logs it via console.error and rethrows a second +// one (code 2) as an unhandled rejection. Both are filtered from the output; +// the first signal observed carries the run's real exit code. +function recordExit(signal) { + if (exitCode === null) exitCode = signal.code; + exitSeen(); +} + +function capture(args) { + const signal = args.find(isExitSignal); + if (signal) { + recordExit(signal); + return; + } + output.push(args.map(String).join(' ')); +} + +console.log = (...args) => capture(args); +console.error = (...args) => capture(args); + +self.addEventListener('unhandledrejection', (event) => { + if (isExitSignal(event.reason)) { + event.preventDefault(); + recordExit(event.reason); + } +}); + +self.onmessage = async (event) => { + const { files, argv = [] } = event.data; + + const vfs = new Map(); + for (const [name, text] of Object.entries(files)) vfs.set('/project/' + name, text); + globalThis.__ftVfs = vfs; + globalThis.__ftArgv = ['node', decodeURIComponent(BUNDLE_URL.pathname), '/project', ...argv]; + + let mod; + try { + mod = await import(BUNDLE_URL.href); + } catch (err) { + postMessage({ ok: false, error: String(err) }); + return; + } + await exited; // resolved by the first ExitSignal - runCli() always exits + + let analysis = null; + try { + analysis = analyzeBuffers(mod, files, argv); + } catch { + // structured results are best-effort; the terminal output stands alone + } + postMessage({ ok: true, output: output.join('\n'), exitCode, analysis }); +}; diff --git a/src/scripts/tutorial.js b/src/scripts/tutorial.js new file mode 100644 index 0000000..0e2355e --- /dev/null +++ b/src/scripts/tutorial.js @@ -0,0 +1,176 @@ +// /learn/ page entry: wires the server-rendered IDE to the chapter engine and +// runner split across ./tutorial-*.mjs. 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 { + ide, + specEditor, + codeEditor, + terminal, + runButton, + argvInput, + setText, + PROGRESS_KEY, +} from './tutorial-dom.mjs'; +import { loadStore, removeStores, progressEntry, loadChapterData, FREE_EDITOR } from './tutorial-store.mjs'; +import { data, current, saveTimer, setData, setPanes, setSaveTimer, setRealRunCleared } from './tutorial-state.mjs'; +import { setupHighlight, syncEditorHeights } from './tutorial-highlight.mjs'; +import { activeFile, initTabs, renderAllTabs, seedBuffers } from './tutorial-files.mjs'; +import { + saveBuffers, + deleteBufferEntry, + deleteProgressEntry, + deleteStepsEntry, + setCompletedOverlay, + renderRailMarks, +} from './tutorial-persistence.mjs'; +import { initStepAccordion, syncStepList } from './tutorial-steps.mjs'; +import { run, scheduleSilentRun } from './tutorial-runner.mjs'; +import { initAssists } from './tutorial-assists.mjs'; +import { chapterById, openChapter } from './tutorial-chapters.mjs'; + +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 + setPanes({ + 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); + syncEditorHeights(); + 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) => { + if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { + event.preventDefault(); + run(false); + } + }); + } + if (argvInput) { + argvInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + run(false); + } + }); + } + + initStepAccordion(); + setData(loadChapterData()); + if (!data) return; // bare runner: Run still works on the visible buffers + + for (const editor of [specEditor, codeEditor]) { + editor.addEventListener('input', () => { + clearTimeout(saveTimer); + setSaveTimer(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(); + }); + } + + const doneView = document.getElementById('done-view'); + if (doneView) { + // peek at the solved files without forgetting the completion: hide the + // card and un-inert the IDE for this visit only. nothing is persisted, so + // reopening the chapter shows the card again. + doneView.addEventListener('click', () => setCompletedOverlay(null)); + } + + 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); + deleteStepsEntry(current.id); + setRealRunCleared(new Set()); + setCompletedOverlay(null); + seedBuffers(); + setText('step-progress', 'Run to check your progress.'); + syncStepList(0, true); + renderRailMarks(); + scheduleSilentRun(); + }); + } + + 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 = + chapterById(fromHash) ?? + data.chapters.find((c) => !progressEntry(c.id)) ?? + data.chapters[0]; + openChapter(initial.id); + + // "Try it out" lands on #editor; on a first visit (no chapter ever + // completed) offer the guided route instead of two empty files + if (initial === FREE_EDITOR && Object.keys(loadStore(PROGRESS_KEY).chapters).length === 0) { + offerWelcome(); + } +} + +function offerWelcome() { + const modal = document.getElementById('welcome-modal'); + const learn = document.getElementById('welcome-learn'); + const editor = document.getElementById('welcome-editor'); + if (!modal || !learn || !editor || typeof modal.showModal !== 'function') return; + const close = (starter) => () => { + modal.close(); + learn.onclick = null; + editor.onclick = null; + starter(); + }; + learn.onclick = close(() => openChapter(data.chapters[0].id)); + editor.onclick = close(() => {}); + modal.oncancel = () => close(() => {})(); // Esc keeps the free editor + modal.showModal(); +} + +if (ide && specEditor && codeEditor && terminal && runButton) { + init(); + initAssists(); +} diff --git a/src/styles/site.css b/src/styles/site.css index ddadce0..edb0716 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 { @@ -358,9 +414,11 @@ button { display: inline-flex; align-items: center; padding: 0.6rem 1.25rem; + border: 0; border-radius: 8px; font-weight: 600; font-size: 0.95rem; + cursor: pointer; } .btn:hover { @@ -415,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; @@ -469,12 +529,37 @@ 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 { border-left: 1px solid #33302c; } +/* a noCode chapter has no source: the code pane disappears entirely and the + spec spreads across the full width */ +.ide-mock.no-code .ide-panes { + grid-template-columns: 1fr; +} + +.ide-mock.no-code .ide-pane + .ide-pane { + display: none; +} + .pane-tab { display: inline-block; padding: 0.35rem 1rem; @@ -795,7 +880,9 @@ button { align-self: start; max-height: calc(100vh - var(--topbar-h)); overflow-y: auto; - padding: 1.5rem 0.5rem 2rem 0; + /* the scroll container clips horizontally - keep left padding >= the + focus outline extent so link outlines and pills aren't cut off */ + padding: 1.5rem 0.5rem 2rem; border-right: 1px solid var(--border); } @@ -838,6 +925,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; } @@ -1182,7 +1296,8 @@ h3:hover .heading-anchor, } @media (max-width: 860px) { - .doc-layout { + .doc-layout, + .tutorial-layout { grid-template-columns: minmax(0, 1fr); } @@ -1234,3 +1349,809 @@ h3:hover .heading-anchor, display: none; } } + +/* ---------- tutorial (/learn/) ---------- */ + +/* compact CTA variant of .btn-primary for the top bar */ +.btn-cta { + padding: 0.3rem 0.85rem; + margin-right: 0.5rem; + border-radius: 6px; + font-size: 0.85rem; + white-space: nowrap; +} + +.tutorial-layout { + max-width: 90rem; + margin: 0 auto; + 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 { + font-size: clamp(1.6rem, 3vw, 2.1rem); + margin: 0 0 0.5rem; + letter-spacing: -0.01em; +} + +.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; +} + +/* 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 { + margin: 0 0 1rem; +} + +.step-list[hidden] { + display: none; +} + +.step-list ol { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-raised); + overflow: hidden; +} + +.step-list li + li { + border-top: 1px solid var(--border); +} + +.step-list li.is-current { + background: color-mix(in srgb, var(--accent) 7%, transparent); +} + +.step-list summary { + display: flex; + align-items: baseline; + gap: 0.4rem; + padding: 0.5rem 0.9rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + list-style: none; +} + +.step-list summary::-webkit-details-marker { + display: none; +} + +.step-list li.is-done summary { + color: var(--muted); + font-weight: 400; +} + +.step-mark { + display: inline-block; + width: 1.1rem; + color: #3fb950; + font-weight: 700; + flex: none; +} + +.step-list li.is-done .step-mark::before { + 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; + font-size: 0.9rem; + color: var(--muted); + max-width: 56rem; +} + +.fallback-steps li { + margin: 0.35rem 0; +} + +/* 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; +} + +/* 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; + 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: #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; + 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: '✓'; +} + +.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; +} + +/* 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); + font-size: 0.95rem; +} + +.assist-progress { + margin: 0; + color: var(--muted); + 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); +} + +/* 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-auto:hover { + border-color: var(--accent); +} + +/* 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; +} + +.done-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: center; +} + +/* 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 { + 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; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1rem; +} + +/* 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; + 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.8rem; + line-height: 1.4; +} + +/* 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 */ +.ide-lg .ide-panes { + align-items: stretch; +} + +.editor { + display: block; + width: 100%; + min-height: 22rem; + margin: 0; + padding: 0.9rem 1rem; + border: 0; + border-top: 1px solid #33302c; + border-radius: 0; + resize: vertical; + 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; + caret-color: var(--accent); +} + +.editor:focus-visible { + outline: none; + 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;
    +}
    +
    +/* 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;
    +}
    +
    +/* 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;
    +  overflow: auto;
    +}
    +
    +.run-btn {
    +  margin-left: auto;
    +  border: 1px solid transparent;
    +  border-radius: 6px;
    +  padding: 0.2rem 0.85rem;
    +  background: var(--accent);
    +  color: #1e1e1e;
    +  font-size: 0.8rem;
    +  font-weight: 650;
    +  cursor: pointer;
    +}
    +
    +.run-btn:hover {
    +  filter: brightness(1.06);
    +}
    +
    +.run-btn:disabled {
    +  opacity: 0.55;
    +  cursor: default;
    +  filter: none;
    +}
    +
    +.tutorial-nojs {
    +  margin: 1.5rem auto 0;
    +  max-width: 56rem;
    +  color: var(--muted);
    +}
    +
    +@media (max-width: 700px) {
    +  .editor {
    +    min-height: 14rem;
    +  }
    +}
    diff --git a/src/tutorial.mjs b/src/tutorial.mjs
    new file mode 100644
    index 0000000..1fd5656
    --- /dev/null
    +++ b/src/tutorial.mjs
    @@ -0,0 +1,300 @@
    +// The /learn/ 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, GITHUB_URL, highlightTokens, pageShell } from './layout.mjs';
    +import { colorizeReport } from './report-colors.mjs';
    +
    +const LANG = 'js';
    +
    +const dots = ``;
    +
    +function docsLinks(chapter) {
    +  return chapter.docs
    +    .map((d) => `${esc(d.label)}`)
    +    .join(' · ');
    +}
    +
    +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 `
    +`;
    +}
    +
    +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 `
    +
    + ${dots} + chapter 1 · ${esc(first.title)} + +
    +
    +
    + ${paneTabs('spec', first.spec.file, first.locked, 'Spec files')} + +
    +
    + ${paneTabs('code', variant.file, first.locked, 'Code files')} + +
    +
    +
    +
    + terminal + $ npx flashtrace + +
    +
    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

    +

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

    + +
    + + +
    +
    `; +} + +// 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() { + 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}
    `; +} + +// 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)}

    +
      + ${chapter.steps.map((step) => `
    1. ${esc(step.title)}. ${esc(step.explain)}
    2. `).join('\n ')} +
    +
    + ${miniWindow(chapter.spec.file, highlightTokens(esc(chapter.spec.body)))} + ${chapter.noCode ? '' : 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, codeExtensions }) { + 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)}

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

    Goal: ${esc(first.goal)}

    +

    Run to check your progress.

    +
    +${resumeDialog()} +${welcomeDialog()} +${fileDialogs(codeExtensions)} +${fallbackChapters(verified)} + + +
    +
    `; + + return pageShell({ + 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: '/learn/', + version, + active: 'tutorial', + body, + bodyClass: 'page-tutorial', + withSidebar: true, + }); +} diff --git a/src/tutorial/chapter-utils.mjs b/src/tutorial/chapter-utils.mjs new file mode 100644 index 0000000..b4a281f --- /dev/null +++ b/src/tutorial/chapter-utils.mjs @@ -0,0 +1,135 @@ +// 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/learn/. + +// 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' (append onto an empty buffer +// skips the prefix), insertBefore suffixes it, so multi-line snippets and +// blank separator lines are encoded in the snippet. `snippetOverride` replaces +// the static snippet text - the adaptive "solve" path of the auto assist. +export function applyStep(chapter, lang, step, state, snippetOverride) { + 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 = snippetOverride ?? (patch.snippet === undefined ? '' : pack.snippets[patch.snippet]); + let start; + let end; + let insert; + if (patch.op === 'append') { + start = text.length; + end = text.length; + insert = text.length === 0 ? snippet : '\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..bbd2a3e --- /dev/null +++ b/src/tutorial/chapters.mjs @@ -0,0 +1,927 @@ +// 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, 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 +// - `noCode: true` hides the code pane; the variant then only seeds a hidden +// empty file so the pane model and the run inputs keep their usual shape +// - a step with `run: true` (patch: null, pane: 'argv') asks the user to press +// Run: it only counts once a real, user-triggered run happened while every +// step before it passed - background silent runs never clear it +// - a step's optional `solve` (serialized like `check`) maps the latest run +// result to the snippet text "Do this step for me" should type, adapting +// the static example to names the user chose; null falls back to the snippet + +export const chapters = [ + // ------------------------------------------------------------ foundations + { + id: 'first-item', + title: 'Your first item', + group: 'Foundations', + locked: true, + noCode: 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, any paragraph below its description. The file is empty and there is no code yet: invent your own first item and watch the tracer pick it up.', + goal: 'Create one traceable item of your own: any heading, any ID.', + docs: [ + { label: 'Markdown items', href: '/docs/markdown-items/' }, + { label: 'Item IDs', href: '/docs/item-ids/' }, + ], + argv: [], + startsClean: true, + spec: { + file: 'spec.md', + body: '', + anchors: {}, + snippets: { + heading: '## 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: '', + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + title: 'Write a heading', + explain: + 'Give your item a title: any Markdown heading you like, say "## Login requirement". On its own 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) => /^#{1,6} \S/m.test(r.spec), + }, + { + title: 'Lay down an ID line', + explain: + 'Now the item itself: below the heading, a line containing nothing but an ID of your choice in backticks. The format is type:name#revision - `req:auth/login#1`, for example. 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) => Boolean(i.title)), + }, + { + title: 'Describe the item', + explain: + 'Give the item a description in your own words: 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) => Boolean(i.title) && i.description.length > 0), + }, + ], + done: (r) => r.clean && r.items.some((i) => Boolean(i.title) && i.description.length > 0), + }, + + { + 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 - the name of the needed item is yours to pick - 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: [ + { + title: 'Demand an implementation', + explain: + 'Add a Needs: line below the description naming an implementation item - call it what you like, "Needs: impl:auth/login#1" for instance. 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.length > 0), + }, + { + title: 'Cover it from the code', + explain: + 'Cover it from the code: put a comment holding the needed ID in square brackets - like "// [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' }, + solve: (r) => { + const q = r.items.find((i) => i.id === 'req:auth/login#1'); + return q && q.needs.length > 0 ? '// [' + q.needs[0] + ']' : null; + }, + check: (r) => { + const q = r.items.find((i) => i.id === 'req:auth/login#1'); + return ( + q !== undefined && + q.needs.length > 0 && + q.needs.every((n) => r.items.some((i) => i.id === n && i.origin === 'code')) + ); + }, + }, + ], + done: (r) => { + const q = r.items.find((i) => i.id === 'req:auth/login#1'); + return ( + r.clean && + q !== undefined && + q.needs.length > 0 && + q.needs.every((n) => r.items.some((i) => i.id === n && i.origin === 'code')) + ); + }, + }, + + { + 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.', + 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: [ + { + title: 'Run the trace', + explain: + 'Press ▶ Run and read the report before touching anything. Each defective item gets one ✘ block: its ID, its location and one bullet per defect. Three blocks come back - and two of them mirror each other.', + pane: 'argv', + run: true, + anchor: null, + patch: null, + check: (r) => r.exitCode !== null, + }, + { + 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', + 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'), + }, + { + 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. Note: In a real-world scenario its possible that actually the implementation is stale and should be removed.', + 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: & unwanted', + group: 'Foundations', + locked: true, + intro: + 'Coverage can also be declared from the covering side: a Covers: entry names the item this one covers. Declaring it is only half the deal, though - the entry counts once the target asks for the coverer in its own Needs. Wire the login requirement to the auth feature and close that loop.', + 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: true, + 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.`, + anchors: { + loginDescription: '^Users must be able to log in with email and password\\.$', + featDescription: '^Everything a user needs to prove who they are\\.$', + }, + snippets: { + coversLine: '\nCovers: 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: [ + { + title: 'Write a Covers: entry', + explain: + 'Below the login description, add "Covers: feat:auth#1" - the requirement now claims to cover the feature above it.', + pane: 'spec', + anchor: 'loginDescription', + patch: { op: 'insertAfter', snippet: 'coversLine' }, + check: (r) => { + const q = r.items.find((i) => i.id === 'req:auth/login#1'); + return q !== undefined && q.covers.includes('feat:auth#1'); + }, + }, + { + title: 'Run the trace', + explain: + 'Run it: the Covers entry comes back unwanted - feat:auth#1 exists, but it never asked for this coverage. (Pointing at an ID that does not exist at all would be an orphaned entry instead.) One side declaring is not enough.', + pane: 'argv', + run: true, + anchor: null, + patch: null, + check: (r) => r.exitCode !== null, + }, + { + title: 'Close the loop', + explain: + 'A Covers entry is only valid when the target lists the coverer in its own Needs. Add "Needs: req:auth/login#1" below the feature\'s description - run again and the loop closes.', + 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')) && + r.items.some((i) => i.id === 'req:auth/login#1' && i.covers.includes('feat:auth#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: [ + { + 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', + 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')), + ), + }, + { + 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', + 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: [ + { + 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', + anchor: null, + patch: { op: 'append', snippet: 'testCode' }, + 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', + 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 - shallow while the target is shallow, deep only once the target is deep. The -v report draws the delegation as a → edge.', + goal: 'Delegate req:login#1 to the auth design, then deepen the design so the requirement follows.', + 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] +// [>>utest:auth#1] +export function openSession(token) { + return sessions.issue(token); +}`, + anchors: {}, + snippets: { + authTest: `\n// [utest:auth#1] +test('auth issues a session', () => { + expect(openSession('tok')).toBeTruthy(); +});`, + }, + }, + }, + steps: [ + { + title: 'Delegate to the design', + explain: + 'There is no impl:login#1 and there never will be - the auth design below owns the details. Replace the dead-end Needs line with the forwarding tag [req:login#1 --> dsn:auth#2]: the requirement drops its own obligation and follows the design instead, drawn as a → edge in the report. Run it - the design is only ~ shallow-covered right now, so the requirement inherits exactly that: shallow, not green yet.', + 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); + }, + }, + { + title: 'Deepen the design', + explain: + 'Forwarding tracks the target\'s depth precisely, so deepen the design and the requirement moves with it. impl:auth#1 demands a utest nobody wrote yet - add the test and tag it "// [utest:auth#1]". The design turns ✔ deep-covered, and req:login#1, still just pointing at it, follows to deep-covered too - you never touched the requirement again.', + pane: 'code', + anchor: null, + patch: { op: 'append', snippet: 'authTest' }, + check: (r) => r.items.some((i) => i.id === 'utest:auth#1'), + }, + ], + 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. Here the auth requirement is not filed under any tag yet, and the reporting requirement fails only because its code lives in another repository slice. Label the auth work first, then narrow the run to it.', + goal: 'Tag the auth requirement, then scope the run to it so this slice traces 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 + +## Weekly report + +\`req:report/weekly#1\` + +Ops can inspect weekly usage numbers. + +Needs: impl:report/weekly#1 + +Tags: reporting`, + anchors: { + loginNeeds: '^Needs: impl:auth/login#1$', + }, + snippets: { + tagsLine: '\nTags: auth', + }, + }, + variants: { + js: { + file: 'login.js', + body: `// [impl:auth/login#1] +export function login(email, password) { + return session.open(email, password); +}`, + anchors: {}, + snippets: {}, + }, + }, + steps: [ + { + title: 'File it under a tag', + explain: + 'Group items with a Tags: line. Below the login requirement\'s Needs, add "Tags: auth" as its own keyword block - a blank line above it, just like Needs and Covers. Run it: nothing moves yet - a tag is only a label until a run filters on it.', + pane: 'spec', + anchor: 'loginNeeds', + patch: { op: 'insertAfter', snippet: 'tagsLine' }, + check: (r) => r.items.some((i) => i.id === 'req:auth/login#1' && i.tags.includes('auth')), + }, + { + 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', + 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: [ + { + 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', + anchor: 'strayDemand', + patch: { op: 'replaceMatch', anchor: 'strayDemand', snippet: 'nothing' }, + 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', + anchor: 'wrongSessionsRev', + patch: { op: 'replaceMatch', anchor: 'wrongSessionsRev', snippet: 'sessionsFixed' }, + 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', + patch: { op: 'replaceLine', anchor: 'coversLine', snippet: 'coversFixed' }, + 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', + anchor: 'duplicateBlock', + patch: { op: 'replaceMatch', anchor: 'duplicateBlock', snippet: 'nothing' }, + 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, + 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: [ + { + 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', + 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), + }, + { + 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', + 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: [ + { + 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', + 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/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 }; diff --git a/src/tutorial/verify.mjs b/src/tutorial/verify.mjs new file mode 100644 index 0000000..15ff0a2 --- /dev/null +++ b/src/tutorial/verify.mjs @@ -0,0 +1,249 @@ +// 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 /learn/ 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) { + // `noCode`/`run`/`solve` only serialize when set, so chapters without them + // keep the rev they had before these fields existed + return { + id: chapter.id, + title: chapter.title, + group: chapter.group, + locked: Boolean(chapter.locked), + ...(chapter.noCode ? { noCode: true } : {}), + 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) => ({ + title: step.title, + explain: step.explain, + pane: step.pane, + anchor: step.anchor, + patch: step.patch, + check: step.check.toString(), + ...(step.run ? { run: true } : {}), + ...(step.solve ? { solve: step.solve.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, files, 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', + ); + + let beforeStep = start; + for (let i = 0; i < chapter.steps.length; i++) { + const step = chapter.steps[i]; + // an adaptive solve() must reproduce the static snippet when the user + // followed the static path - anything else would drift from the patch + // the auto assist falls back to + if (step.solve && step.patch && step.patch.snippet !== undefined) { + const pack = step.pane === 'spec' ? chapter.spec : chapter.variants[lang]; + assertThat( + step.solve(beforeStep.r) === pack.snippets[step.patch.snippet], + chapter, + `step ${i + 1} solve`, + 'solve() on the pre-step state does not reproduce the static snippet', + ); + } + state = applyStep(chapter, lang, step, 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); + beforeStep = after; + 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; +}