@promptctl/cc-candybar 1.37.0 → 1.39.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.
@@ -28,7 +28,14 @@
28
28
 
29
29
  import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
30
30
  import { parseDslConfig } from "./dsl-loader.js";
31
- import { BOOLEAN_MEMBERS, PADDING_RANGE } from "../themes/policy.js";
31
+ // [LAW:one-source-of-truth] The bundled drawer's menus author their own
32
+ // disclosure glyphs now that `{{ menu }}` appends none. Interpolating the
33
+ // shared constants keeps the stdlib bar reading like every other disclosure
34
+ // without restating the vocabulary in three string literals.
35
+ import {
36
+ DISCLOSURE_GLYPH_CLOSED,
37
+ DISCLOSURE_GLYPH_OPEN,
38
+ } from "./disclosure.js";
32
39
  import { mergeWithDefault } from "./loader/merge.js";
33
40
 
34
41
  // ─── Shared template fragments ───────────────────────────────────────────────
@@ -247,17 +254,22 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
247
254
  // hand-authored JSON5, so a malformed group is still caught loudly at import
248
255
  // time — the type safety net just moves from tsc to that parse, never lost.
249
256
  //
250
- // One collapsed-by-default drawer holding every bar-mutable display default:
251
- // theme/style/look/preset (session `set` for a per-conversation preview, PLUS
252
- // a persist-forever twin — candybar-config-engine-71o.5, brandon-presets-0yk.3
253
- // — for pinning the choice as everyone's default), the four .3 globals
254
- // steppers (persist-only HERE — `autoWrap`/`padding` have gained a session
255
- // half since, but the drawer's own controls stay durable-by-default until
256
- // candybar-settings-ui-aok.3 gives the panel one `persist?` toggle instead of
257
- // a spelling per row; `charset`/`colorCompatibility` have no session half at
258
- // all, by design), and one .6
259
- // segment-scoped persist control (directoryPaletteControl, persist-only like
260
- // the four steppers — a per-segment palette pin, not a whole-bar default).
257
+ // One collapsed-by-default drawer holding what the SESSION-scoped settings
258
+ // menu deliberately does not: `charset` and `colorCompatibility` — terminal
259
+ // capability facts (glyph coverage, colour depth) rather than tastes that
260
+ // vary session to session, so they have no session half to choose between —
261
+ // and one segment-scoped persist control (directoryPaletteControl, a
262
+ // per-segment palette pin rather than a whole-bar default).
263
+ //
264
+ // [LAW:one-source-of-truth] candybar-settings-ui-aok.3 moved every setting
265
+ // with BOTH halves — theme/style/look/preset/autoWrap/padding — out of this
266
+ // drawer and into the synthesized settings menu, where each is ONE control
267
+ // whose destination the `persist?` selector chooses. They used to be spelled
268
+ // twice here (`{{ menu "applyTheme" }}` beside `📌{{ menu
269
+ // "applyThemeForever" }}`), which is exactly the second representation that
270
+ // collapse removed. What is left in this drawer is durable-only by nature,
271
+ // not by omission — there is nothing for a persist? selector to choose.
272
+ //
261
273
  // Placed as a sibling in row 1's horizontal container, toggled from beside the
262
274
  // quick-action tray — see `root` below.
263
275
  //
@@ -267,26 +279,16 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
267
279
  // root too), so a second `settingsDrawer` reference embedded in a preset's
268
280
  // own root would be a SECOND declaration of "settings" and collide with
269
281
  // itself, not a reuse of the first. It stays only in the default `root`
270
- // below; the library presets under `presets:` reach back to it — and to the
271
- // rest of the settingsDrawer's controls — by switching to the "default"
272
- // preset via the standalone `presetControl` segment they carry instead (see
273
- // each preset's own comment), never by re-embedding the group.
282
+ // below; the library presets under `presets:` reach it by switching back to
283
+ // the "default" preset from the GLOBAL settings menu, which
284
+ // synthesizeSettingsMenu splices into every preset root
285
+ // (src/config/settings-menu.ts) — never by re-embedding this group.
274
286
  const settingsDrawer = {
275
287
  kind: "group",
276
288
  name: "settings",
277
- label: "⚙ settings",
289
+ label: "⚙ terminal",
278
290
  direction: "horizontal",
279
- children: [
280
- "themeControl",
281
- "lookControl",
282
- "presetControl",
283
- "styleControl",
284
- "charsetControl",
285
- "colorCompatControl",
286
- "wrapToggleControl",
287
- "paddingControl",
288
- "directoryPaletteControl",
289
- ],
291
+ children: ["charsetControl", "colorCompatControl", "directoryPaletteControl"],
290
292
  } as unknown as LayoutNode;
291
293
 
292
294
  // ─── The default config ──────────────────────────────────────────────────────
@@ -309,8 +311,8 @@ export const RAW_DEFAULT_DSL_CONFIG = {
309
311
  // catppuccin-latte — a light palette that landed as a drive-by in an
310
312
  // unrelated formatting-cleanup commit and read poorly on the dark
311
313
  // terminals most users run — after live-clicking every registry theme
312
- // through the bundled themeControl picker: it stays legible as the
313
- // per-row hue-step (see themeControl/lookControl below) shifts each
314
+ // through the settings menu's theme picker: it stays legible as the
315
+ // per-row hue-step shifts each
314
316
  // row's hue, where warmer bases (gruvbox, dracula) drifted toward mud
315
317
  // and the pastel ones (rose-pine, atom-one) washed out at this
316
318
  // contrast.
@@ -780,14 +782,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
780
782
  default: 0,
781
783
  },
782
784
 
783
- // ── Style picker state (the live powerline-shape switcher) ───────────────
784
- // [LAW:one-source-of-truth] `activeStyle` reads the SAME "style" SessionState
785
- // key the daemon resolves into the strip joiner per render (see
786
- // effectiveStripStyle wiring in src/daemon/server.ts) — the picker's write
787
- // and the render's read are one value. Empty default ⇒ the daemon's
788
- // "powerline" floor is in effect and styleControl shows "(default)".
789
- activeStyle: { kind: "state", key: "style", default: "" },
790
- // No page-cursor var: the styleControl {{ menu }} synthesizes its own page
785
+ // No page-cursor var: a {{ menu }} synthesizes its own page
791
786
  // cursor (state var + int action, named by menuPageKey) under the reserved
792
787
  // menus.* namespace, alongside its open-state.
793
788
  },
@@ -1133,132 +1128,29 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1133
1128
  " .metrics.sessionDuration .metrics.messageCount" +
1134
1129
  " .metrics.linesAdded .metrics.linesRemoved }}",
1135
1130
  },
1136
- // Style control — the powerline-shape switcher. A self-contained {{ menu }}
1137
- // disclosure: the ✦ glyph + current-style label is the REPRESENTATION, and the
1138
- // ▸/▾ disclosure (whose identity is DERIVED from this segment + the applyStyle
1139
- // action, see menu-keys.ts) toggles the picker body, which DROPS full-width
1140
- // onto the line below this row when open. [LAW:one-type-per-behavior] "a menu
1141
- // that opens and closes" is one behavior the substrate already expresses — no
1142
- // bespoke open-action + page-cursor-as-open-state + when-gated reveal row.
1143
- // The apply name is the whole declaration: the page cursor (state var + int
1144
- // gate) is synthesized from the menu's identity, and the defaults are the
1145
- // canonical path — paged (the 3 powerline shapes fit one page, so no arrows
1146
- // render) and stay-open, so shapes can be tried in a row; ▾/✕ collapse.
1147
- // [LAW:dataflow-not-control-flow] No display state from the provider — the
1148
- // label is the one "style" value the click writes and the render reads.
1149
- // Lives inside the settingsDrawer group (candybar-config-engine-71o.4) — not
1150
- // on `root` directly — so it renders only while the drawer is open.
1151
- // The 📌 "make default" menu + ↺ reset, one per control below, is
1152
- // styleControl/themeControl/lookControl's PERSIST twin (candybar-config-
1153
- // engine-71o.5) — the exact pairing charsetControl/colorCompatControl/
1154
- // wrapToggleControl/paddingControl already use, since theme/style/look
1155
- // are the only three of the drawer's seven knobs with a session `set`
1156
- // half at all. Its own "pickersForever" accordion key keeps the
1157
- // persist tier visually distinct from the existing "pickers" try tier
1158
- // (opening a session preview and opening a "pin as default" picker are
1159
- // different intents; auto-closing one when the other opens would
1160
- // conflate them) without touching the already-shipped/tested "pickers"
1161
- // accordion's membership.
1162
- styleControl: {
1163
- template:
1164
- "✦ {{ if .activeStyle }}{{ .activeStyle }}{{ else }}(default){{ end }} " +
1165
- '{{ menu "applyStyle" }} ' +
1166
- '📌{{ menu "applyStyleForever" (dict "key" "pickersForever") }} ' +
1167
- '{{ action "resetStyle" "↺" }}',
1168
- bg: "surface",
1169
- fg: "foreground",
1170
- },
1171
- // Theme control — the palette switcher, wired into the DEFAULT bar
1172
- // (brandon-theming-8uj.1) so theme selection is discoverable without
1173
- // reading docs/interaction-authoring.md or hand-authoring a config.
1174
- // [LAW:one-source-of-truth] The trigger reads `.theme.effective` — the
1175
- // SAME daemon-resolved name (effectiveThemeName) the rendered basePalette
1176
- // is built from — so the label and the colors can never drift; unlike
1177
- // styleControl (no "effective style" input exists), no extra `state`
1178
- // variable is needed here. Shares the "pickers" accordion key with
1179
- // lookControl so opening one closes the other, the docs' canonical
1180
- // two-menu pairing. Moved inside the settingsDrawer group
1181
- // (candybar-config-engine-71o.4) alongside style/look/charset/
1182
- // colorCompatibility/autoWrap/padding — one collapsed home for every
1183
- // bar-mutable display default, instead of its own always-on row.
1184
- themeControl: {
1185
- template:
1186
- "🎨 {{ .theme.effective }} " +
1187
- '{{ menu "applyTheme" (dict "key" "pickers") }} ' +
1188
- '📌{{ menu "applyThemeForever" (dict "key" "pickersForever") }} ' +
1189
- '{{ action "resetTheme" "↺" }}',
1190
- bg: "surface",
1191
- fg: "foreground",
1192
- },
1193
- // Look control — the theme-ADAPTATION switcher (see the `looks` block
1194
- // below), the exact twin of themeControl one dimension over:
1195
- // `.look.effective` is the daemon-resolved name (effectiveLookName) the
1196
- // rendered ThemeKey composes from. closeOnPick collapses the drop after a
1197
- // pick — looks are tried one at a time against the chosen theme, not
1198
- // stacked open.
1199
- lookControl: {
1200
- template:
1201
- "◐ {{ .look.effective }} " +
1202
- '{{ menu "applyLook" (dict "key" "pickers" "closeOnPick" true) }} ' +
1203
- '📌{{ menu "applyLookForever" (dict "key" "pickersForever" "closeOnPick" true) }} ' +
1204
- '{{ action "resetLook" "↺" }}',
1205
- bg: "surface",
1206
- fg: "foreground",
1207
- },
1208
- // Preset control — the whole-arrangement switcher (brandon-presets-0yk.3),
1209
- // the exact twin of themeControl/lookControl one level up: `.preset.
1210
- // effective` is the daemon-resolved name (effectivePresetName) that chose
1211
- // BOTH the compiled root this render walked and the globals it rendered
1212
- // with, so the label can never name an arrangement the bar is not in.
1213
- // closeOnPick on both menus, like lookControl: a preset swap is a
1214
- // decisive whole-bar change tried one at a time, not a stackable tweak.
1215
- // Lives inside settingsDrawer (never inside a preset's own staged root —
1216
- // see settingsDrawer's comment), so every bundled preset — including the
1217
- // narrow `compact` one — keeps the one control that switches back.
1218
- presetControl: {
1219
- template:
1220
- "▦ {{ .preset.effective }} " +
1221
- '{{ menu "applyPreset" (dict "key" "pickers" "closeOnPick" true) }} ' +
1222
- '📌{{ menu "applyPresetForever" (dict "key" "pickersForever" "closeOnPick" true) }} ' +
1223
- '{{ action "resetPreset" "↺" }}',
1224
- bg: "surface",
1225
- fg: "foreground",
1226
- },
1227
- // ── The four .3 globals steppers, folded into the settingsDrawer group
1228
- // (candybar-config-engine-71o.4) alongside theme/style/look above. Each
1229
- // pairs a `persist` control with a `↺` reset (docs' persist/reset
1230
- // convention) — `charset`/`colorCompatibility` have no SessionState half
1231
- // at all, so persist is genuinely their only seam, while
1232
- // `autoWrap`/`padding` do have one and are simply spelled durable here
1233
- // (candybar-settings-ui-aok.3 owns whether the panel offers the choice).
1234
- // Labels
1235
- // read `.field.effective` (the daemon-resolved value BuildLineOptions
1236
- // actually rendered with), never a restated literal.
1131
+ // ── The TWO globals steppers left in this drawer. `charset` and
1132
+ // `colorCompatibility` have no SessionState half at all — they describe
1133
+ // the terminal, not a taste that varies per session — so `persist` is
1134
+ // genuinely their only seam and there is no destination for a `persist?`
1135
+ // selector to choose between. Everything with both halves
1136
+ // (theme/style/look/preset/autoWrap/padding) moved to the synthesized
1137
+ // settings menu as ONE dual control each (candybar-settings-ui-aok.3).
1138
+ // Each pairs a `persist` control with a `↺` reset (docs' persist/reset
1139
+ // convention); labels read `.field.effective` (the daemon-resolved value
1140
+ // BuildLineOptions actually rendered with), never a restated literal.
1237
1141
  charsetControl: {
1238
1142
  template:
1239
1143
  "{{ .charset.effective }} " +
1240
- '{{ menu "applyCharsetForever" }} {{ action "resetCharset" "↺" }}',
1144
+ `{{ menu "applyCharsetForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1145
+ '{{ action "resetCharset" "↺" }}',
1241
1146
  bg: "surface",
1242
1147
  fg: "foreground",
1243
1148
  },
1244
1149
  colorCompatControl: {
1245
1150
  template:
1246
1151
  "{{ .colorCompatibility.effective }} " +
1247
- '{{ menu "applyColorCompatForever" }} {{ action "resetColorCompat" "↺" }}',
1248
- bg: "surface",
1249
- fg: "foreground",
1250
- },
1251
- wrapToggleControl: {
1252
- template:
1253
- '{{ action "toggleWrapForever" "wrap: on" "wrap: off" }} ' +
1254
- '{{ action "resetAutoWrap" "↺" }}',
1255
- bg: "surface",
1256
- fg: "foreground",
1257
- },
1258
- paddingControl: {
1259
- template:
1260
- '{{ action "paddingDownForever" "◀" }} padding {{ .padding.effective }} ' +
1261
- '{{ action "paddingUpForever" "▶" }} {{ action "resetPadding" "↺" }}',
1152
+ `{{ menu "applyColorCompatForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1153
+ '{{ action "resetColorCompat" "↺" }}',
1262
1154
  bg: "surface",
1263
1155
  fg: "foreground",
1264
1156
  },
@@ -1273,7 +1165,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1273
1165
  // (loader/persist-target.ts) — the SAME `from: "themes"` domain
1274
1166
  // applyThemeForever already uses.
1275
1167
  // [LAW:one-source-of-truth] exception: unlike charsetControl/
1276
- // paddingControl's `.field.effective` label, there is no
1168
+ // the other controls' `.field.effective` labels, there is no
1277
1169
  // `segments.directory.palette.effective` payload projection — adding one
1278
1170
  // would require threading the full DslConfig through
1279
1171
  // buildRenderPayload's signature (today built from EffectiveGlobals
@@ -1286,7 +1178,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1286
1178
  directoryPaletteControl: {
1287
1179
  template:
1288
1180
  "🎨 directory " +
1289
- '{{ menu "applyDirectoryPaletteForever" }} ' +
1181
+ `{{ menu "applyDirectoryPaletteForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
1290
1182
  '{{ action "resetDirectoryPalette" "↺" }}',
1291
1183
  bg: "surface",
1292
1184
  fg: "foreground",
@@ -1375,70 +1267,19 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1375
1267
  openProject: { open: "{{ .project_dir }}" },
1376
1268
  openTranscript: { open: "{{ .transcript_path }}" },
1377
1269
 
1378
- // [LAW:locality-or-seam] The style menu's behavior, decoupled by NAME from the
1379
- // styleControl {{ menu }} above. The disclosure's open-state toggle, its
1380
- // backing state var, AND the picker body's page cursor (state var + int
1381
- // action) are all SYNTHESIZED by the menu pass (under the reserved menus.*
1382
- // namespace) — no hand-authored open/close or page plumbing. This one action
1383
- // is the picker body's apply effect, gated by derivation
1384
- // (deriveActionValidators): it writes the chosen shape, gated to the
1385
- // STRIP_STYLES allow-list because its value source is `from: "styles"`. The
1386
- // rendered click and the wire gate share that one source — a template cannot
1387
- // smuggle an un-gated style write.
1388
- applyStyle: { set: "style", from: "styles" },
1389
-
1390
- // [LAW:locality-or-seam] The theme/look pickers' behaviors, decoupled by
1391
- // NAME from themeControl/lookControl {{ menu }}s above — same seam as
1392
- // applyStyle. "theme" is the baseline permanent state key (validateTheme,
1393
- // registered in state-validators.ts); dropBaselineAllowLists reuses that
1394
- // gate for an allow-list contribution instead of re-registering it, so
1395
- // this action derives nothing new. "look" has no baseline entry, so this
1396
- // action derives a fresh allow-list validator ranging the merged `looks`
1397
- // block's names — the same derivation test/dsl-looks.test.ts exercises.
1398
- applyTheme: { set: "theme", from: "themes" },
1399
- applyLook: { set: "look", from: "looks" },
1400
- // [LAW:one-type-per-behavior] The preset picker's session-preview
1401
- // behavior (brandon-presets-0yk.3), the exact twin of applyLook one level
1402
- // up: "preset" has no baseline SessionState entry, so this derives a
1403
- // fresh allow-list validator ranging the merged `presets` block's names
1404
- // (the floor included — presetNames always seeds it) via the same
1405
- // `from`-sourced derivation every picker above uses.
1406
- applyPreset: { set: "preset", from: "presets" },
1407
-
1408
- // [LAW:one-source-of-truth] The persist-forever twins of applyTheme/
1409
- // applyStyle/applyLook/applyPreset above (candybar-config-engine-71o.5,
1410
- // brandon-presets-0yk.3) — same domain sources (`from`), same picker
1411
- // mechanism, but the target is the Globals field the config DEFAULT
1412
- // reads (`palette`/`style`/`look`/`preset`, isGlobalsField-checked at
1413
- // load), not the SessionState key the session preview writes.
1414
- // Precedence is unchanged: a session's own `set` pick still wins over a
1415
- // persisted default for that session — effectiveThemeName/
1416
- // effectiveStripStyle/effectiveLookName/effectivePresetName all read
1417
- // SessionState before globals. Paired with a `reset` each, per the docs'
1418
- // persist/reset convention.
1419
- applyThemeForever: { persist: "palette", from: "themes" },
1420
- resetTheme: { reset: "palette" },
1421
- applyStyleForever: { persist: "style", from: "styles" },
1422
- resetStyle: { reset: "style" },
1423
- applyLookForever: { persist: "look", from: "looks" },
1424
- resetLook: { reset: "look" },
1425
- applyPresetForever: { persist: "preset", from: "presets" },
1426
- resetPreset: { reset: "preset" },
1427
-
1428
- // [LAW:locality-or-seam] The settings-drawer steppers' behaviors
1270
+ // [LAW:locality-or-seam] The settings-drawer controls' behaviors
1429
1271
  // (candybar-config-engine-71o.4), decoupled by NAME from
1430
- // charsetControl/colorCompatControl/wrapToggleControl/paddingControl
1431
- // below. Unlike theme/style/look (a per-session experiment via `set`),
1432
- // these four are spelled durable: `charset`/`colorCompatibility` have no
1433
- // SessionState half at all, and `autoWrap`/`padding` have one
1434
- // (candybar-settings-ui-aok.2) but keep persist as the DRAWER's spelling
1435
- // until .3 collapses the twins behind one `persist?`. So every one of
1436
- // these writes the
1437
- // config-file DEFAULT through the daemon-owned overrides layer (never the
1438
- // hand-authored file itself), gated by the SAME deriveActionValidators
1439
- // pass as a `set` (persist mirrors set's value-source shapes one for
1440
- // one). Each is paired with a `reset` so a drawer choice is always
1441
- // undoable from the bar, per the docs' persist/reset convention.
1272
+ // charsetControl/colorCompatControl below. These are durable-only by
1273
+ // NATURE, not by omission: `charset` and `colorCompatibility` describe the
1274
+ // terminal (glyph coverage, colour depth) rather than a taste that varies
1275
+ // between sessions, so they have no SessionState half for a `persist?`
1276
+ // selector to choose between — which is exactly why
1277
+ // candybar-settings-ui-aok.3 left them here while moving every
1278
+ // both-halves setting into the settings menu as one dual control. Each
1279
+ // writes the config DEFAULT through the daemon-owned overrides layer
1280
+ // (never the hand-authored file itself), gated by the SAME
1281
+ // deriveConfigActionValidators pass, and is paired with a `reset` so a
1282
+ // drawer choice is always undoable from the bar.
1442
1283
  applyCharsetForever: { persist: "charset", from: "charsets" },
1443
1284
  resetCharset: { reset: "charset" },
1444
1285
  applyColorCompatForever: {
@@ -1446,15 +1287,6 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1446
1287
  from: "colorCompatibilities",
1447
1288
  },
1448
1289
  resetColorCompat: { reset: "colorCompatibility" },
1449
- // [LAW:one-source-of-truth] The toggle's members and the stepper's bounds
1450
- // are the SAME literals the resolvers parse a session pick with
1451
- // (BOOLEAN_MEMBERS / PADDING_RANGE in themes/policy.ts) — a click cannot
1452
- // write a value the render's own resolution would then refuse.
1453
- toggleWrapForever: { persist: "autoWrap", cycle: [...BOOLEAN_MEMBERS] },
1454
- resetAutoWrap: { reset: "autoWrap" },
1455
- paddingDownForever: { persist: "padding", ...PADDING_RANGE, by: -1 },
1456
- paddingUpForever: { persist: "padding", ...PADDING_RANGE, by: 1 },
1457
- resetPadding: { reset: "padding" },
1458
1290
 
1459
1291
  // [LAW:locality-or-seam] The segment-palette control's behavior
1460
1292
  // (candybar-config-engine-71o.6), decoupled by NAME from
@@ -1462,7 +1294,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1462
1294
  // palette` — NOT a Globals field — so it rides the SAME generic
1463
1295
  // `from`/`reset` machinery every other persist pair here uses, over a
1464
1296
  // key namespace loader/persist-target.ts opened alongside the pre-
1465
- // existing Globals-field one. Like the four .3 steppers, this field has
1297
+ // existing Globals-field one. Like charset/colorCompatibility, this field has
1466
1298
  // no SessionState half at all: a per-segment `palette:` is a static pin
1467
1299
  // that ignores the session theme by design (src/dsl/render.ts), so
1468
1300
  // `persist` is its only seam.
@@ -1531,10 +1363,10 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1531
1363
  // Named config FRAGMENTS — each an alternative `root` + display `globals`,
1532
1364
  // i.e. a whole arrangement of the bar rather than one knob. A preset is to
1533
1365
  // configuration what a look is to a theme, and rides the identical seam:
1534
- // selected per session via the `preset` SessionState key (the applyPreset
1535
- // action, `{ set: "preset", from: "presets" }`, and the presetControl
1536
- // segment's `{{ menu }}` above), resolved as session pick over
1537
- // globals.preset over this floor.
1366
+ // selected per session via the `preset` SessionState key — or pinned as the
1367
+ // durable default via `globals.preset` — through the settings menu's ONE
1368
+ // dual preset control (src/config/settings-menu.ts), resolved as session
1369
+ // pick over globals.preset over this floor.
1538
1370
  // [LAW:one-source-of-truth] Merges by name (user wins per name), so this
1539
1371
  // stdlib is present in every merged config by construction, exactly as
1540
1372
  // looks' "none"/"vivid"/"muted"/… is — a user redefining "compact" or
@@ -1573,11 +1405,13 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1573
1405
  // `gitaculous`, and `padding: 0` to buy back the chrome a narrow column
1574
1406
  // can't spare.
1575
1407
  //
1576
- // [LAW:no-silent-failure] Carries the standalone `presetControl` segment
1577
- // (NOT the full settingsDrawer group — see that constant's own comment on
1578
- // why a group can't be re-embedded) so a session that switches TO compact
1579
- // is never stranded: one click back to "default" restores the drawer and
1580
- // everything else compact traded away for width.
1408
+ // [LAW:no-silent-failure] A session that switches TO compact is never
1409
+ // stranded: synthesizeSettingsMenu splices the global settings menu — and
1410
+ // with it the preset switcher — into EVERY preset root, so one click back
1411
+ // to "default" restores everything compact traded away for width. That is
1412
+ // why this root carries no preset control of its own: one guaranteed door
1413
+ // per root, minted once and referenced, not a segment each preset must
1414
+ // remember to carry [LAW:one-source-of-truth].
1581
1415
  compact: {
1582
1416
  root: {
1583
1417
  kind: "container",
@@ -1586,7 +1420,6 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1586
1420
  { kind: "segment", name: "directory" },
1587
1421
  { kind: "segment", name: "git" },
1588
1422
  { kind: "segment", name: "context" },
1589
- { kind: "segment", name: "presetControl" },
1590
1423
  ],
1591
1424
  },
1592
1425
  globals: { padding: 0 },
@@ -1599,10 +1432,9 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1599
1432
  // picture rather than the quiet default. A third row carries the two
1600
1433
  // per-turn throughput segments, which read "—" between turns
1601
1434
  // ([LAW:no-silent-failure] on speed/tokenSparkline) rather than an empty
1602
- // or stale row. Carries the standalone `presetControl` in place of the
1603
- // full settingsDrawer group, exactly like `compact` and for the same
1604
- // reason (that constant's comment) — switching back to "default" reaches
1605
- // the drawer and every other bar-mutable default from there.
1435
+ // or stale row. Like `compact`, it carries no preset control of its own —
1436
+ // the global settings menu is spliced into every preset root and is the
1437
+ // one door back.
1606
1438
  verbose: {
1607
1439
  root: {
1608
1440
  kind: "container",
@@ -1616,7 +1448,6 @@ export const RAW_DEFAULT_DSL_CONFIG = {
1616
1448
  { kind: "segment", name: "gitaculous" },
1617
1449
  { kind: "segment", name: "gitPr" },
1618
1450
  { kind: "segment", name: "toolbar" },
1619
- { kind: "segment", name: "presetControl" },
1620
1451
  ],
1621
1452
  },
1622
1453
  {
@@ -1779,7 +1610,7 @@ const AUTHORED_PALETTE_NAMES = new Set(
1779
1610
  // the zero-config daemon path (loadConfig: no config file found ⇒ raw={},
1780
1611
  // merged directly against this constant — see src/config/dsl-loader.ts and
1781
1612
  // src/config/loader/merge.ts) would ship an UNSYNTHESIZED default: a
1782
- // `{{ menu (dict "key" …) }}` accordion pairing (themeControl/lookControl,
1613
+ // `{{ menu (dict "key" …) }}` accordion pairing (the settings menu's pickers,
1783
1614
  // brandon-theming-8uj.1) would render its glyph, but clicking it would reject
1784
1615
  // with "unknown state key" — the synthesis that derives a menu's `menus.*`
1785
1616
  // state var + cycle action only ever ran over TEXT a user typed, never over
@@ -32,9 +32,63 @@ export const DISCLOSURE_CLOSED = "closed";
32
32
  // [LAW:representation] The disclosure glyph vocabulary — one pair for the whole
33
33
  // bar so every disclosure reads the same (trailing the label/content it gates,
34
34
  // per pdu.8): collapsed ▸, expanded ▾.
35
+ //
36
+ // [LAW:one-source-of-truth] These are the AUTHORED default, never an emission.
37
+ // Every disclosure splices them into the template it synthesizes — group sugar
38
+ // (loader/layout.ts), the settings menu (settings-menu.ts), the bundled drawer
39
+ // — and a hand-authored config writes whichever glyph it likes, because the
40
+ // trigger's text is a display bound at the call site like any other. Until
41
+ // candybar-settings-ui-aok.4 `{{ menu }}` was the exception, appending ▸/▾ from
42
+ // its own runtime where no author could see or decline it, which is how edit
43
+ // mode's `+` came to render `+▸`.
35
44
  export const DISCLOSURE_GLYPH_CLOSED = "▸";
36
45
  export const DISCLOSURE_GLYPH_OPEN = "▾";
37
46
 
47
+ // [LAW:one-source-of-truth] The glyph that CLOSES an open disclosure. The
48
+ // picker body's ✕ has always been this; it lives here now because a trigger can
49
+ // wear it too — edit mode's `+` does, since a `+` whose only open-state cue was
50
+ // the ▸ this change removed would otherwise be indistinguishable from its
51
+ // siblings (three insertion points render byte-identically when one is open,
52
+ // and their dropped bodies are identical too, so row 0 is the only place the
53
+ // answer can live). Two affordances, one meaning, one glyph.
54
+ export const DISCLOSURE_GLYPH_CLOSE = "✕";
55
+
56
+ // [LAW:single-enforcer] THE display rule every multi-state trigger obeys: bind
57
+ // one display per member, or ONE static display that shows in every state. It
58
+ // lives here, beside the toggle machinery, because both disclosure kinds need
59
+ // it at different times — the loader can count a call's arguments statically
60
+ // and wants an ISSUE to report, the renderer holds the evaluated displays and
61
+ // wants to THROW — and a rule spelled once in each place is a rule that drifts.
62
+ // A `{{ menu }}` folds through it with two members (its `[closed, member]`
63
+ // cycle) and a cycle `{{ action }}` with as many as it declares; nothing about
64
+ // the rule is disclosure-specific beyond who calls it.
65
+ export function cycleDisplayIssue(
66
+ subject: string,
67
+ count: number,
68
+ members: number,
69
+ ): string | undefined {
70
+ if (count === 0) return `${subject} needs a display (the clickable text)`;
71
+ if (count !== 1 && count !== members) {
72
+ return `${subject} cycles ${members} members; bind one display per member (${members}) or one static display, got ${count}`;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ // [LAW:dataflow-not-control-flow] Which display shows is a pure function of
78
+ // (bound displays, current member index): a single static display shows in
79
+ // every state, per-member displays index by the state. Throws the one rule's
80
+ // text rather than silently dropping or repeating an argument.
81
+ export function pickCycleDisplay(
82
+ subject: string,
83
+ displays: readonly string[],
84
+ members: number,
85
+ index: number,
86
+ ): string {
87
+ const issue = cycleDisplayIssue(subject, displays.length, members);
88
+ if (issue !== undefined) throw new Error(issue);
89
+ return displays.length === 1 ? displays[0]! : displays[index]!;
90
+ }
91
+
38
92
  // [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
39
93
  // it holds the open member's name and defaults to `def` (the CLOSED sentinel for
40
94
  // an independent disclosure, or an initially-open member for a group's
@@ -50,6 +50,7 @@ import {
50
50
  } from "./menu-keys.js";
51
51
  import {
52
52
  DISCLOSURE_CLOSED,
53
+ DISCLOSURE_GLYPH_CLOSE,
53
54
  disclosureCycleAction,
54
55
  disclosureStateVar,
55
56
  } from "./disclosure.js";
@@ -211,8 +212,21 @@ function insertChrome(
211
212
  artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
212
213
  artifacts.actions[pageKey] = { set: pageKey, int: true };
213
214
 
215
+ // The `+` IS the trigger — no appended arrow (candybar-settings-ui-aok.4).
216
+ // Beside a `-` that means something else entirely, a ▸ read as part of the
217
+ // affordance rather than as a disclosure hint, so the trigger names the ACTION
218
+ // its click performs instead: `+` inserts here, `✕` closes what `+` opened —
219
+ // the same glyph, and the same effect, as the body's own close cell.
220
+ //
221
+ // [LAW:no-silent-failure] It is deliberately NOT one static display. A preset
222
+ // has N insertion points whose rendered rows are byte-identical, and their
223
+ // dropped bodies are identical too — so with no per-state display, an open `+`
224
+ // is indistinguishable from the two beside it and the bar silently stops
225
+ // answering "which one did I open". The tint that marks other open menus
226
+ // (node-registry's `drops.length > 0`) cannot answer it either: this segment
227
+ // declares no bg, so there is nothing to tint.
214
228
  artifacts.segments[chromeSegName] = {
215
- template: `+{{ menu "${applyName}" }}`,
229
+ template: `{{ menu "${applyName}" "+" "${DISCLOSURE_GLYPH_CLOSE}" }}`,
216
230
  when: EDIT_MODE_GATE,
217
231
  };
218
232
  return { kind: "segment", name: chromeSegName };