App kit: Toggle, Slider, Panel, cmux.theme and cmux.toast for customize panes

Merged
TE
2d ago
+1156-24

Leo asked for a frame that makes a vibe-coded customization look like part of cmux:

"if you want to customize it, it should just be like ultra easy to just be able to vibe code something that's like not only there, but looks cohesive ... there are few f***ing ways to get this done than like to introduce some semblance of frame, right?"

"and also of extensibility of people wanting to customize it with creating custom things for it specific ui elements"

This adds that frame to the customize app (~/.config/cmux/customize/, local/customize) and every other app pane. The kit is a few new scene nodes drawn by the app's own controls and surfaces, tone names in place of colors, and a toast. There is also a copy-paste template that agents start from.

The kit

APIWhat the app draws
Toggle(value, { label?, disabled?, onChange? })The native switch in a settings row: the label leading, the switch trailing, inked in the scope's primary text color.
Slider(value, { min?, max?, step?, disabled?, onChange? })The native slider, snapped to step (by default a hundredth of the range), in the same ink.
Panel({ title?, style?, spacing? }, children) / Panel(children)A group on the sidebar cards' glass surface (CardSurfaceView, which takes the theme tint), or on an opaque elevated card with style: "card". Padding and radius are Metrics.space3, and the title uses the card's eyebrow style.
cmux.themeFrozen tone names: accent, text.primary, text.secondary, text.tertiary, success, warning, danger, separator, hover, selected. They are what color props already accept, so a pane never writes hex, and its colors follow the theme live.
cmux.toast({ title, message?, tone? }) / cmux.toast("text")A notice in the app's existing toast presenter (CmuxToastCenter) in the main window. Each app gets one slot (app:<id>): a new toast replaces that app's last one. The detail shows the message and then the app's id, so an app's notice is never mistaken for one of cmux's own.

Toggle and Slider are two-way bound like TextField:

  • With onChange, a change shows at once and sends change {value}; the next value the app sends wins.
  • While a slider drag is in progress, the app's echoes of earlier values do not move the thumb.
  • Without onChange, the control only shows the app's value.
  • A change payload of the wrong type is dropped, never coerced.

Wired end to end:

  • The JS view DSL, the runtime dispatch and the rebuilt dist.
  • ABI.md.
  • gen-cmux-global.ts, which regenerates cmux-app.d.ts, ops.json and scopes.json. Nothing generated was hand-edited.
  • The Swift AppSceneOp and AppSceneNodeView.
  • The daemon's provider families, plus the Mac's fallback for an older daemon: on its bad-request the Mac registers again without the newest family it sent (toast, then memory), one at a time, so a daemon that knows memory but not toast keeps memory.
  • The iOS parity row. The phone draws no app scenes, so it reads "not from a phone".

Toast scope

toast.show has scope none, so every app holds it without a grant, including unverified local apps, which get only :read scopes at install. The reasoning:

  • It is local and makes no network call.
  • It is capped: a 200-character title and a 1000-character message.
  • It is attributed to the app in the detail.
  • It is limited to one slot per app, under the presenter's own limits.

The daemon still refuses it for a preview host and for an app that is stopping (a_toast_needs_no_grant_but_not_a_preview_or_a_stopping_app). The Swift permission policy reads none the way it reads storage:local, and a manifest cannot request none.

The unified notice system has not landed. Until it does, tone is validated (success, warning or danger; anything else is invalid_params) but not drawn, because the presenter has one look. When the notice system lands, tone maps onto it with no API change.

Docs

  • skills/cmux-customization/SKILL.md gains "Matching the app".
  • references/local-app-views.md gains the same section: the kit table, the rules (no literal colors or sizes, two-way Toggle and Slider), and a focus timer template (a manifest and main.js) built only from the kit, with no scopes.
  • customize_example.rs validates the template's manifest and script as a package and checks that it uses only the kit.
  • kit.test.ts mounts the documented script as written, taps Start, and checks the toast and the progress swap.

Changelog

  • Added: App panes and the customize folder get Toggle, Slider and Panel nodes, cmux.theme tone names and cmux.toast, all drawn with cmux's own controls and surfaces so a vibe-coded pane matches the app, plus a copy-paste focus timer template in the customization skill.

Red / green

Each failing test was committed before its fix, and red and green come from the same command.

  • JS (JavaScript): cmux-ci test --timeout 1500 -- bash -c 'cd cmux-tui/crates/cmux-app-host && bun test js/test/kit.test.ts'
    • Red at 7e749cf8 (run 41822589907): 0 pass, 10 fail.
    • Review-fix red at 51519cb5: 2 fail (the dropped-payload tests), 11 pass (run 41825611102).
    • Green at 95bdcbdf (run 41826016417): 90 pass, 0 fail (with bun js/build.ts --check, bun tools/gen-cmux-global.ts --check and sync-app-runtime.sh --check, all up to date, plus runtime.test.ts and tools.test.ts).
  • Swift: cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg suite --arg Packages/macOS/CmuxNext --arg AppSceneKitTests,ToastAppOpsTests
    • Red at 4f43a932: the suites did not compile (cannot find 'ToastAppOps' in scope, the missing node types).
    • At 637246bf: ToastAppOpsTests 4/4 passed; AppSceneKitTests 4/5. The NSSlider test was wrong: SwiftUI drives the NSSlider over a normalized range, so it now compares the thumb's fraction.
    • Review-fix red at 51519cb5: 3 tests fail (the display-only Toggle flips; snap returns 0.30000000000000004 and 0.7000000000000001; and the NSSlider read above).
    • Green at aeffa447: AppSceneKitTests and ToastAppOpsTests, 10/10. On macOS 27 SwiftUI's Slider has no NSSlider, so that test reads one only where it exists (the snapping is tested on its own).
    • Provider fallback, --arg AppsProviderChannelNewerFamilyTests: red at cddf438d: aDaemonWithMemoryButNoToastKeepsMemory failed, the retry sent ["coderouter"] and lost memory. Green at 8b78c7e3, with AppSceneKitTests and ToastAppOpsTests in the same run: 3/3, 6/6 and 4/4 pass.
  • Rust: ./scripts/verify-cmux-tui-hosted.sh --filter … (hosted Linux and macOS).
    • Red at 51519cb5 (run 41830317591, --filter toast): a_toast_routes_to_the_provider_for_an_app_with_no_scopes FAILED. register_provider refused toast as an unknown family, which would have refused the Mac's whole provider registration.
    • Green at 95bdcbdf (run 41828170954): passes on hosted Linux and macOS.
    • The template test (customize_example.rs) at 6cb4e64b (run 41830141618): passes.
    • lint (linux) is red on base (a clippy needless_borrow at crates/cmux-tui/src/cloud_environment.rs:22), not from this PR.

Observed / Not run

Observed: the runs above on the fleet and on hosted runners, and the generated-file checks.

Not run:

  • I did not draw the toast's tone; the presenter has no tones yet (see above).

  • No live isDark: cmux.theme holds names, and the host resolves them live, so a pane needs no light/dark branch.

  • I did not test the slider drag-echo handling (it ignores app values until the drag ends): it needs a real pointer drag. Only the snapping and the initial value are unit-tested.

  • I did not verify in a running app that the Mac provider registers with the toast family against a live daemon. The daemon-side routing test covers registration and routing, and the Mac sends toast through the existing family registration.

  • A slider can briefly snap back at the end of a drag before the app's echo of the final value arrives; a follow-up.

  • No test checks that a Slider without onChange is disabled (the Toggle one is tested).

Perf

There is no animation. The Toggle and Slider are native controls. A slider drag sends at most one change per distinct snapped value (at most about 100 by default, fewer with step).

Capture

No capture yet: no scene: or tour: state loads a customize-folder app. The capture host starts the app with its own HOME, and no existing scene puts an app into that HOME's ~/.config/cmux/customize or waits for the daemon to install and mount it. A scene that seeds the template and waits for the section's mount to be ready is a change of its own, and I have left it out of this PR. Until then, AppSceneKitTests hosts the nodes in a themed window and checks that the real AppKit controls and surfaces are drawn: an NSSwitch that sends its new value, an NSSlider at its value, and a CardSurfaceView with the theme tint and Metrics.space3 radius.

šŸ¤– Generated with Claude Code

Agent session: agent session