~/changelog · currently v136

changelog

Commits to this site, newest first. Versions auto-increment per push. Co-authored with claude.

  1. v136 2026-09-30 2955109
    Szójáték: lessons A1-5-1 and A1-5-2, and the homework notes' sentences
    details
    Seven new words from term 5 (gyógypedagógus, Egészségedre!, szerelő, szerviz,
    szennyezett, nej, pirítós), 98 more sentences from the notes below the vocabulary
    tables, all voiced, and clips for six class-1 words that had none.
    
    Co-Authored-By: Claude Opus 5.5 
  2. v135 2026-09-22 abf2197
    Szójáték: answerable grammar, known words asked once, common words first
    details
    Grammar gaps show the English and the missing word's dictionary form; a word already known is
    asked once per round; new words arrive commonest first; a typed meaning a word short or a typo
    off says "Nearly"; correct typed answers move on by themselves. Built from hungarian e25e128.
    
    Co-Authored-By: Claude Opus 5.5 
  3. v134 2026-09-22 d9969b2
    Szójáték: audio credits for the new clips, with the synthesised voices restored
    details
    Co-Authored-By: Claude Fable 5.1 
  4. v133 2026-09-22 2c5130e
    Szójáték: simpler home screen, no level gates, grammar inside the round
    details
    Built from hungarian 9d43c05. Eighteen new clips for the September class
    words; two Forvo clips give way to Commons recordings.
    
    Co-Authored-By: Claude Fable 5.1 
  5. v132 2026-09-18 22b8d77
    :airplane: rv12is: a visit to Flightline Interiors
    details
    Co-Authored-By: Claude Fable 5.1 
  6. v131 2026-09-18 4cb2afa
    :airplane: rv12is: Vans signs off on the rivets and the spar, and two new LSRM-As
    details
    Co-Authored-By: Claude Fable 5.1 
  7. v130 2026-09-17 5e9822e
    Szójáték: the memory game no longer charges you for playing it
    details
    Turning over two face-down tiles and finding they do not match is the mechanic,
    not a failure to know the word. A word now has to be missed three times before
    it counts.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  8. v129 2026-09-17 2d8fae3
    Szójáték: drills for every grammar point
    details
    200 cloze items across 50 teaching points, each one checked against the class
    vocabulary before it shipped. Three more sentence clips.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  9. v128 2026-09-17 3dd3c3f
    Szójáték: quests, exams and whole-sentence rounds
    details
    Quests instead of a streak, a daily challenge with a shareable row, an exam
    per zone, and five rounds built on the instructor's own sentences -- fill the
    gap, put it in order, what did they say, write the sentence, say it out loud.
    
    286 new clips, one per sentence, synthesised locally. Everything else in
    audio/ is untouched.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  10. v127 2026-09-17 b007ee4
    Publish Szójáték V2
    details
    Levels, skill lines and a dark theme, plus three rounds that ask for a form
    rather than a headword: the word back in one of the instructor's own
    sentences, listening for meaning rather than spelling, and the meaning typed
    out.
    
    Also offline for the first time -- the manifest has been here for a while and
    a service worker had not, so "installable" meant an icon that showed a
    connection error on a train.
    
    Five new clips; everything else in audio/ is untouched.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  11. v126 2026-09-02 2a069aa
    Publish szojatek with the A1-4-11..13 vocabulary
    details
    Rebuilt from hungarian/data/words.json after importing lesson plans
    A1-4-11, A1-4-12 and A1-4-13 and syncing the reviewed backlog: 2247
    words, 2234 with audio, up from 1062.
    
    Co-Authored-By: Claude Opus 5 
  12. v125 2026-08-28 0688922
    Rename the "?" audio clips in the published szojatek build
    details
    NTFS rejects "?" in a filename, so these thirteen clips failed the backup of
    the workspace share and failed the whole job with them. Fixed upstream in
    hungarian and szojatek; this is the deployed copy.
    
    Eleven were byte-identical duplicates of the clean-named clip already present
    and were dropped, two were renamed. The bundle references the names inside
    backtick template literals rather than quoted strings, so it needed updating
    too - without that, those thirteen words would load a 404 on the live site.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01RABCtgV5xEf8r6swemwiTi
  13. v124 2026-08-26 4af012e
    :lock: Confine the dev server to _site and bind it to loopback
    details
    Two problems in scripts/serve.mjs, both live in this repo rather than theoretical.
    
    The containment check compared a bare prefix, so any path resolving under a sibling
    directory whose name merely starts with the root also passed. This repo has a
    _site.stale directory sitting right next to _site, so `/../_site.stale/...` was served,
    percent-encoded or not. Now compares against ROOT + separator, with the root itself
    allowed.
    
    The server also listened on every interface while printing a localhost URL, which put an
    unpublished local copy of the site on the LAN for the duration of `npm run dev`. It now
    binds 127.0.0.1, matching what it advertises.
    
    Verified against both forms of the escape plus ordinary paths. check:pii:all and the
    build stay green.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01CNgWMcdGJckkWCkKusLXKB
  14. v123 2026-08-25 068b428
    :memo: Document the two scanner modes and the sub-site exclusion
    details
    The CI section still described the old pipeline, and neither the --all mode nor the
    content/sites/ exclusion was written down anywhere a future session would find it.
    Records the cost of the exclusion explicitly, since a skipped directory that nobody
    remembers is how something eventually ships unscanned.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01CNgWMcdGJckkWCkKusLXKB
  15. v122 2026-08-25 bf0d28b
    :lock: Make the PII scanner see the repo, and gate the deploy on it
    details
    Three blind spots meant a green tick was worth less than it looked.
    
    git quotes any path containing a non-ASCII byte and octal-escapes it
    ("...Brass\303\263.mp3"). The diff parser matches a bare `+++ b/` prefix, so every such
    file fell out of the scan: 1373 of 2585 tracked paths, more than half the repo, on a
    public site. The git helper now passes -c core.quotepath=false, after which none are
    quoted. scripts/build.mjs shells to git only for `rev-list --count`, so it needs no
    equivalent change.
    
    Adds a --all mode (npm run check:pii:all) that sweeps every tracked file rather than the
    staged diff. The staged mode is right for the pre-commit hook but passes vacuously when
    nothing is staged, which is the state of a fresh CI checkout: wiring the existing script
    into CI would have reported success having read nothing. Skip rules are now shared by
    both modes rather than inlined in the diff loop, and binaries are excluded by extension
    plus a NUL check.
    
    content/sites/ is now skipped whole, not just its assets/ subdirectories. Sub-sites are
    self-contained published artifacts reviewed when they go up, and their payload IS the
    data: coordinate arrays, and minified bundles where 4294967296 is 2^32 and a weight
    array reads as GPS. Scanning them produced 498 hits, every one of them the thing the
    page exists to show. The trade-off is recorded in the code: anything under content/sites/
    is unscanned, so a new sub-site needs reviewing before it is added.
    
    With those done and the last false positive allowlisted (1013904223, the Numerical
    Recipes LCG increment in build.mjs, ten digits so the phone pattern matched), the
    whole-tree sweep is clean. check:pii:all now runs in deploy.yml ahead of the build, so a
    leak blocks the deploy instead of being noticed after it is live. Verified the full CI
    sequence locally.
    
    Also: .claude/settings.local.json was untracked but unignored on a public repo, one
    `git add -A` from being published, and it carries machine paths and usernames. And CI
    plus engines.node move from Node 20, which is EOL and no longer gets CVE fixes, to 24.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01CNgWMcdGJckkWCkKusLXKB
  16. v121 2026-08-25 608dd37
    :recycle: move copy and tunables out of the code
    details
    Three passes, each verified to leave the rendered text of all 337 pages
    byte-identical to what was live before.
    
    site.config.yaml is new: which sections exist and whether their index is a
    timeline, the reserved FOIA routes, reading speed, excerpt and description
    lengths, every image width and JPEG quality, RSS and keyword limits. All of
    it was previously a literal somewhere in build.mjs.
    
    copy.yaml gains a ui: block for chrome shared by more than one template, and
    the strings the build itself was emitting: the unit words in a meta line, the
    doc/pp counters. Templates no longer hardcode "browse by folder:", "Tags.",
    "other years:", "no commits yet." or the reading-pane button labels. Five keys
    that were defined but referenced by nothing are gone, and two category labels
    that existed in copy while the template ignored them are now actually wired up.
    
    97 inline style attributes become 69: the four that repeated are classes now
    (.sp-crumb, .sp-eyebrow, .sp-page-head, .sp-backlink), each carrying exactly
    the declarations its inline rule did. The FOIA back link kept its wider top
    margin as .is-wide rather than being quietly flattened into the others. What
    remains inline is the genuinely dynamic set, mostly background-image built
    from a photo URL.
    
    Co-Authored-By: Claude Opus 5 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  17. v120 2026-08-24 ec4721b
    :wastebasket: drop the Windows/SMB rationale from the git note
    details
    Work happens on home-server now, from /nas/workspace directly, so the UNC and
    cmd.exe story behind "use execFileSync" no longer applies. The rule stays, on
    the reason that outlives the share: execSync hands the command to a shell and
    re-parses whatever is in a path or a commit subject.
    
    Co-Authored-By: Claude Opus 5 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  18. v119 2026-08-24 0d6fa4e
    :recycle: one list component for musings, rv12is and foia
    details
    Seven templates each had their own copy of the entry markup, which is why the
    three sections had drifted into three different reading experiences: a card
    stack, a dated stream and a ledger table.
    
    They now all include templates/_entries.eta and get the same row, thumbnail,
    pinned strip, sort toggle and reading pane. build.mjs normalizes a post or a
    records request into one entry shape first, so the component never learns what
    a musing or a FOIA request is. 512 lines of duplicated markup become 192 across
    the seven callers plus one 215-line partial.
    
    Musings keep their excerpt and tag line; they are longer-form and the excerpt
    earns its place. FOIA loses the table: filed date, agency, volume and tracking
    go on the meta line, status and fees on the sub-line, the blurb as the excerpt.
    Agency pages show the statute where the index shows the agency, so the column
    does not just repeat the page title. Requests have no featured image, so their
    thumb slot stays empty rather than collapsing, which keeps the left gutter
    aligned with the other two sections.
    
    The reading pane now lifts .musings-main and strips .crumbs, .sp-postnav and
    .sp-backlink, which works for all three page types instead of only musings.
    S3-backed posts point at the 600px thumb they already have rather than baking
    a second crop.
    
    Co-Authored-By: Claude Opus 5 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  19. v118 2026-08-24 f98919a
    :pencil2: the four templates the em dash sweep missed
    details
    They were fixed in the working tree but never staged, so the homepage comment
    and the first/last markers on FOIA release and document pages still carried
    one. Caught by sweeping the deployed site rather than the local build.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  20. v117 2026-08-24 bc98c31
    :pencil2: no em dashes anywhere on the site
    details
    Site-wide rule from here on: period or comma, never an em dash. A period where
    the dash joined two independent clauses, a comma where it set off an
    appositive or introduced a list.
    
    Swept prose, alt text, copy.yaml, template strings, the page title separator
    and the two standalone static sites. Where the dash was an empty-value glyph
    (an unpriced table cell, a missing stat) it becomes a hyphen; where it was a
    leading marker before a parenthetical it is simply gone.
    
    832 visible em dashes down to 21, all of them on /changelog, which renders git
    commit subjects from ten commits written before this rule existed. Clearing
    those means rewriting history or faking the commit text at render time, so
    they stay for now.
    
    The rule is written into CLAUDE.md so it outlives this session.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  21. v116 2026-08-24 f4be782
    :lipstick: rv12is: an empty thumb slot instead of a placeholder
    details
    An entry with no featured image keeps its column so titles stay aligned, but
    shows nothing in it. A pin has no column to hold, so its slot collapses and
    the title sits flush.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  22. v115 2026-08-24 79b64a2
    :mag: rv12is: a pinned strip, a sort toggle, and something for Google to read
    details
    Pinned: `pinned: true` in frontmatter lifts an entry out of the dated stream
    into a compact strip at the top. Costs & hours is the only one so far; Lessons
    Learned joins it when it exists.
    
    Sort: newest first is what ships in the HTML, so the no-JS default is already
    right and the control stays hidden. The toggle rebuilds the // year rules
    rather than just reversing rows, and remembers the choice.
    
    SEO: the index was a bare list of links, which reads as thin content. It now
    carries an intro paragraph and Blog + BlogPosting structured data for all 15
    entries, and the title tag says what the page is instead of "rv12is".
    
    The intro and the title are drafted -- they are marked in copy.yaml and want
    George's own words.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  23. v114 2026-08-24 e5a837f
    :pencil2: rv12is: put the captions back the way George wrote them
    details
    I compressed all nine on the way in. Four lost real content -- the technical
    counselor, what the pre-made flaperon parts save you, the spars visible in the
    garage shot, and "I'm not proud to show this, but if it helps somebody, that's
    awesome," which was the entire reason that photo was published.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  24. v113 2026-08-24 e77c123
    :sparkles: rv12is: a thumbnail per entry, and a > that opens the post beside the list
    details
    Featured images are stored full-size, so the index bakes a 160px square crop
    rather than pulling a dozen 1800px JPEGs. Entries with no featured image keep
    the column so titles stay aligned.
    
    The chevron is an ordinary link; with JS it fetches the post into a pane that
    scrolls on its own. Esc, the scrim and browser Back all close it, and the URL
    tracks what is open so a reload lands on the real page.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  25. v112 2026-08-24 3e25354
    :airplane: rv12is: the finishing and powerplant kits land, and a third set of hands
    details
    Wings at 9.5/8.5/1.0. Two more replacement parts, and the #40-before-#30
    lesson on the aileron counterbalance.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_015yJvzKQmSNts9HrKjY9HX9
  26. v111 2026-08-23 198fa5e
    :speaker: szojatek: audio from 89.8% to 99.5%, and the annotation that was hiding it
    details
    Three sources, in descending order of preference: Wikimedia Commons, Forvo,
    and only then synthesis.
    
    The largest single gain was not new recordings. A headword is not always what
    to ask a pronunciation site for -- `mi?`, `keres (pénzt)`, `rózsaszín(ű)`,
    `bemegy vhova/vhonnan` -- and the fetchers had been querying the lemma
    verbatim since the project began. 48 clips that Commons and Forvo had all
    along, including every one of the question words. `lemmaGuesses` now strips
    the annotation and both fetchers consult it.
    
    It deliberately never offers the synonym inside a cross-reference. Taking the
    shortest variant looks obvious and gives `gramm (= g)` -> `g`, which Commons
    has a clip of -- of the letter G. `édesanya (= anya)` would get anya's voice
    under édesanya's card. A card that says one word while the clip says another
    is worse than silence, so only annotation is stripped, never substituted. A
    test pins it.
    
    The rest is synthesis, 224 clips from the macOS Hungarian voice, marked
    audioSource: 'tts' and credited as synthetic so a later Commons run can still
    upgrade one to a human. Local rather than cloud for a reason beyond cost: a
    cloud voice means POSTing the locked deck's plaintext to somebody else's
    server, which is precisely what its encryption exists to prevent.
    
    The locked deck reaches 123/123, its clips named after their hashed ids. 2,200
    clips ship under readable Hungarian names and doing that here would let anyone
    read the vocabulary off a directory listing. build-data.mjs skips p-*.mp3 when
    pruning, or it would delete all 123 on the next run for being unreferenced.
    
    Twelve entries stay silent on purpose: two bound suffixes, and ten sentence
    frames with gaps. `azért ... mert` read aloud becomes "azért mert", which is
    not the thing being taught.
    
    Also: A1-4 Class 11 vocabulary (hányadik, század, forradalom, világháború,
    aranyérem and eight more), and pofoz, forgalom, körforgalom, gyökér.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01RCxF3MSEkr2nyzX2ahsw3B
  27. v110 2026-08-23 c33f42c
    :card_file_box: foia: what Norwich pays the Law Enforcement Council
    details
    A Connecticut request to the Norwich Police Department for its dealings with
    the Law Enforcement Council of Connecticut and with PoliceApp, the platform
    its hiring runs through. Fifth entry in the archive.
    
    Fourteen pages, released as a scan with no text layer -- which means the PII
    pre-flight could read none of it, so I read all fourteen by hand instead,
    pulling the embedded page images out of the PDF. Worth recording what that
    turned up, since a scanner would have found nothing:
    
    LEC annual assessments climbing from $13,926 to $17,471 between 2022 and 2026.
    Three Eastern Regional Police Academy invoices covering seven named recruits at
    $3,300-$3,500 each -- municipal spend on sponsored recruits, which is a routine
    public record. Two PoliceApp pipeline exports listing every application by date,
    stage and outcome, already anonymised by the department. A vendor sales email.
    
    The substance is on the first two pages, where the department answered each
    item in red: Norwich HR rather than the police department holds the PoliceApp
    contracts and pays those invoices, the department has never received statistics
    reports from PoliceApp, and it does not possess any validation study or
    disparate-impact analysis of the LEC written entry examination. That last one
    was asked for specifically.
    
    Status is partial. The email categories were set aside to get the discrete
    documents moving and are still outstanding.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01SwfZCx4FMsF1CaWt9PUgQW
  28. v109 2026-08-23 be9885f
    :memo: notes to self, now that the FOIA section exists
    details
    CLAUDE.md gains a /foia section: the content model, the traps, and the design
    lesson. The traps are the useful part -- rights language is per-jurisdiction
    and 17 USC 105 does not cover county records; pdf.js needs bucket CORS with
    range headers exposed because it fetches bytes via XHR; a cross-origin
     is ignored so the viewer builds a Blob from bytes it already has;
    and correspondence logs are timeline source, never content, because they carry
    session tokens and home addresses.
    
    Also recorded: shell out to git with execFileSync, never execSync. execSync
    goes via cmd.exe, which refuses a UNC working directory and quietly runs from
    C:\Windows. On this network share that left the PII hook unable to read its own
    staged diff -- so, failing closed, it blocked every commit -- and dropped the
    version to v1 with an empty changelog. That one cost an hour to find and would
    have cost the next session the same.
    
    The design note is the one I most needed and did not have. I built the first
    pass as a stack of notice boxes over a seven-column table with the same records
    repeated as cards underneath, on a site whose whole aesthetic is a narrow flat
    column. "Don't add" now says it plainly: rules beat borders, a footnote beats a
    callout, and check what an existing page type already does before inventing a
    layout.
    
    The skill picks up the AWS bits -- where the CLI actually lives, and that
    authenticating needs a real console rather than a shell with no TTY.
    
    Writing that down promptly tripped the scanner on the very number the note
    cites as a false positive, so 2^32 is now allowlisted.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01SwfZCx4FMsF1CaWt9PUgQW
  29. :books: szojatek: traffic, roots, and a register ladder for the locked deck
    details
    Public pack: forgalom (traffic), körforgalom (roundabout), gyökér.
    
    gyökér is one card carrying both senses -- the literal root and the insult --
    because the insult was already in the locked deck, and adding the literal sense
    separately would have produced two gyökér cards the moment that deck was
    unlocked: one word's progress split in half, and both able to appear in the
    same multiple-choice round with no way to tell them apart. build-private.mjs
    now refuses to build when a headword exists in both decks, which is the check
    that would have caught it.
    
    Locked deck, 117 -> 123. The vulva set is filled in (hüvely, vagina, nuni,
    puni, puna, suna beside the existing picsa, pina, punci) and cigány is added
    with roma beside it, the way buzi already points at meleg.
    
    Every one of the 123 entries now carries a usage note. Eight Hungarian words
    landing on one English word reads as pointless duplication until you notice
    English has the same ladder -- vagina / privates / fanny / pussy / cunt -- and
    simply does not label it. So the clustered words each carry a compact Ladder:
    line naming the whole set in order: cock, arse, shag and hell get one too.
    The notes also say what the near-synonyms actually disagree about, which is
    the part a dictionary never tells you -- balfasz calls you useless where
    faszfej calls you obnoxious, bunkó is about manners and hülye is about wits,
    and fasza, alone in the fasz- family, means "great".
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01RCxF3MSEkr2nyzX2ahsw3B
  30. v107 2026-08-22 65a4de8
    :art: foia: fewer boxes, one list, less shouting
    details
    The first pass was a dashboard: four bordered notice blocks stacked above a
    seven-column table, and then the same four requests rendered a second time as
    cards underneath it. The request page carried a facts grid and twenty timeline
    entries each wearing a date column, a glyph, a type pill, a track pill, a
    heading, a note and a footer row. This site is a narrow flat column and it
    already had the right idiom in the build log's dated list; I ignored it and
    built something that belonged on a different site.
    
    So: the cards are gone, the ledger is three columns with everything secondary
    on one muted sub-line, and the three notice boxes collapse into a single
    footnote with the long version behind a disclosure toggle. The facts grid is
    now a run of prose. Timeline entries lost the glyph column, the rail and the
    bordered pills.
    
    Procedural steps -- acknowledgements, extensions, follow-ups, the requester
    narrowing again -- now collapse into one foldable line reading "MAY 28 - JUN 02
    : 2 extensions", so the events that actually produced documents stand out. A
    run never spans tracks, because the lane filter hides by attribute and could
    not otherwise reach inside a fold. Anything carrying documents stays out of a
    fold whatever its type, and narrowing-request stays prominent: an agency
    telling you the request is too big is a substantive act, and here it always
    arrived as a letter worth reading.
    
    Clow goes from twenty full-height entries to sixteen rows at roughly a third
    the height. The stylesheet loses 267 lines.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01SwfZCx4FMsF1CaWt9PUgQW
  31. v106 2026-08-22 993933e
    :speech_balloon: szojatek: pofoz, and both spellings of the 1pl/2pl accusative
    details
    pofoz (pofozni) joins Lili's pack, stored 3sg-first like the other 214 verbs.
    
    bennünket and benneteket are added as accepted `forms` on minket and titeket.
    They are not variants of a word, they are the same paradigm cell said the
    other way -- which is exactly why they are forms rather than cards: two cards
    would land in one "Finish the set" round with no way to tell which was being
    asked for. As forms they grade correct while the wrong person still does not.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01RCxF3MSEkr2nyzX2ahsw3B
  32. v105 2026-08-22 899b09a
    :card_file_box: foia: the documents, and the paper trail that got them
    details
    A new top-level section for records I've obtained under FOIA and state public
    records law. No commentary -- the documents are the point.
    
    Four requests to start: JWA Aviation at Clow International Airport (the
    Village of Bolingbrook plus a parallel FAA request), two Chicago CDOT
    intersections, and an Illinois Valley Airport fee-waiver fight with Josephine
    County, Oregon.
    
    The modelling decision worth knowing: a release IS a timeline event. There is
    no separate list of releases -- any event carrying files is one. So the
    timeline is the complete account of a request, and the per-dump file listing
    falls out of it. Requests that fanned out across agencies or reference numbers
    use tracks, which render a JS-free lane filter; the Clow entry interleaves
    twenty events across three of them.
    
    Documents live on S3 like the photos do, not in the repo -- GitHub Pages caps
    a site at 1 GB and one release here is already 23 MB. Each file is recorded
    with its size, page count and sha256, and the viewer is a small driver over a
    vendored pdf.js so a scanned release renders the same on a phone as on a
    desktop.
    
    Machine-written scaffolding is labelled as such. The build refuses to publish
    a summary with no generated_by/generated_on, and /foia carries a standing
    disclosure that the timelines, titles and descriptions around the documents
    are Claude's work while the documents themselves are untouched.
    
    Two things that were quietly broken on this machine, both from execSync going
    via cmd.exe, which cannot take a UNC working directory and falls back to
    C:\Windows: the PII hook could not read its own staged diff, so -- failing
    closed, correctly -- it refused every commit; and the version fell back to v1
    with the changelog empty. Both now use execFileSync.
    
    The phone pattern also no longer fires on records reference numbers and
    sha256 substrings. A scanner that cries wolf on every FOIA commit just teaches
    you --no-verify.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01SwfZCx4FMsF1CaWt9PUgQW
  33. v104 2026-08-22 eadf9dc
    :runner: szojatek: longer rounds, and priority that is actually felt
    details
    Ordering the queue was correct and still not enough. An admission takes a new
    word only on the pace's beat -- one in four at "normal", one in eight at
    "slow" -- and an admission only happens when nothing already in play is due.
    A twenty-answer round therefore delivered one to three new words, so a
    priority pack of eight took up to eight rounds to meet: right order, and
    indistinguishable from no order at all.
    
    A priority word now halves the beat. Halved rather than suspended, because
    `fresh` is sorted priority-first and the locked deck is 116 words -- admitting
    them on sight would rebuild the wall Session exists to avoid. Measured over a
    round, ordinary -> priority goes 1 -> 3 at the slow pace and 3 -> 5 at normal;
    "aggressive" is already at the floor of 2 and does not move.
    
    Round length becomes a setting rather than a constant, defaulting up from 20
    to 25 answers with 12 and 45 either side. It sits beside the new-word pace
    because the two together decide how much new material a sitting reaches, which
    is the thing that was actually wrong.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01RCxF3MSEkr2nyzX2ahsw3B
  34. v103 2026-08-22 e71e03b
    :lock: szojatek: Lili's words, and a deck that ships encrypted
    details
    Two new packs. `lili` is eight words she taught across a table -- teherautó,
    kamion, rendszámtábla, jelzőlámpa, túlsúlyos, újságos, pára, fülledt -- and
    lámpa is amended in place rather than duplicated, because two lámpa cards
    would split one word's progress and then face each other in a multiple-choice
    round.
    
    Both packs are `priority`: their unseen words are introduced before other
    unseen words and their due words reviewed first. Deliberately not ahead of
    due-ness -- a week-overdue review still wins -- because priority is only free
    inside each group. Among 900 unseen words that ordering is the difference
    between meeting a word tonight and meeting it in March.
    
    The second pack ships encrypted. The site is static, so "only I can see these"
    cannot be a switch: a switch is a line of JavaScript anyone can read with the
    words sitting next to it. AES-256-GCM under PBKDF2-SHA256 x 600,000, unlocked
    by passphrase in Settings and cached per device. Word ids are hashes, because
    exportSave writes ids into the file the app tells people to carry around, and
    the passphrase itself is never stored.
    
    Every entry in it carries a register -- mild, vulgar, very vulgar, slur -- shown
    as a word rather than a colour, since buzi and meleg translate to neighbouring
    English words and are not remotely interchangeable.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01RCxF3MSEkr2nyzX2ahsw3B
  35. v102 2026-08-21 3c4b1b3
    :shield: szojatek: a grid round cannot throw on its own props
    details
    Belt to the previous commit's braces. `Match` and `Memory` built their tiles from
    `question.grid[0]!` -- a non-null assertion over an optional field, which is a promise rather
    than a check. The promise was broken exactly once, by a component being handed the next question
    before it was swapped out, and the result was an error card rather than a missing round.
    
    That race is fixed at the source. The assertion was still a lie: a mode that can throw on its own
    props is a blank screen waiting for the next mistake to find it. Both now return early when there
    is no grid to lay out.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01MCGSQM7Krukmjv1Y7VigDe
  36. v101 2026-08-21 658b679
    :bug: szojatek: the round on screen is always the round being asked
    details
    This is the error card George kept hitting, and it was mine. Loading the game components on
    demand meant holding the component in its own `$state` and assigning it from an `$effect` -- but
    `question` changes the moment an answer is recorded, and an effect does not run until after the
    DOM has been updated. So there was one render where the *new* question was handed to the
    *previous* question's component.
    
    Most components survive that; the grid ones do not. `Match` and `Memory` read `question.grid[0]`
    directly in an effect, so a question with no grid throws on the spot -- which is why it was
    reliably "after the pair unflip game": whatever came after a memory round arrived while `Memory`
    was still mounted. The word on the card, `fal` or `erdő`, was never the problem; it was just
    whichever word came next. Pressing "Try again" worked because by then the effect had caught up,
    which made it look like bad data rather than a race.
    
    The component is now derived from the question rather than assigned alongside it, so the two
    cannot come apart: it is either the right component or none at all. A chunk that has not arrived
    yet renders nothing for a frame instead of rendering the wrong thing.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01MCGSQM7Krukmjv1Y7VigDe
  37. v100 2026-08-21 630286e
    :bug: szojatek: switching a game off actually switches it off
    details
    Follow-on from the error card, and the other half of the same fault. `gamesFor` ends with a
    floor -- "something must always be askable" -- that returned `choice-en` without checking whether
    the learner had switched it on. So turning multiple choice off did not turn it off, and with most
    games off the session leaned on that floor constantly.
    
    That is also how the crash was reached. With nearly everything off, almost no word suits the one
    remaining round, so `next()` fell through to its fallback on nearly every question -- and until
    the previous commit that fallback served a round without checking it could be built. The matching
    rounds read `grid[0]` directly, so the answer was an error card, over and over, in exactly the
    place this was reported: after a memory round, on the question that followed it.
    
    The floor now names a game only if it is allowed, and returns nothing when it cannot. `next()`
    decides what to do with that, and it checks what it serves. `fallback.test.ts` plays every game
    alone against the whole corpus and asserts two things: nothing invalid is ever served, and
    nothing switched off is ever served.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01MCGSQM7Krukmjv1Y7VigDe
  38. v99 2026-08-21 0bf33d6
    :bug: szojatek: a round that cannot be built is no longer served
    details
    George hit the error card on `fal`, which is how I found this: when nothing in the rotation can
    build a round for a word, `next()` fell through to a path that returned a round *without checking
    it*. That was survivable while it was hard-coded to multiple choice, which can almost always
    build. Once it started respecting switched-off games it could return any mode at all -- and an
    unchecked round is a component rendering a payload it was promised would never arrive. `Match`
    and `Memory` read `grid[0]` directly, so they throw outright.
    
    Reaching it is easy: turn most games off in the settings panel and play. The fallback now tries
    multiple choice, then anything else switched on, and only then introduces the word -- an
    introduction always renders, because it needs nothing but the word itself. Showing a word is a
    poor round; it is not a broken one. `fallback.test.ts` plays every game alone against the whole
    corpus and asserts nothing is ever served that its own `valid()` rejects.
    
    The card that appeared said "That round would not build", which is a sentence for whoever has to
    fix it, not for someone in the middle of a session. It now says what happened, shows the word
    anyway, and says the rest of the session is fine.
    
    In the matching round the words shifted sideways whenever the key numbers appeared or a cleared
    pair renumbered the column -- which is exactly when they are being read. The number's slot is
    always in the layout now; only the glyph comes and goes.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01MCGSQM7Krukmjv1Y7VigDe
  39. v98 2026-08-21 007b637
    :bug: szojatek: the accent verdict reaches the screen, and unscramble stops being unsolvable
    details
    An audit of the whole app, then the fixes. Three findings were worth the exercise on their own.
    
    `hungarian.ts` splits a word into Hungarian letters and nothing put them back: `letters('rossz')`
    is r·o·sz·sz, and joining that plainly spells `rosszsz`. Unscramble deals those as tiles, so 31
    words could not be solved at all, and the fill-the-blanks mask and the hangman board drew
    spellings that do not exist. There is now an inverse, and a corpus test asserts the round trip
    over all 2,214 words -- the test that would have caught it.
    
    The `accent-miss` verdict is the idea the whole grading design is built around, and it was
    invisible. Four games computed a sentence to explain it and nothing rendered that sentence;
    `--near` was the least readable colour in the app at 2.29:1. Worse, in the one round built to
    separate `hal` from `hál`, typing the *other word of the pair* was greeted with "right word,
    check the accents" and passed as a stumble. The note now shows, every colour that carries text
    clears AA, right and wrong carry a mark rather than only a hue, and the partner is graded wrong.
    
    Every mode reported the same rating to FSRS, so a two-option guess and a word written from its
    meaning were identical evidence and a word grew in stability on recognition alone until it
    stopped being asked. `intro` graded too, advancing a card for a word that had only been *shown*.
    
    Two new rounds, both built from vocabulary the class already has: vowel harmony (`-ban vagy
    -ben?`, 1,155 words) and `hol? / hova? / honnan?` (18 complete place families). Harmony refuses
    to classify mixed-vowel words -- the rule says the back vowel wins and it usually does
    (`papírban`) and sometimes does not (`ábécében`) -- because a drill that is confidently wrong
    one time in twenty teaches the exception as the rule.
    
    Also: an error boundary, so a round that throws no longer blanks the page; progress can be saved
    to a file and restored, since one browser key was the whole of someone's work; a round has a
    finish line; a mis-tap can be undone; the 19 game components load on demand rather than before
    the home screen can draw a button; and four headwords that arrived from the .docx extractor with
    their `ő` or `ű` replaced by a space are repaired and pinned.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
    Claude-Session: https://claude.ai/code/session_01MCGSQM7Krukmjv1Y7VigDe
  40. v97 2026-08-20 52788f3
    :jigsaw: szojatek: the grid rounds stop asking questions that have no answer
    details
    Four real rounds prompted this, two good and two not, so I sampled the builders against the real
    corpus -- 590 group sorts and 614 odd-one-outs -- rather than argue from four screenshots. Two in
    five sorts had a bucket that is a grab-bag: "Describing things" is 306 unrelated adjectives, and
    you cannot decide from a word's meaning whether it belongs there, which is the whole exercise.
    One in three had a chip nobody could sort: "az is tetszik, hogy…", a sentence opener, or
    "állomás (= vasútállomás)" with its cross-reference showing. Two in five odd-one-outs had a verb
    or an adjective sitting inside a family of things.
    
    The buckets were the easy half. VAGUE_TOPICS was written for exactly this and wired only to
    odd-one-out, so the sort round had no topic filter at all. It has one now, and a tile filter that
    every grid shares -- no phrases, no ellipsis frames, no cross-references, no bare affixes, in
    sorting, odd-one-out, match, memory and the speed round alike. The sort round also says outright
    where each tile belongs instead of leaving the component to pick the first bucket the tags happen
    to match, which is how "antipatikus" -- an adjective meaning unpleasant -- became the correct
    answer for "People & family".
    
    The hard half was upstream. Tags are keyword matches on the English gloss, and the boundary was
    [^a-z]: ASCII only, so every accent read as a word break. `bus` matched inside "Busó-walking" and
    a carnival was filed under transport. `one's` made three verbs numbers. "(first person of kér)"
    gave `kérek` the tags people, numbers and feelings, three of its four. And a compound is not its
    first half: a hot dog was an animal AND weather, hand cream was a body part, a coffee table was a
    drink, "to suit somebody" was one of only 25 clothing words. A general head-of-compound rule was
    tried and rejected -- it moves bikavér out of drink and into body, worse than what it fixes -- so
    twelve words are named outright instead.
    
    Fifty everyday nouns had no keyword at all and sat in `general`, which every themed round strips:
    pohár, kanál, kés, hűtő, garázs, tyúk, festő. A third of the collection was invisible to both
    games, which is the other half of why rounds repeated.
    
    Re-tagging now REPLACES rather than unions. Union is only safe while the tagger is too quiet; the
    moment it is too loud, a union can never take the wrong tag back off, and that is how virsli
    stayed an animal through three earlier passes. Words the grammar tables wrote by hand are left
    alone. 127 words changed. `body` went from ten words to six actual body parts.
    
    Nine headwords lost their ő or ű on the way in from the glossary -- `id` glossed "time",
    `sz nyeg` glossed "carpet" -- and all nine shipped as unanswerable cards. Seven duplicated an
    entry that came through intact; two were the only record of their word.
    
    Every rule here was chosen so as not to suppress the two rounds that were good, and both are
    pinned as regression tests. Excluding category names would have killed the first (ruhadarab among
    clothes) and requiring nouns would have killed the second (egészséges among nouns) -- so neither
    rule exists, and the 42% figure is fixed upstream instead, by `áll` no longer being clothing.
    
    Also, two things asked for directly:
    
    The speed round now holds its options back for a beat once a word is well known. The mechanic
    already existed -- `recallPause`, which the ordinary choice rounds have used for months -- and had
    simply never been pointed at the speed round. The word and the audio arrive at once and the four
    answers land a second and a half later, so you have to retrieve the meaning instead of scanning
    for it. The clock keeps running. No new switch: it turns itself on at level 3.
    
    And a new round with no English on it anywhere. `cipőbolt` is a shop, not a shoe; `vörösbor` is a
    wine, not a colour. Hungarian compounds are right-headed without exception, so the last element
    says what the thing *is* -- one fact that unlocks a lot of vocabulary, because someone who knows
    `bolt` and `cipő` can read a word nobody taught them. The round shows the compound and asks which
    word it is a kind of, with the modifier always offered beside the head, because that is the answer
    you give when you have read the spelling and not the meaning. 129 of them, both halves already in
    the deck, nothing added. Asking instead which two words make it up would have been a spelling
    puzzle -- the letters give that away without any meaning being involved.
    
    Odd-one-out has also been playable from the keyboard the whole time except that it wasn't: it drew
    1-4 on the tiles and had no key handler.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  41. v96 2026-08-20 1ce0dff
    :loud_sound: szojatek: better recordings for 927 words, from Wikimedia Commons
    details
    Forvo is a crowd: every clip a different room, a different microphone, a different person's idea
    of how loud to be. Wikimedia Commons has Lingua Libre, where native speakers record whole word
    lists to one protocol in one sitting, and it is the difference between a set that sounds like a
    recording and a set that sounds like twelve strangers. 927 of the deck's 2221 words had one, so
    they use it now, and three words that had no audio at all finally do.
    
    The filenames came out of the dump already downloaded for the conjugation work, so nothing had to
    ask an API where anything lives -- Commons files sit under the first two hex digits of the md5 of
    their name, which is arithmetic. One request per second with the backoff Wikimedia asks for, ogg
    to mono mp3 through ffmpeg, about twenty minutes for the lot.
    
    Reversible by construction: the Forvo clip keeps its own filename and only the reference moves,
    so putting one back is a database edit rather than another download. `audioSource` records which
    is which. One word, magas, 404s on Commons and keeps what it had.
    
    Nobody was going to listen to nine hundred recordings, so they were checked by duration instead:
    mean 1.14 seconds, none under a quarter of a second, one at 4.6. That is what a list of single
    spoken words looks like, and it is what catches a whole example sentence hiding among them.
    
    Attribution ships with them. audio-credits.json names the Commons file behind every clip, rebuilt
    from the database each run rather than appended to -- an accumulating list drifts the moment a run
    dies before writing it, which happened twice, and a credits file missing entries is worse than
    none. Settings carries the one line the licence actually requires.
    
    Two things the share taught along the way, both already written down for npm and both true here:
    writing nine hundred small files straight to it stops dead after fourteen, and copying them one
    at a time dies with EBADF after three hundred. Build on a local disk, hand the directory to
    rsync. The corpus build now retries a failed clip copy for the same reason -- a transient failure
    there does not fail loudly, it just leaves a card silent.
    
    Also: verbs open in the word browser. Clicking a Hungarian word shows its six present-tense forms,
    folded away by default because 207 of 2221 words have a table and six extra lines each turns a
    list you can scan into one you have to read.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  42. v95 2026-08-20 9daa860
    :sparkles: szojatek: verb endings, from Wiktionary rather than from a rule
    details
    Six present-tense forms for 207 of the deck's 214 verbs, and none of them is a new word. They
    ride on the verb as a field, so the deck is still 2221 cards and not 3463, and a round reports
    against the verb -- getting szeretunk wrong is a mark against szeret, where it belongs.
    
    Harmony cannot be read off the last vowel. lakik takes lakunk and fekszik takes fekszunk; both
    end in -ik and neither announces which, and a last-vowel rule gets 42 of the deck's 65 -ik verbs
    wrong. alszik becomes *alszunk with an umlaut -- a form that looks entirely plausible and is not
    a word, which is the worst kind of mistake to drill. So the class comes from the data: a 1.6 GB
    English Wiktionary dump, streamed once into a 6.3 MB table of conjugation class, Commons audio
    filename and IPA. hu-conj-ok on lakik says back harmony despite the ending, and alszik has a
    class of its own so no detection is needed at all.
    
    Sixteen classes implemented, three of them regular and covering 157 verbs, the rest one-offs
    where jon swaps its stem for jov- in half the table. The phonology is where the care went: sz is
    one letter, so kivisz is not a consonant cluster and takes kivisztek rather than *kiviszetek --
    counting characters gets every digraph verb wrong. A doubled consonant is awkward only after a
    short vowel, so all takes allsz and hall takes hallasz, and Wiktionary marks the same five verbs
    the same way with short=y against long=y. Stems that drop a vowel drop it only before a vowel:
    csorgok but csorogsz.
    
    Then the whole thing is checked against Wiktionary's own form pages, 35,141 of them, tagged
    structurally: alszunk is "first-person plural indefinite present of alszik" and alszunk-with-an-
    umlaut is not a page at all. 357 forms confirmed, and in the three places where the rules and the
    dump disagree the dump wins and the swap is printed. Absence proves nothing -- Wiktionary has
    visztek and not kivisztek -- so only a contradiction counts.
    
    Nineteen hobby verbs had no page: borozik, fenykepezik, sorozik, taxizik, the whole free-time
    chapter. Those read their harmony off the suffix that derived them, since -ozik is back and
    -ezik front by the same rule that governs every other suffix. Three of the nineteen have a page
    for the bare stem and all three agree.
    
    Two rounds, no new components: pick the form for a pronoun, and finish the table with one row
    blank. Both are the paradigm rounds with a different table behind them.
    
    Also, the pairs the learner actually named. csipos against cipos was missing because edit
    distance calls it two substitutions -- the same score as six thousand pairs of unrelated words.
    It is not two mistakes; it is one word written the way it sounds, so two near misses in one word
    now count as one confusion. hal/hall was missing twice over: under the length floor, and rejected
    as a stem with a suffix, which a doubled consonant is not. And amikor/mikor is gone -- both tiles
    said "when", so the round had no answer.
    
    Four verbs were filed under their infinitive by a phrase split and shadowed the real entry, so
    szallitani and szallit were two cards for one verb. Merged, keeping the sense each loser added.
    hal, cipos and fekszik added by hand as the missing halves of pairs, each checked against the
    724,942-title Hungarian Wiktionary list rather than guessed.
    
    Last: both drills were nearly unreachable. A pair round needs a word with a partner and a learner
    who has met both, which is about one question in a hundred -- right for a mixed session, and no
    way to ship a feature someone asked for. "Easy to mix up" and "Verb endings" now sit above the
    topic chips and hand the whole list straight to a session.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  43. v94 2026-08-20 2c7129e
    :sparkles: szojatek: drills for the words that get mistaken for each other
    details
    vilag and virag, ajto and auto, anyuka and apuka, bors and gyors. One letter apart and nothing
    to do with one another, and an ordinary round never puts them together -- distractors are chosen
    by topic, and these pairs almost never share one. So the mistake survives.
    
    122 pairs, found by edit distance over Hungarian letters rather than characters, which matters:
    sz is one letter, so sor and szor differ by one substitution and not by an insertion. Counting
    characters gets every digraph word wrong in both directions. 119 of the 122 have a recording on
    both sides, and six differ only by an accent -- bal/bal, ot/ot, fel/fel -- which the grader
    already had a separate verdict for.
    
    Three filters, all using signals the data already carried: a word and its own stem (asztal /
    asztali) is related, not confusable; a form a word already lists is not a trap; and two cells of
    one grammatical table are drilled by the paradigm games, so re-presenting them as a mix-up
    teaches the wrong thing about them. Two cells of one verb are deliberately kept -- szeretek
    against szeretnek is exactly the confusion worth having.
    
    A minimum length of four letters, because below that one letter is most of the word: ad/az and
    agy/ar are a letter apart and share nothing, and over half of all distance-1 hits were words of
    three letters or fewer. Accent twins are exempt, since ot/ot is short and is the point.
    
    Four rounds: hear one and pick which, see both and pick the one that means it, hear one and
    write it, and see the difference said out loud -- "sz, not s" -- because a letter position would
    be true and useless.
    
    Computed at build time, about a second, and the answer does not change between page loads. The
    engine's own letter tokeniser does the work, so the tricky part is written once.
    
    Also: "Only what we've covered in class" is now a switch. 1933 of 2222 words were taught in a
    lesson, a MagyarOK chapter or a class transcript; the other 289 are the grammar tables and the
    old Anki import. It is off by default, and it ANDs with the pack switches rather than replacing
    them -- packs choose which material, this chooses how far ahead of the class to go. That rule now
    sits at the top of both CLAUDE.md files, because several things being built here could pull in
    the whole language if left alone, and none of them is allowed to add anything on its own.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  44. v93 2026-08-18 ce4b6a9
    :sparkles: szojatek: hol, hova, honnan -- and the thirteen sets built on it
    details
    Hungarian splits place three ways where English mostly manages with one: bent is "inside", be is
    "inside" as somewhere you are going, bentrol is "inside" as somewhere you have come from. Every
    word of hol/hova/honnan, itt/ide/innen and ott/oda/onnan was already in the database and none of
    them belonged to anything, so nothing ever drilled them together -- which is the only way that
    distinction is learnable.
    
    Thirteen sets now: the question words themselves, here, there, home, inside, outside, up, down,
    front, back, elsewhere, near and far. 22 new words, and 11 that were already present simply
    joined a family and kept their own glosses.
    
    Two judgements worth recording.
    
    The question set gets its own row headers. Every other set is labelled hol? hova? honnan?, which
    is ideal for a postposition and useless for the question words themselves, where the row would
    hand over its own answer.
    
    "Outside" has no middle slot. The hova? form of kint is ki, which is spelled exactly like the
    question word "who?" and is a different word that happens to share a spelling. The database
    models that as two entries; hu paradigms deliberately never forks one, so the set stays
    two-thirds built rather than quietly stealing the question word out of its own family.
    
    Also fixed on the way: the fourth clip of "hazulrol" has no Forvo recording, and one of the
    twenty-two new words likewise -- both are marked rather than silently missing.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  45. v92 2026-08-18 7c0757b
    :bug: szojatek: odd one out asked questions with no answer
    details
    "Three of these are numbers" is not a puzzle, and that is what the game was building. A round
    offered brassoi apropecsenye against het, mobilszam and van -- "is, there is" -- because all
    three carry a numbers tag, and another offered ertekezlet against nalam, mennyibe and
    tizenegyedik. Nothing links those. There is no odd one out.
    
    Two causes, neither in the game.
    
    Tags are keyword-matched against the English gloss, and 946 words from the textbook import had
    none at all: they were written with categorize(gloss, pos) when it takes a single object, so
    every one of them got the "nothing matched" fallback. scripts/ was outside the tsconfig include,
    so nothing checked the call. Both are fixed, and 518 words gain real tags -- colours goes from 3
    to 15, nature 5 to 14, health 10 to 13, and three topics become offerable that were too thin
    before. tag-words.ts was also documented as safe to re-run while it assigned tags wholesale,
    which would have deleted all 213 tags the grammar tables declare; it is additive now.
    
    Families are also chosen better: they must share a topic that names a kind of thing -- food,
    drink, animals, clothing, transport, and so on. Time, work, places, people and numbers are
    states and abstractions, and three of them together look like a group without being one. The
    stranger stays unrestricted, because a pronoun against three health words is a perfectly good
    question and restricting it throws away a third of the rounds for nothing. Topic choice is
    weighted by the square root of the topic's size, so a four-word topic is not as likely as a
    two-hundred-word one, and the round is not always about food.
    
    Odd one out can now be built for 51% of asks rather than 31%, and the four reported rounds are
    all unbuildable.
    
    Four headwords were also repaired. The double-acute o and u drop out of one of the PDF's fonts,
    which was known for the example sentences -- they were left out -- but it hit "csipos", "szuk",
    "szolo" and "buszsofor" too, and they slipped through as phrases because a space reads as one.
    The import's own validation could not have caught them: it matched 719 extracted headwords
    against the database, which says the extraction is sound and says nothing about the broken
    ones, since a word with a hole in it matches nothing by construction. The importer now refuses
    a noun, adjective or verb that contains a space.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  46. v91 2026-08-18 dc499f6
    :sparkles: szojatek: matching rounds that do not answer themselves at the end
    details
    Match up was a fixed board of five pairs at every level, and with the columns equal the last
    few pairs are free: whatever is left has to go together, so the end of every round tested
    nothing at all.
    
    Now it grows -- four pairs, then six -- and at the hardest level some tiles have no partner.
    Which side carries the spares changes from round to round, so "the longer column is the one
    with extras" does not become the new shortcut either. A decoy is always a word already met,
    because an unfamiliar one is not a distraction so much as noise, and never reads the same as a
    real tile on its side, which would make one of them unanswerable rather than merely hard.
    
    The board says so when there are spares. Finishing with tiles left over is the correct outcome
    there, and without a word for it the round just looks broken.
    
    No special case in the matching itself: a decoy's id matches nothing in the grid, so it can
    never clear and pairing one is wrong through the path that was already there.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  47. v90 2026-08-18 69f9688
    :bug: szojatek: sz is a letter, and hangman now knows it
    details
    Hungarian counts cs, dz, dzs, gy, ly, ny, sz, ty and zs as single letters of the alphabet, each
    with its own sound and its own place in the ordering. Every part of the app that worked letter
    by letter was splitting them with [...word], which is wrong in three places at once: hangman
    offered "s" and lit up half of "orszag", unscramble dealt "s" and "z" as separate tiles for a
    word containing neither, and the fill-the-blanks mask could hide one half of a letter and ask
    for something that does not exist.
    
    New engine/hungarian.ts tokenises a word the way the language does. The subtle part is that a
    long digraph doubles only its first character -- "asszony" is a-sz-sz-o-ny, not a-s-sz-o-ny --
    so a naive reading hands a guess of "s" a free hit. The one case it cannot resolve is a compound
    seam, where "kisszek" really is kis + szek; telling those apart needs morphology the app has
    not got, and doubling is much the commoner case in ordinary words.
    
    Hangman's alphabet now offers the digraphs, and typing still works: a key that could begin one
    is held for a moment, so pressing s then z guesses sz rather than burning a life on s and then
    guessing z. Otherwise nine letters of the alphabet would have been mouse-only.
    
    The scramble test asserted character-level behaviour and now asserts the letters of the word --
    cseresznye is seven tiles, not ten. The reachability test was pinned to a single seed and broke
    because tokenising differently consumes the rng differently, which says nothing about whether a
    game can be reached; it now checks across several seeds.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  48. v89 2026-08-18 f83eea6
    :bug: the PII scan skipped itself on the biggest commits
    details
    execSync buffers the whole diff and throws past its 1 MiB default, which the hook never
    raised. Committing 780 audio files produced a 1.3 MB diff, execSync threw, and the catch
    reported it as "not in a git repo? bailing" and let the commit through -- so the scan opted
    out of precisely the commits most worth scanning, while printing something that read like a
    harmless note.
    
    Raised the buffer, and made the failure path fail closed: a scanner that cannot read the diff
    and stays quiet is worse than no scanner, because it looks like it passed. --no-verify is
    still the deliberate way past.
    
    Checked afterwards: nothing sensitive reached the published site. The only pattern-matches in
    everything added today were two FSRS weight vectors inside the minified bundle, which look
    like coordinates and are not.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  49. v88 2026-08-18 0e70bc5
    :lipstick: szojatek: name the packs by level -- MagyarOK A1 Ch 3
    details
    "MagyarOK 1 · 3" asked the reader to know the series numbering. The level is the part that
    means something, and it keeps reading correctly when the A2 book arrives.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  50. v87 2026-08-18 749eede
    :sparkles: szojatek: a streak, a word list, and topics you can actually pick
    details
    Most of this was already written and simply unreachable. `themedQueue`, `WordIndex.topics` and
    `topicProgress` were finished and tested when themed rounds went in, but `game.topic` could only
    ever be set from the argument `start()` had just been handed, so nothing could reach them.
    `Progress.days` has been recorded on every session since the first version, documented as
    "Drives the streak", while `computeStats` took it as a parameter and never touched it.
    `sfx.levelUp()` was written for a word graduating and never called, though the store already
    knew the exact moment. `Session.remaining` had no reader. `Stats.due` was computed every render
    and thrown away.
    
    So: a streak and a "due now" count on the home screen, both from data already on disk -- an
    existing save shows a real number immediately. Yesterday still counts as unbroken; a run that
    dies at midnight before you have had a chance to play is a punishment for sleeping. The best
    run is shown beside the current one so a break is not the whole story.
    
    A word list, which the app has never had. Pick a topic or a chapter and see every word in it,
    weakest first, with how well you know each one -- and practise just those, or just the ones you
    have never seen. It could tell you 939 words were unmet but not which ones.
    
    Topic chips on the home screen, with two-tone progress: FSRS needs four reviews before a word
    counts as learned, so a bar driven by that alone sits empty for a fortnight across every topic
    at once. "Describing things" and "Pronouns" are filtered out -- 179 and 128 words, and neither
    is a theme anyone would choose.
    
    The trouble list is now something you can act on: each word starts a round, or practise all
    eight. It had been eight inert pills on the one screen meant to answer "what am I bad at".
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  51. v86 2026-08-18 7dd42c3
    :sparkles: szojatek: the textbook's own vocabulary, switchable by chapter
    details
    1256 -> 2202 words. The corpus was being outrun -- 897 answers spread over 123 words while 939
    sat untouched -- and a weekly class cannot fill it fast enough. This is the glossary of
    MagyarOK 1, the book the class actually uses, which is a far better source than any frequency
    list: it is the vocabulary these lessons are built on, and it arrives already divided into the
    chapters they are taught in.
    
    946 new words, and 760 words we already had now record the chapter that introduces them, so a
    chapter can be practised or switched off whether or not it came from the book.
    
    Eleven switches on the home screen, one per chapter plus class lessons, grammar tables and
    everything else. Switching one off removes those words everywhere -- the queue, the counts, and
    the pool wrong answers are drawn from, which is the one that would have gone unnoticed: a
    chapter you had turned off would have gone on supplying distractors. Choices are stored as
    explicit yes/no rather than a list of exclusions, so a MagyarOK 2 pack can later ship switched
    off until it is asked for; a deny-list cannot say that.
    
    Extraction was checked rather than assumed: 719 of the glossary's headwords match words already
    in the database exactly, which a mangled parse could not do. Example sentences are deliberately
    left out -- the double-acute o and u drop out of the font they are set in, turning "baratnomnek"
    into two words. Headwords are unaffected.
    
    The book's own English is kept as the gloss. It is the meaning the class will be tested on, and
    rewriting it would only introduce drift. Verb government becomes the case it asks for, so
    "ad vkinek vmit" reads "to give sth to sb (+ -nak/-nek, -t)". 781 of the new words have a Forvo
    recording; 68 have none and are marked.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  52. v85 2026-08-18 d6bf76c
    :sparkles: szojatek: ordinals, postposition triads and the words that join sentences
    details
    1176 -> 1256 words. The corpus was starved exactly where a beginner needs it most: nine words
    marked num. and four marked ppn. across the whole collection, with "de", "vagy" and "ha"
    missing outright.
    
    Added as sixteen more grammatical families plus twenty-eight plain entries:
    
      ordinals            elso..nyolcadik, then kilencedik..ezredik
      postposition triads alatt/ala/alol, mogott/moge/mogul, and four more -- the row headers are
                          hol? hova? honnan?, which is how the distinction is actually taught
      the vala- series    valahol / sehol / mindenhol / barhol / akarhol, one family per question
                          word, because the prefix carries the meaning and the stem never moves
      joining words       nelkul, szerint, miatt, ota, mulva, de, vagy, ha, amig, mielott...
    
    Two judgements worth recording. The series tables leave slots out rather than inventing
    symmetry: there is no *semmikor and no *mindenhogy, so "when" takes soha and mindig and "how"
    has no universal at all. And the suffix is appended to a gloss only where it distinguishes one
    near-identical family from another -- "second (-dik)" and "under (at) (under)" are noise.
    
    Every one of the eighty new words has a Forvo recording; none was missing.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  53. v84 2026-08-18 e69c7dc
    :sparkles: szojatek: grammatical families, and new words that actually arrive
    details
    Two changes, one of which is a bug fix that matters more than the feature.
    
    `Session.admit()` took every due word before it would look at a new one. A word only retires
    after two consecutive correct answers, so a backlog of R words cost at least 2R answers before
    the first introduction -- in practice, 897 answers spread across 123 words while 939 had never
    been shown once. Fifteen minutes of play could produce no new word at all. New words now
    interleave on a ratio, at a pace the learner sets: slow, normal or eager. The regression test
    plays a session with a 150-word backlog and fails if nothing new appears.
    
    The corpus goes 1062 -> 1176 words. The new ones are the closed grammatical tables: the
    personal pronouns declined for case, the ez/az demonstratives, and the ki/mi question words --
    114 cells across 16 families, hand-authored because an invented form is indistinguishable from
    a real one and would be drilled as fact. Four games teach them, and every round draws only from
    one family, so the ending is the question rather than something you can skip past.
    
    Also: the trouble list finally says what the words mean. It had been eight bare Hungarian words
    with the English in a title tooltip, which does not exist on a phone -- on the one screen meant
    to answer "what am I bad at".
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  54. v83 2026-08-17 18cd5f6
    :wrench: keep the git hooks running when Windows touches them
    details
    The pre-commit hook came back from a Windows checkout rewritten with CRLF endings, which
    turns its shebang into a request for an interpreter literally named "bash\r". The hook then
    does not run at all. It is the PII scan for a public repo, so it fails in the quietest and
    least useful way possible: the commit succeeds and nothing is checked.
    
    Pinning it to LF costs nothing -- it is the only shebang file in the repo, and the working
    tree and index are already LF, so this changes no content today. *.sh is covered too, for the
    next script that gets added.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  55. v82 2026-08-17 c06bfb5
    :sparkles: szojatek: 1062 words from the re-filed lesson plans
    details
    The published corpus goes 879 -> 1062 words, all vetted, 988 of them with a pronunciation
    clip. 246 new clips ship alongside.
    
    The words come from the 50 lesson plans, which imports now leave where they are instead of
    moving them out of an inbox as they are read. Extraction offered 791 proposals: 522 inherited
    a decision already recorded in words.json, 57 were dropped again, and 212 were decided by
    hand. That pass fixed 21 typos carried in from the source documents (füllhallgató ->
    fülhallgató, küldföld -> külföld, öccs -> öcs, trolli -> troli), collapsed infinitives to the
    third-person singular the rest of the deck is written in, and dropped 73 entries that were
    headings, example sentences or duplicates rather than vocabulary.
    
    Only the built site lives here; the word database and the tooling that fills it are in the
    hungarian repo.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  56. v81 2026-08-06 4053d29
    :bug: szojatek: never serve a blank round, and hold the options back once a word is known
    details
    Odd one out was coming up blank. Its builder correctly returns nothing when there are not four
    words from topics the learner has actually met -- which got much more common after last change
    tightened what counts as a fair family -- but the empty round was served anyway and the
    component had nothing to draw. Group sort could fail the same way.
    
    Every game now declares what a built round needs to render, and the session tries its
    candidates in turn and serves the first that actually builds, falling back to multiple choice.
    Fixing it per game would have left the next builder free to do it again.
    
    Choice questions now hide their options for a few seconds once a word is familiar: three at
    level 2, four at level 3, none at level 1 where the word is still new enough that staring at a
    blank card teaches nothing. A list of answers on screen turns recall into recognition -- you
    spot the right one instead of remembering it, which is the easier task and the less useful one.
    Enter cuts the wait short. Speed round is deliberately left alone, since a deliberate pause is
    the opposite of what it is for.
  57. v80 2026-08-05 cc1ca8e
    :sparkles: szojatek: phrase parts as real words, honest topic tags, fairer odd-one-out
    details
    63 component words of multi-word entries are now entries themselves, written by hand rather
    than fetched -- Wiktionary is good at citation forms and poor at what actually turns up inside
    a phrase (`vagyok`, `benne`, `órakor`, `szénsavas`). Phrases with every part covered went from
    10 of 55 to 51, and none is left as an opaque lump. Each new word records the phrase it came
    out of, so it can be audited or dropped later.
    
    Odd one out was "sometimes a stretch", and it was, for two reasons. It could pick `describing`
    as the family -- 163 words whose only shared trait is being adjectives, which nobody can see as
    a category. And it only checked that the odd word lacked the one tag the family was grouped by,
    so a word carrying a second shared tag could honestly belong. Now vague topics cannot be a
    family, and the stranger must share no topic at all with any of the three.
    
    That surfaced worse tagging underneath. `sárgarépa` was filed under colours, because its gloss
    says "carrot (lit. yellow root)"; so were parsnip, apricot, both wines and black pepper. And
    Hungarian lemmas were being matched against English keyword lists, which made `fog` weather
    rather than a tooth. Tags now come from the gloss alone, colours needs a sense that is a bare
    colour, and a few English idioms are dropped first because their words are not about their
    subject -- "on the other hand" is not anatomy. Colours honestly has 3 words, not 11.
    
    Hangman had no "I don't know", so being stuck meant burning lives on letters you knew were
    wrong. The speed round now says each word as it comes up rather than after the answer, which
    was a beat too late to be any use.
  58. v79 2026-08-05 464455b
    :sparkles: szojatek: stop glosses leaking the answer, teach phrases as their parts
    details
    Twenty-two of the 816 glosses quote the word they define -- "lángos (deep-fried flatbread)",
    "fork (also: villa, large house)", "take-out (elvitelre: to take away)". That is fine in a
    dictionary, where you see both sides at once, and useless in a game, where the gloss is the
    prompt and the word is the answer.
    
    engine/gloss.ts trims them at the point of asking rather than editing the corpus, since Anki
    still wants the full entry. It drops bracketed asides that quote the word (matching inflected
    forms too, because Hungarian agglutinates) and a leading repetition of the word itself. The
    trailing guard matters: an earlier cut turned "deciliter" into "liter" and "motorcycle" into
    "cycle", which is worse than the leak. Nineteen of the twenty-two now read cleanly; the three
    that cannot -- hotel, modern, park, where the word simply is the English -- are kept out of the
    games that ask you to write it.
    
    Phrases are now taught as their parts. Fifty-six entries are multi-word, and meeting "egész
    hétvége" as one opaque lump wastes the fact that "egész" and "hétvége" are already cards. The
    intro and the answer card list whichever parts exist, each speaking when tapped. Grammar words
    and punctuation are skipped, so "Hány óra van?" resolves to hány? and óra rather than to van.
    
    Audio now plays wherever the Hungarian is on screen: Pár / Nincs pár, the prompt side of
    choice-en, group sort as each word lands, and -- as asked -- after correctly picking the
    Hungarian in choice-hu, which waits for the clip instead of cutting it off.
  59. v78 2026-08-05 a4a949f
    :bug: szojatek: make the free-form games actually show their result
    details
    A single Enter did two jobs. The input submitted the answer, and the same keystroke carried on
    bubbling to the window listener that dismisses the answer card -- so the card was built and torn
    down inside one keypress, and no typed answer ever showed a result. It affected all four
    free-form games: type it, fill the blanks, unscramble, hangman. The dismiss handler now ignores
    keys that arrive in the same breath as the answer, which fixes the class rather than the four
    instances.
    
    Group sort printed the English gloss under every word, so sorting needed no Hungarian at all.
    The meaning now appears once the word is committed to a group: before that it is the answer,
    after it is the thing worth learning.
    
    Affixes are no longer asked in games that want the word written out. "-féle" is glossed "-like,
    of the sort of", and there is no route from that back to the spelling.
    
    Balloon pop is gone -- it did not work and was not worth repairing.
    
    Settings now record which games are switched *off* rather than which are on. The allow-list
    meant a save written before a game existed pinned it off forever, which is why four new games
    shipped and nobody who had already played ever saw them.
    
    Also drops the PII hook's scan of compiled site bundles: minified output where 4294967296 (2^32)
    reads as a phone number and an FSRS weight array reads as GPS coordinates.
  60. v77 2026-08-05 78dfe08
    :sparkles: szojatek: make every game reachable, and confirm correct free-form answers
    details
    The difficulty bands were keyed to a familiarity no word ever reached. A word retires from a
    session after two correct answers, so it never climbed past ~3 -- which put nine of the fifteen
    games behind a gate nothing could open. A first session served four game types all evening.
    
    Games now open at the familiarity they are actually useful at, and only the pure recognition
    ones close again: picking from a list is worth nothing once you know the word, whereas a puzzle
    or a timed round stays worthwhile. Twelve of fifteen games now appear in a first session.
    
    Also adds four Wordwall-style modes (balloon pop, group sort, hangman, speed round), scales
    option counts, timers and lives with familiarity, and stops free-form modes from silently
    skipping past a correct answer -- they now show the word, its meaning and how it sounds.
    
    rotation.test.ts plays real sessions and counts what comes out, because testing gamesFor() with
    a made-up familiarity is exactly how this got through the first time.
  61. v76 2026-08-05 27a0c5b
    szojatek: Hungarian vocabulary game at /sites/szojatek/
    details
    A static word game built from the lesson-plan vocabulary database in the
    hungarian project. 816 reviewed words, 741 with native Forvo pronunciations,
    16 topic rounds and 10 game modes.
    
    Progress lives in localStorage and is scheduled with FSRS, so everyone who
    opens the link keeps their own private spaced repetition with no account and
    nothing to maintain.
    
    Co-Authored-By: Claude Opus 5 (1M context) 
  62. v75 2026-08-01 0e97d03
    remove stray _site.stale build artifact, ignore it
    details
    Co-Authored-By: Claude Fable 5 
  63. v74 2026-08-01 1408ff0
    a11y + perf + copy: contrast, heading order, image dims, home payload
    details
    - ink-3 darkened/lightened per theme to pass WCAG AA (was ~3.1:1)
    - footer h5 -> h3 for sequential heading order
    - local post images get width/height at build (no CLS)
    - home page prefetches 3 shuffle photos instead of all (~46MB -> ~2MB)
    - build log entries count as musings in the numbers box
    - build log copy touch-ups
    
    Co-Authored-By: Claude Fable 5 
  64. v73 2026-08-01 e0c0d02
    rv12is: chronological sidebar on entry pages
    details
    Co-Authored-By: Claude Fable 5 
  65. v72 2026-08-01 1e6969f
    rv12is: timeline index + photos surface under /media/rv12is
    details
    Co-Authored-By: Claude Fable 5 
  66. v71 2026-08-01 392c979
    rv12is: backfill build log from VAF thread — 14 entries, 64 photos
    details
    Co-Authored-By: Claude Fable 5 
  67. v70 2026-08-01 bb47549
    rv12is section: nav tab + reusable post sections in build
    details
    Co-Authored-By: Claude Fable 5 
  68. v69 2026-08-01 23fa9fc
    musing: fix transport mode on bmw z4 e85
    details
    Co-Authored-By: Claude Fable 5 
  69. v68 2026-05-19 683cea5
    backfill: webp + lqip on all 95 existing images
  70. v67 2026-05-19 e51a781
    webp + lqip pipeline, broken-image checker, link checker, dev watch
    details
    images:
    - process-staging.mjs now renders 6 keys per upload: jpeg + webp for
      full/med/thumb. metadata.yaml entries get s3_webp/s3_med_webp/
      s3_thumb_webp/lqip in addition to the existing jpeg urls.
    - makeLQIP renders a 24px-wide blurred jpeg and embeds it as base64
      (~600 bytes per image) in metadata as `lqip`.
    - new helper `pictureFor(im, size, alt, extraAttrs)` in build.mjs:
      emits  with WebP source + JPEG fallback + inline lqip
      background-image when available.
    - photo page main image, gallery feed view, and post-inline images
      all use pictureFor (or its inline equivalent in the markdown body
      rewriter). older entries without webp variants fall through to a
      plain .
    
    new scripts:
    - scripts/backfill-webp-lqip.mjs: walks all metadata + post
      frontmatter, downloads each S3 jpeg that's missing webp, renders
      + uploads variants, writes lqip base64, patches the yaml/md.
      idempotent.
    - scripts/check-images.mjs (`npm run check:images`): HEADs every
      S3 url referenced from content. exit 1 if any are unreachable.
    - scripts/check-links.mjs (`npm run check:links`): crawls _site
      html and HEADs every external href. opt-in, run after build.
    - scripts/dev.mjs (`npm run dev`): watches content/templates/
      design/copy.yaml + scripts/build.mjs; rebuilds on change;
      serves _site on :4321.
  71. v66 2026-05-19 74bf4a2
    perf + a11y + feeds + new pages: 8 enhancements
    details
    performance:
    - preconnect to s3 bucket in 
    - width/height attrs on every  (uses exif dims already in
      metadata) — eliminates layout shift as photos load
    - fetchpriority="high" on the photo-page hero
    
    accessibility:
    - prefers-reduced-motion: kills hover-grow transforms + cuts
      transition durations to 0.01ms for users who set the preference
    - :focus-visible: 2px accent outline + accent-3 link bg
    - skip-to-content link (visible on focus)
    
    feeds + favicon:
    - /atom.xml alongside /rss.xml (some readers prefer atom)
    -  for both feeds in head
    - design/favicon.svg: simple ~/red monogram, wired in head + copied
      to _site at build
    
    new pages:
    - /musings/tags index — alphabetical list of all tags + counts
    - /musings/YYYY year archives — group posts by year, with cross-
      links to other years
    - related-posts section on each post page (up to 3 by shared-tag
      score, then recency)
    
    random-post link:
    - "take me somewhere" link in the footer; clicking picks a random
      musing url client-side. all post urls are emitted as a json
      attribute on the link.
  72. v65 2026-05-19 3466754
    gallery view modes: folders / grid / feed
    details
    each gallery page that has sub-albums now renders a small toggle
    (top-right of the gallery) with three views:
    
    - folders (default): sub-albums above, then loose photos in this
      folder below
    - grid: every image in this subtree (this album + every descendant)
      in a single tight VSCO grid
    - feed: same set, but as a vertical scrolling stream — large centered
      images with caption + album link below each
    
    implementation:
    - build.mjs computes `allDescendantImages` (deduped by src) per node
    - template renders all three modes; CSS toggles which is visible via
      :checked sibling-selector — no JS required for the toggle
    - a tiny JS hook persists the chosen mode in localStorage so it
      survives page navigation
    - empty galleries (no sub-albums, no photos) fall through to the
      empty-state message, and single-folder galleries skip the toggle.
  73. v64 2026-05-19 25f7569
    license page + footer copyright + changelog redesign + mona/outback fixes
    details
    - /license page: full CC BY-NC 4.0 reuse terms with body + bullets +
      commercial section, all driven by copy.license in copy.yaml.
    - footer adds a 'license' link, an inline copyright line
      '© George Main · CC BY-NC 4.0', drops the 'est ' line.
    - changelog: cleaner ol-based row layout, version+date+hash inline at
      top (hash is a clickable github.com/.../commit/ link), subject
      on its own line, optional body collapsed in a 
    summary. - outback restructure: misc is no longer a sub-album. All 19 misc photos moved (S3 mv) into /media/australia/outback/ as loose photos. content/media/australia/outback/misc/ deleted. - new content/media/australia/tasmania/metadata.yaml: cover = russell-falls (better than the default first-descendant). - mona cover = fat-car (more striking than cement-truck for the album thumbnail). - mona alts: noodles-on-pedestal renamed to rubber-bands-on-pedestal (s3 mv + alt); 'red text piece by an artist commenting on artists' → 'Red text piece, MONA.'; bit.fall flipped to 'looking down at'; 'Textural close-up resembling stalactites' → 'Wall of rock inside MONA.'
  74. v63 2026-05-19 8153f77
    album cover overrides + corrections + photo-arrow layout
    details
    - build.mjs: new optional `cover:` field on a gallery's metadata.yaml
      selects which image becomes the album cover (instead of images[0]).
      cover lookup recurses into descendant galleries so virtual parents
      (e.g. /media/australia, /media/australia/outback) can pick a cover
      from any nested album.
    - new content/media/australia/metadata.yaml + .../outback/metadata.yaml
      (parent nodes that were previously virtual-only) — both pick
      uluru-sunset as cover.
    - misc covered by northern-territory-sign; kings-canyon by
      lost-city-overview.
    - s3 renames + alt fixes for the misc outback gallery: memorial-bell
      → centre-of-australia (it's the Centre of Australia cast-iron
      monument); red-dirt-campground → yulara-coach-campground;
      desert-camp → yulara-camp-overview; glamping-tents-overview →
      yulara-eco-tents-overview; camp-from-above → yulara-camp-from-above;
      glamping-tents → yulara-eco-tents.
    - kings-canyon: overhang → rim-walk-squeeze (alt: rim walk squeeze
      between two rocks); person-on-rim → canyon-rim-trail (no person
      in shot); shelby-on-rim deleted entirely.
    - uluru: uluru-from-viewpoint deleted.
    - photo page < / > arrows moved beside the image (flex row) instead
      of overlaid on top of it.
  75. v62 2026-05-19 a907fa0
    uluru: 'red-dirt-tire-tracks' was actually shoe prints in sand — rename slug + s3 keys + alt
  76. v61 2026-05-19 03bc11a
    revert palette to v2 cream — fully back to v2 design
  77. v60 2026-05-19 0ee197c
    homepage shuffle + perf + universal hover-grow
    details
    - drop the musings index subtitle ("Long-form notes...") — not needed.
      copy.musings.index.intro removed from copy.yaml and template.
    - fix random-photo shuffle: allImages was including the same image
      twice when it appeared in both a real and a synthesized-from-post
      gallery. dedupe by src URL so consecutive shuffles always land on
      a different image.
    - preload all shuffled photos via new Image() at homepage load so reroll
      swaps are instant from the browser cache.
    - hover-grow added to: album cards (.sp-album), homepage random-photo
      widget (#photo-img), photo-page main image. previously only the vsco
      grid cells and post figures had it.
  78. v59 2026-05-19 f75b129
    revert v3 design refresh — keep paper palette only
    details
    user feedback: v3 paper-card design felt worse (abrupt bottom edge,
    broke the stats grid boxes). preferred the v2 chrome but liked the
    new paper color. so revert tokens.css + site.css + templates/ to
    commit 6ce9249 (v2-final), then patch in only the cool paper palette
    (#f2f1ec bg, #b21f1a accent) from v3.
    
    what stays from v3: just the color palette.
    what's restored from v2: section markers, paper-card-free shell,
    stats-grid boxes, all template structure.
  79. v58 2026-05-19 d525491
    v3 design refresh: paper palette, less power-user chrome
    details
    design bundle update from claude design tool:
    - new tokens.css: cool off-white paper (#f2f1ec), no beige. deeper
      burgundy red accent (#b21f1a). subtle SVG noise texture. gutter 40px.
    - new site.css: content sits on a paper card (.site-shell now has
      background + border-left/right). section markers are now a centered
      '· · ·' ornament + small italic '— a random musing —' kicker, not
      '// 01 · words' mono captions. headings are serif weight 600, not
      mono. tags are serif italic. .sp-tree replaced by nested ul. new
      .sp-photo-frame + .sp-photo-meta + .sp-photo-counter classes.
    - templates:
      - drop mono '~/musings · 67 posts' breadcrumbs everywhere.
      - musing/category/musings-index now share the .musings-layout with
        a tree sidebar (was: index didn't have the tree). class renamed
        musings-main -> musings-body.
      - serif italic crumbs with › separators (Musings › Travel › Japan).
      - written-out dates (May 11, 2026) instead of 2026·05·11.
      - drop file-path lines from random widgets — title is the title.
      - photo page switches table → 
    + n of total counter + keyboard hint. - build.mjs: fmtLongDate helper, exposed to templates. shuffled musings/ photos + recent + stats all carry dateLong now. - footer: drop "est " line; version
  80. gets class="version".
  81. v57 2026-05-19 6ce9249
    photo page: floating < / > arrows on either side of the image
  82. v56 2026-05-19 4bf652b
    upload mt field national park gallery (russell, horseshoe, lady barron falls)
    details
    5 photos from sony rx100 vii, ~20mb of originals on s3, ~0 bytes in repo.
    metadata.yaml at content/media/australia/tasmania/mt-field/ with full
    camera/lens/settings + dimensions captured from sharp.
    
    also:
    - alt text is now optional with a warning, not a hard build failure
      (build.mjs + process-staging.mjs)
    - build auto-creates virtual parent gallery nodes for any folder depth,
      so /media/australia and /media/australia/tasmania resolve as routes
      even without metadata.yaml at those levels
  83. v55 2026-05-19 5612e1a
    backfill image dimensions; capture w/h via sharp on every ingest going forward
  84. v54 2026-05-19 a6bc841
    photo page: add size, dimensions + megapixels rows
  85. v53 2026-05-19 72b7a27
    cap image heights so portrait shots fit the viewport without forcing scroll
    details
    - homepage random photo: max-height 60vh
    - photo page: max-height 82vh, width auto
    - post figures full-width: max-height 75vh with object-fit contain
    - inline-floated images keep natural sizing
  86. v52 2026-05-19 930e53c
    fix: featured-image entry now picks up s3 urls from frontmatter.images map
  87. v51 2026-05-19 bad8ddf
    all images on s3 — zero image bytes in repo (migration of existing posts done)
  88. v50 2026-05-19 4b8bc4e
    stats: pictures-size pulls recorded full-size bytes (s3 original) when available
  89. v49 2026-05-19 dcba78f
    drop accidentally-committed AWSCLIV2.pkg installer
  90. v48 2026-05-19 39ff258
    s3 originals + camera EXIF metadata
    details
    scripts/process-staging.mjs:
    - reads full EXIF (camera, lens, ISO, aperture, shutter, focal length, dimensions)
      in addition to date/GPS
    - scrubs the original (strips all EXIF including GPS via sharp's default behavior)
    - uploads scrubbed full-size original to s3://georgemain-com-media//
    - writes a smaller 1400px local reference into content/media/ for build-time
      thumb/med generation (keeps repo lean)
    - metadata.yaml entries now carry { file, s3, alt, title, date, location, exif }
    
    templates/media-photo.eta:
    - camera/lens/settings/dimensions table when exif present
    - "view full size ↗" link to the S3 original when s3 is set,
      falls back to local file for older entries
  91. v47 2026-05-19 76dff3a
    homepage: by-the-numbers stats grid (musings/words/pictures/size/dates)
  92. v46 2026-05-19 59a0edc
    japan: fix year, 2023 not 2024
  93. v45 2026-05-19 33d3007
    category page: replace .sp-tree (white-space: pre eats template whitespace) with compact .cat-list
  94. v44 2026-05-19 de6aa62
    japan: add Kiyomizu-dera title image
  95. v43 2026-05-19 e519ce4
    japan: replace vending machine photo with higher-res original
  96. v42 2026-05-19 8ec7fd5
    dates: budapest 2025-11-01, japan 2024-09-01
  97. v41 2026-05-19 11496dd
    cleanup: wire home button copy keys, strip orphan token-viewer css, better build error messages
    details
    - templates/home.eta: random/next/read/open buttons now read their labels
      from copy.yaml's home.random_musing.* and home.random_photo.*. previously
      hardcoded.
    - design/site.css: dropped the .tg-* class block (~64 lines). these were
      styles for the token-viewer page in the design canvas, never used in
      production.
    - scripts/build.mjs: error messages now lead with the file path and name
      the exact missing field — e.g. ": image 'foo.jpg' is missing
      required 'alt' text" instead of "Image alt missing in ...".
  98. v40 2026-05-19 cdbe826
    footer: simpler creed, drop em-dash
  99. v39 2026-05-19 07b7323
    GBR post: hero caption + boats-my-whole-life note
  100. v38 2026-05-19 96ff541
    per-photo pages with prev/next nav (vsco-style)
    details
    clicking a gallery cell now opens a dedicated photo page at
    /media/// instead of the raw image file. each page:
    
    - big image with caption (alt text)
    - metadata table: title, date, where, album, original file link
    - prev / next nav (wraps around at the ends)
    - counter "3 / 12"
    - left/right arrow keys also navigate
    - ↑ back to  link
    
    works for both real galleries and synthesized-from-musing galleries.
  101. v37 2026-05-19 e2f424c
    auto-derive media gallery path for any musing with images
    details
    - before: synthesis was opt-in via gallery.path
    - now: any post with images auto-gets a gallery. default path = post
      category minus first segment + slug. override with gallery.path,
      disable with gallery: false.
    - trim verbose alt text on three images
    - make nunobiki falls inline (small) instead of full-width
    - add CLAUDE.md
  102. v36 2026-05-19 6d037ae
    tree sidebar: nested ul (wraps long names) + mobile-collapsed by default
    details
    - renderTreeHtml outputs nested 
      so long names like "great-barrier-reef" wrap naturally instead of being clipped by the fixed sidebar column. - depth indent via padding-left + dashed left-border guide (cleaner than ASCII glyphs that depend on white-space: pre). - mobile (<=900px): tree is hidden behind a "browse ▾" toggle (pure CSS checkbox hack). desktop unaffected — tree always visible. - previous spaces+pre approach is gone; no more clipping or weird wrap. also fixed earlier issue: vsco gallery captions now appear BELOW the cell on hover, not overlaid on the photo.
  103. v35 2026-05-19 173850e
    fix: stripe pattern bleeding through over real photos in gallery cells
  104. v34 2026-05-19 207888d
    synthesize media galleries from musings' frontmatter
    details
    opt-in via gallery: { path, title, subtitle, location, date } on a musing.
    build walks ancestor segments to create virtual parent gallery nodes so
    breadcrumbs and album-tree work. images stay single-source in the post's
    media/ folder (no duplication). synthesized gallery + each card show a
    "↩ from the musing: " link back to the source post.
    
    skip the page-route check for image/static asset urls.</pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v33</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/95e4eef" title="view commit on github">95e4eef</a>
              </div>
              <div class="cl-subject">fix: don't double-escape alt/caption; trim hero caption</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v32</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/c4f338d" title="view commit on github">c4f338d</a>
              </div>
              <div class="cl-subject">post figures: scale only when hovering the image itself, not the caption</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v31</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/d8417fe" title="view commit on github">d8417fe</a>
              </div>
              <div class="cl-subject">post figures: subtle hover-enlarge to match media grid</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v30</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/4c01161" title="view commit on github">4c01161</a>
              </div>
              <div class="cl-subject">fix: marked v14 image renderer signature — hero figures were rendering with undefined href</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v29</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/efd8993" title="view commit on github">efd8993</a>
              </div>
              <div class="cl-subject">constrain post figure images to figure width</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v28</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/9ef4aff" title="view commit on github">9ef4aff</a>
              </div>
              <div class="cl-subject">fix: dedupe featured_image key in great barrier reef post</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v27</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/1e35208" title="view commit on github">1e35208</a>
              </div>
              <div class="cl-subject">great barrier reef: add hero photo + inline deck shot</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v26</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/ad3c2ce" title="view commit on github">ad3c2ce</a>
              </div>
              <div class="cl-subject">decode html entities when stripping markdown for previews</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v25</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/c1c4611" title="view commit on github">c1c4611</a>
              </div>
              <div class="cl-subject">add folder-tree sidebar to musing posts + tighten category page</div>
              <details class="cl-detail"><summary>details</summary><pre>- build.mjs: walks musings into a {folder|post} tree, renders to HTML
      with active/in-active classes. Passed to musing.eta + musings-category.eta.
    - musing.eta + musings-category.eta: 2-col grid (200px tree + reading column).
    - site.css: .musings-layout grid, sticky tree sidebar, :has() override widens
      the site-shell to 1080px on musing pages so the tree fits without squishing
      the reading column. Mobile collapses to single column under 900px.
    - category page restructured: no more 3 stacked sp-sections each with 64px
      top margin + horizontal rule. Now subfolders + posts are tight sequential
      blocks with just a thin rule + // caption between them.</pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v24</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/4e2ecb2" title="view commit on github">4e2ecb2</a>
              </div>
              <div class="cl-subject">drop top gap+rule above the first section on a page</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v23</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/8f61de6" title="view commit on github">8f61de6</a>
              </div>
              <div class="cl-subject">fix: random/next buttons — eta 3 doesn't recognize <%- so JSON payload was empty</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v22</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/e23c49d" title="view commit on github">e23c49d</a>
              </div>
              <div class="cl-subject">apply v2 design refresh — toned-down retro</div>
              <details class="cl-detail"><summary>details</summary><pre>design bundle update from claude design tool:
    - flat background (drop grid texture). content column narrowed 1240→820.
    - new tokens.css: red-only, ibm plex only, fs ramp adjusted, lh-body=1.65,
      reading=680px / content=760px / max=820px / gutter=32px.
    - new site.css: section markers are now thin top rule + "// 01 · words"
      caption (was the boxy [§ ...] rail). random-musing + random-photo blocks
      drop the panel chrome entirely (sp-panel → sp-block). tags become plain
      #tag text (no border/chip). lighter buttons, no offset shadow. new
      sp-link-btn ([text] inline link-style button). albums grid now 2-col.
    - templates updated to match: home is content-first with sp-block + sp-link-btn,
      musings index uses inline browse strip (folder list + tag chips) instead
      of right sidebar, footer uses sp-footer-mark class and inlines version + est.</pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v21</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/add0fa0" title="view commit on github">add0fa0</a>
              </div>
              <div class="cl-subject">changelog: render from git log instead of manual yaml entries</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v20</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/84e328c" title="view commit on github">84e328c</a>
              </div>
              <div class="cl-subject">footer: keep version label, drop attribution</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v19</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/5580e81" title="view commit on github">5580e81</a>
              </div>
              <div class="cl-subject">remove footer attribution line entirely</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v18</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/8342f10" title="view commit on github">8342f10</a>
              </div>
              <div class="cl-subject">fix html/body sizing — use 100vh on body, drop 100% on html</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v17</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/f8489f5" title="view commit on github">f8489f5</a>
              </div>
              <div class="cl-subject">remove stale hero comment from copy.yaml</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v16</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/55e1bc3" title="view commit on github">55e1bc3</a>
              </div>
              <div class="cl-subject">remove homepage hero — straight into the random-musing widget</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v15</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/8d8a934" title="view commit on github">8d8a934</a>
              </div>
              <div class="cl-subject">fix scroll clipping on long pages + relabel footer attribution</div>
              <details class="cl-detail"><summary>details</summary><pre>- design/site.css: remove overflow:hidden from .site-page. it was
      there for design-canvas artboards but clips body scroll on the
      real site. long posts now scroll.
    - copy.yaml: footer.built_by_hand "built by hand" -> "built with claude".
      truthful and matches the changelog tone (prose hand-written, finished
      with AI).</pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v14</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/2738965" title="view commit on github">2738965</a>
              </div>
              <div class="cl-subject">add staging/ media ingest workflow + EXIF GPS check in pre-commit</div>
              <details class="cl-detail"><summary>details</summary><pre>- scripts/process-staging.mjs: interactive CLI (npm run media)
      - reads EXIF date/GPS via exifr
      - groups bursts (same filename prefix + capture time within 30 min)
      - asks gallery / batch date / batch location once per burst
      - per-image: title (optional) + alt (required)
      - strips ALL metadata on output (sharp default behavior)
      - moves file out of staging/ into content/media/<gallery>/
      - appends entry to metadata.yaml
      - offers img_NNN renaming
    - scripts/check-pii.mjs now also scans staged image EXIF and fails
      on GPS coords, so missed scrubs are caught at commit time.
    - .claude/skills/process-staging/SKILL.md as the chat-friendly entry point.
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com></pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v13</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/05325b9" title="view commit on github">05325b9</a>
              </div>
              <div class="cl-subject">add CI deploy, PII pre-commit hook, claude code skills</div>
              <details class="cl-detail"><summary>details</summary><pre>- .github/workflows/deploy.yml: build + deploy to GitHub Pages on push to main
    - .githooks/pre-commit + scripts/check-pii.mjs: regex scan of staged diff for
      emails, phones, SSNs, CC#s, GPS coords, precise timestamps, US addresses.
      optional AI second-pass when ANTHROPIC_API_KEY is set.
      .pii-allowlist.yaml lets known-public strings through.
    - npm run setup wires git hooks (one-time per clone).
    - .claude/skills/: add-musing, add-images-to-musing, add-media-gallery,
      add-images-to-gallery, add-tab, seo-audit, add-static-site, pii-audit.
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com></pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v12</span>
                <span class="cl-date">2026-05-19</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/8bae61c" title="view commit on github">8bae61c</a>
              </div>
              <div class="cl-subject">add static site generator + copy.yaml + initial musings</div>
              <details class="cl-detail"><summary>details</summary><pre>build pipeline: node + eta templates + sharp for images. content lives in
    content/musings (markdown + frontmatter) and content/media (yaml + jpegs).
    all human-facing copy is in copy.yaml so it can be edited in one place.
    version auto-increments from git commit count. light/dark toggle persists
    to localStorage. existing flight pages moved to content/sites/ and pass
    through to /sites/* unchanged.
    
    Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com></pre></details>        </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v11</span>
                <span class="cl-date">2026-03-12</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/f91203c" title="view commit on github">f91203c</a>
              </div>
              <div class="cl-subject">update timing and add pilot stats</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v10</span>
                <span class="cl-date">2026-03-12</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/b67dadb" title="view commit on github">b67dadb</a>
              </div>
              <div class="cl-subject">update for kml</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v9</span>
                <span class="cl-date">2026-02-10</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/9cc0070" title="view commit on github">9cc0070</a>
              </div>
              <div class="cl-subject">add cache</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v8</span>
                <span class="cl-date">2026-02-10</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/387e56b" title="view commit on github">387e56b</a>
              </div>
              <div class="cl-subject">add flight viewer</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v7</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/088e60f" title="view commit on github">088e60f</a>
              </div>
              <div class="cl-subject">add desktop version</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v6</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/7c990c0" title="view commit on github">7c990c0</a>
              </div>
              <div class="cl-subject">mobile</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v5</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/c3a80ef" title="view commit on github">c3a80ef</a>
              </div>
              <div class="cl-subject">add first-flight</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v4</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/d35930a" title="view commit on github">d35930a</a>
              </div>
              <div class="cl-subject">Create CNAME</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v3</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/c5518ef" title="view commit on github">c5518ef</a>
              </div>
              <div class="cl-subject">Delete CNAME</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v2</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/db73e37" title="view commit on github">db73e37</a>
              </div>
              <div class="cl-subject">Create CNAME</div>
                      </li>
                  <li class="cl-row">
              <div class="cl-line">
                <span class="cl-ver">v1</span>
                <span class="cl-date">2026-01-31</span>
                <a class="cl-hash" href="https://github.com/gemivnet/gemivnet.github.io/commit/847b4e5" title="view commit on github">847b4e5</a>
              </div>
              <div class="cl-subject">Initial commit</div>
                      </li>
              </ol>
      </section>
    
    <style>
      .changelog { list-style: none; padding: 0; margin: 0; }
      .cl-row {
        padding: var(--sp-3) 0;
        border-top: 1px solid var(--rule);
      }
      .cl-row:last-child { border-bottom: 1px solid var(--rule); }
      .cl-line {
        font-family: var(--font-mono);
        font-size: var(--fs-tiny);
        color: var(--ink-3);
        display: flex;
        gap: var(--sp-4);
        align-items: baseline;
        margin-bottom: 4px;
      }
      .cl-line .cl-ver { color: var(--accent); font-weight: 600; font-size: var(--fs-small); min-width: 4em; }
      .cl-line .cl-hash {
        margin-left: auto;
        color: var(--ink-3);
        opacity: 0.7;
        text-decoration: none;
      }
      .cl-line .cl-hash:hover { color: var(--accent); opacity: 1; }
      .cl-subject { color: var(--ink); font-size: var(--fs-body); line-height: 1.45; }
      .cl-detail { margin-top: var(--sp-2); font-family: var(--font-mono); font-size: var(--fs-small); }
      .cl-detail summary { cursor: pointer; color: var(--ink-3); }
      .cl-detail summary:hover { color: var(--accent); }
      .cl-detail pre {
        white-space: pre-wrap;
        margin: var(--sp-2) 0 0;
        padding-left: var(--sp-4);
        border-left: 2px solid var(--rule);
        color: var(--ink-2);
      }
      @media (max-width: 560px) {
        .cl-line { flex-wrap: wrap; }
        .cl-line .cl-hash { margin-left: 0; }
      }
    </style>
      </main>
    
      <footer class="sp-footer">
        <div class="sp-footer-inner">
          <div>
            <div class="sp-footer-mark"><span class="tilde">~</span>/george.main</div>
            <p class="creed">a personal knowledge page, made for fun. no ads, no tracking, no warranty.</p>      </div>
          <div>
            <h3>elsewhere</h3>
            <ul>
                          <li><a href="https://github.com/gemivnet">github.com/gemivnet</a></li>
                          <li><a href="mailto:george.main@gemiv.net">george.main@gemiv.net</a></li>
                      </ul>
          </div>
          <div>
            <h3>this site</h3>
            <ul>
                          <li><a href="/rss.xml">/rss.xml</a>  <span style="color: var(--accent);">●</span></li>
                          <li><a href="/changelog">/changelog</a></li>
                                    <li><a href="#" id="random-musing-link" data-urls='["/musings/cars/z4/transport-mode","/musings/travel/australia/great-barrier-reef","/musings/travel/budapest/suggestions","/musings/travel/new-zealand/suggestions","/musings/travel/japan/suggestions"]'>take me somewhere</a></li>
                        <li><a href="/license">license</a></li>
              <li style="margin-top: var(--sp-3); color: var(--ink-3);">© George Main · CC BY-NC 4.0</li>
              <li class="version" style="color: var(--ink-3);">v136</li>
            </ul>
          </div>
        </div>
      </footer>
    
      <script>
        (function () {
          var page = document.body;
          var toggle = document.getElementById('theme-toggle');
          function setTheme(t) {
            if (t === 'dark') { page.classList.remove('light'); toggle.innerHTML = '☽ dark'; }
            else { page.classList.add('light'); toggle.innerHTML = '☀ light'; }
            try { localStorage.setItem('gm-theme', t); } catch (_) {}
          }
          try { var s = localStorage.getItem('gm-theme'); if (s) setTheme(s); } catch (_) {}
          toggle && toggle.addEventListener('click', function () {
            setTheme(page.classList.contains('light') ? 'dark' : 'light');
          });
    
          var rnd = document.getElementById('random-musing-link');
          if (rnd) {
            rnd.addEventListener('click', function (e) {
              e.preventDefault();
              try {
                var urls = JSON.parse(rnd.getAttribute('data-urls') || '[]');
                if (urls.length) window.location.href = urls[Math.floor(Math.random() * urls.length)];
              } catch (_) {}
            });
          }
        })();
      </script>
    </body>
    </html>