diff --git a/.gitignore b/.gitignore index 798df6b..ea28553 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ __pycache__/ .claude/settings.local.json .DS_Store site/dist/ +# wrangler's local cache, written by a deploy from this checkout +.wrangler/ diff --git a/AGENTS.md b/AGENTS.md index fd577a0..0f6f3b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,14 +67,19 @@ Headless jobs run through an adapter (`BOARD_AGENT_ADAPTER`, default a directory with `run` (execute one job: `AGENT_PROMPT` + `AGENT_MODE` work|act-pr|review + `AGENT_COMMANDS` in, stdout = the log, markers parsed from it) and `wire` (idempotently give the host project live-session -visibility). Headless jobs answer no permission prompts, so each intent is -granted exactly the side effects its prompt demands — commit and test for -work, push for act-pr, posting PR verdicts for review — with the project's -own runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral -prefixes each adapter renders in its vendor's rule syntax. Adapters -translate their vendor's events into the board's normalized schema at the -edge — core never sees vendor payloads. The full contract, including the -event schema, lives in `core/adapters/README.md`. +visibility). + +Headless jobs answer no permission prompts, so each intent is granted +exactly the side effects its prompt demands — commit and test for work, +push for act-pr, posting PR verdicts for review — with the project's own +runnable commands coming from `BOARD_AGENT_COMMANDS` as neutral prefixes +each adapter renders in its vendor's rule syntax. + +Adapters translate their vendor's events into the board's normalized +schema at the edge — core never sees vendor payloads. That is what keeps +the board's own code free of any one vendor: swapping adapters swaps the +binary that does the work, not the board. The full contract, including +the event schema, lives in `manager/core/adapters/README.md`. ## Drives @@ -291,12 +296,14 @@ is a review question, not a state, so the chip takes no colour. A launch that inherited the vendor default, and a session replayed from disk, wear no chip at all — the board says nothing rather than guessing. -**▸ start work** launches a headless `claude -p` on the task. It exists only -on `in-progress/` cards: moving a card to in-progress is the commitment, and -only then does work start — the server refuses launches from anywhere else. -In team mode it also refuses a card someone else holds, naming them; the -action reads **▸ take over** there, and firing it is the deliberate -reassignment. An unclaimed card claims itself on launch. +**▸ start work** launches a headless agent run on the task, through the +configured adapter. It exists only on `in-progress/` cards: moving a card +to in-progress is the commitment, and only then does work start — the +server refuses launches from anywhere else. In team mode it also refuses a +card someone else holds, naming them; the action reads **▸ take over** +there, and firing it is the deliberate reassignment. An unclaimed card +claims itself on launch. One agent per task at a time, and a work agent's +worktree must not already exist when it starts. 1. The board creates a git worktree at `.worktrees//` on a new branch `task/` from the newest main it can see: with an @@ -393,13 +400,14 @@ branch — additively, never rebasing or force-pushing — in a dedicated resolution commit, and refuses semantic ones, naming the collision for a human to settle. GitHub computes mergeability lazily, so an UNKNOWN reading keeps the chip's last state rather than flapping. -Tool chips (CI, copilot, PR, drive) -are destinations, not statuses: they live in the card's footer row, never + +The card wears that verdict in the design system's state colours: +approved → pine (`--calm`) border and an `approved` pill; changes asked → +terracotta (`--alarm`) and a `changes asked` pill; otherwise it stays the +neutral `waiting on you`. Tool chips (CI, copilot, PR, drive) are +destinations, not statuses: they live in the card's footer row, never squeezed into the author row — `CI ✓` (pine), `CI ✕` (terracotta), `◌` -while in flight, with hover actions staying in the status pill's slot. The card wears it in the design -system's state colours: approved → pine (`--calm`) border and an -`approved` pill; changes asked → terracotta (`--alarm`) and a -`changes asked` pill; otherwise it stays the neutral `waiting on you`. +while in flight — with hover actions staying in the status pill's slot. Merging remains yours — the board never merges. The agent's first duty is to judge whether the task is actionable. If the @@ -438,9 +446,8 @@ clicks needs merge rights on the repo, not just push rights, and a branch without a PR is refused with a pointer to **↑ open PR** — there is nothing for origin to merge otherwise. Single-player merges locally, exactly as above. -One agent per task at a time; a work agent's worktree must not already -exist when starting. +## Stages The flow is linear: @@ -448,8 +455,6 @@ The flow is linear: backlog → to-do → in-progress → review → done ``` -## Stages - ### backlog/ Where new tasks are written and where they wait. A backlog task may be rough, incomplete, or fully specified — what it has in common with its neighbours is @@ -485,7 +490,7 @@ Supporting documents that tasks can link to — external specs, API documentatio research notes, screenshots, competitive analysis, regulatory references, etc. These don't move through the workflow; they're stable resources. Reference them from task files using relative links — two levels up from a stage directory -(e.g. `[IVASS spec](../../reference/ivass-document-requirements.md)`). +(e.g. `[the payments API spec](../../reference/payments-api.md)`). ## Moving a task @@ -529,10 +534,10 @@ messaged `board: ()`, staged by pathspec so unrelated staged work is neither committed nor unstaged (hooks are skipped — this is bookkeeping, not code). Pushing is not part of it: those commits sit on your local `main` until you push it (or until `BOARD_SYNC` pushes them — -see below), which the PR guard above will tell you about if you forget. The -setting is off by default: a single-player -board neither writes nor clears the assignee and makes no commits, exactly -as before, and `tasks/` is committed by hand. The gate governs only whether +see "Syncing boards"), which the guard in "Pull requests" will tell you +about if you forget. The setting is off by default: a single-player +board neither writes nor clears the assignee and makes no commits, and +`tasks/` is committed by hand. The gate governs only whether a *move* writes the line — an **Assignee:** added to a file by hand is still read and shown on the card whether the gate is on or off. @@ -634,6 +639,22 @@ and keep the status in step with the directory the file sits in. Priority may carry a short justification after the level (e.g. `Medium — foundational for any real environment`). +Every header field, and who writes it: + +| Field | Required | Value | Written by | +| --- | --- | --- | --- | +| **Status** | yes | `Backlog` · `To Do` · `In Progress` · `Review` · `Done` (`Archived` for a card in `tasks/archive/`) | you, or the board on a move | +| **Priority** | yes | `High` · `Medium` · `Low`, optionally followed by a short justification | you | +| **Type** | no | `Discovery` · `Bug` · `Feature` · `Refactor` · `Chore` | you | +| **Assignee** | no | a name, taken from `git config user.name` | the board on a claiming move, or you by hand | +| **Depends on** | no | task numbers or external preconditions, comma-separated | you | +| **PR** | no | the pull request url | the board when it opens one | + +**Status** is the field the board holds you to: a header that disagrees +with the directory the file sits in is flagged `status drift`. +**Assignee** and **PR** it writes and reads itself. The rest are for +whoever picks the next card. + An optional **Type** line can record what kind of work the task is, when that isn't obvious from the title: diff --git a/README.md b/README.md index 4f59c4e..0b3ffd3 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,15 @@ install one-liner above depends on. ## The three-layer law Core knows about tasks, worktrees, PRs and events. It knows nothing about -any particular app (drivers do: `local/driver/start`), agent vendor -(adapters do: `core/adapters/`), or project (`local/` does). Full docs in -AGENTS.md; the adapter contract in `manager/core/adapters/README.md`. +any particular app (drivers do: `manager/local/driver/start`), agent +vendor (adapters do: `manager/core/adapters/`), or project +(`manager/local/` does). + +The consequence is what makes an update safe: `update.sh` replaces +`manager/core/` wholesale, and everything a project taught bench about +itself lives outside it. Full docs in [AGENTS.md](AGENTS.md); the adapter +contract in +[manager/core/adapters/README.md](manager/core/adapters/README.md). ## License diff --git a/manager/core/board.html b/manager/core/board.html index c4376b1..a97927f 100644 --- a/manager/core/board.html +++ b/manager/core/board.html @@ -447,6 +447,17 @@ #drawer .dbody h2{font-size:14px;margin:18px 0 6px} #drawer .dbody h3{font-size:13px;margin:14px 0 4px} #drawer .dbody p,#drawer .dbody li{font-size:12.5px;color:var(--muted)} + #drawer .dbody ul,#drawer .dbody ol{margin:6px 0;padding-left:18px} + #drawer .dbody li{margin:3px 0} + #drawer .dbody li>ul,#drawer .dbody li>ol{margin:3px 0} + /* task-list items: a glyph, not an input — the file is the source of truth */ + #drawer .dbody li.tick{list-style:none;margin-left:-18px} + #drawer .dbody li.tick .box{ + display:inline-block;width:11px;height:11px;margin-right:7px; + border:1px solid var(--border);border-radius:3px; + font-size:9px;line-height:10px;text-align:center; + } + #drawer .dbody li.tick.on .box{border-color:var(--calm);color:var(--calm)} #drawer .dbody strong{color:var(--text)} #drawer .dbody code{font-family:var(--mono);font-size:11.5px;background:var(--sunken);padding:1px 4px;border-radius:3px} #drawer .dbody pre{background:var(--sunken);border-radius:8px;padding:10px 12px;overflow-x:auto} @@ -1900,7 +1911,8 @@ async function openExtra(dir, name) { } } -// Small markdown renderer — enough for these task files, no dependencies. +// Small markdown renderer — for these task files, which are hard-wrapped +// prose: a logical line spans several source lines. No dependencies. function md(src) { const blocks = esc(src).split(/\n{2,}/); const inline = (t) => t @@ -1909,6 +1921,56 @@ function md(src) { .replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1$2') .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + /* Lists: group physical lines into logical items before rendering. A new + item begins only at a marker; a line without one is continuation text + joined to the item above with a space — that is what stops a wrapped + item from sprouting a second bullet. A marker indented past the level + it sits in opens a nested list, and a shallower one closes back to the + level that fits, so children indent under their parent instead of + flattening beside it. */ + const MARKER = /^([ \t]*)(?:[-*]|\d+\.)[ \t]+(.*)$/; + const ORDERED = /^[ \t]*\d+\./; + const TICK = /^\[([ xX])\]\s*(.*)$/; // - [ ] / - [x], read-only here + + const listTree = (lines) => { + const root = { ordered: ORDERED.test(lines[0]), items: [] }; + const stack = [{ indent: null, list: root }]; + let last = null; + for (const line of lines) { + const m = line.match(MARKER); + if (!m) { // continuation of the item above + if (last && line.trim()) last.text += ' ' + line.trim(); + continue; + } + const indent = m[1].replace(/\t/g, ' ').length; + const top = () => stack[stack.length - 1]; + if (top().indent === null) top().indent = indent; + while (stack.length > 1 && indent < top().indent) stack.pop(); + if (last && indent > top().indent) { + last.sub = { ordered: ORDERED.test(line), items: [] }; + stack.push({ indent, list: last.sub }); + } + last = { text: m[2], sub: null }; + top().list.items.push(last); + } + return root; + }; + + const listHtml = (list) => { + const tag = list.ordered ? 'ol' : 'ul'; + return `<${tag}>` + list.items.map((it) => { + const t = it.text.match(TICK); + const body = inline(t ? t[2] : it.text) + (it.sub ? listHtml(it.sub) : ''); + if (!t) return `
  • ${body}
  • `; + // A glyph, never an input: the task file is the source of truth and + // the drawer is not an editor. Done reads as settled, open is neutral. + const done = t[1] !== ' '; + return `
  • ` + + `` + + `${done ? '✓' : ''}${body}
  • `; + }).join('') + ``; + }; + let inFence = false, fenced = []; const out = []; for (const block of blocks) { @@ -1923,7 +1985,8 @@ function md(src) { } continue; } - const lines = block.split('\n'); + const text = block.replace(/\s+$/, ''); // the split leaves the last block a trailing newline + const lines = text.split('\n'); if (lines.length >= 2 && /^\s*\|.*\|\s*$/.test(lines[0]) && /^\s*\|[\s:|-]+\|\s*$/.test(lines[1])) { const cells = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map(c => inline(c.trim())); const head = cells(lines[0]); @@ -1932,21 +1995,20 @@ function md(src) { head.map(h => `${h}`).join('') + '' + rows.map(r => '' + r.map(c => `${c}`).join('') + '').join('') + ''); - } else if (/^#{1,6}\s/.test(block)) { + } else if (/^#{1,6}\s/.test(text)) { out.push(lines.map(l => { const m = l.match(/^(#{1,6})\s+(.*)$/); return m ? `${inline(m[2])}` : `

    ${inline(l)}

    `; }).join('')); - } else if (/^\s*[-*]\s/.test(block)) { - out.push('
      ' + lines.map(l => `
    • ${inline(l.replace(/^\s*[-*]\s+/, ''))}
    • `).join('') + '
    '); - } else if (/^\s*\d+\.\s/.test(block)) { - out.push('
      ' + lines.map(l => `
    1. ${inline(l.replace(/^\s*\d+\.\s+/, ''))}
    2. `).join('') + '
    '); - } else if (/^>/.test(block)) { - out.push('
    ' + inline(block.replace(/^>\s?/gm, '')).replace(/\n/g, '
    ') + '
    '); - } else if (/^-{3,}$/.test(block.trim())) { + } else if (MARKER.test(lines[0])) { + out.push(listHtml(listTree(lines))); + } else if (/^>/.test(text)) { + // reflow, don't preserve the author's wrap column + out.push('
    ' + inline(text.replace(/^>\s?/gm, '')).replace(/\n/g, ' ') + '
    '); + } else if (/^-{3,}$/.test(text.trim())) { out.push('
    '); } else { - out.push(`

    ${inline(block).replace(/\n/g, '
    ')}

    `); + out.push(`

    ${inline(text).replace(/\n/g, ' ')}

    `); } } if (fenced.length) out.push(`
    ${fenced.join('\n\n')}
    `); diff --git a/site/README.md b/site/README.md index be9e99f..302c306 100644 --- a/site/README.md +++ b/site/README.md @@ -18,13 +18,22 @@ one-liner is read out of `README.md`'s "Install into a repo" block and the version out of `manager/core/VERSION`, and the template is offered them as `$install_block` and `$version`. +An article page authors one sentence of its own — the lede under the +title — because a slice starts mid-document and a reader arriving from +the nav is owed a line saying what they are looking at. That is the whole +allowance. When a section reads badly on the web, the fix is the section: +edit `AGENTS.md` so it reads well in both places rather than forking the +prose into this directory. + ```bash python3 -m pip install -r site/requirements.txt # once -python3 site/fetch-fonts.py # once, needs network python3 site/build.py # → site/dist/ ``` -`site/dist/` is gitignored — it is output, rebuilt on every deploy. +`site/dist/` is gitignored — it is output, rebuilt on every deploy. The +woff2 files under `static/fonts/` are not: they are committed, so a +clean checkout builds the real typography without asking anyone for it, +and a deploy is the same bytes from any machine. `site/` never reaches a host repo: releases ship exactly what `../manager/core/release-manifest` lists, and it does not list this @@ -77,13 +86,12 @@ separate decision with a separate cost, and a follow-up card. ## Deploying -From a clean checkout, four commands. Re-running the whole sequence is +From a clean checkout, three commands. Re-running the whole sequence is safe: the build empties and rewrites `dist/`, and `wrangler deploy` replaces the Worker's assets rather than adding to them. ```bash python3 -m pip install -r site/requirements.txt # once per machine -python3 site/fetch-fonts.py # once per checkout python3 site/build.py # → site/dist/ npx wrangler@4 deploy --config site/wrangler.jsonc ``` @@ -156,7 +164,7 @@ answers rather than files: | `root/` | Copied to the **top** of `dist/` verbatim: `_headers`, which the host reads and never serves | | `wrangler.jsonc` | The Worker: assets directory, url handling, 404, custom domain | | `requirements.txt` | `markdown-it-py`, pinned. The only dependency | -| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once | +| `fetch-fonts.py` | Refetches the committed woff2 files, or adds a face | ## A manifest entry @@ -178,21 +186,32 @@ answers rather than files: written to exactly that path — `/404.html` is the only one, and it exists because the host looks for that literal filename. - **`layout`** names a file in `templates/`. -- **`section`** groups the page in the nav and the sidebar. The IA is - read out of this file in this file's order — nothing is derived from - the directory layout. A `null` section keeps the page out of both, - which is how the 404 page stays off the nav. +- **`section`** groups the page in the nav and the sidebar, and puts it + on the reading order prev/next walks. The IA is read out of this file + in this file's order — nothing is derived from the directory layout. A + `null` section keeps the page out of the nav, the sidebar and the flow, + which is how the 404 page stays off all three. - **`source`** is repo-relative, or `null` for a landing page whose body is authored in its template rather than sliced. - **`from`** is the heading the slice starts at. It is matched on the heading's text; writing the `#`s (`## Stages`) pins the level too. - Headings inside fenced code blocks never match. + Headings inside fenced code blocks never match. It is also what + "Edit this page on GitHub" anchors to, so the link opens the section + rather than the top of a 700-line file. - **`to`** is optional. Without it the slice runs to the next heading of the same level or shallower. +- **`description`** is the page's meta description, and doubles as the + visible lede under the title. +- **`lede`** is optional, and only worth setting when the sentence a + reader should see differs from the one a search engine should. It is + the only prose a page may author. The `from` heading itself is dropped — the layout renders the page title — and what remains is promoted by `level - 1`, so a section's `###` -sub-headings land as the page's `

    `s. +sub-headings land as the page's `

    `s. Promotion stops at `

    `: a +slice that deliberately runs past its own section ("Stages" through +"Moving a task") carries headings at the `from` level, and those become +`

    ` peers rather than a second `

    ` on a page that already has one. ## What fails the build diff --git a/site/build.py b/site/build.py index 4977b14..97f2932 100644 --- a/site/build.py +++ b/site/build.py @@ -40,6 +40,13 @@ page emits — a door on the landing page as much as a link inside a slice — must resolve to something this build writes, or the build stops (`check_links`). +An article page authors exactly one sentence of its own: the lede under +the title (`$lede`, the manifest's `lede` or its `description`). A slice +begins mid-document, so a reader arriving from the nav is owed a line +saying what they are looking at — but that is the whole allowance, and a +manifest entry that tried to carry a body would still have nowhere to put +it. + ## What the host needs from the build Two things here exist for the way the site is served (site/wrangler.jsonc, @@ -151,13 +158,20 @@ def find_heading(marks: list, value: str, after: int = -1): def promote(text: str, by: int) -> str: """Shift every heading in a slice `by` levels shallower, so a section lifted out of a larger document keeps its internal hierarchy while - starting at

    under the page's own

    .""" + starting at

    under the page's own

    . + + Never above

    . A slice that runs past the end of its own section — + "Stages" through "Moving a task", one page about one idea — carries + headings at the `from` heading's own level, and those would promote to + a second

    on a page that already has one. They land beside the + section's children as

    instead: on the page they are peers, which + is what putting them on one page said.""" if by <= 0: return text lines = text.splitlines() for index, level, _ in list(headings(text)): found = ATX.match(lines[index]) - lines[index] = "#" * max(1, level - by) + " " + found.group(2).strip() + lines[index] = "#" * max(2, level - by) + " " + found.group(2).strip() return "\n".join(lines) @@ -281,6 +295,17 @@ def repo_facts(repo: Path) -> dict: # ── links ───────────────────────────────────────────────────────────── +def github_anchor(heading: str) -> str: + """GitHub's own anchor for a heading, so "Edit this page" lands on the + section the page was cut from rather than at the top of a 700-line + file. GitHub lowercases, drops punctuation that is not a hyphen or an + underscore, and turns spaces into hyphens — which is not quite + slugify()'s rule (that one collapses runs), so it is written out here + rather than shared.""" + text = heading.lstrip("#").strip().lower() + return re.sub(r"[^\w\- ]", "", text).replace(" ", "-") + + def rewrite_link(href: str, *, page: dict, source: str, manifest: dict, repo: Path) -> str: """A repo-relative link out of a markdown file is a dead path on the @@ -442,6 +467,43 @@ def render_sidebar(manifest: dict, current: dict) -> str: return "\n".join(out) +def flow(manifest: dict) -> list: + """The pages in reading order — the sidebar, flattened. Prev/next walks + this list, so what the arrows do and what the sidebar shows cannot + disagree. A page with no section (the landing page, the 404) is not on + the flow and gets no arrows.""" + return [page for group in sections(manifest) for page in group["pages"]] + + +def render_flow(manifest: dict, current: dict) -> str: + """The two arrows at the foot of an article. Absent neighbours keep + their slot as an empty span, so `next` stays on the right on the first + page exactly as it does on every other.""" + order = flow(manifest) + here = next((index for index, page in enumerate(order) + if page["path"] == current["path"]), None) + if here is None: + return "" + neighbours = ( + (order[here - 1] if here > 0 else None, "prev", "← previous"), + (order[here + 1] if here + 1 < len(order) else None, "next", "next →"), + ) + if not any(page for page, _, _ in neighbours): + return "" + out = ['") + return "\n".join(out) + + def render_contents(contents: list) -> str: if not contents: return "" @@ -507,12 +569,31 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, blob = config["blob_base"].rstrip("/") + "/" stamps = stamps if stamps is not None else stamp(site) facts = facts if facts is not None else repo_facts(repo) + + # "Edit this page" is a promise that the reader lands on the thing that + # is wrong. For a sliced page that is the section, not the file: an + # anchor built from the same `from` heading the slice starts at, so the + # two cannot point at different places. + source_url = config["repo_url"] + if source: + source_url = blob + source + anchor = github_anchor(page["from"]) + if anchor: + source_url += "#" + anchor + fields = { "stylesheet": stamps["stylesheet"], "icon": stamps["icon"], "title": escape(page["title"]), "description": escape(page.get("description") or config.get("description", "")), + # The design's lede. It is the one sentence a page is allowed to + # author, because a slice starts mid-document and a reader arriving + # from the nav needs to be told what they are looking at; the + # manifest's own `description` says that already, so `lede` only + # exists for the pages where the two want different words. + "lede": escape(page.get("lede") or page.get("description") + or config.get("description", "")), "site_title": escape(config["title"]), "site_tagline": escape(config.get("tagline", "")), "version": escape(facts["version"]), @@ -521,12 +602,13 @@ def render_page(page: dict, manifest: dict, *, site: Path, repo: Path, "toc": render_contents(contents), "nav": render_nav(manifest, page), "sidebar": render_sidebar(manifest, page), + "flow": render_flow(manifest, page), "breadcrumb": render_breadcrumb(page), "section": escape(page.get("section") or ""), "repo_url": config["repo_url"], "issues_url": config.get("issues_url", config["repo_url"]), "releases_url": config.get("releases_url", config["repo_url"]), - "source_url": (blob + source) if source else config["repo_url"], + "source_url": source_url, "source_path": escape(source or ""), "canonical": config.get("base_url", "").rstrip("/") + page["path"], } diff --git a/site/pages.json b/site/pages.json index 335315a..a3ee4a5 100644 --- a/site/pages.json +++ b/site/pages.json @@ -39,7 +39,16 @@ "description": "backlog, to-do, in-progress, review, done — the directory a task file sits in is its status, and there is no other source of truth.", "source": "AGENTS.md", "from": "## Stages", - "to": "## Moving a task" + "to": "## Claiming a card" + }, + { + "path": "/concepts/task-files/", + "title": "Task files", + "layout": "article", + "section": "Concepts", + "description": "A task is a markdown file with a numbered name and a short header. Status is the only field the board enforces.", + "source": "AGENTS.md", + "from": "## Task file format" }, { "path": "/concepts/claiming-a-card/", @@ -91,6 +100,17 @@ "from": "## The three-layer law", "to": "## License" }, + { + "path": "/concepts/adapters/", + "title": "Agent adapters", + "layout": "article", + "section": "Concepts", + "description": "Headless jobs run through an adapter, so the board works with coding agents other than Claude Code — and never sees a vendor's payloads.", + "lede": "The adapter is the layer that knows a coding agent. It launches one headless job, and translates that vendor's events into the board's own schema — which is what keeps every other line of core free of any particular agent.", + "source": "AGENTS.md", + "from": "## Agent adapters", + "to": "## Drives" + }, { "path": "/404.html", "title": "Not found", diff --git a/site/static/fonts/IBMPlex.LICENSE.txt b/site/static/fonts/IBMPlex.LICENSE.txt new file mode 100644 index 0000000..c35c4c6 --- /dev/null +++ b/site/static/fonts/IBMPlex.LICENSE.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/site/static/fonts/IBMPlexMono-Medium.woff2 b/site/static/fonts/IBMPlexMono-Medium.woff2 new file mode 100644 index 0000000..3308bce Binary files /dev/null and b/site/static/fonts/IBMPlexMono-Medium.woff2 differ diff --git a/site/static/fonts/IBMPlexMono-Regular.woff2 b/site/static/fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000..52b6c75 Binary files /dev/null and b/site/static/fonts/IBMPlexMono-Regular.woff2 differ diff --git a/site/static/fonts/IBMPlexSans-Italic.woff2 b/site/static/fonts/IBMPlexSans-Italic.woff2 new file mode 100644 index 0000000..9426b4b Binary files /dev/null and b/site/static/fonts/IBMPlexSans-Italic.woff2 differ diff --git a/site/static/fonts/IBMPlexSans-Medium.woff2 b/site/static/fonts/IBMPlexSans-Medium.woff2 new file mode 100644 index 0000000..feb1cdf Binary files /dev/null and b/site/static/fonts/IBMPlexSans-Medium.woff2 differ diff --git a/site/static/fonts/IBMPlexSans-Regular.woff2 b/site/static/fonts/IBMPlexSans-Regular.woff2 new file mode 100644 index 0000000..ee30928 Binary files /dev/null and b/site/static/fonts/IBMPlexSans-Regular.woff2 differ diff --git a/site/static/fonts/IBMPlexSans-SemiBold.woff2 b/site/static/fonts/IBMPlexSans-SemiBold.woff2 new file mode 100644 index 0000000..ef2cb45 Binary files /dev/null and b/site/static/fonts/IBMPlexSans-SemiBold.woff2 differ diff --git a/site/static/fonts/README.md b/site/static/fonts/README.md index 5ce4bf4..80103e2 100644 --- a/site/static/fonts/README.md +++ b/site/static/fonts/README.md @@ -18,20 +18,36 @@ in `site.css` ask for: | `IBMPlexMono-Medium.woff2` | IBM Plex Mono | 500 | normal | | `ZillaSlab-SemiBold.woff2` | Zilla Slab | 600 | normal | -## Fetching them +## Where they come from + +The seven files are **committed**, so a clean checkout builds the real +typography with no network and a deploy is the same bytes from any +machine. Run the script only to refresh them or to add a face: ```bash python3 site/fetch-fonts.py ``` -The script asks Google Fonts for the CSS these faces would need, reads -the `woff2` URLs out of the reply, and writes the files here under the -names above. What it downloads is already the `latin` subset Google -serves — the site's copy of the font, not a link to Google's. +It asks Google Fonts for the CSS these faces would need, reads the +`woff2` URLs out of the reply, and writes the files here under the names +above. What it downloads is already the `latin` subset Google serves — +the site's copy of the font, not a link to Google's. That request +happens once, on the machine that refreshes a face; nothing the site +serves ever makes it. Both families are licensed for this: IBM Plex under the SIL Open Font -License 1.1, Zilla Slab likewise. Keeping the downloaded `*.LICENSE.txt` -beside the woff2 files satisfies the licence's one obligation. +License 1.1, Zilla Slab likewise. The licence's one obligation is that +its text and copyright notice travel with the font, and committing the +woff2 files here is redistribution — so they do: + +| File | Covers | From | +| --- | --- | --- | +| `IBMPlex.LICENSE.txt` | the six Plex faces | [IBM/plex](https://github.com/IBM/plex) | +| `ZillaSlab.LICENSE.txt` | `ZillaSlab-SemiBold` | [mozilla/zilla-slab](https://github.com/mozilla/zilla-slab) | + +`fetch-fonts.py` does not fetch these — it asks the Google Fonts CSS +API, which serves the woff2 and nothing else. Adding a family means +adding its licence here by hand. ## If they are absent diff --git a/site/static/fonts/ZillaSlab-SemiBold.woff2 b/site/static/fonts/ZillaSlab-SemiBold.woff2 new file mode 100644 index 0000000..e82c137 Binary files /dev/null and b/site/static/fonts/ZillaSlab-SemiBold.woff2 differ diff --git a/site/static/fonts/ZillaSlab.LICENSE.txt b/site/static/fonts/ZillaSlab.LICENSE.txt new file mode 100644 index 0000000..ddbfff4 --- /dev/null +++ b/site/static/fonts/ZillaSlab.LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2017, The Mozilla Foundation + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/site/static/site.css b/site/static/site.css index c9f2602..05a1e6d 100644 --- a/site/static/site.css +++ b/site/static/site.css @@ -171,6 +171,13 @@ a:hover{color:var(--text);text-decoration:underline} /* ── generated prose ── */ .page-article .prose{padding:30px 40px 44px;min-width:0} +/* The lede is the article's own sentence; everything after it is the + slice. It reads wider and quieter than body copy, as in the design. */ +.prose-lede{ + margin:0 0 26px;max-width:60ch; + font-size:var(--t-lede);line-height:1.6;color:var(--muted); + text-wrap:pretty; +} .prose h1{ font:600 var(--t-title)/1.1 var(--display);letter-spacing:-.02em; margin:0 0 12px; @@ -231,6 +238,23 @@ a:hover{color:var(--text);text-decoration:underline} .prose td{padding:11px 14px;border-bottom:1px solid var(--canvas);color:var(--muted);vertical-align:top} .prose tr:last-child td{border-bottom:0} +/* Prev/next along the same order the sidebar shows. An absent neighbour + leaves a .spacer in its slot, so `next →` stays right-hand on the first + page exactly as it does on every other. */ +.flow{ + display:flex;gap:14px;margin-top:44px;padding-top:22px; + border-top:1px solid var(--border-soft); +} +.flow-link{ + display:flex;flex-direction:column;gap:5px;flex:0 1 auto;max-width:46%; + padding:12px 16px;background:var(--surface); + border:1px solid var(--border-soft);border-radius:11px;color:var(--text); +} +.flow-link:hover{border-color:var(--accent);color:var(--text);text-decoration:none} +.flow-next{margin-left:auto;text-align:right} +.flow-dir{font-size:var(--t-micro);letter-spacing:.08em;color:var(--dim)} +.flow-title{font:600 15px/1.3 var(--display)} + /* ── home layout (1b Dockside) ── */ .hero{ display:grid;grid-template-columns:1.05fr .95fr;gap:44px; diff --git a/site/templates/article.html b/site/templates/article.html index f46a3e5..086cfac 100644 --- a/site/templates/article.html +++ b/site/templates/article.html @@ -6,7 +6,7 @@ A phone has room for one of those columns, so the other two are written twice: once as the column the design draws, and once as a
    strip that is display:none until the breakpoint which - hides its column. The strips carry the same $sidebar and $toc, so + hides its column. The strips carry the same $$sidebar and $$toc, so there is one source for the links and no way for the folded copy to say something the column does not. @@ -70,15 +70,23 @@ $sidebar

    $title

    + body has no h2s gets an empty $$toc, and the stylesheet drops the + strip rather than offering a summary over nothing. It sits above + the lede on purpose: the stylesheet's lede adjacency names + .menu-contents + p for exactly this order. --> + +

    $lede

    $body +$flow