Skip to content

Settings UI: theme, font sizes, and worktrees directory - #5

Merged
jbachorik merged 23 commits into
mainfrom
feat/settings
Jul 27, 2026
Merged

Settings UI: theme, font sizes, and worktrees directory#5
jbachorik merged 23 commits into
mainfrom
feat/settings

Conversation

@jbachorik

Copy link
Copy Markdown
Contributor

Adds a Settings modal — theme, interface font size, terminal font size, worktrees directory — reachable from a new gear button in the title bar and from ⌘,.

Spec: docs/superpowers/specs/2026-07-25-settings-ui-design.md

Where each setting lives

Setting Store Applies
Theme WorkspaceUiState.theme live
Interface font size WorkspaceUiState.uiFontSize (new) live
Terminal font size WorkspaceUiState.terminalFontSize (new) live, to running sessions
Worktrees directory ~/.drydock/config.json next worktree created

Cosmetic state rides the existing single-writer ApplicationStateStore; the schema change is additive and decodes leniently, so older documents load with defaults. UserConfig was read-only and gains atomic writes (temp file + atomic move), serialized through one executor with newest-wins coalescing and a bounded flush at shutdown.

Two design decisions worth reviewing

Interface font size does not use CSS em. JavaFX resolves font-relative sizes against the inherited font, not .root, so a mechanical px→em rewrite compounds: .code-area (12px) containing .lineno (11px) lands at 10.15px instead of 13.54px at factor 16/13. Instead UiFontScale generates a scaled copy of app.css at runtime. An inline style on the scene root was also rejected — it never reaches combo popups, context menus, or tooltips, which are separate scene graphs, as app.css:1304 already documents. UiFontScaleTest locks the nested-ratio behaviour in.

Terminal font size lives in the generated ghostty config, not ghostty_surface_config_s.font_size. A theme toggle calls ghostty_surface_update_config, which re-derives a running surface's config and would silently discard a per-surface override mid-session. Going through the config file also makes the size apply live to running terminals over the path a theme toggle already takes.

Verification

455 tests pass. Beyond that, the modal is the one component with no unit-test reach (no JavaFX toolkit harness here), so it was driven in a running app via a new app.drydock.diag.settingsScript hook, alongside the file's existing diag hooks:

  • ghostty honours font-size for embedded surfaces — the assumption this design rested on. Measured externally from the child pty's winsize, since the surface is a native view above the FX scene: 13px → 30×98, 18px → 21×71, 10px → 39×131, 14px → 27×92.
  • A size change reaches an already-running session, and survives a theme toggle (21×71 before and after) — the regression the rejected per-surface approach would have caused.
  • Esc no longer discards a typed directory: the config file goes from absent to holding the typed path across an Esc close.
  • Modal legible in both themes; interface scaling verified across 11–16.

That pass also caught the one defect no review could see: the Directory field and Browse button were the modal's only unstyled controls, falling back to modena's light defaults inside the dark modal.

Known limitations

  • ThemeManager's constructor generates the scaled stylesheet synchronously. This is a deliberate deviation from AGENTS.md's no-FX-thread-I/O rule: the sheet must be in place before the stage is shown, or every launch flashes a re-layout. Documented at the call site.
  • A terminal-size slider drag can still outrun the pre-warm and do the config write inline; the pre-warm makes that rare rather than guaranteed, and the Javadoc says so instead of claiming a guarantee.
  • The two font-size sliders and the theme radio are only reachable interactively, so their wiring is covered by the diag driver rather than by unit tests. The apply/persist sequencing underneath them is pure and tested (SizeSettingTest).

🤖 Generated with Claude Code

jbachorik and others added 23 commits July 27, 2026 12:15
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two approaches in the first draft were wrong in ways that look right:

- px->em conversion of app.css: JavaFX resolves em against the inherited
  font, not .root, so nested rules compound (.code-area/.lineno would
  land at 10.15px, not 11px). Replaced with a runtime-generated scaled
  stylesheet, which also reaches popup scene graphs that an inline root
  style never would.
- per-surface ghostty font_size: the theme-toggle path already calls
  ghostty_surface_update_config, which would drop the override and reset
  the terminal mid-session. Moved into the generated ghostty .conf, which
  makes the size apply live to running surfaces and shrinks the diff to
  one class.

Also: codec no longer clamps (matching sidebarWidth), names
RepositoryManager as the state writer, adds UserConfig.flushPendingSaves,
makes cmd+, inert while a modal is showing, and records the libghostty
font-size assumption as a verification step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven tasks, TDD throughout. Task 1 is a gate: it verifies libghostty
honours font-size in a config file, and cuts terminal font size from the
round if it does not, rather than falling back to the per-surface write
the spec rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A one-line pass-through to applyTerminalTheme would be flagged as a
redundant wrapper; the shared path is documented on the real method instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A comment between the -fx-font-size colon and its px value (e.g.
"-fx-font-size: /* TODO bump */ 12px;") was matched by neither the
declaration alternative (whitespace-only between colon and digit) nor
recognised as part of a declaration by the standalone-comment
alternative, so the value was silently left unscaled. Allow comments
plus whitespace between the colon and the value so the whole thing is
still recognised as one declaration.
saveAsync previously spawned an independent virtual thread per call and
tracked only the latest future via a plain AtomicReference.set(), so
concurrent saves (the settings modal commits on both field-blur and
Browse) could race with no ordering guarantee, and flushPendingSaves
could return while an earlier save was still writing -- silently losing
updates and defeating the flush's purpose. Route saves through a
single-threaded executor with newest-wins coalescing and a no-op-flush,
mirroring AnnotationStore's proven pattern. Also: resolve configFile to
an absolute path so save() never lands its temp file on a different
filesystem, and strengthen the "preserves unknown members" test to
check parsed structure instead of substrings.

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

- UiFontScale and TerminalThemes gain async entry points (stylesheetForAsync,
  configFileForAsync): a cache hit/default resolves synchronously with no
  I/O, a miss generates on a virtual thread and lands via Platform.runLater,
  and a generation failure is logged instead of reaching the uncaught
  handler. ThemeManager and MainWorkspace route slider-driven live-apply
  through these; the startup path in ThemeManager's constructor stays
  synchronous as required.
- SettingsModal.sizeRow debounces persistence via the slider's
  valueChangingProperty: live apply fires every tick, the commit callback
  (Settings.commitUiFontSize/commitTerminalFontSize) fires once, on drag
  release. The terminal slider is now integer-only, matching what
  TerminalThemes actually renders.
- DrydockApplication gates cmd+shift+L on modalLayer().isShowingModal(),
  matching cmd+comma, so the theme cannot change underneath an open Settings
  modal and desync its radio.
- SettingsModal's RadioButton/Slider get style classes and matching app.css
  rules (following the SearchRail .result-check precedent), fixing
  near-unreadable radio captions and slider tracks in both themes.
- Minor fixes: stale comment naming a nonexistent method, UserConfig's
  loadAsync/runPendingSave now catch(Throwable) so a future can never hang
  forever, MainWorkspace's terminal font provider is a DoubleSupplier, and
  UserConfigTest's user.home-mutating tests get @ResourceLock.
- docs/manual-terminal-checklist.md gains sections for opening/closing
  Settings, default-size parity, dual-theme legibility, and the
  worktrees-directory round trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MainWorkspace.applyTerminalConfig had no staleness guard: a fast slider
drag spawns one virtual thread per cache-miss size, and thread start
order doesn't determine callback order, so an older size's callback
could land after a newer one and leave terminals showing a stale size.
Guard it the same way ThemeManager.setUiFontSize already does: record
the latest requested (theme, fontSize) and drop a callback that no
longer matches it.

ThemeManager.setUiFontSize assigned its uiFontSize field before async
generation completed, so a generation failure left the field naming a
size with no cached stylesheet -- apply()'s "cache hit guaranteed"
Javadoc was false, a later setTheme could throw UncheckedIOException
out of the toggle handler, and the bad size could reach persistence
and fail the *next* launch from the constructor. uiFontSize is now
only assigned once its stylesheet is confirmed generated, and
DrydockApplication.commitUiFontSize persists that confirmed value
instead of the raw slider position. Also collapse redundant full-scene
CSS reapplies during a drag by comparing the resolved stylesheet URL
instead of the raw font size.

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

An arrow key or a track click never sets Slider.valueChanging, so the
commit ran in the same FX event as the apply. On a first visit to a size
the stylesheet generation is a cache miss whose success callback is still
queued in Platform.runLater, so ThemeManager.uiFontSize() -- which
commitUiFontSize read back -- still held the PREVIOUS size. Every
keyboard adjustment persisted one step behind, invisibly until restart.

The read-back was guarding a real hazard: ThemeManager's constructor
generates the scaled sheet synchronously before the stage is shown, and a
generation failure threw, so a persisted bad size was a hard launch
failure. Fix that at the source instead -- UiFontScale.stylesheetFor now
logs and falls back to the bundled unscaled sheet, caching the fallback
so no later FX-thread re-application retries the failed I/O inline. With
resolution total, stylesheetForAsync always invokes its callback and no
persisted size can prevent launch.

That removes the reason for the read-back, so the apply/persist
sequencing moves into a pure SizeSetting record whose persist step is
handed the value the user chose. SettingsModal.Settings loses six methods
for two, and DrydockApplication wires RepositoryManager's update methods
directly -- there is no lambda body left for a read-back to hide in.

SizeSettingTest drives that sequencing with a deferred-apply double, no
toolkit needed: reverting the one line to the read-back fails two of its
five tests.

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

Fixes three documentation/naming issues found in code review:

1. MainWorkspace.applyTerminalTheme: Stale Javadoc claimed the settings modal
   calls this method to apply terminal font size changes. Actually, the modal
   calls previewTerminalFontSize. Updated to clarify that applyTerminalTheme
   is called only by the theme toggle, and explain the distinction between
   the two callers (apply vs preview).

2. UiFontScale.stylesheetFor and stylesheetForAsync: Javadoc promised "never
   throws" and "onReady always runs exactly once", but the fallback path calls
   baseStylesheetUrl() which can NPE if the bundled stylesheet resource is
   missing—unhandled by the virtual thread. Softened both claims to what is
   actually guaranteed: generation failures degrade to the bundled stylesheet,
   so no persisted size prevents app startup. The unhandled case (missing
   bundled resource) is a broken build, which fails at startup anyway.

3. SizeSetting.appliedSize: Renamed to currentSize to clarify that the record
   component covers two different data sources: for interface size it reads the
   theme manager's applied size, but for terminal size it reads persisted
   state. Updated Javadoc to explicitly document this dual role. Rename touches
   SizeSetting.java, its usages, and SizeSettingTest.java (which still passes
   all 5 test cases). All 454 tests pass.

No logic changes. Documentation and naming only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- SettingsModal: flush a pending worktrees-directory edit on every modal
  close path (Done/x/Esc/backdrop), not just focus-loss, by wiring
  ModalLayer's onClosed callback to a new flushPendingEdit(). Made the
  commit idempotent and re-entrancy-guarded so Esc never loses a typed
  path and a single Enter keystroke never fires two overlapping saves.
- TerminalThemes/MainWorkspace/DrydockApplication: pre-warm the ghostty
  config cache at startup (off the FX thread) instead of restructuring
  the synchronous FX-thread config lookup at session-open time, and make
  configFileFor's Javadoc describe the real (warmed-by-construction, not
  guaranteed) contract.
- UserConfig: route loadAsync's read through the same single-threaded
  SAVE_EXECUTOR saves use, so a load is FIFO-ordered against every
  previously queued save instead of racing it on an independent thread.
- SettingsModal: correct a comment that described a completion path
  (an "early return inside saveWorktreesDirectory") that does not exist.

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

FIX 1: Assign lastCommitted[0] only on successful save, not before. This
ensures failed saves remain retryable—pressing Enter again will retry,
not silently no-op. Idempotency (no redundant save for unchanged value)
is preserved by the early return check. On close-flush, unchanged values
still compare and return early.

FIX 2: Compute Optional<Path> directory before setting committing[0]=true.
If Path.of() threw InvalidPathException (practically unreachable but
worst-case possible), the flag would latch and all future commits would
no-op. Computing first ensures the exception escapes cleanly without
latching the guard.

All disable/re-enable paths (success and failure) remain intact and
unconditional in the whenComplete callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Running the app revealed what no review could: the worktrees Directory
field and its Browse button were the modal's only controls without a
style class, so they fell back to modena's light defaults -- a white
field and a grey 3D button inside the dark modal. They now reuse the
New-worktree modal's .worktree-field and .worktree-cancel-button, the
same controls doing the same job.

Adds app.drydock.diag.settingsScript alongside the file's existing diag
hooks, with verbs to open the modal, set the theme and both sizes, type
into the directory field, close via the Esc path, snapshot, and dump the
persisted state. The Settings modal is the one part of this feature no
unit test can reach, so its risky paths get a driver rather than an
unverified claim.

Verified with it:
- Esc no longer discards a typed directory: the config file goes from
  absent to holding the typed path across the Esc close.
- The modal is legible in both themes.
- Interface size scales the whole UI, popups included, across 11-16.
- ghostty honours font-size in a config file for EMBEDDED surfaces --
  the gate this design rested on. The child pty's winsize tracks it:
  13px -> 30x98, 18px -> 21x71, 10px -> 39x131, 14px -> 27x92.
- A size change reaches an ALREADY-RUNNING session, and survives a
  theme toggle (21x71 before and after) -- the regression the rejected
  per-surface approach would have caused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jbachorik
jbachorik merged commit 41c0cd7 into main Jul 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant