diff --git a/.birds/.gitignore b/.birds/.gitignore new file mode 100644 index 00000000..2d2c13da --- /dev/null +++ b/.birds/.gitignore @@ -0,0 +1,5 @@ +/workspaces/ +/workspace-baselines/ +/tombstones/ +/ideas/.locks/ +/ideas.cache diff --git a/.birds/ideas/rk-accept-and-or-not-and-a-leading-dash-fbf676d5.md b/.birds/ideas/rk-accept-and-or-not-and-a-leading-dash-fbf676d5.md new file mode 100644 index 00000000..81ba2b4d --- /dev/null +++ b/.birds/ideas/rk-accept-and-or-not-and-a-leading-dash-fbf676d5.md @@ -0,0 +1,95 @@ +--- +id: rk-accept-and-or-not-and-a-leading-dash-fbf676d5 +short-id: fb +title: Accept AND, OR, NOT and a leading dash +priority: 1 +labels: +- type:feature +- search-clause-roles +deps: +- blocked-by:rk-make-the-whole-query-one-clause-tree-6f7c3949 +closed: true +--- +Accept `AND`, `OR`, `NOT` and a leading `-` as spellings of `&`, `|` and `!`, so +the query language matches the syntax readers already know from other search +boxes. + +## Why both spellings and not one + +Lucene, Xapian and Bleve all accept the terse symbol form AND the keyword form +simultaneously, and that is the field's settled answer rather than an oversight: + +- Lucene's classic parser takes `AND` / `OR` / `NOT` as well as `+` / `-`: + +- Xapian's parser takes `AND OR NOT XOR NEAR` alongside `+` / `-`: + + +The keyword form is the discoverable one; the symbols are the shortcut for +someone who has learned them. There is evidence a terse operator alone does not +get learned: when Google retired `+` in 2011 it reported the operator appeared in +under 0.5% of searches and that two thirds of those uses were wrong. + +## What is already in the tree — do not re-derive + +- `_prec` at `/home/lox/code/_fcl/rookery/search/0.1.0/src/tagquery.typ` line 40 is + `("!": 3, "&": 2, "|": 1)`, and the tokenizer tests membership with `c in _prec` + on a SINGLE cluster. A multi-character keyword cannot come out of that test as it + stands. +- `parse-tag-query` at line 58 accumulates non-operator clusters into an atom, so + `AND` currently parses as an atom with the value `and`. +- Its twin is `parseTagQuery` at `src/tagquery.js` line 39, with `PREC` mirroring + `_prec`. +- The escape set is `( ) | & ! \` and the file states it is FROZEN — a tag holding + one of those must be escapable, and promoting a new character to an operator + would change what already-written queries mean. Adding a KEYWORD does not touch + that set, which is why this bird is safe; adding a new symbol would not be. +- An earlier bird in this label made an unescaped space an implicit-AND operator. + +## Steps + +1. When an atom is about to be pushed, test whether its unfolded text is exactly + `AND`, `OR` or `NOT`, case-insensitively. If it is, emit the corresponding + operator token (`&`, `|`, `!`) through the existing precedence loop instead of + an atom token. Doing the test at PUSH time rather than in the tokenizer is what + keeps the single-cluster `c in _prec` test intact and the diff small. +2. A keyword must be a whole word to count. `android` is an atom, not `AND` plus + `roid`. Because the test runs on a complete accumulated atom, this falls out + for free — say so in a comment so nobody reintroduces a substring test. +3. An escaped keyword is an atom: `\AND` searches for the word "and". The escape + already forces its cluster into the atom, so make the keyword test skip any + atom that consumed an escape. Track that with a flag set in the `\` branch. +4. Accept a leading `-` on a clause as `!`: `-tags:draft` is `!tags:draft`, and + `window -depth` is `window & !depth`. Recognise it only where the `-` opens an + atom (nothing accumulated yet and an operator or the start of input precedes + it), so a `-` INSIDE a word stays part of the word. This matters: `_fold` turns + `-` into a space, and `in-progress` must keep working as a value. +5. Mirror every rule in `parseTagQuery` in `tagquery.js`. +6. Add `` fixture cases in `test/parity.typ` for: `a AND b`, + `a and b`, `a OR b`, `NOT a`, `android`, `\AND`, `-tags:draft`, + `window -depth`, and `in-progress`. + +## Do NOT + +- Do not add any new character to the escape set or to `_prec`'s keys. The escape + set is frozen and widening it is a breaking change to queries already written. +- Do not add `+` as a synonym for `&`. A leading `-` earns its place because it is + near-universal for negation; `+` does not, and the Google data above is the + reason. +- Do not add `XOR`, `NEAR`, phrase quoting or ranges. Each is its own decision. +- Do not change precedence. `NOT` takes `!`'s precedence 3, `AND` takes 2, `OR` + takes 1, and `!`'s right-associativity rule at equal precedence stays exactly as + written. + +## VERIFY + +Run from `/home/lox/code/_fcl/rookery/search/0.1.0`: + +1. `just parity` passes, including the nine new fixture cases. +2. `just test` passes. +3. `#context search-ideas("tags:draft AND window")` and + `#context search-ideas("tags:draft & window")` return identical rows in + identical order. +4. `#context search-ideas("android")` still matches the note whose name contains + "android", and `#context search-ideas("in-progress")` still matches the tag + `in-progress`. +5. `just build` succeeds. \ No newline at end of file diff --git a/.birds/ideas/rk-accepts-only-heading-where-as-ideate-dc9435ee.md b/.birds/ideas/rk-accepts-only-heading-where-as-ideate-dc9435ee.md new file mode 100644 index 00000000..3c54c7cc --- /dev/null +++ b/.birds/ideas/rk-accepts-only-heading-where-as-ideate-dc9435ee.md @@ -0,0 +1,200 @@ +--- +id: rk-accepts-only-heading-where-as-ideate-dc9435ee +short-id: dc +title: Accepts only heading.where as ideate separator +priority: 3 +labels: +- ideate-separator +deps: [] +closed: true +--- +Touches: core/0.1.0/src/ideate.typ, core/0.1.0/readme.md, core/0.1.0/test/units.typ + +`#ideate` (in `@rookery/core`) accepts its `separator:` argument in five +spellings. Two of them name a heading level: the selector +`heading.where(level: 2)` and the element `heading(level: 2)[]`. Drop the +element form. After this bird the only heading spelling accepted is +`heading.where(level: N)`, for ANY N — not just 2 — and the element form +raises `#ideate`'s own panic instead of being honoured. + +Why the element form goes: it exists only because `heading(level: 2)` bare is +illegal Typst (`error: missing argument: body`, since `heading` takes its body +positionally), so the element form has to carry a pointless `[]` to be written +at all. That is a trap dressed as a convenience — the selector spelling is +Typst's own idiom for naming a heading level, needs no empty body, and already +works. One accepted spelling is better than two when one of them is a +footgun. + +`heading.where(depth: N)` must KEEP working — it is a second field spelling of +the same selector, already accepted, and nothing here changes that. + +## Where the code is + +All in `core/0.1.0/src/ideate.typ`. Run every `rg` from the repository root +(`/home/lox/code/_fcl/rookery`) so a moved file still resolves. + +1. The classification block inside the `ideate` function. + + ``` + rg -n 'let heading-elem = type\(separator\)' core/0.1.0 + ``` + + One hit, `src/ideate.typ:357` as of filing, inside `#let ideate(..)`. The + three lines there are: + + - `let heading-elem = type(separator) == content and separator.func() == heading` + - `let heading-sel = type(separator) == selector` + - `let heading-mode = heading-elem or heading-sel` + +2. The level-resolution line, immediately after the validation panic. + + ``` + rg -n 'let want = if heading-elem' core/0.1.0 + ``` + + One hit, `src/ideate.typ:373` as of filing, inside `#let ideate(..)`. It + reads `let want = if heading-elem { _level-of(separator) } else if heading-sel { _sel-level(separator) }`. + +3. The validation panic message listing the accepted spellings. + + ``` + rg -n 'must be one of `par`' core/0.1.0 + ``` + + One hit, `src/ideate.typ:363` as of filing, inside `#let ideate(..)`. The + message runs from line 362 to line 371 and ends `+ repr(separator),`. + +4. The file-header comment block documenting the spellings. + + ``` + rg -n 'the same, as an element' core/0.1.0 + ``` + + Two hits, one per file — `src/ideate.typ:37` (the five-row list under the + comment heading `// ---- Choosing what starts a note: \`separator:\` ----`) + and `readme.md:597` (a table row). Both are the element form's own + documentation and both go. + +5. The readme prose asserting the element form is supported. + + ``` + rg -n 'stays supported and needs no string' core/0.1.0/readme.md + ``` + + One hit, `readme.md:632` as of filing, in the section headed + `### Choosing what starts a note`. + + ``` + rg -n 'naming both accepted forms' core/0.1.0/readme.md + ``` + + One hit, `readme.md:658` as of filing, same section. It says the panic names + "both accepted forms" — after this bird there is one. + +Anchors 4 and 5 are on text this bird DELETES. They stop matching once the step +lands, which is expected and is why they must not appear in VERIFY. If an +anchor does not hit at the start, widen the search to the repository root; if it +is still gone, stop and report the miss rather than guessing where the text +went. + +## Steps + +1. In `src/ideate.typ`, delete the `heading-elem` binding and rewrite + `heading-mode` to be `heading-sel` alone. Keep the `heading-mode` NAME — it + is read in four places further down (the `if not (none-mode or heading-mode + or par-mode)` gate, the group-splitting `is-separator` branch, the + `lead-i` position lookup, and the title/name/tags-function guard) and none of + those need to change. + +2. Rewrite `let want = ...` to call `_sel-level(separator)` when `heading-mode`, + with no `heading-elem` branch. + +3. Rewrite the validation panic message to name FOUR spellings, not five: + `par` (every paragraph becomes a note), `parbreak` (the same thing), + `heading.where(level: 2)` (every heading of that level starts a note — any + level, and `depth:` works in place of `level:`), and `none` (nothing splits + — the whole body is one note). Add one sentence telling a caller who wrote + `heading(level: 2)[]` to write `heading.where(level: 2)` instead. Keep the + trailing `+ repr(separator),` so the panic still shows what it got. Do NOT + describe `par` as "the default" in the new message — a sibling bird is + changing that default, and a message asserting it will be wrong. + +4. Update the file-header comment block: the five-row list becomes four rows + (drop the `heading(level: 2)[]` row). Leave the `(the default)` annotation + where it is, on the `par` row — it is still true, and a sibling bird moves + it. The block further down headed + `// \`heading(level: 2)\` BARE IS ILLEGAL TYPST` currently explains why the + element form exists and why the bare form cannot be caught here; replace it + with at most three lines saying that `heading(level: 2)` and + `heading(level: 2)[]` are both refused, the first by Typst's own compiler + before this function is reached and the second by the panic above, and that + `heading.where(level: 2)` is the spelling to use. Follow the project comment + style in `CLAUDE.md`: describe the present, no history, no "used to". + +5. Update `readme.md` in the section headed `### Choosing what starts a note`: + delete the element-form table row, delete the paragraph anchored by "stays + supported and needs no string", and reword the "both accepted forms" + sentence to the singular. The prose anchored by "on its own is illegal + Typst" should stay in some form but shrink — the bare form is still a thing + a reader will try — and must no longer present the element form as the + remedy. Say `heading.where(level: 2)` is the remedy. + +6. In `core/0.1.0/test/units.typ`, find the `_level-of` assertions: + + ``` + rg -n '_level-of\(heading\(level: 2\)\[\]\)' core/0.1.0/test/units.typ + ``` + + One hit, `test/units.typ:639` as of filing, in the section headed + `// ---- #ideate's three pure predicates ----`. `_level-of` itself is NOT + being removed — it is still called on markup headings found in a body, which + carry `depth` rather than `level` — so keep the assertions as tests of that + helper. Add one assertion beside the existing `_sel-level` ones proving a + level OTHER than 2 resolves, e.g. `#assert.eq(_sel-level(heading.where(level: 1)), 1)`, + since "any level works" is the claim this bird makes and nothing currently + pins level 1. + +## What NOT to do + +- Do NOT remove `_level-of`. It is load-bearing for reading markup headings out + of the body being split. +- Do NOT change `_sel-level`, its `repr`-parsing regex, or its panic. They + already accept any level and both field spellings. +- Do NOT change the DEFAULT value of `separator:`. It stays `par` in this bird; + a separate bird changes it. +- Do NOT add a `title:`/`document.title` behaviour. A separate bird does that. +- Do NOT export a `heading` of your own, or shadow the `heading` element, to + make the bare form work. That breaks every consumer's `#show heading:` rule + outright (`error: only element functions can be used as selectors`). +- Do NOT touch any package other than `core/0.1.0`. A repository-wide search + for `ideate` and `separator:` found no hits outside it. + +## VERIFY + +Run from `core/0.1.0/`: + +1. `just test` exits 0 and prints `units OK`. +2. `cd demo/rheo && just check` exits 0 and prints its own OK line. The demos + at `demo/rheo/content/ideated.typ` and `demo/rheo/content/ideated-named.typ` + already use the selector spelling, so they must keep passing untouched. +3. `cd demo/rheo && just check-typst` exits 0. +4. The element form now panics. Write a scratch file OUTSIDE the repository, + e.g. `/tmp/sep.typ`: + + ```typ + #import "@rookery/core:0.1.0": ideate + #ideate(separator: heading(level: 2)[])[== A + + body] + ``` + + and compile it with + `typst compile --features html --format html /tmp/sep.typ /tmp/sep.html`. + It must FAIL with `#ideate`'s own `separator:` panic naming + `heading.where(level: 2)`. Typst has no way to assert a panic inside + `test/units.typ`, which is why this is a manual step rather than a unit + assertion. Note that this scratch compile resolves `@rookery/core` through + the machine's package cache rather than the flight; if it picks up different + code, say so in the landing message instead of treating it as a failure. +5. `rg -n 'heading.where\(level: 1\)' core/0.1.0/test/units.typ` hits, showing + the any-level assertion was added. \ No newline at end of file diff --git a/.birds/ideas/rk-add-a-hoist-predicate-to-todo-table-7ccfc18f.md b/.birds/ideas/rk-add-a-hoist-predicate-to-todo-table-7ccfc18f.md new file mode 100644 index 00000000..03432941 --- /dev/null +++ b/.birds/ideas/rk-add-a-hoist-predicate-to-todo-table-7ccfc18f.md @@ -0,0 +1,144 @@ +--- +id: rk-add-a-hoist-predicate-to-todo-table-7ccfc18f +short-id: 7c +title: Add a hoist predicate to todo-table +priority: 3 +labels: +- feat-todo-band-order +deps: +- blocked-by:rk-band-todo-table-by-urgency-and-priority-93eeda98 +closed: true +--- +`#todo-table`'s band 0 is derived from dates and priority, and that is every +reason a todo can be urgent **except** the one only the site knows: a hand-set +tag meaning "on for today whatever the dates say". Give the panel a `hoist:` +predicate so a site can put such a row in band 0 outright. + +Touches: /home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/test/units.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/readme.md + +## Why this parameter exists + +`/home/lox/code/_fcl/rookery/todos/0.1.0/src/today.typ` line 77 has the same +hole, named `also:`, and its comment (lines 70-76) states the case in full: + +> A predicate over a row that ORs INTO the day's selection, where `filter:` +> narrows it instead. It is the one hole this package cannot fill for itself: a +> site may have a reason a todo belongs on today's list that no date or +> priority captures — a hand-set tag meaning "on for today whatever the dates +> say", say — and every other argument here can only ever REMOVE rows from the +> selection. + +One real consumer depends on it. `/home/lox/code/waterline/rookery/_lib/template.typ` +line 36 defines `#let TODAY-TAG = "today"`, and line 34 calls it "A hand-set +tag, NOT derived from any date". `/home/lox/code/waterline/rookery/index.typ` +passes `also: r => TODAY-TAG in r.tags-dict`. That tag matches no date and no +priority, so without `hoist:` it can never reach band 0. + +This is `also:` moved one layer down, from the day view into the panel the day +view is built on. Do not name it `also:` — that name meant "add this row to a +selection", and this one means "this row reads in band 0". Different claim. + +## What `_band` looks like now + +`/home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ` holds a private +`_band(row, days, scale)` returning a band number 0 to 3, where 0 is the top +band. It returns 0 for an in-progress row, and otherwise the lower of the +row's countdown band and its priority rung. Read the function before editing +it — a preceding bird added it, so its line number is not one this description +can give. + +## What to do + +1. Give `_band` a `hoist:` keyword argument defaulting to `none`, tested in + the same place the in-progress check already sits — both are reasons a row + is band 0 regardless of either ladder: + + ```typ + #let _band(row, days, scale, hoist: none) = { + if hoist != none and hoist(row) { return 0 } + if row.status == "in-progress" { return 0 } + ... + } + ``` + + Extend its comment with one sentence: a site's own reason for a row being + in the top band, which no date and no priority can express. + +2. Add the parameter to `#todo-table`, immediately after `filter:` (which is + at line 228 before this bird — find it by name, not by number, since a + preceding bird has edited this parameter list). Document it: + + ```typ + // ROWS THIS SITE PUTS IN THE TOP BAND, as a predicate over a row — + // `r => bool`, `none` for none of them. A site may have a reason a todo is + // urgent that no date and no priority captures — a hand-set tag meaning "on + // for today whatever the dates say" — and band 0 is otherwise derived + // entirely from the two ladders. + // + // NOT `filter:`, which decides which rows are rows at all. This one cannot + // add a row or remove one; it only moves a row that is already listed into + // the top band. + hoist: none, + ``` + +3. Forward it at the `_band(..)` call in the row map: + + ```typ + let band = _band(r, ..., scale, hoist: hoist) + ``` + +4. Document it in `/home/lox/code/_fcl/rookery/todos/0.1.0/readme.md`, in the + `## Grouped pills: #todo-table` section (header at line 381), beside the + band prose a preceding bird wrote there. One short paragraph and one + example: + + ```typ + #todo-table(today: TODAY, hoist: r => "today" in r.tags-dict) + ``` + +## Non-goals + +- **Do not remove or change `#today-panel`'s `also:`.** A later bird removes + that whole file; this bird leaves it alone so the two can land + independently. +- **No `bands:` parameter.** That is a separate bird. +- Do not make `hoist:` able to demote a row to a cooler band. It promotes to + band 0 or does nothing — a predicate returning a bool cannot say which band, + and a site that wants to reorder the ladder itself wants a different feature. +- Do not give `hoist:` a default that reads any particular tag. `"today"` is + one site's convention and this package declares no tag vocabulary of its own + beyond the `todo` namespace. + +## Comment style + +This repo's `CLAUDE.md` lines 66-98: present tense, describe what the code is +and why, never what it replaced or where it moved from. No bird ids, no branch +names. Emphasis capitals for the one claim in a block that carries it. + +## VERIFY + +Run from `/home/lox/code/_fcl/rookery/todos/0.1.0`: + +1. Add assertions to `/home/lox/code/_fcl/rookery/todos/0.1.0/test/units.typ` + beside the existing `_band` block. Privates are in scope through + `#import "/src/lib.typ": *` at line 5, so no new import is needed: + + ```typ + // A hoisted row is band 0 with no date and no priority at all. + #assert.eq( + _band((status: "open", priority: 0, tags-dict: ("today": none)), none, (4, 3, 2), hoist: r => "today" in r.tags-dict), + 0, + ) + // The predicate only promotes: a row it says nothing about keeps its ladder band. + #assert.eq( + _band((status: "open", priority: 0, tags-dict: (:)), none, (4, 3, 2), hoist: r => "today" in r.tags-dict), + 3, + ) + // `hoist: none` is the default and changes nothing. + #assert.eq(_band((status: "open", priority: 0, tags-dict: (:)), none, (4, 3, 2)), 3) + ``` + +2. `just test` — `typst compile --features html --root . --format pdf test/units.typ /dev/null && ./test/panics.sh`. Must pass. + +3. `just check` — `rheo compile demo/rheo && ./demo/rheo/check.sh`. Must pass + unchanged: the demo passes no `hoist:`, so nothing there moves. \ No newline at end of file diff --git a/.birds/ideas/rk-add-active-true-as-a-status-shorthand-2a7f6a1e.md b/.birds/ideas/rk-add-active-true-as-a-status-shorthand-2a7f6a1e.md new file mode 100644 index 00000000..96d92dc7 --- /dev/null +++ b/.birds/ideas/rk-add-active-true-as-a-status-shorthand-2a7f6a1e.md @@ -0,0 +1,234 @@ +--- +id: rk-add-active-true-as-a-status-shorthand-2a7f6a1e +short-id: 2a7 +title: 'Add active: true as a status shorthand' +priority: 3 +labels: +- feat-todos-in-progress +deps: +- blocked-by:rk-hoist-in-progress-todos-to-the-top-d3a08506 +closed: true +--- +Marking a todo as being worked on costs a quoted string today — +`#todo("x", status: "in-progress")` — where closing one costs a named argument, +`done: d`. Add `active: true` as the shorthand for `status: "in-progress"`, the +same way `done:` is the shorthand for `timeline: (closed: ..)`. + +**It does not exist yet.** `rg -n '\bactive\b'` over +`/home/lox/code/_fcl/rookery/todos/0.1.0/src/*.typ` and its `readme.md` returns +nothing; the only nearby name is the log stage `ACTIVATED-STAGE` +(`src/tags.typ:61`), which is a different thing and stays untouched — see the +decisions below. + +Touches: /home/lox/code/_fcl/rookery/todos/0.1.0/src/tags.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/src/todo.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/test/units.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/readme.md + +## What exists now + +- `#todo` (`/home/lox/code/_fcl/rookery/todos/0.1.0/src/todo.typ:125`) takes + `status: none` at line 128 and forwards it into `todo-tags` as + `status: status,` at line 144. +- `todo-tags` (`/home/lox/code/_fcl/rookery/todos/0.1.0/src/tags.typ:148`) takes + `status: none` at line 152 and, in the block at lines 180-189, asserts the + value is in `STATUSES` (`tags.typ:50`, which is + `("in-progress", "deferred", "draft")`) and inserts the flat key + `"todo-" + status`. +- `todo-tags` is exported through `src/lib.typ:20` and is directly unit-tested: + `test/units.typ:23` already asserts + `todo-tags(status: "in-progress").keys() == ("todo", "todo-in-progress")`. + +## Decisions already made — do not re-derive + +- **Resolve `active:` inside `todo-tags`, not inside `#todo`.** `todo-tags` is + the exported, directly-testable function that owns every one of this package's + keys, and putting the synonym there means a caller reaching `todo-tags` + directly gets it too. `#todo` only grows the parameter and forwards it. +- **`active:` is a BOOL, where `done:` is a date, and that asymmetry is correct.** + A close is a dated EVENT and lives in the log, which is why `done: true` is + refused (`src/todo.typ:95-104`). Being under way is a STATE — the flat + `todo-in-progress` key — and `status: "in-progress"` has never carried a date. + There is no clock to stamp one from anyway (`datetime.today()` returns + 1980-01-01 under a reproducible build). So `active:` takes `true` or `false` + and nothing else. +- **Do NOT write an `activated` log entry from `active:`.** `ACTIVATED-STAGE` + (`src/tags.typ:61`) is a dated stage on `TODO-LADDER` and the comment above it + states the split outright: the flat key is what a tag query filters on, the log + entry is what says SINCE WHEN. A boolean cannot supply a date, and synthesising + one would put a false date in the log. A caller wanting both writes + `#todo("x", active: true, timeline: (activated: d))`, and that must keep + working. +- **`active: false` emits nothing**, exactly as `closed: false` does + (`src/tags.typ:143-147` explains why): absence means "not said", not "not in + progress". A `todo-in-progress` key valued `false` would mark every open todo + as in progress to any consumer testing for the key. +- **Refuse `active: true` together with any non-`none` `status:`**, even when the + status IS `"in-progress"`. Two spellings of one field on one call is a + contradiction to report rather than a merge to perform — the same reading + `_closing` takes of a close written twice (`src/todo.typ:112-119`). One write + path, one message. +- **Use a NEW local name in `todo-tags` rather than shadowing the `status` + parameter.** `#let status = if active { .. } else { status }` reads the + parameter on its own right-hand side, which is a Typst question this bird has + no reason to open. Bind `st` and use it in the block below. +- **Nothing downstream changes.** `_state-of` (`src/table.typ:127-133`) reads + `row.status`, `status-of` (`src/tags.typ:284`) reads the flat key, and both see + exactly what `status: "in-progress"` already produces. The `#done(date)` and + `#epic(name)` factories, and a site's own `#let todo = todo.with(..)`, are all + `.with` over `#todo` and inherit the new parameter for free. + +Line numbers are as of filing. If they have shifted, match the quoted text. + +## Steps + +1. In `/home/lox/code/_fcl/rookery/todos/0.1.0/src/tags.typ`, add a parameter to + `todo-tags`. The list currently reads (lines 148-157) `#let todo-tags(` / + `tags: none,` / `priority: none,` / `kind: none,` / `status: none,` then the + two-line comment above `closed: false,`. Insert immediately after + ` status: none,`: + + ```typ + // `true` IS THE SHORTHAND for `status: "in-progress"`, resolved below so that + // everything downstream reads one field. A BOOL, where `done:` is a date: + // a close is a dated event and lives in the log, while being under way is a + // state and never carried a date. `false` emits nothing, like `closed:` below. + active: false, + ``` + +2. In the same file, replace the whole `if status != none { .. }` block at lines + 180-189 with the following, which validates `active:`, refuses the two + spellings together, and resolves them into one local: + + ```typ + assert( + type(active) == bool, + message: "@rookery/todos: `active` is a flag, `true` or `false` — got " + + repr(active) + ". It is the shorthand for `status: \"in-progress\"`, and " + + "carries no date: a todo recording WHEN it was picked up writes " + + "`timeline: (activated: ..)` alongside it.", + ) + // TWO SPELLINGS OF ONE FIELD ON ONE CALL is a contradiction to report rather + // than a merge to perform — the same reading `_closing` takes of a close + // written twice. Refused even where the two agree, so there stays one write path. + assert( + not (active and status != none), + message: "@rookery/todos: this todo says how far along it is twice — once as " + + "`active: true` and once as `status: " + repr(status) + "`. They are the " + + "same field, so write one of them.", + ) + let st = if active { "in-progress" } else { status } + + if st != none { + assert( + st in STATUSES, + message: "@rookery/todos: `status` must be one of " + + STATUSES.join(", ") + " — got " + repr(st) + + ". A CLOSED todo is expressed by `done:`, not by `status:`, and a " + + "BLOCKED one is derived from its dependencies rather than declared.", + ) + out.insert("todo-" + st, none) + } + ``` + +3. In `/home/lox/code/_fcl/rookery/todos/0.1.0/src/todo.typ`, add `active: false,` + to `#todo`'s parameter list immediately after ` status: none,` (line 128). + +4. In the same file, in the `todo-tags(` call inside `#todo`, add + ` active: active,` on the line immediately after + ` status: status,` (line 144). + +5. Add to `/home/lox/code/_fcl/rookery/todos/0.1.0/test/units.typ`, immediately + after the existing line 23 + (`#assert.eq(todo-tags(status: "in-progress").keys(), ("todo", "todo-in-progress"))`): + + ```typ + // `active: true` is the same claim in fewer characters, and resolves to the + // same key rather than to one of its own. + #assert.eq(todo-tags(active: true).keys(), ("todo", "todo-in-progress")) + // `false` is the default and says nothing, like `closed: false`. + #assert.eq(todo-tags(active: false).keys(), ("todo",)) + #assert.eq(todo-tags().keys(), ("todo",)) + ``` + + Do NOT add asserts for the two panics here. `units.typ` is a compile-and-pass + fixture: an `assert` that fires fails the whole compile, so a test that + EXPECTS a panic cannot live in it. `test/panics.sh` is where this package + tests refusals; adding a case there is out of scope for this bird. + +6. In `/home/lox/code/_fcl/rookery/todos/0.1.0/readme.md`, document the + shorthand. Insert a section immediately before the line + `## 0.1.0 — a todo's dates are one log` (line 77): + + ```markdown + ## Marking a todo in progress + + Two spellings, one key — `active:` folds into `status:` before anything reads + it, so they cannot disagree: + + ```typst + #todo("fetch", status: "in-progress")[...] // the field, written directly + #todo("fetch", active: true)[...] // the shorthand + ``` + + - **`active:` is a FLAG, not a date**, where `done:` is the other way round. A + close is a dated event and belongs in the log; being under way is a state, + and `status: "in-progress"` never carried a date. A todo that wants to record + when it was picked up writes the log stage that exists for it alongside: + `#todo("fetch", active: true, timeline: (activated: d))`. + - **`active: false` is the default and emits nothing.** Absence is "not said", + not "not in progress" — the same rule `closed: false` follows. + - **Both at once is refused.** `active: true` together with any `status:` is + one field written twice, so write one of them. + ``` + + Then update the status row of the flat-tag table at line 173, which currently + reads: + + ```markdown + | `todo-in-progress`, `todo-deferred`, `todo-draft` | `status:` | + ``` + + to: + + ```markdown + | `todo-in-progress`, `todo-deferred`, `todo-draft` | `status:` (or `active:` for the first) | + ``` + +## Do NOT + +- Do not add a date, a log entry or an `activated` stage from `active:`. See the + decisions above. +- Do not touch `ACTIVATED-STAGE`, `TODO-LADDER`, `STATUSES` or any other constant + in `src/tags.typ:40-75`. +- Do not add a matching `deferred:` or `draft:` shorthand. Only the one asked for. +- Do not touch `src/table.typ`, `src/today.typ`, `src/views.typ`, `src/todos.css` + or any `.js` file — nothing downstream reads `active:`, and two other birds are + changing the order and the colour of in-progress rows. +- Do not add a panic case to `test/panics.sh`. +- Do not edit `src/todo.typ`'s `_closing`, the `done:` argument, or any of its + assertions. + +## VERIFY + +```sh +cd /home/lox/code/_fcl/rookery/todos/0.1.0 && just test +cd /home/lox/code/_fcl/rookery/todos/0.1.0 && just check +``` + +Expected: `just test` compiles `test/units.typ` without a panic, runs +`test/panics.sh`, and prints `units OK`. The three new asserts are the decisive +part — `todo-tags(active: true)` producing anything other than +`("todo", "todo-in-progress")` fails the compile with a line number. + +Then confirm the two refusals fire, by compiling a one-line fixture that should +NOT compile: + +```sh +cd /home/lox/code/_fcl/rookery/todos/0.1.0 && printf '#import "/src/lib.typ": *\n#let _ = todo-tags(active: true, status: "draft")\n' > /tmp/active-clash.typ && typst compile --features html --root . --format pdf /tmp/active-clash.typ /dev/null +``` + +Expected: it FAILS, with the message `says how far along it is twice`. A clean +compile here means the assertion was not reached and step 2 is wrong. + +No separate `just build` is needed: `typst.toml`'s `entrypoint` points at +`src/lib.typ`, so a Typst edit takes effect immediately, and `dist/` holds only +the JavaScript bundle this bird does not touch. (The `build` recipe's comment in +the `Justfile` claims otherwise; it is stale.) \ No newline at end of file diff --git a/.birds/ideas/rk-add-filter-and-sort-to-window-and-ideas-743f0195.md b/.birds/ideas/rk-add-filter-and-sort-to-window-and-ideas-743f0195.md new file mode 100644 index 00000000..d9a6c0b1 --- /dev/null +++ b/.birds/ideas/rk-add-filter-and-sort-to-window-and-ideas-743f0195.md @@ -0,0 +1,191 @@ +--- +id: rk-add-filter-and-sort-to-window-and-ideas-743f0195 +short-id: '743' +title: Add filter and sort to window and ideas +priority: 3 +labels: +- feat-selection-arg-parity +deps: +- blocked-by:rk-add-outline-target-idea-front-door-52bfc795 +closed: true +--- +Touches: core/0.1.0/src/window.typ, core/0.1.0/src/data.typ, core/0.1.0/test/units.typ, core/0.1.0/readme.md + +Three functions in `@rookery/core` select ideas, and each offers a different slice of one query +language: + +| | `tags` | `match` | `filter` | `sort` | +| --- | --- | --- | --- | --- | +| `#window` | yes | yes | **no** | yes | +| `ideas()` | yes | yes | **no** | **no** | +| `#ideas-outline` | yes | yes | yes | no (and must not — see the non-goals) | + +`filter:` is the one that matters. `tags:`/`match:` can say "any of these" and "all of these" +and nothing else, so `draft but not phd`, or `(phd and draft) or todo`, is unexpressible — the +author drops out of `#window` entirely and hand-rolls a loop over `ideas()`. The packages built +on core already treat the trio as one vocabulary: `todos-list(tags, match, filter, ..)` +(`todos/0.1.0/src/views.typ` line 188 as of filing) and +`timeline-upcoming(tags, match, filter, ..)` (`timeline/0.1.0/src/upcoming.typ` line 270) both +carry all three. Core is the layer that does not. + +The machinery is already in place. `_tag-pred(tags, match, filter: none)` +(`core/0.1.0/src/pure.typ` line 150 as of filing) takes the caller's predicate and ANDs it with +the tag test, and `#ideas-outline` already passes one through. This bird gives the other two +callers the same argument, and gives `ideas()` the `sort:` that `#window` already has. + +## Steps + +1. **`#window` — add the argument.** Find its selection arguments: + + ``` + rg -n 'match: "any",' /home/lox/code/_fcl/rookery/core/0.1.0/src/window.typ + ``` + + One hit as of filing, line 112, near the end of the multi-line `#let window(..)` parameter + list (`tags: none,` above it, `sort: auto,` below). Add `filter: none,` to that group. Give + it a short comment in the file's style saying it is a predicate over the idea's tag + DICTIONARY, ANDed with `tags:`/`match:` rather than replacing them, and that it is what + expresses a selection those two cannot — exclusion, or an OR of ANDs. + +2. **`#window` — assert it.** The function's other asserts sit together just below the + signature: + + ``` + rg -n '_assert-match\(match, "#window' /home/lox/code/_fcl/rookery/core/0.1.0/src/window.typ + ``` + + One hit as of filing, line 126. Add a `filter` assert beside it, worded like the one + `#ideas-outline` already carries (`rg -n 'filter. must be none or a' core/0.1.0/src/outline.typ`, + one hit, line 471 as of filing): + + ```typ + assert( + filter == none or type(filter) == function, + message: "@rookery/core: #window's `filter` must be none or a function taking the " + + "idea's tag dictionary — got " + repr(filter), + ) + ``` + +3. **`#window` — plumb it through.** The one place the predicate is built: + + ``` + rg -n 'let pred = _tag-pred\(tags, match\)' /home/lox/code/_fcl/rookery/core/0.1.0/src + ``` + + One hit as of filing, `window.typ` line 264, inside the `context` block that selects the + tagged ideas. Change it to `_tag-pred(tags, match, filter: filter)`. + + **Read the four lines around it before editing.** The branch reads + `if pred == none { () } else { .. }` — `_tag-pred` returns `none` when there is nothing to + filter by, and with a `filter:` and no `tags:` it now returns a predicate where it used to + return `none`. That is correct and is what makes `#window(filter: ..)` with no `tags:` work, + but check the surrounding `if tags == none { () }` guard on the line above: as of filing + the tagged branch is skipped entirely when `tags == none`, so a filter-only window would + select nothing. Change that guard to run whenever `tags != none or filter != none`. + +4. **`ideas()` — add both arguments.** Find the signature: + + ``` + rg -n 'let ideas\(tags: none' /home/lox/code/_fcl/rookery/core/0.1.0/src + ``` + + One hit as of filing, `data.typ` line 273: + `#let ideas(tags: none, match: "any", index: none, values: false)`. Add `filter: none` and + `sort: auto`, keeping `index:`/`values:` last. Assert both — the `filter` assert exactly as + in step 2 with `#ideas'` as the subject, and the `sort` assert modelled on `#window`'s + (`rg -n 'sort. must be auto' core/0.1.0/src/window.typ`, one hit, line 137 as of filing), + naming the same three legal values. + +5. **`ideas()` — plumb the filter.** In the same function: + + ``` + rg -n 'let keep = _tag-pred\(tags, match\)' /home/lox/code/_fcl/rookery/core/0.1.0/src + ``` + + One hit as of filing, `data.typ` line 287. Change it to + `_tag-pred(tags, match, filter: filter)`. The `.filter(..)` call below it already handles a + `none` predicate (`keep == none or keep(..)`), so nothing else on that chain changes. + +6. **`ideas()` — plumb the sort.** Directly below, the chain opens + `.sorted(key: ((id, _)) => id)` (line 289 as of filing), which is the id order this function + has always published. Reuse the existing helper rather than writing a second ordering: + + ``` + rg -n 'let _sort-ids' /home/lox/code/_fcl/rookery/core/0.1.0/src + ``` + + One hit as of filing, `pure.typ` line 262 — `_sort-ids(ids, reg, sort)`, which returns + id-ascending for anything but `"date"` and date-descending with id-ascending ties for + `"date"`. Filter first, then order the surviving ids through it, then map. `auto` and + `"lexicographic"` both mean the id order this function already published, so only `"date"` + changes anything; say that in the comment, because a reader will otherwise expect `auto` to + mean something separate. + +7. **Unit asserts.** `core/0.1.0/test/units.typ` already exercises `_tag-pred` — find the + block: + + ``` + rg -n '_tag-pred\(\(draft: none\), "any"\)' /home/lox/code/_fcl/rookery/core/0.1.0/test/units.typ + ``` + + One hit as of filing, line 316, inside the `_tag-pred` section. Add asserts beside it for + the combination this bird makes reachable from two more callers: a `filter:` with no + `tags:` returns a predicate rather than `none`; a `filter:` AND a `tags:` is an AND, not an + OR (a tag match that fails the filter is rejected, and vice versa). `_sort-ids` needs no new + assert — it is unchanged. + +8. **Readme — document the two new arguments** where `#window` and `#ideas()` are documented, + in the same register as `#ideas-outline`'s existing `filter:` paragraph + (`rg -n 'ideas-outline\(filter:\). receives the tag DICTIONARY' core/0.1.0/readme.md`, one + hit, line 127 as of filing). State once, for all three, that `filter:` takes the tag + DICTIONARY — `t => "phd" in t` tests keys, and `t.any(..)`/`t.all(..)`/`t.at(0)` are not + available on a dictionary. + +9. **Readme — fix a phantom argument.** One passage documents an argument that does not exist: + + ``` + rg -n 'ideas-outline\(sort: "date"\)' /home/lox/code/_fcl/rookery/core/0.1.0/readme.md + ``` + + One hit as of filing, line 114, in the `minted` → `created` migration note: "the same + date-descending sort behind `#ideas-outline(sort: "date")`". `#ideas-outline` has no `sort` + argument and is not getting one (see the non-goals). The function that does is `#window` — + correct the spelling to `#window(sort: "date")`, leaving the rest of the sentence intact. + +## Non-goals + +- **Do not give `#ideas-outline` a `sort:` argument.** An outline is a TREE whose nesting is + each idea's literal containment depth, built by `_nest-outline` from a flat run in document + order. Reordering that run by date would reparent entries under whatever preceded them. If a + date-ordered list of ideas is wanted, that is `ideas(sort: "date")`, which this bird adds. +- **Do not add `scope:` to `#window` or `ideas()`.** Both read the whole registry and have no + page notion to narrow to. +- **Do not unify `limit:`.** `#window`'s `limit:` truncates ONE idea's body to a number of + blocks; `todos-list`'s truncates a number of ROWS. Same word, different units, deliberately + untouched here. +- **Do not change `_tag-pred` or `_sort-ids`.** Both already do what these callers need; this + bird is wiring, not new machinery. +- **Do not change any existing default.** `#window` and `ideas()` with no new argument must + render exactly what they render today — that is what VERIFY's demo checks are for. + +## VERIFY + +1. `cd core/0.1.0 && just test` — prints `units OK`, including the new `_tag-pred` asserts. +2. `cd core/0.1.0/demo/pure && just build` — succeeds, output unchanged. +3. `cd core/0.1.0/demo/rheo && just check` — succeeds. (Needs the `rheo` binary, present at + `~/.cargo/bin/rheo` when this bird was filed; if it is genuinely missing, say so in the + report rather than skipping quietly.) +4. A filter-only window selects. From `core/0.1.0`: + + ``` + mkdir -p demo/pure/build + printf '#import "/src/lib.typ": *\n#show: rookery\n#idea(, tags: "keep")[AAA]\n#idea(, tags: "drop")[BBB]\n#window(filter: t => "keep" in t)\n' > demo/pure/build/filter-only.typ + typst compile --features html --format html --root . demo/pure/build/filter-only.typ demo/pure/build/filter-only.html + grep -c AAA demo/pure/build/filter-only.html + grep -c BBB demo/pure/build/filter-only.html + ``` + + The first grep must print a non-zero count and the second must print `0`. Then remove both + scratch files. +5. `rg -n 'ideas-outline\(sort:' core/0.1.0/readme.md` returns nothing. +6. `bd status ` reports `retired` after the flight lands. \ No newline at end of file diff --git a/.birds/ideas/rk-add-only-to-bibtex-for-a-subset-e4dfea67.md b/.birds/ideas/rk-add-only-to-bibtex-for-a-subset-e4dfea67.md new file mode 100644 index 00000000..2a050400 --- /dev/null +++ b/.birds/ideas/rk-add-only-to-bibtex-for-a-subset-e4dfea67.md @@ -0,0 +1,162 @@ +--- +id: rk-add-only-to-bibtex-for-a-subset-e4dfea67 +short-id: e4 +title: 'Add only: to bibtex for a subset' +priority: 3 +labels: +- feat-only-filter +deps: +- blocked-by:rk-parse-each-bib-entry-from-its-own-chunk-0e4227b5 +closed: true +--- +`bibtex(..)` parses every entry in the file it is handed, and `all()` then +mints a note for every one of them. A consumer whose `.bib` is a whole +reference manager library gets a note — and, on a rheo site, a PAGE — per +entry, when it wanted notes for the handful of works it has actually written +about. + +A real case, `waterline`: 1416 entries in `references.bib`, four of them +referred to anywhere on the site. That site now filters the `.bib` SOURCE +TEXT by hand before calling `bibtex(..)`, reimplementing an entry splitter in +its own `_lib/template.typ` to do it. The package should offer the parameter +instead. + +## Decisions already made — do not re-derive + +**`only:` filters, it does not merely hide.** The kept keys are the only ones +parsed, so the cost of a large bibliography scales with what is used rather +than with the file. This is the whole point of the parameter; a filter +applied after parsing would save nothing. + +**A key in `only:` that the file does not carry is silently dropped, not an +error.** `entry(key)` already asserts `no `` in the bibliography` at the +moment something actually asks for that entry, which is a better error than +one raised at factory-construction time about a key nobody ended up wanting. +Say so in the readme. + +**`only: auto` (the default) means the whole file**, matching today's +behaviour exactly. Do not use `none` for that — `none` reads as "no entries" +and the difference matters when a caller computes the list. + +**Do not make the factory lazy.** Parsing on demand per key would mean the +returned `bib` could no longer be a plain dictionary, which every consumer +reads directly, and the sibling bird's chunked parser already brings a +1416-entry library down to well under a second. `only:` is the cheap half of +the win and breaks nothing. + +## Depends on the chunked parser + +This bird BLOCKS ON the bird that rewrites +`/home/lox/code/_fcl/rookery/bibtex/0.1.0/src/parse.typ` to split a `.bib` +into per-entry chunks. What that bird adds, and what this one uses, is one +new public function in `parse.typ`: + +```typst +// `key -> that entry's own source text` +#let bib-chunks(src) = { .. } +``` + +`src/lib.typ` line 31 already does `#import "parse.typ": *`, so `bib-chunks` +is in scope with no new import. If it is missing when you start, the +blocking bird has not landed and this one cannot be implemented — stop and +say so rather than writing a second splitter here. + +## Steps + +1. `/home/lox/code/_fcl/rookery/bibtex/0.1.0/src/lib.typ`, the `bibtex` + signature at lines 58-64: add `only: auto` as the last named parameter, + after `show-fields: (:)`. + +2. Same file, lines 74-75, currently: + + ```typst + let src = if type(src) == array { src.join("\n") } else { src } + let bib = parse-bib(src) + ``` + + Keep the join, then parse either everything or the selected chunks: + + ```typst + let src = if type(src) == array { src.join("\n") } else { src } + let bib = if only == auto { parse-bib(src) } else { + let chunks = bib-chunks(src) + let kept = only.filter(k => k in chunks).map(k => chunks.at(k)) + if kept.len() == 0 { (:) } else { parse-bib(kept.join("\n")) } + } + ``` + +3. Same file, add an `assert` beside the existing `keywords` one at lines + 65-69, so a wrong shape fails where it is written: + + ```typst + assert( + only == auto or type(only) == array, + message: "@rookery/bibtex: `only` must be auto or an array of keys — got " + repr(only), + ) + ``` + +4. Document it in the header comment of the same file (the parameter list at + lines 4-16 names each of `bib`, `entry`, `fields`, `citation`, `all`) and + in `readme.md`: the signature line at line 34 + (`bibtex(src, tagged-idea:, tag:, keywords:, show-fields:)`), the + paragraph under it, and one short paragraph in the `all()` section + (readme lines 62-80) saying that `all()` sweeps the entries the factory + knows, so `only:` is how a large library mints a small number of notes. + Include the reason a missing key is not an error. + +5. `test/units.typ`, in the `parse-bib` section that ends at line 31, add + assertions using the `TWO` fixture already defined at line 9: + + ```typst + #assert.eq(bibtex(TWO, only: ("smith2020",)).bib.keys(), ("smith2020",)) + #assert.eq(bibtex(TWO, only: ()).bib.len(), 0) + #assert.eq(bibtex(TWO, only: ("nosuchkey",)).bib.len(), 0) + #assert.eq(bibtex(TWO).bib.keys().sorted(), ("badiou2002", "smith2020")) + ``` + + `bibtex` is already in scope there — `test/units.typ` line 6 imports + `/src/lib.typ` with `*`. + +## Do NOT + +- Do NOT touch `src/parse.typ`. The blocking bird owns that file, and both + editing it is how the two flights conflict on landing. +- Do NOT add a second way to say the same thing (no `except:`, no `keys:` + alias, no glob or regex matching in `only:`). +- Do NOT change `all()`'s once-per-document guard in `src/claim.typ`, or the + `citation`/`entry`/`fields` closures. +- Do NOT change the default behaviour: `bibtex(src)` with no `only:` must + parse and sweep the whole file exactly as it does now. + +## VERIFY + +1. `cd /home/lox/code/_fcl/rookery/bibtex/0.1.0 && just test` is green, + including the four new assertions. + +2. A subset factory mints only its subset. In a scratch directory: + + ```bash + cd /tmp && cat > onlysweep.typ <<'EOF' + #import "/home/lox/code/_fcl/rookery/bibtex/0.1.0/src/lib.typ": bibtex + #import "@rookery/core:0.1.0": ideas, rookery + #show: rookery + #let refs = bibtex( + "@book{a, title = {A},}\n@book{b, title = {B},}\n@book{c, title = {C},}\n", + only: ("a", "c"), + ) + #(refs.all)() + #context [minted: #ideas(values: true).map(n => n.name).sorted().join(",")] + EOF + typst compile --features html --format html --root / onlysweep.typ onlysweep.html + grep -o "minted: [a-z,]*" onlysweep.html + ``` + + It must print `minted: a,c`. Dropping the `only:` line and recompiling + must print `minted: a,b,c`. + + Honest about this one: the `ideas(values: true)` read-back above is + written from the idiom in this package's own `test/sweep.typ` (which + mints under `#show: rookery` and greps the rendered HTML) but has not + been run as written. If the field names differ, mirror `test/sweep.typ` + lines 10-40 exactly rather than inventing a third shape — the assertion + that matters is which keys `all()` minted. \ No newline at end of file diff --git a/.birds/ideas/rk-add-outline-target-idea-front-door-52bfc795.md b/.birds/ideas/rk-add-outline-target-idea-front-door-52bfc795.md new file mode 100644 index 00000000..e6dc1ec2 --- /dev/null +++ b/.birds/ideas/rk-add-outline-target-idea-front-door-52bfc795.md @@ -0,0 +1,157 @@ +--- +id: rk-add-outline-target-idea-front-door-52bfc795 +short-id: '52' +title: 'Add outline(target: idea) front door' +priority: 3 +labels: +- feat-outline-target-idea +deps: +- blocked-by:rk-replace-rookery-wide-with-scope-on-64195060 +closed: true +--- +Touches: core/0.1.0/src/outline.typ, core/0.1.0/readme.md, core/0.1.0/demo/rheo/content/relations.typ + +`@rookery/core` exports `#ideas-outline(..)` for a table of contents over ideas and leaves +Typst's `#outline(..)` alone. Typst's own idiom for an outline over something that is not a +heading is a `target:` argument — `#outline(target: figure.where(kind: image))` — so the rookery +spelling should be `#outline(target: idea)`, with every other target handed straight to Typst. + +This bird adds that front door. `#ideas-outline` STAYS, exported and unchanged, as the +implementation and as the name that needs no argument. + +**Depends on the bird that replaces `rookery-wide:` with `scope:` on `#ideas-outline`.** That +one lands first; this one forwards whatever argument list `#ideas-outline` has when it flies. If +you find `rookery-wide:` still in `#ideas-outline`'s signature, the dependency has not landed — +report that rather than working around it. + +Two facts make the dispatch straightforward, both already true in this package: + +- **Comparing a function value by identity works and is already done here.** `#ideate` accepts + `separator: par` and tests it with `separator == par or separator == parbreak` + (`core/0.1.0/src/ideate.typ` line 361 as of filing). `target == idea` is the same test. +- **`std.` is reachable and already used.** `core/0.1.0/src/template.typ` line 674 calls + `std.footnote(..)`, `core/0.1.0/src/base.typ` line 24 calls `std.target()`. + +## Steps + +1. **Find where the new function goes.** `#ideas-outline`'s signature: + + ``` + rg -n 'let ideas-outline' /home/lox/code/_fcl/rookery/core/0.1.0/src + ``` + + One hit as of filing, `outline.typ` line 449. Read it through to its closing brace. The new + `outline` goes immediately after it, at the end of the file: a Typst `#let` closure captures + the scope visible at definition time, and this one calls `ideas-outline`. + +2. **`idea` is already in scope in this file.** `outline.typ` opens with a run of imports + including `#import "idea.typ": *` (line 14 as of filing). Add no import. If that line has + gone, report the miss rather than adding an import of your own — the import order in this + package is load-bearing and documented in `src/lib.typ`. + +3. **Write the function**, with its own header comment in the file's style. Say what it is and + why the dispatch is on a bare function value rather than a selector; do not narrate the + change or name a bird, per the comment rules in the repo's `CLAUDE.md`. + + ```typ + #let outline(target: heading, ..args) = { + assert( + args.pos().len() == 0, + message: "@rookery/core: #outline takes no positional arguments — got " + + str(args.pos().len()) + ".", + ) + if target == idea { return ideas-outline(..args) } + let idea-only = args.named().keys().filter(k => k in ("tags", "match", "filter", "scope")) + assert( + idea-only.len() == 0, + message: "@rookery/core: #outline got " + repr(idea-only) + ", which only an outline " + + "over ideas takes — write #outline(target: idea, ..) for that, or drop the " + + "argument for Typst's own outline over headings.", + ) + std.outline(target: target, ..args) + } + ``` + + Two things in there are load-bearing and belong in the comment. **`std.outline`, never a bare + `outline(..)`**: from this `#let` onwards the bare name means THIS function, so an unqualified + call recurses forever. And **the `idea-only` check exists because the arguments are silently + wrong otherwise** — `#outline(tags: "draft")` with no target would forward `tags:` to + `std.outline`, which fails with Typst's own message naming `outline`, confusing because the + author did write `outline`. `scope` is in that list for the same reason as the other three: it + means nothing to a heading outline. + +4. **Update the comment that says Typst's outline cannot do this.** Find it: + + ``` + rg -n "Typst.s own .#outline" /home/lox/code/_fcl/rookery/core/0.1.0/src/outline.typ + ``` + + Hits at lines 172, 176, 391 and 397 as of filing; the one to edit is the header block above + `#ideas-outline` starting at line 172. Its explanation stays correct and stays where it is — + an idea is a Typst `heading` only on the paged target, so a heading-targeted outline would see + every idea on PDF and none on HTML. Add one sentence saying `#outline(target: idea)` is the + front door onto this function and that every other target proxies to Typst's own. Leave the + comments at 391 and 397 alone; they are about the outline's own title, not about targets. + +5. **Document it in the readme.** Find the `#ideas-outline` section: + + ``` + rg -n 'ideas-outline\(\). lists the current page' /home/lox/code/_fcl/rookery/core/0.1.0/readme.md + ``` + + One hit as of filing, line 1188. Add a short passage giving both spellings as equivalent and + stating the rule for the proxy: with any target but `idea`, `#outline` IS Typst's — arguments, + defaults and behaviour. Two example lines are enough: + + ```typst + #outline(target: idea, tags: "draft") // the ideas outline + #outline() // Typst's own, over headings + ``` + + Say plainly that importing `@rookery/core` shadows `outline` in that file's scope, since a + reader who did not expect a shadow needs to be told where it came from. + +6. **Cover both branches in the rheo demo.** The page that already exercises outlines: + + ``` + rg -n 'ideas-outline\(title: \[Tagged phd\]' /home/lox/code/_fcl/rookery + ``` + + One hit as of filing, `core/0.1.0/demo/rheo/content/relations.typ` line 44. Below it add two + calls — `#outline(target: idea, tags: "phd")` and a bare `#outline()` — so both branches + compile in a real build. Leave the `#ideas-outline` calls above them exactly as they are: + they are what proves the older spelling still works. + +## Non-goals + +- **Do not remove, rename or deprecate `#ideas-outline`.** It stays the implementation and a + supported public name. No forwarding shim, no warning, no "prefer this" note in its comment. +- **Do not make the ideas branch accept a selector.** `target: idea` is the bare function value. + A `heading.where(..)`-shaped selector over ideas is not something this package offers. +- **Do not change `#ideas-outline`'s own arguments** — not their names, not their defaults, not + their asserts. This bird only routes. +- **Do not touch `#footnote`** or the `show FNK:` rule in `template.typ`. Core's other shadow is + unrelated. +- **Do not add `#outline` to `.marrow.typ`'s import list** (`core/0.1.0/.marrow.typ`, one 33-name + import). It calls no outline. + +## VERIFY + +1. `cd core/0.1.0 && just test` — prints `units OK`. +2. `cd core/0.1.0/demo/pure && just build` — succeeds. It star-imports core, so this is the check + that a new public name named `outline` breaks no existing scope. +3. `cd core/0.1.0/demo/rheo && just check` — succeeds, exercising both new calls. (Needs the + `rheo` binary, present at `~/.cargo/bin/rheo` when this bird was filed; if it is genuinely + missing, say so in the report rather than skipping quietly.) +4. The heading branch really is Typst's. From `core/0.1.0`: + + ``` + mkdir -p demo/pure/build + printf '#import "/src/lib.typ": *\n#show: rookery\n= A heading\n#outline()\n' > demo/pure/build/outline-proxy.typ + typst compile --features html --root . --format pdf demo/pure/build/outline-proxy.typ /dev/null + ``` + + Must exit zero. Then `rm demo/pure/build/outline-proxy.typ`. +5. The rejection fires: the same three lines with `#outline(tags: "x")` as the body must exit + non-zero with a message naming `target: idea`. Delete the scratch file afterwards. +6. `bd status ` reports `retired` after the flight lands. \ No newline at end of file diff --git a/.birds/ideas/rk-add-the-meetings-package-4a1ea2fb.md b/.birds/ideas/rk-add-the-meetings-package-4a1ea2fb.md new file mode 100644 index 00000000..f55f6453 --- /dev/null +++ b/.birds/ideas/rk-add-the-meetings-package-4a1ea2fb.md @@ -0,0 +1,718 @@ +--- +id: rk-add-the-meetings-package-4a1ea2fb +short-id: 4a +title: Add the meetings package +priority: 3 +labels: +- feat-meetings-package +deps: [] +closed: true +--- +Create a new pure-Typst package `@rookery/meetings:0.1.0` at +`/home/lox/code/_fcl/rookery/meetings/0.1.0/`, exporting `#meeting` — a wrapper +around `@rookery/core`'s `#idea` that takes `with:` (who was in the room) and +`on:` (when the meeting happened). + +**Where this surface comes from.** It already exists, hand-rolled, in a consuming +project: `/home/lox/code/waterline/rookery/_lib/template.typ` carries +`MEETING-KEY` (line 97), `MEETING-WITH-KEY` (line 114), `_meeting-with-block` +(lines 480-489) and the `meetings(..)` factory (lines 491-571). Meetings are the +third note family that project invented and the only one with no lifecycle, and +none of it is project-specific — so it moves into the family, where the readers +(`meeting-with-of`, `occurred-of`), the stylesheet and the tests can live with it. +`on:` is the one thing that is NEW: waterline's version has no date argument at +all and leans on rookery's `created:`. + +You do not need to read waterline to do this bird. Every line of the package is +below, and it has been compiled and asserted already (see MEASURED). + +## Decisions already made — do NOT re-derive these + +1. **`on:` writes BOTH a log entry and `created:`.** It inserts an `occurred` + entry into @rookery/timeline's log (via that package's `entries(..)` tag + fragment) AND passes the same datetime to rookery core's `created:`. The log + entry is what makes the date a timeline EVENT; the row field is what makes it a + date that is free to filter and sort by — rookery keeps `created` on every + `ideas()` row, where a tag value (the log included) costs a `tag-data()` walk to + reach. Giving both `on:` and `created:` is an ERROR (two answers to one + question), asserted with a message naming the fix. +2. **The record block and the rail live in the note's BODY, above the prose**, not + in a page template. @rookery/core's transclusion renders a note's body and knows + nothing about a consuming project's page chrome, so a rail drawn by a template + exists on the note's own minted page and nowhere else. This is the same move + `#submission` makes in waterline (`_lib/template.typ` lines 988-1024, "THE RAIL + LIVES IN THE BODY so it survives transclusion"). +3. **The rail is drawn with `(:)` as `#timeline-view`'s first argument**, not the + note's own registry row. That argument is what makes @rookery/timeline prepend + rookery's `created` to the rail — and `on:` has just set `created` to the very + date the `occurred` entry carries, so passing a row would draw the same day + twice. +4. **`today:` is a parameter, on the factory and on each call.** Typst has no wall + clock, `#timeline-view` needs a reference date to tell what has happened from + what is booked, and @rookery/timeline panics rather than guessing one (see its + `src/when.typ` `_today`). A project stamps its build date in as an input and + passes it once to `meetings(..)`; a per-call `today:` overrides it. A document + with its own `#set document(date:)` needs neither. +5. **Both dependencies are imported as ALIASED MODULES** (`as core`, `as tl`), + never star-imported. A Typst module re-exports every top-level binding it holds, + star-imported ones included — so a star import here would make this package a + second source of `idea`, `window` and `rookery`, and a consumer star-importing + several rookery packages resolves those by IMPORT ORDER. An undecorated `window` + arriving from here would silently shadow @rookery/todos' skinned one. This is + the trap waterline's `_lib/rookery.typ` documents at its lines 26-54. +6. **`core.tagged-idea`, NOT @rookery/timeline's decorated one.** This package + folds `scheduled:`/`deadline:`/`timeline:` into `tl.entries(..)` itself, so it + needs no skin — and it must, because it has to hand the SAME tag dictionary to + `#timeline-view` for the rail. Do not add `dated(..)` on top: the fragment would + then be built twice. +7. **Its own CSS classes (`.meeting-fields-head`, `.meeting-fields`) and its own + complete stylesheet.** The block is @rookery/bibtex's citation-fields block by + design — one gutter, one hairline, one label size across the family — but a + project using this package must not have to install THAT one to see a meeting's + header, so the rules are copied and re-keyed rather than borrowed. waterline's + current markup emits `class="citation-fields-head meeting-fields-head"`; the + package drops the `citation-*` half. +8. **The synthesized title.** `Meeting with on ` where both are + given, `Meeting with ` with only `with:`, `Meeting on ` with only + `on:`, and nothing at all with neither (core then falls back to the body's first + sixty characters). An author's own `title:` always wins. The names are `ref`s, + not text — a person's name is typed once, on their own note — and the date is + @rookery/timeline's own `_fmt-day` short form, so the title cannot disagree with + the rail two lines below it. + +## MEASURED, on typst 0.15.1, before this bird was filed + +Every claim below was checked by compiling the exact files in this bird: + +- `tl._fmt-day(datetime(year: 2026, month: 9, day: 10))` is `"10.9.26"`. Underscore + names travel through a star import, so `_fmt-day` — defined in + @rookery/timeline's `src/read.typ` and star-imported into its `src/lib.typ` — is + reachable as `tl._fmt-day` through an aliased module import. +- `occurred` passes @rookery/timeline's stage-name assert (it is not one of that + package's three reserved names, `scheduled`/`deadline`/`closed`). +- the note's flattened `label` on its `ideas()` row comes out as exactly + `"Meeting with Finale Doshi-Velez on 10.9.26"` — the ref in the title resolves + to the target note's own title in the plain-text projection. +- the card's document order is: `.meeting-fields-head`, `.meeting-fields` `
`, + `
    `, then the prose. +- a past-dated meeting's rail is exactly one + `
  1. ` carrying `10.9.26` + and the stage `occurred`; a future-dated one draws `timeline-future`. +- `just test` (the recipe below) is green, and root `just check-versions` accepts + the package (`meetings/0.1.0/` matches `name`/`version`, and every + `@rookery/:` spec it contains resolves to a directory in the repo). + +## Steps + +1. `mkdir -p /home/lox/code/_fcl/rookery/meetings/0.1.0/src /home/lox/code/_fcl/rookery/meetings/0.1.0/test` +2. Create each file below with EXACTLY the content given. They are verified + sources, not sketches — do not reformat, re-comment or "improve" them. +3. `chmod +x meetings/0.1.0/test/check.sh`. +4. Run the VERIFY commands at the foot of this bird. + +### `meetings/0.1.0/typst.toml` + +```toml +[package] +name = "meetings" +version = "0.1.0" +compiler = "0.15.0" +entrypoint = "src/lib.typ" +authors = ["The Free Computing Lab "] +license = "MIT" +description = "A meeting note for @rookery/core — who was in the room, when it happened, and what was said" +repository = "https://github.com/freecomputinglab/rookery" + +[tool.rheo] +min_version = "0.6.2" + +[tool.rheo.html] +css_stylesheet = "src/meetings.css" +``` + +### `meetings/0.1.0/.gitignore` + +```gitignore +/dist/ +/node_modules/ +build/ +``` + +### `meetings/0.1.0/Justfile` + +```makefile +default: + @echo "@rookery/meetings: pure Typst package, entrypoint is src/lib.typ directly — nothing to build" + +# Two fixtures. `test/units.typ` asserts every VALUE `#meeting` derives — the tags, +# the date, the synthesized title — and `test/view.typ` plus `test/check.sh` assert +# the MARKUP and, above all, the order of the three blocks in a meeting's card. +# +# `--root .` so a fixture's `#import "/src/lib.typ"` resolves against THIS package. +# `--features html` for parity with this repo's other Justfiles. +# +# BOTH FIXTURES COMPILE TO HTML, where timeline's units fixture compiles to a +# throwaway PDF. `#meeting`'s record block is `html.elem`, which a paged export +# drops with a warning per element — so a paged units run is a wall of warnings +# about markup this package only claims to draw on the web. The cost is that the +# paged branch of `#timeline-view` is not exercised here; it is exercised in +# @rookery/timeline's own fixture, which owns it. +# +# `mkdir` first: unlike `rheo`, `typst compile` does not create its output +# directory and fails with "No such file or directory" on a fresh checkout, since +# `test/build/` is gitignored and never committed. +test: + mkdir -p test/build + typst compile --features html --root . --format html test/units.typ test/build/units.html + @echo "units OK" + typst compile --features html --root . --format html test/view.typ test/build/view.html + ./test/check.sh +``` + +### `meetings/0.1.0/src/lib.typ` + +```typst +// @rookery/meetings — a meeting: who was in the room, when it happened, what was +// said. +// +// A meeting is the smallest note family in this repo and the only one with no +// lifecycle: it is over the moment it happened. What makes it worth a package is +// the two things a plain `#idea` cannot say — WHO it was with, and WHEN it took +// place — and the fact that both are askable across a whole rookery once they are +// stored rather than written into prose. +// +// #import "@rookery/meetings:0.1.0": meeting +// #meeting(, with: , +// on: datetime(year: 2026, month: 9, day: 10), today: TODAY)[..] +// +// TWO ALIASED IMPORTS, and the aliases are load-bearing rather than tidy. A Typst +// module re-exports every top-level binding it holds, star-imported ones included, +// so `#import "@rookery/core:0.1.0": *` here would make this package a second +// source of `idea`, `window` and `rookery` — and a consumer star-importing several +// rookery packages resolves those names by IMPORT ORDER, so an undecorated `window` +// arriving from here would silently shadow @rookery/todos' skinned one. Aliased, +// this module exports its own five names and nothing else. +#import "@rookery/core:0.1.0" as core +#import "@rookery/timeline:0.1.0" as tl + +// The flat tag every meeting carries, so `tags:meeting` is askable corpus-wide. +#let MEETING-KEY = "meeting" + +// WHO WAS IN THE ROOM, as idea NAMES — `("hagen-blix", "ed-ongweso")`. A REFERENCE +// to other ideas, which is what earns it a key of its own rather than a place in +// the flat tag list. An ARRAY, a meeting being a thing that happens between +// several. +// +// THE NAMES ARE MEANT TO BE PEOPLE and are not required to be. Nothing here +// checks: the key names the RELATION rather than a family, so whatever the target +// note turns out to be — a person, a lab, a reading group — "this meeting was with +// that" is the same fact. +// +// A TAG PER PERSON IS THE OTHER DESIGN AND IS WORSE. A tag key is interpolated +// into an `idea-tag-` class, so every name that ever walked into a room would +// have to stay CSS-safe; and a person is already a note, so a tag would be a +// second, thinner copy of one. A valued key keeps the person's own page as the only +// place they are described, and `tags:meeting-with` still asks "which meetings +// record who was there". +#let MEETING-WITH-KEY = "meeting-with" + +// THE STAGE `on:` WRITES into @rookery/timeline's log. Not one of that package's +// three reserved names (`scheduled`, `deadline`, `closed`) — those are plans and a +// closing, and this is the event itself. +#let OCCURRED-STAGE = "occurred" + +// Readers, for a consumer building a view over meetings. Both take the tag +// DICTIONARY — `tag-data()`'s per-note value, or an `ideas(values: true)` row's +// `tags-dict` — so neither needs a registry read of its own. +#let meeting-with-of(tags) = tags.at(MEETING-WITH-KEY, default: ()) +#let occurred-of(tags) = tl.stage-date(tags, OCCURRED-STAGE) + +// A page tag about to be interpolated into an `idea-tag-` class. Rejected +// here rather than in a stylesheet, where the only symptom is a rule that silently +// never matches. +#let _css-safe(name) = assert( + type(name) == str and name.match(regex("^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$")) != none, + message: "@rookery/meetings: the page tag " + + repr(name) + + " is not usable as a CSS class fragment. Use alphanumerics and interior hyphens only.", +) + +// `with:` takes either spelling a rookery reference does — a LABEL +// (``, the form to prefer, since it reads as the reference it is) or a +// bare string — and one name needs no array ceremony. `core._norm` flattens all of +// them, a full `idea:x` included, to the one name the tag stores. +#let _who(with) = { + if with == none { return () } + let given = if type(with) == array { with } else { (with,) } + given.map(core._norm) +} + +// EACH NAME AS ITS OWN `ref`, which does two things no written link can. It takes +// the RESOLVED TITLE of the note it points at (rookery's `show ref: hyperlink` +// rule), so a person's name is typed once, on their own note; and it EARNS THEM A +// BACKLINK, so their page lists every meeting they were in. +#let _refs(who) = who.map(n => ref(label("idea:" + n))).join(", ") + +// THE HEADER A MEETING OPENS WITH: a labelled row, not a sentence. "With Hagen +// Blix" as a paragraph reads as the note's first thought; a labelled row reads as +// the note's record, which is what it is — and what a body full of what was +// actually said should not have to open by restating. +// +// A DIV, NOT A HEADING, for the label: it names the block under it and a meeting's +// body carries real headings, so a heading here would claim a place in the page's +// outline above them. +// +// HTML ONLY, in the sense that `html.elem` contributes nothing at all on a paged +// target — element and children alike. A meeting's prose still renders there; its +// record does not. That is the same trade every view in this family makes. +#let _fields(who) = { + html.elem("div", attrs: (class: "meeting-fields-head"), "Meeting") + html.elem("dl", attrs: (class: "meeting-fields"), { + html.elem("dt", "With") + // COMMA-JOINED, not one per line: a person's name carries no commas of its + // own, so a row of them reads as a list without needing a column. + html.elem("dd", _refs(who)) + }) +} + +// `#meetings(..)` -> a `#meeting` factory carrying a page's own tags: +// +// #let meeting = meetings("digital-theory-lab", today: TODAY) +// #meeting(, with: , on: d)[..] +// +// PLURAL IS THE FACTORY, singular the note. Each positional argument takes any +// shape a rookery `tags:` does (a string, an array, a dictionary), and VARIADIC so +// `meetings()` is a legal call — a page collecting meetings under no subject of its +// own wants exactly that, and a required parameter would force it to write +// `meetings(none)`. +// +// `today:` IS HERE BECAUSE TYPST HAS NO CLOCK. The rail below is drawn by +// @rookery/timeline's `#timeline-view`, which needs a reference date to tell what +// has happened from what is booked; that package refuses to guess one and panics +// with a message naming the fix. A project stamps its build date in as an input and +// passes it once, here — a per-call `today:` overrides it for one meeting. A +// document that sets its own `#set document(date:)` needs neither. +#let meetings(..names, today: none) = { + assert( + names.named().len() == 0, + message: "@rookery/meetings: #meetings takes the page's tags POSITIONALLY — " + + "`meetings(TAG_NAME, today: ..)`. Got the named argument(s) " + + names.named().keys().join(", ") + + ", which would be silently dropped.", + ) + let own = names.pos().fold((:), (acc, t) => acc + core._norm-tags(t)) + for (k, _) in own.pairs() { _css-safe(k) } + // Captured under its own name so the closure's `today:` parameter can default to + // the factory's without shadowing the value it falls back to. + let factory-today = today + let mint = core.tagged-idea(MEETING-KEY) + ( + tags: none, + tag: none, + with: none, + on: none, + today: factory-today, + created: none, + scheduled: none, + deadline: none, + timeline: none, + ..args, + ) => { + let who = _who(with) + assert( + on == none or type(on) == datetime, + message: "@rookery/meetings: `on:` is when the meeting happened and must be a " + + "datetime — got " + repr(on) + ".", + ) + // ONE DATE, ONE SPELLING. `on:` sets rookery's own `created:` (see below), so + // giving both is two answers to when this meeting was — and picking one + // silently would put a date nobody wrote on the record. + assert( + not (on != none and created != none), + message: "@rookery/meetings: `on:` and `created:` are the same date for a " + + "meeting — `on:` sets `created:` itself. Give one of them.", + ) + let log = if timeline == none { (:) } else { timeline } + if on != none { + assert( + OCCURRED-STAGE not in log, + message: "@rookery/meetings: `on:` writes the `" + OCCURRED-STAGE + + "` log entry, and `timeline:` already names it. Give one of them.", + ) + log.insert(OCCURRED-STAGE, on) + } + // `#idea`'s OWN POSITIONAL CONTRACT, restated here because the header has to + // land BEFORE the body and so cannot ride through `..args` blind: one + // positional is the body, two are the name and the body. + let pos = args.pos() + assert( + pos.len() == 1 or pos.len() == 2, + message: "@rookery/meetings: #meeting takes a body, optionally preceded by a " + + "name — `#meeting()[..]` or `#meeting[..]` — got " + + str(pos.len()) + + " positional argument(s).", + ) + let name = if pos.len() == 2 { pos.at(0) } else { none } + let body = pos.last() + // Caller's tags first, the page's own on the right — the order that lets a + // meeting say something the page did not. The two derived keys land on top of + // both: neither is the caller's free tags nor the page's subject, and nothing + // else can be writing them. + let all-tags = core._norm-tags(tags) + core._norm-tags(tag) + own + if who.len() > 0 { all-tags.insert(MEETING-WITH-KEY, who) } + all-tags += tl.entries(scheduled: scheduled, deadline: deadline, timeline: log) + // THE NAME AN UNTITLED MEETING GETS, and `with:`/`on:` are the whole reason it + // can have one: "Meeting with Finale Doshi-Velez on 10.9.26" is what the note + // IS, and it is the one thing this factory knows that `#idea`'s own fallback + // cannot reach — without it a titleless meeting is called by the first sixty + // characters of its body wherever it is NAMED rather than rendered, which for a + // meeting is the first thing that happened to come up in it. A meeting that + // titles itself keeps its own title; nothing here overrides an author. + // + // REFS, not the names as text, for `_fields`' first reason: a person's name is + // typed once, on their own note, and a title built from it by hand would drift + // the moment that note is renamed. The plain-text projection follows the + // reference — @rookery/core's `_ref-text` resolves one to the target's own name + // — so the search index reads "Meeting with Finale Doshi-Velez on 10.9.26". + // + // THE DATE IS @rookery/timeline'S OWN SHORT FORM, `tl._fmt-day`, rather than a + // format spelled out here: the rail under the header writes its dates that way, + // and a title disagreeing with the rail two lines below it would be this + // package holding two answers to how it writes a date. + let stamp = if on == none { none } else { tl._fmt-day(on) } + let derived = if "title" in args.named() { + (:) + } else if who.len() > 0 and stamp != none { + (title: [Meeting with #_refs(who) on #stamp]) + } else if who.len() > 0 { + (title: [Meeting with #_refs(who)]) + } else if stamp != none { + (title: [Meeting on #stamp]) + } else { + (:) + } + // THE RECORD OPENS THE NOTE, above the prose: who was there, then when. It + // lives in the BODY rather than in a page template for the reason + // @rookery/core's transclusion forces — a `#window` renders the body and knows + // nothing about the consuming project's page chrome, so a rail drawn by a + // template exists on the note's own page and nowhere else. + // + // `(:)` AS THE ENTRY, not the note's own row, and that is what keeps the rail + // one line: `tl.timeline` prepends rookery's `created` to a note's log, and + // `on:` has just set `created` to the very date the `occurred` entry carries — + // so passing the row would draw the same day twice. + let full = { + if who.len() > 0 { _fields(who) } + tl.timeline-view((:), all-tags, today: today) + body + } + // `on:` SETS `created:`, which is what makes a meeting's date free to filter + // and sort by: rookery keeps `created` a ROW field on every `ideas()` row, + // where a tag value — the log included — costs a `tag-data()` walk to reach. + // The log entry is what makes the date a TIMELINE event; the row field is what + // makes it a date. One value, two channels, and no way for them to disagree. + let resolved-created = if created != none { created } else { on } + // TWO BRANCHES because a name is POSITIONAL and Typst has no way to pass "no + // positional argument here": an unnamed meeting must be called with the body + // alone, not with `none` in front of it, which `#idea` would read as the name. + if name == none { + mint(tags: all-tags, created: resolved-created, ..derived, ..args.named(), full) + } else { + mint(name, tags: all-tags, created: resolved-created, ..derived, ..args.named(), full) + } + } +} + +// The bare form, for a project with no page tags to fold in. `today:` per call. +#let meeting = meetings() +``` + +### `meetings/0.1.0/src/meetings.css` + +```css +/* @rookery/meetings — the record a meeting opens with: who was in the room, and + the rail of when it happened under it. + + Thin on purpose, like every stylesheet in this family: enough that the block + reads correctly out of the box, and nothing that presumes a page design. + + THE LAYER, and it is not optional. rheo links a PACKAGE's stylesheet AFTER the + project's own, so on equal specificity this file would win every tie and a + project could not fix it by writing its rule "later" — there is no later. + Wrapping everything in a cascade layer inverts that: any UNLAYERED rule in the + project's CSS beats any layered rule here, whatever its specificity or position. + + THE PROPERTIES. Every colour and size is `var(--x, )`, the default being + the literal in the var() call. Set one on `.meeting-fields` (or anywhere it + inherits from) and the block is themed without overriding a rule at all: + + --meeting-fg a field's value + --meeting-muted the label above the block, and a field's name + --meeting-line the rules between fields + --meeting-gap space between a field's text and the rule under it + --meeting-gutter width of the name column + + THE GUTTER MATCHES @rookery/timeline'S RAIL, 7.5em, and the match is the point: + a meeting draws this block and that rail one after the other, and two adjacent + tables whose columns start in different places read as two conventions rather + than one note. The default reads `--timeline-gutter` first, so setting that + single property lines both up. + + THE SAME MARKUP @rookery/bibtex GIVES A CITATION, deliberately — one gutter, one + hairline, one label size across the family — but its OWN classes and its own + copy of the rules, because a project using this package must not have to install + that one to see a meeting's header. What differs is the spacing: bibtex's block + is a FOOTER and its gaps are measured for one, which is exactly wrong at the top + of a note. Here the space goes below the block rather than above it. */ +@layer meetings { + .meeting-fields-head { + margin: 0.25em 0 0; + color: var(--meeting-muted, gray); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* TWO COLUMNS: the field's name in the gutter, its value to the right. A grid on + the `
    ` itself, with each `
    `/`
    ` auto-placed as its own item — so a + value that wraps to three lines pushes the next row down instead of drifting + out of column. A grid rather than a flex line, for the reason measured across + this family: a flex item's basis is only a HYPOTHETICAL size, so a long field + name would push its value and no two rows would agree where it starts. */ + .meeting-fields { + display: grid; + grid-template-columns: var(--meeting-gutter, var(--timeline-gutter, 7.5em)) 1fr; + column-gap: 0.9rem; + margin: 0.6rem 0 1.2rem; + border-top: 1px solid var(--meeting-line, var(--timeline-line, currentColor)); + } + + /* THE RULES BETWEEN FIELDS, one per row, drawn on BOTH cells so the two segments + abut into a single line across the block. Horizontal only: the field names are + a label column, not a second column of data. */ + .meeting-fields dt, + .meeting-fields dd { + padding: var(--meeting-gap, 0.4rem) 0; + border-bottom: 1px solid var(--meeting-line, var(--timeline-line, currentColor)); + } + + .meeting-fields dt { + color: var(--meeting-muted, gray); + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.85em; + } + + /* `margin: 0` is load-bearing rather than tidy: a browser's default `
    ` + carries `margin-inline-start: 40px`, which in a grid cell indents every value + away from its own column. */ + .meeting-fields dd { + margin: 0; + color: var(--meeting-fg, inherit); + } + + /* A RAIL FOLLOWING THE RECORD carries the block's bottom space instead, so the + table and the rail read as one header rather than two tables with a gap + between them. `.timeline` sets `margin: 0.6rem 0 0` itself, which is the space + ABOVE it; what it has no opinion about is the prose underneath. */ + .meeting-fields:has(+ .timeline) { + margin-bottom: 0; + } + + .meeting-fields + .timeline { + margin-bottom: 1.2rem; + } +} +``` + +### `meetings/0.1.0/test/units.typ` + +```typst +// Unit fixture: every VALUE `#meeting` derives — the tags it stores, the date it +// sets, the title it synthesizes. No runner: an `assert` failing fails the compile +// with a line number, and a passing compile is the green light. The MARKUP is +// `test/view.typ`'s business, which needs an HTML target this one does not. +#import "/src/lib.typ": * +#import "@rookery/core:0.1.0": idea, ideas, rookery, tag-data +#import "@rookery/timeline:0.1.0": timeline-of + +#show: rookery + +#let TODAY = datetime(year: 2026, month: 9, day: 10) +#let ON = datetime(year: 2026, month: 9, day: 10) +#let meeting = meetings("lab", today: TODAY) + +#idea("doshi-velez-finale", title: [Finale Doshi-Velez])[A person.] + +#meeting("dv", with: , on: ON)[What was said.] +#meeting("plain")[Nothing declared.] +#meeting("titled", with: , on: ON, title: [Own title])[Titled.] +#meeting("dated", on: ON)[Nobody named.] + +#context { + let rows = ideas().map(r => (r.name, r)).to-dict() + + // `on:` SETS `created`, the free row field every date-sorted view reads. + assert.eq(rows.dv.created, ON, message: "created is " + repr(rows.dv.created)) + assert.eq(rows.dated.created, ON) + assert.eq(rows.plain.created, none) + + // The synthesized name, as PLAIN TEXT — a ref resolves to its target's own name. + assert.eq( + rows.dv.label, + "Meeting with Finale Doshi-Velez on 10.9.26", + message: "label is " + repr(rows.dv.label), + ) + assert.eq(rows.dated.label, "Meeting on 10.9.26") + // An author's own title wins outright. + assert.eq(rows.titled.label, "Own title") + + let dv = tag-data().at("idea:dv") + assert.eq(occurred-of(dv), ON) + assert.eq(meeting-with-of(dv), ("doshi-velez-finale",)) + assert("meeting" in dv, message: "no meeting tag: " + repr(dv.keys())) + assert("lab" in dv, message: "the factory's page tag is missing") + assert.eq(timeline-of(dv).len(), 1) + assert.eq(timeline-of(dv).first().stage, "occurred") + + // A meeting with neither argument stores neither key and gets no derived title. + let plain = tag-data().at("idea:plain") + assert.eq(timeline-of(plain).len(), 0) + assert.eq(occurred-of(plain), none) + assert.eq(meeting-with-of(plain), ()) +} +``` + +### `meetings/0.1.0/test/view.typ` + +```typst +// Rendered fixture: the MARKUP a meeting opens with, which `test/units.typ` +// cannot see — the record's own `
    `, the rail under it, and the order of the +// three blocks inside one card. Asserted by `test/check.sh` against the built +// HTML, because "the rail is above the prose" is a fact about document order. +#import "/src/lib.typ": * +#import "@rookery/core:0.1.0": idea, rookery + +#show: rookery + +#let TODAY = datetime(year: 2026, month: 9, day: 10) +#let meeting = meetings(today: TODAY) + +#idea("doshi-velez-finale", title: [Finale Doshi-Velez])[A person.] + +// HAPPENED: the rail's one row is past, and it is the current stage. +#meeting("held", with: , on: datetime(year: 2026, month: 9, day: 10))[ + What was said. +] + +// BOOKED: a meeting in the diary, drawn as a future row. +#meeting("booked", with: , on: datetime(year: 2026, month: 9, day: 24))[ + Not yet held. +] + +// NEITHER ARGUMENT: no record block at all, and no rail. +#meeting("bare")[Nothing declared.] +``` + +### `meetings/0.1.0/test/check.sh` + +```bash +#!/usr/bin/env bash +# Asserts on the rendered fixture's OUTPUT, not merely that it compiled. +# `units.typ` covers every value; this covers the markup and, above all, the +# ORDER of the three blocks a meeting's card holds: record, rail, prose. +set -euo pipefail +cd "$(dirname "$0")/.." +H=test/build/view.html +[ -f "$H" ] || { echo "FAIL: no $H — run 'just test' first"; exit 1; } + +python3 - "$H" <<'PY' +import re, sys +h = open(sys.argv[1]).read() +fail = 0 +def note(msg): + global fail + print("FAIL:", msg); fail = 1 + +# One card per note, sliced on the note's own anchor id. +def card(name): + i = h.find('id="idea:%s"' % name) + if i < 0: + note("no card for %s" % name); return "" + j = h.find('', i) + return h[i:j] + +held = card("held") +# 1. THE RECORD IS THERE, with the person as a resolved ref. +if 'class="meeting-fields-head"' not in held or 'class="meeting-fields"' not in held: + note("held: no record block") +if "Finale Doshi-Velez" not in held: + note("held: the with: ref did not resolve to the target's title") + +# 2. THE ORDER IS RECORD, RAIL, PROSE — the whole point of the block living in +# the body rather than in a page template. +o_head = held.find('class="meeting-fields-head"') +o_dl = held.find('class="meeting-fields"') +o_rail = held.find('
      ') +o_body = held.find("What was said") +if not (0 < o_head < o_dl < o_rail < o_body): + note("held: blocks out of order (head %d, dl %d, rail %d, body %d)" + % (o_head, o_dl, o_rail, o_body)) + +# 3. ONE ROW, past and current, carrying the date in timeline's short form and +# the `occurred` stage — and no `created` row doubling the same day. +rows = re.findall(r'
    1. (.*?)
    2. ', held, re.S) +if [c for c, _ in rows] != ["timeline-past timeline-current"]: + note("held: rail rows are %r" % [c for c, _ in rows]) +if rows and ("10.9.26" not in rows[0][1] or "occurred" not in rows[0][1]): + note("held: rail row reads %r" % rows[0][1]) + +# 4. A MEETING STILL AHEAD is drawn as booked, which is what a reference date buys. +booked = card("booked") +if "timeline-future" not in booked: + note("booked: a future meeting is not drawn as a future row") + +# 5. NEITHER ARGUMENT, NEITHER BLOCK: no record, no rail, no empty apparatus. +bare = card("bare") +if "meeting-fields" in bare or '
        ' in bare: + note("bare: a meeting with no with:/on: drew a record block anyway") + +sys.exit(fail) +PY +echo "view OK" +``` + +## Do NOT + +- Do NOT touch any other package in this repo. `core`, `timeline`, `bibtex`, + `search`, `todos` and `slipshow` are all unchanged by this bird. +- Do NOT write the package's readme, and do NOT edit the repo's `CLAUDE.md` — a + separate bird owns both, and it names the files this one creates. +- Do NOT edit `.github/workflows/check.yml`. A separate bird adds the CI step. +- Do NOT add a `package.json`, a `vite.config.js` or a `dist/`. This is a + pure-Typst package like `core` and `timeline`: the manifest's `entrypoint` and + `css_stylesheet` point straight at `src/`, and the root `just build` walks every + nested `Justfile`, so the `default` recipe must stay a no-op echo. +- Do NOT add a rheo demo project. The two fixtures are plain `typst compile`. +- Do NOT symlink anything into `~/.cache/typst/packages/`. The fixtures import + `/src/lib.typ` by path under `--root .`; only `@rookery/core:0.1.0` and + `@rookery/timeline:0.1.0` resolve from the cache, and both are already there. +- Do NOT render the `on:` date as a second `
        `/`
        ` row in the record table. + The rail under the table is where the date goes; a table row saying the same + thing is the design that was rejected. + +## VERIFY + +Run all three, from `/home/lox/code/_fcl/rookery`: + +1. `cd meetings/0.1.0 && just test` — prints `units OK` then `view OK` and exits + 0. `units.typ` asserts the derived values (created, label, tags, the log); + `check.sh` asserts the markup and the order of the three blocks. +2. `cd /home/lox/code/_fcl/rookery && just check-versions` — prints + `check-versions OK across 7 manifests` (6 before this bird). +3. `cd meetings/0.1.0 && just` — prints the buildless echo and exits 0, which is + what the repo-root `just build` runs for this package. + +If step 1 fails inside `#timeline-view` with "this view needs a reference date and +there is none", a fixture lost its `today:` — that is the panic decision 4 above +describes, and the fix is in the fixture, not in the package. \ No newline at end of file diff --git a/.birds/ideas/rk-add-today-panel-a-day-view-over-todos-4803a6b6.md b/.birds/ideas/rk-add-today-panel-a-day-view-over-todos-4803a6b6.md new file mode 100644 index 00000000..84a53d9c --- /dev/null +++ b/.birds/ideas/rk-add-today-panel-a-day-view-over-todos-4803a6b6.md @@ -0,0 +1,155 @@ +--- +id: rk-add-today-panel-a-day-view-over-todos-4803a6b6 +short-id: '480' +title: 'Add #today-panel, a day view over todos' +priority: 3 +labels: +- today-panel +- type:feature +deps: +- blocked-by:rk-rename-filter-panel-to-todo-table-9971e639 +- blocked-by:rookery-priority-tests-hax +closed: true +--- +`@rookery/todos` has one view, `#todo-table`, and it lists EVERYTHING open, ordered by date. That is the right shape for a worklist and the wrong shape for the question a person asks at the start of a day: what is on for today, and what is the most important thing outstanding. Answering it today means reading a list of several hundred rows. + +Add `#today-panel` — the same table, over a much smaller selection: the todos dated for today, plus the todos at the site's topmost priority whatever their date. + +It is a SELECTION and nothing else. The projection, the pills, the date-cell ramp and the row rendering all come from `#todo-table`, which is `@rookery/todos`' existing view — renamed from `#filter-panel` and given a `corpus:` argument by bird rk-rename-filter-panel-to-todo-table-9971e639 ("Rename #filter-panel to #todo-table"). After that bird it lives in `/home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ` and takes `rows:` (the rows to list), `corpus:` (every todo, so the dependency graph can resolve a closed blocker), `filter:`, `today:`, and the panel knobs `facets`, `tag-filter`, `pill-rows`, `when`, `order`, `countdown`, `overdue`, `undated-priority`, `visible`, `placeholder`, `noun`, `empty`, `haystack`, `render`. + +All paths are under `/home/lox/code/_fcl/rookery/todos/0.1.0/`. + +## The facts you need, restated so you need no other file + +**The priority scale.** `priority:` is a non-negative integer with NO upper bound. A BIGGER number is MORE important. An absent priority decodes to `0`, meaning unprioritised — `priority-of` (`src/tags.typ:251`) never returns `none`, and every row from `todos()` (`src/graph.typ:30-48`) carries an integer `priority` field. So "the topmost priority" is the MAXIMUM priority in use, and `0` is never a top priority. + +**The three date predicates already exist**, in `@rookery/timeline`'s `src/when.typ`, and this bird writes no new date logic: + +- `is-upcoming(tags, today: none, within: 7)` (when.typ:69) — has a deadline falling from `today` up to and including `within` days later. `within: 0` asks "due today". An already-overdue deadline is NOT upcoming. +- `is-overdue(tags, today: none)` (when.typ:59) — has a deadline STRICTLY before `today`. A deadline falling on `today` is due, not overdue. +- `is-scheduled-now(tags, today: none)` (when.typ:90) — is scheduled, and that date has arrived, on or before `today`. This is the "may I start" question. It deliberately covers a todo scheduled last Tuesday and never done, which is exactly right for a day view: an old scheduled date does not stop being today's problem. + +All three take the note's tag dictionary, which every `todos()` row carries as `r.tags-dict`. + +**They need a reference date.** Nothing in this stack may call `datetime.today()` — it returns 1980-01-01 under a reproducible build and does not error. These predicates fall back to the document's own date and PANIC when there is neither, so `today:` is effectively required. + +## Steps + +1. Create `src/today.typ` with a file header in this repo's comment style — present tense, describing what the file is, one header and no interior banners, no issue ids (see `/home/lox/code/_fcl/rookery/CLAUDE.md`). What it says: the day view is a SELECTION over `#todo-table`, and the questions it unions — what is dated for today, and what is most important — are different questions that a person starting a day asks together. + +2. Import what it needs: `#import "table.typ": *`, `#import "graph.typ": *` (for `todos`), and `#import "@rookery/timeline:0.1.0": is-overdue, is-scheduled-now, is-upcoming`. + +3. Define the two PURE helpers first, so the selection is testable without rendering anything: + + ``` + #let _top-priority(rows) = { + let ps = rows.map(r => r.priority).filter(p => p > 0) + if ps.len() == 0 { none } else { calc.max(..ps) } + } + + #let _on-today(row, today: none, horizon: 0, overdue: true, top: none, also: none) = { + let t = row.tags-dict + ((also != none and also(row)) + or is-upcoming(t, today: today, within: horizon) + or (overdue and is-overdue(t, today: today)) + or is-scheduled-now(t, today: today) + or (top != none and row.priority >= top)) + } + ``` + + Comment `_top-priority` with the decision behind it: the band is derived from the rows the panel is ABOUT TO LIST, not from the whole rookery, because a closed todo at priority 9 would otherwise set a band that no open row can reach and the priority half of the panel would come out empty. + +4. Define `#today-panel`. Document every parameter in the style `#todo-table` uses (`src/table.typ`, the parameter list running from its `#let` down to its `) = context {`) — a paragraph per argument saying what it does and why the default is the default, not a one-line gloss: + + ``` + #let today-panel( + rows: none, + today: none, + filter: none, + also: none, + horizon: 0, + overdue: true, + priority: auto, + facets: ("epic", "tag", "state", "priority"), + tag-filter: none, + pill-rows: , + when: none, + order: "soonest", + countdown: true, + undated-priority: true, + visible: none, + placeholder: "Filter today", + noun: "todos", + empty: [Nothing for today.], + haystack: none, + render: none, + ) = context { + let all = if rows != none { rows } else { todos() } + let keep = if filter != none { filter } else { r => not r.closed } + let open = all.filter(keep) + let top = if priority == none { none } else if priority == auto { + _top-priority(open) + } else { priority } + todo-table( + rows: open.filter(r => _on-today( + r, today: today, horizon: horizon, overdue: overdue, top: top, also: also, + )), + corpus: all, + filter: r => true, + today: today, + overdue: overdue, + .. + ) + } + ``` + + Two of those arguments need a comment where they are passed, because both look redundant and neither is: + + - `corpus: all` — `#todo-table` builds the dependency graph from what it is given, and a graph built from the day's handful of rows cannot see their blockers, which `is-blocked` reads as "not blocking". Without this every row in the panel would come out `ready`. + - `filter: r => true` — the selection has already happened above. `#todo-table`'s own `filter:` defaults to dropping closed rows, and letting it run again would apply the site's question twice. + + The four arguments that are this function's own, and what their documentation must say: + + - `horizon: 0` — how many days past today still count as "for today", passed to `is-upcoming`'s `within:`. `0` is due today exactly. A site that plans in two-day chunks passes `2`. It does NOT affect the scheduled or priority halves. + - `overdue: true` — whether a deadline already behind you is listed. `true` lists it, and `#todo-table` paints its date cell the overdue band. `false` drops it. On by default: a day view that silently hides what is late is worse than no day view. + - `priority: auto` — which todos join the list on importance alone, regardless of date. `auto` (the default) is the topmost priority actually in use among the listed rows. An integer is a floor: every row at that priority or above. `none` turns the priority half off entirely, leaving a plain "dated for today" list. + - `also: none` — a predicate over a row that ORs INTO the day's selection, where `filter:` narrows it. It is what lets a site put a todo on today's list for a reason this package cannot know: the consuming site `/home/lox/code/waterline` has a hand-set `today` tag (`_lib/template.typ:36`) meaning "this is on for today whatever its dates say", and without `also:` there is no way to honour it — every other argument here can only ever remove rows. `none` is no extra rows. + + `filter:` and `also:` are not the same knob in two directions and the documentation must say so: `filter:` decides which todos EXIST for this panel (open, not answered, whatever the site means by live), and it applies to every row including the ones `also:` brings in. + + `visible: none` rather than `#todo-table`'s `8`: a day view is meant to be read whole, and a scroll box over a list of six rows hides the sixth behind a gesture nothing advertises. + +5. Add `#import "today.typ": *` to `src/lib.typ`, immediately after the `#import "table.typ": *` line, with a short comment in that file's own idiom saying it comes after the module it projects, for the same dependency-order reason the rest of that list is ordered. Do not disturb the existing order — `skin.typ` must stay LAST, because it is what shadows `window`. + +6. Add assertions to `test/units.typ`, beside the other pure-helper fixtures. Test `_top-priority` and `_on-today` — not the panel, which needs a rendering context. You will need to reach the two helpers; `test/units.typ` already imports this package's internals, so follow whatever import form the file uses for `_state-of` or another underscore-prefixed helper. Cases: + + - `_top-priority(())` is `none`. + - `_top-priority` over rows with priorities `(0, 0)` is `none` — unprioritised is not a top priority. + - `_top-priority` over `(0, 3, 7, 3)` is `7`. + - `_on-today` is `true` for a row whose deadline IS today, with `horizon: 0`. + - `_on-today` is `false` for a row whose deadline is tomorrow, with `horizon: 0`, and `true` for the same row with `horizon: 1`. + - `_on-today` is `true` for a row with a deadline a week behind today when `overdue: true`, and `false` for that same row when `overdue: false` and it has nothing else qualifying it. + - `_on-today` is `true` for a row scheduled a month ago and still open. + - `_on-today` is `false` for a row scheduled next month with no deadline and a priority below `top`. + - `_on-today` is `true` for an undated row whose priority equals `top`, and `false` for an undated row one below `top`. + - `_on-today` is `true` for an undated, unprioritised row when `also:` returns `true` for it, and `false` for that same row when `also:` is `none`. + + Build the fixture rows the way the existing fixtures in that file build theirs — a dictionary with `tags-dict` and `priority` is all `_on-today` reads. Dates come from `@rookery/timeline`'s `entries(deadline: ..)` / `entries(scheduled: ..)`, which is how the demo content writes them. + +## Do NOT + +- Do not write any new date arithmetic. The three predicates above are the whole of it, and a second copy of "is this today" is exactly how two surfaces drift apart. +- Do not change `src/table.typ`, `src/views.typ`, `src/graph.typ` or `src/todos.css`. `#today-panel` reuses the table's existing classes (`todo-when-overdue`, the countdown bands, the priority label cell) and needs no new CSS. +- Do not add a heading, a count line, or any chrome of its own. `#todo-table` renders the whole widget. +- Do not use `priority-scale()` from `src/graph.typ` even though it computes a similar list. It walks the WHOLE rookery including closed todos, and this panel's band must come from the rows it is listing — see the comment step 3 asks for. +- Do not touch `readme.md` or the demo. Documenting and demoing `#today-panel` is a separate bird. + +## VERIFY + +From `/home/lox/code/_fcl/rookery/todos/0.1.0`: + +1. `just test` passes, with the new assertions in it. An `assert` that fails fails the compile with a line number, so a clean compile is the green light. +2. `just check` passes. +3. In a scratch `.typ` file compiled against this package, `#today-panel(today: datetime(year: 2026, month: 8, day: 25))` over the demo's own corpus renders a panel containing the overdue `invoice` todo and NOT the `retro` todo, which is scheduled for December. +4. The same call with `priority: none` renders a strictly smaller list than with `priority: auto`, and with `overdue: false` drops the `invoice` row. +5. In that same scratch file, a todo BLOCKED by an open dependency shows the `blocked` state pill in the today panel, not `ready`. That is `corpus:` doing its job — if every row reads `ready`, it is not being passed. diff --git a/.birds/ideas/rk-add-urlstate-js-url-param-state-2afe3277.md b/.birds/ideas/rk-add-urlstate-js-url-param-state-2afe3277.md new file mode 100644 index 00000000..b1800285 --- /dev/null +++ b/.birds/ideas/rk-add-urlstate-js-url-param-state-2afe3277.md @@ -0,0 +1,186 @@ +--- +id: rk-add-urlstate-js-url-param-state-2afe3277 +short-id: 2a +title: Add urlstate.js, URL-param state primitives +priority: 3 +labels: +- type:feature +- feat-url-state +deps: [] +closed: true +--- +Add `src/urlstate.js` to `@rookery/search`: the query-string primitives every +stateful widget in this family will use to survive a reload. This bird adds the +module, publishes it on the package's global, registers it in the manifest, and +unit-tests it. It wires NOTHING to any widget — that is a separate bird. + +## Why this exists + +`rheo watch` reloads a page with a hard `location.reload()` (rheo's own +`crates/html/src/server.rs`, the injected SSE script). Every filter widget in this +package keeps its state in a closure — `wirePanel`'s `facets` Map and `pressed` Set +at `/home/lox/code/_fcl/rookery/search/0.1.0/src/panel.js:146-167` — so a rebuild +empties the filter box and un-presses every pill. Mirroring that state into the +query string is what makes it survive, and it makes a filtered view a copyable link +as a side effect. + +Nothing in this repository reads or writes a query string today. Grepped and +confirmed: zero hits for `URLSearchParams`, `hashchange`, `pushState`, +`localStorage`, `sessionStorage` anywhere. The single precedent for URL state is +`slipshow/0.1.0/src/slipshow.js:212`, which uses `history.replaceState` for a slide +position and carries a comment at `:209-212` on why not `location.hash =`. + +## Decisions already made — do not re-derive + +- **`history.replaceState`, never `pushState`.** Back must keep leaving the page. + Typing in a filter box would otherwise stack one history entry per keystroke. + Same call, same reason, as `slipshow.js:212`. +- **Namespaced repeated params**, e.g. + `?todos.q=rheo&todos.state=ready&todos.state=blocked`. One param per value, + repeated — read with `URLSearchParams.getAll`, written with `delete` then + `append`. NOT comma-joined: `_attr` in + `/home/lox/code/_fcl/rookery/search/0.1.0/src/panel.typ:46-59` permits any scalar + value, and only `_multi-attr` at `:73-96` forbids whitespace, so a scalar facet + value legitimately containing a comma would corrupt a joined param and there is + no escaping rule to fall back on. +- **Pure functions over a query STRING, not over `location`.** The node suite here + runs under linkedom (`test/panelinput.test.mjs` uses `parseHTML`), which provides + no `history`, so anything reading `location`/`history` directly is untestable. + Every function below takes and returns a string; only `commit` touches the + address bar, and it is guarded so a node import is a no-op. +- **Params merge, never clobber.** A page can carry several synced widgets plus a + tab key plus whatever a site put there. Each function rewrites only the params + belonging to the key it was given, and leaves every other param — and their + order — alone. + +## Steps + +1. Create `/home/lox/code/_fcl/rookery/search/0.1.0/src/urlstate.js`. It imports + nothing from this package. Export exactly these seven bindings, with the + contracts stated: + + - `readSync(key, search)` → `{ q, values }`. `search` is a query string with or + without a leading `?`. `q` is the value of the `.q` param, or `""` when + absent. `values` is a `Map>`: one entry per OTHER param + whose name starts with `key + "."`, the field being everything after that + prefix, the Set holding every repeated value for it. A param not starting with + the prefix is ignored entirely. + - `writeSync(key, state, search)` → the new query string, WITHOUT a leading `?`, + `""` when nothing is left. `state` is `{ q, values }` in `readSync`'s shape + (`values` may be any iterable of `[field, iterable-of-values]` pairs, so a + caller can hand over a `Map` directly). Delete every param starting with + `key + "."` first, then append `.q` when `q` is a non-empty string after + trimming, then one `.` param per value. An empty `q` and an empty + value set therefore write nothing, so a widget the reader has cleared leaves + no trace in the URL. Every param outside this key's namespace survives. + - `readParam(key, search)` → the value of the bare `` param, or `null` when + absent. This is the scalar case a radio group needs. + - `writeParam(key, value, search)` → the new query string. `null`, `undefined` or + `""` deletes the param instead of writing it. + - `commit(search)` → writes `search` into the address bar in place, via + `history.replaceState(null, "", pathname + qs + hash)` where `qs` is + `search === "" ? "" : "?" + search`. Preserve the existing `location.hash` — + rheo's link rule mints in-page `#handle` anchors and dropping one would break a + reader's position. Return immediately, doing nothing, when + `typeof location === "undefined" || typeof history === "undefined" || typeof history.replaceState !== "function"`. + That guard is what lets node import this module and lets a linkedom test call + it harmlessly. + - `claimKey(key)` → `true` the first time a key is claimed on this page, + `false` on every repeat, having emitted one + `console.warn` naming the key and saying that the second widget will not sync. + Backed by a module-level `Set`. Typst cannot see across two widget calls to + assert this, so it is checked here. + - `debounce(fn, ms = 200)` → a wrapped function that runs `fn` at most once per + quiet `ms`, using `setTimeout`/`clearTimeout`. Exists so a caller does not + `replaceState` per keystroke. Guard nothing: `setTimeout` exists under node. + + Write the file's header comment in this repo's style — see + `/home/lox/code/_fcl/rookery/CLAUDE.md`, section "Comment style": one header + block per file saying what the file is, present tense, no interior + `// ---- Section ----` banners, no issue ids, comment the non-obvious only. The + two facts worth a comment are the `replaceState`-not-`pushState` rule and why + every function takes a string instead of reading `location`. + +2. In `/home/lox/code/_fcl/rookery/search/0.1.0/src/search.js`: + - add `import { readSync, writeSync, readParam, writeParam, commit, claimKey, debounce } from "./urlstate.js";` + alongside the existing imports at lines 29-45. The comment at `:33-36` + explains why this package imports AND re-exports rather than using + `export ... from` alone — the global at the bottom names the values, so the + import is required. Follow that. + - add `export { readSync, writeSync, readParam, writeParam, commit, claimKey, debounce } from "./urlstate.js";` + to the re-export block at lines 47-51. + - add the same seven names to the `globalThis.RookerySearch ??= { .. }` object + at lines 154-172. This is the surface `@rookery/todos` will feature-detect + later, exactly as `todos/0.1.0/src/todo-search.js:95-96` already + feature-detects `globalThis.RookerySearch` for the `tags:` language. + - do NOT call anything from `init()` (lines 59-140) in this bird. Nothing is + wired yet. + +3. In `/home/lox/code/_fcl/rookery/search/0.1.0/typst.toml`, add `"src/urlstate.js"` + to the `[tool.rheo.source.html]` `js_scripts` array at lines 45-60. Put it + FIRST, before `"src/text.js"`: the list is dependency-ordered and this module + depends on nothing. THIS STEP IS LOAD-BEARING AND EASY TO SKIP — the comment at + lines 41-44 says why: rheo's asset copy acts on exactly this list and not on a + scan of import statements, so a file left out never lands in a consuming + project's output even though the browser's own module graph would have found it, + and nothing warns. + +4. Create `/home/lox/code/_fcl/rookery/search/0.1.0/test/urlstate.test.mjs`, in the + shape the existing suite uses — `import { test } from "node:test";`, + `import assert from "node:assert/strict";`, one `test("...", () => {..})` per + claim. No DOM needed: `URLSearchParams` is a node global. Cover at least: + - `readSync("todos", "todos.q=rheo&todos.state=ready&todos.state=blocked")` + gives `q === "rheo"` and `values.get("state")` equal to + `new Set(["ready", "blocked"])`. + - `readSync` on a string with a leading `?` behaves identically. + - `readSync("todos", "ideas.t=cfp")` gives `q === ""` and an empty `values` — + another widget's params are invisible to this one. + - `writeSync` round-trips: feeding `readSync`'s output back to `writeSync` with + the same key yields a string `readSync` parses to the same state. + - `writeSync("todos", { q: "", values: new Map() }, "todos.q=x&tab=todos")` + returns just `"tab=todos"` — clearing a widget removes its params and keeps + everyone else's. + - a facet value containing a comma and a space survives a + `writeSync`/`readSync` round trip unchanged. This is the case that rules out + comma-joining, so pin it. + - `readParam`/`writeParam` for the scalar case, including that + `writeParam("tab", null, "tab=todos&todos.q=x")` returns `"todos.q=x"`. + - `claimKey("a")` is `true` then `false`, and `claimKey("b")` is `true`. + - `commit("a=1")` does not throw when called with no `history` present. + +## Do NOT + +- Do NOT touch `src/panel.js`, `src/panel.typ`, `src/filter-panel.typ`, or anything + under `todos/`. No widget is wired in this bird. +- Do NOT add a `sync:` parameter to any Typst function here. +- Do NOT use `pushState`, `location.hash =`, `localStorage` or `sessionStorage`. +- Do NOT read `location` or `history` from any function except `commit`. +- Do NOT add a dependency to `package.json`. This module needs none. +- Do NOT touch `search/0.1.0/readme.md` — a later bird documents the whole feature + in one pass, so that 1571-line file has a single writer. +- Do NOT edit `dist/` by hand. It is a gitignored build artifact. + +## VERIFY + +From `/home/lox/code/_fcl/rookery/search/0.1.0`: + +```sh +just test +just parity +just build +``` + +`just test` (`node --test test/*.test.mjs`) must be fully green, including the new +`urlstate.test.mjs` and every pre-existing file. `just parity` must still pass — it +diffs the Typst and JavaScript copies of the ranking rule and this bird touches +neither, so a failure there means something unrelated broke. `just build` +(`pnpm install && pnpm run build`) must succeed, proving vite can still bundle the +entrypoint with the new import in it. + +Then, to confirm the manifest edit actually took: + +```sh +grep -n urlstate typst.toml +``` + +must print a line inside the `[tool.rheo.source.html]` array, above `src/text.js`. \ No newline at end of file diff --git a/.birds/ideas/rk-adds-id-slug-for-title-derived-note-ids-d9206148.md b/.birds/ideas/rk-adds-id-slug-for-title-derived-note-ids-d9206148.md new file mode 100644 index 00000000..759580a8 --- /dev/null +++ b/.birds/ideas/rk-adds-id-slug-for-title-derived-note-ids-d9206148.md @@ -0,0 +1,147 @@ +--- +id: rk-adds-id-slug-for-title-derived-note-ids-d9206148 +short-id: d92 +title: Adds _id-slug for title-derived note ids +priority: 3 +labels: +- feat-title-derived-ids +deps: [] +closed: true +--- +Touches: core/0.1.0/src/pure.typ, core/0.1.0/test/units.typ + +Add `_id-slug`, a pure helper that turns a note's title into a string safe to use as +that note's id. It is the first piece of a larger change (deriving an unnamed +`#idea`'s id from its title instead of from a counter); this bird adds the helper and +its unit tests ONLY, and wires it to nothing. + +## Why a new helper rather than reusing `_slug` + +`@rookery/core` already has a slug function, and it is nearly right: + +``` +rg -n -F '#let _slug(s) = {' /home/lox/code/_fcl/rookery +``` + +One hit, `core/0.1.0/src/pure.typ` (line 814 as of filing), in the block whose header +comment begins "A URL-safe slug from a heading's plain text". It lowercases, collapses +every run of characters outside `[a-z0-9]` to a single `-`, and trims leading and +trailing `-`. + +Three things make it unusable as-is for an id, and all three are reasons to ADD a +helper beside it rather than change it: + +1. **It panics on an empty result.** A string of nothing but punctuation aborts the + compile. That is correct for its current caller but wrong here: a note whose title + slugs to nothing must quietly fall back to the counter, not kill the build. +2. **It has no numeric guard.** `_slug("42")` returns `"42"`, which collides with the + namespace an unnamed note's counter mints into (ids `1`, `2`, `3`, …). +3. **It has no length cap.** A 200-character title becomes a 200-character id, and an + id becomes a filename (`ideas/.html`). + +`_slug` must keep its exact current behaviour, panic included. Two existing callers +depend on it: the public `slug` function one line below it +(`rg -n -F '#let slug(content) = _slug' /home/lox/code/_fcl/rookery`, one hit, same +file), which is part of this package's public API; and `#ideate`'s heading-derived +naming in `core/0.1.0/src/ideate.typ`, which relies on the panic to reject an +unnameable section. + +## Steps + +1. Find the site: + + ``` + rg -n -F '#let slug(content) = _slug' /home/lox/code/_fcl/rookery + ``` + + One hit, `core/0.1.0/src/pure.typ` (line 828 as of filing). Add `_id-slug` + immediately AFTER that `slug` definition, so the three slug functions sit together. + +2. Write it: + + ```typ + #let _id-slug(s, limit: 60) = { + let out = lower(s).replace(regex("[^a-z0-9]+"), "-").trim("-") + if out == "" { return none } + if out.match(regex("^[0-9]+$")) != none { return none } + if out.len() > limit { out = out.slice(0, limit).trim("-", at: end) } + if out == "" { none } else { out } + } + ``` + + Note the trailing `.trim("-", at: end)` after slicing: cutting mid-word can leave a + dangling `-`, and an id must not end in one. Re-check for emptiness after the trim. + + Do NOT call `_slug` from inside `_id-slug` — `_slug` panics on the empty case, which + is the one case this function exists to handle gracefully. Duplicating the one-line + regex is correct here; say so in the comment. + +3. Comment it to this package's house style (see `CLAUDE.md`, "Comment style"): describe + the present, no history, and give the reason for each of the three rules. The + non-obvious facts a reader needs are that `none` means "this title cannot name a + note, use the counter instead", that a purely-numeric slug is refused because it + would collide with the counter's own namespace, and that the cap exists because an + id becomes a filename. Keep it to a short paragraph plus the rules; do not restate + what the code says. + +4. Export it for the test fixture. `core/0.1.0/test/units.typ` imports an explicit list + of names from `/src/lib.typ`. Find it: + + ``` + rg -n -F '_no-content, _slug, _ideate-tag-value' /home/lox/code/_fcl/rookery + ``` + + One hit, `core/0.1.0/test/units.typ` (line 23 as of filing), inside the + `#import "/src/lib.typ": (` list. Add `_id-slug` to that list, next to `_slug`. + + No other export work is needed: `lib.typ` re-exports `base.typ` with `*`, and + `base.typ` re-exports `pure.typ` with `*`, so a new top-level `#let` in `pure.typ` is + already reachable. + +5. Add unit assertions. Find the existing slug section: + + ``` + rg -n -F "_slug — a heading's plain text as a URL-safe name" /home/lox/code/_fcl/rookery + ``` + + One hit, `core/0.1.0/test/units.typ` (line 670 as of filing). Add a new + `// ---- _id-slug — ...` section AFTER the existing `slug` section that follows it + (the one containing `#assert.eq(slug([Waterline]), "waterline")`). Assert at least: + + ```typ + #assert.eq(_id-slug("My Title"), "my-title") + #assert.eq(_id-slug("Fuzzy search: ranking & scoring"), "fuzzy-search-ranking-scoring") + #assert.eq(_id-slug("!!!"), none) + #assert.eq(_id-slug(""), none) + #assert.eq(_id-slug("42"), none) + #assert.eq(_id-slug("Chapter 42"), "chapter-42") + #assert.eq(_id-slug("a" * 80).len(), 60) + #assert.eq(_id-slug("abc", limit: 2), "ab") + ``` + + Match the fixture's existing convention: a short comment above each assertion saying + what rule it pins. Note the file's own header describes it as a regression suite — + these are new-contract assertions rather than regressions, so say in the section + header that they pin `_id-slug`'s contract. + +## Non-goals + +- Do NOT change `_slug` or the public `slug` in any way, including its comment. +- Do NOT touch `core/0.1.0/src/idea.typ`, `ideate.typ`, `state.typ` or + `transclusion.typ`. Nothing calls `_id-slug` after this bird, and that is correct — + a separate bird wires it in. +- Do NOT add `_id-slug` to any public export list, readme, or documentation. It is + internal (leading underscore) and the readme is a separate bird's job. +- Do NOT change how ids are currently generated anywhere. + +## VERIFY + +From `core/0.1.0`: + +1. `just test` exits 0 and prints `units OK`. This is the whole harness — `assert.eq` + failures abort the compile with a line number. +2. `rg -n -F '#let _id-slug' src/pure.typ` returns exactly one hit. +3. `rg -n -F '#let _slug(s) = {' src/pure.typ` still returns exactly one hit, and + `rg -n -F 'could not be slugged to a name' src/pure.typ` still returns exactly one + hit — proving `_slug`'s panic is untouched. +4. `rg -c '_id-slug' src/` reports hits in `pure.typ` only. \ No newline at end of file diff --git a/.birds/ideas/rk-adds-resolve-display-for-the-display-5a057095.md b/.birds/ideas/rk-adds-resolve-display-for-the-display-5a057095.md new file mode 100644 index 00000000..515ee861 --- /dev/null +++ b/.birds/ideas/rk-adds-resolve-display-for-the-display-5a057095.md @@ -0,0 +1,150 @@ +--- +id: rk-adds-resolve-display-for-the-display-5a057095 +short-id: 5a +title: Adds _resolve-display for the display dictionary +priority: 3 +labels: +- feat-display-dict +deps: +- blocked-by:rk-adds-id-slug-for-title-derived-note-ids-d9206148 +closed: true +--- +Touches: core/0.1.0/src/pure.typ, core/0.1.0/test/units.typ + +Add `_resolve-display`, a pure helper that merges a `display:` dictionary with a set of +individual `display-*` flags into one canonical dictionary. It is the first piece of a +larger change replacing `@rookery/core`'s `show-*` arguments on `#idea` and +`#window` with a single +`display` dictionary plus matching `display-*` overrides; this bird adds the helper and +its unit tests ONLY, and wires it to nothing. + +## The contract + +Nine keys: `context`, `backlinks`, `background`, `date`, `frame`, `id`, `label`, +`tags`, `title`. + +They are the union of two sets. `#idea` uses seven of them (`context`, `backlinks`, +`date`, `frame`, `id`, `tags`, `title`); `#window` uses six (`date`, `frame`, `id`, +`tags`, `label`, `background`). Both call this one helper and each ignores the keys it +has no use for — one vocabulary rather than two overlapping ones. Do NOT try to give +each caller its own key subset; a caller passing a key its function ignores is +harmless, and per-caller subsets would put the validation in two places. + +`_resolve-display(dict, flags, where)` returns a dictionary with ALL NINE keys always +present. For each key, the value is the first of: + +1. `flags.at(key)`, when it is not `auto` — an individual `display-` argument the + caller passed. These take precedence. +2. `dict.at(key)`, when the key is present in the `display:` dictionary. +3. `auto` — no opinion from this caller. + +`auto` is deliberately preserved in the output rather than resolved to a boolean. It +means "this caller expressed no preference", and what happens next differs by key: +three of them (`context`, `backlinks`, `title`) fall back to a document-wide setting +much later, on the minted page; the rest are given their built-in default by their +caller. Resolving `auto` here would destroy that distinction, so this function +must not do it. + +`where` is a caller label for error messages, in the style this file already uses — +`"#idea's"`, `"#rookery's"`. Find an existing example: + +``` +rg -n -F '_assert-tags(tags, "#idea' /home/lox/code/_fcl/rookery +``` + +## Validation + +Both of these are caller errors that must panic with a clear message, because a silent +typo in a display dictionary produces a note that renders wrong with no clue why: + +- **An unknown key in `dict`.** Panic naming the offending key AND listing all nine + valid keys, so the author can see the typo. Message prefix `@rookery/core: `, as + every other message in this package has. +- **A value in `dict` that is neither `true`, `false` nor `auto`.** Panic naming the + key and the bad value with `repr(..)`. + +Do NOT validate `flags` — those come from named parameters the package controls, not +from author input. + +## Steps + +1. Find where to put it. `_resolve-tags-color` is a comparable resolution helper in the + same file: + + ``` + rg -n -F '#let _resolve-tags-color' /home/lox/code/_fcl/rookery + ``` + + One hit, `core/0.1.0/src/pure.typ`. Add `_resolve-display` near it. Also add a + module-level constant for the key list so the panic message and the iteration share + one source: + + ```typ + #let _DISPLAY-KEYS = ( + "context", "backlinks", "background", "date", "frame", + "id", "label", "tags", "title", + ) + ``` + +2. Implement it. Iterate `_DISPLAY-KEYS` and build the result; validate `dict`'s keys + against `_DISPLAY-KEYS` first so an unknown key is reported before anything else. + +3. Comment it to this package's house style (`CLAUDE.md`, "Comment style"): present + tense, no history. The non-obvious facts worth stating are the precedence order, and + WHY `auto` survives into the output rather than being resolved here. + +4. Export for the fixture. `core/0.1.0/test/units.typ` imports an explicit list from + `/src/lib.typ`: + + ``` + rg -n -F '_no-content, _slug, _ideate-tag-value' /home/lox/code/_fcl/rookery + ``` + + One hit (line 23 as of filing). Add `_resolve-display` and `_DISPLAY-KEYS` to that + list. No other export work is needed: `lib.typ` re-exports `base.typ` with `*`, and + `base.typ` re-exports `pure.typ` with `*`. + +5. Add a `// ---- _resolve-display — ...` section to the fixture asserting at least: + + ```typ + // All nine keys are always present, `auto` where nobody had an opinion. + #assert.eq(_resolve-display((:), (:), "#t").len(), 9) + #assert.eq(_resolve-display((:), (:), "#t").frame, auto) + // The dictionary supplies a value. + #assert.eq(_resolve-display((frame: false), (:), "#t").frame, false) + // An individual flag WINS over the dictionary. + #assert.eq(_resolve-display((frame: false), (frame: true), "#t").frame, true) + // A flag left `auto` does NOT override the dictionary. + #assert.eq(_resolve-display((frame: false), (frame: auto), "#t").frame, false) + // `auto` is a legal dictionary value and stays `auto`. + #assert.eq(_resolve-display((title: auto), (:), "#t").title, auto) + // Keys nobody mentioned are still present and still `auto`. + #assert.eq(_resolve-display((frame: false), (:), "#t").backlinks, auto) + ``` + + Match the fixture's convention of a short comment above each assertion naming the + rule it pins. The two panic cases cannot be asserted — a panic aborts the compile, + which this `assert.eq` harness cannot survive. The fixture's own header explains + this; follow the precedent set by the `_slug` section, which documents its panic case + in a comment instead of asserting it. + +## Non-goals + +- Do NOT touch `core/0.1.0/src/idea.typ`, `window.typ`, `template.typ`, `state.typ`, + `transclusion.typ`, `permalink.typ`, `ideate.typ` or `.marrow.typ`. + Nothing calls `_resolve-display` after this bird, and that is correct. +- Do NOT rename any existing `show-*` argument anywhere. Separate birds do that. +- Do NOT resolve `auto` to a boolean inside this function. +- Do NOT add document-wide state, or read any state — this is a pure function. +- Do NOT touch the readme. + +## VERIFY + +From `core/0.1.0`: + +1. `just test` exits 0 and prints `units OK`. +2. `rg -n -F '#let _resolve-display' src/pure.typ` returns exactly one hit. +3. `rg -n -F '#let _DISPLAY-KEYS' src/pure.typ` returns exactly one hit. +4. `rg -c 'show-' src/pure.typ` reports the same count as before your change — this + bird renames nothing. +5. `rg -c '_resolve-display' src/` reports hits in `pure.typ` only. \ No newline at end of file diff --git a/.birds/ideas/rk-adds-tag-and-base-tags-to-idea-9960f68e.md b/.birds/ideas/rk-adds-tag-and-base-tags-to-idea-9960f68e.md new file mode 100644 index 00000000..d695beb7 --- /dev/null +++ b/.birds/ideas/rk-adds-tag-and-base-tags-to-idea-9960f68e.md @@ -0,0 +1,380 @@ +--- +id: rk-adds-tag-and-base-tags-to-idea-9960f68e +short-id: '996' +title: 'Adds tag and base-tags to #idea' +priority: 3 +labels: +- feat-idea-base-tags +deps: [] +closed: true +--- +Touches: core/0.1.0/src/pure.typ, core/0.1.0/src/idea.typ, core/0.1.0/test/units.typ, core/0.1.0/readme.md + +Give `#idea` in `@rookery/core` two new tag arguments, `tag:` and `base-tags:`, +so that `idea.with(..)` is a safe way to build a note constructor. + +## Why + +Today the only safe way to build a tagged constructor is the `tagged-idea` +factory. The obvious `.with()` spelling is a documented trap: + + #let note = idea.with(tags: ("note",)) + #note("c", tags: ("draft",))[body] + +`.with()` binds a DEFAULT, and a caller's explicit `tags:` REPLACES it +wholesale, so note "c" comes out tagged `("draft",)` — the "note" tag is +silently gone. This was measured on the current tree, not inferred: +`tagged-idea("note")` gives `("note", "draft")` for the same call. + +`tag:` and `base-tags:` are merging slots next to the replacing one. A caller's +`tags:` is merged ON TOP OF them rather than replacing them, so `.with()` +becomes safe: + + #let note = idea.with(tag: "note") + #note("c", tags: ("draft",))[body] // -> ("note", "draft") + +After this bird there are exactly three ways to put tags on a note, and they +differ in who writes them and what they accept: + + #idea( + tag: "onlystring", // a string, or none + base-tags: ("string", "or", "array"), // none, string, array or dictionary + tags: "string-or-array", // none, string, array or dictionary + ) + +- `tags:` is the CALL SITE's. It replaces whatever a constructor bound, which + is why it alone is safe to name at a call site and unsafe to bind with + `.with()`. +- `base-tags:` is a CONSTRUCTOR's, and takes every shape `tags:` does. The + dictionary form binds a VALUE per tag — + `idea.with(base-tags: (todo: (state: "open")))`. +- `tag:` is the same thing narrowed to the single-tag case, which is the + common one: `idea.with(tag: "note")`. It accepts a STRING ONLY. + +PRECEDENCE, lowest to highest: `tag:`, then `base-tags:`, then `tags:`. Where +two of them name the same tag, the higher one's value wins outright — there is +no deep merge, which is the rule `_dedup-tag` already implements. + +## Where + +All sites are in `core/0.1.0/`. Run the anchor commands from the FLIGHT ROOT +(the directory containing `core/`, `search/`, `todos/` …). Each was run before +filing and printed exactly ONE hit. + +**Site 1 — `_assert-tags`,** the shared validator, in `core/0.1.0/src/pure.typ` +(around line 760 as of filing). + + rg -Fn '#let _assert-tags(v, where)' core/ + +**Site 2 — `_dedup-tag`,** the per-key merge, also in `core/0.1.0/src/pure.typ` +(around line 226 as of filing). The new helper goes directly BELOW it. + + rg -Fn '#let _dedup-tag(tag, tags, value: none)' core/ + +**Site 3 — `#idea`'s signature,** in `core/0.1.0/src/idea.typ` (around line 45). + + rg -Fn '#let idea(level: 1, title: none, tags: ()' core/ + +**Site 4 — `#idea`'s tag normalization,** three lines into that function's body +(around line 55). + + rg -Fn '_assert-tags(tags, "#idea'"'"'s")' core/ + +**Site 5 — `tagged-idea`'s fold,** in `core/0.1.0/src/idea.typ` (around line +524). + + rg -Fn 'own-tags.rev().fold(tags,' core/ + +**Site 6 — `tagged-idea`'s banner comment,** in `core/0.1.0/src/idea.typ` +(around line 436). + + rg -Fn 'is a FACTORY: it returns an' core/ + +**Site 7 — the units fixture's `_dedup-tag` block,** in +`core/0.1.0/test/units.typ` (around line 48). + + rg -Fn '_dedup-tag — `#todo("x", tags: ("todo",))` must not double the tag' core/ + +**Site 8 — the readme's `tagged-idea` section heading,** in +`core/0.1.0/readme.md` (around line 1756). + + rg -Fn '### `tagged-idea` — build your own constructors' core/ + +**Site 9 — the readme's signature paragraph,** in `core/0.1.0/readme.md` +(around line 23). + + rg -Fn 'the sink accepts the body alone' core/ + +If an anchor does not resolve, widen the `rg` to the flight root. If it is +still gone, STOP and report the miss naming the landmark (the function, the +comment banner, the readme heading) rather than guessing or recreating text. + +## Steps + +1. **Site 1 — let `_assert-tags` name the argument it is checking.** It + currently hardcodes the word `tags` in its message. Give it a `what:` + parameter defaulting to `"tags"` and use that in the message instead of the + literal: + + #let _assert-tags(v, where, what: "tags") = assert( + v == none + or type(v) == str + or type(v) == dictionary + or (type(v) == array and v.all(t => type(t) == str)), + message: "@rookery/core: " + where + " `" + what + "` must be none, a " + + "string, an array of strings, or a dictionary — got " + repr(v), + ) + + Every existing call site passes two arguments and keeps working unchanged. + Do NOT touch the four existing callers (`data.typ`, `window.typ`, + `outline.typ`, `template.typ`). + +2. **Site 2 — add `_merge-base-tags` directly below `_dedup-tag`.** It folds a + constructor's base tags UNDER a caller's, reusing `_dedup-tag` so the + caller-wins rule is defined in exactly one place: + + #let _merge-base-tags(base, tags) = { + let base = _norm-tags(base) + base.keys().rev().fold( + _norm-tags(tags), + (acc, t) => _dedup-tag(t, acc, value: base.at(t)), + ) + } + + Give it a comment block in the project's style (see `CLAUDE.md`) saying what + it is: the merge behind `#idea`'s `tag:` and `base-tags:`, where the + higher-precedence side wins outright on a key collision because + `_dedup-tag`'s "already a key" guard says so, and where the `.rev()` is + there so keys come out in the order the constructor named them + (`_dedup-tag` prepends, so the last one folded ends up first). It NESTS to + give the three-way precedence — that is worth one sentence, since it is why + no three-argument version of this helper exists. Do not narrate the change. + +3. **Site 3 — add the two parameters to `#idea`.** Insert + `tag: none, base-tags: none,` immediately after `tags: (),` in the + signature. Leave every other parameter and its order alone. + +4. **Site 4 — validate and merge them.** The function body currently opens + with: + + _assert-tags(tags, "#idea's") + let tags = _norm-tags(tags) + + Make it: + + _assert-tags(tags, "#idea's") + _assert-tags(base-tags, "#idea's", what: "base-tags") + assert( + tag == none or type(tag) == str, + message: "@rookery/core: #idea's `tag` must be a single tag name as a " + + "string — pass several as `base-tags: (\"a\", \"b\")` — got " + + repr(tag), + ) + let tags = _merge-base-tags(tag, _merge-base-tags(base-tags, tags)) + + Read the nesting from the inside out: `base-tags` merges under the caller's + `tags`, and `tag` merges under the result of that. `_merge-base-tags` + normalizes both its sides, so the separate `_norm-tags(tags)` call is no + longer needed here. + + `tag:` gets its OWN assert rather than `_assert-tags`, because it is the one + tag argument that does NOT take all four shapes — a string only. The message + must point at `base-tags:` as the way to pass several, or a caller who + writes `tag: ("a", "b")` has nowhere to go. + + **Do NOT name a local `base` anywhere in this function.** `#idea` already + binds `let base = if named { _norm(name) } else { none }` further down and + uses it for the note's id; shadowing it breaks every named note. + + Add a short comment above the asserts giving the three-way split and its + precedence: `tags:` is the call site's and replaces; `base-tags:` and `tag:` + are a constructor's and are merged under it, `tag:` lowest; which is what + makes `idea.with(tag: ..)` safe where `idea.with(tags: ..)` silently drops + the constructor's tag. + +5. **Site 5 — reimplement `tagged-idea` over the new parameters,** so the two + cannot drift. Its returned closure currently reads: + + ( + tags: none, + exclude-tags: exclude-tags, + ..args, + ) => idea( + tags: own-tags.rev().fold(tags, (acc, t) => _dedup-tag(t, acc, value: value)), + exclude-tags: exclude-tags, + ..args, + ) + + Replace the whole returned-closure expression with: + + idea.with( + base-tags: if value == none { + own-tags + } else { + ((own-tags.at(0)): value) + }, + exclude-tags: exclude-tags, + ) + + This is an exact translation: the asserts above it already guarantee + `own-tags` is non-empty, and that `value` is only ever set when there is + exactly ONE tag — so `own-tags.at(0)` is safe in that branch. `.with()` + binds `exclude-tags` as a default a caller can still override, which is the + behaviour the closure's explicit `exclude-tags:` parameter provided. + + Use `base-tags:`, NOT `tag:` — the factory takes several tags and `tag:` + takes one. + + Keep every assert above it exactly as it is, including the `own.named()` + sink check and the `value == none or own-tags.len() == 1` check. + +6. **Site 6 — update `tagged-idea`'s banner comment.** Two claims in it are now + false and must be rewritten to describe what is actually there: + + - "THE TRAP, do not reintroduce: `#let note = idea.with(tags: (note: none))`" + — still a trap, but the reason to reach for `tagged-idea` is no longer + that `.with()` cannot merge. Rewrite this paragraph to say that `tags:` + replaces while `tag:`/`base-tags:` merge, and that `idea.with(tag: ..)` is + the supported `.with()` spelling. + - The paragraph beginning "`value:` is the default this factory binds" and + its "IT TAKES ONE TAG" reasoning — keep the description of what `value:` + does, and note that `base-tags:` in its dictionary form expresses the same + thing without the one-tag restriction, since there the dictionary IS the + tag record. + + Also say plainly that the factory is now a thin wrapper over + `idea.with(base-tags: ..)`. Follow `CLAUDE.md`'s comment style: present + tense, describe what is there, no "used to", no issue ids. + +7. **Site 7 — add unit cases.** In `core/0.1.0/test/units.typ`, add + `_merge-base-tags` to the big `#import "/src/lib.typ": (..)` list at the top + (keep the list's existing grouping), and add a new block directly BELOW the + existing `_dedup-tag` block: + + // ---- _merge-base-tags — a constructor's tags, under the caller's ------ + // The trap this pins: `idea.with(tags: ("note",))` lets a caller's own + // `tags:` REPLACE the constructor's tag outright, so `#note("c", tags: + // ("draft",))` loses "note". `tag:`/`base-tags:` merge instead. + #assert.eq(_merge-base-tags("note", none), (note: none)) + #assert.eq(_merge-base-tags("note", ("draft",)), (note: none, draft: none)) + // Several tags keep the order the constructor named them in. + #assert.eq( + _merge-base-tags(("person", "participant"), none), + (person: none, participant: none), + ) + // A dictionary base binds a VALUE per tag, which is what makes a + // one-tag `value:` restriction unnecessary. + #assert.eq( + _merge-base-tags((todo: (state: "open"), draft: none), none), + (todo: (state: "open"), draft: none), + ) + // THE CALLER'S OWN VALUE FOR THE SAME TAG WINS OUTRIGHT — no deep merge. + #assert.eq( + _merge-base-tags((todo: (state: "open")), (todo: (state: "done"))), + (todo: (state: "done")), + ) + // An absent base is the caller's tags, normalized and nothing else. + #assert.eq(_merge-base-tags(none, ("draft",)), (draft: none)) + // NESTED, which is how `#idea` gets its three-way precedence: + // `tag:` under `base-tags:` under the caller's `tags:`. + #assert.eq( + _merge-base-tags("low", _merge-base-tags(("mid",), ("high",))), + (low: none, mid: none, high: none), + ) + #assert.eq( + _merge-base-tags((k: "low"), _merge-base-tags((k: "mid"), (k: "high"))), + (k: "high"), + ) + +8. **Site 8 — update the readme's `tagged-idea` section.** Add `tag:` and + `base-tags:` as the primary way to build a constructor, with the three + argument forms shown and the precedence rule stated, plus the `.with()` + composition example. The sentence "`tagged-idea` exists because `.with()` + cannot express \"merge, don't replace\"" is now false — find it with + `rg -Fn 'cannot express' core/0.1.0/readme.md` and rewrite it to say that + `tag:`/`base-tags:` are what express it, and that `tagged-idea` remains as a + factory spelling of the same thing. Keep the existing `exclude-tags` + discussion in that section; it still holds for the factory. + +9. **Site 9 — update the readme's full-signature paragraph** so the listed + signature includes `tag: none, base-tags: none` after `tags: ()`. + +## Non-goals + +- **Do NOT remove or deprecate `tagged-idea` in this bird.** It stays + exported, with its current signature and behaviour, reimplemented over + `base-tags:` per step 5. `todos`, `slipshow`, `meetings`, `cfps`, `bibtex` + and `timeline` all still import it, and they are migrated off it by their + own birds before a separate later bird deletes it from core. Removing it + here breaks six packages at once. +- **Do NOT touch any package outside `core/0.1.0/`.** +- Do not add `tag:` or `base-tags:` to `#window`, `#ideas`, `#ideas-outline` + or `#ideate`. Their `tags:` is a FILTER, not a constructor's tags, and + merging makes no sense there. +- Do not change what `tags:` does. It still replaces. +- Do not make `tag:` accept an array, a dictionary or a label. A string or + `none`, and nothing else — that restriction is the point of having it + alongside `base-tags:`. +- Do not touch `.marrow.typ`, the exclusion gate, the registry write, or any + rendering branch. +- Do not add a demo file under `core/0.1.0/demo/`. + +## VERIFY + +Run 1, 2 and 3 from `/core/0.1.0/`. + +1. The unit fixtures compile, which is the whole harness: + + just test + + Expect it to end with `units OK`. + +2. The trap is actually fixed end to end, and all three arguments compose. + Create `_basetags_check.typ` INSIDE `core/0.1.0/` (it must live under the + compile root — a file in `/tmp` fails with "source file must be contained + in project root"): + + #import "/src/lib.typ": idea, rookery, tagged-idea, tags-of, tag-value + #show: rookery + #let note = idea.with(tag: "note") + #let participant = idea.with(base-tags: ("person", "participant")) + #let todo = idea.with(base-tags: (todo: (state: "open"))) + #let factory = tagged-idea("note") + #note("a", tags: ("draft",))[body] + #factory("b", tags: ("draft",))[body] + #participant("c")[body] + #todo("d")[body] + #idea("e", tag: "low", base-tags: ("mid",), tags: ("high",))[body] + #context { + assert.eq(tags-of("a"), ("note", "draft")) + assert.eq(tags-of("b"), ("note", "draft")) + assert.eq(tags-of("c"), ("person", "participant")) + assert.eq(tags-of("d"), ("todo",)) + assert.eq(tag-value("d", "todo"), (state: "open")) + assert.eq(tags-of("e"), ("low", "mid", "high")) + } + + Compile it: + + typst compile --features html --root . --format html _basetags_check.typ /dev/null + + Expect exit 0 (an `html export is under active development` warning is + normal and expected). The asserts are the test: note "a" proves + `.with(tag:)` survives a caller's own `tags:`, note "b" proves the + reimplemented factory still behaves identically, and note "e" proves the + three-way precedence. + + DELETE `_basetags_check.typ` afterwards — it must not be left in the tree. + +3. The demo suite still builds: + + cd demo/pure && just build + + Expect it to end with `demo/pure OK`. + +4. The readme documents both new arguments. From `/core/0.1.0/`: + + rg -Fn 'base-tags' readme.md + + Expect at least three hits (the signature paragraph and the `tagged-idea` + section). \ No newline at end of file diff --git a/.birds/ideas/rk-allow-idea-with-no-body-03558c3a.md b/.birds/ideas/rk-allow-idea-with-no-body-03558c3a.md new file mode 100644 index 00000000..6b187cdc --- /dev/null +++ b/.birds/ideas/rk-allow-idea-with-no-body-03558c3a.md @@ -0,0 +1,203 @@ +--- +id: rk-allow-idea-with-no-body-03558c3a +short-id: '03' +title: 'Allow #idea with no body' +priority: 3 +labels: +- fix-idea-bodiless +deps: [] +closed: true +--- +Touches: core/0.1.0/src/idea.typ, core/0.1.0/test/units.typ, core/0.1.0/readme.md + +`#idea` in `@rookery/core` currently REQUIRES at least one positional +argument. A call that carries only named arguments and no trailing content +block — `#idea(title: [hello])` — panics with the package's own arity message +("#idea takes a body, optionally preceded by a name ... got 0 positional +arguments"). + +That is wrong. A note with a title and no body is a legitimate note: it +registers, it carries an id and an anchor, it can be linked to and +transcluded, and an empty body is already a fully supported shape everywhere +downstream — `#idea("x")[]` works today, and `test/units.typ` already pins +two of its consequences (`_join(())` returning `""` rather than `none`, and +`_derived-title([])` staying `none`). The only thing rejecting the call is +the arity guard at the top of `#idea`. Nothing below it needs to change. + +So: make zero positional arguments legal, meaning an unnamed note with an +empty body. + +## Where + +All three sites are in `core/0.1.0/`. Run the anchor commands from the +REPOSITORY ROOT (the directory containing `core/`, `search/`, `todos/` …). +Each command below was run before filing and printed exactly ONE hit. + +**Site 1 — the arity guard.** Inside the `#let idea(...)` function in +`core/0.1.0/src/idea.typ`, around line 62 as of filing. + +``` +rg -n 'pos.len\(\) >= 1 and pos.len\(\) <= 2' core/ +``` + +**Site 2 — the destructuring just below it.** Same function, around line 69. + +``` +rg -n 'let \(name, body\) = if pos.len\(\) == 1' core/ +``` + +**Site 3 — the arity fixture.** Under the heading `// ---- #idea — the two +legal positional shapes still compile` in `core/0.1.0/test/units.typ`, around +lines 76-87 as of filing. + +``` +rg -n 'let _bare = idea\[body text\]' core/ +``` + +**Site 4 — the signature paragraph in the readme.** `core/0.1.0/readme.md`, +around line 23 as of filing. + +``` +rg -n 'the sink accepts the body alone' core/ +``` + +If an anchor does not resolve, widen the `rg` to the repository root. If it +is still gone, STOP and report the miss naming the landmark (the `#let idea` +function, the units heading, the readme signature paragraph) rather than +guessing at the site or recreating the text. + +## Steps + +1. At site 1, change the assertion's condition from + `pos.len() >= 1 and pos.len() <= 2` to `pos.len() <= 2`. + +2. Rewrite that assertion's `message:` so it no longer claims a body is + required. The message exists to catch ONE remaining mistake — a third + positional, which is silently dropped — so it should say that and nothing + else. Replace the existing message text with: + + ``` + message: "@rookery/core: #idea takes an optional name and an optional " + + "body — #idea(, title: [T])[body], not #idea(, [T])[body]. " + + "A title is a named argument; a third positional is silently the " + + "one that gets dropped — got " + + str(pos.len()) + " positional arguments.", + ``` + +3. At site 2, extend the destructuring to handle the empty case. It currently + reads: + + ```typ + let (name, body) = if pos.len() == 1 { + (none, pos.at(0)) + } else { + (pos.at(0), pos.at(1)) + } + ``` + + Make it: + + ```typ + let (name, body) = if pos.len() == 0 { + (none, []) + } else if pos.len() == 1 { + (none, pos.at(0)) + } else { + (pos.at(0), pos.at(1)) + } + ``` + + `[]` — empty content, NOT `none`. Everything downstream (`_derived-title`, + `_outbound`, `_flatten`, `_own-cited-keys`, `_std-footnotes`) is written + against content and already handles an empty body; `none` would reach + `.children` and fail. + +4. Update the comment block directly above the assertion at site 1. It + currently explains the variadic sink with "a positional parameter cannot + carry a default in Typst, and `#idea[body]` has to be callable with no name + at all." Keep that reason and add the second one — the sink is also what + lets the body itself be absent. Follow the project's comment style in + `CLAUDE.md`: describe the present, do not narrate the change, no issue ids, + no "used to". + +5. At site 3, widen the units fixture. The heading and its explanatory comment + say "the two legal positional shapes"; there are now three. Rewrite the + heading to `// ---- #idea — the three legal positional shapes still compile + -----------` (pad or trim the trailing dashes so the line stays within the + file's existing width) and add a third binding beside `_named` and `_bare` + inside the same `#context` block: + + ```typ + let _bodiless = idea(title: [Some Title]) + ``` + + Adjust the prose in that comment block so it describes three shapes — a + name plus a body, a bare body, and named arguments alone — and keep its + existing final point that a third positional is a manual VERIFY because + Typst cannot catch a panic from inside a document. + +6. At site 4, update the readme's signature paragraph so the sink's accepted + shapes include the empty one. The sentence currently reads "the sink accepts + the body alone, `(name, body)`, or `(, body)`". Make it also state + that all positionals may be omitted, giving an unnamed note with an empty + body, and that this is how a title-only note is written: + `#idea(title: [hello])`. + +## Non-goals + +- **Do NOT make a NAMED bodiless note possible.** `#idea("x", title: [T])` has + one positional and therefore still means "unnamed note whose body is the + string `x`". Changing what a lone positional means would break every + existing `#idea[..]` and `#idea("literal text")` call. That gap is real and + deliberately left alone here. +- Do not add a `name:` named argument, or any other new parameter. +- Do not touch the exclusion gate, the registry write, the rendering branches, + or anything else in `idea.typ` below the destructuring. +- Do not touch `#window`, `#hyperlink`, `#ideate` or `.marrow.typ`. Their own + argument sinks are out of scope even though they share the pattern. +- Do not add a demo file under `core/0.1.0/demo/`. + +## VERIFY + +Run all of these from `core/0.1.0/`. + +1. The unit fixtures compile, which is the whole test harness — `assert.eq` + failing the compile with a line number is the runner: + + ``` + just test + ``` + + Expect it to end with `units OK`. + +2. The new shape actually renders rather than merely type-checking. Write a + scratch file OUTSIDE the repository, at `/tmp/rookery-bodiless.typ`: + + ```typ + #import "/src/lib.typ": idea, rookery + #show: rookery + #idea(title: [hello]) + #idea("after")[A note following a bodiless one.] + ``` + + and compile it: + + ``` + typst compile --features html --root . --format pdf /tmp/rookery-bodiless.typ /dev/null + ``` + + Expect a clean compile with no panic and no warning. + +3. The arity guard still rejects three positionals. Add a third positional to + the scratch file above (`#idea(, [b], [c])`), re-run the same `typst + compile` command, and confirm it FAILS with the package's message naming + "a third positional is silently the one that gets dropped". Then remove + that line again. + +4. The readme states the new shape: + + ``` + rg -n 'idea\(title: \[hello\]\)' readme.md + ``` + + Expect at least one hit. \ No newline at end of file diff --git a/.birds/ideas/rk-backlink-tag-selected-windows-per-note-9f632f19.md b/.birds/ideas/rk-backlink-tag-selected-windows-per-note-9f632f19.md new file mode 100644 index 00000000..1e7a797c --- /dev/null +++ b/.birds/ideas/rk-backlink-tag-selected-windows-per-note-9f632f19.md @@ -0,0 +1,276 @@ +--- +id: rk-backlink-tag-selected-windows-per-note-9f632f19 +short-id: 9f +title: Backlink tag-selected windows per note +priority: 3 +labels: +- fix-tag-window-backlinks +deps: +- blocked-by:rk-backlink-tag-selected-windows-per-page-e99edead +closed: true +--- +A `#window` that selects by TAG rather than by name gives the notes it shows no +backlink. The companion bird fixed the PAGE-level half of that (a vertebra +carrying the window now appears in those notes' Backlinks). This bird fixes the +NOTE-level half: a tag window written INSIDE another note's body should make that +note link to every note the window showed, exactly as a named window already +does. + +Touches: core/0.1.0/src/links.typ, core/0.1.0/src/idea.typ, core/0.1.0/.marrow.typ, core/0.1.0/demo/rheo/content/tags.typ, core/0.1.0/demo/rheo/check.sh + +## What the companion bird already did — you are building on it + +It added three keys to the `` metadata payload emitted by +`#window`, so the marker now reads: + +```typst +[#metadata(( + rookery-window: ids, + backlink: backlink, + tagged: tagged, + match: match, + filtered: filter != none, +)) ] +``` + +Confirm that before starting: + +``` +rg -n --fixed-strings 'filtered:' /home/lox/code/_fcl/rookery/core/0.1.0/src/window.typ +``` + +One hit expected, in `src/window.typ` around line 262, in the statement just +above the `context {` block. If it is NOT there, this bird's dependency has not +landed — stop and report that rather than adding the keys yourself. + +`tagged` and `match` are the values `#window` was given, unchanged. `filtered` is +`true` when the call also passed a `filter:` predicate. + +## Why the note-level half needs more than the page-level half did + +The page-level fix was cheap because `_page-links()` (`src/outline.typ:119`) +already runs at render time via `query()`, inside a context, after the registry +is final. The note-level graph does not work that way: + +- `_outbound` (`src/links.typ:39`) walks a note's RAW body at REGISTRATION and + returns a flat array of target ids, stored on that note's registry record as + its `links` field (`src/idea.typ:422`, `links: links,`). +- The reverse index is built much later, in `core/0.1.0/.marrow.typ:132-140`, + inside the file's one `#context` block, which binds + `let registry = _registry.final()` at `.marrow.typ:95`. + +So the registry IS final where the index is built — it is only unavailable where +the walk happens. The fix is therefore to record an UNRESOLVED SELECTOR at +registration and expand it when the index is built. No walk ever reads a registry +that is still being written, which is the rule the comment at `src/window.typ` +warns about. + +## The `filter:` rule — same as the companion bird + +`filter:` is a function (asserted `src/window.typ:143`) and cannot be stored in +metadata; nothing in this package stores a function there. `tagged:` and +`filter:` are ANDed, so resolving the tag half alone would claim backlinks from +notes the window never showed. A marker with `filtered: true` is therefore SKIPPED +entirely and keeps today's behaviour — no backlink. Do not try to persist the +predicate. + +## Steps + +1. Add a second walk beside `_outbound` in `src/links.typ`. + + ``` + rg -n --fixed-strings 'for (_, v) in node.fields() { out += _outbound(v) }' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `src/links.typ:88` as of filing — the last line of `_outbound`'s + generic recursion (the landmark is the function `_outbound` itself). Add a new + function `_outbound-tag-selectors(node)` directly AFTER `_outbound` ends. + + It returns an array of dictionaries, each `(tagged: .., match: ..)`, and it + mirrors `_outbound`'s traversal rules exactly, because the two walks must agree + about which windows belong to which note: + + - an array recurses elementwise and flattens, as `links.typ:40` does; + - a non-content value returns `()`, as `links.typ:41` does; + - a `figure` whose `kind` is `IK` returns `()` — a nested note owns its own + links, as `links.typ:47` does; + - a `metadata` whose dictionary value has a `rookery-window` key returns `()` + when `backlink` is false (`.at("backlink", default: true)`, as at + `links.typ:59`), `()` when `.at("filtered", default: false)` is true, `()` + when `.at("tagged", default: none)` is `none`, and otherwise the single-entry + array `((tagged: v.tagged, match: v.at("match", default: "any")),)`; + - a `metadata` with a `rookery-fn` key recurses into its payload, as + `links.typ:81-83` does — a `#window` inside a footnote counts, and that + traversal blind spot has already been fixed once for `_outbound` (see its + comment) so do not reintroduce it here; + - everything else recurses over `node.fields()` as `links.typ:88` does. + + Do NOT collect `link`/`ref` targets here — those are `_outbound`'s and are + already recorded. + + Comment the function: say it is the deferred half of `_outbound`, that it + records a SELECTOR rather than ids because the registry does not exist at + registration, and that `.marrow.typ` is what expands it once the registry is + final. + +2. Record the selectors on the note's registry record, in `src/idea.typ`. + + ``` + rg -n --fixed-strings 'let links = _outbound(body)' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `src/idea.typ:387` as of filing, inside `#idea`'s deferred context + block. Immediately after the existing `links` binding and its dedupe (the + dedupe is at idea.typ:388-389), add: + + ```typst + let tag-links = _outbound-tag-selectors(body) + ``` + + Then store it on the record: + + ``` + rg -n --fixed-strings 'links: links,' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `src/idea.typ:422` as of filing, in the dictionary that becomes the + registry record. Add `tag-links: tag-links,` beside it. + + Every later reader uses `.at("tag-links", default: ())`, so an older record + without the field is harmless — no migration, no version bump. + +3. Expand the selectors when the reverse index is built, in + `core/0.1.0/.marrow.typ`. + + NOTE: `.marrow.typ` is a DOTFILE. `rg` skips hidden files unless told not to, + so every search below needs `--hidden`: + + ``` + rg -n --hidden --fixed-strings 'for target in rec.at("links", default: ())' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `.marrow.typ:135` as of filing, inside the `NOTE backlinks` loop that + starts at `.marrow.typ:134` (the landmark is the comment line + `// NOTE backlinks: the inverse of every note's recorded outbound links.`). + + Inside the same `for (src, rec) in registry.pairs()` loop, after the existing + `links` loop, add the selector expansion: + + - for each selector in `rec.at("tag-links", default: ())`, build + `let pred = _tag-pred(sel.tagged, sel.at("match", default: "any"))`; + - skip a `none` predicate defensively; + - for every `(target, trec)` in `registry` whose + `trec.at("tags", default: (:))` satisfies `pred`, insert the edge + `src -> target` into `backlinks` the same way the existing loop does. + + TWO GUARDS, both required: + + - **Skip `target == src`.** A tag window is a query, so a note can easily + match its OWN selection — a note tagged `phd` whose body windows everything + tagged `phd`. The named path cannot realistically hit this and so has no + guard; this path hits it immediately, and a note listed in its own Backlinks + is a visible bug. + - **Do not double-insert.** The existing loop guards with + `if src not in seen`; do the same, since a window may both name a note and + match it by tag. + + `_tag-pred` is NOT currently imported here. Add it to the import list: + + ``` + rg -n --hidden --fixed-strings '_visible-tags' /home/lox/code/_fcl/rookery/core/0.1.0/.marrow.typ + ``` + + The first hit (`.marrow.typ:92` as of filing) is the single long + `#import "@rookery/core:0.1.0": ...` line; add `_tag-pred` to it. The function + is defined at `src/pure.typ:150` and reaches the package's public surface + through `src/base.typ:45`'s star-import, so no other export work is needed. + +4. Add the fixture this bird needs, in `core/0.1.0/demo/rheo/content/tags.typ`. + + ``` + rg -n --fixed-strings '#window(tagged: ("todo", "phd"), match: "all")' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `demo/rheo/content/tags.typ:30` as of filing. That window sits at the + PAGE's top level, not inside a note, so it exercises the page-level path only — + which is why this bird needs a fixture of its own. + + Add, below the existing tag notes on that page, a note whose BODY carries a tag + window, in the same style as the notes already there: + + ```typst + #note("tag-windower")[ + A note whose body windows by tag rather than by name. + + #window(tagged: ("todo", "phd"), match: "all") + ] + ``` + + `#note` is already bound on that page (it comes from `content/lib.typ` via the + page's own import at `tags.typ:1`) and `window` is already imported at + `tags.typ:2` — check both are still there rather than adding imports blind. + + The note must NOT itself carry the `todo` and `phd` tags, or it will match its + own selection and the self-edge guard becomes the thing under test rather than + the backlink. `#note` prepends the `note` tag only, so the snippet above is + already safe — keep it that way. + +5. Assert it in `core/0.1.0/demo/rheo/check.sh`. + + The companion bird added an assertion that + `build/html/ideas/tag-t-both.html` contains `Backlinks`. Add one more, + numbered in the same style: `build/html/ideas/tag-t-both.html` must now also + mention `tag-windower`, the note whose body windowed it. + + Grep for the slug rather than for prose, and write the `note` message to say + that a tag window inside a note stopped producing a note-level backlink. + +## Non-goals + +- Do NOT change `_outbound` itself. It keeps returning ids and only ids; the new + walk is a separate function. Changing its return type would touch every caller. +- Do NOT try to persist `filter:`. A `filter:` window still gets no backlink. +- Do NOT touch `_page-links` (`src/outline.typ:119`) or the marker payload — both + are the companion bird's and are already correct. +- Do NOT change what any window RENDERS. +- Do NOT add a migration or bump the package version for the new record field; + every read uses a default. + +## VERIFY + +1. Unit fixture still compiles: + + ```bash + cd /home/lox/code/_fcl/rookery/core/0.1.0 && just test + ``` + + Expect `units OK`. + +2. Demo builds and every assertion passes: + + ```bash + cd /home/lox/code/_fcl/rookery/core/0.1.0/demo/rheo && just check + ``` + + Expect no `FAIL:` lines. (`rheo` must be on PATH; if it is not, report that + rather than working around it.) + +3. The note-level edge exists, checked by hand after that build: + + ```bash + cd /home/lox/code/_fcl/rookery/core/0.1.0/demo/rheo + grep -c 'tag-windower' build/html/ideas/tag-t-both.html + ``` + + Must print a positive number. + +4. No note lists itself: + + ```bash + grep -c 'tag-windower' build/html/ideas/tag-windower.html + ``` + + The self-edge guard means this note's own Backlinks must not name itself. A + minted page mentions its own slug in other places (its permalink, its href), + so read the Backlinks section rather than trusting the count alone if this is + not zero. \ No newline at end of file diff --git a/.birds/ideas/rk-backlink-tag-selected-windows-per-page-e99edead.md b/.birds/ideas/rk-backlink-tag-selected-windows-per-page-e99edead.md new file mode 100644 index 00000000..8f7bb210 --- /dev/null +++ b/.birds/ideas/rk-backlink-tag-selected-windows-per-page-e99edead.md @@ -0,0 +1,240 @@ +--- +id: rk-backlink-tag-selected-windows-per-page-e99edead +short-id: e9 +title: Backlink tag-selected windows per page +priority: 3 +labels: +- fix-tag-window-backlinks +deps: [] +closed: true +--- +A `#window` that selects its notes by TAG gives those notes no backlink, while a +`#window` that NAMES them does. This bird fixes the PAGE-level half of that gap: +after it, a vertebra carrying `#window(tagged: "phd")` appears in the Backlinks +of every note that window actually showed. The NOTE-level half — a tag window +written inside another note's body — is a separate bird (see "Non-goals"). + +Touches: core/0.1.0/src/window.typ, core/0.1.0/src/outline.typ, core/0.1.0/demo/rheo/check.sh + +## The defect, as it stands + +`#window` announces the notes it shows in a `metadata` element so the backlink +walks can see them, and that payload carries ONLY the ids the author named: + +``` +rg -n --fixed-strings 'rookery-window: ids, backlink: backlink' /home/lox/code/_fcl/rookery/core +``` + +One hit, `core/0.1.0/src/window.typ:262` as of filing: + +```typst + [#metadata((rookery-window: ids, backlink: backlink)) ] +``` + +`ids` above is built only from the POSITIONAL argument (window.typ:208-211). The +`tagged:`/`match:`/`filter:` selection happens later, inside the `context` block +that opens at window.typ:264, and nothing from it ever reaches the marker. + +Observable right now, before any change: + +```bash +cd /home/lox/code/_fcl/rookery/core/0.1.0/demo/rheo && just check +grep -c -i backlink build/html/ideas/tag-t-both.html # prints 0 +grep -c -i backlink build/html/ideas/root-note.html # prints a positive number +``` + +`tag-t-both` is shown by `#window(tagged: ("todo", "phd"), match: "all")` on +`demo/rheo/content/tags.typ` and gets nothing; `root-note` is shown by a named +window and gets its Backlinks section. That asymmetry is the whole bug. + +## Why this is fixable, and where + +The reconnaissance is done — do not redo it. There are two backlink walks and +they are NOT equally stuck: + +- `_outbound` (`src/links.typ:39`) runs at note REGISTRATION on the raw body, + before any registry exists. It genuinely cannot resolve a tag. Not this bird. +- `_page-links()` (`src/outline.typ:119`) is different: it uses + `query()`, so it runs at RENDER time, inside a `context`, + and its only caller is `core/0.1.0/.marrow.typ:148`, which sits inside a + `#context` block that has already bound `let registry = _registry.final()` at + `.marrow.typ:95`. The registry is FINAL by the time `_page-links` runs. + +So the predicate can be evaluated inside `_page-links`. Only the SELECTOR has to +travel there, and a selector is plain data. + +## The `filter:` trap — read this before writing any code + +`#window`'s `filter:` is a FUNCTION (asserted at window.typ:143). Nothing in this +package stores a function inside `metadata` — every `metadata((..))` call site +carries strings, booleans, dictionaries or content, and that is checked. A +function cannot ride on the marker. + +That matters for CORRECTNESS, not just completeness. `tagged:` and `filter:` are +ANDed (window.typ:298 passes both to `_tag-pred`), so a window written as +`#window(tagged: "phd", filter: t => "draft" not in t)` shows FEWER notes than +`tagged: "phd"` alone. Resolving the tag half on its own would announce backlinks +from notes the window never rendered — a wrong backlink is worse than a missing +one. + +So: the marker carries a boolean saying a filter was in play, and every reader +SKIPS tag resolution entirely when it is set. A `filter:` window keeps exactly +today's behaviour — no backlink — and that stays true until someone finds a way +to persist a predicate, which is not this bird. + +## Steps + +1. Add the selector to the marker payload in `src/window.typ`. + + Find the site: + + ``` + rg -n --fixed-strings 'rookery-window: ids, backlink: backlink' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `src/window.typ:262` as of filing, the last statement before the + `context {` block that opens at line 264. Add two keys, making the payload: + + ```typst + [#metadata(( + rookery-window: ids, + backlink: backlink, + tagged: tagged, + match: match, + filtered: filter != none, + )) ] + ``` + + `tagged` and `match` are passed through UNCHANGED, exactly as `#window` + received them — `_tag-pred` accepts the same shapes `#window` accepts, and + normalising here would only put a second interpretation of the argument in the + codebase. Note that `tagged` is re-bound later at window.typ:297 inside the + context block; line 262 runs first and still sees the parameter, which is what + you want. + + Write a comment above the marker explaining that the tag SELECTOR rides along + because it is data, while `filter:` can only be recorded as a boolean because + it is a function — and that `filtered: true` therefore means "this selection + is narrower than its tags, do not resolve it". + +2. Correct the stale comment that sits directly above that marker. + + ``` + rg -n --fixed-strings 'A tag selection is not known until' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `src/window.typ:223` as of filing. The paragraph there says tag + matches get no backlink and ends with an instruction not to "fix" it by + announcing the tags. That instruction was about having `_outbound` read the + registry while it is still being built, which this bird does NOT do — the + resolution happens in `_page-links`, at render time. Rewrite the paragraph to + say what is now true: the selector is announced, `_page-links` resolves it + against the final registry, `_outbound` still cannot (note-level backlinks are + a separate matter), and a `filter:` window still announces nothing resolvable. + Keep the warning against making `_outbound` read the registry — it is still + correct, and it is the reason the fix lives where it does. + +3. Resolve the selector in `_page-links`, `src/outline.typ`. + + ``` + rg -n --fixed-strings 'for n in v.at("rookery-window", default: ())' /home/lox/code/_fcl/rookery/core + ``` + + One hit, `src/outline.typ:146` as of filing, inside the + `for el in query()` loop that starts at outline.typ:137, + in the function `_page-links` (the landmark to name if the anchor has moved). + + That loop already computes `seen` (the ids this page links to) and already + skips a marker whose `backlink` is `false` at outline.typ:142. After the + existing `for n in ...` loop that adds the named ids, add the tag resolution: + + - skip when `v.at("filtered", default: false)` is `true`; + - skip when `v.at("tagged", default: none)` is `none`; + - otherwise build `let pred = _tag-pred(v.tagged, v.at("match", default: "any"))` + and, for every `(id, rec)` in `_registry.final()` whose + `rec.at("tags", default: (:))` satisfies `pred`, push `id` into `seen` if it + is not already there. + + `_tag-pred` is already in scope: it lives in `src/pure.typ:150` and + `src/base.typ:45` star-imports `pure.typ`, which `outline.typ:7` star-imports + in turn. Do not add an import. + + `_registry` is likewise in scope via `src/state.typ` (outline.typ:8), and + `_page-links` is only ever called from inside a context, so `.final()` is + legal here. Ids in the registry are already full ids — do NOT apply `_pfx()` + to them the way the named branch does at outline.typ:147, which is there + because a marker carries BARE names. + + Comment the branch: say that this is the one reader that can resolve a tag + selection at all, because it runs after the registry is final, and that the + `filtered` skip is about not claiming backlinks a filter would have excluded. + +4. Add an assertion to `core/0.1.0/demo/rheo/check.sh`. + + ``` + rg -n --fixed-strings 'no minted page at ideas' /home/lox/code/_fcl/rookery/core + ``` + + One hit, in `demo/rheo/check.sh`, inside the numbered assertion list. Add a + new numbered assertion at the END of that list, in the same style as the ones + already there (a `grep -q ... || note "..."` pair): + + - `build/html/ideas/tag-t-both.html` must contain `Backlinks` — the fixture + `#window(tagged: ("todo", "phd"), match: "all")` at + `demo/rheo/content/tags.typ:30` sits on the vertebra `tags`, so that note + must now record a page backlink. + + Write the `note` message so a failure says what broke: that a tag-selected + window stopped registering a page backlink. + +## Non-goals + +- Do NOT touch `_outbound` (`src/links.typ:39`) or the note-level graph. A tag + window written INSIDE a note's body is bird `rk-` and needs a + new registry field; doing it here will collide with that bird on landing. +- Do NOT try to persist `filter:`, in metadata or on a state. A window with + `filter:` keeps getting no backlink, on purpose. +- Do NOT change what a window RENDERS. This bird changes the backlink graph + only; no window shows a different set of notes afterwards. +- Do NOT touch `_cite-scan` (`src/bib.typ:61`), which reads the same marker for + citation partitioning and cares only that it exists. +- Do NOT add a self-edge guard here. A page is not a note, so a page linking to + a note it contains is already handled by the existing page-backlink rules in + `.marrow.typ:142-154`; leave them alone. + +## VERIFY + +1. Unit fixture still compiles: + + ```bash + cd /home/lox/code/_fcl/rookery/core/0.1.0 && just test + ``` + + Expect `units OK`. + +2. The demo builds and every assertion passes, including the new one: + + ```bash + cd /home/lox/code/_fcl/rookery/core/0.1.0/demo/rheo && just check + ``` + + Expect no `FAIL:` lines. (`just check` cleans, builds with `rheo compile .`, + then runs `check.sh`. `rheo` must be on PATH; if it is not, report that rather + than working around it.) + +3. The specific behaviour, checked by hand after that build: + + ```bash + cd /home/lox/code/_fcl/rookery/core/0.1.0/demo/rheo + grep -c -i backlink build/html/ideas/tag-t-both.html + ``` + + Must now print a positive number. It printed `0` before this bird. + +4. Named windows did not regress: + + ```bash + grep -c -i backlink build/html/ideas/root-note.html + ``` + + Still positive. \ No newline at end of file diff --git a/.birds/ideas/rk-band-todo-table-by-urgency-and-priority-93eeda98.md b/.birds/ideas/rk-band-todo-table-by-urgency-and-priority-93eeda98.md new file mode 100644 index 00000000..e0c8f367 --- /dev/null +++ b/.birds/ideas/rk-band-todo-table-by-urgency-and-priority-93eeda98.md @@ -0,0 +1,334 @@ +--- +id: rk-band-todo-table-by-urgency-and-priority-93eeda98 +short-id: 93e +title: Band todo-table by urgency and priority +priority: 4 +labels: +- feat-todo-band-order +deps: +- blocked-by:rk-scale-the-priority-ramp-from-listed-rows-c0476e1e +closed: true +--- +`#todo-table` orders rows by date alone, so the most important todo on a site +sinks below every dated one. Interleave the two ladders the package already +has: a row's **band** becomes the sooner of its countdown band and its +priority rung, so the top priority floats to the top alongside what is due +today. + +Touches: /home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/test/units.typ, /home/lox/code/_fcl/rookery/todos/0.1.0/readme.md, /home/lox/code/_fcl/rookery/todos/0.1.0/demo/rheo/content/index.typ + +## The order wanted + +Four bands, read top to bottom. A row sits in the **lowest-numbered** band it +qualifies for by either ladder: + +| band | countdown level | priority rung | +|------|-----------------|---------------| +| 0 | `urgent` — overdue, today, tomorrow | hottest priority in use | +| 1 | `soon` — 2 to 7 days | second hottest | +| 2 | `later` — 8 to 14 days | third hottest | +| 3 | no level — more than 14 days out, or undated | fourth hottest and below, or unprioritised | + +So the hottest priority appears in band 0 whatever its date, and a todo due +tomorrow appears in band 0 whatever its priority. A todo with both stays in +band 0. A P-hottest todo dated eight months out is band 0. + +Within one band, in this order: + +1. In-progress rows first. +2. Then rows that carry a date, earliest first. +3. Then rows with no date, highest priority first. + +Rule 2 before rule 3 is the point: a scheduled or deadlined todo outranks one +that is in the band on priority alone. + +## What exists already + +Both ladders are in the repo. Do not write a third. + +**The countdown bands** are `/home/lox/code/_fcl/rookery/timeline/0.1.0/src/when.typ` +line 190, `#countdown(days)`, returning `(text: .., level: ..)` or `none`: + +```typ +#let countdown(days) = { + if days == none { return none } + if days > 14 { return none } + if days < -1 { return (text: str(-days) + " days ago", level: "urgent") } + if days == -1 { return (text: "yesterday", level: "urgent") } + if days == 0 { return (text: "today", level: "urgent") } + if days == 1 { return (text: "tomorrow", level: "urgent") } + if days <= 7 { return (text: "in " + str(days) + " days", level: "soon") } + (text: "in " + str(days) + " days", level: "later") +} +``` + +`/home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ` already imports it as +`_tl` (line 36) and already calls it at lines 405-406 to pick the date cell's +colour wash: + +```typ + let c = if countdown and d != none and today != none { + _tl.countdown(_tl.days-until(d, today)) + } else { none } +``` + +**You must map from `countdown(..).level`, not re-derive the cutoffs.** The +comment at `table.typ` lines 402-405 states the rule: a second copy of +`if days <= 7` in this file is how two surfaces drift apart. The cutoffs live +in `when.typ` and only there. + +**The priority ladder** is `/home/lox/code/_fcl/rookery/todos/0.1.0/src/tags.typ` +line 293, `#priority-rung(p, scale, rungs: 3)`: the index of `p` in `scale` +(the distinct priorities in use, hottest first), clamped to `rungs - 1`, and +`none` for priority 0 or a priority absent from the scale. + +**The scale** is a `let scale = ...` in `table.typ`, computed from the rows the +panel lists. Read the line where it is set before writing any code — a +preceding bird moved it, so its line number is not the one this description +would have given. + +## The current key, and why it is a string + +`/home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ` line 144: + +```typ +#let _sort-key(state, when, order) = { + let newest = order == "newest" + let lead = if state == "in-progress" { + if newest { "1" } else { "0" } + } else if newest { "0" } else { "1" } + lead + (if when == none { "\u{ffff}" } else { when }) +} +``` + +It is one string per row because that is `#panel`'s whole sort contract. +`/home/lox/code/_fcl/rookery/search/0.1.0/src/panel.typ` lines 477-486 +compares the named field as a plain string, ascending, and reverses the whole +list under `descending:`: + +```typ + let s = rows.sorted(key: key) + if descending { s.rev() } else { s } +``` + +`table.typ` line 598 passes `sort: "sort-key"` and line 599 passes +`descending: order == "newest"`. Two consequences you must honour: + +- **Every leading field must be complemented under `"newest"`**, or the + reversal sinks exactly the rows it lifted. That is what the existing `lead` + flip does, and the new fields need the same treatment. +- **Typst's `sorted` is stable**, which is what makes the priority pre-sort at + `table.typ` lines 360-364 work as a tie-break rather than a competing order. + Leave that block alone; it is what orders the undated rows within a band. + +`#panel`'s browser script does not re-sort: `search/0.1.0/src/panel.js` line +299 sorts on score with the markup index as tie-break, and with an empty query +every score is equal, so the Typst-side order survives filtering. No +JavaScript changes in this bird. + +## What to do + +1. In `/home/lox/code/_fcl/rookery/todos/0.1.0/src/table.typ`, add a private + mapping from a countdown level to a band number, near `_past` (line 55): + + ```typ + // A COUNTDOWN LEVEL AS A BAND NUMBER. The cutoffs that produce the level are + // @rookery/timeline's and stay there; this is only the ordering of the three + // it returns, plus the fourth band every unmeasured row falls to. + #let _LEVEL-BAND = ("urgent": 0, "soon": 1, "later": 2) + #let _BANDS = 4 + ``` + +2. Add a private `_band`, beside it: + + ```typ + // WHICH BAND A ROW READS IN: the sooner of the two ladders, so the hottest + // priority rises to the top whatever its date and a deadline landing tomorrow + // rises there whatever its priority. An in-progress row is band 0 outright — + // someone is on it now, which is the one fact neither ladder can express. + // + // `rungs: _BANDS` rather than `priority-rung`'s own default of three: the + // priority ladder has to be as long as the date ladder for the two to + // interleave, and the coolest band is where an unprioritised row lands. + #let _band(row, days, scale) = { + if row.status == "in-progress" { return 0 } + let c = _tl.countdown(days) + let date-band = if c == none { _BANDS - 1 } else { _LEVEL-BAND.at(c.level) } + let rung = priority-rung(row.priority, scale, rungs: _BANDS) + let pri-band = if rung == none { _BANDS - 1 } else { rung } + calc.min(date-band, pri-band) + } + ``` + +3. Replace `_sort-key` (line 144) and its comment block (lines 135-143) with a + version that takes the band. Keep the `\u{ffff}` sentinel and keep the + complementing: + + ```typ + // THE STRING `#panel` ORDERS THE LIST BY. It compares `sort:` ascending and + // reverses the whole list under `descending:`, so every field ahead of the + // date is complemented under `"newest"` — otherwise the reversal sinks + // exactly the rows the ordering lifted. The date itself is not complemented: + // reversing it is what `"newest"` means. + // + // FOUR FIELDS, widest first: the band, then in-progress, then whether the row + // carries a date at all — a scheduled or deadlined todo reads above one that + // is in the band on priority alone — then the date. An undated row keeps + // `#panel`'s own `\u{ffff}` sentinel, and the priority pre-sort is what orders + // those rows among themselves. + #let _sort-key(band, state, when, order) = { + let newest = order == "newest" + let flip(i, n) = if newest { str(n - 1 - i) } else { str(i) } + flip(band, _BANDS) + + flip(if state == "in-progress" { 0 } else { 1 }, 2) + + flip(if when == none { 1 } else { 0 }, 2) + + (if when == none { "\u{ffff}" } else { when }) + } + ``` + +4. Add `"urgency"` as a third legal `order:` and make it `#todo-table`'s + default. At line 236 the parameter currently reads `order: "soonest",` — + change it to `order: "urgency",` and rewrite its doc comment (lines 233-235) + to state the three values and what each one means. Widen the assertion at + lines 318-323 to accept `"urgency"` and name it in the message. + +5. Keep `"soonest"` and `"newest"` behaving exactly as they do now — the old + key, no bands. In the row map at line 389, pick the key by order: + + ```typ + let band = _band(r, if d == none or today == none { none } else { + _tl.days-until(d, today) + }, scale) + ``` + + and then, where `sort-key:` is set: + + ```typ + sort-key: if order == "urgency" { + _sort-key(band, state, stamp, order) + } else { + _sort-key(_BANDS - 1, state, stamp, order) + }, + ``` + + Passing the coolest band for every row under the two old orders makes that + field constant, which leaves the key ordering by exactly what it ordered by + before — in-progress, then dated, then date — with one extra constant + character in front. That is why `"soonest"` needs no second key function. + +6. Project `band` as a plain row field in the same map, beside `when-date`: + + ```typ + band: band, + ``` + + **Not a facet.** `#panel` emits one `data-` attribute per entry in + `facets:` and this is not one of them; the comment at `table.typ` line 387 + already states that rule for `sort-key`. A following bird filters on this + field, and a caller's `render:` can read it. + +7. `descending: order == "newest"` at line 599 stays exactly as it is. + `"urgency"` is ascending. + +8. Do not change the `let ranked = ...` block at lines 360-364. It sorts by + descending priority before `#panel`'s stable sort, which is what puts the + undated rows of a band in priority order, and it already reverses itself + under `"newest"`. + +## When there is no reference date + +`_band` needs `days`, and `days` needs `today:`. With `today: none` nothing is +measurable, so every row's `date-band` is the coolest one and the ordering +falls back to priority bands with dated rows first inside each. This is +correct and needs no special case — but say so in one sentence in the +`order:` doc comment, because a caller who forgets `today:` gets an order that +looks arbitrary rather than an error. Nothing in this package may call +`datetime.today()`; the comment at `table.typ` lines 166-169 says why. + +## Non-goals + +- **No `bands:` parameter.** Filtering rows by band is a separate bird. +- **No `hoist:` parameter.** A site declaring its own band-0 rows is a + separate bird. +- **No band pill group and no band CSS class.** `band` is a field and nothing + more in this bird. Do not add it to `facets:`, `pill-rows:`, `row-class:` or + `todos.css`. +- **Do not touch `#today-panel`** (`/home/lox/code/_fcl/rookery/todos/0.1.0/src/today.typ`). + It passes `order: "soonest"` explicitly at line 109 and must keep doing so; + a later bird removes the file outright. +- **Do not touch `priority-rung`** (`tags.typ` line 293) or its `rungs: 3` + default. The date cell's colour wash at `table.typ` line 452 keeps calling it + with the default, because the CSS heat ramp has three steps. The ordering + asks for four. Those are two different questions about the same scale. +- No JavaScript changes. + +## A name that is already taken, and is not a conflict + +`table.typ` line 437 has a local `let band = ...` inside the row renderer, +holding a CSS class name. A row field called `band` does not collide with it — +the local shadows nothing, and `r.band` still reads the field. Leave the local +alone. + +## Comment style + +This repo's `CLAUDE.md` lines 66-98: describe the present, never the history. +No "used to sort by date", no "this replaces", no bird id, no branch name. One +header per file and no interior `// ---- Section ----` dividers. Emphasis +capitals for the one claim in a block that carries it. + +## VERIFY + +Run from `/home/lox/code/_fcl/rookery/todos/0.1.0`: + +1. Rewrite the `_sort-key` assertions in + `/home/lox/code/_fcl/rookery/todos/0.1.0/test/units.typ` lines 546-572. + They assert the old two-part key and will not compile — the header comment + at 546 and the seven assertions below it all go. `test/units.typ` reaches + privates through `#import "/src/lib.typ": *` at line 5, so `_band`, + `_sort-key`, `_LEVEL-BAND` and `_BANDS` are all in scope with no new import. + Assert at least: + + ```typ + // The hottest priority in use is band 0 whatever its date. + #assert.eq(_band((status: "open", priority: 4), 200, (4, 3, 2)), 0) + // A deadline tomorrow is band 0 whatever its priority. + #assert.eq(_band((status: "open", priority: 2), 1, (4, 3, 2)), 0) + // The ladders interleave: third-hottest priority, three weeks out. + #assert.eq(_band((status: "open", priority: 2), 21, (4, 3, 2)), 2) + // Neither ladder says anything: the coolest band. + #assert.eq(_band((status: "open", priority: 0), none, (4, 3, 2)), 3) + // In progress outranks both ladders. + #assert.eq(_band((status: "in-progress", priority: 0), none, (4, 3, 2)), 0) + // Inside one band a dated row reads above an undated one, and band 0 reads + // above band 1 whatever the dates say. + #assert(_sort-key(0, "open", none, "urgency") < _sort-key(1, "open", "20260101", "urgency")) + #assert(_sort-key(0, "open", "20261231", "urgency") < _sort-key(0, "open", none, "urgency")) + // Complementing survives the reversal `descending:` applies under "newest". + #assert(_sort-key(0, "open", "20260825", "newest") > _sort-key(1, "open", "20260825", "newest")) + ``` + +2. `just test` — `typst compile --features html --root . --format pdf test/units.typ /dev/null && ./test/panics.sh`. Must pass. + +3. `just check` — `rheo compile demo/rheo && ./demo/rheo/check.sh`. Must pass. + If `check.sh` asserts a row order that this change alters, fix the assertion + to the new expected order rather than reverting the change — but read the + assertion first and say in your landing message which one moved and why. + +4. `/home/lox/code/_fcl/rookery/todos/0.1.0/demo/rheo/content/index.typ` line + 124 narrates "An OVERDUE todo — a deadline already behind `TODAY`. + `#todo-table` lists it first". Still true under the new order (overdue is + `urgent`, so band 0, and dated rows sort earliest-first inside a band), so + leave it — unless the demo happens to also carry a hotter-priority undated + todo, in which case correct the sentence. + +5. In `/home/lox/code/_fcl/rookery/todos/0.1.0/readme.md`, rewrite the + ordering prose in the `## Grouped pills: #todo-table` section (header line + 381; the relevant paragraphs run roughly 491-532) to describe the four + bands, the two ladders, the within-band order, and the three `order:` + values. Include the band table from this bird. Do not touch the + `## A day view: #today-panel` section at line 533, and do not confuse + `#todo-table`'s `order:` with `#todo-slipshow`'s unrelated `order:` + documented at readme lines 626-711. + +6. `just test-js` — `node --test test/*.test.mjs`. Must pass unchanged; no + JavaScript is in scope. \ No newline at end of file diff --git a/.birds/ideas/rk-browser-smoke-tests-for-the-search-modal-7e1a2d1b.md b/.birds/ideas/rk-browser-smoke-tests-for-the-search-modal-7e1a2d1b.md new file mode 100644 index 00000000..91d5bd05 --- /dev/null +++ b/.birds/ideas/rk-browser-smoke-tests-for-the-search-modal-7e1a2d1b.md @@ -0,0 +1,138 @@ +--- +id: rk-browser-smoke-tests-for-the-search-modal-7e1a2d1b +short-id: 7e +title: Browser smoke tests for the search modal +priority: 1 +labels: +- fix-safari-search-modal +- test-browser +deps: +- blocked-by:rk-wire-search-triggers-before-the-index-7974cadf +- blocked-by:rk-stand-up-the-shared-browser-harness-cbc3ae56 +closed: true +--- +A reader on Safari (macOS and iPad) reported that clicking the search button in a +rookery site's header does nothing, while the same click on Firefox and Brave +opens the modal. `#search-modal`'s actual behaviour — does the trigger open the +dialog, does it render rows, does Escape close it, does a failed index leave a +dead button — is asserted nowhere, because all 18 of this package's suites run +under node with linkedom, which has no ``, no top layer, no layout and no +`fetch`. Diagnosing that report meant standing a browser harness up by hand. + +This bird pins the modal's behaviour in three real engines, WebKit among them. + +WHAT THE HAND-BUILT HARNESS ALREADY ESTABLISHED, so the suite starts from +measured facts rather than guesses. Driven under Playwright's WebKit 26.5 (the +Safari 26 engine), Chromium and Firefox against the built HTML of two real +rookery sites, the modal opens at an identical rect in all three, on a 1280x900 +desktop viewport and on an emulated iPad with a touch tap. A top-layer `` +was also checked against every ancestor style that could plausibly clip or +contain it — `overflow: hidden`, `transform`, `filter`, `backdrop-filter`, +`contain: paint`, `will-change`, `isolation`, `opacity` — and WebKit escapes all +of them exactly as Blink and Gecko do. Aborting or 500-ing the index fetch, by +contrast, makes the trigger inert in all three engines with nothing logged. That +last one is the reported defect, and it is fixed by the bird this one is blocked +behind. + +THE FIXTURE IS THE BUILT DEMO, not a hand-written page. +`/home/lox/code/_fcl/rookery/search/0.1.0/demo/rheo/content/index.typ` places +`#search-bar()` at line 79 and `#search-modal()` at line 80, and that project is +compiled by its own `just check` +(`/home/lox/code/_fcl/rookery/search/0.1.0/demo/rheo/Justfile`) into +`demo/rheo/build/html/`. Testing what rheo actually emits is the point; a +hand-written approximation of the markup would drift from it silently. + +DEPENDS ON two birds: + +- the browser-harness bird, which supplies `loadPlaywright`, `serve`, + `requireBuild` and `run` from + `/home/lox/code/_fcl/rookery/test/browser/harness.mjs` and a root + `just browser` recipe that runs every `*/*/test/browser/*.mjs`; +- the bird that wires the search trigger before the index loads, without which + case 3.5 below has no behaviour to assert — a failed index currently leaves the + trigger unwired and the empty state reading `No match found`. + +Touches: /home/lox/code/_fcl/rookery/search/0.1.0/test/browser/modal.mjs + +## Steps + +1. Create `/home/lox/code/_fcl/rookery/search/0.1.0/test/browser/modal.mjs`. + Import `serve`, `requireBuild` and `run` from + `../../../../test/browser/harness.mjs`, and `assert` from + `node:assert/strict`. + +2. Guard the build first: + + ```js + const ROOT = new URL("../../demo/rheo/build/html/", import.meta.url).pathname; + requireBuild(`${ROOT}index.html`, "cd search/0.1.0/demo/rheo && just check"); + ``` + +3. Serve `ROOT` and open `index.html` per engine via + `run("search-modal", async ({ newPage }) => { ... })`. Assert these five things + and nothing else. + + 1. **The trigger opens the modal.** Click `.rookery-search-trigger`, assert the + `dialog[data-rookery-search]` reports `open === true`, matches `:modal`, has + a `getBoundingClientRect().height` greater than zero, and that + `document.activeElement` is the `.rookery-search-input` inside it. Assert no + `pageerror` and no console error was recorded. + 2. **Rows render for a query.** Type a query into the modal's input and assert + at least one `.rookery-search-row` appears in `.rookery-search-list`. Pick + the query by reading a real row's title off the page first, rather than + hardcoding a word the demo content could change out from under. + 3. **Escape closes it.** Press `Escape`, assert `open === false`. Note that + `modal.js` handles Escape explicitly rather than leaving it to the dialog's + own cancel algorithm, because a `type="search"` input with a value consumes + the first press — so do this with a NON-EMPTY query in the field, which is + the case the explicit handler exists for. + 4. **A touch tap opens it too.** Repeat case 1 in a context created with + `{ hasTouch: true, isMobile: true, viewport: { width: 820, height: 1180 } }`, + using `.tap()` rather than `.click()`. An iPad was half the bug report. + 5. **A failed index still opens the modal, loudly.** Route the index request to + `route.abort()` before loading the page — the built island carries + `data-rookery-search-src`, so the request is the JSON file it names. Then + click the trigger and assert three things: the dialog opens + (`open === true`); the pane's text contains `Search index unavailable`; and + at least one captured console message of type `warning` mentions + `@rookery/search`. Before the bird this one is blocked behind, all three of + those fail — the trigger is never wired at all — which is exactly the + reported Safari symptom. + +## Non-goals + +- Do NOT change anything under `/home/lox/code/_fcl/rookery/search/0.1.0/src/`. + This bird observes; if an assertion fails, report it rather than fixing it. +- Do NOT assert anything about `#search-bar`'s dropdown or the preview pane's + fetched content. Those belong to the separate search-dropdown browser bird, + which writes `test/browser/bar.mjs` — a different file, so the two can fly at + once. +- Do NOT test `#panel` or `#filter-panel`, which are also on the demo page. +- Do NOT change `demo/rheo/` content or its `check.sh`. +- Do NOT add a recipe to `/home/lox/code/_fcl/rookery/search/0.1.0/Justfile`. The + root `just browser` recipe finds every `*/*/test/browser/*.mjs` on its own. +- Do NOT add visual or screenshot comparison, and do NOT assert exact pixel + positions. Every assertion above is a presence, a boolean, a count, a non-zero + height, or a substring. +- Do NOT touch any existing `*.test.mjs` file, `test/internal.mjs` or + `test/parity.mjs`. +- Do NOT touch `.github/workflows/check.yml`. + +## VERIFY + +1. `cd /home/lox/code/_fcl/rookery/search/0.1.0 && just build` then + `cd /home/lox/code/_fcl/rookery/search/0.1.0/demo/rheo && just check` — builds + the demo into `build/html/` and passes its own assertions, as it does today. +2. `cd /home/lox/code/_fcl/rookery && nix develop -c just browser` exits 0 and + prints `ok search-modal [webkit]`, `ok search-modal [chromium]` and + `ok search-modal [firefox]`. +3. Prove the guard works: `mv search/0.1.0/demo/rheo/build search/0.1.0/demo/rheo/build.bak`, + re-run step 2, confirm it exits non-zero naming + `cd search/0.1.0/demo/rheo && just check`. Move it back. +4. Prove case 3.5 bites: temporarily move the `.rookery-search-trigger` click + listener in `/home/lox/code/_fcl/rookery/search/0.1.0/src/search.js` back below + the index `await`, re-run step 2, and confirm the suite fails on the + aborted-index case naming `webkit` first. Restore it. +5. `cd /home/lox/code/_fcl/rookery/search/0.1.0 && just test` — the 18 existing + node suites still pass, proving the glob `test/*.test.mjs` did not pick up the + new file under `test/browser/`. \ No newline at end of file diff --git a/.birds/ideas/rk-browser-tests-for-the-pinboard-f7b22d58.md b/.birds/ideas/rk-browser-tests-for-the-pinboard-f7b22d58.md new file mode 100644 index 00000000..b4b308c0 --- /dev/null +++ b/.birds/ideas/rk-browser-tests-for-the-pinboard-f7b22d58.md @@ -0,0 +1,148 @@ +--- +id: rk-browser-tests-for-the-pinboard-f7b22d58 +short-id: f7 +title: Browser tests for the pinboard +priority: 1 +labels: +- feat-pinboard +- test-browser +deps: +- blocked-by:rk-stand-up-the-shared-browser-harness-cbc3ae56 +- blocked-by:rk-pin-a-card-s-place-to-its-idea-id-836da185 +closed: true +--- +Everything `@rookery/pinboard` does is browser behaviour that `linkedom` cannot see. +Pointer capture, a drag that tracks a cursor, a card clamped to a board whose size +came from layout, a collapsed card shrinking to its handle — none of it exists in a +DOM object graph with no engine underneath. The package's own `test/*.test.mjs` +suites cover the arithmetic and the attribute writes, deliberately, and stop there. +This bird covers the rest, in three real engines. + +## What it depends on, and what each dependency supplies + +**The shared browser harness**, bird `rk-stand-up-the-shared-browser-harness-cbc3ae56`. +It puts `/home/lox/code/_fcl/rookery/test/browser/harness.mjs` in the tree, exporting +exactly four things: + +- `loadPlaywright()` — resolves the three engines out of the nix store or, in CI, out + of `node_modules`. +- `serve(dir)` — an `http` server on an ephemeral port, returning `{ origin, close }`. +- `requireBuild(path, hint)` — exits 1 with `browser: is missing — run + first` when a suite's fixture has not been built. +- `run(suiteName, fn)` — the engine loop. It runs `fn({ browser, engine, newPage })` + against WebKit, Chromium and Firefox in that order, prints `ok []` + or `FAIL []: `, and owns process exit. Suites use + `node:assert/strict` for assertions; the harness supplies none of its own. `newPage` + returns a page that records every `pageerror` and every `console` message of type + `error` into an array the suite can read. + +It also adds a root `just browser` recipe that finds every `*/*/test/browser/*.mjs` +on its own — so this bird registers nothing anywhere, it just adds a file in the +right place. + +**The pinboard itself**, bird `rk-pin-a-card-s-place-to-its-idea-id-836da185` and the three before it. What the suite asserts +against: + +- The package is at `/home/lox/code/_fcl/rookery/pinboard/0.1.0/`, with a demo rheo + project at `demo/rheo/` and a `just check` recipe that runs `rheo compile demo/rheo`. +- The board is `
        `, holding one + `
        ` per note, each with a + `
        ` containing a ` +``` + +`label: auto` renders `value.replace("-", " ")`, which is what the pill block does. That bird also taught `panel.js` to write `aria-pressed` onto EVERY `.panel-pill` carrying the same facet and value, and its click loop already binds every `.panel-pill` inside the panel container — rows included. So a pill you draw inside a row is a fully wired filter with no JavaScript of your own: pressing it filters the list, presses its twin in the block above, and writes to the URL when `sync:` is set. + +**`rk-let-an-idea-row-badge-be-raw-content-a7c93322`** (in `@rookery/core`) made `#idea-row-body`'s `badges:` accept an entry that is CONTENT as well as an entry that is a `(text: .., tag: ..)` dictionary. A dictionary still renders ``; content is placed in the strip verbatim, and the caller owns its element and classes. + +## Decisions already made — do not re-derive + +**One knob, not two.** `badge-pills` turns on both halves at once: the multi-valued facets join the strip, AND every badge in the strip is drawn as a `facet-pill` instead of a chip. They are not separable in any way worth a second argument — chipped tags that are not pressable is the tease above, and pill-shaped state beside chip-shaped tags is the two-conventions problem. + +**`#today-panel` defaults it ON.** That is a second default it changes on the way in, alongside `visible:`. The demo at `demo/rheo/content/index.typ:256` (`#today-panel(today: TODAY, noun: "todos")`) therefore picks the new behaviour up with no edit, which is what the VERIFY below reads. + +**The priority label rule survives untouched.** `src/table.typ:473-474` drops the `priority` facet from the strip when the row draws its priority as a `P` LABEL in the date cell, because the label IS that chip, moved — a row would otherwise say one thing twice at opposite ends. Keep that filter exactly as it is, whatever `badge-pills` is. + +**No CSS fight.** `@rookery/core`'s chip rule (`[data-rookery="row-badges"] > [data-rookery="tag"]`, `core.css:1212-1234`) is UNLAYERED, and unlayered CSS beats layered CSS whatever the specificity — so `@layer todos` could never have restyled a chip into a pill. It does not have to: a `facet-pill` is a `