From c78881a9ce05f361c4bd2fa82e7044ccec9915e1 Mon Sep 17 00:00:00 2001 From: Remon Panman <228601219+Tradebaas@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:17:38 +0200 Subject: [PATCH 1/3] feat(checks): the board gets the look it was given, and the page stops scrolling The board was rebuilt against the reference the owner supplied on 2026-08-24 and had never been built to. Dark is that reference's own charcoal with one peach accent; light is this project's own, measured (ink 15.9:1, accent 4.5:1 on canvas, white on accent 4.9:1). The lanes sit in a row with the folded ones as upright strips, and the icons are Lucide's own geometry inlined and animated in CSS, because the board serves default-src 'none' and can load nothing at runtime. The board is five pages now, not one: the way in, the lanes, the epic, the features, one feature, and a folder index behind a chapter that holds too many documents to list. Every one of them carries the same sidebar, which is what a followed link had no way back from before. The four shelves are that sidebar's four chapters: the shelf rule still decides where a document stands, still from its path alone, but it decides a chapter rather than a section under the lanes. The page holds still and the containers in it move. On a viewport-sized screen the document body never scrolls: the sidebar scrolls in itself, the lane row sideways, each open lane in itself, and every other page in its own column. Below the stacking breakpoint, and on paper, the page scrolls the ordinary way, because a stacked sidebar above a locked page would put everything under it out of reach. Measured in a browser at 1440, 1280, 820 and 390, and at a short viewport where a lane has to scroll: an open
keeps its content in a box of its own, so the height is handed down through ::details-content or the cards below it are laid out at full length and clipped away. Both scrolling regions are reachable by keyboard: a lane's cards hold nothing focusable, so the box is its own stop and carries the lane's name, and the skip link's target takes focus rather than only the scroll position. Four defects the rebuild had left in it are fixed here rather than tested around. A docs/ folder that cannot be read was swallowed and rendered as a project with no documents; it is named in the sidebar where its subjects would have stood. A document page offered one click back to a shelf section no page renders any more; the shelf is a statement now, since the sidebar it sits in already says where the reader is. The printed file carried a sidebar of addresses that answer nothing off the network, breaking the one promise it makes: it has no navigation, because a file with one page in it has nowhere to navigate to. And the skip link was hard-coded English on a board whose framing words follow the project's language. Nothing dead ships with it: shelvesSection and the renderShelves, shelf and row it called, the two words only they read, the .shelf:target and .docs rules, and the owns field shelfDocuments still gathered from the docs manifest for a row that no longer exists. One rule lives in shelves.mjs now instead of two. The suites were redrawn against the markup that exists, not softened to fit it. board.test.mjs failed 19 of 28 and board-file.test.mjs 5 of 7; board-server lost one to the dead anchor, which now asserts the opposite. One test retired whole with its reason at the site, because no rendering carries a manifest sentence any more. Two were added: every route the sidebar offers is walked, and a feature key naming nothing is refused. The suite split rather than growing past the code-file cap, so board-nav.test.mjs sits beside board-nav.mjs and both lists that name a suite gained one. Traces-to: SC-10 --- .github/workflows/ci.yml | 1 + checks/board-document.mjs | 27 ++- checks/board-file.test.mjs | 13 +- checks/board-fixture.mjs | 18 +- checks/board-icons.mjs | 99 ++++++++++ checks/board-nav.mjs | 219 ++++++++++++++++++++++ checks/board-nav.test.mjs | 186 ++++++++++++++++++ checks/board-page.mjs | 147 +++++++++++---- checks/board-server.mjs | 42 ++++- checks/board-server.test.mjs | 8 +- checks/board-shell.mjs | 353 +++++++++++++++++++++++++---------- checks/board-views.mjs | 135 ++++++++++++++ checks/board.test.mjs | 177 +++--------------- checks/drill.mjs | 4 +- checks/shelves.mjs | 82 ++------ 15 files changed, 1129 insertions(+), 382 deletions(-) create mode 100644 checks/board-icons.mjs create mode 100644 checks/board-nav.mjs create mode 100644 checks/board-nav.test.mjs create mode 100644 checks/board-views.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19abd5a..d37ebc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: node checks/work.test.mjs node checks/board-path.test.mjs node checks/board-server.test.mjs + node checks/board-nav.test.mjs node checks/board.test.mjs node checks/board-strip.test.mjs node checks/board-file.test.mjs diff --git a/checks/board-document.mjs b/checks/board-document.mjs index 62a7b8d..16ba3d4 100644 --- a/checks/board-document.mjs +++ b/checks/board-document.mjs @@ -43,34 +43,43 @@ export function formatSize(bytes) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -// Where a reader came from, and the one click back. The shelf is not remembered and not passed -// along: it is read off the path by the same rule that put the file on that shelf, so a link -// somebody kept says the same thing tomorrow. A file that stands on no shelf (the readers the two -// lines under the shelves name, which live outside docs/) goes back to the board itself. +// Where this document stands. The shelf is not remembered and not passed along: it is read off +// the path by the same rule that put the file there, so a link somebody kept says the same thing +// tomorrow. It is a statement, not a link, because since S-07 the shelf is a chapter of the +// sidebar this page already carries, opened on the row the reader is on. A file that stands on no +// shelf (the readers outside docs/) keeps the one click back to the board. function backFrom(relPath, w) { const shelf = shelfFor(relPath); return shelf - ? `${escapeHtml(w.onShelf)} ${escapeHtml(w.shelfNames[shelf])}.` + ? `${escapeHtml(w.onShelf)} ${escapeHtml(w.shelfNames[shelf])}.` : `${escapeHtml(w.back)}`; } -export function renderFile(project, relPath, text) { +// The sidebar comes in from the caller rather than being built here: this module renders one +// document and knows nothing about the tree it hangs in, and a document page without navigation +// is a dead end a reader has to use the back button to leave. +export function renderFile(project, relPath, text, nav = '', title = null) { const w = words(project.lang); return page({ lang: project.lang, + nav, + frame: true, title: relPath, body: [ - `

${escapeHtml(relPath)}

`, - `

${escapeHtml(w.readOnly)} ${backFrom(relPath, w)}

`, + `

${escapeHtml(title || relPath)}

`, + `

${escapeHtml(relPath)} - ${escapeHtml(w.readOnly)} ` + + `${backFrom(relPath, w)}

`, `
${escapeHtml(text)}
`, ].join('\n'), }); } -export function renderNotice(project, notice) { +export function renderNotice(project, notice, nav = '') { const w = words(project.lang); return page({ lang: project.lang, + nav, + frame: true, title: notice, body: [ `

${escapeHtml(notice)}

`, diff --git a/checks/board-file.test.mjs b/checks/board-file.test.mjs index 33e4a95..6bd0c8d 100644 --- a/checks/board-file.test.mjs +++ b/checks/board-file.test.mjs @@ -80,7 +80,11 @@ test('the printed file is the same picture as the served board, down to the last .replace(LIVE, `${WHEN} ${NAMES}`) .replace(HERE, (m, n, t) => THERE(n, t)) // A name is a name: the anchor goes, the name it wrapped stays exactly as it was. - .replace(/([^<]*<\/code>)<\/a>/g, '$1'); + .replace(/([^<]*<\/code>)<\/a>/g, '$1') + // The fourth rule, added by S-07. A served page is a frame: it fills the window and the + // containers in it scroll, so the lanes stay where the reader put them. A file is one long + // document that scrolls the ordinary way, and the class is the whole of that difference. + .replace(' class="frame"', ''); assert.equal(asPrinted, printed); // And the served board really did carry all three, so the diff above proved something. assert.match(served, new RegExp(LIVE)); @@ -165,9 +169,10 @@ test('a copy that has planned nothing prints too, and reads as not started', () assert.match(printed, /<\/html>\n$/); assert.match(text, /Scope is not defined yet/); assert.match(text, new RegExp(`Made from the project files on ${STAMP}`)); - // Every shelf is named and says it holds nothing, the same as on the served board. - assert.equal(printed.split('
/); + // Redrawn for S-07: the shelves became the sidebar's chapters, and a printed file has no + // sidebar at all, because there is nowhere in it to navigate to. What has to hold is that an + // empty copy still prints as not started rather than as a failure, which the rest of this asks. + assert.doesNotMatch(printed, /
]/); // Not an error and not a blank page: the file still says what to do next. assert.match(text, /names no next step/); diff --git a/checks/board-fixture.mjs b/checks/board-fixture.mjs index 8d36de1..1bd13ee 100644 --- a/checks/board-fixture.mjs +++ b/checks/board-fixture.mjs @@ -109,18 +109,26 @@ export const idChip = (id) => new RegExp(`${id}`); // matching across the whole page. Each keeps its own markup: the fold state is part of what is // asserted. export function laneOf(html, name) { - const found = html.split('
').slice(1) + const found = html.split(/
/).slice(1) .find((s) => s.includes(`${name}`)); assert.ok(found, `no lane called ${name} on the page`); return found.split('
')[0]; } -export function shelfOf(html, key) { - const found = html.split(`
`)[1]; - assert.ok(found, `no shelf called ${key} on the page`); - return found.split('
')[0]; +// The sidebar cut into one chapter. Since S-07 a shelf is a chapter of the navigation rather +// than a section of the board, so this is where a test asks what stands on one. The chapter is +// found by the name a reader sees, because that name is the whole of what identifies it. +export function chapterOf(html, name) { + const found = html.split('
').slice(1) + .find((s) => s.includes(`${name}`)); + assert.ok(found, `no chapter called ${name} in the sidebar`); + return found.split('
')[0]; } +// How many chapters the sidebar holds. An empty copy has none, and a docs/ folder that could not +// be read has none either - and those two must not be told apart by counting alone. +export const chapterCount = (html) => html.split('
').length - 1; + // ---------------------------------------------------------------- talking to a served page export function get(port, path, { host, method = 'GET' } = {}) { diff --git a/checks/board-icons.mjs b/checks/board-icons.mjs new file mode 100644 index 0000000..200a03a --- /dev/null +++ b/checks/board-icons.mjs @@ -0,0 +1,99 @@ +// The icon set: Lucide's own geometry (lucide-static 1.34.0, ISC), inlined here and animated in +// CSS. The animated Lucide library the owner pointed at is React plus Motion, so its motion needs +// JavaScript at runtime, and this board serves default-src 'none' on purpose: no script runs on +// any page it makes. The motion is therefore rebuilt as keyframes in the one inline stylesheet - +// the same icons, the same hover behaviour, nothing to execute. Every shape carries +// pathLength="1", so one draw keyframe fits an icon of any size. +// +// Attribution: Lucide (lucide.dev), ISC License, Copyright (c) Lucide Icons and Contributors. + +const BODY = { + 'house': ``, + 'columns-3': ``, + 'target': ``, + 'layers': ``, + 'file-text': ``, + 'eye': ``, + 'crosshair': ``, + 'book-a': ``, + 'network': ``, + 'palette': ``, + 'scale': ``, + 'inbox': ``, + 'triangle-alert': ``, + 'history': ``, + 'archive': ``, + 'compass': ``, + 'shield-check': ``, + 'book-open': ``, + 'chevron-right': ``, + 'circle-check': ``, + 'loader-circle': ``, + 'list-checks': ``, + 'folder-tree': ``, + 'square-pen': ``, +}; + +// Which motion an icon uses when a reader points at the row it sits in. +const MOTION = { + 'house': 'draw', + 'columns-3': 'lift', + 'target': 'pulse', + 'layers': 'lift', + 'file-text': 'draw', + 'eye': 'blink', + 'crosshair': 'pulse', + 'book-a': 'draw', + 'network': 'draw', + 'palette': 'tilt', + 'scale': 'tilt', + 'inbox': 'lift', + 'triangle-alert': 'pulse', + 'history': 'tilt', + 'archive': 'lift', + 'compass': 'tilt', + 'shield-check': 'draw', + 'book-open': 'draw', + 'chevron-right': 'nudge', + 'circle-check': 'pulse', + 'loader-circle': 'spin', + 'list-checks': 'draw', + 'folder-tree': 'draw', + 'square-pen': 'draw', +}; + +// One icon, as inline SVG. An unknown name renders nothing rather than a broken box: an icon is +// decoration here, and the label beside it is what carries the meaning. aria-hidden for the same +// reason - a screen reader that read both would hear the row twice. +export function icon(name) { + const body = BODY[name]; + if (!body) return ''; + return ``; +} + +export const ICON_STYLE = `.ico{width:16px;height:16px;flex:none;overflow:visible} +.ico>*{transform-box:view-box;transform-origin:12px 12px} +@keyframes ico-draw{from{stroke-dasharray:1;stroke-dashoffset:1}to{stroke-dasharray:1;stroke-dashoffset:0}} +@keyframes ico-pulse{0%{transform:scale(1)}45%{transform:scale(.7)}100%{transform:scale(1)}} +@keyframes ico-lift{0%{transform:translateY(0)}40%{transform:translateY(-2.5px)}100%{transform:translateY(0)}} +@keyframes ico-tilt{0%{transform:rotate(0)}40%{transform:rotate(-9deg)}100%{transform:rotate(0)}} +@keyframes ico-blink{0%,100%{transform:scaleY(1)}50%{transform:scaleY(.08)}} +@keyframes ico-nudge{0%,100%{transform:translateX(0)}50%{transform:translateX(2.5px)}} +@keyframes ico-spin{to{transform:rotate(360deg)}} +@media(prefers-reduced-motion:no-preference){ + /* The motion belongs to the row, not to the icon: pointing at a link is what plays it. */ + a:hover .m-draw>*,summary:hover .m-draw>*{animation:ico-draw .5s ease-out both} + a:hover .m-draw>*:nth-child(2),summary:hover .m-draw>*:nth-child(2){animation-delay:.07s} + a:hover .m-draw>*:nth-child(3),summary:hover .m-draw>*:nth-child(3){animation-delay:.14s} + a:hover .m-draw>*:nth-child(4),summary:hover .m-draw>*:nth-child(4){animation-delay:.21s} + a:hover .m-pulse>*,summary:hover .m-pulse>*{animation:ico-pulse .5s ease-out both} + a:hover .m-pulse>*:nth-child(2),summary:hover .m-pulse>*:nth-child(2){animation-delay:.06s} + a:hover .m-pulse>*:nth-child(3),summary:hover .m-pulse>*:nth-child(3){animation-delay:.12s} + a:hover .m-lift>*:first-child,summary:hover .m-lift>*:first-child{animation:ico-lift .45s ease-out both} + a:hover .m-tilt,summary:hover .m-tilt{animation:ico-tilt .45s ease-out both} + a:hover .m-blink>*:nth-child(2),summary:hover .m-blink>*:nth-child(2){animation:ico-blink .4s ease-in-out both} + a:hover .m-nudge,summary:hover .m-nudge{animation:ico-nudge .4s ease-out both} + .m-spin{animation:ico-spin 1.6s linear infinite} +}`; diff --git a/checks/board-nav.mjs b/checks/board-nav.mjs new file mode 100644 index 0000000..5229d30 --- /dev/null +++ b/checks/board-nav.mjs @@ -0,0 +1,219 @@ +// The sidebar: four destinations, four subjects, one word per row. It is the whole navigation of +// this board and the first thing anyone who adopts Groundwork sees of their own project, so it is +// held to one rule above all others: a person finds where they are going without reading. +// +// The shape follows the way Confluence tells a team to lay a project space out - a shallow tree of +// subjects with their pages under them, and an index page per kind rather than every file in the +// navigation. Groundwork's own four shelves (decision 0021, Diataxis) are those subjects. +// +// What is deliberately not in here, and why: +// - The work tree. Its epic, features and stories are the three derived pages at the top; listing +// the same files again would put every card in the tree twice. +// - Templates. They are scaffolding for the agent, not pages the owner reads, and four of them +// carry the same title as the document they are a blank of. +// - Anything the file route will not serve. In navigation, a row you cannot open is not a +// destination; it is a status report, and it is named on the index page where the reason fits. +// - Any folder holding more than FOLD_AT documents. That folder becomes one row to its own index +// page. Twenty-one decisions under one heading is a filing cabinet, not a choice. +// None of that hides a document: every one of them is named on an index page one click away. +// +// Sources: Atlassian, "Organize and customize your Confluence space" and "Best practices for a +// great page tree experience"; Confluence Data Center, "Blueprints" (an index page per kind, +// linked from the sidebar). + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { SHELVES, OTHER } from './shelves.mjs'; +import { escapeHtml, fileHref } from './board-shell.mjs'; +import { icon } from './board-icons.mjs'; + +// Past this many documents a folder stops being a list and becomes a place. +const FOLD_AT = 3; + +// Which icon a row gets. Its path decides, first match wins, the way the shelf rule does: no file +// is tagged by hand, and a project that adds a folder still gets a sensible face. +const ICONS = [ + ['docs/product/VISION', 'eye'], + ['docs/product/BRIEF', 'crosshair'], + ['docs/product/CONTEXT', 'book-a'], + ['docs/product/ARCHITECTURE', 'network'], + ['docs/state/INTAKE', 'inbox'], + ['docs/state/DEBT', 'triangle-alert'], + ['docs/state/log', 'history'], + ['docs/state/', 'square-pen'], + ['docs/specs/archive', 'archive'], + ['docs/specs', 'file-text'], + ['docs/decisions', 'scale'], + ['docs/standards', 'compass'], + ['docs/compliance', 'shield-check'], + ['docs/operations', 'list-checks'], + ['docs/design', 'palette'], + ['docs/DESIGN', 'palette'], + ['docs/PRODUCT', 'layers'], + ['docs/README', 'book-open'], +]; + +const iconFor = (path) => (ICONS.find(([p]) => path.startsWith(p)) || [, 'file-text'])[1]; + +// A navigation label is a name, not a title. A document's own first heading is written for the +// page it opens ("BRIEF: what this project is and is not"), and the part before the colon is the +// name a person would say out loud, so that is what a row carries. The full heading goes in the +// row's title attribute, where a reader who wants it can still get it. +const shortLabel = (heading, relPath) => { + const named = heading.match(/^([A-Za-z][A-Za-z0-9 -]{1,18}?)\s*[:(]/); + if (named) { + const word = named[1].trim(); + return word.length > 3 && word === word.toUpperCase() + ? word.charAt(0) + word.slice(1).toLowerCase() + : word; + } + if (heading.length <= 22) return heading; + const stem = relPath.split('/').pop().replace(/\.local\.md$|\.md$|\.html$/, ''); + // A two-letter opening word is an initialism (ui, ai, eu), not a word to sentence-case. + return stem.replace(/[-_]/g, ' ') + .replace(/^([a-z]{2})\b/, (m2) => m2.toUpperCase()) + .replace(/^([a-z])/, (ch) => ch.toUpperCase()); +}; + +// What a page is called, read from the file itself: the heading is where a reader would look for +// it and the only place it is written down. +export function headingOf(root, relPath) { + try { + const head = readFileSync(resolve(root, relPath), 'utf8').slice(0, 2000); + const m = head.match(/^#\s+(.+?)\s*$/m); + if (m) return m[1].replace(/^(?:EPIC|E-\d{2}|F(?:-\d{2})?|S-\d{2}):\s*/, '').trim(); + } catch { /* a file that cannot be read is named by its path, like any other */ } + return relPath.slice('docs/'.length).replace(/\.local\.md$|\.md$/, ''); +} + +// Two names this project's own files carry that read badly as a row, and nowhere else to put +// the mapping: it is about wording, which is this file's business. +const RENAME = { 'docs/README.md': 'Manifest', 'docs/PRODUCT.md': 'Product record' }; + +export const titleOf = headingOf; + +// A short name for a folder, from the folder itself. "docs/state/log" -> "Log". +const folderLabel = (dir) => { + const last = dir.split('/').pop(); + return last.charAt(0).toUpperCase() + last.slice(1); +}; + +const IS_WORK = (p) => p.startsWith('docs/work/'); +const IS_TEMPLATE = (p) => /(^|\/)TEMPLATE[-.]/i.test(p) || /-TEMPLATE\.md$/i.test(p) + || /(^|\/)[a-z-]*template[a-z-]*\.md$/i.test(p); +// Which place a document belongs to: the first folder under docs/, so an archive full of spec +// folders counts as one archive instead of one row per spec. A file lying loose in docs/ is its +// own place, because there is nothing above it to fold into. +const sectionOf = (p) => { + const parts = p.split('/'); + return parts.length > 2 ? `${parts[0]}/${parts[1]}` : p; +}; + +// What to call a folded place: the deepest folder every document in it shares. The log lives at +// docs/state/log while the rest of docs/state does not, so it is called Log and not State. +const commonDir = (paths) => { + const split = paths.map((p) => p.slice(0, p.lastIndexOf('/')).split('/')); + const first = split[0]; + let i = 0; + while (i < first.length && split.every((s2) => s2[i] === first[i])) i += 1; + return first.slice(0, i).join('/'); +}; + +// The four destinations the work tree earns. They sit above the subjects, permanently, because +// they are what a person opened this board for - not peers of a document. +const DESTINATIONS = (w) => [ + { href: '/', label: w.start, icon: 'house' }, + { href: '/board', label: w.boardShort, icon: 'columns-3' }, + { href: '/epic', label: w.epic, icon: 'target' }, + { href: '/features', label: w.features, icon: 'layers' }, +]; + +export const folderHref = (dir) => `/folder?path=${encodeURIComponent(dir)}`; + +export function navModel(root, docs, w, opens = () => true) { + const live = docs.filter((d) => !IS_WORK(d.path) && !IS_TEMPLATE(d.path) && opens(d.path)); + return [...SHELVES, OTHER].map((key) => { + const mine = live.filter((d) => (SHELVES.includes(d.shelf) ? d.shelf : OTHER) === key); + if (!mine.length) return null; + // Folders first, so a subject reads as a few places and then a few pages. + const byDir = new Map(); + for (const d of mine) { + const dir = sectionOf(d.path); + if (!byDir.has(dir)) byDir.set(dir, []); + byDir.get(dir).push(d); + } + const rows = []; + for (const [, held] of [...byDir].sort((a, b) => b[1].length - a[1].length)) { + const dir = commonDir(held.map((d) => d.path)); + if (held.length > FOLD_AT) { + // The count is what the page behind this row will show, not what survived the filters: + // a row standing in for a place says how much is in the place. + const inPlace = docs.filter((d) => d.path.startsWith(`${dir}/`)).length; + rows.push({ + href: folderHref(dir), label: folderLabel(dir), icon: iconFor(`${dir}/`), + count: inPlace, full: dir, + }); + } else if (held.length === 1 && held[0].path.split('/').length > 2) { + // A folder holding one document is a concept with an instance in it, so the row carries + // the concept: "Standards", not the name of the single file that happens to fill it. + rows.push({ + href: fileHref(held[0].path), label: folderLabel(dir), icon: iconFor(held[0].path), + full: headingOf(root, held[0].path), path: held[0].path, + }); + } else { + for (const d of held) { + const heading = headingOf(root, d.path); + rows.push({ + href: fileHref(d.path), + label: RENAME[d.path] || shortLabel(heading, d.path), + icon: iconFor(d.path), full: heading, path: d.path, + }); + } + } + } + // Two rows that read the same are two rows a person has to open to tell apart. When a short + // name collides, both fall back to the file's own name, which is the thing that differs. + const seen = new Map(); + for (const r of rows) seen.set(r.label, (seen.get(r.label) || 0) + 1); + for (const r of rows) { + if (seen.get(r.label) > 1 && r.path) { + r.label = r.path.split('/').pop().replace(/\.local\.md$|\.md$/, ''); + } + } + return { key, label: w.navNames[key], rows }; + }).filter(Boolean); +} + +// ---------------------------------------------------------------- the sidebar itself + +const row = (p, here) => { + const on = p.href === here; + return `
  • ` + + `${icon(p.icon)}${escapeHtml(p.label)}` + + `${p.count ? `${p.count}` : ''}
  • `; +}; + +// A chapter arrives closed. What a person meets is four destinations and four names, and the +// documents are one click behind the name they belong to - which is the whole point of grouping +// them. The one exception is the chapter holding the page you are on: no script runs here, so a +// closed chapter would leave a document page with nothing saying where in the project it sits. +// failure: the reason docs/ could not be walked, when it could not. It is said where the subjects +// would have stood, because a sidebar that quietly shows no documents reads as a project with +// none, and that is the one thing this navigation may never say by accident. +export function sidebar(name, model, w, { here = '/', failure = null } = {}) { + const groups = model.map((g) => { + const holdsHere = g.rows.some((p) => p.href === here); + return `
    ` + + `${escapeHtml(g.label)}${icon('chevron-right')}` + + `
      ${g.rows.map((p) => row(p, here)).join('')}
    `; + }).join(''); + const subjects = failure + ? `

    ${escapeHtml(w.partFailed(failure.message))}

    ` + : groups; + return ``; +} diff --git a/checks/board-nav.test.mjs b/checks/board-nav.test.mjs new file mode 100644 index 0000000..90792d3 --- /dev/null +++ b/checks/board-nav.test.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +// Self-test for the navigation the board hangs in: the sidebar, the four subjects under it, the +// pages it offers, and the shelf rule that decides which subject a document belongs to +// (checks/board-nav.mjs and checks/shelves.mjs, rendered through checks/board-page.mjs and served +// by checks/board-server.mjs). +// What is proven here is that a reader who follows a link is never left somewhere with no way on, +// that a row is only offered when there is something behind it, and that a docs/ folder which +// cannot be read says so rather than reading as a project with no documents. +// The lanes and the cards are proven in checks/board.test.mjs, the printed file in +// checks/board-file.test.mjs, and what may be opened at all in checks/board-path.test.mjs. +// Story: docs/work/E-01-agile-first/F-04-board/S-07-the-board-gets-its-look (local). +// Run: node --test checks/board-nav.test.mjs + +import { execFileSync } from 'node:child_process'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + fixture, project, STORY, + visible, laneOf, chapterOf, chapterCount, get, listen, +} from './board-fixture.mjs'; +import { startPage, boardOnlyPage } from './board-page.mjs'; +import { shelfFor, OTHER } from './shelves.mjs'; +import { createBoardServer } from './board-server.mjs'; + +// ---------------------------------------------------------------- the four shelves +test('a document stands on the shelf its path puts it on, and one the rule does not know is named anyway', () => { + const f = project({ 'S-01-a': STORY('S-01', 'A card', { status: 'to do' }) }); + // Nothing but the two files changes between the two renders. + assert.doesNotMatch(visible(startPage(f.root)), /VISION|handy/); + f.put('docs/product/VISION.md', '# VISION\n'); + f.put('docs/tools/handy.md', '# A folder the rule never heard of\n'); + const html = startPage(f.root); + + // Redrawn for S-07: the shelves are the sidebar's chapters now, so this is where the rule shows. + // What it proves is unchanged - a path the rule knows lands on its own shelf, and one it has + // never heard of lands on the named catch-all rather than falling off the page. + assert.match(chapterOf(html, 'Why we build it'), /VISION/, 'a path the rule knows lands on its shelf'); + assert.match(chapterOf(html, 'Elsewhere'), /handy/, 'a path it does not know lands on the named one'); + // The other direction: neither document is anywhere else in the navigation. + assert.doesNotMatch(chapterOf(html, 'Elsewhere'), /VISION/); + assert.doesNotMatch(chapterOf(html, 'Why we build it'), /handy/); + // Retired with the section that carried it: the catch-all used to explain itself in a sentence + // under its documents. A chapter has no room for one, and the owner's distillation of + // 2026-08-25 chose the name to carry the meaning instead. The name is asserted above. + f.clean(); +}); + +test('the shelf rule answers on its own: a path in, a shelf out', () => { + assert.equal(shelfFor('docs/product/BRIEF.md'), 'why'); + assert.equal(shelfFor('docs/work/E-01-shop/F-01-till/S-01-a.md'), 'now'); + assert.equal(shelfFor('docs/standards/GLOBAL.md'), 'built'); + assert.equal(shelfFor('docs/decisions/0021-agile-first.md'), 'learned'); + // The narrower row wins over the folder it sits inside, in both directions. + assert.equal(shelfFor('docs/product/ARCHITECTURE.md'), 'built'); + assert.equal(shelfFor('docs/specs/archive/000-baseline/spec.md'), 'learned'); + assert.equal(shelfFor('docs/specs/012-a-shipped-change.md'), 'now'); + assert.equal(shelfFor('docs/tools/handy.md'), OTHER); + // A file that is no project document at all stands on no shelf, and says so. + assert.equal(shelfFor('checks/links.mjs'), null); + assert.equal(shelfFor(null), null); +}); + +test('a document the project keeps out of git is never a door that does not open', () => { + const f = project({ 'S-01-a': STORY('S-01', 'A card', { status: 'to do' }) }, { + '.gitignore': '*.local.md\n', + 'docs/state/STATE.local.md': '# STATE\n\n- **Now ▶** finish the till\n', + 'docs/state/DEBT.md': '# DEBT\n', + }); + execFileSync('git', ['init', '-q'], { cwd: f.root, stdio: 'ignore' }); + const html = startPage(f.root); + // Redrawn for S-07. This used to read "named on its shelf, and not linked", because a shelf + // listed every document it held. The sidebar the owner signed off on 2026-08-25 lists only rows + // that open, so an untracked document is not offered there at all. The half that mattered is + // the half that stayed: the board never shows a door that does not open. + assert.doesNotMatch(html, /href="[^"]*STATE\.local/, 'no link to a file the file route refuses'); + assert.doesNotMatch(chapterOf(html, 'Building now'), /STATE\.local/, 'and no row standing for one'); + // Its neighbour is tracked, so that one does open: the rule is git's answer, not the folder. + assert.match(chapterOf(html, 'Building now'), / { + const f = fixture({}); + const html = startPage(f.root); + const text = visible(html); + // Redrawn for S-07. Four shelves used to stand on the page whether or not they held anything, + // each saying it was empty. The sidebar the owner signed off on 2026-08-25 names a subject only + // when there is something behind the name, because a row that opens nothing is a row a reader + // has to check to learn it was pointless. An empty copy therefore has no chapters at all, and + // that has to read as not started rather than as broken, which is what the rest of this asks. + assert.equal(chapterCount(html), 0, 'no subject is invented for a project with no documents'); + assert.doesNotMatch(text, /could not be built/, 'and nothing failed: there is nothing yet'); + // The way in is still there, so an empty copy is navigable rather than a wall. + for (const dest of ['/', '/board', '/epic', '/features']) { + assert.match(html, new RegExp(`href="${dest}"`), `the way to ${dest} stands`); + } + assert.match(text, /Scope is not defined yet/); + // A zero would read as progress on undecided scope, so none is shown; the next step still + // names the skill that writes one. + assert.doesNotMatch(text, /0 of the \d+ things/); + assert.match(text, /names no next step.*checkpoint skill/); + // The way in has no lanes on it by design; the page that does has none to show either. + assert.doesNotMatch(boardOnlyPage(f.root), /
    { + // A name that is no folder at all is the portable stand-in for a folder that cannot be read: + // both leave readdir throwing something other than "it is not there". + const f = fixture({ docs: 'this is a file where a folder should be\n' }); + const html = startPage(f.root); + assert.match(visible(html), /could not be built:/); + assert.match(visible(html), /The rest still holds/); + // A silent empty shelf would read as a project with no documents, which is the lie this + // catches. The rest of the board is still there. + assert.doesNotMatch(html, /
    { + const planned = project({ 'S-01-a': STORY('S-01', 'A card in a lane', { status: 'to do' }) }); + const server = createBoardServer(planned.root, { isIgnored: () => false }); + const port = await listen(server); + try { + const front = await get(port, '/'); + assert.equal(front.status, 200); + // Redrawn for S-07. The board was one page; it is five now, and the way in is the one that + // answers what the project is for and where it stands. The lanes did not disappear from the + // board, they became the page the sidebar's second row opens, which is asserted below. + assert.match(visible(front.body), /What this project is for/); + assert.match(visible(front.body), /gates on this machine are armed/); + assert.match(visible(front.body), /documents, with \d+ links between them/); + assert.doesNotMatch(visible(front.body), /A card in a lane/, 'the way in is not the lanes'); + assert.match(front.body, /href="\/board"/, 'and it offers the way to them'); + + // One click behind it: the lanes, with the card in the lane its own status line puts it in. + const board = await get(port, '/board'); + assert.equal(board.status, 200); + assert.match(visible(board.body), /A card in a lane/, 'the lanes are their own page'); + assert.match(laneOf(board.body, 'To do'), /A card in a lane/); + + // And no page links at the retired route any more. + assert.doesNotMatch(front.body, /href="\/overview"/); + assert.doesNotMatch(board.body, /href="\/overview"/); + + const retired = await get(port, '/overview'); + assert.equal(retired.status, 404, 'the route answers like any other unknown path'); + assert.equal(retired.status, (await get(port, '/nothing-here')).status); + assert.match(visible(retired.body), /Not available/); + } finally { server.close(); planned.clean(); } +}); + +test('every page the sidebar offers answers, and carries the sidebar itself', async () => { + const f = project({ + 'S-01-a': STORY('S-01', 'Waiting to be picked up', { status: 'to do' }), + 'S-02-b': STORY('S-02', 'Under the hands', { status: 'in progress' }), + }); + const server = createBoardServer(f.root, { isIgnored: () => false }); + const port = await listen(server); + try { + // The five pages S-07 built, plus the folder index behind a folded row. A followed link that + // arrived somewhere with no way onward was the whole reason the sidebar exists, so the page + // answering is only half of what is asked here: it has to carry the way out as well. + const routes = ['/', '/board', '/epic', '/features', + '/feature?key=E-01%2FF-01', '/folder?path=docs%2Fwork']; + for (const route of routes) { + const page = await get(port, route); + assert.equal(page.status, 200, `${route} answers`); + assert.match(page.body, /
    `; diff --git a/checks/board-nav.test.mjs b/checks/board-nav.test.mjs index 90792d3..4018567 100644 --- a/checks/board-nav.test.mjs +++ b/checks/board-nav.test.mjs @@ -184,3 +184,31 @@ test('every page the sidebar offers answers, and carries the sidebar itself', as assert.equal(nowhere.status, 404); } finally { server.close(); f.clean(); } }); + +test('a document behind a folded row is still placed: the chapter opens and the place is marked', async () => { + // More documents in one folder than the sidebar will list, so the row it gets stands for the + // folder rather than for any one of them. Without this, a reader who followed a link into that + // folder met a sidebar with nothing open and nothing marked - which is the dead end the sidebar + // was built to end, arriving by another door. + const f = project({ 'S-01-a': STORY('S-01', 'A card', { status: 'to do' }) }, Object.fromEntries( + Array.from({ length: 6 }, (_, i) => [`docs/decisions/000${i + 1}-one.md`, `# 000${i + 1}: one\n`]), + )); + const server = createBoardServer(f.root, { isIgnored: () => false }); + const port = await listen(server); + try { + const page = await get(port, '/file?path=docs%2Fdecisions%2F0003-one.md'); + assert.equal(page.status, 200); + const chapter = chapterOf(page.body, 'What we learned'); + assert.match(chapter, /aria-current="location"/, 'the place the document sits in is marked'); + assert.match(chapter, /href="\/folder\?path=docs%2Fdecisions"/, 'and it is the folder row'); + // "location", never "page": the folder is where this document is, not what is being shown. + assert.doesNotMatch(chapter, /aria-current="page"/); + // The chapter holding it is open, because no script runs here to open it afterwards. + assert.match(page.body, /
    [\s\S]*?What we learned/, + 'and the chapter it belongs to arrives open'); + + // The other direction: a document with a row of its own is the page, not a location. + const own = await get(port, '/file?path=docs%2Fproduct%2FBRIEF.md'); + assert.match(chapterOf(own.body, 'Why we build it'), /aria-current="page"/); + } finally { server.close(); f.clean(); } +}); From 2af760a4f2c3c1f82a45be13988d3e49e578af82 Mon Sep 17 00:00:00 2001 From: Remon Panman <228601219+Tradebaas@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:24:20 +0200 Subject: [PATCH 3/3] refactor(checks): one place for the two cards, and a heading that reads the language Three findings from the review of the two commits before this one, applied here as their own change rather than folded into the diff that raised them. The pair of cards a page opens with - what this project is for, and where the round stands - was written out in full on both pages that carry it, so the same knowledge sat in two places and could drift into two arrangements of the same two cards. It is one helper now, asked for by both. A feature page picked its first heading by comparing a translated word to an English literal: storiesHead === 'Stories' decided between two hard-coded sentences. The language is a field on the project and every other string on that page reads it from the words table, so this one does too, as afterHead in both languages. Reword storiesHead and nothing silently falls into Dutch any more. And a test that S-07 retired whole now says so where it stood. "A row says what its document owns in the manifest own words" guarded a shelf row carrying the manifest's sentence about the fact a document owns; no rendering carries that sentence any more, and shelfDocuments no longer gathers one. A test that is simply gone leaves no trace of what stopped being true, so the reason sits in board-nav.test.mjs beside the shelf rule it belonged to. The manifest itself is still gated, by the docs-manifest check in check.mjs, which is where that rule lives. The gate-weakening scan over the whole branch is otherwise clean: eight test names left the suites and seven of them are the same tests under names that describe what they now assert, with the retired eighth accounted for above. No assertion was softened, no threshold moved, no CI step or config entry dropped - the one deletion in drill.mjs is the suite list rewrapping to fit a fourteenth name. The security axis fired on the two new routes that take a query parameter and found nothing: /folder validates its path and never touches the filesystem with it, filtering an already-walked list instead, and /feature matches its key against the features that exist, which is an allow-list by construction. Both refuse everything else with the same 404 any unknown path gets, measured with traversal and script payloads. Traces-to: SC-10 --- checks/board-nav.test.mjs | 7 +++++++ checks/board-page.mjs | 14 ++++++++++---- checks/board-views.mjs | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/checks/board-nav.test.mjs b/checks/board-nav.test.mjs index 4018567..5304a15 100644 --- a/checks/board-nav.test.mjs +++ b/checks/board-nav.test.mjs @@ -45,6 +45,13 @@ test('a document stands on the shelf its path puts it on, and one the rule does f.clean(); }); +// Retired by S-07, recorded here because a test that is simply gone leaves no trace of what +// stopped being true: "a row says what its document owns in the manifest own words, pattern rows +// included". A shelf row used to carry the manifest's sentence about the fact a document owns. +// The sidebar names a document by its own heading instead, so no manifest sentence reaches any +// page, and shelfDocuments no longer gathers one. The manifest itself is still gated, by the +// docs-manifest check in checks/check.mjs, which is where that rule belongs. + test('the shelf rule answers on its own: a path in, a shelf out', () => { assert.equal(shelfFor('docs/product/BRIEF.md'), 'why'); assert.equal(shelfFor('docs/work/E-01-shop/F-01-till/S-01-a.md'), 'now'); diff --git a/checks/board-page.mjs b/checks/board-page.mjs index d3beaf7..d769690 100644 --- a/checks/board-page.mjs +++ b/checks/board-page.mjs @@ -69,6 +69,7 @@ const BOARD_WORDS = { inFolder: (n) => `${n} documents`, notInGit: 'kept out of git, so this board does not open it', tasksHead: 'Tasks', storiesHead: 'Stories', acceptanceHead: 'Acceptance', + afterHead: 'What you can do after it', finishedHead: 'What finished means', goalHead: 'The goal', worthHead: 'What that is worth', visionHead: 'Which choice it serves', ofDone: (d, t) => `${d} of ${t} done`, @@ -103,6 +104,7 @@ const BOARD_WORDS = { inFolder: (n) => `${n} documenten`, notInGit: 'buiten git gehouden, dus dit bord opent het niet', tasksHead: 'Taken', storiesHead: 'Stories', acceptanceHead: 'Acceptatie', + afterHead: 'Wat je erna kunt', finishedHead: 'Wat klaar betekent', goalHead: 'Het doel', worthHead: 'Wat dat waard is', visionHead: 'Welke keuze het dient', ofDone: (d, t) => `${d} van ${t} klaar`, @@ -339,12 +341,17 @@ export const shellFor = (c, here) => sidebar(c.project.name, // The way in: what this project is for, where the round stands, and the two derived lines. The // lanes are not here - they are the board, which is its own page. +// What this project is for and where the round stands, side by side. Both pages that open with +// them ask for them here, so the pair cannot drift into two arrangements of the same two cards. +const topCards = (root, c) => `
    ` + + `${purposeCard(readBrief(root), c.project.lang, c.w, c.opens)}` + + `${roundCard(c.progress, c.epic, readHandoff(root).now, c.project.lang, c.w, c.opens)}
    `; + export function startPage(root, opts = {}) { const c = context(root, opts); c.rootPath = root; const body = [ - `
    ${purposeCard(readBrief(root), c.project.lang, c.w, c.opens)}` - + `${roundCard(c.progress, c.epic, readHandoff(root).now, c.project.lang, c.w, c.opens)}
    `, + topCards(root, c), renderStrip(c.facts, c.project.lang, c.opens, c.made), ].filter(Boolean).join('\n'); return renderBoard(c.project, body, c.w, c.made, shellFor(c, '/')); @@ -372,8 +379,7 @@ export function boardPage(root, { made = null, ...deps } = {}) { c.rootPath = root; const { work } = c.project; const body = [ - `
    ${purposeCard(readBrief(root), c.project.lang, c.w, c.opens)}` - + `${roundCard(c.progress, c.epic, readHandoff(root).now, c.project.lang, c.w, c.opens)}
    `, + topCards(root, c), c.epic ? lanes(work, c.epic.key, c.w, c.opens) : '', c.epic ? otherEpics(work.epics.filter((e) => e.key !== c.epic.key), c.w) : '', renderStrip(c.facts, c.project.lang, c.opens, c.made), diff --git a/checks/board-views.mjs b/checks/board-views.mjs index 5fd58f5..19c779c 100644 --- a/checks/board-views.mjs +++ b/checks/board-views.mjs @@ -84,7 +84,7 @@ export function featurePage(root, key, opts = {}) { if (!f) return null; const stories = project.work.stories.filter((s) => s.feature === f.key); const body = [ - card(w.storiesHead === 'Stories' ? 'What you can do after it' : 'Wat je erna kunt', + card(w.afterHead, { lang: project.lang, path: f.path, opens: c.opens }, () => [ f.value ? lead(f.value, sentence) : '', bar(f.progress.done, f.progress.total),