Skip to content

feat(schedule): change the font on a presence-gated timer - #25

Merged
datapointchris merged 15 commits into
mainfrom
font-schedule
Oct 18, 2026
Merged

datapointchris merged 15 commits into
mainfrom
font-schedule

Conversation

@datapointchris

@datapointchris datapointchris commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

font changes the font only when asked, so the fonts font random weighs toward get time on screen only when someone remembers to run it. This adds font schedule, a timer that changes the font while you are at the machine. It also adds what the history needs to judge the result: a trigger on every apply row, usage hours counted per machine, and each font's scheduled turns in font rank.

What to look at

  1. lib/schedule.sh (0ccfd50). schedule_decide and schedule_run decide from this machine's latest apply row, and the outcome is read back off the history row the apply wrote. Check that a check which wrote no row can never record applied.
  2. _JQ_TIMELINE_DEFS in lib/storage.sh (245a145). Every reading of usage hours goes through apply_spans. Check that another machine's latest apply never runs to now.
  3. log_action (11da426). A scheduled row carries machine, platform, ts, font, action and trigger, and no terminal fields. Check every reader of .terminal against a row without one.
  4. ghostty_reload in lib/terminals/ghostty.sh (f30b28c). Check it can never start a Ghostty that was not running.
  5. start_session_daemon in lib/arch-apps.sh (f1a202d). Waybar has to restart outside the unit's cgroup, or systemd kills it when the oneshot exits.
  6. _schedule_explain_quiet in lib/schedule.sh (316d709). Check that a stopped timer and a run that fails before recording each get their own message.
  7. get_schedule_turns in lib/storage.sh (9983162). A turn ends at the next apply of another font on the same machine.

How it was verified

  • bats tests/: 49 tests pass. Summed per-test time was 6.1 s and 6.3 s on two runs. Main's 53 tests summed 6.4 s and 6.2 s, run alternately with the branch. The branch's wall time was 11.1 s and 12.5 s on two runs, so it moves with load on the box.

  • font schedule run ran three times as a real systemd user oneshot in dev mode.

    • With WAYLAND_DISPLAY and HYPRLAND_INSTANCE_SIGNATURE unset, it recorded blocked and the unit exited non-zero.

    • With the display session's logind IdleHint set, it recorded away.

    • With a change due, it applied JetBrains Mono Nerd Font to Ghostty, kitty, Waybar, hyprlock and dunst. Waybar was still running in the session scope afterward. The row it wrote, with the machine and timestamp elided:

      {"machine":"…","platform":"archlinux","ts":"…","font":"JetBrains Mono Nerd Font","action":"apply","trigger":"schedule"}
  • font schedule on, status and off in dev mode, from a copy of the checkout.

    • on wrote only font-schedule-dev.service and font-schedule-dev.timer, with FONT_ENV=development in the service.
    • With the copy's bin/font deleted, the timer fired and the service failed with status=203/EXEC. Given a twenty-minute quiet spell holding that trigger, _schedule_explain_quiet printed No check recorded for 20m, though the timer runs: each run fails first. and named journalctl --user -u font-schedule-dev.service.
    • Given a window starting after the trigger, it printed No check for 20m, so the timer is not running.
    • off left no unit files.
  • Ghostty on Linux, with a window open: ghostty_apply redrew the window in the new font, seen in a screenshot. The same D-Bus call by hand printed () and redrew it too.

  • The font info preview, on a five-hour-old apply of the font Ghostty's config names, printed Usage time: 5h and Sessions: 1 (avg 5h). Before 82e1175 it printed Usage time: not used and Sessions: 1 (avg 0s).

  • Usage hours on a real multi-machine history, before and after the per-machine timeline:

    Font Before After
    Monaspice Ne Nerd Font 40d 2h 147d 15h
    Roboto Mono Nerd Font 55d 22h 134d 11h
    Meslo LGM Nerd Font 69d 15h 124d 10h
    Fira Code Nerd Font 9d 23h 2d 9h
  • font apply, font apply "Nonexistent Mono", font note, font reject, font sync bogus and font bogus each exit 2 with nothing on stdout. A bare font sync prints its subcommands and exits 0.

  • Four tests were proved able to fail by undoing the fix each pins, then restoring it:

    • -- removed from the name check: font apply --version validated.
    • The machine filter removed from get_recently_used: another machine's font came back.
    • The normalizer removed from get_machine_id: a host named Linux-Box lost its own rows.
    • -h removed from main()'s help arm: font reject -h rejected the font with -h as the reason.
  • The previous release's footer, rendering a font whose latest apply was scheduled, shows Terminal: unknown under Last Session. This branch shows the latest apply typed in a terminal: Terminal: kitty, its size and its font size.

  • Not established: whether a running kitty re-renders a changed font_family when its config reloads. kitty was sent the reload and its config held the new font. kitty's documentation lists the options a reload cannot change, and font_family is not among them.

What changes

  • font schedule on|off|status|run. A systemd user timer on Linux or a launchd agent on macOS checks every five minutes, at two minutes past each five-minute mark. A change is due six hours after a scheduled apply and twelve after a hand apply. It waits while logind marks the display session idle or locked, or on macOS until there has been input in the last ten minutes. WSL is refused.
  • A schedule turned on from a development checkout is its own unit, font-schedule-dev, beside the installed copy's. Turning either on or off leaves the other alone.
  • font schedule status tells a stopped timer from runs that fail before they record. The second case names the journal on Linux, or schedule.log on macOS.
  • Apply rows carry "trigger":"manual" or "trigger":"schedule". A row without one reads as manual.
  • Usage hours in font rank, font stats, font current and font info end each apply at the next apply on the same machine. On a history spread across machines they move by weeks, as the table above shows.
  • font last toggles between the two fonts most recently applied on this machine.
  • The font info and font change previews count the time of the font on screen.
  • The machine id goes through the normalizer the history uses, so a host whose rows carry a legacy spelling of its name finds them.
  • font rank lists each ranked font's scheduled turns under its two rankings: kept out of turns, switched, and the median time before a switch.
  • An apply redraws open Ghostty windows on Linux, through Ghostty's reload-config action on the session bus. On macOS Ghostty shows the font after a config reload or in a new window, and a hand apply prints that.
  • An apply restarts Waybar instead of sending it SIGUSR2. Waybar 0.15 with an mpris module crashed on SIGUSR2.
  • Usage errors exit 2 and print to stderr. font apply with an unknown name and no near match exited 1 and printed nothing. It now prints the error and points at font list.

Decisions, and what they rejected

  • A check's outcome is read off the history row, never the apply's exit status. An apply that wrote its row and then failed one reload still changed the font. One that exited 0 without a row changed nothing.
  • A scheduled turn ends at the next apply of another font. A same-font re-apply continues a session, so it does not end a turn either.
  • Checks run at two minutes past each five-minute mark. Another tool restarting Waybar on the marks would race this one. Two restarts at one moment can leave two bars.
  • A scheduled row carries no terminal fields. The timer runs with no terminal attached. Recording the terminal whose config it wrote would credit that terminal with a session nobody typed in. get_last_terminal_context and the per-font terminal mode skip rows without a terminal.
  • The scheduled path pushes in the foreground and skips the background sync. systemd kills a oneshot's leftover processes when it exits, and a backgrounded push is one of them.
  • Ghostty reloads through its D-Bus action, never through SIGUSR2. SIGUSR2's default action ends a process with no handler for it, so a Ghostty build without one would exit. The call runs only while a ghostty process exists, because calling its bus name would start one.
  • The schedule, storage and sync code stays a copy of code another tool also carries. A shared bash library was rejected. This code is to be ported to Go, where the shared half becomes one package, so a bash library would be a third copy to port.
  • Smallest change that would work — the schedule alone, on the merged timeline. The table above rules it out: on one merged timeline another machine's apply ends this screen's font, so hours and turns would count time on the wrong screen.

Risk and rollback

Merging cuts a release, and each machine takes it on its next update. A revert leaves a timer on every machine that ran font schedule on, so run font schedule off there first. Rows already written keep their trigger, which the previous release does not read. That release shows Terminal: unknown under a font's Last Session when the font's latest apply was scheduled.

What this does not do

  • It does not redraw Ghostty on macOS. The macOS Ghostty 1.3.1 binary carries no SIGUSR2 reload, and this change adds no other route to it.
  • It does not run on WSL, which has no display session to read.

The review

#25 (comment) — 4 correctness, 6 design, 1 prose.

Correctness

  1. fixed — f30b28c. Through Ghostty's D-Bus reload-config action rather than pkill -USR2, for the reason under Decisions.
  2. fixed — 8df778a.
  3. fixed — 316d709.
  4. fixed — 82e1175.

Design and architecture

  1. fixed — 4d2953c, by the finding's second remedy. The copy stays, and its comments state the Waybar race without naming another tool. The shared library is rejected under Decisions.
  2. fixed — 70a5994 and 0448a9b. One walk replaces the five literal tests.
  3. fixed — 70a5994. The test runs font apply --version, another of grep's flags, where the finding proposed -q.
  4. fixed — 70a5994.
  5. fixed — 70a5994.
  6. fixed — 70a5994.

Prose

  1. fixed — README.md:82 in f30b28c, README.md:258 in 316d709, and the other three in 4d2953c.

lib.sh and storage.sh each defined detect_platform, and every entry point
sources storage.sh after lib.sh, so lib.sh's copy never ran. The two had
already drifted: one read /etc/os-release and the other answered "linux".

terminal.sh branches on the platform, so it now sources storage.sh itself,
behind a source guard like lib.sh's.
…lizer

get_history normalizes every row's machine, stripping the legacy
macos-, archlinux-, arch-, wsl-, linux- and unknown- prefixes, while
get_machine_id returned the raw hostname. Sync compares the two by
equality: a host named linux-box reads its rows back as "box", so a push
sent none of them and a pull dropped them from the local history.

The id now goes through the same normalizer, so writes, comparisons and
the gist filename agree. It moves into storage.sh beside that normalizer.
Usage time ended an apply at the next apply in the merged history, so a
font on one machine stopped counting the moment another machine changed
its own font. On the real history Monaspice Ne reads 147 days rather than
40, and Roboto Mono 134 rather than 56.

The four readers - calculate_usage_time, get_font_usage_stats,
get_font_monthly_usage_map and get_stats_machine_favorites - each carried
their own copy of that timeline. They now share one apply_spans
definition that groups applies by machine, so they agree by construction.
Only this machine's latest apply runs to now. Every other machine's
latest is left open, because nothing here knows when it ended. Sessions,
which collapse consecutive applies of one font, are counted per machine.

parse_ts lived in six programs. It lives in the same definitions.
…hedule

log_action takes an optional trigger and writes it only when given.
apply_font_cmd writes "manual", and a row without the key reads as
manual, since every apply before it was typed by hand. A scheduled apply
also skips the backgrounded push, because its unit would end it.

A scheduled apply runs with no terminal, so its row carries the machine
and platform and none of the terminal fields, which would otherwise read
"unknown" and outvote the terminals the font was really used in. Storage
now writes machine and platform on every row itself, and the terminal
context carries only what a terminal reports. The per-font page's last
session skips applies with no terminal.

font log names the schedule where a terminal would be, and the per-font
history says "applied by the schedule".

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Used: read every test the diff adds or edits, and every existing test of code the diff changes, beside that code. Ran bats tests/ on the branch in the suite's own sandbox: 57 of 57 pass.
Standards: testing.md § "Test behavior the user cares about, not what this commit happened to change"; testing.md § "Never assert on rendered output — assert the value it was built from"; pull-requests.md § "Tests the diff adds are checked for whether they test the program or the change".
Findings: 5 design

Kept: all three tests in tests/schedule.bats pin behavior a user reaches. They cover the hold each kind of apply earns, the presence gate, and the turn counts font rank prints. Both edited or added tests in tests/stats.bats stay too. One pins per-machine sessions. The other pins that a timer apply never outvotes a terminal. stats.bats "font rank sorts by likes and by hours apart, over curated unrejected fonts" reads rendered rows. It stays, because show_rankings applies the registry and rejection filter, and nothing else pins that filter.

Design and architecture

  1. tests/help.bats — five tests assert that literal help rows and headings from commands_table and usage appear in the output. Delete each one:

    • "the help screen lists every verb the table carries"
    • "a flat verb gets its own synopsis, not the whole screen"
    • "a verb with several rows prints all of them"
    • "an unknown verb falls back to the full screen"
    • "font help still renders the commands and the examples"

    Every string they match is a heredoc literal in bin/font. Reach: this diff adds four schedule rows and rewrites the rank row, and the next rewording of the reject or sync rows fails three of these tests while nothing is broken. The one piece of logic on that path is verb_usage selecting a verb's rows. Replace all five with one tree-walk invariant. For each verb help_screen_verbs yields, every line font <verb> --help prints that starts with font names that verb. The walk fails when help_screen_verbs yields nothing. That guard is the one thing the first and last tests do for the walks, which otherwise pass on zero iterations.

  2. tests/flag-arguments.bats — "a font-name check separates the pattern from grep's own flags" runs grep -Fxq -- "--help" in a bare bash -c. Delete it. It tests GNU grep's documented -- handling and never runs bin/font. Reach: the diff rewrites the apply validation block it claims to cover, and the test would still pass with the -- removed from bin/font. Replace it with this behavior test: against a fixture registry, font apply -q exits 2 and writes no history row. That runs the program's own check.

  3. tests/help.bats and tests/selection.bats — two tests duplicate one another test each. Delete both:

    • help.bats "font apply --help does not reach the font-name check". "every verb on the help screen answers --help without acting" already runs apply --help. It checks exit 0 and that no history was written, and that covers both ways the old bug showed.
    • selection.bats "compute_font_weights carries a name containing spaces through intact". "compute_font_weights gives a never-applied font the capped weight" already asserts the whole line Unseen Mono<TAB><weight>, which pins a spaced name through the tab.

    Reach: the diff moves compute_font_weights onto the shared _JQ_TIMELINE_DEFS and adds schedule to the verbs the walk covers. Each future change there pays to read two tests that cannot fail apart.

  4. tests/list-status.bats — "rejected is not a verb" pins the removal of a verb that no longer exists. Delete it. It only makes sense to someone who knows font rejected once existed. Reach: the diff edits it to assert_failure 2. That edit is the only place the suite pins the new exit 2 for an unknown command. The test is named for the old verb, so the next sweep of transition tests takes the exit-code check down with it. Replace it with this behavior test: font nonsense exits 2, and its error goes to stderr with stdout empty. Use run --separate-stderr.

  5. tests/sync.bats — "the filename carries this machine's id" asserts history-$(get_machine_id).jsonl. That is the body of _sync_history_filename copied into the test. Delete it. Reach: the diff moves get_machine_id into storage.sh and routes it through _JQ_NORMALIZE_MACHINE. This test passes whatever get_machine_id returns, so it cannot catch the bug that rewrite fixes. Replace it with this behavior test: with uname stubbed to print Linux-Box, a row log_action writes is a row _sync_own_records "$(get_machine_id)" returns. No other test covers a host name the normalizer changes. schedule.bats stubs testbox, which the normalizer leaves unchanged.

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Used: I built a copy of the branch in a scratch directory and seeded .dev-data there with scripts/seed-dev-data.sh. I ran font --help, schedule --help, schedule status, rank, stats, log, install --check, and the usage-error paths (schedule bogus, apply, apply Nonexistent_Mono, apply hack, note, sync bogus, bogus). I ran schedule_run twice with no terminal in the environment, with terminal_apply_font and the sync stubbed. bats tests/ passed 57 of 57. I did not run schedule on or off, because both write the real user unit directory.
Standards: help.md § "A failure with two causes is attributed before either one is blamed". pull-requests.md § "Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite". Searches for a rule on code copied between two repos found none.
Findings: 2 correctness · 1 design · 1 prose

Correctness

  1. bin/font:814 (cmd_font_last), lib/storage.sh (get_recently_used): font last can bring back another machine's font. A user runs it to undo a font the schedule chose, and gets a font that was never on this screen. get_recently_used reads the merged history from every machine. This branch moves usage hours, turns and the schedule's own decision onto the per-machine timeline, on the stated grounds that "another machine's apply says nothing about this screen". font last stays on the merged timeline. Reach: two machines with sync on, either running the schedule. Each check pulls first and pushes after, so every scheduled apply on one box lands in the other's history within minutes. I ran this against the seeded history. This machine's last two applies were Hack (by hand), then Comic Mono (scheduled). I added one scheduled desktop apply a minute later. get_recently_used 2 returned [["desktop","Fira Code Nerd Font"],["archlinux","Comic Mono Nerd Font"]]. With Comic Mono on screen, cmd_font_last targets .[0], which is Fira Code. The README promises "back to the font applied before this one", which here is Hack. Fix: select .machine == $machine from get_machine_id in get_recently_used, as get_machine_last_apply already does. Then rewrite README.md:128 to say "last toggles between the two fonts most recently applied on this machine."

  2. lib/schedule.sh:19-20, :250, :454-455: dev mode and the installed copy share one timer, and status then names the wrong cause. font schedule on in a checkout writes font-schedule.service with ExecStart pointing at the checkout and FONT_ENV=development. That overwrites the installed copy's unit, because the unit name and the launchd label have no mode in them. font schedule off in the checkout deletes the installed copy's timer. The checkout's timer writes .dev-data/schedule.json, so the installed copy's status prints Status: On ✓ (timer active), followed by "No check for …, so the timer is not running." Once the worktree is removed, ExecStart points at nothing and every run fails before _schedule_record. status keeps blaming the timer while it reports the timer as active. Reach: the next change to the schedule, checked the way this PR was (font schedule on, status and off in dev mode). CLAUDE.md documents that as a supported flow. Not run, because on writes the real unit directory. Read from _schedule_write_systemd and the quiet check in schedule_status. help.md § "A failure with two causes is attributed before either one is blamed" covers the status line. Fix: put the mode in FONT_SCHEDULE_UNIT and FONT_SCHEDULE_LABEL when _is_dev_mode, for example font-schedule-dev. Before blaming the timer, status should also read the service's last result (systemctl --user show font-schedule.service -p Result -p ExecMainStatus, or the last exit status from launchctl print). When the timer is active and the service failed, say the run failed and point at journalctl --user -u font-schedule.service.

Design and architecture

  1. lib/schedule.sh, lib/storage.sh, lib/sync.sh: about 460 lines of schedule code are copied from theme, and the coupling is enforced only by a sentence. Reach: the new CLAUDE.md says "A fix to one is due in the other", so every later fix to these files has to be made twice. This branch is already the second copy of three theme fixes:

    • the machine id read through the normalizer
    • the per-machine timeline
    • the waybar restart through start_session_daemon

    Correctness finding 2 is due in theme as well, since theme's unit is a fixed theme-schedule. The copies have started to differ. Theme restarts dunst through start_session_daemon, and font relies on D-Bus activation. The two-minute offset is a contract between the two tools, but only font records it, in FONT_SCHEDULE_CHECK_OFFSET_MINUTES. Theme's five-minute marks are not named anywhere font can read them. The fleet already ships a shared bash library this way, bashselfupdate, which is loaded from ~/.local/lib and optional at source time. A schedule library on that pattern could own presence detection, unit and agent writing, outcome recording, the cgroup escape and each tool's check offset. Each tool would supply its draw and its history reader. Without it, each fix is a pair of PRs, and the next difference between the copies is found only when it breaks.

Prose

  1. Three sentences carry a count or a derived number that stands without it. Each goes stale on the next change to what it counts.
    • bin/font:76: "so sync prints its six and a plain verb prints its one." schedule now prints four, and this sentence has to be edited each time a subcommand is added. Rewrite: "Every row whose first word is the verb, so a verb with subcommands prints one row for each and a plain verb prints its own."
    • CLAUDE.md:39: "A timer run has no terminal, which shapes three things." The list follows directly, and a fourth item makes the count false. Rewrite: "A timer run has no terminal."
    • README.md:258: "status warns when no check has run for fifteen minutes." The number is FONT_SCHEDULE_CHECK_MINUTES * 3, which nothing names. Rewrite: "status warns when no check has run for three check intervals."

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR review

Used: Ran font schedule status, on and run in a sandbox HOME, with systemctl, loginctl, hyprctl, pkill, pgrep and gh stubbed. The runs covered the applied, waiting, away and blocked outcomes. Also ran font rank, font current, font log, the font info preview script, and bats tests/ (57 pass).
Standards: documentation.md § "A repo is a product for a stranger; the fleet is one deployment of it" and help.md § "Never write a sentence a later release will make false" are cited below. cli-design.md § "Exit codes are 0 success, 2 usage error, other non-zero for failure" and infrastructure.md § "Scheduled work reports its own outcome" were checked, and the branch meets both.
Findings: 2 correctness · 1 design · 1 prose

Correctness

  1. lib/terminals/ghostty.sh:87 — A scheduled change never reaches an open Ghostty window. ghostty_apply writes fonts/current.conf and signals nothing. A hand apply prints "Restart terminal to see all changes", but the timer has nobody to tell. So the old font stays on screen until a new window opens. Meanwhile font current in that window reads the config file and names the new font. font rank counts hours and a kept or switched turn for a font nobody saw. Those are the numbers this PR adds to judge the schedule. The body rules a reload out with "Ghostty 1.3.1 reloads its config only by keybind or restart". On Linux that premise is false. The installed Ghostty 1.3.1 binary carries the GTK application's log line received SIGUSR2, reloading configuration. Reach: every scheduled apply on a Linux machine with a Ghostty window open. Ghostty is the terminal on 272 of the 335 apply rows in this machine's history (read only, nothing written). On macOS reach is unproven — whether the macOS app also reloads on SIGUSR2. Fix: end ghostty_apply with pkill -USR2 -x ghostty 2>/dev/null || true on Linux, the way kitty_apply sends USR1. Then rewrite the two sentences that describe the gap. They are README.md:260 ("A scheduled change reaches Ghostty at its next config reload or in a new window, the same as a font applied by hand") and bin/font:110 ("Ghostty shows a new font once its config reloads or in a new window").

  2. bin/font:706 — font info reports the font on screen as unused. The preview scripts at bin/font:706 (font info) and bin/font:787 (font change) source lib.sh and storage.sh but not terminal.sh. So get_current_font is undefined inside the preview, $current is empty, and apply_spans sets the open apply's seconds to null. Reach: picking the font on screen in font info or font change. I seeded a five-hour-old apply of JetBrains Mono with Ghostty's config naming it. font current printed Usage time: 5h. The font info preview script printed Usage time: not used and Sessions: 1 (avg 0s). The branch did not cause this, but lib/storage.sh:81 now claims font rank, font stats and the per-font page "agree to the second". Fix: source terminal.sh in both preview scripts. It pulls in lib.sh and storage.sh through their guards.

Design and architecture

  1. lib/schedule.sh, CLAUDE.md:41 — The schedule is a near-copy of a sibling tool's, and the repo now names that tool to keep the copies aligned. CLAUDE.md:41 says schedule.sh, storage.sh and sync.sh "are near-copies of theme's. A fix to one is due in the other". lib/schedule.sh:12 builds a constant on "theme's timer checks on the five-minute marks". That breaks documentation.md § "A repo is a product for a stranger; the fleet is one deployment of it", whose own text says nothing committed may name "a sibling tool". Reach: the rule is its own reach, and the repo's sentence names the next change. Every later fix to presence detection, unit writing or status is due twice. The copies already drift within this branch. Diffing the two schedule.sh files turns up two fixes that exist only here. One is _schedule_mtime for BSD stat, where theme still calls date -r. The other is the dev-mode FONT_ENV written into the unit. Fix: move the shared half (presence, the unit and plist writers, the state file, status) into one library both tools load, the way bin/font already loads bashselfupdate from XDG_LIB_HOME. If the copy stays, keep the lesson and drop the name. For example: "Another tool restarting waybar on the five-minute marks would race this one, so checks run two minutes later."

Prose

  1. README.md:82, lib/arch-apps.sh:83, README.md:258, bin/font:76 — Four sentences carry a version or a number that the next change makes false. Under help.md § "Never write a sentence a later release will make false":
    • README.md:82: "because Waybar 0.15 can crash on its reload signal". Rewrite: "because Waybar's mpris module can crash on its reload signal."
    • lib/arch-apps.sh:83: "When playerctld starts after waybar 0.15, its mpris module leaks a player". Rewrite: "When playerctld starts after waybar, its mpris module leaks a player".
    • README.md:258: "status warns when no check has run for fifteen minutes". This repeats three times FONT_SCHEDULE_CHECK_MINUTES, and font schedule --help already interpolates the constants. Rewrite: "status warns when three checks in a row are missing."
    • bin/font:76: "so sync prints its six and a plain verb prints its one". The branch edited this sentence. Rewrite: "so a verb with subcommands prints every row it owns, and a plain verb prints its one."

This review does not gate merge.

@datapointchris

Copy link
Copy Markdown
Owner Author

PR review — merged

Three reviewers ran: two general and one test reviewer. This comment unions reviews 5561399951, 5561400385 and 5561383426. The author's session launched the round. Each reviewer ran blind to the others.

Used: every reviewer ran bats tests/ on the branch, and 57 of 57 pass. The general reviewers each ran the changed verbs in a sandbox: font schedule status, run (applied, waiting, away and blocked outcomes), rank, stats, log, current, the font info preview script, and the usage-error paths. Neither ran schedule on or off against the real unit directory.
Findings: 4 correctness · 6 design · 1 prose

Correctness

  1. lib/terminals/ghostty.sh:87: a scheduled change never reaches an open Ghostty window. ghostty_apply writes fonts/current.conf and signals nothing. A hand apply prints "Restart terminal to see all changes". The timer has nobody to tell. The old font stays on screen until a new window opens. Meanwhile font current in that window reads the config file and names the new font. font rank counts hours, plus a kept or switched turn, for a font nobody saw. Those are the numbers this PR adds to judge the schedule. The PR body says "Ghostty 1.3.1 reloads its config only by keybind or restart". That premise is false on Linux. The installed Ghostty 1.3.1 binary carries the GTK application's log line received SIGUSR2, reloading configuration. Reach: every scheduled apply on a Linux machine with a Ghostty window open. Ghostty is the terminal on 272 of the 335 apply rows in this machine's history. On macOS the reach is unproven — whether the macOS app also reloads on SIGUSR2. Fix: on Linux, end ghostty_apply with pkill -USR2 -x ghostty 2>/dev/null || true, the way kitty_apply sends USR1. Then rewrite the two sentences that describe the gap:

    • README.md:260
    • bin/font:110

    (1 of 2 general reviewers)

  2. bin/font:814 (cmd_font_last), lib/storage.sh (get_recently_used): font last can bring back another machine's font. A user who runs it to undo a font the schedule chose gets a font that was never on this screen. get_recently_used reads the merged history from every machine. This branch moves usage hours, turns and the schedule's own decision onto the per-machine timeline, because "another machine's apply says nothing about this screen". font last stays on the merged timeline. Reach: two machines with sync on, either one running the schedule. Each check pulls first and pushes after, so every scheduled apply on one box reaches the other's history within minutes. Reproduced against the seeded history: this machine applied Hack by hand, then Comic Mono by schedule. One scheduled desktop apply a minute later made get_recently_used 2 return [["desktop","Fira Code Nerd Font"],["archlinux","Comic Mono Nerd Font"]]. font last then targets Fira Code, where the README promises Hack. Fix: in get_recently_used, select .machine == $machine from get_machine_id, as get_machine_last_apply already does. Then rewrite README.md:128 to "last toggles between the two fonts most recently applied on this machine." (1 of 2 general reviewers)

  3. lib/schedule.sh:19-20, :250, :454-455: dev mode and the installed copy share one timer. status then blames the wrong cause. Running font schedule on in a checkout writes font-schedule.service with ExecStart pointing at the checkout and FONT_ENV=development. That overwrites the installed copy's unit, because neither the unit name nor the launchd label includes the mode. font schedule off in the checkout deletes the installed copy's timer. The checkout's timer writes .dev-data/schedule.json. So the installed copy's status prints Status: On ✓ (timer active), then says no check has run, "so the timer is not running." Once the worktree is removed, ExecStart points at nothing, and every run fails before _schedule_record. Reach: the next change to the schedule tested the way CLAUDE.md documents, with font schedule on, status and off in dev mode. Not run, because on writes the real unit directory. Read from _schedule_write_systemd and the quiet check in schedule_status. The status line breaks help.md § "A failure with two causes is attributed before either one is blamed". Fix:

    • When _is_dev_mode, put the mode in FONT_SCHEDULE_UNIT and FONT_SCHEDULE_LABEL, for example font-schedule-dev.
    • Before status blames the timer, read the service's last result: systemctl --user show font-schedule.service -p Result -p ExecMainStatus, or the last exit status from launchctl print.
    • When the timer is active and the service failed, say the run failed and point at journalctl --user -u font-schedule.service.

    (1 of 2 general reviewers)

  4. bin/font:706 and bin/font:787: font info and font change report the font on screen as unused. Both preview scripts source lib.sh and storage.sh but not terminal.sh. So get_current_font is undefined in the preview, $current is empty, and apply_spans sets the open apply's seconds to null. Reach: selecting the font on screen in font info or font change. Reproduced: with a five-hour-old apply of JetBrains Mono that Ghostty's config names, font current printed Usage time: 5h. The font info preview printed Usage time: not used and Sessions: 1 (avg 0s). The branch did not cause this. But lib/storage.sh:81 now claims that font rank, font stats and the per-font page "agree to the second". Fix: source terminal.sh in both preview scripts. It pulls in lib.sh and storage.sh through their guards. (1 of 2 general reviewers)

Design and architecture

  1. lib/schedule.sh, lib/storage.sh, lib/sync.sh, CLAUDE.md:41: about 460 lines are copied from theme. Only one sentence keeps the copies aligned, and that sentence names a sibling tool. CLAUDE.md says "A fix to one is due in the other", so every later fix to these files is made twice. This branch is already the second copy of three theme fixes:

    • the machine id read through the normalizer
    • the per-machine timeline
    • the waybar restart through start_session_daemon

    The copies already differ:

    • Theme restarts dunst through start_session_daemon. Font relies on D-Bus activation.
    • Font has _schedule_mtime for BSD stat. Theme still calls date -r.
    • Font writes dev-mode FONT_ENV into the unit. Theme does not.
    • Correctness 3 is due in theme too, because theme's unit is a fixed theme-schedule.

    The two-minute offset is a contract between the two tools, and only font records it, in FONT_SCHEDULE_CHECK_OFFSET_MINUTES. Naming theme in CLAUDE.md:41 and lib/schedule.sh:12 also breaks documentation.md § "A repo is a product for a stranger; the fleet is one deployment of it", which forbids naming a sibling tool. Reach: every later fix to presence detection, unit writing, outcome recording or status. Fix: move the shared half into one library both tools load. That half is presence, the unit and plist writers, the state file, status, the cgroup escape, and each tool's check offset. bin/font already loads bashselfupdate from XDG_LIB_HOME this way. Each tool keeps its own draw and its own history reader. If the copy stays, keep the lesson and drop the name: "Another tool restarting waybar on the five-minute marks would race this one, so checks run two minutes later." (2 of 2 general reviewers)

  2. tests/help.bats: five tests assert that literal help rows and headings from commands_table and usage appear in the output. Delete each one:

    • "the help screen lists every verb the table carries"
    • "a flat verb gets its own synopsis, not the whole screen"
    • "a verb with several rows prints all of them"
    • "an unknown verb falls back to the full screen"
    • "font help still renders the commands and the examples"

    Every string they match is a heredoc literal in bin/font. Reach: this diff adds four schedule rows and rewrites the rank row. The next rewording of the reject or sync rows fails three of these tests while nothing is broken. Replace all five with one tree-walk invariant: for each verb help_screen_verbs yields, every line font <verb> --help prints that starts with font names that verb. The walk fails when help_screen_verbs yields nothing. (test reviewer only)

  3. tests/flag-arguments.bats: "a font-name check separates the pattern from grep's own flags" runs grep -Fxq -- "--help" in a bare bash -c. Delete it. It tests GNU grep's documented -- handling and never runs bin/font. Reach: the diff rewrites the apply validation block it claims to cover. The test still passes with the -- removed from bin/font. Replace it with a behavior test: against a fixture registry, font apply -q exits 2 and writes no history row. (test reviewer only)

  4. tests/list-status.bats: "rejected is not a verb" pins the removal of a verb that no longer exists. Delete it. Reach: the diff edits it to assert_failure 2. That edit is the only place the suite pins the new exit 2 for an unknown command. Because the test is named for the old verb, the next sweep of transition tests deletes the exit-code check with it. Replace it with a behavior test: font nonsense exits 2, its error goes to stderr, and stdout is empty. Use run --separate-stderr. (test reviewer only)

  5. tests/sync.bats: "the filename carries this machine's id" asserts history-$(get_machine_id).jsonl. That is the body of _sync_history_filename copied into the test. Delete it. Reach: the diff moves get_machine_id into storage.sh and routes it through _JQ_NORMALIZE_MACHINE. This test passes whatever get_machine_id returns, so it cannot catch the bug that the rewrite fixes. Replace it with a behavior test: with uname stubbed to print Linux-Box, a row log_action writes is a row _sync_own_records "$(get_machine_id)" returns. No other test covers a host name the normalizer changes. (test reviewer only)

  6. tests/help.bats and tests/selection.bats: each of two tests duplicates another test. Delete both:

    • help.bats "font apply --help does not reach the font-name check". "every verb on the help screen answers --help without acting" already runs apply --help, checks exit 0, and checks that no history was written.
    • selection.bats "compute_font_weights carries a name containing spaces through intact". "compute_font_weights gives a never-applied font the capped weight" already asserts the whole line Unseen Mono<TAB><weight>.

    Reach: the diff moves compute_font_weights onto the shared _JQ_TIMELINE_DEFS and adds schedule to the verbs the walk covers. (test reviewer only)

The test reviewer kept these tests, because each pins behavior a user reaches:

  • all three tests in tests/schedule.bats
  • the two edited or added tests in tests/stats.bats
  • stats.bats "font rank sorts by likes and by hours apart, over curated unrejected fonts"

Prose

  1. Five sentences carry a count, a version or a derived number. Each goes stale on the next change to what it counts. (2 of 2 general reviewers)
    • bin/font:76: "so sync prints its six and a plain verb prints its one." schedule now prints four. Rewrite: "Every row whose first word is the verb, so a verb with subcommands prints one row for each and a plain verb prints its own."
    • CLAUDE.md:39: "A timer run has no terminal, which shapes three things." A fourth item makes the count false. Rewrite: "A timer run has no terminal."
    • README.md:258: "status warns when no check has run for fifteen minutes." The number is FONT_SCHEDULE_CHECK_MINUTES * 3, which nothing names. Rewrite: "status warns when no check has run for three check intervals."
    • README.md:82: "because Waybar 0.15 can crash on its reload signal". Rewrite: "because Waybar's mpris module can crash on its reload signal."
    • lib/arch-apps.sh:83: "When playerctld starts after waybar 0.15, its mpris module leaks a player". Rewrite: "When playerctld starts after waybar, its mpris module leaks a player".

This review does not gate merge.

3 rule(s) proposed, filed into the fleet inbox as proposals.

…ling it

A SIGUSR2 reload segfaults waybar 0.15 when playerctld started after it:
the mpris module leaks a player whose handlers outlive the module the
reload destroys. A restarted waybar holds no leaked player, so the font
apply restarts it.

The restart goes through start_session_daemon, Hyprland's exec
dispatcher first and a transient systemd-run scope otherwise. An apply
run from a systemd service would otherwise leave the new bar in the
service's cgroup, which systemd kills when the run ends, setsid or not.
dunst needs no such help: D-Bus starts it again through dunst.service.
font schedule on installs a systemd user timer or a launchd agent that
checks every five minutes, at two minutes past each five-minute mark.
Another tool restarting Waybar on the marks would otherwise race it. A
change is due six hours after a scheduled apply and twelve after a hand
apply, counted from this machine's own last apply row. It waits while
the session is idle or locked, and draws with random_font.

Each check records its outcome in schedule.json, read back off the
history row the apply wrote. font schedule status prints it beside the
timer's state. A scheduled apply pushes its row in the foreground, so
the oneshot's cgroup does not take the push down with it.

random_font falls back to this machine's last apply row when no
terminal names a current font, which is the case under a timer with no
terminal attached.
font rank lists, under its two rankings, how often each ranked font the
schedule applied kept the screen until the next scheduled change, and
how long it lasted before a hand apply replaced it otherwise.

A turn ends at the next apply of another font on the same machine. A
re-apply of the same font is part of the session it continues, and
another machine's apply says nothing about this screen. A turn still
running is not counted.

The dev fixture carries two days of scheduled applies so the section
renders in dev mode.
An unknown command, an unknown sync subcommand, apply with no name or
with a name the registry does not hold, and note or reject with no
message now exit 2, so a caller can tell a mistyped command from one
that ran and failed. Each message goes to stderr.

note and reject check their message before reading the terminal, so a
missing message is reported as one even where no terminal is detected.

An unknown font with no near match exited 1 and printed nothing: the
suggestion lookup's grep found no line, and pipefail ended the script.
It now prints the error and the pointer to font list.

A bare font sync prints its subcommands and exits 0, as font schedule
does.
An apply wrote Ghostty's font file and signaled nothing, so an open
window kept the old font until it reloaded by hand or a new one opened.
A scheduled change then counted hours and a turn for a font nobody saw.

On Linux, ghostty_apply and ghostty_set_size now run the reload-config
action Ghostty's GTK app exports on the session bus, and an open window
redraws in the new font. A bus call fails harmlessly where the action is
missing, which SIGUSR2 does not: it ends any Ghostty built without a
handler. The call is skipped when no Ghostty runs, since calling its bus
name would start one.

macOS has no session bus, so there apply still tells the user to reload
Ghostty, and nowhere else.
The preview scripts of font info and font change sourced lib.sh and
storage.sh but not terminal.sh, so get_current_font was undefined there.
The open apply of the font on screen then had no end, and the preview
reported it as not used while font current gave its hours.

Both now source terminal.sh, which loads the other two through their
guards.
font last read the merged history from every machine. With sync on, a
scheduled apply on another machine reaches this one within minutes, so
undoing a font the schedule chose here could bring back a font that was
never on this screen.

get_recently_used keeps only this machine's applies, as usage hours,
turns and the schedule already do.
The comments and CLAUDE.md explained the two-minute check offset and the
turn rule by naming another tool's timer. A reader of this repo has no
such tool, so each now states the race itself: a second waybar restart
at the same moment can leave two bars.

The waybar comment drops a version pin the crash is not limited to, and
verb_usage's comment no longer counts sync's subcommands.
…stop

A dev-mode `font schedule on` wrote the same unit as the installed copy.
It replaced the real timer with one pointed at the checkout, and a later
`schedule off` from either side removed the other's.

In dev mode the unit is font-schedule-dev and the launchd label ends in
-dev, so the two never touch.

`schedule status` said a quiet timer was not running even when it fired
every five minutes and each run died before it could record a check,
such as when the program the unit names was deleted. It now reads the
timer's last trigger from systemd, or the job's last exit code from
launchd. A run inside the window means the runs are failing, and the
message points at the journal or the log that holds their errors.
help.bats named five verbs and their help literally, and asserted -h
separately. One test now reads the verbs off the help screen and checks
each answers --help and -h with its own rows, without acting. A verb
added later is covered without a new test. A mistyped command is pinned
to exit 2 with its error on stderr and nothing on stdout.

The grep-pattern unit test becomes `font apply --version`, which a user
can type. It exits 2 and logs nothing. The sync filename test becomes a
host named Linux-Box finding its own rows when it pushes.

Deleted: the duplicate weights test for a name with spaces, and the
test that `rejected` is not a verb, which the walk covers.
main() matches -h and --help in one case arm, so running -h for every
verb on the help screen repeated one check per verb. The walk keeps
--help per verb and compares -h against it for reject alone. With the
-h arm removed, the test still fails: reject takes "-h" as its reason.
@datapointchris
datapointchris merged commit 370d06a into main Oct 18, 2026
1 check passed
@datapointchris
datapointchris deleted the font-schedule branch October 18, 2026 04:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant