git:20260612.4c34d92 to git:20260612.de820f6

23 added, 263 removed. Audit A to A.

# floating-clock
Always-on-top floating desktop clock for macOS. Single-file Objective-C implementation (~80 KB binary, ~13 MB physical footprint) using NSPanel. Right-click for 25 color themes, 15 font sizes (10–64 pt), and live market-session state across 12 major global stock exchanges. Sub-0.1% idle CPU.
**Hub:** [CLAUDE.md](../../CLAUDE.md) | **Sibling:** [plugins/CLAUDE.md](../CLAUDE.md)
## Build
From the plugin directory:
```bash
cd plugins/floating-clock
make all # build + bundle + sign
make run # build + bundle + sign + open app
make install # install to /Applications
make test # build + run data-layer unit tests
make check # build + test (pre-release validation)
make clean # remove build/ artifacts
make help # list all targets
```
Tests live in `tests/test_session.m` + `tests/test_levers.m` + `tests/test_holidays.m` + `tests/test_halfdays.m` (iter-176 + iter-193 splits) — 84 fixtures covering
`computeSessionState` (session boundaries, weekend skip, lunch state,
progress math), the TZ-helper layer (DST branching for
BST/CEST/EDT/AEDT, UTC-offset formatting including Kolkata's UTC+5:30,
fullTzLabel composition), cityCode / flag emoji mapping coverage for
all 14 exchanges, starter-profile key-coverage invariants (caught
iter-55 drift in iter-56), progressive countdown format (sub-day
`T-HH:MM:SS` vs ≥24h `T-Nd Hh MMm`), lunch-market identification,
`FCFormatLandingTime` cross-day/cross-weekday matrix,
`FCParseFontWeight` id→NSFontWeight mapping with fallback (iter-88),
`FCResolveSegmentWeight` three-tier fallback chain (segment key →
global FontWeight → Medium, iter-89), and `FCResolveSegmentOpacity`
three-tier fallback chain with clamping (segment key → CanvasOpacity
→ theme->alpha, iter-90). Added after iter-48 caught a
"closed-before-open-today" off-by-7h bug that had shipped since
iter-9.
Binary at `build/floating-clock` (~184 KB signed), app bundle at
`build/FloatingClock.app`. App bundle includes
`Contents/Resources/Icon.icns` (generated at build time from
`Sources/gen-icon.m` via Core Graphics — no external image
dependencies). Spotlight/Launchpad/Finder index the app with this icon
after first install.
Third-party code: `Sources/vendor/RMBlurredView/` vendors
[RMBlurredView](https://github.com/raffael/RMBlurredView) (Raffael
Hannemann, 2013, MIT) for the frosted-glass segment backdrops. ~115
LoC, only public APIs (CIFilter + CALayer.backgroundFilters), one
local pragma delta from upstream (iter-81).
## Slash Commands
| Command | Purpose |
| --------------------------- | -------------------------------------------------------------------------------- |
| `/floating-clock:install` | Build + copy to `/Applications/` + launch |
| `/floating-clock:launch` | Open the installed (or local) app |
| `/floating-clock:quench` | Terminate the running clock |
| `/floating-clock:diagnose` | One-page health report (binary info, signing, process, active profile, tests) |
| `/floating-clock:uninstall` | Quit, remove from `/Applications/`, clear NSUserDefaults (confirmation required) |
## Design
- **NSPanel (not NSWindow)**: Borderless, non-activating, always floating
- **All Spaces**: `collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorStationary`
- **Timer**: `dispatch_source_t` aligned to second boundary, 1-second interval
- **No Dock icon**: `LSUIElement=YES` in Info.plist
- **Font resolution** (in priority order):
1. User override via NSUserDefaults key `FontName` (PostScript name)
2. iTerm2 default profile's `Normal Font` (extracted from `com.googlecode.iterm2.plist`)
3. System monospaced fallback: SF Mono (macOS 10.15+) or Menlo (older)
4. Size selectable from 15 options (10 / 12 / 14 / 16 / 18 / 20 / 22 / 24 / 28 / 32 / 36 / 42 / 48 / 56 / 64 pt) grouped as Small / Medium / Large / Huge in the context menu
- **Color themes**: 30 preset bundles (each sets foreground, background, alpha atomically). Originals (10): Terminal, Amber CRT, Green Phosphor, Solarized Dark, Dracula, Nord, Gruvbox, Rose Pine, High Contrast, Soft Glass. iter-32 (+10): Synthwave, Monokai, Gotham, Ayu Mirage, Catppuccin, Tokyo Night, Kanagawa, Paper White, Sepia, Midnight Blue. iter-92 (+5): Oceanic Deep, Cherry Blossom, Espresso, Lavender Dream, Mint Dark. iter-132 (+2): Forest, Volcanic. iter-169 (+1): Carnival (Brazilian yellow-on-green, pairs with B3). iter-195 (+1): Aurora (cyan-green on deep indigo — cool winter-night mood). iter-222 (+1): Concrete (architectural chromaless gray on charcoal — fills the gap between nord's blue-tinted gray and high_contrast's pure white-on-black). Menu items show 14×14 color swatches drawn inline via Core Graphics.
- **Market sessions** (when a non-local market is selected):
- 14 major exchanges grouped by region (Americas / Europe / Africa / Asia / Oceania) — NYSE, TSX, B3, LSE, Euronext, XETRA, SIX, TSE, HKEX, SSE, KRX, NSE, ASX, JSE (iter-155 JSE, iter-161 B3)
- Time displayed in that exchange's local time via IANA `NSTimeZone` (DST-correct across hemispheres)
- Second line shows state glyph + market code + progress bar + countdown:
- `●` green: OPEN (regular session)
- `◑` violet: LUNCH (TSE / HKEX / SSE only)
- `◐` amber: PRE-MARKET (iter-123, final 15 min before today's open, weekdays only)
- `◒` rose: AFTER-HOURS (iter-125, first 15 min after today's close, weekdays only)
- `○` gray: CLOSED (overnight, weekend) — shows `opens in Xh Ym` or `opens EEE HH:mm` for gaps >99h
- Progress bar uses Unicode 1/8-width blocks (`█▉▊▋▌▍▎▏░`) for sub-cell smoothness
- Countdown format: `2h17m` (≥1h), `47m` (<1h), `5m32s` (<2m)
- Window auto-resizes to a 2-line layout with center-anchor; falls back to 1-line when Local Time is selected
- **Default position** (first launch, no saved state): bottom-center of main screen's `visibleFrame` (respects menu bar and Dock)
- **Multi-monitor position persistence**:
- Saves both window frame and screen ID on every move (`windowDidMove:`)
- On launch: restores only if saved screen still connected AND frame intersects that screen
- If saved screen disconnected: falls back to bottom-center of main screen
- At runtime: monitors `NSApplicationDidChangeScreenParametersNotification`; if clock's screen unplugged, relocates to bottom-center of main screen with animation
- **Defensive parsing**: All plist dictionary lookups verify `isKindOfClass:` before use — malformed iTerm2 plist cannot crash the clock
- **Self-generated icon**: `gen-icon` helper draws a 1024×1024 clock glyph (dark rounded square + white face + 10:10 hands) using only Core Graphics. `iconutil` bundles into ICNS at build time. Zero external image assets.
## Touchpoints
Everything this plugin touches on your system. Nothing outside this table.
| Kind | Detail |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Reads (filesystem)** | `~/Library/Preferences/com.googlecode.iterm2.plist` — read-only, font lookup only. If missing/malformed: silently falls back to SF Mono. |
| **Writes (filesystem)** | `~/Library/Preferences/com.terryli.floating-clock.plist` — written via NSUserDefaults. See Runtime Preferences below for keys. |
| **Install path** | `/Applications/FloatingClock.app` — placed by `/floating-clock:install` or `make install`. Uninstall removes this directory only. |
| **Build artifacts** | `plugins/floating-clock/build/` — gitignored. Contains `floating-clock` binary, `gen-icon` helper, PNG iconset, `Icon.icns`, and `FloatingClock.app`. `make clean` removes. |
| **Linked frameworks** | `Cocoa`, `Foundation`, `AppKit`, `CoreFoundation`, `libobjc`, `libSystem` (main binary). Plus `ImageIO`, `UniformTypeIdentifiers`, `CoreServices` (build-time `gen-icon` helper only — not linked into the running app). All framework paths are system, not third-party. |
| **Signing** | Ad-hoc code signature via `codesign --force --deep --sign -`. No Developer ID cert required. No notarization. Gatekeeper allows ad-hoc apps on first run with standard right-click → Open bypass if needed. |
| **Entitlements** | None. Unsandboxed. No hardened runtime flags. |
| **Network** | None. The binary makes no network calls. |
| **Launchd** | None. Not registered as a LaunchAgent or LaunchDaemon. No autostart at login — launch manually via Spotlight, Launchpad, Finder, or `/floating-clock:launch`. |
| **Dock / menu bar** | Hidden from both. `LSUIElement=YES` in Info.plist makes it an accessory app — no Dock tile, no application menu bar. The only visible UI is the floating clock window itself. |
| **System permissions** | None at runtime. Accessibility access is NOT required (nothing uses the AX API). The context menu works via standard NSMenu, which needs no permission grant. |
| **Keyboard monitors** | One local (in-process) `NSEvent` monitor for ⌘Q to route to `NSApp terminate:`. Scope: only this app's events — does not see other apps' keystrokes. No global event taps. |
| **Clock source** | `dispatch_source_t` on the main queue, timer aligned to second boundaries via `[NSDate timeIntervalSince1970]` fractional remainder. No IPC with any external time service. |
## Runtime Preferences
All settings persist in `~/Library/Preferences/com.terryli.floating-clock.plist` (NSUserDefaults). Inspect or reset via the `defaults` CLI:
```bash
defaults read com.terryli.floating-clock # show all
defaults delete com.terryli.floating-clock # reset everything (next launch -> defaults)
```
**Full key table (60+ keys): [docs/runtime-preferences.md](./docs/runtime-preferences.md)** — display modes, fonts, themes, per-segment overrides, profiles, urgency levers, indicator bars. Update the spoke, not this hub, when adding keys.
## Implementation
**Entry point**: `Sources/clock.m` (~240 LoC)
**Icon helper**: `Sources/gen-icon.m` (~170 LoC, build-time only)
Post-v4 modularization, source is organized hierarchically by area:
```
Sources/
clock.m entry + registerDefaults + panel init
core/
FloatingClockPanel.{h,m} NSPanel subclass (interface)
FloatingClockPanel+Runtime.{h,m} tick pipeline, timers, positioning
FloatingClockPanel+Layout.{h,m} 3-segment + legacy layout maths
DateFormatPrefix.{h,m} 9-preset DateFormat → UTS#35 pattern (iter-113)
SkyGlyph.{h,m} 5-phase hour-of-day → emoji dispatcher (iter-114)
SegmentGap.{h,m} 7-preset SegmentGap → points (iter-115)
DensityPad.{h,m} 6-preset Density → inner-row padding (iter-116)
CornerRadius.{h,m} 8-preset CornerStyle → layer radius (iter-117)
ShadowSpec.{h,m} 7-preset ShadowStyle → spec struct (iter-120)
SessionSignalWindow.{h,m} 5-preset SessionSignalWindow → minutes, gates PRE-MARKET + AFTER-HOURS promotions (iter-126)
ClipboardHeader.{h,m} FCComposeClipboardSnapshot — self-documenting UTC-stamped header for Copy cluster (iter-160)
segments/
FloatingClockSegmentViews.{h,m} Local/Active/Next/ClockContentView subclasses
content/
ActiveSegmentContentBuilder.{h,m} live-markets rendering
NextSegmentContentBuilder.{h,m} next-to-open rendering
SegmentHeaderRenderer.{h,m} shared title/legend/hrule helper (iter-73)
UrgencyColors.{h,m} shared urgency palette + thresholds (iter-73), continuous gradient + 1Hz pulse (iter-212)
UrgencyHorizon.{h,m} 6-preset UrgencyHorizon → seconds, runtime gradient horizon (iter-215)
UrgencyFlash.{h,m} 4-preset UrgencyFlash → dim-alpha, runtime 1Hz pulse intensity (iter-219)
WeekProgressBar.{h,m} FCWeekFraction + FCBuildWeekProgressBar — pure-offline week-progress bar on LOCAL (iter-229)
LandingTimeFormatter.{h,m} dual-zone time w/ weekday disambiguation (iter-74)
data/
ThemeCatalog.{h,m} 25 theme presets + CG swatches (iter-92)
MarketCatalog.{h,m} 12-exchange registry + IANA helpers
MarketSessionCalculator.{h,m} computeSessionState, countdown fmts, progress-bar 10-glyph dispatch (iter-91)
rendering/
FontResolver.{h,m} iTerm2 → system monospaced cascade + FontWeight (iter-88/89) + LetterSpacing (iter-94) + LineSpacing (iter-95) + CurrentTimeFormat (iter-107)
SegmentOpacityResolver.{h,m} 3-tier canvas-opacity fallback (iter-90)
AttributedStringLayoutMeasurer.{h,m} NSLayoutManager multi-line height
VerticallyCenteredTextFieldCell.{h,m} cell that centers attributed text
menu/
FloatingClockPanel+MenuBuilder.{h,m} full preferences menu + Profile submenu + Quick Styles integration
FloatingClockPanel+SegmentMenus.{h,m} LOCAL / ACTIVE / NEXT scoped menus (iter-87 split)
FloatingClockPanel+MenuHelpers.{h,m} shared NSMenu helpers (iter-96 proactive split)
actions/
FloatingClockPanel+ActionHandlers.{h,m} every menu-item action target (40+ setters + applyQuickStyle + resetVisualStyle)
preferences/
FloatingClockPanel+ProfileManagement.{h,m} save/load/switch/delete
FloatingClockStarterProfiles.{h,m} 6 bundled starters + profileManagedKeys
FloatingClockQuickStyles.{h,m} 14 Quick Style bundled moods (iter-104 extracted, iter-105/106/130/144/170/196 expansions)
vendor/
RMBlurredView/ iter-65 frosted-glass library (MIT)
gen-icon.m build-time-only icon renderer
```
Design notes:
- `@autoreleasepool` for memory hygiene
- Self-contained: no separate header files, no external dependencies beyond system frameworks
- No SwiftUI, no Swift runtime tax
- Automatic Reference Counting (ARC)
- `resolveClockFont()`: 4-tier font resolution
- `defaultFrame()`: bottom-center of primary display (uses `[NSScreen screens].firstObject`, not `mainScreen`)
- `screensChanged:`: runtime observer for monitor hot-unplug
- `buildMenu` / `refreshMenuChecks:` / `applyDisplaySettings`: NSMenu-driven preferences
- `groupedSubmenuTitled:` + `setChecksInMenu:` — recursive helpers for hierarchical menus (font sizes, regions)
- `kThemes[]` — static C array of 10 theme structs (id, display, fg_rgb, bg_rgb, alpha)
- `swatchForTheme()` — inline CoreGraphics drawing of menu item color swatches
- `kMarkets[]` — static C array of 13 market structs (id, display, code, iana, session hours, lunch times)
- `computeSessionState()` — state + progress + countdown via `NSCalendar` in the exchange's IANA TZ
- `buildProgressBar()` — Unicode 1/8-width block bar with color-split filled/unfilled portions via `NSAttributedString`
- `ClockContentView`: custom `NSView` subclass whose `menuForEvent:` returns the context menu on right-click
## Canonical UI Names (iter-199 registry)
Every user-visible UI element has a stable canonical short name so feedback can be precise (e.g. "the label in the bottom-right of ACTIVE called [COUNTDOWN] is 2pt too small"). Toggle `Show Debug Labels` in the context menu (or `defaults write com.terryli.floating-clock ShowDebugLabels -bool YES`) to render these as tiny corner overlays on the running app. Hovering any named NSView shows its full NSToolTip.
| Nameidg | Surface class | Purpose / hover tooltip |
| ---------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `[LOCAL]` | `LocalSegmentView` | LOCAL — top segment, current user-local time |
| `[ACTIVE]` | `ActiveSegmentView` | ACTIVE — bottom-left segment, currently-open markets with progress bars |
| `[NEXT]` | `NextSegmentView` | NEXT — bottom-right segment, upcoming-open markets with landing countdowns |
| `[TIME]` | `LocalSegmentView.timeLabel` | TIME — user-local time text inside [LOCAL] (NSToolTip only; no corner overlay since the label fills the segment) |
Sub-element names for elements that live inside attributed-string content (e.g. `[PROGRESSBAR]`, `[COUNTDOWN]`, `[HEADER-LEGEND]`, `[SKYGLYPH]`, `[DATE]`, `[MARKETCODE]`) can't use NSToolTip directly because `NSAttributedString` text runs aren't distinct `NSView`s. A future iter may add a custom NSView overlay that turns on text-run hit-testing when `ShowDebugLabels` is YES; for now the names stay as a paper reference so the user can call them out verbally ("the [COUNTDOWN] column right-aligns oddly when the market has lunch") and we find them in code by grep.
- ## Always-visible audio I/O status bar (`AudioStatusIndicator`, 2026-06-11)
-
- `Sources/core/AudioStatusIndicator.{h,m}` — interactive bar pinned directly above
- the clock (the **bottom-most** slot of the indicator stack; the mic-mute and VPN
- bars shift one 20pt+3pt slot up while it shows). Visible **by default**
- (`AudioBarEnabled` registered YES). Replaces the decommissioned
- `com.terryli.audio-device-monitor` launchd service (amonic repo,
- `archive/audio-device-monitor-decommissioned-2026-06-11/`) — automatic
- "plug-and-play" prioritization is gone; this bar is fully manual control.
-
- Layout: `IN <device> − <level> + │ OUT <device> − <level> +` (green `IN` /
- blue `OUT` prefixes, middle-truncating names, levels 0–100 or `--` when the
- device exposes no volume control — e.g. DisplayPort sinks).
-
- Interactions (each zone independent):
-
- - **Click device name** → switch that category to the next available REAL
- device, name-sorted ring. Virtual/aggregate transports (BackgroundMusic,
- Lark loopback, multi-output sets) are excluded from the ring — cycling
- wedged on "Background Music" otherwise (its UI-Sounds sibling refuses
- main-default) — but a virtual default is still _displayed_ truthfully, and
- one click escapes to the first real device.
- - **Click − / +** → step that category's volume by `AudioBarStep` (default 5).
- - **Click the number** → top half steps up, bottom half steps down.
- - **Scroll over a zone** → fine adjust (±2 per notch).
-
- Implementation notes: same NSPanel+CALayer mechanics as the other banners but
- `ignoresMouseEvents = NO`; `FCAudioZoneView` overrides `hitTest:` to claim every
- click inside the zone (NSTextField subviews swallowed mouseDown otherwise —
- verified 2026-06-11). Refresh is tick-driven (6 HAL property reads/sec, no
- listeners/IOProcs); each zone caches a render-key composite so labels only
- redraw when something visible changes.
-
- **Drag-welding (2026-06-12)**: all three overlay panels (audio bar, mic-mute,
- VPN) are attached as CHILD WINDOWS of the clock via
- `Sources/core/ClockChildWindowAttachment.{h,m}` — the WindowServer moves them
- atomically with the clock during drags (the old windowDidMove→syncPosition
- chase trailed one move-event behind; fast drags visibly decoupled the bar —
- user-caught "elastic trail"). Separation of concerns: indicators own content
-
- - relative stacking (syncPosition unchanged); the attachment module owns only
- the idempotent attach + detach-BEFORE-orderOut contract. New overlays get
- welding by calling the two functions at their show/hide points.
-
- **Any-background legibility (research-converged "dual-layer" treatment,
- 2026-06-11):** the pill melted into pure-black backgrounds (user report). Fix —
- the same recipe macOS HUDs / launcher panels use, zero per-frame sampling:
- 1pt hairline border (white @ 0.22) defines the edge on black where shadows are
- invisible; surface lifted 0.11 → 0.16 gray (Material-style dark elevation) so
- the fill separates from `#000`; `NSPanel hasShadow` keeps doing the work on
- light backgrounds. Verified by screenshot over both pure-black and white
- backdrops. 2026-06-11 (second user request): the same treatment IS now on the
- clock body — see "Hairline segment border" below.
-
- ### Hairline segment border (`BorderStyle`, 2026-06-11)
-
- The audio bar's edge recipe promoted to the clock pills. Catalog dispatcher
- `Sources/core/SegmentBorderSpec.{h,m}` (locked by test_levers), applied in
- BOTH layout families via `FCApplyBorderToLayer` (FloatingClockPanel+Layout.m):
- the three-segment pills AND the compact local-only/single-market modes, where
- the window contentView IS the pill (first ship missed those — user caught the
- bare double-click-shrunk view; three-segment clears the contentView border so
- mode switches never leak a stale frame). Color is luminance-adaptive per
- segment theme bg: white @ alpha on dark fills, black @ alpha+0.08 on light.
- Menu: context menu → Display → Border. Presets: `none` / `hairline` (1pt @
- 0.22, DEFAULT — registered in clock.m, threaded through all 8 starter
- profiles; Minimalist=none, Auction Watcher=frame) / `frame` (1.5pt @ 0.35).
-
- AskUserQuestion-selected extras (same day, all verified on-screen):
-
- - **"Show Audio Bar"** context-menu toggle (Display section) → flips
- `AudioBarEnabled` with instant show/hide + checkmark.
- - **Mute state on IN**: while the ACTIVE mic is muted (CoreAudio mute flag on
- the current default input OR the mic indicator's banner state), the IN zone
- renders `IN⊘` + red struck-through device name + red level. 2026-06-11
- fix: `FCMicMuteIndicator` now binds DEFAULT-INPUT-FIRST (was Antlion-first,
- which falsely flagged AirPods red when the Antlion's hardware button was
- pressed); Bluetooth inputs are never silence-metered (a persistent IOProc
- would hold the headset in HFP/SCO call mode). The Antlion's analog button
- is still caught — when the Antlion is the default input. Companion fix
- outside this repo: `~/.local/bin/mic-mute` (chezmoi) gained a `default`
- target and both Karabiner F10 bindings use it, so the mute key follows the
- active mic too.
- - **Change flash**: a device or level change blinks the affected text amber
- for ~1.4s (`kFlashSecs`), then decays to white on the next tick — external
- changes (volume keys, other apps) catch the eye.
-
- | default key | type | default | meaning |
- | ----------------- | ---- | ------- | ------------------------------ |
- | `AudioBarEnabled` | BOOL | `YES` | master on/off (also in menu) |
- | `AudioBarStep` | int | `5` | −/+ click step %, clamped 1–25 |
-
- ### Pull-out device menus + Bluetooth connect/takeover (2026-06-11, second directive)
-
- **Right-click / two-finger tap / ctrl-click** on either zone pops an independent
- device-selection menu (all three gestures come free via `-menuForEvent:`).
- The left-click cycle toggle is untouched. Verified on-screen 2026-06-11:
- menu structure, direct live-device selection, IN/OUT independence, and a real
- takeover (device connected & switched from an iPhone).
-
- - `Sources/core/AudioDeviceSelectionMenuController.{h,m}` — builds the menu
- fresh per invocation: live CoreAudio devices (✓ on current; click = switch
- now) + `BLUETOOTH — CONNECT` section of paired-but-offline BT audio devices
- (`○` prefix). Orchestrates connect → bounded HAL polling (0.5s × 16) →
- set-default, with transient `⏳ name…` / `✗ name` status in the zone.
- - `Sources/core/BluetoothPairedAudioDeviceConnector.{h,m}` — IOBluetooth
- wrapper: `pairedDevices` filtered to the Audio/Video major class;
- async `openConnection:` with self-retaining attempt objects + timeout
- backstop. **`openConnection` IS the takeover request** — audio devices
- (AirPods/W1/H1, multipoint headsets) switch to the most recent requesting
- host; this is the in-app equivalent of `blueutil --connect` /
- lapfelix/BluetoothConnector (the FOSS canon for un-sticking devices from an
- iPhone). CoreBluetooth is BLE-only and useless here; IOBluetooth remains
- the only public classic-BT API.
- - Requirements: `-framework IOBluetooth` (Makefile CFLAGS) and
- `NSBluetoothAlwaysUsageDescription` (Info.plist; macOS TCC prompts on the
- first menu open since `pairedDevices` is called lazily).
- - Resource posture: zero steady-state cost — no listeners, no daemons, no
- polling; IOBluetooth is touched only while a menu is open or a connect is
- in flight. Connect ≠ routed: the CoreAudio endpoint appears async after
- the baseband link, hence the poll-then-select stage (honest `✗` when a
- device connects but exposes no endpoint in that scope, e.g. a speaker
- picked from the INPUT menu).
-
- ### Scope-independence hijack guard (2026-06-11, user bug report)
-
- Selecting AirPods for INPUT also flipped the OUTPUT. Probe-verified root
- cause (`scripts/audio-diagnostics/fc-audio-default-routing-probe.m`): the
- defaults are NOT bound — AirPods are TWO HAL devices (24kHz HFP mic +
- 48kHz A2DP out) behind separate default-in/default-out properties —
- **coreaudiod auto-routes the other scope to a BT device when it connects**
- (watcher caught it twice, +0s and +7s after connect). Fix in
- `AudioDeviceSelectionMenuController`: snapshot the other scope before
- connect; for 20s (40 × 0.5s) restore it whenever it's hijacked _by the
- connected device_ (ID-change first, then name match), max 3 restores then
- `✗ macOS keeps re-routing`. `↩ name` transient on each restore. Explicit
- user selections bump a per-scope generation counter that cancels the guard
- (menu picks AND the left-click cycle). Restore targets may legitimately be
- virtual (Background Music) — existence is re-probed across ALL transports
- (`deviceIDStillExists:`), since HAL IDs are reassigned on unplug.
- Adversarially reviewed (19-agent workflow, 16 findings → 8 confirmed → all
- fixed or dispositioned 2026-06-11).
-
- **Per-app caveat (Typeless et al.)**: live capture sessions do NOT migrate
- when the default input changes — apps must listen for
- `kAudioHardwarePropertyDefaultInputDevice` and rebind; many (Typeless
- "Auto-detect") resolve once at session start. Capture-path truth-test:
- `scripts/audio-diagnostics/fc-default-input-capture-rms-probe.m` (records
- default input, prints per-500ms RMS; AirPods stem-scratch is the
- discriminator).
-
- ### Stable local signing identity (2026-06-11, TCC re-prompt fix)
-
- Ad-hoc signing (`codesign --sign -`) mints a new code identity per build →
- TCC (Bluetooth/mic) re-prompts after every reinstall. The Makefile now
- signs with the self-signed cert **"FloatingClock Local Signing"** when
- present (auto-fallback to ad-hoc). One Allow then persists forever.
- Recreate on a new machine (OpenSSL 3 p12 import is broken against macOS
- `security` — import PEMs separately):
-
- ```bash
- DIR=~/.local/share/floating-clock-signing && mkdir -p $DIR && cd $DIR
- openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 3650 -nodes \
- -subj "/CN=FloatingClock Local Signing" \
- -addext "keyUsage=critical,digitalSignature" \
- -addext "extendedKeyUsage=critical,codeSigning" \
- -addext "basicConstraints=critical,CA:false"
- security import key.pem -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign
- security import cert.pem -k ~/Library/Keychains/login.keychain-db
- security add-trusted-cert -p codeSign -k ~/Library/Keychains/login.keychain-db cert.pem # GUI password dialog
- ```
-
- Silent Bluetooth pre-authorization is impossible without MDM — the grant
- row is `kTCCServiceBluetoothAlways` / `com.terryli.floating-clock` in the
- user TCC.db; one human click is mandatory, once per identity.
-
- ## Solar canvas (`CanvasColorMode`, 2026-06-11)
-
- "Colorful, not transparent": the COMPACT modes' background derives from the
- CONTINUOUS solar elevation at the user's real location — authoritative
- ephemeris math, nothing scheduled by clock hours.
+ ## Overlay indicators (audio bar · mic-mute · VPN) — [docs/overlay-indicators.md](./docs/overlay-indicators.md)
- - `Sources/data/SolarEvents.m` gained `FCSolarElevationDegrees` (SunCalc/
- Meeus sun position; same lineage as the event calculator so glyph and
- canvas can never disagree). Locality = the CoreLocation fix cached by
- `FCLocationProvider` (same `Latitude`/`Longitude` defaults the sky glyph
- reads); pre-fix fallback = coarse local-hour sinusoid.
- - `Sources/core/SolarSkyColorRamp.{h,m}`: OKLab ramp keyed to international
- twilight standards (−18/−12/−6/−4/0/+6/+20/+50°). Two styles (locked by
- test_levers): `solar-vivid` (DEFAULT — LCh polar interpolation, constant
- chroma; day-side hue arc runs UP through green/cyan 440→605 because the
- descending arc retraced magenta and made mid-morning purple — preview-
- validated with an ANSI swatch harness) and `solar-atmospheric` (Cartesian
- Lab interpolation — warm→blue crossfades desaturate through neutral like
- the real sky). Menu: Display → Canvas Color; registered default
- solar-vivid; threaded through all 8 starter profiles.
- - Painting: the ROUNDED contentView layer, NOT the window background (the
- window rect extends past the corner radius — square patches, user-caught).
- Three-segment + theme paths clear the layer fill/border so nothing leaks
- across mode switches. 1Hz evolution in tick, 8-bit-quantized.
- - Text: `Sources/rendering/SolarOutlinedTextRenderingView.{h,m}` — Core
- Text, white fill + ROUND-JOIN black outline (2.2pt), replaces `_label`
- while solar is active (\_label stays populated for sizeToFit). Rejected on
- the way (all user-caught on-screen): negative NSStrokeWidth eats the fill
- from inside; positive-stroke underlay field has NO join control → miter
- spikes on descenders; CTLineDraw fills with the RUN color (black) unless
- `kCTForegroundColorFromContextAttributeName` opts into context colors.
- Optical alignment user-tuned: lift 1.5pt, left-shift 2.0pt.
- - Location staleness bug (found during this work): `FCLocationProvider
- kickoff` ran only at launch and `requestLocation` failures are silent →
- coords stranded on a 7-week-old fix from another city. Fixed: hourly
- re-kick from tick (self-gates on the 24h freshness check). NOTE: changing
- the code-signing identity RESETS TCC grants — Location (and Bluetooth)
- each need one re-Allow after the stable-identity migration.
+ Audio I/O bar (`AudioStatusIndicator`, default ON): manual IN/OUT device +
+ volume control above the clock; right-click pull-out menus with Bluetooth
+ connect/takeover (`AudioDeviceSelectionMenuController` +
+ `BluetoothPairedAudioDeviceConnector`); coreaudiod hijack guard keeps IN/OUT
+ independent. All overlay panels are drag-WELDED to the clock as child windows
+ (`ClockChildWindowAttachment`). The VPN/state-file banner stays generic and
+ secret-free. Defaults: `AudioBarEnabled`, `AudioBarStep`, `VPNIndicator*`.
+ Full saga, defaults tables, and diagnostics: the spoke above.
- ## Generic external-state status indicator (`VPNStatusIndicator`, 2026-06-07)
+ ## Solar canvas + segment styling — [docs/solar-canvas-and-styling.md](./docs/solar-canvas-and-styling.md)
- A second banner alongside the mic-mute bar: `Sources/core/VPNStatusIndicator.{h,m}`.
- It shows a colored bar (default violet `#8B2FE6`) above the clock — and **above the
- mic-mute bar when that one is showing** (via the new `FCMicMuteIndicator -isShowing`) —
- whenever an external **state file** exists on disk. Same `NSPanel`+CALayer mechanics, the
- same 1 Hz refresh off the clock `tick`, and `syncPosition` from `windowDidMove:`.
+ Compact-mode background rides an OKLab twilight ramp over the LIVE solar
+ elevation at the user's CoreLocation coordinates (`CanvasColorMode`:
+ solar-vivid DEFAULT / solar-atmospheric / theme — `SolarSkyColorRamp` +
+ `FCSolarElevationDegrees`). Hairline segment border lever (`BorderStyle`:
+ hairline DEFAULT / frame / none — `SegmentBorderSpec`,
+ `FCApplyBorderToLayer`). Compact text legibility via Core Text round-join
+ outline (`SolarOutlinedTextRenderingView`).
- Deliberately **generic and secret-free** — it has no idea what the state represents (a
- VPN, tunnel, build, backup). Everything is `NSUserDefaults`-driven (domain
- `com.terryli.floating-clock`) and it is **disabled by default**, so the public build ships
- inert until a deployment opts in:
+ ## Build signing + TCC — [docs/signing-and-tcc.md](./docs/signing-and-tcc.md)
- | default key | type | default | meaning |
- | ----------------------- | ------ | ------------------------------------- | ------------------------------ |
- | `VPNIndicatorEnabled` | BOOL | `NO` | master on/off |
- | `VPNIndicatorStateFile` | string | `~/.config/floating-clock/vpn-active` | bar shows iff this path exists |
- | `VPNIndicatorLabel` | string | `"VPN"` | bar text |
- | `VPNIndicatorColorHex` | string | `"#8B2FE6"` | bar color `#RRGGBB` |
+ The Makefile signs with the persistent "FloatingClock Local Signing" cert so
+ TCC grants (Bluetooth / mic / location) survive rebuilds. Identity changes
+ reset ALL grants — one re-Allow each. Cert recreation recipe in the spoke.
- A deployment wires it up by (a) `defaults write com.terryli.floating-clock VPNIndicatorEnabled -bool YES`
- (+ optional label/color/path overrides) and (b) having some external toggle create/remove
- the state file. Keep this plugin free of any host/IP/secret — the _meaning_ of the state
- (and the toggle that drives it) lives in the operator's private infra repo, never here.
## Known limitations
- **Holiday awareness: 14/14 exchanges (2026 fixtures); half-days: 8/14.** `Sources/data/HolidayCalendar.{h,m}` + `HalfDayCalendar.{h,m}`, wired into `computeSessionState` with correct back-to-back chaining (weekend+holiday clusters). Live caveats: 6 exchanges' half-days deferred, SSE make-up Saturdays (补班) not modelled, lunar dates fixture-locked best-effort. Full iter-173…192 chronicle + per-exchange detail: [docs/holiday-coverage.md](./docs/holiday-coverage.md).
- **No extended after-hours trading window modelled**. Each exchange's full extended session (US equities 16:00–20:00 ET, various 1–2 h windows elsewhere) is not modelled. What is modelled: the first 15 minutes immediately after regular close promote CLOSED → AFTER-HOURS (iter-125, rose ◒ glyph) — a short signal symmetric to iter-123's PRE-MARKET. Full per-market extended-session modelling remains deferred pending a decision on per-exchange duration data.
- ## Future Enhancements
-
- ### Near-term
-
- - Holiday awareness (bundled annual JSON per exchange, refreshed yearly)
- - System appearance (light/dark) auto-adjust of themes
- - Copy Time / Copy segments keyboard shortcuts (requires global event tap for LSUIElement)
-
- ### Tier-3
-
- - Multi-market rotation mode (cycle 2–3 favorites every 10 s)
- - User-definable theme bundles (pick fg/bg/alpha via menu)
- - Settings export/import (JSON, for sync across machines)
- - Launchd login-item for autostart
-
- ### Already shipped in v4 (moved out of "future")
+ ## Future Enhancements — [docs/roadmap.md](./docs/roadmap.md)
- - ~~Per-segment themes (LocalTheme / ActiveTheme / NextTheme)~~ — iter-14
- - ~~Profile system with bundled starters~~ — iter-17
- - ~~Segment-scoped right-click menus~~ — iter-15
- - ~~Regional TZ abbreviations (PDT / BST / CEST / JST / AEDT) + UTC offset~~ — iter-37/38
- - ~~Inline UTC reference on LOCAL~~ — iter-39
- - ~~Sun/moon day-night glyph~~ — iter-42
- - ~~Progress-bar running head~~ — iter-43
- - ~~Urgency color tiers on ACTIVE + NEXT countdowns~~ — iter-44/45
- - ~~NEXT cross-day weekday disambiguation~~ — iter-49
- - ~~Unit test harness for session-state + TZ helpers~~ — iter-50/51
- - ~~PRE-MARKET state (15-min amber ◐ pre-open window)~~ — iter-123
- - ~~AFTER-HOURS state (15-min rose ◒ post-close window)~~ — iter-125
- - ~~User-configurable SessionSignalWindow (off / 5-60 min)~~ — iter-126
- - ~~Copy Time / Active Markets / Next Opens to clipboard~~ — iter-149/150
+ Near-term + Tier-3 backlog and the shipped-in-v4 log live in the spoke.