From 180d1f7ac5a4aca080912a4ab3ed40a2052aa78e Mon Sep 17 00:00:00 2001 From: Daniel Verdu <59574085+davertor@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:45:02 +0200 Subject: [PATCH 1/4] feat(take-notes): user-defined genres, and a router generated from them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A genre was three things kept in step by hand: a contract file, a row in SKILL.md's Step 3 table, and a label in gallery.py's UI dict — and the offprint's contract was not even a file, it was a section of SKILL.md. That made "add your own genre" impossible without editing the skill. Now a genre is one Markdown file with a flat front-matter block (layout, label-en, label-es, when) followed by the contract. genres.py scans the bundled three plus ~/take-notes/genres/, validates them, and prints Step 3's table from the `when:` lines in precedence order — user genres first, then recipe, fieldguide, offprint as the catch-all. A genre without `when` is on request only; a user file named like a bundled genre replaces it. The layouts stay a closed set of three: a user genre picks one and brings its sections, which is where the value is. The offprint's contract moves to genres/offprint.md in the same shape, so SKILL.md loses 52 lines per note and the three are defined identically. Every template now records data-genre, the offprint's two included, so a user genre on that layout reads back as itself; notes.py accepts slug names. check-drift.py refuses a router row written back into SKILL.md by hand. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 26 ++ CONTRIBUTING.md | 64 +++-- README.md | 18 +- scripts/check-drift.py | 19 +- skills/take-notes/SKILL.md | 89 ++---- .../take-notes/assets/article-template.html | 2 +- skills/take-notes/assets/template.html | 2 +- skills/take-notes/genres/fieldguide.md | 7 + skills/take-notes/genres/offprint.md | 67 +++++ skills/take-notes/genres/recipe.md | 7 + skills/take-notes/scripts/gallery.py | 24 +- skills/take-notes/scripts/genres.py | 258 ++++++++++++++++++ skills/take-notes/scripts/notes.py | 7 +- skills/take-notes/scripts/render.py | 77 ++++-- 15 files changed, 531 insertions(+), 138 deletions(-) create mode 100644 skills/take-notes/genres/offprint.md create mode 100644 skills/take-notes/scripts/genres.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69bac6b..42d0e0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: # the whole test suite. See CONTRIBUTING.md. - name: Run script self-checks run: | - for s in render notes gallery export transcript tags retag slides themes; do + for s in render notes gallery export transcript tags retag slides themes genres; do echo "--- $s" uv run skills/take-notes/scripts/$s.py --selftest done diff --git a/CHANGELOG.md b/CHANGELOG.md index 72c4dd9..22a98b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project are documented here, in the written by hand; the version and the tag come from `cz bump` — see [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md). +## [1.6.0] + +### Added + +- **Your own genres.** A genre is now one Markdown file — a front-matter block + naming its layout, its card label and the one-line "the source is…" the + router reads, then the sections the skill writes — and `~/take-notes/genres/` + is scanned beside the three that ship. Pick one of the three layouts, write + the sections, and it is routable the moment it has a `when:` line; leave that + out and it is used only by name with `--genre`, the safe way to try one. A + file named like a bundled genre replaces it, so a copy of `recipe.md` with + different facts on the card is your own recipe genre. +- **The router is generated, not written.** `scripts/genres.py --list` prints + Step 3's table from the contracts' front-matter, in precedence order — user + genres first, then `recipe`, `fieldguide`, `offprint` as the catch-all — and + `check-drift.py` refuses a router row written back into `SKILL.md` by hand. + +### Changed + +- **The offprint's contract moved to `genres/offprint.md`**, in the same shape + as the other two. `SKILL.md` is 52 lines shorter for every note, and the + three genres are now defined the same way — the precondition for letting a + user define a fourth. +- Every note template records its genre on ``, the offprint's + two included, so a user genre on the offprint layout reads back as itself. + ## [1.5.0] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d6a6da..17ff872 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,33 +14,13 @@ cd take-notes No build, no virtualenv, no install step. `uv` provisions Python per script. -### 6. A genre is a contract plus a template - -`SKILL.md` Step 3 routes a source's *content* to a genre; each genre is one body -contract and one template, and the offprint is the default. The offprint's -contract stays inline in `SKILL.md` (it is the common case and saves a Read per -note); every other genre's lives in `skills/take-notes/genres/.md`. -Adding one means: - -1. a row in the Step 3 table, above the offprint's catch-all; -2. `genres/.md` — the sections, in `## Sections`' voice, plus the class - vocabulary the template styles (the one exception to "no classes"); -3. `assets/-template.html`, self-contained like the others, carrying the - landmarks in the table above, `{{PALETTE}}`, `{{FONTS}}`, - `data-genre="{{GENRE}}"`, and `{{MASTHEAD}}` where the poster or kicker goes; -4. an entry in `GENRE_TEMPLATES` in `scripts/render.py`, a label in both - languages of `UI` in `scripts/gallery.py`, and asserts in each `--selftest`. - -The theme is orthogonal: a genre template consumes the palette tokens and never -hard-codes a colour. - ## Verify There is no test suite. Each script with non-trivial logic carries its own asserts behind `--selftest`, and all of them must pass before a PR: ```sh -for s in render notes gallery export transcript tags retag slides themes; do +for s in render notes gallery export transcript tags retag slides themes genres; do uv run skills/take-notes/scripts/$s.py --selftest done ``` @@ -140,7 +120,7 @@ styling: |---|---|---| | `.poster`, `.kicker`, `.meta`, `.watch` | both note templates | the masthead parser | | `.tags` / `.tag` / `.tag.is-primary`, immediately above the literal `
` | both note templates | the tag parser, the gallery's chips, and `retag.py`'s insertion anchor | -| `` | the genre templates (absent = offprint) | the genre parser, the gallery's card label, the Markdown frontmatter | +| `` | every note template (absent = a note older than genres, an offprint) | the genre parser, the gallery's card label, the Markdown frontmatter | | `
` with a flat run of `

` — and no `
` inside it | every note template, and every genre contract | the section splitter, which ends the body at the first `
` | | `
  • term — definition
  • ` | `SKILL.md` Key points / Concepts | the Anki card builder | @@ -176,6 +156,46 @@ own type). `--selftest` enforces the contrast floors, that the display weight is one the family actually loads, and that the payload survives the round trip the gallery sends it on. +### 6. A genre is a contract; a layout is a template + +`SKILL.md` Step 3 routes a source's *content* to a genre. A genre is one file, +`skills/take-notes/genres/.md`, for all three that ship — the offprint's +contract is no longer inline in `SKILL.md`. The file opens with a flat +front-matter block the scripts read (`genres.py` parses it; no YAML library): + +``` +--- +layout: recipe # offprint | fieldguide | recipe — the template +label-en: recipe # the gallery card's label +label-es: receta +when: a dish — ingredients with quantities and a method with times +--- +``` + +`when` is the genre's row in the router. `genres.py --list` generates the +table Step 3 reads from these lines, in precedence order — user genres first, +then `recipe`, `fieldguide`, `offprint` (`BUNDLED_ORDER`) — so the table is +never written by hand and `check-drift.py` refuses a row that is. A genre +without `when` is on request only (`--genre `). Users add genres under +`~/take-notes/genres/`, and one named like a bundled genre replaces it. + +Adding a **bundled genre** means: + +1. `genres/.md` — the front-matter above, then the sections in the voice + of the other contracts, plus the class vocabulary the template styles (the + one exception to "no classes"); add the name to `BUNDLED_ORDER` in + `scripts/genres.py`, above `offprint`; +2. if it needs a new **layout**: `assets/-template.html`, self-contained + like the others, carrying the landmarks in the table above, `{{PALETTE}}`, + `{{FONTS}}`, `data-genre="{{GENRE}}"`, and `{{MASTHEAD}}` where the poster + or kicker goes; an entry in `LAYOUT_TEMPLATES` in `scripts/render.py` and in + `LAYOUTS` in `scripts/genres.py`. Most genres do not — the layouts are a + closed set on purpose, and a genre's value is its sections; +3. asserts in `genres.py --selftest` and `render.py --selftest`. + +The theme is orthogonal: a genre template consumes the palette tokens and never +hard-codes a colour. + ## Good first contributions - **A source it handled badly** — open an issue with the URL and what the notes diff --git a/README.md b/README.md index cf3ae1f..f68d12e 100644 --- a/README.md +++ b/README.md @@ -190,9 +190,19 @@ different objects rather than the same outline with different words: The default is `offprint`, and it stays the default whenever the call is not obvious — a wrong genre is worse than the plain one. Override it for a run with `--genre` or in words ("write it as a recipe"); the skill says which genre it -chose whenever it is not the offprint. Each genre has its own contract under -[`skills/take-notes/genres/`](skills/take-notes/genres/) and its own template -under `assets/`; the colour [theme](#theme) applies to all of them. +chose whenever it is not the offprint. Each genre is one contract under +[`skills/take-notes/genres/`](skills/take-notes/genres/) — a front-matter block +naming its layout and the "the source is…" line the router reads, then the +sections the skill writes — rendered by one of three layouts under `assets/`; +the colour [theme](#theme) applies to all of them. + +**Your own genres** go in `~/take-notes/genres/.md`, in the same shape. +Pick a layout, write the sections, and it is routable the moment you give it a +`when:` line — or leave that out and use it by name with `--genre `, +which is the safe way to try one. A file named like a bundled genre replaces +it: copy `recipe.md` there and change the facts on the card to have your own +recipe. `uv run skills/take-notes/scripts/genres.py --list` prints what is +installed, in the order the router reads it. ### Language @@ -241,7 +251,7 @@ silently. You are never prompted to invent a tag mid-run. The full grammar, for reference: ```sh -/take-notes [more urls…] [focus] [--lang en|es] [--genre offprint|fieldguide|recipe] +/take-notes [more urls…] [focus] [--lang en|es] [--genre ] /take-notes --tags | --add-tag "AI" | --remove-tag "AI" | --retag /take-notes --theme | --theme "notebook" ``` diff --git a/scripts/check-drift.py b/scripts/check-drift.py index 30ba70a..9fe82df 100755 --- a/scripts/check-drift.py +++ b/scripts/check-drift.py @@ -32,8 +32,9 @@ "Didactic means explaining, not compressing": "the point of the whole skill", } -# What a copied-in section contract looks like. The writing standard has one -# copy, in SKILL.md; the guides under references/ are acquisition only. +# What a copied-in section contract looks like. The writing standard lives in +# SKILL.md and the contracts under genres/; the guides under references/ are +# acquisition only. WRITING_STANDARD_MARKERS = ("Executive summary", "The one takeaway", "Didactic means") @@ -86,7 +87,19 @@ def main() -> int: if phrase not in skill: errors.append(f"SKILL.md lost the rule {phrase!r} — {why}") - # 6. The version that ships is a version someone can read the notes for. + # 6. The genre router is generated from the contracts (genres.py --list), so + # a row written back into SKILL.md by hand would be a second copy that + # drifts from the front-matter the script reads. + if "genres.py\" --list" not in skill: + errors.append("SKILL.md Step 3 no longer runs `genres.py --list` — the router table is generated, not written") + stale = re.findall(r"^\|.*`(recipe|fieldguide|offprint)`.*\|$", skill, re.M) + if stale: + errors.append( + f"SKILL.md carries a hand-written router row for {', '.join(stale)} — the table comes " + "from genres/*.md front-matter; edit the `when:` there instead" + ) + + # 7. The version that ships is a version someone can read the notes for. version = re.search(r'^\s*version:\s*"([^"]+)"', skill, re.M) if version is None: errors.append("SKILL.md has no `version:` in its frontmatter") diff --git a/skills/take-notes/SKILL.md b/skills/take-notes/SKILL.md index baa04da..ccdd9a2 100644 --- a/skills/take-notes/SKILL.md +++ b/skills/take-notes/SKILL.md @@ -11,7 +11,7 @@ metadata: # `allowed-tools` stays comma-separated: the spec asks for spaces but marks the # field experimental ("support may vary"), and commas are what Claude Code # parses today. Do not "correct" either without testing in Claude Code first. -argument-hint: " [more-urls…] [focus] [--lang en|es] [--genre offprint|fieldguide|recipe] | --tags | --add-tag X | --remove-tag X | --retag | --theme [name]" +argument-hint: " [more-urls…] [focus] [--lang en|es] [--genre ] | --tags | --add-tag X | --remove-tag X | --retag | --theme [name]" allowed-tools: Bash, Read, WebFetch, AskUserQuestion disable-model-invocation: true --- @@ -264,14 +264,18 @@ defines jargon (*Concepts*), or leaves something unresolved (*Going deeper*). A note's **genre** is the shape of its content — which sections it has and which template renders them. It is a property of the source, not a taste, so -you pick it after reading, from this table, **top to bottom, first row that -fits**: +you pick it after reading. Print the router: -| The source is… | Genre | Contract | -|---|---|---| -| a dish — ingredients with quantities and a method with times | `recipe` | Read `genres/recipe.md` | -| several things of one kind described on shared axes — tools, models, products, options, the papers in a survey; four or more of them | `fieldguide` | Read `genres/fieldguide.md` | -| anything else — a talk, an article, a paper, a docs page, a repo, a lesson | `offprint` | `## Sections` below | +```bash +uv run "${SKILL_DIR}/scripts/genres.py" --list --lang +``` + +It is a table of every installed genre — the bundled `recipe`, `fieldguide` +and `offprint`, plus any the user added under `~/take-notes/genres/` — with a +one-line "the source is…" per row and the path of its contract. Read it **top +to bottom, first row that fits**; the offprint is the last row and the +catch-all. A genre listed as *on request only* is never picked here — it is +used when asked for by name. Resolution order, stop at the first that applies: `--genre ` in the invocation, or a request in words ("write it as a recipe", "hazla como guía") @@ -279,10 +283,10 @@ invocation, or a request in words ("write it as a recipe", "hazla como guía") than the default, the same rule as for tags. With several sources the genre comes from the set as a whole, not from the first URL. -For a genre other than the offprint, Read its contract file now; it replaces -`## Sections` below and nothing else — `## Rules` still applies in full. Say -which genre you chose in the same short line as the language and the tag, only -when it is not the offprint: +Now Read the contract at the path the table gives. It holds the sections for +Step 4 and nothing else — `## Rules` still applies in full. Say which genre +you chose in the same short line as the language and the tag, only when it is +not the offprint: > Writing in Spanish per your config, filed under **Cooking**, as a **recipe**. @@ -301,8 +305,7 @@ be understood first; a reader should not be able to tell where the seam was. ## Step 4 — write the notes as HTML -Use the sections of the genre you picked in Step 3 — the offprint's are under -`## Sections` below; the other genres' live in `genres/`. Write **body HTML +Use the sections of the contract you read in Step 3. Write **body HTML only** — no ``, ``, ``, no `

    `, and no metadata line: the renderer supplies the document shell and the masthead from the fields you collected in Step 1. @@ -327,9 +330,9 @@ HTML Pass one `--tag` per tag chosen in Step 2, **primary first** — `--tag AI --tag Engineering`. With no `--tag` at all the note is filed under `Unknown`. -`--genre` is Step 3's choice — `fieldguide` or `recipe`; leave it off for an -offprint. `--video-id` still decides the masthead in every genre: a poster with -it, a byline kicker without. +`--genre` is Step 3's choice, by name; leave it off for an offprint. +`--video-id` still decides the masthead in every genre: a poster with it, a +byline kicker without. The masthead flags describe the **primary** source. When the run combined several, add one `--source "