~/changelog · currently v136
changelog
Commits to this site, newest first. Versions auto-increment per push. Co-authored with claude.
-
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
-
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
-
Szójáték: audio credits for the new clips, with the synthesised voices restored
details
Co-Authored-By: Claude Fable 5.1
-
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
-
:airplane: rv12is: a visit to Flightline Interiors
details
Co-Authored-By: Claude Fable 5.1
-
:airplane: rv12is: Vans signs off on the rivets and the spar, and two new LSRM-As
details
Co-Authored-By: Claude Fable 5.1
-
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)
-
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)
-
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)
-
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)
-
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
-
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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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 -
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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)
-
: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) -
: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)
-
: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)
-
: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)
-
: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.
-
: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.
-
: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.
-
: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.
-
: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.
-
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)
-
remove stray _site.stale build artifact, ignore it
details
Co-Authored-By: Claude Fable 5
-
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
-
rv12is: chronological sidebar on entry pages
details
Co-Authored-By: Claude Fable 5
-
rv12is: timeline index + photos surface under /media/rv12is
details
Co-Authored-By: Claude Fable 5
-
rv12is: backfill build log from VAF thread — 14 entries, 64 photos
details
Co-Authored-By: Claude Fable 5
-
rv12is section: nav tab + reusable post sections in build
details
Co-Authored-By: Claude Fable 5
-
musing: fix transport mode on bmw z4 e85
details
Co-Authored-By: Claude Fable 5
-
backfill: webp + lqip on all 95 existing images
-
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.
-
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.
-
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.
-
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.' -
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.
-
uluru: 'red-dirt-tire-tracks' was actually shoe prints in sand — rename slug + s3 keys + alt
-
revert palette to v2 cream — fully back to v2 design
-
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. -
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.
-
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 → -
photo page: floating < / > arrows on either side of the image
-
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
-
backfill image dimensions; capture w/h via sharp on every ingest going forward
-
photo page: add size, dimensions + megapixels rows
-
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
-
fix: featured-image entry now picks up s3 urls from frontmatter.images map
-
all images on s3 — zero image bytes in repo (migration of existing posts done)
-
stats: pictures-size pulls recorded full-size bytes (s3 original) when available
-
drop accidentally-committed AWSCLIV2.pkg installer
-
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 -
homepage: by-the-numbers stats grid (musings/words/pictures/size/dates)
-
japan: fix year, 2023 not 2024
-
category page: replace .sp-tree (white-space: pre eats template whitespace) with compact .cat-list
-
japan: add Kiyomizu-dera title image
-
japan: replace vending machine photo with higher-res original
-
dates: budapest 2025-11-01, japan 2024-09-01
-
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 ...". -
footer: simpler creed, drop em-dash
-
GBR post: hero caption + boats-my-whole-life note
-
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. -
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
-
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.
-
fix: stripe pattern bleeding through over real photos in gallery cells
-
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. -
fix: don't double-escape alt/caption; trim hero caption
-
post figures: scale only when hovering the image itself, not the caption
-
post figures: subtle hover-enlarge to match media grid
-
fix: marked v14 image renderer signature — hero figures were rendering with undefined href
-
constrain post figure images to figure width
-
fix: dedupe featured_image key in great barrier reef post
-
great barrier reef: add hero photo + inline deck shot
-
decode html entities when stripping markdown for previews
-
add folder-tree sidebar to musing posts + tighten category page
details
- 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. -
drop top gap+rule above the first section on a page
-
fix: random/next buttons — eta 3 doesn't recognize <%- so JSON payload was empty
-
apply v2 design refresh — toned-down retro
details
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.
-
changelog: render from git log instead of manual yaml entries
-
footer: keep version label, drop attribution
-
remove footer attribution line entirely
-
fix html/body sizing — use 100vh on body, drop 100% on html
-
remove stale hero comment from copy.yaml
-
remove homepage hero — straight into the random-musing widget
-
fix scroll clipping on long pages + relabel footer attribution
details
- 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).
-
add staging/ media ingest workflow + EXIF GPS check in pre-commit
details
- 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/
/ - 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 -
add CI deploy, PII pre-commit hook, claude code skills
details
- .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
-
add static site generator + copy.yaml + initial musings
details
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
-
update timing and add pilot stats
-
update for kml
-
add cache
-
add flight viewer
-
add desktop version
-
mobile
-
add first-flight
-
Create CNAME
-
Delete CNAME
-
Create CNAME
-
Initial commit