Skip to content

Worktree naboj editor - #36

Merged
sesquideus merged 57 commits into
masterfrom
worktree-naboj-editor
Aug 26, 2026
Merged

Worktree naboj editor#36
sesquideus merged 57 commits into
masterfrom
worktree-naboj-editor

Conversation

@sesquideus

@sesquideus sesquideus commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Played a game with Claude one late night at the Náboj hut... and then some.

Run the editor with

uv run flask --app tools/editor/app.py run --port 5050

and point your browser to

http://localhost:5050/

sesquideus and others added 19 commits July 28, 2026 21:43
Lightweight Flask tool for editing problem.md/solution.md/answer.md,
meta.yaml, and preamble.md side by side with a live Jinja-rendered
preview and advisory mdcheck linting. Shells out to the real `make
render/naboj/...` targets and core/markdown-check.py rather than
reimplementing any pipeline logic, so preview output can't drift from
the actual build. The render→build (pandoc/TeX/LaTeX) stage is
intentionally out of scope: that needs a full LaTeX toolchain, and
anyone who has one can just run `make` directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a small dependency-free tokenizer (tools/editor/static/highlight.js)
that highlights Jinja (§ … §) tags, math, LaTeX commands, cross-ref
labels, YAML keys/strings/numbers, and the preamble's @j mini-language,
rendered via a transparent-textarea-over-highlighted-<pre> overlay so no
external editor library or CDN is needed. Jinja tags are resolved with
highest priority so they stay visible even nested inside $...$ math
instead of being swallowed by the math span.

Also restricts problem discovery/lookup to directories whose parent is
literally named "problems" — the naive meta.yaml scan was previously
also picking up venue/constants/language config files (e.g.
chem/01/languages/sk) that aren't problems and have a different schema.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Editor: add a Save button (and Ctrl/Cmd+S) that just writes the current
buffers to their real source files via a new /api/save endpoint,
without invoking make render/. Previously the only way to persist edits
was Render, which always also ran the pipeline.

Pipeline: JinjaConvertor.prepare_template() now joins the preamble and
template with exactly one newline regardless of how many trailing
newlines the preamble file has, and treats an empty/whitespace-only
preamble as absent so no leading blank line is introduced. Previously
it unconditionally did `preamble + "\n" + template`, which left a stray
blank line whenever the preamble already ended in a newline (the
common case) and still inserted one even when the preamble was empty.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…l ruler

Source and rendered-output now sit side by side across the full width
(previously squeezed to a third each alongside meta/preamble), so
120-character lines have room to stay unwrapped. meta.yaml and
preamble.md move to a fixed-height strip along the bottom, side by
side with each other instead of stacked.

Also adds a subtle vertical rule at column 120 to every code pane,
drawn as a background-attachment:local gradient so it stays aligned
with column 120 even when scrolled down a long file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a draggable gutter between the source/output row and the
meta.yaml/preamble.md strip, backed by a --context-height CSS custom
property so both the grid template and the drag handler share one
source of truth. The chosen height persists across reloads via
localStorage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Everything in the Náboj build so far starts at a whole volume, which is no use
while authoring: `make output/naboj/phys/25/languages/cs/booklet.pdf` fails on a
missing `evaluators.jtex` and tells you nothing about the problem in front of
you. The finest-grained artefact was a bare TeX fragment with no
`\documentclass`, only ever `\protectedInput`-ed by a booklet template.

    make output/naboj/phys/29/problems/banked-turn/sk/standalone.pdf

renders one problem -- statement, extra, solution and every answer variant that
exists -- as an A4 document in about three seconds.

`standalone.jtex` extends `base.jtex`, not `base-booklet.jtex`: everything
volume-shaped lives in the latter (logo header, competition name, volume date,
`i18n[...]`) and none of it says anything about the problem. What remains is a
context of five strings taken from the path, so `BuilderNabojStandalone` is
modelled on `core.builder.standalone` rather than `BaseBuilder` -- no meta.yaml,
no schema, no `validate_repo()`, no `git rev-parse`. That is what lets a preview
build for a volume that cannot: all five languages of 25/emergency render while
the 25 booklet still dies.

Geometry does match the booklet, since line breaking and overfull boxes are what
an author is checking and those depend on the text width. Equations lose the
`\arabic{problem}.` prefix that would otherwise number everything `0.1`, and
each part is guarded by `\IfFileExists` so the document agrees with whatever the
Makefile actually built.

Also set `lang ?= sk`. The `.tikz` and `.gp` picture rules both referenced
`$(lang)`, which nothing ever assigned, so they passed an empty argument and
died on `invalid choice`. This was invisible because a stale intermediate in
`build/` let make skip the broken rule -- `make -B` on any `.tikz.tex` shows it,
and 29/chopper could not be built from scratch at all. Pictures live at the
problem level and are shared by every translation, so one fixed language is the
only coherent choice; it stays overridable as `make lang=en`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editor rendered Markdown to Markdown and stopped there, on the grounds that
the LaTeX stage needs a full TeX toolchain. Now that one problem can be built on
its own, the pane on the right can show the actual page.

Compile (Ctrl/Cmd+Enter) writes the dirty buffers, runs
`make output/naboj/<problem>/<language>/standalone.pdf` and reloads the PDF in
place. The browser's own viewer does the rendering, so there is nothing to
vendor; `#pagemode=none` keeps its outline sidebar shut, and reloading the frame
rather than reassigning `src` keeps the reader's scroll position, which a src
swap always resets to page one.

A compile that fails leaves the previous PDF on screen with a red "showing last
good render" badge and switches to the Log tab, which carries the real message
("File ended while scanning use of \frac", with the line number). That is why
successful builds are copied to `build/.editor-preview`: `-halt-on-error` can
leave a truncated file in `output/`, and a blank pane says much less than a
stale one that admits it is stale.

Every file of a problem is now open at once, one tab each, dirty-marked
individually -- previously only `problem`, `solution` and the non-translated
answers were reachable, so `problem-extra` and `answer-extra` could not be
edited at all. `answer-extra` was also misfiled as non-translatable, though
NABOJ_TRANSLATABLE covers it and its file lives under the language directory.
Only dirty buffers are sent, so make stays incremental; saving cannot create a
file that does not exist yet.

`preamble.md` support is gone with the preambles themselves, which frees the
bottom strip for meta.yaml alone, and the source/output split is now draggable
like the strip above it.

Note a one-problem document cannot resolve a reference into another problem --
28/nevera cites `eq:archery:sincos`. LaTeX prints `??` and warns; the warning
reaches the Log tab, and the build still succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`lang ?= sk` alone was too blunt. A picture is Jinja-rendered before it becomes a
PDF, so its language decides number formatting -- pick wrong and a Slovak figure
gets a decimal point where it wants a comma.

Resolve it per picture instead, most specific first: an explicit `make lang=en`
always wins, then the language directory the picture sits in, then `$(lang)`.

Most pictures are problem-level, shared by every translation, with nothing in the
path to infer from -- those keep taking `$(lang)`, so specifying it by hand on the
command line works exactly as before. But a picture may also belong to a single
translation, and chem/03/johan-august/sk/puzzle.tikz is one; its own directory
names its language, and no volume, competition or problem directory is named
after a language code, so matching a path component against SUPPORTED_LANGUAGES
is unambiguous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split the window into two columns instead of two rows plus a strip: everything
you edit on the left, the compiled page on the right, spanning all three rows.

meta.yaml moves from a full-width strip under both panes into the bottom of the
left column, stacked under the source. It stays next to the prose, which is the
point of having it visible at all -- `values:` and `derived:` are what the
source's `(§ … §)` references resolve against -- but it no longer costs the
preview any height. On a 950px window the pane now reaches the fourth equation
of 28/nevera where it previously stopped at the second.

The column gutter resizes source and meta together, since they share the column.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preview printed `answer`, `answer-extra`, `answer-also` and
`answer-interval` as four separate sections. That is not what an answer is. It
is one run-on line: the answer, then each extra glued on after a `\!` so the
negative thin space pulls back the newline every pandoc fragment ends with and
the comma sits tight against what precedes it; interval and also additionally
carry a localised lead-in and a closing full stop; and `answer-extra` gets the
comma only when `answer.md` is non-empty, so a problem whose whole answer lives
in the extra does not open with a stray comma. Reading it off the four sections
told an author almost nothing about the line that will be printed.

Rather than restate that in a second place and let the two drift, move it out of
`blocks/answer.jtex` into `blocks/answer-body.jtex` and include it from both.
The extraction is verbatim, and 28 and 29 regenerate byte-identical `answers.tex`
and byte-identical booklets apart from the colophon's own hash and timestamp.
For 28/nevera the standalone now emits the same sequence as the booklet, modulo
leading indentation TeX discards at line start.

The one behavioural change is a guard: the emptiness test used to call
`file_size` on `answer.md` unconditionally, which throws if the file is absent.
It never is in a booklet, where the validator demands one, but a half-written
problem in the editor has no such promise.

This does cost the preview the i18n context it had done without, since the
lead-ins are translated -- but that is a read of `.static/i18n/*.yaml` and
nothing more; no volume, no problem tree, nothing to validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preview said "Problem", "Solution", "Answer" in English over Slovak or
Hungarian text. The booklet already names those sections in
`i18n[language].section`, and the preview now reads them from the same place:
Zadania / Vzorové riešenia / Výsledky, Feladatok / Megoldások / Válaszok. Every
language a problem is written in has an i18n file, in both phys and chem, so
there is no gap to fall through.

`problem-extra` loses its own heading in the process, and should: i18n has no
name for it because the booklet has never treated it as a section, only as a
continuation of the statement. So `blocks/problem.jtex` gives up its body to
`blocks/problem-body.jtex` the same way the answer did, and the preview includes
it -- problem and extra under one heading, exactly as printed. booklet, answers,
solutions, tearoff and online all regenerate identically apart from the
colophon.

That leaves the preview built from the same three pieces the booklet is: a
problem body, a solution, an answer body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Folding problem-extra into the statement was wrong, and the previous commit
justified it by looking only at the booklet. The tearoff is where the file earns
its keep: `tearoff.jtex` runs a second pass over every problem after the closing
page and gives each extra a sheet of its own, numbered `N*` and vertically
centred. Being torn off separately is the entire point of the file, so a preview
that silently glues it onto the statement hides the one thing worth checking.

It gets its own page here too, headed with the tearoff's `*` marker -- `Zadania*`
over a fresh page. The booklet keeps appending it inline, because a booklet has
no sheets to hand out.

`blocks/problem-body.jtex` goes away with it. It existed to keep the preview and
the booklet from drifting on this, and now they are deliberately different;
`blocks/problem.jtex` takes its two lines back and booklet, answers, solutions,
tearoff and online are all byte-identical to before either commit touched them.

Verified with a temporary problem-extra in 29/apple-jerky, since the only five in
the repository are chem problems whose empty meta.yaml cannot render yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The picker was built by walking for `meta.yaml`, so a problem without one did
not exist as far as the editor was concerned. That hid 69 of the 144 chem
problems -- all 36 of chem/02, whose volume vanished from the dropdown
entirely, and 33 of chem/01 -- along with phys/10/southeast. Exactly the
unconverted ones, which are the ones worth opening.

Walk the directories under `<competition>/<volume>/problems/` instead and report
`has_meta`, which the picker shows as a warning sign beside the name and the
bottom pane as "meta.yaml (does not exist yet)". Saving creates the file;
`write_files` already wrote meta.yaml unconditionally, it just had nowhere to be
called from.

Compiling one of them used to die with `No rule to make target`, naming the PDF
rather than the file actually missing -- every render rule takes the problem's
meta.yaml as a prerequisite, so make cannot build the chain and blames the far
end of it. Say so directly instead, and drop the "(exit null)" from a failure
that never reached make.

Note this only exposes them; 37 further chem metas exist but are empty, so those
still fail schema validation on `authors:` and `tags:`. Converting them is the
same job volume 25 needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A problem that cannot be built is fine; one that cannot say why is not. Every
authoring mistake buried its message: an unconverted meta.yaml put "Missing
keys: 'authors', 'tags'" second-to-last in a twenty-frame traceback, under a
pretty-printed schema; a bad equation put the TeX error under the package
banner; `MissingVariablesError` interpolates the whole template before naming
the variable, so the message opened with the problem statement and the useful
list was several lines down.

Pull out the one line worth reading and put it in the status bar and at the head
of the log, which still holds everything:

    no meta.yaml     meta.yaml does not exist
    empty meta.yaml  SchemaMissingKeyError: Missing keys: 'authors', 'tags'
    broken equation  line 55: File ended while scanning use of \frac
    unknown tag      Missing variables: ['nope']
    malformed YAML   ParserError: while parsing a flow sequence

TeX wraps its log at 79 columns and used to chop that fourth message mid-word
("File ended while sc / anning use of \frac"), so the editor runs make with a
wider `max_print_line`. It changes no build output, only the log.

And `.DELETE_ON_ERROR`, which is the real find here. `argparse.FileType('w')`
opens the output file when the arguments are parsed, so the renderer and the
convertor both truncate their target before doing any work: a meta.yaml that
failed validation left a 0-byte `render/.../solution.md` behind, make saw a
target newer than its prerequisites, and the *next* build succeeded with an
empty document -- three headings, no text, no error anywhere. chem/03's
johan-august was doing exactly this. A silent empty success is worse than any
unclear message, and one line cures it for every rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Not "Zadania" and "Vzorové riešenia" over each part, but the problem's number in
the same 7mm framed box, run in with the text -- the `\titleformat` copied
verbatim from `base-booklet.jtex`. Side by side with page 9 of the 28 booklet
the two now agree down to where the statement breaks across its three lines,
which is the point of a preview: it is for judging how a problem will look in
print, so its headings have to be the printed ones. Naming the sections was
answering a question nobody asks about a document that holds one problem.

The number is read straight from the volume's `problems:` list rather than
through `ContextVolume`, which validates the whole volume meta and would refuse
one that is merely incomplete -- 18 has no `problems:` list at all. Any failure
falls back to `?` in the box, because an unnumbered preview beats no preview.
problem-extra keeps the tearoff's marker and reads `34*`.

i18n stays in the context: `blocks/answer-body.jtex` still needs it for the
interval and "also accept" lead-ins, which are prose and remain translated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A .gp is Jinja-rendered like everything else -- `render/naboj/%.gp`, with the
problem's meta.yaml as its context -- and becomes a figure in the very document
shown on the right. leaky-graph's reads `(§ tfull.mag §)`, `(§ hmax.mag §)` and
`(§ const.gforce.approx.mag §)` off the same values the prose does. Editing it
somewhere else while watching the PDF here made little sense. The .dat tables a
script plots are its prerequisites, so they come along.

They are named by path relative to the problem rather than by a target name,
since there can be any number and they may sit in the language directory, which
means the name is now arbitrary text rather than one of seven: `aux_path`
insists it stay inside the problem and carry an editable extension. Traversal,
absolute paths, and writes aimed at meta.yaml or problem.md are all refused.

Render works on a .gp and shows the expanded script; on a .dat it says the file
is copied verbatim, and the style checker declines anything that is not
Markdown. Both buttons grey out rather than failing when pressed.

They get their own highlighting: the .md grammar would read `# Flow rate` as a
heading and `$1` as maths, but plain text would hide the `(§ … §)` tags, which
are the whole reason to edit the file here.

Editing time.gp and compiling rebuilds the graph and the PDF in 2.3s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reload used to drop you back on the first problem of the first competition
with an empty preview pane. The URL now names the open file --

    #phys/28/problems/leaky-graph/sk/time.gp

-- so a refresh returns to the same problem, language and tab, and the same URL
can be bookmarked or pasted to someone else. A problem key is always four
segments and a language never contains a slash, so the rest is the file, which
lets `sk/data.dat` keep its shape without escaping. Written with `replaceState`:
switching tabs should not fill the history with entries that send you somewhere
else when you meant to leave the page.

The scroll offset comes back too, per file, and so does the compiled PDF -- it
outlives the browser session in the cache, so there is no reason to show an
empty pane and make you recompile to see what you were just looking at.

Nothing auto-saves, as asked, so leaving with edits in the buffers now warns
rather than dropping them silently.

Two things fell out of testing this. `loadProblem` assigned the volume and
problem selects rather than repopulating them, so arriving from a bookmark in
another competition selected nothing at all -- invisible until now, because
nothing could arrive anywhere but the first problem. And a stale or hand-typed
URL rejected by the server left an unhandled rejection in the console; it says
"No such problem" in the status bar instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the editor had been keeping to itself, or working around.

The XFAIL fall-through rules printed "Incorrect fall-through rule called" and
then exited 0. The recipe does not produce its target, so reporting success only
moves the failure downstream, where make blames a rule that is not the one at
fault -- a problem with no meta.yaml lands here, and the message you got named
the PDF. They exit 1 now. Both booklets still rebuild from scratch with -B, so
nothing was relying on the pretence. While there: the warning was painted with
`$(c_err)`, which this makefile does not define, so it came out unpainted; the
variable is `c_error`.

`max_print_line` moves from the editor's environment into the `xelatex` define
and the three direct invocations, so a hand-typed `make` gets the whole error
too -- "File ended while scanning use of \frac", not "File ended while sc /
anning use of \frac". It affects what is printed, never what is typeset, and the
editor no longer needs an environment of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sesquideus sesquideus self-assigned this Aug 6, 2026
sesquideus and others added 8 commits August 6, 2026 19:25
The editor knew one shape of repository: a problem is four path segments deep,
under a literal `problems/`, with a directory per language. A seminar problem is
five deep with no languages; a scholar handout is four, holds its own `text.md`,
and holds a directory per problem which is five. None of that fits, and none of
it belonged hardcoded in `tools/editor` anyway.

Each module now ships `modules/<name>/editor.yaml` saying where its units live,
which files they hold, and which document previews one:

    units:
      - glob: '*/*/problems/*'
        targets: [problem, problem-extra, solution, answer, ...]
        translated: [problem, problem-extra, solution, answer-extra]
        preview: 'output/naboj/{unit}/{language}/standalone.pdf'

Nothing in the editor names a module any more, so a fourth needs a descriptor
and no code. Scholar declares four unit kinds and no languages; seminar one.
Neither has a one-problem document, so both preview `{parent}` -- the round's
solutions, the whole handout -- which is what those problems are printed in.

The picker follows suit: one select per path level, generated from the module's
unit tree, skipping levels with a single choice. Naboj still reads competition,
volume, problem, because its literal `problems/` collapses; seminar shows four
because FKS is its only competition. A node that is both a unit and a parent --
a scholar handout -- offers `·` for itself alongside its children. URLs keep
working across the differing depths by matching the longest unit that prefixes
the hash.

Unit paths are validated by membership in the discovered set rather than by
inspecting the string, which settles traversal at the same time: no glob yields
a path outside the module's source root.

Discovery skips dotted directories. `FKS/.git/logs/refs/heads` is five segments
deep and matches a seminar problem's glob perfectly.

Both new modules compile through to a PDF target and fail there, for reasons of
their own that predate this and reproduce under plain make: seminar hits
`Misplaced \noalign` in solutions-full, and scholar's renderer schema rejects the
meta.yaml its own handouts carry (`Wrong key 'date'`, `Missing key: 'title'`).
The editor states both in one line, which is the first time either has been easy
to read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Previewing a seminar problem by building its whole round, or a scholar problem by
building its whole handout, was the cheap answer and it showed: slow, and mostly
other people's problems. Both modules get what Náboj has -- one unit, one PDF.

Neither extends its module's `base.jtex`. Unlike Náboj's, those files define the
booklet's page furniture inline, so extending them would demand a whole
competition or course context -- logos, founding years, addresses -- to render
pagestyles the document never uses. Each standalone takes the class, the
geometry and the titleformats from its base verbatim, and a context of a few
strings from the path. All three modules turn out to agree on
`\setActive{\rootDirectory/<module>/<unit>}`.

Seminar can do better than Náboj on one point: a problem's round and number are
segments of its own path, so the preview sets the counters and prints the real
`3.7 Automatický Patrik` the booklet will. Scholar's one template serves both
its depths, choosing `text` or `problem`+`solution` from the length of the path.

`truepath` moves from modules/naboj/module.mk to the root Makefile. It was
already shared -- every module.mk is included into one makefile -- but seminar
reaching into Náboj's file for it was only working by accident.

Scholar's renderer schema had to be fixed for any of this to run: it required
`title` and permitted nothing else, which matched 25 of the 163 meta.yaml files
in the tree. 43 are empty, 27 pair `title` with `date`, 26 carry a `deadline`,
26 a `name`, two a `bonus`. Every other unit failed to render, so no scholar
document could be built at all -- `handout-solved.pdf` was equally dead and
builds again now. The content predates the schema and is the source of truth, so
the schema follows it: everything optional, all 163 validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit widened scholar's front matter until everything validated,
which let a handout go undated and homework go without a due date. The
distinction was real and worth keeping:

    handouts/<issue>   date required, title optional        41 sheets
    homework/<issue>   deadline required                    26 sheets
    <issue>/<problem>  title, name, bonus -- all optional   96 problems

A single schema for all of scholar could only ever be the union of those, so it
is now chosen per file from where the file sits: `<kind>/<issue>/meta.yaml` is a
sheet, one segment deeper is a problem inside one. All 163 still validate, and a
handout missing its `date`, homework missing its `deadline`, or a handout
carrying a `deadline` are each rejected again.

`title` and `name` both stay accepted for problems. They appear 25 and 26 times
across the tree, so one of them is a rename that never finished -- not something
to settle by declaring the other invalid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sources were renamed to `title` rather than the schema taught to accept
both, so the alternative goes. All 168 unit metas still validate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seminar's picker opened on volume 30 with no sign of which repository it came
from: the competition level had one value, FKS, so it was collapsed away. FKS is
not the only seminar -- the others simply are not checked out here -- and a
picker that hides the choice because of what happens to be on disk gives no hint
that they exist, nor anywhere to pick them once they are.

What may be collapsed is now a property of the descriptor rather than of the
tree: a level disappears only where every unit kind pins it to a literal and
they all pin it to the same one. That is Náboj's `problems/`, and nothing else.
Scholar pins its third level too, but to `handouts` in one kind and `homework`
in another, so that one was being wrongly hidden as well -- STA has only
homework -- and is now a working switch between the two.

Each level is named in its module's descriptor, so the selects carry
`competition`, `semester`, `round` as tooltips rather than being four
indistinguishable dropdowns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`(§ eq.foo §)` wrapped the fragment in dollars, which made it useless for the
thing a named fragment is most wanted for: building a larger expression out of
a piece. 29/weird-scale shows the bind -- it splices `eq.res` into the middle of
an aligned block and had to write `$(§ eq.res §)$` to do it, producing `$$` in
the middle of `$$`, which LaTeX tolerated and typeset wrongly.

How an equation is delimited is the filter's business, not the fragment's:

    (§ eq.foo §)        the LaTeX as written, no delimiters
    (§ eq.foo|raw §)    the same, said explicitly
    (§ eq.foo|inl §)    $…$
    (§ eq.foo|disp §)   $$\n    …\n$$ {#eq:foo}   (unchanged)
    (§ eq.foo|align §)  aligned block            (unchanged)

`|inline` becomes `|inl` as asked; nothing in any source used the old name. Only
one reference in the whole tree was bare, and it is the one that wanted this.
The 153 that carry `|disp`, `|align` or a punctuation shorthand are untouched,
and both booklets, the tearoff, the answer sheet and standalones in all three
modules build.

Punctuation is still refused on anything that is not a display block, now
including a raw include, which has no math to put it inside of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Getting volume 28 to zero violations turned up six rules that were wrong rather
than sources that were. Together they accounted for 30 of its 224 reports, hid
47 more, and mismeasured every line.

Fixed here, none of them 28-specific:

- `tws` could never fire on a trailing space. `(?! )[ \t]$` evaluates the
  lookahead at the position `[ \t]` is about to consume, so a space failed its
  own guard and only a tab was ever caught. 14 files in volume 28 had trailing
  whitespace and passed -- including 19 lines with the *two* trailing spaces
  that make a Markdown hard line break, which were forcing line breaks in the
  PDF.

- `LineLength` counted the newline, so the effective limit was 119 and every
  line of exactly 120 characters was flagged -- inconsistently, since a final
  line without a newline got the full 120. It also had no idea about pipe
  tables, where the limit is unsatisfiable: a table row has no continuation
  syntax, and stripping every scrap of alignment padding from volume 28's
  `truth-or-dare-celestial` tables would still leave 12 rows over. Table rows
  are now exempt.

- `encoding` checked UTF-8 but not line endings, so 40 wholly-CRLF files went
  unreported. Reported per file rather than per line, since a file that has CRLF
  has it everywhere.

- `sws` exempted siunitx's list syntax only for a numeric argument with no
  option group, so `\Coord{R;H}` and `\qtylist[list-units=single]{4;2;...}` were
  both flagged -- 27 reports in volume 28. The exemption now matches the whole
  argument, and `[^}]*` rather than `[^};]*` so it reaches back past the earlier
  semicolons of a list to the opening brace.

- `uni` had plain ASCII `~` in with the curly quotes and long dashes, so every
  `~~strikethrough~~` was a "fancy Unicode dash". It is not moved to a rule of
  its own: the only lone `~` anywhere in `source/` is in `~user` URL paths, so
  such a rule would report nothing but false positives.

- `DoubleDollars` rejected `$$]`, which is how a display equation inside a
  footnote closes, with the interrupted sentence carrying on after it. Valid
  pandoc, nine reports.

Separately, `mzm` and `tht` are Slovak spelling rules that ran on every
translation, so a Czech file using `tohoto` -- the correct Czech word, and a
misspelling of Slovak `tohto` -- was reported. They now live in a
`language_errors` table keyed by the language directory in the path, matched
against `i18n.languages` rather than a fixed index so it works for `seminar`'s
different layout too.

Expect higher counts elsewhere: the `tws` fix finds real trailing whitespace
that was always there (phys/27 155 to 158, phys/29 34 to 41, chem 284 to 312).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two places where the code was stricter than the declared contract, both found
trying to build volumes 26 and 27 for the first time in years.

`NabojValidator` marks `evaluators.jtex` optional and `module.mk` reaches for it
through `$(wildcard ...)`, but `BuilderNaboj.build_templates` rendered every
`i18n_template` unconditionally. A language without one died on
`TemplateNotFound` and could not build *anything* -- not even its tearoffs,
which are the competition-critical artifact. That is volume 26's `cs`, `es`,
`hu` and `pl`: all forty problem statements translated, no evaluator sheet.
Now skipped with a warning naming the file and the language. Only
`evaluation.pdf` and the venue `answers-modulo` read the result, so nothing else
notices.

While there: render before opening the target. `open(..., 'w')` truncates
immediately, so a template that raised left a 0-byte `.tex` behind, which make
then treats as up to date and never rebuilds -- the same trap `.DELETE_ON_ERROR`
exists to close in the Makefile.

`NabojStandaloneContext` admits no unknown key, so three problems carrying
editorial metadata failed to render at all rather than merely being
unannotated: `26/liquid-crane` (`difficulty`), `27/antifreeze` (`physics`,
`math`) and `27/half-g` (`similar`). `difficulty` and the `physics`/`math` pair
look like one idea before and after being split in two; `similar` names a
related problem. Nothing reads any of them yet, but deleting them would throw
away the only trace, so they are declared optional instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sesquideus and others added 29 commits August 18, 2026 09:45
`spectrum` had been doing two jobs -- thermal radiation and dispersion -- so
`blackbody` takes the first and `spectrum` is narrowed to the second. Three
problems carry `blackbody`, all of which invoke the Stefan-Boltzmann law.

`jerk` was left out of the previous list because its only use, on
`12/lifting-barrel`, was wrong: that barrel is lifted at constant acceleration.
`29/apple-jerky` is the real thing -- constant jerk, and the duck's displacement
is the answer -- so the tag is warranted after all.

53 entries, and still an exact match with what every phys volume uses: nothing
listed goes unused, nothing used goes unlisted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One list could not say who did what. `idea` is whoever thought of the problem,
`problem` whoever wrote the statement, `solution` whoever wrote up the solution.

All three are optional, so a problem records only what is known and a new one is
not forced to write two empty lists -- absent and empty both mean unrecorded. The
migration still wrote all three into every existing file, so the gaps are visible
where they are.

The old flat list is now rejected rather than tolerated, which is deliberate: it
is the only way to be sure nothing was missed. It caught 36 files in
`naboj/chem`, which shares this schema and would otherwise have gone quietly
invalid.

Note the volume-level `authors` in `contexts/hierarchy.py` is a different thing
-- `problems`, `pictures`, `editors`, `head` across a whole volume -- and is the
only one any template reads. This field is read by nothing, so no output moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
84 files under `source/` are symlinks and `Path.write_text` follows them, so any
script that rewrites files per language lands on the same real file once per
link. The second edit, applied at offsets computed from the original text,
destroys it.

This corrupted 88 files while hoisting equations in `phys/27`, which mirrors 78
untranslated solutions that way. Volume 26 escaped only because its four links
sit in `truth-or-dare-elmag`, which has no equations to hoist. Neither the
renderer nor the schema noticed; it was caught by diffing pandoc's output before
and after.

The section says where the links are, why they exist -- a translation nobody has
written yet mirrors its master instead of keeping a copy that can drift, which is
how `28/turntable` came to hold physics `sk` had already corrected -- and what to
do about it: resolve first, write each real file once, and never assume one file
per language when 41 Czech and 43 Spanish solutions are not files at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
German abbreviations want a thin non-breaking space -- `d. h.`, `z. B.`, `u. a.` --
and the two rules banning "typographic corrections" left no way to write one. The
`thc` rule is gone, so `\thinspace` is now the sanctioned spelling.

`\,` stays banned, and the ban is now the useful part: a backslash before
punctuation is a *Markdown escape*, so `d.\,h.` reaches the TeX as `d.,h.` -- a
literal comma inside the word, with nothing to warn you. `\;` and `\.` are the
same trap. The `tgc` message now names `\thinspace` instead of just refusing.

That is why this is not quite what was asked for. Allowing `\,` itself would have
permitted a silent corruption; `\thinspace` is the nearest thing that works, and
LaTeX defines the two as the same command (`latex.ltx`: `\let\thinspace\,`), so
nothing is lost. For a literal `\,` in the TeX there is also the raw-inline form
`` `\,`{=latex} ``, which pandoc passes through exactly -- verbose, but available.

CLAUDE.md gets a section on all of this, including two things worth knowing: U+202F
works but is invisible in a diff and `str.isspace()` is true for it, so a
whitespace pass will eat it; and nine Slovak chemistry problems use U+202F for a
different job, keeping a one-letter preposition off the end of a line, where the
rest of the corpus writes `v\ istej`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were 57 U+202F characters standing in for `\ ` after one-letter prepositions,
from originals written in a word processor. Now fixed in the chem submodule, so the
note says what to do rather than describing a mess that still exists: a preposition
takes `\ `, a normal non-breaking space, and a thin space is only for abbreviations
like `d. h.`

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`19/astroball` is two astronauts throwing balls at each other in orbit and asks
for their relative velocity afterwards. It is conservation of momentum and
nothing else, and the vocabulary had no word for that -- `dynamics` and `energy`
are both wrong for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`math.tex` declares `\dgsbracket@` and then `\ExpectedE` asks for `\dgsbracket`**s**`@`, which is
defined nowhere in the repository. So `\ExpectedE` -- and `\Expected`, which calls it -- raised
"Undefined control sequence" at every use.

Náboj never noticed because `.static/format-override.tex` redefines `\Expected` to use
`\ExpectedChevrons` instead, so the broken branch is dead code there. `scholar` is not so lucky:
`scholar/TA1/.static/format-override.tex` routes `\Expected` straight into `\ExpectedE`, and
`TA1/2022/handouts/04/text.md` calls `\ExpectedE` directly.

Verified by compiling scholar's override against the class: it fails before and renders `E[I_ν]`
after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It calls `\EvalAt`, which is defined nowhere in the repository -- the macro is `\Eval`, which
`\DerivativeEval` immediately above it uses correctly. So `\PDerivativeEval` raised "Undefined
control sequence" at every use, and nothing used it, which is why nobody noticed.

Found because `22/overhead-line/hu` had made the same slip by hand, four times, and took down the
Hungarian booklet and solutions. Same shape as the `\ExpectedE` typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…L changes

Two things kept Farsi from building, and volume 24 is the only volume that has it.

`babel_id` was always the language's own name, and csquotes only defines styles for languages it
knows -- there is no `farsi`, so `\setquotestyle{farsi}` failed with "Quote style not defined" and
took down the Farsi booklet, solutions and answers. A language may now name its style, defaulting
to what it did before; `fa` borrows `english`, whose marks are the ones `fa.yaml` already declares.

The second one hid the first: `build/core/i18n/%.tex` depended on the template but **not on
`core/i18n/%.yaml`**, so editing a language's own definition never rebuilt its `.tex`. The fix to
`fa.yaml` appeared to do nothing until the stale file was deleted by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`chem/01`, `phys/02` and `phys/03` predate the `authors` block and list their people under `orgs`
instead -- sixteen real names in `chem/01` alone. `ContextVolume._schema` admits no unknown key, so
those volumes could only validate by deleting the names or by guessing which of them wrote problems,
neither of which is acceptable. Optional `orgs` keeps them where they are.

Same reasoning as the editorial keys in the problem schema: recording what somebody wrote beats
deleting it to satisfy a validator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Auditing volumes 19 to 29 left behind a scratchpad of one-off regexes and a hand-written
`errors.md`. Every finding had to be rediscovered by writing a fresh sweep, several of those sweeps
were wrong the first time, and the numbers that would have made the state obvious -- who wrote what,
which tags exist, which volumes record no authors -- were visible nowhere. This makes the sweeps
permanent and the numbers visible.

`core/audit/` is the durable part: a registry of 29 checks over one read of a scope's sources.
Reading the whole repository is 4704 files and about a fifth of a second, so there is nothing lazy
and nothing to invalidate -- all 38 scopes across the three modules audit in one second, which is
why the overview can recompute on every request.

`tools/editor` gains `/audit`: every volume in one table, and one volume in detail -- an author
leaderboard split by role, the tag distribution, files by language, what is templated, and a row
per problem with its findings. A problem id links into the editor, which is the whole reason for
living in the same app. `descriptors.py` learns to group units into scopes, and each module names
the level it groups at (`scope: volume`, `scope: year`) so no module is named in code.

Checks that need a build are separate, in `core/audit/build.py`: rendering and compiling a volume
is minutes rather than milliseconds, so the page asks for it and the answer is cached under
`build/.audit/` with a fingerprint of the sources, letting the page say whether they have moved
since.

Every check is tested twice over: once that it fires, and once that it stays quiet on a case that
looks like it and is not. That second half is the point -- each of those quiet cases is a false
positive a hand-written version of the sweep actually produced. `\rightarrow` contains `\right`.
`\right` may sit before a newline, because it is a control word. `\qty[per-mode=symbol]{200}{…}` is
invisible to a regex without an option group, and that one silently produced a whole table of
disagreements that were not disagreements. `\qty{5730 \pm 40}` is an uncertainty and `\qty{e13}` a
bare exponent, both valid. `\qtyrange{0}{30}{\celsius}` has three arguments, so reading it as a
`\qty` makes `30` look like a unit. Whitespace in maths is not significant, but whitespace inside
`\text{}` is.

Two things it found immediately, neither of which I had found by hand:

- `chem/01` had two literal units that kept its solutions from compiling -- one wrapped in
  `\text{}`, one mixed in with a real macro. Fixed in the chemistry submodule.
- the `?` in `authors.idea` is the repository's marker for "not recorded", so it now counts as
  unrecorded rather than being reported 36 times in `chem/04` as a name that would be typeset.

The problem schema admits an `audit: {ignore: [...]}` key, for a finding that is the point of the
problem rather than a defect. Nothing needs it yet.

Verified: 74 new tests, 568 in total; all 38 scopes audit in 0.99s; `phys/28` reports 40 problems,
40 metas, 9 languages and 10 people; the build checks on `phys/24` came back 28 of 28 in 130s and
the result is cached and reported as fresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checks answer "what is wrong with this problem" and are the right shape for a defect: one
finding, one place, one message. They are the wrong shape for "how far along is this", which is what
a column wants -- and which is what makes a volume's state readable at a glance.

`core/audit/status.py` adds a verdict per problem for the four things that take a volume from
converted-on-paper to finished:

    translations   per language: written, absent, empty, or still mirroring its master
    equations      is an equation that appears twice hoisted into `eq:`
    pictures       is every picture that exists included, and every inclusion resolvable
    values         are the numbers a statement shares across translations named in `values:`

Five states, worst first: broken, missing, partial, ok, none. `none` is not a complaint -- a problem
with no pictures has nothing to include, and volumes 19 and 23 have Slovak solutions only, so nothing
there is written out twice and there is nothing to de-duplicate.

Translation status reads symlink *targets*, so a mirrored translation says which language it waits
on: `27/water-level` reports its Czech solution as "mirrors sk" and its Spanish as "mirrors en",
which is the deliberate not-translated-yet marker rather than a hole.

Picture status draws a distinction the first version of `figure-missing` got wrong. A reference to
`x.pdf` where the repository holds `x.svg` still resolves, because make converts one into the other,
so it is a naming slip and now reports as `figure-built-name`; a reference to a file that exists
nowhere is `figure-missing` and prints LaTeX's placeholder. Volume 23's `hall-effect` is the second
kind in five languages. A new `figure-malformed` catches `![](x.svg}`, which pandoc does not read as
an image at all.

The overview gains four marks per volume, coloured by its worst problem, and the detail page gains a
progress panel and four columns. Which makes the shape of the remaining work plain: 28 is the only
volume whose translations are complete, 19 to 27 have every problem partial, chemistry has no
`values:` anywhere, and 21 has no equation labels at all.

22 new tests, 588 in total.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The incantation lived only in a shell history. Records what the two pages are, that the app must run
from the repository root and why (`core/i18n` opens its data by a repo-relative path, so it cannot
import from anywhere else), that port 5000 is often taken, and where the parts live -- the checks in
`core/audit/` rather than in the app, because they are the durable half.

And the rule for adding a check: one function, two tests. Every "must stay quiet" case in
`core/tests/test_audit.py` is a false positive a hand-written version of that sweep really produced,
so writing only the positive test is how they come back.

No make target: this is a thing you run, not a thing you build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a volume did nothing for Kvík, and the page had no way to say why: an exception anywhere in
the render left it looking merely inert. A one-page tool that fails silently costs more to diagnose
than it ever saved.

- `window.onerror` and `unhandledrejection` now write into the status bar, so any script error is
  visible without opening a console.
- a failed scope fetch replaces the "Reading…" placeholder with the error instead of leaving it.
- one row that cannot be drawn costs one row, not the whole table.
- `severities` and `states` fall back to constants mirrored from `core/audit`, so a page served by an
  older process degrades rather than throwing on the first row it draws.

The logic itself is not at fault, and that is now demonstrated rather than assumed: `audit.js` runs
under QuickJS against a stub DOM and the three real API payloads, draws 38 clickable rows, and a
simulated click on the first fetches `/api/audit/scope/naboj/chem/01` and fills the detail pane. The
harness lives in the session scratchpad; it wants `quickjs` as a dev dependency before it can become
a test, which is a decision rather than a commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a volume did nothing: no highlight, no request, no console error. The JS was never at
fault. `.placeholder` in style.css is `position: absolute; inset: 0`, which is correct in the editor
because every pane it appears in is `position: relative` -- `.output-frame` and `.code-editor` both
are. The audit page positions nothing, so "Pick a volume above." resolved its containing block to
the initial containing block and stretched an invisible <p> across the entire viewport. Every click
on the page hit that paragraph, including the ones on the topbar.

audit.css already redeclared `.placeholder` for colour and padding, which is why this was easy to
miss: the rule looked owned when it was only half overridden. It now resets `position` too.

`.placeholder` also gains `pointer-events: none` in style.css. It is informational text and never a
click target, so it should not be able to absorb a click even when it does land where it should.
That is the fix for the class rather than the instance.

Worth recording how long this took to find. A QuickJS harness ran audit.js against a stub DOM and
the three real API payloads and reported 38 clickable rows and a working click, twice, because a
stub DOM has no layout and therefore no hit testing -- it proved the handlers were attached, which
was never the question. What actually located it was the server log: Chrome fetched
`/api/audit/checks` and `/api/audit/overview` on three separate loads and never once fetched
`/api/audit/scope/...`, so the table was drawn and the clicks were being intercepted before they
reached it. Ask what the browser did, not what the code says it should do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things Kvík hit on first use.

No stylesheet ever set a colour for `a`, so every link fell back to the user agent's #0000EE, and
#551A8B once visited. Against the #1e1e1e background those measure 1.77:1 and 1.51:1 -- both far
under the 4.5:1 minimum, and the problem-id column is nothing but links. They are now #79c0ff at
8.57:1, brightening on hover. `:visited` is reset to the same colour rather than dimmed: these links
move around inside the app, so fading the ones already used only makes the table harder to read.
`.tab-link` stays deliberately muted, which needs `.tab-link:visited` to outrank `a:visited`.

The column heads were abbreviations with nothing to expand them: `trans`, `eq`, `pics`, `vals`, and
`no author` and `metas` besides. Each head is now an <abbr> carrying a sentence saying what the
column asks, with a dotted underline to show there is something to read -- the four verdicts also
carry the legend for their marks. Language columns name the language, `uk` becoming
`Ukrainian (українська)` from `core/i18n`, since a two-letter code in a 2.2rem column is the least
readable thing on the page.

The QuickJS harness now reads its payloads from the live API rather than a copy inlined months of
edits ago -- it reported the new language titles as absent, which was the stale copy talking, not the
code. Same lesson as the placeholder: test against what the thing actually serves.

Note `stats_json` changed, so a running editor needs restarting to serve `language_names`; the CSS
and JS come off disk per request and only need a reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The problem table was alphabetical, which says nothing about a volume. The volume meta's `problems:`
list is the running order -- easiest first -- and it is also what `ContextVolume` iterates
(hierarchy.py:129), so it is now the order the page loads in, with a `#` column giving each problem
its competition number. Alphabetical is a select in the panel head, for when you know the name and
not the number; it keeps the numbers visible, so sorting by name still shows that `a12-speed` is
problem 40.

Reading the meta to order by it turns two mismatches into findings, both errors, because the list is
the build list:

- `unit-unlisted` -- a directory the list omits, so nothing ever builds it. `chem/03/zázračný-ľad`
  has full sources and has never been printed. Such rows are tinted and marked with a dash rather
  than a number.
- `listed-missing` -- an id with no directory. This is the worse one: `\protectedInput` writes
  `Missing file ...!` into the page instead of failing, so volume 19 printed that sentence where
  problem 42 should be, in every language, while `make` exited 0. Fixed in the submodule.

A check with nothing to check against stays quiet: a scope whose meta has no `problems:` list is not
a scope where every problem is unlisted, which is what `phys/18` would otherwise report eight times.

`fingerprint()` now sorts by path rather than trusting iteration order, so reordering a meta does not
call every cached build stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things Kvík asked for.

**The audit covers `naboj` alone.** Seminar and scholar are built differently -- no language
directory per unit, no `values:`/`eq:` convention, no volume `problems:` list -- so measuring them
against Náboj's checks reported the difference as the defect. The gate is `audit: true` in
`modules/naboj/editor.yaml`, not a module name in code: the overview lists only audited modules and
the scope endpoint answers `Scholar is not audited` with a 400. The editor still edits all three. If
either ever wants auditing it wants its own checks, which is a module's worth of work and not a flag.
The overview drops from 39 rows to 20, all Náboj.

**Each language column now names its files instead of counting them.** The verdict in `trans` is one
mark for the whole problem; these columns are the ones that say why. Per language, one mark per file:
`P` for the statement and `S` for the solution, bold green when translated, `→` when the file
mirrors another language, `∅` when it exists and is empty, struck through when absent -- struck
rather than coloured alone so it reads without relying on hue. `phys/20/big-brother` now shows `cs`
as statement-only at a glance, and the cell's tooltip spells it out: `cs -- problem.md translated;
solution.md absent; answer-extra.md absent`.

`translation_status` reports every file it finds rather than only the two it judges on. 164 problems
carry `answer-extra.md` and five carry `problem-extra.md`; those are optional, so one language
lacking one is not a defect and does not touch the verdict -- `presence` already reports it as a
warning -- but it is a gap and the column shows it, in lower case (`ae`, `pe`) to mark it optional.
The file order comes from `status.py` through the payload, so the table cannot drift from the verdict
it sits beside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The table joined `idea`, `problem` and `solution` into one deduplicated list, which loses the thing
worth seeing: the three roles are separately unrecorded. A problem crediting an idea and nothing else
is not in the same state as one crediting nobody, and repo-wide that is nearly every problem -- 140
record an idea, 2 record a writer of the statement, 2 a writer of the solution. One column made that
look like 140 credited problems.

Three columns now, named as the meta names them, each with the mapping key in its tooltip. A blank
cell means the role is empty, which is the honest value for an author nobody wrote down; an em dash
means there is no `authors` mapping at all, which is a different thing and now looks different. `?`
is a real value here -- "not classified" -- so it is shown rather than swallowed, as it already is in
the leaderboard.

The id column was headed `problem`, which now belongs to a role, so it is headed `id`.

Also corrected, in the checks and in CLAUDE.md: `\protectedInput`'s red `Missing file` box is
deliberate. `core/latex/utilities.tex` sets it through `\errorMessage` precisely so a hole in a
booklet cannot be missed, and the build exiting 0 is the same decision -- one absent file should not
cost the other 43 problems. Volume 19's fault was the stale `problems:` entry alone, and what it cost
was noticing, not correctness. `listed-missing` earns its keep by naming the entry in the sources
instead of leaving it to be found on page 42.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-language cell packed several marks into one box -- `PSae` -- which was compact and not a
table. Each file now has a real column, grouped under its language by a two-row header: the language
spans its files on the top row, the files name themselves underneath, and everything else spans both.
A cell holds one glyph: ✓ written, → mirrors another language, ∅ exists but empty, · absent, — no
directory for this language.

The vocabulary comes from `module.mk` rather than from a list in `status.py` or from whatever happens
to be on disk. `NABOJ_TRANSLATABLE` (`problem`, `problem-extra`, `solution`, `answer-extra`) lives
inside `<language>/`; `NABOJ_NONTRANSLATABLE` (`answer`, `answer-also`, `answer-interval`) lives beside
the unit and gets its own group, `beside the problem`, since it is one file per problem and not one per
language -- so nothing there is untranslated and nothing is missing when a problem has none.

The route is the module descriptor, which already documents itself as mirroring those two families,
so `core/audit` still names no module. Two tests pin the mirror: one that the descriptor lists exactly
what module.mk's `foreach` lines build, one that `core.audit`'s fallback copy matches the descriptor.
Adding a file to module.mk can no longer silently cost it a column. A third test keeps an unexpected
file -- one no rule defines -- from being hidden, because a table that omits what it did not expect
lies.

Each volume narrows the vocabulary to what it has: `chem/03` shows `P pe S ae`, `phys/20` shows
`P S ae`, and a volume with no extras shows two columns rather than four empty ones.

This is how the nineteen empty `answer.md` files turned up, recorded in the submodule's errors.md.
Fifteen are the intended shape -- the answer is prose and lives in a per-language `answer-extra.md` --
and four have no answer anywhere. No check fires: it would be wrong fifteen times, and `∅` against `·`
already tells them apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three small things.

`beside the problem` is where those files sit, not what they are; `common` is what they are -- one
file per problem, shared by every language. The group head and its tooltip say so.

`moi` becomes `moment-of-inertia` in `VALID_TAGS`, with the 23 problems using it renamed in the
submodule. The short spelling was the survivor of merging `moment-inertia` and `moi`, and it reads as
an abbreviation nobody outside that file would expand. Zero `tag-unknown` findings across phys
afterwards, which is the check confirming all 23 landed.

Rows are one line each. With forty-odd columns a wrapped cell made its whole row two or three lines
tall and the matrix stopped lining up with the header it belongs to, which is the reason for having
columns rather than a packed cell in the first place. The prose columns -- tags, the three author
roles, findings -- clip with an ellipsis and carry the full text in a title, so nothing is lost, only
folded away. `tags` is 14rem, which fits `rot-mech moment-of-inertia`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cell said `→` and made you hover to learn what it mirrored, which is a poor way to read a column of
nine languages. It now says `→sk`.

The target is a field rather than something scraped back out of the note: `translation_status` and
`shared_file_states` both carry `language`, set from `link_language`, which is None when the link goes
somewhere that is not a sibling language directory. In that case the arrow stays bare and the path
stays in the tooltip -- a two-character cell is no place for `../../../shared-solution.md`. Both
directions are tested.

The file columns lost their fixed `width` for a `min-width`, so an auto table layout gives the extra
room to exactly the columns that mirror something and leaves the rest one glyph wide.

Volume 27 renders 39 `→sk` and 39 `→en`, which is the 78 symlinks CLAUDE.md records for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…itten

Two inaccuracies the phys-wide hoist sweep exposed, both of which decided what the worklist said.

**A mirrored translation was counted as a second copy.** `27/brakemaster` reported an equation
"identical in 7 files" when it is written in five -- `cs` and `es` are symlinks, so two of those
seven are the same bytes. Worse, `27/mole`'s `Krtko` was reported as duplicated when it exists in
exactly one real file, which is not duplication at all. `Unit.real_label` resolves a language path
to the file it really is, and both `hoistable-equation` and `equation_status` count those.

**A label that cannot become an `eq:` key was reported as hoistable.** `ValidIdentifier` is
`^[A-Za-z_][A-Za-z_0-9]*$`, and Jinja reads `eq.stone-x` as `eq.stone - x`, so five of
`22/skateboard`'s labels can never be referenced. Since the key becomes the label, hoisting them
would rename them and move the label in the built page. They get their own category rather than being
lumped in with equations that differ: `3 in eq:, 1 differ between languages, 5 identical but the
label is not a usable key` says three separate true things.

Both were found the same way -- by acting on what the auditor said and watching it not add up. Four
tests: a mirror is not a second copy, two real files still are, a hyphenated label is not suggested,
and a plain one is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every source in this repository is indented -- four spaces for a display block, two for YAML -- and
there was no way to type either, because Tab in a textarea is the browser's focus control. It now
indents to the next tab stop, four in the sources and two in `meta.yaml`; Shift+Tab outdents; Escape
blurs, which is the way out now that Tab is not.

A selection is never replaced. A general-purpose code editor swaps selected text for a tab, and my
first version did too, until a test case showed it eating a selected sentence: in a repository where
the prose is authored deliberately, losing a sentence to a stray keystroke is a far worse bargain
than losing a shortcut nobody needs. So a bare caret inserts, and anything selected indents the lines
it touches. Blank lines gain nothing, so Tab cannot invent the trailing whitespace `encoding` flags.

Edits go through `execCommand("insertText")` where it exists: deprecated, but the only way to change
a textarea and keep the browser's own undo stack, and it fires `input` so the highlight overlay
follows. The fallback assigns `value` and dispatches the event itself.

`indentEdit` is pure -- value and selection in, replacement span and new selection out -- so
`core/tests/test_editor_indent.py` lifts it out of app.js and runs it under QuickJS against thirteen
cases, including a property check that Tab never removes a character. It tests the shipped code
rather than a Python transcription. `quickjs` is not a project dependency, so the module skips unless
it is there; `uv run --with quickjs pytest` runs it, and adding it to the dev dependencies would make
that automatic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule is asymmetric, so it takes two checks. `solution-unlabelled` wants a label on every display
block in a solution, referred to or not, because that is what makes the equation numbered and a
solution of numbered equations reads better. `problem-labelled` wants none anywhere else: a statement
is read once under time pressure, and a number in it is a reference nobody will follow. The second
finds nothing today, which is the point -- it holds by practice and now holds by check.

Each has a test that the opposite file kind stays quiet, because that asymmetry is the whole rule and
a check that fired on both halves would be worse than none.

`inline-long` is the third part: inline maths past 90 characters is as unreadable in the source as a
display block and can move into `eq:` behind `|inl`, the one spec that carries no label. 90 is where
the population puts the line -- of 20532 spans in phys the median is 7 characters and the 99.5th
percentile 83, so it picks out 63 rather than burying the page. Informational: whether a span reads
better inline is the author's call.

`solution-unlabelled` exempts a block that closes a footnote (`$$]`), where a label would land
outside the note. `28/a12-speed` is the only one, and a check that demands the impossible teaches
people to ignore it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`subscript-unwrapped` for `E_{kin}`, which sets `kin` as three italic variables multiplied together.
Two or more lowercase letters in a braced subscript, not already wrapped.

Four of its five tests are about what it must not fire on, because that is where the work was.
Capitals are labels, not words: `T_{KJ}` and `c_{Kx}` stay italic. A single letter is a symbol. And a
template tag is masked before the search -- `(§ t_up - t_down §)` names two `values:` entries, and
reading identifiers as subscripts produced 220 false positives on the first attempt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A word inside an equation has to change with the language, and until now the only way to do that was
to write the equation out once per language -- which is how the copies drift apart. The Markdown stage
could not have done better: it parsed `locale` and dropped it, so only the convertor ever knew which
language it was rendering.

**Recurring words** go in `core/i18n/<lang>.yaml` under `words:`, reached as
`(§ i18n.words['and'] §)`. `default.yaml` holds English and `merge()` makes it the fallback, so a
language nobody has filled in still builds. Populated from what the sources already say: `a` in
Slovak and Czech, `und`, `és`, `і`, and `odkiaľ` for `wherefrom`. `and` and `or` cannot be reached as
`i18n.words.and` -- both are Jinja keywords -- hence the subscript form.

Harvesting them turned up a defect worth its own fix: the Spanish, Polish and Portuguese solutions
contain `\QQText{and}` or `\QQText{a}`, English and Slovak left in translations. Their entries here
say `y`, `i` and `e`.

**A word belonging to one problem** goes in its `meta.yaml` under `words:`, term -> language -> text,
reached as `(§ w.air §)`. That is the common case by a distance: of the 190 words found inside
`\text{}` across phys, 167 appear in exactly one problem and only 23 in more than one.

Resolved on access, not up front. `21/troll-science` writes its equation with translated subscripts in
four of six languages and differently in the other two, so eager resolution would fail a Polish build
over a word Polish never asks for. Asking for a word the language lacks raises `MissingWordError`,
which names the term, the language and what translations exist -- a problem's own word has no English
to fall back on, and silence there would ship a hole in a booklet.

`i18n` and `w` join `const` and `eq` in `RESERVED_NAMES` so a `values:` key cannot shadow them.

Six tests, including the one that matters: one `eq:` entry rendering correctly in three languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fallback was wrong and I should not have built it. A word inside maths is prose, and English prose
in a Slovak booklet looks right on screen, passes every check, and is found in print. Failing the
build is the only version of this that ever gets fixed.

So `default.yaml` carries no `words:` at all -- `merge()` would have made whatever it held the
fallback for every language -- and the English words live in `en.yaml` like any other language's.
Asking for a word the active language does not define raises `MissingWordError`, which names the word,
the language, and the file to add it to:

    `therefore` has no sk translation in core/i18n/sk.yaml (it has none).
    Add it there; there is deliberately no fallback.

One error and one lookup now serve both tiers, the difference being only which file the message points
at. `therefore` remains defined in English alone, which is now a build failure waiting for anyone who
uses it rather than a silent Anglicism -- and that is the intended state until somebody who writes the
language fills it in.

Nothing in `source/` references `i18n.words` yet, so removing the fallback cannot break an existing
build; the whole repository still renders and the schema is still clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kvík asked whether the tool already reports this. It reported half of it. `value_status` reads
`problem.md` and asks whether the numbers the problem *gives* are named in `values:`; it never opens a
solution or an answer, so a result typed as a literal was invisible to it. `hardcoded-value` is a
different thing again -- it compares translations of a statement with each other.

`answer-literal` closes the gap, and the answer file is the right place to look: it *is* the result, by
definition, so a number there is a number nothing computes. 305 of them, in every volume, worst in
23, 24, 26 and 27; the newest volumes are nearly clean, 25 with two and 29 with one.

The verdict now folds it in, which costs the page some green and is the honest reading: `ok` falls from
178 to 88 and `partial` rises from 18 to 160, because ninety problems have a fully extracted statement
and still type their answer.

Five of its six tests are about staying quiet, because that is where the judgement is: 226 answers are
a chemical formula or a word, `chem/01/ciment` answers the bare integer `12`, and 19 are empty. None
of those has a quantity to compute. Only a numeric siunitx macro with no tag counts, which reuses the
arity-aware `magnitudes()` rather than a fresh regex.

`answer-extra.md` is deliberately out of scope: it is prose commentary and lives per language, so a
number in it need not be the answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sesquideus
sesquideus merged commit 9d6f56f into master Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant