@promptctl/cc-candybar 1.35.0 → 1.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,9 +91,9 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.35.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.35.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.35.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.35.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.36.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.36.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.36.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.36.0"
98
98
  }
99
99
  }
@@ -50,6 +50,8 @@ import { synthesizeGroupDecls, validateRoot } from "./loader/layout.js";
50
50
  import { synthesizeMenuDecls } from "./loader/menu-synth.js";
51
51
  import { synthesizeEditModeToggle } from "./loader/edit-mode.js";
52
52
  import { synthesizeEditChrome } from "./edit-chrome.js";
53
+ import { SETTINGS_NS, synthesizeSettingsMenu } from "./settings-menu.js";
54
+ import { reservedNamespaceCollisions } from "./loader/reserved-namespace.js";
53
55
  import { validateActions } from "./loader/actions.js";
54
56
  import { validateLooks } from "./loader/looks.js";
55
57
  import { validatePresets } from "./loader/presets.js";
@@ -152,7 +154,12 @@ export function validateConfig(
152
154
  // into freshly-synthesized segments, actions into freshly-synthesized
153
155
  // actions) is correct by construction and does not re-enter cross-ref/
154
156
  // cycle checking, exactly as group/menu synthesis's output doesn't either.
155
- const withChrome = synthesizeEditChrome(config);
157
+ // [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.1's global settings
158
+ // menu, spliced BEFORE edit chrome so edit chrome walks the final content tree
159
+ // and treats the menu's reserved `settings.` names as chrome-exempt — the
160
+ // full ordering argument lives in settings-menu.ts's header, beside the pass
161
+ // it governs.
162
+ const withChrome = synthesizeEditChrome(synthesizeSettingsMenu(config));
156
163
  return withChrome as ValidatedConfig;
157
164
  }
158
165
 
@@ -295,6 +302,18 @@ function validateTopLevel(
295
302
  // affordances) runs later, in validateConfig, once the merged/preset-
296
303
  // resolved/rootOps-replayed tree exists to derive it from.
297
304
  synthesizeEditModeToggle(ctx, out);
305
+ // [LAW:one-source-of-truth] The global settings menu reserves its namespace
306
+ // here and synthesizes NOTHING here: the tree it must be present in only
307
+ // exists after merge (a user `root` replaces the default's), so the artifacts
308
+ // are minted in validateConfig. The reservation is unconditional all the same,
309
+ // mirroring every other namespace above — "you never author settings.*" is a
310
+ // stable contract, not a rule that switches on when the pass happens to fire.
311
+ reservedNamespaceCollisions(
312
+ ctx,
313
+ out,
314
+ SETTINGS_NS,
315
+ "the global settings menu",
316
+ );
298
317
  return out;
299
318
  }
300
319
 
@@ -40,6 +40,7 @@ import {
40
40
  EDIT_TOGGLE_ACTION,
41
41
  } from "./loader/edit-mode.js";
42
42
  import { GROUP_NS } from "./loader/layout.js";
43
+ import { SETTINGS_NS } from "./settings-menu.js";
43
44
  import {
44
45
  menuActionName,
45
46
  menuMember,
@@ -61,7 +62,11 @@ import {
61
62
  // knowledge, so the banner below is spliced UNCONDITIONALLY for every
62
63
  // preset — same shape, every reload — and this predicate is what decides
63
64
  // whether it's visible, never a branch in this synthesis pass.
64
- const PRESET_CUSTOMIZED_GATE = "{{ .preset.customized }}";
65
+ // [LAW:one-source-of-truth] Exported: test/helpers/ambient-chrome.ts filters this
66
+ // ensured name out of "what did the AUTHOR declare" assertions and must read the
67
+ // same string, never a second copy that a rename here would leave behind.
68
+ export const PRESET_CUSTOMIZED_VAR = "preset.customized";
69
+ const PRESET_CUSTOMIZED_GATE = `{{ .${PRESET_CUSTOMIZED_VAR} }}`;
65
70
 
66
71
  // [LAW:one-source-of-truth] group/menu-synthesized segments (`groups.`/
67
72
  // `menus.`) and edit mode's own trigger/chrome (`edit.`) are structural —
@@ -73,7 +78,12 @@ function isChromeExempt(name: string): boolean {
73
78
  return (
74
79
  name.startsWith(EDIT_NS) ||
75
80
  name.startsWith(MENU_NS) ||
76
- name.startsWith(GROUP_NS)
81
+ name.startsWith(GROUP_NS) ||
82
+ // The global settings menu is the entry point edit mode is REACHED from
83
+ // (candybar-settings-ui-aok.1) — offering a `-` beside it would let one
84
+ // click delete the door back in, the self-lockout the `toolbar` trigger's
85
+ // placement was chosen to avoid. Structural, like the three above it.
86
+ name.startsWith(SETTINGS_NS)
77
87
  );
78
88
  }
79
89
 
@@ -93,6 +103,11 @@ function escapeTemplateLiteral(s: string): string {
93
103
  // so cross-preset names (disambiguated by `presetIdent`) can never collide.
94
104
  interface ChromeArtifacts {
95
105
  readonly variables: Record<string, VariableDecl>;
106
+ // [LAW:no-silent-failure] Declarations this synthesis DEPENDS on rather than
107
+ // OWNS: merged UNDER the config so a user's own declaration of the same name
108
+ // wins, unlike `variables` above, which lives in a reserved namespace no user
109
+ // may write and therefore merges over.
110
+ readonly ensured: Record<string, VariableDecl>;
96
111
  readonly actions: Record<string, ActionDeclType>;
97
112
  readonly segments: Record<string, SegmentDecl>;
98
113
  }
@@ -219,7 +234,18 @@ function spliceContainer(
219
234
  posCounter: { n: number },
220
235
  ): ContainerNode {
221
236
  const children: LayoutNode[] = [];
222
- for (const child of node.children) {
237
+ // [LAW:one-source-of-truth] The trailing `+`'s position is "after the last
238
+ // CONTENT segment", not "after the last child". Those coincided until a
239
+ // synthesis started appending exempt chrome (the global settings menu,
240
+ // candybar-settings-ui-aok.1) to a row's end, at which point reading the last
241
+ // child silently dropped the row's final insert point — N segments offering
242
+ // only N insert points instead of N+1.
243
+ const lastContent = node.children.reduce(
244
+ (idx, child, i) =>
245
+ child.kind === "segment" && !isChromeExempt(child.name) ? i : idx,
246
+ -1,
247
+ );
248
+ for (const [i, child] of node.children.entries()) {
223
249
  if (child.kind === "container") {
224
250
  children.push(
225
251
  spliceContainer(
@@ -250,24 +276,19 @@ function spliceContainer(
250
276
  );
251
277
  children.push(child);
252
278
  children.push(removeChrome(presetIdent, rootOpsKey, child.name, artifacts));
253
- }
254
- const last = node.children[node.children.length - 1];
255
- if (
256
- last !== undefined &&
257
- last.kind === "segment" &&
258
- !isChromeExempt(last.name)
259
- ) {
260
- children.push(
261
- insertChrome(
262
- presetIdent,
263
- rootOpsKey,
264
- String(posCounter.n++),
265
- domainName,
266
- last.name,
267
- "after",
268
- artifacts,
269
- ),
270
- );
279
+ if (i === lastContent) {
280
+ children.push(
281
+ insertChrome(
282
+ presetIdent,
283
+ rootOpsKey,
284
+ String(posCounter.n++),
285
+ domainName,
286
+ child.name,
287
+ "after",
288
+ artifacts,
289
+ ),
290
+ );
291
+ }
271
292
  }
272
293
  return { ...node, children };
273
294
  }
@@ -301,6 +322,20 @@ function prependCustomizedBanner(
301
322
  // edited down to zero non-exempt segments (no removeChrome/insertChrome
302
323
  // persist actions left to register it) doesn't orphan this exact click.
303
324
  artifacts.actions[actionName] = { reset: rootOpsKey };
325
+ // [LAW:one-source-of-truth] The banner reads `.preset.customized`, so THIS
326
+ // pass is what requires that variable — not whichever config happens to
327
+ // declare it. The bundled default does, which is why the dependency stayed
328
+ // invisible until the global settings menu made edit mode reachable from
329
+ // configs that never declared it, and the missing field surfaced as a ⚠ on
330
+ // the bar. Ensured, never overridden: a user declaration of the same name
331
+ // wins (see the merge in synthesizeEditChrome), so this only supplies the
332
+ // floor the synthesis itself depends on.
333
+ artifacts.ensured[PRESET_CUSTOMIZED_VAR] = {
334
+ kind: "input",
335
+ path: PRESET_CUSTOMIZED_VAR,
336
+ type: "boolean",
337
+ default: false,
338
+ };
304
339
  const label = escapeTemplateLiteral(presetName);
305
340
  artifacts.segments[chromeSegName] = {
306
341
  template: `{{ action "${actionName}" "↺ ${label} customized" }}`,
@@ -391,6 +426,7 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
391
426
  if (!(EDIT_TOGGLE_ACTION in config.actions)) return config;
392
427
  const artifacts: ChromeArtifacts = {
393
428
  variables: {},
429
+ ensured: {},
394
430
  actions: {},
395
431
  segments: {},
396
432
  };
@@ -404,7 +440,11 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
404
440
  }
405
441
  return {
406
442
  ...config,
407
- variables: { ...config.variables, ...artifacts.variables },
443
+ variables: {
444
+ ...artifacts.ensured,
445
+ ...config.variables,
446
+ ...artifacts.variables,
447
+ },
408
448
  actions: { ...config.actions, ...artifacts.actions },
409
449
  segments: { ...config.segments, ...artifacts.segments },
410
450
  presets,
@@ -29,6 +29,13 @@ import {
29
29
  import { listGlobalsFieldNames } from "./globals.js";
30
30
  import { parsePersistTarget } from "./persist-target.js";
31
31
  import { presetNames } from "../presets.js";
32
+ import {
33
+ canHostSessionState,
34
+ countAnchors,
35
+ isSettingsAnchor,
36
+ SESSION_ID_VAR,
37
+ SETTINGS_ANCHOR,
38
+ } from "../settings-menu.js";
32
39
  import { ident } from "../ident.js";
33
40
  import { findKeyLine } from "./diagnostics.js";
34
41
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
@@ -248,11 +255,30 @@ export function validateCrossReferences(
248
255
  // never a second traversal that could learn a different idea of what a valid
249
256
  // layout is. This is what makes `cc-candybar check` catch a preset staging a
250
257
  // segment nobody declared.
258
+ //
259
+ // [LAW:single-enforcer] The menu precondition is asked once, of the same
260
+ // predicate synthesizeSettingsMenu gates on, and read by every tree walked.
261
+ const menuWillSynthesize = canHostSessionState(cfg);
251
262
  const checkLayoutTree = (
252
263
  root: LayoutNode,
253
264
  layoutKey: string,
254
265
  layoutLine: number | undefined,
255
266
  ): void => {
267
+ // [LAW:one-source-of-truth] The global settings menu's anchor is a POSITION
268
+ // an author may place and this walk must therefore accept, even though no
269
+ // config declares a segment by that name — synthesizeSettingsMenu provides
270
+ // it unconditionally, immediately after these checks pass. Two placements is
271
+ // the real error: one state key holds one open state, so a second anchor
272
+ // would be a second toggle writing one disclosure, with two bodies claiming
273
+ // to be it. Counted over the SAME census the synthesis reads, so "placed"
274
+ // means one thing [LAW:single-enforcer].
275
+ if (countAnchors(root) > 1) {
276
+ ctx.issues.push({
277
+ path: layoutKey,
278
+ message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}" ${countAnchors(root)} times — it may appear at most once per layout (it is one disclosure, and one state key holds one open state). Remove all but the placement you want; removing every placement puts the menu at its default position.`,
279
+ line: layoutLine,
280
+ });
281
+ }
256
282
  for (const node of walkNodes(root)) {
257
283
  // [LAW:locality-or-seam] A node's `when` reads the global scope (bare
258
284
  // globals + namespaced segment vars) — the same existence-check shape as a
@@ -263,6 +289,24 @@ export function validateCrossReferences(
263
289
  });
264
290
  }
265
291
  if (node.kind !== "segment") continue;
292
+ if (isSettingsAnchor(node.name)) {
293
+ // [LAW:one-source-of-truth] Accepting the anchor asserts that
294
+ // synthesizeSettingsMenu WILL declare a segment by this name, so the
295
+ // acceptance reads the very predicate that pass decides by rather than
296
+ // assuming its answer. When it is false the reference is genuinely
297
+ // dangling, and the error names the unmet precondition: the author
298
+ // placed a documented anchor, they did not typo a segment name, and the
299
+ // generic "does not match any declared segment" would teach the wrong
300
+ // lesson [LAW:no-silent-failure].
301
+ if (!menuWillSynthesize) {
302
+ ctx.issues.push({
303
+ path: layoutKey,
304
+ message: `${layoutKey} places the global settings menu anchor "${SETTINGS_ANCHOR}", but this config declares no "${SESSION_ID_VAR}" variable — the menu is a click surface and every click composes a URL from "${SESSION_ID_VAR}", so it is not synthesized for a config without it. Declare a "${SESSION_ID_VAR}" variable (any config merged onto the bundled default inherits one) or remove the anchor placement.`,
305
+ line: layoutLine,
306
+ });
307
+ }
308
+ continue;
309
+ }
266
310
  if (!Object.prototype.hasOwnProperty.call(cfg.segments, node.name)) {
267
311
  const renamed = RENAMED_SEGMENTS[node.name];
268
312
  const hint =
@@ -29,6 +29,20 @@
29
29
  // of this pass caused. The reserved namespace stays reserved unconditionally
30
30
  // (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
31
31
  // conditional.
32
+ //
33
+ // WHAT candybar-settings-ui-aok.1 CHANGED, and what it did not: the gate above
34
+ // is intact and still the only way edit mode is reached — but it now has a
35
+ // PERMANENT DEMANDER. synthesizeSettingsMenu mints a `settings.edit` segment
36
+ // referencing `edit.toggle` into every config it can host, so in practice the
37
+ // demand is satisfied for essentially every config a user writes, and reading
38
+ // this section as "most bars carry no edit mode" is no longer true. The
39
+ // separation the gate protects still holds exactly where it always mattered:
40
+ // the menu declines to synthesize for a config with no `session.id`
41
+ // (canHostSessionState in settings-menu.ts), which is precisely the static,
42
+ // non-interactive bar this comment was written to keep clean. Production blast
43
+ // radius was nil either way — the bundled default's `toolbar` segment already
44
+ // referenced `edit.toggle`, so every config merging it already demanded edit
45
+ // mode before the menu existed.
32
46
 
33
47
  import { createEngine } from "@promptctl/go-template-js";
34
48
  import type { Mutable, ValidateCtx } from "./validate-core.js";