Skip to content

Pomodoro themes

A Pomodoro theme is one +-separated component of a named ledger entry's — NAME tail. Bob derives a catalog of themes from canonical daily notes, folds hide and color pins from a per-host journal, and assigns each theme a stable hue. Clients render what Bob reports; they never scan the vault or compute a hue of their own.

This is the shared contract for Rust, the Obsidian ledger mirror, Bob Mac Capture, and the CLI. Rust owns the meaning. JSON grows additively at schema_version 1. PT1–PT10 describe shipped behavior across the catalog, capture menus, CLI, Obsidian plugins, Mac Capture, and menu-bar indicator.

PT1 — Vocabulary and identity

  • Theme: one +-separated component of a Pomodoro entry's — NAME tail. Split with split_components. The key is normalize_component: whitespace collapsed, lowercased. The display name is the most recent authored spelling for that key.
  • Pomodoro name: the whole tail. — BOB + DECKS is one name with two themes.
  • Not themes: an unnamed entry, the budget's (unnamed) placeholder, a Pomodoro home, or an Obsidian appearance theme.
  • Naming in code: pomodoro_themes (Rust), bob-pomo-theme-* (CSS), PomodoroTheme* (Swift), api.themes (JS).
  • User-facing words: theme, Recent, Hide/Unhide, Pin color, and Rename theme. Hiding changes suggestions only.

PT2 — Catalog

  • Source: every canonical daily note, YYYY/YYYYMMDD.md and nothing else. Not _templates/, done/, _conflicts/, or *_done.md / *_poms.md siblings. Read column-0 entries in ## Pomodoros, open and closed, through capture_pomodoros::scan. Fenced lines are skipped.
  • Per-theme fields: key, name, first_used, last_used, days_used, and entries. Only days up to and including today count. Future-dated notes feed rename, not recency.
  • today: open, closed, or absent, for the theme's presence in today's ledger. Open wins when both occur.
  • History is derived only from ledger entries. Never infer it from task text or homes.

PT3 — Cache

  • Location: bob_cli_cache_dir()/themes/catalog-v1.json and known-keys-v1.json, each keyed by vault root. Machine-local, never synced, disposable.
  • Contents: a per-file (mtime_ns, size) fingerprint and that file's theme summary.
  • Completion also keeps a disposable per-vault themes/completion-v1-<vault-hash>.json view containing precomputed past-theme totals and the days on which those themes occurred. It checks the source cache fingerprint, date, year-directory fingerprints, and sweep age before reuse; corruption, rename, and a fresh catalog rebuild invalidate it.
  • Today's note is always read live. Completion reuses its current ledger scan when it is the canonical daily note.
  • A full sweep of past days runs at most once per 60 s, or when a year directory's mtime changes.
  • Rebuild on a corrupt or missing cache, after a rename, and with --fresh. The rename preview always rescans from source.
  • Writes are atomic (temp file + rename).
  • Stores prune entries for vault roots that no longer exist. When the catalog prunes a root, it also removes completion views not associated with a remaining vault and atomic-write temp files older than ten minutes, keeping the machine-local cache bounded by live vaults.
  • Budget: warm capture-complete and capture-parse stay within +5 ms p95 of today's baseline on apollo against the live vault. bob themes list uses the same source cache.

PT4 — Preferences journal

  • Location: .bob/themes/<host>.jsonl, one append-only file per host, inside the vault so it syncs.
  • host: the same host label vault-sync uses in its commit subjects.
  • Events (v: 1): theme.hide, theme.unhide, theme.color (hue is a slot name, or null to unpin), and theme.rename. via names the client: bob-cli, bob-mac-capture, or a plugin id.
  • Fold: read every *.jsonl file; skip malformed lines with a bounded warning; sort by (ts instant, host, line number); last writer wins per key for the hidden state and for the pin. A rename moves the source key's explicit state to the target unless the target already has explicit state. Events after the rename on the old key apply to the old key.
  • Writers: bob is the only writer. bob themes hide, unhide, color, and rename append under bob_sync.lock (wait at most 3 s; a busy lock is lock_busy). Each event is one O_APPEND write. The directory is created on first use.
  • Allowlist: the vault tracks .bob/themes/*.jsonl (!/.bob/themes/*.jsonl in .gitignore) and unions concurrent appends (.bob/themes/*.jsonl merge=union). A host that has not synced the allowlist keeps its hides local until it does. See Vault Git sync.

PT5 — Hue rule

In order:

  1. A journal pin wins (hue_source: "pin").
  2. Else a key in normalized plan.exempt is muted: hue: null, hue_source: "exempt" (GTD by default).
  3. Else a key equal to a top-level area note's stem (an area whose parent is [[area]]) wears that area's resolved hue (hue_source: "area"): a frontmatter color: slot, else the stem hash.
  4. Else Hue::ALL[fnv1a32(key) % 8] (hue_source: "hash").

Results: JOB → aqua, DEV → blue, LOVE → yellow, GTD → muted. Palette: the eight existing slots (blue, orange, aqua, yellow, magenta, green, violet, red), with their light/dark hex values and xterm-256 codes. Colors are stable, not unique; pins fix recurring collisions. Rust computes the hue. JS mirrors it under shared vectors. Swift and Lua only consume the hue Bob reports.

PT6 — Visual language

Names wear a tint; places wear a dot.

  • Pill (Obsidian, Mac): a soft tint of the hue behind normal-colored text (16 % light / 24 % dark; hover 24 % / 32 %). Corner radius 4 px, horizontal padding about 0.3 em, no border, box-decoration-break: clone. The hue is never the text color.
  • Muted pill: exempt themes such as GTD. Hover background, muted text.
  • Swatch: in dense rows, an 8 pt rounded square (radius 2) before the name. Area homes keep their ● circle.
  • Composites: each component gets its own pill; the + stays neutral.
  • CLI: an xterm-256 ■ before a default-color name when color is enabled (TTY and no NO_COLOR). An exempt theme gets □. bob themes list already draws these swatches; bob plan, capture destinations, and the Stats theme dimension use the same squares, and tmux uses its native format color before each named component.
  • Typing: a name the catalog does not know stays neutral while you type it; it takes its hue once it is committed or the cursor leaves the line.
  • Color is never the only signal. The name text, Today/Recent labels, and hidden, pinned, and exempt badges carry the meaning under NO_COLOR.

PT7 — Menu composition

Implemented in both capture-complete Pomodoro-name contexts and the Obsidian Pomodoro-name pickers through api.themes.

Filter first, then group:

  1. A: Today rows that match the query, in today's order.
  2. B: Recent exact and prefix matches. Sort exact first, then last_used descending, days_used descending, then name.
  3. C: The create row, only when the query is creatable and no A or B row is an exact match.
  4. D: Recent substring matches, in B's order.
  5. E: Today's query-independent utility rows.

Recent rows exclude hidden themes and any theme that is a component of a named Today row in the same menu. An empty query shows at most 5 Recent rows; a non-empty query shows at most 12. Matching is ASCII case-insensitive on slugs. A partial quoted literal matches on the normalized key prefix.

bob themes list -q ranks B then D over the catalog. Capture completion adds Today rows, applies the caps, and folds an exact Recent match into the create action. Recent rows carry origin: "history", last_used, and days_used; they create a Pomodoro, report the plan-theme preview, and omit ref and line. Every named row carries component themes.

A create row carries hidden: true when its exact key is hidden, and similar_to for at most two visible known names, most recent first. A query needs at least three characters. Similarity means equality ignoring spaces and punctuation, a singular/plural pair (s or es), or edit distance one when both keys have at least five characters. This badge never changes ranking or Return. Zsh descriptions show recent · Oct 21 · 3 days.

Fetching, filtering, and accepting suggestions never touch the journal.

PT8 — Lossless acceptance

A history or “again” row emits its plain slug only when the slug round-trips to the displayed name and does not prefix-select a different today entry. Otherwise it emits the exact-name literal #"NAME". Compare by key equality, never by slug or prefix. In a bare start (=name), a literal replacement includes the leading #; in a # field it replaces just the name. Partial straight or curly quotes match normalized key prefixes, and acceptance replaces the whole partial literal. Bob emits straight quotes. A prefix-shadowed “again” name also uses a literal: with SASE V18 open, accepting completed SASE creates SASE instead of starting SASE V18.

PT9 — Hide

Hiding removes a theme from Recent rows only. Today's rows, typing the name, history, Stats, and colors are unaffected. Using a hidden name never unhides it. bob themes hide NAME… and bob themes unhide NAME… append journal events; bob themes list -H lists hidden themes. Ctrl+D in a Pomodoro-name menu hides a Recent row in place; the ghost remains selectable and Ctrl+D again restores it. Exact hidden create rows can be unhidden, while Today and ordinary create rows consume Ctrl+D without writing.

PT10 — Global rename

bob themes rename OLD NEW [-d/--dry-run] [-f/--format human|json] [-m/--merge] [-o/--offline]

One previewed, verified, single-commit rewrite of the theme component in — NAME tails of canonical daily notes (past, today, and future, open and closed) plus _templates/daily.md, modeled on bob ref rename.

  • Scope: only the theme component in Pomodoro tails. Prose, task text, and fenced examples are never touched.
  • Byte preservation: separators keep their + spelling. A resulting duplicate collapses (FIX + FIXES → FIX). Every other byte is preserved.
  • NEW must be a valid single theme. A NEW containing + is invalid_name.
  • Flow: take bob_sync.lock, then pre-sync unless --offline, then replan from the synced files. Snapshot each target and compare again right before writing (a change aborts with stale_note). Write each file atomically, then append theme.rename. Verify by rescanning: OLD is absent, entry counts are unchanged, and only name tails differ. On failure, restore the before-images. Make one commit, bob themes rename: FIXES → FIX, then post-sync. Undo is git revert.
  • Refusals: unknown_theme (OLD never used), invalid_name (NEW contains + or is blank), unchanged (OLD and NEW share a key), theme_exists (NEW already used without --merge), config_reference (OLD is in plan.exempt, plan.inventory_labels, or a Stats metric filter and NEW is not — edit the chezmoi config to add NEW first), plus lock_busy, dirty_paths (offline with dirty targets), not_a_worktree, sync_failed, verify_failed, and stale_note.
  • Color and state: the color follows the new name, and the preview says so. Hidden state and pins move with the rename per PT4.
  • Reappearance: if OLD's spelling reappears after a rename event (an offline host, muscle memory), bob themes list reports it in renamed_reappeared. It only reports; it never rewrites on its own.

Runbook: git log --oneline -3, confirm the bob themes rename: OLD → NEW commit, then git revert <sha> and bob vault-sync to restore every byte.

JSON contract

Additive; schema_version stays 1.

ThemeRef: {key, name, hue, hue_source}, plus known: false only for names the catalog does not know. Where offsets matter, it also carries start / end byte offsets into the draft. capture-parse attaches themes to each Pomodoro-name span; offsets identify component text and exclude quotes and +. capture-pomodoros attaches themes to every entry. bob plan returns theme_refs beside theme_names and themes on each entry; bob capture returns added_theme_refs beside added_themes; task reconcile uses the same plan-budget fields. These arrays preserve source order.

Stats reports include theme_hues beside homes, keyed by normalized theme key, for theme-dimension values present in the report. Exempt themes have hue: null and hue_source: "exempt". Unknown names carry known: false; while typing, clients keep them neutral.

bob themes list -f json:

{
  "schema_version": 1,
  "today": "2026-10-24",
  "window_days": 90,
  "counts": { "known": 81, "listed": 40, "today": 4, "hidden": 2 },
  "themes": [
    {
      "key": "bob",
      "name": "BOB",
      "hue": "magenta",
      "hue_source": "hash",
      "pinned": false,
      "hidden": false,
      "exempt": false,
      "first_used": "2026-08-26",
      "last_used": "2026-10-24",
      "days_used": 26,
      "entries": 49,
      "today": "open"
    }
  ],
  "hints": [
    {
      "code": "theme_hue_collision",
      "themes": ["SASE", "BLOG"],
      "hue": "orange",
      "days_together": 6,
      "suggest": { "name": "BLOG", "hue": "red" }
    }
  ],
  "renamed_reappeared": [],
  "warnings": []
}

theme_hue_collision pairs are unpinned, non-exempt themes that share a hue and met on at least 2 of the last 30 days. The theme with fewer days_used is the one to move. The suggested slot is the least used by the last 30 days' themes, excluding hues of themes that met the moved theme in that window; ties follow palette order. renamed_reappeared lists every rename source key that still has occurrences after its rename event, as {key, name, renamed_to, renamed_to_name}.

Writers (hide, unhide, color, rename) emit {schema_version, ok, ...} on success and {ok:false, code, message} on refusal, even on a nonzero exit. Refusal codes: unknown_theme, invalid_hue, invalid_name, unchanged (no journal write), theme_exists, config_reference, lock_busy, dirty_paths, not_a_worktree, sync_failed, verify_failed, stale_note. The Obsidian manager previews with rename -d -f json (files, entries, before/after tails, state and color change) and confirms with --merge when needed.

bob themes CLI

The group is bob themes, a top-level catalog noun in Daily workflow after stats. Bare bob themes is list. Members, alphabetical in help:

  • color NAME HUE|auto
  • hide NAME…
  • list [-a/--all] [-F/--fresh] [-f/--format human|json] [-H/--hidden] [-n/--limit N] [-q/--query Q]
  • rename OLD NEW [-d/--dry-run] [-f/--format human|json] [-m/--merge] [-o/--offline]
  • unhide NAME…

List defaults: visible themes used in the last 90 days or today, newest first. -a shows every theme and flags hidden ones. -H shows only hidden themes. -q ranks per PT7 groups B then D.

Human list:

THEMES  81 known · 4 today · 2 hidden · last 90 days
  ■ BOB        today     26 days   magenta
  ■ JOB        Oct 23    24 days   aqua · area
  ■ BLOG       Oct 23    14 days   red · pinned
  □ GTD        today     51 days   muted · exempt
  hint  SASE and BLOG share orange and met on 6 days → bob themes color BLOG red

Shared vectors

tests/fixtures/pomodoro_themes/vectors.json is the single source. Sections: components, hue (including the areas input), fold, rank, and similar, menu, and literal. CLI tests consume the menu and literal sections, including capture dry-run round trips. bob-plugins copies the file byte-for-byte and runs the same hue, fold, and ranking vectors in JavaScript.

Tested rolling snapshot bob-cli 41afa14 bob-mac-capture 556bb67 bob-plugins af1e691 Built 2026-10-24T22:34:13Z (zensical) build.json