@promptctl/cc-candybar 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.mjs +86 -85
  2. package/package.json +6 -6
  3. package/schema/cc-candybar.schema.json +193 -4
  4. package/src/check.ts +49 -27
  5. package/src/click/wire.ts +16 -0
  6. package/src/config/action.ts +57 -22
  7. package/src/config/default-dsl-config.ts +424 -55
  8. package/src/config/dsl-loader.ts +14 -2
  9. package/src/config/dsl-types.ts +59 -0
  10. package/src/config/loader/actions.ts +283 -109
  11. package/src/config/loader/cross-ref.ts +148 -28
  12. package/src/config/loader/emit-schema.ts +2 -0
  13. package/src/config/loader/globals.ts +118 -31
  14. package/src/config/loader/merge.ts +58 -1
  15. package/src/config/loader/persist-target.ts +32 -0
  16. package/src/config/loader/presets.ts +107 -0
  17. package/src/config/option-domain.ts +164 -0
  18. package/src/config/presets.ts +156 -0
  19. package/src/daemon/cache/git.ts +1 -1
  20. package/src/daemon/cache/render.ts +61 -7
  21. package/src/daemon/config-overrides-store.ts +322 -0
  22. package/src/daemon/paths.ts +10 -0
  23. package/src/daemon/render-payload.ts +84 -19
  24. package/src/daemon/server.ts +68 -55
  25. package/src/daemon/verbs/config-validators.ts +127 -0
  26. package/src/daemon/verbs/index.ts +129 -2
  27. package/src/daemon/verbs/state-validators.ts +98 -586
  28. package/src/daemon/verbs/validator-registry.ts +457 -0
  29. package/src/demo/dsl.ts +17 -10
  30. package/src/dsl/node-registry.ts +54 -39
  31. package/src/dsl/render.ts +158 -46
  32. package/src/help-text.ts +3 -3
  33. package/src/install/index.ts +2 -2
  34. package/src/render/action.ts +155 -33
  35. package/src/render/active-segment.ts +78 -0
  36. package/src/render/menu.ts +16 -11
  37. package/src/render/picker.ts +51 -13
  38. package/src/render/segment-color.ts +74 -0
  39. package/src/segments/git.ts +389 -48
  40. package/src/template-engine/colors.ts +67 -45
  41. package/src/template-engine/engine.ts +11 -12
  42. package/src/themes/index.ts +1 -4
  43. package/src/themes/palette-resolvers.ts +22 -30
  44. package/src/themes/policy.ts +37 -16
@@ -26,7 +26,7 @@
26
26
  // loader's own synthesis pass (see the bottom of this file) — a `DslConfig`,
27
27
  // the same effective shape every user config resolves to.
28
28
 
29
- import type { DslConfig, SegmentDecl } from "./dsl-types.js";
29
+ import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
30
30
  import { parseDslConfig } from "./dsl-loader.js";
31
31
  import { mergeWithDefault } from "./loader/merge.js";
32
32
 
@@ -74,30 +74,111 @@ const DIR_TEMPLATE =
74
74
  "{{ end }}{{ end }}" +
75
75
  "{{ abbreviatePath $dir }}";
76
76
 
77
- // Git working-tree counts leading-space-then-trim idiom: each present count
78
- // contributes " +N", trim drops the leading space, survivors single-spaced.
77
+ // Git-fact semantic palette color, ONE table both the `git` and
78
+ // `gitaculous` segment templates below read from. [LAW:one-source-of-truth]
79
+ // brandon-segments-3eo.1 (the recoloring itself) typed this choice
80
+ // independently into each template's literal string and the two drifted —
81
+ // gitaculous colored branch `accent` where git used `primary`, and left
82
+ // stash uncolored where git used `accent` — caught by live testing
83
+ // (brandon-segments-3eo.1.1). A shared table makes "what color is this
84
+ // fact" a value read twice, not a decision re-typed twice.
85
+ const GIT_COLOR = {
86
+ branch: "primary",
87
+ staged: "success",
88
+ unstaged: "warning",
89
+ untracked: "accent",
90
+ conflicts: "error",
91
+ ahead: "success",
92
+ behind: "warning",
93
+ stash: "accent",
94
+ } as const;
95
+
96
+ // Paint one git fact in its semantic color. The table above holds palette
97
+ // *variable names* (data), and this is the one place a name becomes a template
98
+ // call, so the fact→color decision and its spelling stay separate concerns.
99
+ // [LAW:one-source-of-truth]
100
+ //
101
+ // Note the shape: `fg (color "…")`, which is also what a composed color looks
102
+ // like — `fg (darken (color "…") 1)`. A template that wants to adjust one of
103
+ // these later wraps the color expression instead of rewriting the call.
104
+ const paint = (fact: keyof typeof GIT_COLOR, content: string): string =>
105
+ `{{ fg (color "${GIT_COLOR[fact]}") ${content} }}`;
106
+
107
+ // How far the two git segments' *structural* text — labels, punctuation,
108
+ // brackets, the sha, the upstream name, the elapsed-time annotation — sits
109
+ // toward their own background, as a percentage. It is applied as the segments'
110
+ // `fg:`, so structural text is simply what a token renders as when the
111
+ // template does NOT paint it; only the operative facts (branch, counts,
112
+ // status) name a color. [LAW:dataflow-not-control-flow] — the quiet case is
113
+ // the default that always applies, not a wrapper each token opts into. That
114
+ // inversion is why these templates carry no de-emphasis markup at all.
115
+ //
116
+ // The blend target is the segment's OWN background (`bgOf`), not the theme's
117
+ // `background`. The git segments render on `surface-active`, so a palette
118
+ // `foreground-muted` — which blends toward `background` — would be muted
119
+ // toward a color that is not behind them, and would read either too loud or
120
+ // too faint depending on how far the surface sits from the base.
121
+ //
122
+ // The `readableOn` floor is what makes the result theme-independent, and it is
123
+ // not decoration: the bare 60% blend measures 1.93–3.10 contrast across the
124
+ // bundled themes (catppuccin-latte at 1.93 is genuinely hard to read), because
125
+ // a fixed *percentage* of a distance is not a fixed amount of legibility when
126
+ // themes disagree about how far apart their own foreground and surface sit.
127
+ // Flooring at WCAG's 3:1 large-text threshold lands every theme at 3.00–3.10
128
+ // against full-strength foregrounds of 5.8–10.9 — as quiet as each theme can
129
+ // afford, and never quieter. Verified across the bundled themes in
130
+ // test/default-dsl-config.test.ts rather than eyeballed on one.
131
+ // [LAW:verifiable-goals]
132
+ const GIT_QUIET_PCT = 60;
133
+ const GIT_QUIET_MIN_CONTRAST = 3;
134
+ const GIT_QUIET_FG =
135
+ `{{ readableOn (mix (color "foreground") (bgOf) ${GIT_QUIET_PCT}) (bgOf) ` +
136
+ `${GIT_QUIET_MIN_CONTRAST} }}`;
137
+
138
+ // Git working-tree counts — each present count renders in its own semantic
139
+ // palette color (GIT_COLOR above) so a dirty tree reads at a glance,
140
+ // p10k/gitaculous-prompt style, instead of one uniform segment fg. `$first`
141
+ // tracks whether a separator space is still owed before the next present
142
+ // count.
143
+ // [LAW:dataflow-not-control-flow]: one variable carries the "have we emitted
144
+ // yet" state rather than four copies of positional space logic. `$first` is
145
+ // declared inside the outer `{{ if or ... }}` gate (unlike DIR_TEMPLATE's
146
+ // `$dir`, declared at the template's top level) and reassigned via `=` in
147
+ // nested `{{ if }}` blocks below — Go template variable scoping walks up to
148
+ // the declaring frame on `=` regardless of nesting depth, so this still
149
+ // works, just at one more scope level than DIR_TEMPLATE's `$dir`. Verified
150
+ // by test/default-dsl-config.test.ts's "worktree counts render
151
+ // single-space-separated" test, not merely asserted here.
79
152
  const GIT_WORKTREE =
80
153
  "{{ if or (gt .git.staged 0) (gt .git.unstaged 0) (gt .git.untracked 0) (gt .git.conflicts 0) }}" +
81
- ' ({{ printf "%s%s%s%s"' +
82
- ' (ternary (printf " +%v" .git.staged) "" (gt .git.staged 0))' +
83
- ' (ternary (printf " ~%v" .git.unstaged) "" (gt .git.unstaged 0))' +
84
- ' (ternary (printf " ?%v" .git.untracked) "" (gt .git.untracked 0))' +
85
- ' (ternary (printf " !%v" .git.conflicts) "" (gt .git.conflicts 0)) | trim }}){{ end }}';
154
+ ` ({{ $first := true }}` +
155
+ `{{ if gt .git.staged 0 }}${paint("staged", '(printf "+%v" .git.staged)')}{{ $first = false }}{{ end }}` +
156
+ `{{ if gt .git.unstaged 0 }}{{ if not $first }} {{ end }}${paint("unstaged", '(printf "~%v" .git.unstaged)')}{{ $first = false }}{{ end }}` +
157
+ `{{ if gt .git.untracked 0 }}{{ if not $first }} {{ end }}${paint("untracked", '(printf "?%v" .git.untracked)')}{{ $first = false }}{{ end }}` +
158
+ `{{ if gt .git.conflicts 0 }}{{ if not $first }} {{ end }}${paint("conflicts", '(printf "!%v" .git.conflicts)')}{{ $first = false }}{{ end }}` +
159
+ "){{ end }}";
86
160
 
87
- // Status icon precedence: conflicts → ⚠, dirty → ●, else clean ✓.
161
+ // Status icon precedence: conflicts → (error), dirty → (warning), else
162
+ // clean ✓ (success) — colored to match the state it reports.
88
163
  const GIT_STATUS =
89
- '{{ if eq .git.status "conflicts" }}⚠{{ else }}' +
90
- '{{ if eq .git.status "dirty" }}{{ else }}✓{{ end }}{{ end }}';
164
+ '{{ if eq .git.status "conflicts" }}{{ fg (color "error") "" }}{{ else }}' +
165
+ '{{ if eq .git.status "dirty" }}{{ fg (color "warning") "●" }}' +
166
+ '{{ else }}{{ fg (color "success") "✓" }}{{ end }}{{ end }}';
91
167
 
168
+ // Every unpainted token here — the repo name, `⎇`, `♯`, the sha, the worktree
169
+ // parentheses, `→`, the upstream name — renders in the segment's quiet `fg:`
170
+ // (GIT_QUIET_FG). Only the operative facts name a color, so the template reads
171
+ // as the line it draws rather than as de-emphasis markup wrapped around it.
92
172
  const GIT_TEMPLATE =
93
- '{{ if ne .git.repoName "" }}{{ .git.repoName }} {{ end }}⎇ {{ .git.branch }}' +
173
+ '{{ if ne .git.repoName "" }}{{ .git.repoName }} {{ end }}' +
174
+ `⎇ ${paint("branch", ".git.branch")}` +
94
175
  "{{ if .git.sha }} ♯ {{ .git.sha }}{{ end }}" +
95
176
  "{{ if or (gt .git.ahead 0) (gt .git.behind 0) }}" +
96
- " {{ if gt .git.ahead 0 }}{{ .git.ahead }}{{ end }}" +
97
- "{{ if gt .git.behind 0 }}{{ .git.behind }}{{ end }}{{ end }}" +
177
+ ` {{ if gt .git.ahead 0 }}${paint("ahead", '(printf "↑%v" .git.ahead)')}{{ end }}` +
178
+ `{{ if gt .git.behind 0 }}${paint("behind", '(printf "↓%v" .git.behind)')}{{ end }}{{ end }}` +
98
179
  GIT_WORKTREE +
99
180
  "{{ if .git.upstream }} →{{ .git.upstream }}{{ end }}" +
100
- "{{ if gt .git.stash 0 }} ⧇ {{ .git.stash }}{{ end }}" +
181
+ `{{ if gt .git.stash 0 }} ${paint("stash", '(printf "%v" .git.stash)')}{{ end }}` +
101
182
  " " +
102
183
  GIT_STATUS;
103
184
 
@@ -146,6 +227,51 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
146
227
  );
147
228
  }
148
229
 
230
+ // ─── The settings drawer (candybar-config-engine-71o.4) ──────────────────────
231
+
232
+ // [LAW:one-source-of-truth] exception: `kind: "group"` is authoring-grammar
233
+ // sugar the loader lowers at parse time (src/config/loader/layout.ts) —
234
+ // deliberately NOT a member of the canonical LayoutNode union DslConfig.root
235
+ // requires (arranging + gating are behaviors `container` already has; "group"
236
+ // is only a spelling), so a plain `satisfies DslConfig` cannot type-check it
237
+ // inline below. Hand-lowering it here instead (writing the toggle segment +
238
+ // gated body container by hand, under the reserved `groups.` namespace) is
239
+ // NOT an option: reservedNamespaceCollisions rejects any USER-authored
240
+ // variables/actions/segments name starting with `groups.` before synthesis
241
+ // ever runs, so a hand-authored `groups.settings` segment would be rejected
242
+ // as squatting the very namespace it's trying to populate — the sugar node is
243
+ // the only legal way to populate it. This literal is unconditionally
244
+ // round-tripped through the real parseDslConfig pipeline below (see the
245
+ // module-load parse near the bottom of this file) exactly like a user's
246
+ // hand-authored JSON5, so a malformed group is still caught loudly at import
247
+ // time — the type safety net just moves from tsc to that parse, never lost.
248
+ //
249
+ // One collapsed-by-default drawer holding every bar-mutable display default:
250
+ // theme/style/look (session `set` for a per-conversation preview, PLUS a
251
+ // persist-forever twin — candybar-config-engine-71o.5 — for pinning the
252
+ // choice as everyone's default), the four .3 globals steppers (persist-only,
253
+ // no SessionState half at all), and one .6 segment-scoped persist control
254
+ // (directoryPaletteControl, persist-only like the four steppers — a
255
+ // per-segment palette pin, not a whole-bar default). Placed as a sibling in
256
+ // row 1's horizontal container, toggled from beside the quick-action tray —
257
+ // see `root` below.
258
+ const settingsDrawer = {
259
+ kind: "group",
260
+ name: "settings",
261
+ label: "⚙ settings",
262
+ direction: "horizontal",
263
+ children: [
264
+ "themeControl",
265
+ "lookControl",
266
+ "styleControl",
267
+ "charsetControl",
268
+ "colorCompatControl",
269
+ "wrapToggleControl",
270
+ "paddingControl",
271
+ "directoryPaletteControl",
272
+ ],
273
+ } as unknown as LayoutNode;
274
+
149
275
  // ─── The default config ──────────────────────────────────────────────────────
150
276
 
151
277
  // [LAW:one-source-of-truth] The AUTHORED literal, pre-synthesis. Production
@@ -229,6 +355,50 @@ export const RAW_DEFAULT_DSL_CONFIG = {
229
355
  path: "look.effective",
230
356
  default: "",
231
357
  },
358
+ // [LAW:one-type-per-behavior] The effective PRESET name, theme/look's twin
359
+ // one level up — effectivePresetName(sessionState.preset, globals.preset,
360
+ // presets), the SAME name that selected the layout this render walked and
361
+ // the globals it rendered with. A preset-picker trigger reads
362
+ // `{{ .preset.effective }}` for its label, so the label and the arrangement
363
+ // trace to one resolution.
364
+ "preset.effective": {
365
+ kind: "input",
366
+ path: "preset.effective",
367
+ default: "",
368
+ },
369
+ // [LAW:one-type-per-behavior] style/charset/colorCompatibility/autoWrap/
370
+ // padding are theme/look's twins over the remaining persistable globals
371
+ // (candybar-config-engine-71o.3) — the SAME values BuildLineOptions
372
+ // renders with, each read back through this projection so a `persist`
373
+ // action over the field shows a "current selection" highlight and a
374
+ // trigger label can display the active value without restating it.
375
+ "style.effective": {
376
+ kind: "input",
377
+ path: "style.effective",
378
+ default: "",
379
+ },
380
+ "charset.effective": {
381
+ kind: "input",
382
+ path: "charset.effective",
383
+ default: "",
384
+ },
385
+ "colorCompatibility.effective": {
386
+ kind: "input",
387
+ path: "colorCompatibility.effective",
388
+ default: "",
389
+ },
390
+ "autoWrap.effective": {
391
+ kind: "input",
392
+ path: "autoWrap.effective",
393
+ type: "boolean",
394
+ default: true,
395
+ },
396
+ "padding.effective": {
397
+ kind: "input",
398
+ path: "padding.effective",
399
+ type: "number",
400
+ default: 1,
401
+ },
232
402
 
233
403
  // [LAW:one-source-of-truth] The usable terminal width for THIS render —
234
404
  // the exact post-reserve cell count FlexStrip wraps to. renderDsl injects
@@ -281,6 +451,10 @@ export const RAW_DEFAULT_DSL_CONFIG = {
281
451
  path: "git.repoName",
282
452
  default: "",
283
453
  },
454
+ // The repo's browsable web page, transposed from its remote by the daemon.
455
+ // "" is the genuine "no remote a browser can open" (local-only repo, bare
456
+ // path remote) — the toolbar's link reads that value, not a flag.
457
+ "git.repoUrl": { kind: "input", path: "git.repoUrl", default: "" },
284
458
  "git.branch": { kind: "input", path: "git.branch", default: "" },
285
459
  "git.sha": { kind: "input", path: "git.sha", default: "" },
286
460
  "git.ahead": {
@@ -609,31 +783,59 @@ export const RAW_DEFAULT_DSL_CONFIG = {
609
783
  git: {
610
784
  template: GIT_TEMPLATE,
611
785
  bg: "surface-active",
612
- fg: "foreground",
786
+ // A computed `fg:` — the field is a template evaluating to a color
787
+ // reference, and `bgOf` is available here because a segment's background
788
+ // is resolved before its foreground. Structural text therefore sits a
789
+ // fixed distance from THIS segment's background whatever theme, look, or
790
+ // hue shift is in effect.
791
+ fg: GIT_QUIET_FG,
613
792
  when: '{{ ne .git.branch "" }}',
614
793
  },
615
794
  gitaculous: {
795
+ // Recolored from raw green/red (render-bugs-pdu.3's era) to semantic
796
+ // palette names (brandon-segments-3eo.1), then unified against `git`'s
797
+ // choice of color per fact via the shared GIT_COLOR table above
798
+ // (brandon-segments-3eo.1.1 — the two had drifted: branch, untracked,
799
+ // and stash each disagreed with `git`'s coloring of the same fact).
800
+ // staged/ahead share `success` (positive — ready to commit / unpushed
801
+ // additions), unstaged/behind share `warning` (needs attention),
802
+ // untracked/stash share `accent` (their own GIT_COLOR entries, kept
803
+ // visually distinct from unstaged by using a different glyph, not a
804
+ // different color, so "U" vs "?" reads apart at a glance), conflicts
805
+ // gets its own `error`, branch gets `primary`.
806
+ //
807
+ // Everything the template does NOT paint — the "(git)" label, repo name,
808
+ // the operation and upstream brackets, the sha, the elapsed-time
809
+ // annotation — renders in the segment's quiet `fg:` and recedes, so the
810
+ // eye lands on the operative colored facts first (brandon-segments-
811
+ // 3eo.1.1.1: live feedback that "most of it" read as one flat color when
812
+ // only two facts happened to be present). Note that this needs no markup
813
+ // in the template — including around `{{ template "formatTimeSince" }}`,
814
+ // which as a top-level Go-template action could never have been wrapped
815
+ // in a styling call at all. Making quiet the default rather than a
816
+ // wrapper is what put that token in reach.
616
817
  template:
617
818
  "(git)" +
618
819
  '{{ if ne .git.repoName "" }} {{ .git.repoName }}{{ end }}' +
619
820
  '{{ if ne .git.operation "" }} [{{ .git.operation }}]{{ end }}' +
620
821
  '{{ if ne .git.sha "" }} {{ .git.sha }}{{ end }}' +
621
822
  "{{ if or (gt .git.staged 0) (gt .git.unstaged 0) (gt .git.untracked 0) (gt .git.conflicts 0) }} " +
622
- '{{ if gt .git.staged 0 }}{{ green "S" }}{{ end }}' +
623
- '{{ if or (gt .git.unstaged 0) (gt .git.untracked 0) }}{{ red "U" }}{{ end }}' +
624
- '{{ if gt .git.conflicts 0 }}{{ red (printf "!%v" .git.conflicts) }}{{ end }}' +
823
+ `{{ if gt .git.staged 0 }}${paint("staged", '"S"')}{{ end }}` +
824
+ `{{ if gt .git.unstaged 0 }}${paint("unstaged", '"U"')}{{ end }}` +
825
+ `{{ if gt .git.untracked 0 }}${paint("untracked", '"?"')}{{ end }}` +
826
+ `{{ if gt .git.conflicts 0 }}${paint("conflicts", '(printf "!%v" .git.conflicts)')}{{ end }}` +
625
827
  "{{ end }}" +
626
- " ⎇ {{ .git.branch }}" +
828
+ `${paint("branch", ".git.branch")}` +
627
829
  '{{ if ne .git.upstream "" }} [{{ .git.upstream }}' +
628
830
  "{{ if or (gt .git.ahead 0) (gt .git.behind 0) }} " +
629
- '{{ if gt .git.ahead 0 }}{{ green (printf "+%v" .git.ahead) }}{{ end }}' +
831
+ `{{ if gt .git.ahead 0 }}${paint("ahead", '(printf "+%v" .git.ahead)')}{{ end }}` +
630
832
  "{{ if and (gt .git.ahead 0) (gt .git.behind 0) }}/{{ end }}" +
631
- '{{ if gt .git.behind 0 }}{{ red (printf "-%v" .git.behind) }}{{ end }}' +
833
+ `{{ if gt .git.behind 0 }}${paint("behind", '(printf "-%v" .git.behind)')}{{ end }}` +
632
834
  "{{ end }}]{{ end }}" +
633
- "{{ if gt .git.stash 0 }} ({{ .git.stash }} stashed){{ end }}" +
835
+ `{{ if gt .git.stash 0 }} ${paint("stash", '(printf "(%v stashed)" .git.stash)')}{{ end }}` +
634
836
  '{{ if gt .git.timeSinceCommit 0 }} ◷ {{ template "formatTimeSince" .git.timeSinceCommit }}{{ end }}',
635
837
  bg: "surface-active",
636
- fg: "foreground",
838
+ fg: GIT_QUIET_FG,
637
839
  when: '{{ ne .git.branch "" }}',
638
840
  },
639
841
  // Git PR/MR — the branch's open pull/merge request as a clickable link.
@@ -657,18 +859,27 @@ export const RAW_DEFAULT_DSL_CONFIG = {
657
859
  when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
658
860
  },
659
861
  // Quick-action tray — the default bar's interactivity: copy the session id,
660
- // open the project dir / transcript (this session's jsonl) in the editor.
862
+ // open the project dir / transcript (this session's jsonl) in the editor,
863
+ // and open the repo's web page in the browser.
661
864
  // (copyDir — copy the cwd — stays declared as an action below for users who
662
- // want a fourth glyph; it is simply not in the default tray.)
865
+ // want a fifth glyph; it is simply not in the default tray.)
663
866
  // [LAW:locality-or-seam] The glyph is the REPRESENTATION; the named action
664
867
  // (below) is the BEHAVIOR; the action name is the seam between them. Re-glyph
665
868
  // without touching behavior; re-target without touching this template. Each
666
869
  // `{{ action … }}` emits one OSC-8 clickable region whose URL the wire codec
667
870
  // owns end-to-end.
871
+ //
872
+ // `↗ repo` is the one glyph here that is NOT an action: the daemon already
873
+ // resolved the remote to an https page, so `{{ link }}` hands that URL
874
+ // straight to the terminal/OS (same seam the gitPr segment uses) — routing a
875
+ // public web URL through a cc-candybar:// verb would buy nothing. It is
876
+ // gated on the VALUE (`ne … ""`), not on a flag: a local-only repo simply
877
+ // supplies no page and the glyph is absent. [LAW:dataflow-not-control-flow]
668
878
  toolbar: {
669
879
  template:
670
880
  '{{ action "copySession" "⎘ id" }}' +
671
- ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}',
881
+ ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }}' +
882
+ '{{ if ne .git.repoUrl "" }} {{ link .git.repoUrl "↗ repo" }}{{ end }}',
672
883
  bg: "surface",
673
884
  fg: "foreground",
674
885
  },
@@ -829,10 +1040,25 @@ export const RAW_DEFAULT_DSL_CONFIG = {
829
1040
  // render) and stay-open, so shapes can be tried in a row; ▾/✕ collapse.
830
1041
  // [LAW:dataflow-not-control-flow] No display state from the provider — the
831
1042
  // label is the one "style" value the click writes and the render reads.
1043
+ // Lives inside the settingsDrawer group (candybar-config-engine-71o.4) — not
1044
+ // on `root` directly — so it renders only while the drawer is open.
1045
+ // The 📌 "make default" menu + ↺ reset, one per control below, is
1046
+ // styleControl/themeControl/lookControl's PERSIST twin (candybar-config-
1047
+ // engine-71o.5) — the exact pairing charsetControl/colorCompatControl/
1048
+ // wrapToggleControl/paddingControl already use, since theme/style/look
1049
+ // are the only three of the drawer's seven knobs with a session `set`
1050
+ // half at all. Its own "pickersForever" accordion key keeps the
1051
+ // persist tier visually distinct from the existing "pickers" try tier
1052
+ // (opening a session preview and opening a "pin as default" picker are
1053
+ // different intents; auto-closing one when the other opens would
1054
+ // conflate them) without touching the already-shipped/tested "pickers"
1055
+ // accordion's membership.
832
1056
  styleControl: {
833
1057
  template:
834
1058
  "✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
835
- '{{ menu "applyStyle" }}',
1059
+ '{{ menu "applyStyle" }} ' +
1060
+ '📌{{ menu "applyStyleForever" (dict "key" "pickersForever") }} ' +
1061
+ '{{ action "resetStyle" "↺" }}',
836
1062
  bg: "surface",
837
1063
  fg: "foreground",
838
1064
  },
@@ -845,11 +1071,16 @@ export const RAW_DEFAULT_DSL_CONFIG = {
845
1071
  // styleControl (no "effective style" input exists), no extra `state`
846
1072
  // variable is needed here. Shares the "pickers" accordion key with
847
1073
  // lookControl so opening one closes the other, the docs' canonical
848
- // two-menu pairing.
1074
+ // two-menu pairing. Moved inside the settingsDrawer group
1075
+ // (candybar-config-engine-71o.4) alongside style/look/charset/
1076
+ // colorCompatibility/autoWrap/padding — one collapsed home for every
1077
+ // bar-mutable display default, instead of its own always-on row.
849
1078
  themeControl: {
850
1079
  template:
851
1080
  "🎨 {{ .theme.effective }} " +
852
- '{{ menu "applyTheme" (dict "key" "pickers") }}',
1081
+ '{{ menu "applyTheme" (dict "key" "pickers") }} ' +
1082
+ '📌{{ menu "applyThemeForever" (dict "key" "pickersForever") }} ' +
1083
+ '{{ action "resetTheme" "↺" }}',
853
1084
  bg: "surface",
854
1085
  fg: "foreground",
855
1086
  },
@@ -862,7 +1093,73 @@ export const RAW_DEFAULT_DSL_CONFIG = {
862
1093
  lookControl: {
863
1094
  template:
864
1095
  "◐ {{ .look.effective }} " +
865
- '{{ menu "applyLook" (dict "key" "pickers" "closeOnPick" true) }}',
1096
+ '{{ menu "applyLook" (dict "key" "pickers" "closeOnPick" true) }} ' +
1097
+ '📌{{ menu "applyLookForever" (dict "key" "pickersForever" "closeOnPick" true) }} ' +
1098
+ '{{ action "resetLook" "↺" }}',
1099
+ bg: "surface",
1100
+ fg: "foreground",
1101
+ },
1102
+ // ── The four .3 globals steppers, folded into the settingsDrawer group
1103
+ // (candybar-config-engine-71o.4) alongside theme/style/look above. Each
1104
+ // pairs a `persist` control with a `↺` reset (docs' persist/reset
1105
+ // convention) — these four have no SessionState half at all, so persist
1106
+ // is their only seam, unlike theme/style/look's session `set`. Labels
1107
+ // read `.field.effective` (the daemon-resolved value BuildLineOptions
1108
+ // actually rendered with), never a restated literal.
1109
+ charsetControl: {
1110
+ template:
1111
+ "{{ .charset.effective }} " +
1112
+ '{{ menu "applyCharsetForever" }} {{ action "resetCharset" "↺" }}',
1113
+ bg: "surface",
1114
+ fg: "foreground",
1115
+ },
1116
+ colorCompatControl: {
1117
+ template:
1118
+ "{{ .colorCompatibility.effective }} " +
1119
+ '{{ menu "applyColorCompatForever" }} {{ action "resetColorCompat" "↺" }}',
1120
+ bg: "surface",
1121
+ fg: "foreground",
1122
+ },
1123
+ wrapToggleControl: {
1124
+ template:
1125
+ '{{ action "toggleWrapForever" "wrap: on" "wrap: off" }} ' +
1126
+ '{{ action "resetAutoWrap" "↺" }}',
1127
+ bg: "surface",
1128
+ fg: "foreground",
1129
+ },
1130
+ paddingControl: {
1131
+ template:
1132
+ '{{ action "paddingDownForever" "◀" }} padding {{ .padding.effective }} ' +
1133
+ '{{ action "paddingUpForever" "▶" }} {{ action "resetPadding" "↺" }}',
1134
+ bg: "surface",
1135
+ fg: "foreground",
1136
+ },
1137
+ // [LAW:verifiable-goals] candybar-config-engine-71o.6's own acceptance
1138
+ // bar, mirrored from .3/.5: at least ONE segment-scoped field must be
1139
+ // menu-able from the BUNDLED default with no hand-authored actions.
1140
+ // `directory` is the demo target — always visible, palette-driven
1141
+ // bg/fg, so an override is immediately legible. The persist/reset pair
1142
+ // below targets `segments.directory.palette` (not a Globals field),
1143
+ // proving the option-domain-as-data seam generalizes to segment-scoped
1144
+ // keys with zero engine edits beyond opening the key namespace itself
1145
+ // (loader/persist-target.ts) — the SAME `from: "themes"` domain
1146
+ // applyThemeForever already uses.
1147
+ // [LAW:one-source-of-truth] exception: unlike charsetControl/
1148
+ // paddingControl's `.field.effective` label, there is no
1149
+ // `segments.directory.palette.effective` payload projection — adding one
1150
+ // would require threading the full DslConfig through
1151
+ // buildRenderPayload's signature (today built from EffectiveGlobals
1152
+ // alone), a change with no other motivation than this one label. The
1153
+ // control still writes/persists/resets correctly without it: per
1154
+ // render/action.ts's CONFIG_KEY_TO_EFFECTIVE_VAR, a persist key with no
1155
+ // effective-var entry writes fine and only loses the picker's "current
1156
+ // selection" highlight — a documented, already-accepted degrade path,
1157
+ // not a bug.
1158
+ directoryPaletteControl: {
1159
+ template:
1160
+ "🎨 directory " +
1161
+ '{{ menu "applyDirectoryPaletteForever" }} ' +
1162
+ '{{ action "resetDirectoryPalette" "↺" }}',
866
1163
  bg: "surface",
867
1164
  fg: "foreground",
868
1165
  },
@@ -870,23 +1167,24 @@ export const RAW_DEFAULT_DSL_CONFIG = {
870
1167
 
871
1168
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
872
1169
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
873
- // the loader's authoring surface for user JSON, not this typed literal).
1170
+ // the loader's authoring surface for user JSON, not this typed literal — the
1171
+ // one exception being `settingsDrawer` above, whose `kind: "group"` sugar has
1172
+ // no canonical-form equivalent it could be hand-lowered to; see its own
1173
+ // comment).
874
1174
  //
875
- // Three rows stacked by the vertical container: an IDENTITY + ACTIONS row
876
- // (where am I / what can I do here — the directory, the verbose `gitaculous`
877
- // line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
878
- // quick-action tray: copy session id, open project / transcript in the editor)
879
- // over a STATUS row (what's happening now — model, context-window fill,
880
- // prompt-cache warmth, and the 5h / 7d rate-limit quotas) over an APPEARANCE
881
- // row (theme + look pickers, brandon-theming-8uj.1 the bundled default's
882
- // only on-bar affordance to discover and use the theme/look feature without
883
- // reading docs or hand-authoring a config). The tray sits on the identity
884
- // row because its actions are workspace-scoped (this session, this
885
- // project), not usage metrics; the pickers get their own row rather than
886
- // crowding the identity row because a `{{ menu }}` drops its picker body
887
- // onto the line immediately below its row, and that drop must not land on
888
- // top of an unrelated row's content. Each row zips its segments through the
889
- // powerline joiner; `\n` separates the rows.
1175
+ // Two always-visible rows stacked by the vertical container: an IDENTITY +
1176
+ // ACTIONS row (where am I / what can I do here — the directory, the verbose
1177
+ // `gitaculous` line, the quick-action tray: copy session id, open project /
1178
+ // transcript in the editor, and the settingsDrawer toggle) over a STATUS row
1179
+ // (what's happening now — model, context-window fill, prompt-cache warmth,
1180
+ // and the 5h / 7d rate-limit quotas). The settingsDrawer (candybar-config-
1181
+ // engine-71o.4) sits on the identity row beside the tray — collapsed by
1182
+ // default and visually silent (a single "⚙ settings ▸" cell) — and reveals a
1183
+ // third row of every bar-mutable display default (theme, style, look,
1184
+ // charset, colorCompatibility, autoWrap, padding) on the line immediately
1185
+ // below row 1 when opened, exactly where a `{{ menu }}`'s own picker body
1186
+ // would drop. Each row zips its segments through the powerline joiner; `\n`
1187
+ // separates the rows.
890
1188
  //
891
1189
  // [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
892
1190
  // own signal (no repo → the identity row is just the directory + tray; no
@@ -906,6 +1204,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
906
1204
  { kind: "segment", name: "directory" },
907
1205
  { kind: "segment", name: "gitaculous" },
908
1206
  { kind: "segment", name: "toolbar" },
1207
+ settingsDrawer,
909
1208
  ],
910
1209
  },
911
1210
  {
@@ -919,14 +1218,6 @@ export const RAW_DEFAULT_DSL_CONFIG = {
919
1218
  { kind: "segment", name: "weekly" },
920
1219
  ],
921
1220
  },
922
- {
923
- kind: "container",
924
- direction: "horizontal",
925
- children: [
926
- { kind: "segment", name: "themeControl" },
927
- { kind: "segment", name: "lookControl" },
928
- ],
929
- },
930
1221
  ],
931
1222
  },
932
1223
 
@@ -972,6 +1263,63 @@ export const RAW_DEFAULT_DSL_CONFIG = {
972
1263
  // block's names — the same derivation test/dsl-looks.test.ts exercises.
973
1264
  applyTheme: { set: "theme", from: "themes" },
974
1265
  applyLook: { set: "look", from: "looks" },
1266
+
1267
+ // [LAW:one-source-of-truth] The persist-forever twins of applyTheme/
1268
+ // applyStyle/applyLook above (candybar-config-engine-71o.5) — same
1269
+ // domain sources (`from`), same picker mechanism, but the target is the
1270
+ // Globals field the config DEFAULT reads (`palette`/`style`/`look`,
1271
+ // isGlobalsField-checked at load), not the SessionState key the session
1272
+ // preview writes. Precedence is unchanged: a session's own `set` pick
1273
+ // (applyTheme/applyStyle/applyLook) still wins over a persisted default
1274
+ // for that session — effectiveThemeName/effectiveStripStyle/
1275
+ // effectiveLookName all read SessionState before globals. Paired with a
1276
+ // `reset` each, per the docs' persist/reset convention.
1277
+ applyThemeForever: { persist: "palette", from: "themes" },
1278
+ resetTheme: { reset: "palette" },
1279
+ applyStyleForever: { persist: "style", from: "styles" },
1280
+ resetStyle: { reset: "style" },
1281
+ applyLookForever: { persist: "look", from: "looks" },
1282
+ resetLook: { reset: "look" },
1283
+
1284
+ // [LAW:locality-or-seam] The settings-drawer steppers' behaviors
1285
+ // (candybar-config-engine-71o.4), decoupled by NAME from
1286
+ // charsetControl/colorCompatControl/wrapToggleControl/paddingControl
1287
+ // below. Unlike theme/style/look (a per-session experiment via `set`),
1288
+ // these four have no SessionState half at all — .3's handoff established
1289
+ // `persist` as their ONLY seam — so every one of these writes the
1290
+ // config-file DEFAULT through the daemon-owned overrides layer (never the
1291
+ // hand-authored file itself), gated by the SAME deriveActionValidators
1292
+ // pass as a `set` (persist mirrors set's value-source shapes one for
1293
+ // one). Each is paired with a `reset` so a drawer choice is always
1294
+ // undoable from the bar, per the docs' persist/reset convention.
1295
+ applyCharsetForever: { persist: "charset", from: "charsets" },
1296
+ resetCharset: { reset: "charset" },
1297
+ applyColorCompatForever: {
1298
+ persist: "colorCompatibility",
1299
+ from: "colorCompatibilities",
1300
+ },
1301
+ resetColorCompat: { reset: "colorCompatibility" },
1302
+ toggleWrapForever: { persist: "autoWrap", cycle: ["true", "false"] },
1303
+ resetAutoWrap: { reset: "autoWrap" },
1304
+ paddingDownForever: { persist: "padding", min: 0, max: 16, by: -1 },
1305
+ paddingUpForever: { persist: "padding", min: 0, max: 16, by: 1 },
1306
+ resetPadding: { reset: "padding" },
1307
+
1308
+ // [LAW:locality-or-seam] The segment-palette control's behavior
1309
+ // (candybar-config-engine-71o.6), decoupled by NAME from
1310
+ // directoryPaletteControl below. The target key is `segments.directory.
1311
+ // palette` — NOT a Globals field — so it rides the SAME generic
1312
+ // `from`/`reset` machinery every other persist pair here uses, over a
1313
+ // key namespace loader/persist-target.ts opened alongside the pre-
1314
+ // existing Globals-field one. Like the four .3 steppers, this field has
1315
+ // no SessionState half at all: a per-segment `palette:` is a static pin
1316
+ // that ignores the session theme by design (src/dsl/render.ts), so
1317
+ // `persist` is its only seam.
1318
+ applyDirectoryPaletteForever: {
1319
+ persist: "segments.directory.palette",
1320
+ from: "themes",
1321
+ },
1322
+ resetDirectoryPalette: { reset: "segments.directory.palette" },
975
1323
  },
976
1324
 
977
1325
  // ─── Looks ───────────────────────────────────────────────────────────────
@@ -1028,6 +1376,27 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1028
1376
  },
1029
1377
  },
1030
1378
 
1379
+ // ─── Presets ─────────────────────────────────────────────────────────────
1380
+ // Named config FRAGMENTS — each an alternative `root` + display `globals`,
1381
+ // i.e. a whole arrangement of the bar rather than one knob. A preset is to
1382
+ // configuration what a look is to a theme, and rides the identical seam:
1383
+ // selected per session via the `preset` SessionState key (an action
1384
+ // `{ set: "preset", from: "presets" }` + a `{{ menu }}`), resolved as session
1385
+ // pick over globals.preset over this floor.
1386
+ // [LAW:one-source-of-truth] Merges by name (user wins per name), so this
1387
+ // stdlib — currently just the floor — is present in every merged config by
1388
+ // construction, exactly as looks' "none" is.
1389
+ presets: {
1390
+ // [LAW:dataflow-not-control-flow] "default" is just the identity fragment
1391
+ // — the resolution floor as a value, not a special case. An empty fragment
1392
+ // declares no `root` and no `globals`, so it stages the config's own, which
1393
+ // is precisely what "no preset chosen" means. A usable preset LIBRARY on
1394
+ // top of this floor is brandon-presets-0yk.3; the floor itself belongs here
1395
+ // because effectivePresetName's collapse target must exist in every merged
1396
+ // config, the same load-bearing reason "none" ships beside the real looks.
1397
+ default: {},
1398
+ },
1399
+
1031
1400
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
1032
1401
  // for the cost/token/budget family lives here as named template helpers, each
1033
1402
  // DEFINED ONCE and called from every segment via `{{ template "name" .arg }}`