Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
30af202
feat(GenesisKit, GenesisHub, widgets): the inbox button lists every s…
genesiscz Oct 23, 2026
a490d46
feat(GenesisKit, GenesisHub, widgets): bubbles answer the pointer, th…
genesiscz Oct 23, 2026
cf4b57c
fix(GenesisKit, GenesisHub, widgets): a size the hover list asks for …
genesiscz Oct 23, 2026
b71501e
fix(GenesisHub, flow, widgets): GenesisTools records with any Genesis…
genesiscz Oct 23, 2026
81404c4
fix(GenesisKit, GenesisHub, widgets): the widget, markdown and settin…
genesiscz Oct 23, 2026
709c790
feat(GenesisKit, GenesisHub, widgets): a decision click stages the pi…
genesiscz Oct 23, 2026
853a60d
fix(GenesisKit, Clicky): the key sound starts its audio engine off th…
genesiscz Oct 23, 2026
3dcde4f
test(GenesisKit, GenesisHub, widgets): staging is tested from the pic…
genesiscz Oct 23, 2026
e6da476
fix(GenesisKit, GenesisHub, widgets): Clicky keeps its own engine sta…
genesiscz Oct 23, 2026
7fc43f0
fix(GenesisKit, GenesisHub, widgets): a decision card, the alert popu…
genesiscz Oct 23, 2026
af84902
fix(GenesisKit, GenesisHub, widgets): a voice note transcription leav…
genesiscz Oct 23, 2026
d778e83
feat(GenesisKit, GenesisHub, widgets): the reply box microphone shows…
genesiscz Oct 23, 2026
8af5fdc
feat(GenesisKit, permissions): the vault key window shows the command…
genesiscz Oct 23, 2026
156d3f3
feat(GenesisKit, GenesisHub, widgets): an Agents card in the widget s…
genesiscz Oct 23, 2026
cda324f
fix(GenesisKit, GenesisHub, widgets): a rail bubble takes clicks and …
genesiscz Oct 23, 2026
d74b44d
fix(GenesisKit, GenesisHub, widgets): a click on the vault key window…
genesiscz Oct 23, 2026
f970c21
test(GenesisKit, GenesisHub, widgets): the replay picture comes from …
genesiscz Oct 23, 2026
696c716
fix(GenesisKit, GenesisHub, widgets): the dictation row shows everyth…
genesiscz Oct 23, 2026
c4f2c13
feat(GenesisKit): a generic screen recorder: a target picker for a wi…
genesiscz Oct 23, 2026
4c3b091
feat(GenesisKit, GenesisHub, widgets): the video button picks a windo…
genesiscz Oct 23, 2026
5f03332
fix(GenesisKit): the recording controls go in once per recording and …
genesiscz Oct 23, 2026
fd7085b
fix(GenesisKit): the recording picker opens at its content's size eve…
genesiscz Oct 23, 2026
50da029
fix(GenesisKit, GenesisHub, widgets): the recording controls say why …
genesiscz Oct 24, 2026
8bf3069
feat(GenesisKit, permissions): opening a Privacy pane floats the app'…
genesiscz Oct 24, 2026
0f32092
fix(GenesisKit, GenesisHub, widgets): a decision send says what arriv…
genesiscz Oct 24, 2026
5d1018b
fix(GenesisKit, GenesisHub, widgets): a glass panel is one surface fo…
genesiscz Oct 24, 2026
560e93b
feat(GenesisKit, GenesisHub, widgets): switching sessions shows the c…
genesiscz Oct 24, 2026
b478f72
fix(GenesisKit, GenesisHub, widgets): the hover list stays open while…
genesiscz Oct 24, 2026
cf0b326
docs(GenesisKit, GenesisHub, widgets): the hover list's origin exampl…
genesiscz Oct 24, 2026
3f99595
fix(GenesisKit): the branch carries FrameProbe, so the recorder contr…
genesiscz Oct 25, 2026
6817452
fix(GenesisKit, GenesisHub, widgets): bot review: staged decisions co…
genesiscz Oct 25, 2026
dc74a0c
fix(GenesisKit): bot review: Clicky restarts after an audio device ch…
genesiscz Oct 25, 2026
f7deb61
fix(GenesisHub): bot review: a commit link counts only its own unpush…
genesiscz Oct 25, 2026
1f4f702
test(GenesisKit): the staging tests pass stagedOnly through their sna…
genesiscz Oct 25, 2026
ec0117e
fix(GenesisKit, GenesisHub, widgets): bot review round 2: a voice not…
genesiscz Oct 25, 2026
848edd0
fix(GenesisKit): bot review round 2: Clicky restarts after a device c…
genesiscz Oct 25, 2026
4d1c77b
fix(GenesisHub): bot review round 2: Whole PR cancels a local-commit …
genesiscz Oct 25, 2026
6fc162f
fix(GenesisKit): a test run never takes the user's keyboard focus: a …
genesiscz Oct 25, 2026
74ce97f
feat(control): tools control capture session records in segments you …
genesiscz Oct 23, 2026
cdce8b9
feat(control): capture session --no-indicator records without the red…
genesiscz Oct 23, 2026
abaa817
fix(control): the first recording after a recorder source change comp…
genesiscz Oct 24, 2026
8a4515f
fix(control): a recording segment that never got its first frame keep…
genesiscz Oct 24, 2026
9f6f185
fix(GenesisKit, GenesisHub, widgets): bot review round 3: staging an …
genesiscz Oct 25, 2026
ad4a0a5
fix(control, GenesisKit): #562 followup - a failed save pauses the se…
genesiscz Oct 25, 2026
06aa617
fix(GenesisKit, GenesisHub, widgets): #562 followup - a live voice no…
genesiscz Oct 26, 2026
8dd1fcc
fix(control): #562 followup - a stop on a recorder that failed or was…
genesiscz Oct 26, 2026
92c0316
fix(control, question): #562 followup - a recording whose folder cann…
genesiscz Oct 26, 2026
17291b6
fix(ax-tool, GenesisKit, GenesisHub, widgets): #562 followup - motion…
genesiscz Oct 26, 2026
dece9a5
fix(control): #562 followup - a single-segment save onto another volu…
genesiscz Oct 26, 2026
b39731f
fix(control, GenesisHub, voice notes): #562 followup - a multi-segmen…
genesiscz Oct 26, 2026
9d74bca
fix(control): #562 followup - a default recording name carries a rand…
genesiscz Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .claude/rules/opinionated-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
paths:
- "src/macos/**/*.swift"
- "**/*.tsx"
- "src/*/ui/**"
- "src/dashboard/**"
- "src/dev-dashboard/**"
- "src/hub/lib/widget/**"
- "src/notify/**"
---

# Opinionated behavior gets a setting

Loaded by Claude Code when you read, write or edit native or web UI code, the widget's wire schema, or notifications.

Martin's rule (2026-10-23): when a UI change picks one way where a reasonable user could want another, the choice
is a setting with that way as its default. You decide the default and ship the setting in the same change; do not
ask first, and do not ship the behavior hard-coded.

## Opinionated means

- **Interruptions**: a sound, which sound per kind of event, a popup, a panel that opens or expands by itself, a
notification, a focus steal.
- **Attention cues**: a flash, a pulse, a badge, their length and their colour; "flash the thing that caused it" is
the expected default, and its duration is the setting.
- **Placement and order**: where a composer or toolbar sits (top or bottom), list order, which filter a list opens
with, what a list shows (only what needs you, or everything active).
- **Hover behavior**: a tooltip or a preview on hover, hover delays, whether hover opens anything at all.
- **Timings**: auto-collapse delays, how long a popup stays.

Not opinionated: a bug fix, a layout that was wrong, a missing hover effect, accessibility labels, performance.
Those are fixed, not made optional.

## How

- One preference per choice, stored where its surface already stores preferences (the widget: the zod schema in
`src/hub/lib/widget/types.ts` plus `WidgetPreferences` in GenesisKit `WidgetWireModels.swift`; a native window:
its settings store). An enum over a boolean when a third way is plausible ("auto" | "always" | "never").
- The control goes on the settings page of that surface, in the card the behavior belongs to, with a one-line detail
that says what it does. A sound picker has a play button. A list that has a filter in place gets a settings
shortcut that opens the page at its card (`<page>#<anchor>`, `.nativeSettingsAnchor`), which flashes the card's border.
- The default is your best judgment of what Martin asked for; the option you did not pick stays reachable.
- A shared component's look change keeps the old look as a named option (the `AttentionPulse` presets: the border
flash is the default, the filled flash stays as `.filledFlash`).
- Say in the reply which settings you added and their defaults.
64 changes: 64 additions & 0 deletions .claude/skills/swift/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,67 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not
- **Driving a background app's context menu with `tools control` is not safe yet.** `AXShowMenu` on a SwiftUI tab
blocked 2 s and opened the parent's menu first; the menu rows were not in `see --scope menu`; a coordinate click
into the open menu closed it without choosing. Trigger the code path from a test instead.

## Row-only controls with popovers, key modifiers, shared builds — 2026-10-23 15:17

- **A control that exists only on the hovered row must keep its popover open after the pointer leaves.** The
popover's `isPresented` lives in the control; remove the control on hover-out and SwiftUI closes the popover.
Keep the `.popover` modifier on a same-size `Color.clear` placeholder while the row is not live, and treat "popover
open" as live (`WidgetCmuxButton(live:)`, GenesisKit Widgets). Hover is the row's, not the control's: a hover
sensor on the button covers only the button (`WidgetHoverRow { hovering in … }`).
- **A popover from an edge panel needs its own "dialog open" flag.** Setting the host's shared `dialogOpen` from the
popover's `onChange` can release a window the host opened when the popover closes. One flag per presenter, ORed
into the getter (`sessionChooserOpen`, `cmuxPopoverOpen`).
- **`.onKeyPress(.downArrow) { … }` has no key press to read.** Its closure takes no argument; to read Shift use
`.onKeyPress(.downArrow, phases: [.down, .repeat]) { press in press.modifiers.contains(.shift) }`. A click's
modifiers come from `NSEvent.modifierFlags` inside the button action.
- **"input file … was modified during the build" is another session editing GenesisKit**, not your code. Rerun the
build through `.claude/skills/babysit/scripts/swift-slot.ts`; do not stash or touch their file.

## A whole window ignores clicks after one exception in an action — 2026-10-23 16:58

- **`NSEvent.clickCount` raises for any event that is not a mouse click.** It is an Objective-C exception
(`NSInternalInconsistencyException`), not a Swift error, and `NSApp.currentEvent` inside a SwiftUI button action is
not always a mouse event: a key press, an accessibility press or a system-defined event can be current. The raise
unwound through SwiftUI's event dispatch and left the Genesis Markdown window ignoring every click, tab bar and
sidebar included, until relaunch (2026-10-23 16:36, the tab group chip's double-click check). Read the count through
GenesisKit `MouseClick.current` or `MouseClick.count(of:)`, which checks the event type first. lint-rules
`swift-current-event-click-count` fails `currentEvent?.clickCount`. Inside `mouseDown(with:)` or a monitor
filtered to mouse types, `event.clickCount` is safe.
- **When "nothing is clickable", read the app's unified log for an exception first.** `command log show --predicate
'processID == <pid>' --last 10m | rg -i "exception|raise|terminat"` found the line in one query; hit tests, AX
walks and layout theories had found nothing.
- **A test window's synthesized click always makes a mouse event current.** `NSApp.postEvent` down and up gives the
action a real `leftMouseUp`, so a test can never reproduce a crash that needs a key or AX event as the current
one. A unit test of the reader with a key event (`NSEvent.keyEvent(...)`) covers it; a passing click test does not.
- **An AX hit test over a `WKWebView` names the web area, not the view on top.** `tools control hittest` at a tab
that sat above the page reported `AXWebArea` (the web document's rect), while real clicks reached the tab. Judge
delivery by the app's own action log, never by the AX hit test.
- **A hidden test window has no accessibility tree, so find click targets with `FrameProbe`.** GenesisKit
`.frameProbe("name")` records a view's window frame only while `FrameProbe.enabled` (a test sets it); click the
centre of `FrameProbe.frames["name"]`. Never scan a grid of clicks to find a target: the scan pressed the toolbar's
Obsidian and Editor buttons and opened dialogs and editor tabs in the user's real apps.

## Trigger animations, and loops no stall detector sees — 2026-10-23 17:47

- **An animation started from `onChange(of: trigger)` never plays in a view built after the change.** The widget's
hover list was opened by the same alert that bumped the flash counter, so its rows started with the new value and
did not flash. Pass the start time too and play the rest on appear (`AttentionPulse(since:)`).
- **A SwiftUI update loop never delays a main-queue ping.** Each pass returns to the run loop, so a queue-latency
watchdog reports nothing while the app burns a whole core (the widget, 2026-10-23 17:10, three minutes, no
log line). Read the main thread's CPU time instead: `thread_info(THREAD_BASIC_INFO)` on the port from
`mach_thread_self()` taken on the main thread (`HangWatch.SpinTracker`, `main-spin` lines).
- **Read a spin's sample for who schedules the next pass.** Inside the transaction, look for
`asyncTransaction` callers: `LazyLayoutViewCache.signalPrefetch` (a lazy stack that never settles) and
`EventBindingManager.enqueueHoverUpdateIfNeeded` (hover re-checked after every update) were the two in the widget's.
- **A test process has no `NSApp` until something makes one.** `NSApp.postEvent` or `nextEvent` then crashes
with "unexpectedly found nil" on the line that reads `NSApp`; call `_ = NSApplication.shared` first.
- **A window resize lays SwiftUI out synchronously, and content can call back into its controller from there.** The
edge panel moved its window in `begin`, the hover list measured itself in that layout and asked for its real size,
and the nested request started a reshape that `begin` then overwrote with its own transition: the panel kept the
estimate (336 instead of 489 pt). Defer a request made while a transition is being set up, and run the latest one
after it (`EdgePanelController.applyingPresentation`).
- **A `CADisplayLink` keeps a strong reference to its target.** A ticker held only by a struct that gets overwritten
never deinitialises and fires every frame for the life of the process. Stop it wherever the owner is replaced (a
`didSet` on the property), and count live ones for a test (`EdgeFrameTicker.live`). Find them in a sample of the
idle app: `EdgeFrameTicker.step` under `CA::Display::DisplayLink` with nothing animating.
24 changes: 24 additions & 0 deletions .claude/skills/ux/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,30 @@ thing for real, frame by frame, and comparing it with the checklist**, then fixi
after sit next to each other.
6. **Write down what was wrong on the first try** in the ledger, generically, with the commit.

## What Martin asks for, before he has to (his reviews of 2026-10-23)

Read this before a widget, hub or settings change, and do these without being asked. Each line is a request he had
to make by screenshot.

- **Every clickable thing answers the pointer.** Round bubbles get a round hover ring, not a rectangular plate or
nothing. If he cannot tell a thing is clickable by hovering it, it is a defect.
- **Opinionated means configurable.** A sound, a popup, an auto-expand, a flash, a tooltip, a placement, an order:
ship a setting with your pick as its default (`.claude/rules/opinionated-settings.md`), and say which you added.
- **Say why something moved.** When the widget chimes or opens by itself, the session that caused it flashes
(its bubble, its row, its popup) for a configurable few seconds. A cue without its cause is noise.
- **Rows carry their times.** A session row says when it was last active and when its newest question or answer
arrived ("active 12m ago · asked 3m ago"), live (`LiveAgo`). A header says what the session is: agent, project,
model, working or idle for how long, context size.
- **No jumps.** A filter that empties a list does not shrink an open panel; a tab, filter or headline never scrolls
under a fade; content never blinks empty while a panel grows or a hover changes it.
- **The action is near the eye.** The reply box sits where the reading happens (under the tabs), not at the far
bottom of a tall panel.
- **Attention cues point, they do not cover.** A flash is the border; a tinted overlay over a whole card reads as
covered. Border flash is the `AttentionPulse` default.
- **A tooltip never stands in for the real hover behavior.** If hover should open a preview, a tooltip appearing
instead is a bug to fix, not a fallback.
- **Every count opens what it counts**, and every session offers its terminal (cmux) and its inbox.

## Design authorities (load before a judgment call)

| Question | Load |
Expand Down
36 changes: 36 additions & 0 deletions .claude/skills/ux/references/checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,42 @@ Walk it for every view you change. Each line exists because it was missed at lea
- One navigation per surface; no two controls that do the same thing side by side.
- Distinct icons for distinct features (two modules once shared `waveform`).

## Widget and panel controls (Martin, 2026-10-23 14:40)
- A text field inside a padded box: the whole box focuses the field (`.contentShape` + `.onTapGesture { focus = true }`
on the box); a click beside or under the one-line field did nothing.
- A button that opened something closes it on a second click when what it opened is still on screen (a rail
session's inbox, the inbox overview). A second click that does nothing reads as a dead control.
- A count leads to the list of exactly what it counts. "35 need you" over 4 rows, and "99+ unread" that opened one
arbitrary session, are both defects: the count opens a list of every counted item (scrollable, filterable), and
each item says which session it belongs to (name, project, short id) and how to get there.
- Every session row offers a way to its terminal: the cmux button with GenesisKit `CmuxSessionPanel` (focus, open
in its last pane, choose a pane), one shared component in the hub and the widget.
- A list's filter and order live next to the list, with a settings shortcut that opens the page at the card and
pulses it (`<page>#<anchor>` + `.nativeSettingsAnchor(id)`, GenesisKit `AttentionPulse`).
- One row style for every top-level item of a list. Sessions with sub-agents drew as disclosure headers and sessions
without as plain rows, in one list: same row, a chevron only where there is something to fold, and selection, pin
and cmux inside the row's own box.
- A scroll view reused for several sections (or sessions) gets an identity per content (`.id(section + key)`): it
otherwise keeps the old offset, and a short section drew its rows over the header.

## Widget and panel controls, round 2 (Martin, 2026-10-23 15:35)
- Hover on every control, shaped like the control: a circle bubble gets a ring (`WidgetBubbleChrome`), which pulses
once on enter and settles. Opacity only, never a repeating animation.
- A filter bar and its headline sit outside the scroll view they filter, pinned under the tabs.
- A filter change in an open panel holds the panel's height (`WidgetModel.holdAgentFit`); folding a row or switching
view releases it. An empty filter result must not collapse the panel.
- A header line under a title is the place for facts: agent · project · model · working / idle `LiveAgo` · ctx.
- Rows: last active and newest question or answer, both `LiveAgo`.
- An alert (sound, popup, auto-expand) has a setting each, a sound per kind with a play button, and flashes the
session that caused it for `alertFlashSeconds`.
- A hover tooltip that duplicates a hover preview is off by default ("auto"), and the preview must open reliably:
a control the pointer reaches inside a panel starts the panel's hover itself, since the panel's own hover fires
only on crossing its edge.
- Two views of the same sessions (the rail bubbles and the hover list) read in one order; a bubble and its row sit
at the same place. A sticky order that never moves is an option, not the default (Martin, 2026-10-23 17:47).
- A flash reaches every place the session shows, including a list the alert itself opened: such a list is built
after the trigger changed, so it needs the flash start time, not only the trigger.

## Feedback
- Every action answers within 100 ms: visible state change, sound for capture, progress for slow work.
- Errors say what happened and what to do, in a dialog when the user must act. A missing grant goes through
Expand Down
Loading
Loading