@promptctl/cc-candybar 1.23.0 → 1.25.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.23.0",
3
+ "version": "1.25.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.23.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.23.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.23.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.23.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.25.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.25.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.25.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.25.0"
98
98
  }
99
99
  }
package/src/check.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  ConfigError,
26
26
  } from "./config/dsl-loader.js";
27
27
  import { expandHome } from "./config/loader/discovery.js";
28
+ import { DEFAULT_DSL_CONFIG } from "./config/default-dsl-config.js";
28
29
  import { VariableStore } from "./var-system/store.js";
29
30
  import { SourceRegistry } from "./var-system/sources.js";
30
31
  import { SessionState } from "./daemon/session-state.js";
@@ -250,7 +251,7 @@ function loadRegisterRender(
250
251
  cwd: string,
251
252
  warnings: string[],
252
253
  ): string {
253
- const { config: merged, source } = loadConfig(configPath);
254
+ const { config: merged, source } = loadConfig(configPath, DEFAULT_DSL_CONFIG);
254
255
  const config = validateConfig(merged, configPath ?? "<default>", source);
255
256
 
256
257
  const store = new VariableStore();
@@ -19,12 +19,16 @@
19
19
  // (add its name to the children), not new code. The same data flows through
20
20
  // the same render path whether the root has 1 leaf or 16.
21
21
  //
22
- // [LAW:types-are-the-program] `satisfies DslConfig` (not an annotation)
23
- // preserves the literal's narrow keys for downstream consumers every
24
- // declared segment name shows up as a known key, every variable name shows
25
- // up in the input set the daemon must populate.
22
+ // [LAW:types-are-the-program] The authored literal (RAW_DEFAULT_DSL_CONFIG)
23
+ // uses `satisfies DslConfig` (not an annotation) so every declared segment
24
+ // and variable name is checked against the real shape at the point of
25
+ // authoring. The exported DEFAULT_DSL_CONFIG is that literal run through the
26
+ // loader's own synthesis pass (see the bottom of this file) — a `DslConfig`,
27
+ // the same effective shape every user config resolves to.
26
28
 
27
- import type { DslConfig } from "./dsl-types.js";
29
+ import type { DslConfig, SegmentDecl } from "./dsl-types.js";
30
+ import { parseDslConfig } from "./dsl-loader.js";
31
+ import { mergeWithDefault } from "./loader/merge.js";
28
32
 
29
33
  // ─── Shared template fragments ───────────────────────────────────────────────
30
34
  //
@@ -144,12 +148,30 @@ function etaHeatFg(etaRef: string, warnRef: string): string {
144
148
 
145
149
  // ─── The default config ──────────────────────────────────────────────────────
146
150
 
147
- export const DEFAULT_DSL_CONFIG = {
151
+ // [LAW:one-source-of-truth] The AUTHORED literal, pre-synthesis. Production
152
+ // code wants the synthesized DEFAULT_DSL_CONFIG below; this is exported only
153
+ // for tests that round-trip "what a user would get by copy-pasting the
154
+ // bundled default into their own file" through the real per-file parse —
155
+ // that round-trip must start from the AUTHORED declarations, never from
156
+ // DEFAULT_DSL_CONFIG's own already-synthesized `menus.*` entries (reparsing
157
+ // those would trip the reserved-namespace guard, which exists to catch a
158
+ // user hand-declaring a name only synthesis may write).
159
+ export const RAW_DEFAULT_DSL_CONFIG = {
148
160
  globals: {
149
161
  // Picked by the daemon's basePalette resolution; user overrides in their
150
- // own config. catppuccin-latte ships every spec name the default
151
- // segments reference (surface, panel, surface-active, foreground).
152
- palette: "catppuccin-latte",
162
+ // own config. Every registry theme ships the same derived spec set
163
+ // (surface, panel, surface-active, foreground — see rich-js
164
+ // buildPalette), so this is a pure taste call, not a compatibility one.
165
+ // tokyo-night chosen (brandon-theming-8uj.2) over the prior
166
+ // catppuccin-latte — a light palette that landed as a drive-by in an
167
+ // unrelated formatting-cleanup commit and read poorly on the dark
168
+ // terminals most users run — after live-clicking every registry theme
169
+ // through the bundled themeControl picker: it stays legible as the
170
+ // per-row hue-step (see themeControl/lookControl below) shifts each
171
+ // row's hue, where warmer bases (gruvbox, dracula) drifted toward mud
172
+ // and the pastel ones (rose-pine, atom-one) washed out at this
173
+ // contrast.
174
+ palette: "tokyo-night",
153
175
  },
154
176
 
155
177
  // ─── Variables ─────────────────────────────────────────────────────────────
@@ -814,20 +836,56 @@ export const DEFAULT_DSL_CONFIG = {
814
836
  bg: "surface",
815
837
  fg: "foreground",
816
838
  },
839
+ // Theme control — the palette switcher, wired into the DEFAULT bar
840
+ // (brandon-theming-8uj.1) so theme selection is discoverable without
841
+ // reading docs/interaction-authoring.md or hand-authoring a config.
842
+ // [LAW:one-source-of-truth] The trigger reads `.theme.effective` — the
843
+ // SAME daemon-resolved name (effectiveThemeName) the rendered basePalette
844
+ // is built from — so the label and the colors can never drift; unlike
845
+ // styleControl (no "effective style" input exists), no extra `state`
846
+ // variable is needed here. Shares the "pickers" accordion key with
847
+ // lookControl so opening one closes the other, the docs' canonical
848
+ // two-menu pairing.
849
+ themeControl: {
850
+ template:
851
+ "🎨 {{ .theme.effective }} " +
852
+ '{{ menu "applyTheme" (dict "key" "pickers") }}',
853
+ bg: "surface",
854
+ fg: "foreground",
855
+ },
856
+ // Look control — the theme-ADAPTATION switcher (see the `looks` block
857
+ // below), the exact twin of themeControl one dimension over:
858
+ // `.look.effective` is the daemon-resolved name (effectiveLookName) the
859
+ // rendered ThemeKey composes from. closeOnPick collapses the drop after a
860
+ // pick — looks are tried one at a time against the chosen theme, not
861
+ // stacked open.
862
+ lookControl: {
863
+ template:
864
+ "◐ {{ .look.effective }} " +
865
+ '{{ menu "applyLook" (dict "key" "pickers" "closeOnPick" true) }}',
866
+ bg: "surface",
867
+ fg: "foreground",
868
+ },
817
869
  },
818
870
 
819
871
  // Default layout — the canonical LayoutNode tree (`satisfies DslConfig`
820
872
  // requires the lowered form here; the terse Option-A `{ h/v/seg }` grammar is
821
873
  // the loader's authoring surface for user JSON, not this typed literal).
822
874
  //
823
- // Two rows stacked by the vertical container: an IDENTITY + ACTIONS row
875
+ // Three rows stacked by the vertical container: an IDENTITY + ACTIONS row
824
876
  // (where am I / what can I do here — the directory, the verbose `gitaculous`
825
877
  // line (repo, sha, working-tree, upstream, stash, time-since-commit), then the
826
878
  // quick-action tray: copy session id, open project / transcript in the editor)
827
879
  // over a STATUS row (what's happening now — model, context-window fill,
828
- // prompt-cache warmth, and the 5h / 7d rate-limit quotas). The tray sits on
829
- // the identity row because its actions are workspace-scoped (this session,
830
- // this project), not usage metrics. Each row zips its segments through the
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
831
889
  // powerline joiner; `\n` separates the rows.
832
890
  //
833
891
  // [LAW:dataflow-not-control-flow] Every status segment is when-gated on its
@@ -861,6 +919,14 @@ export const DEFAULT_DSL_CONFIG = {
861
919
  { kind: "segment", name: "weekly" },
862
920
  ],
863
921
  },
922
+ {
923
+ kind: "container",
924
+ direction: "horizontal",
925
+ children: [
926
+ { kind: "segment", name: "themeControl" },
927
+ { kind: "segment", name: "lookControl" },
928
+ ],
929
+ },
864
930
  ],
865
931
  },
866
932
 
@@ -895,6 +961,17 @@ export const DEFAULT_DSL_CONFIG = {
895
961
  // rendered click and the wire gate share that one source — a template cannot
896
962
  // smuggle an un-gated style write.
897
963
  applyStyle: { set: "style", from: "styles" },
964
+
965
+ // [LAW:locality-or-seam] The theme/look pickers' behaviors, decoupled by
966
+ // NAME from themeControl/lookControl {{ menu }}s above — same seam as
967
+ // applyStyle. "theme" is the baseline permanent state key (validateTheme,
968
+ // registered in state-validators.ts); dropBaselineAllowLists reuses that
969
+ // gate for an allow-list contribution instead of re-registering it, so
970
+ // this action derives nothing new. "look" has no baseline entry, so this
971
+ // action derives a fresh allow-list validator ranging the merged `looks`
972
+ // block's names — the same derivation test/dsl-looks.test.ts exercises.
973
+ applyTheme: { set: "theme", from: "themes" },
974
+ applyLook: { set: "look", from: "looks" },
898
975
  },
899
976
 
900
977
  // ─── Looks ───────────────────────────────────────────────────────────────
@@ -1058,3 +1135,46 @@ export const DEFAULT_DSL_CONFIG = {
1058
1135
  "{{ else }}{{ . }}m{{ end }}",
1059
1136
  },
1060
1137
  } satisfies DslConfig;
1138
+
1139
+ // [LAW:locality-or-seam] The palette names this module-level parse is allowed
1140
+ // to accept — DERIVED from RAW_DEFAULT_DSL_CONFIG itself (globals.palette +
1141
+ // every per-segment palette: pin), never from the live theme registry
1142
+ // (listResolvablePaletteNames()). A real user file must validate against the
1143
+ // live registry (an author can type any name); this file validates against
1144
+ // ITSELF (every name here is a literal we wrote and every render test below
1145
+ // exercises against the real registry already). This is what keeps the
1146
+ // module-load parse below from ever depending on the registry being healthy
1147
+ // at import time — a registry-loading bug elsewhere would surface where it
1148
+ // actually matters (a real render failing), never as an uncatchable crash on
1149
+ // every importer of this file before any daemon/CLI error handling runs.
1150
+ const AUTHORED_PALETTE_NAMES = new Set(
1151
+ [
1152
+ RAW_DEFAULT_DSL_CONFIG.globals.palette,
1153
+ ...(Object.values(RAW_DEFAULT_DSL_CONFIG.segments) as SegmentDecl[]).map(
1154
+ (s) => s.palette,
1155
+ ),
1156
+ ].filter((name): name is string => name !== undefined),
1157
+ );
1158
+
1159
+ // [LAW:single-enforcer] Run the authored literal through the SAME
1160
+ // parse → synthesize pipeline every user config goes through (JSON5 stage +
1161
+ // synthesizeMenuDecls' `menus.*` synthesis, and any future group/menu
1162
+ // synthesis pass) instead of hand-duplicating that logic here. Without this,
1163
+ // the zero-config daemon path (loadConfig: no config file found ⇒ raw={},
1164
+ // merged directly against this constant — see src/config/dsl-loader.ts and
1165
+ // src/config/loader/merge.ts) would ship an UNSYNTHESIZED default: a
1166
+ // `{{ menu (dict "key" …) }}` accordion pairing (themeControl/lookControl,
1167
+ // brandon-theming-8uj.1) would render its glyph, but clicking it would reject
1168
+ // with "unknown state key" — the synthesis that derives a menu's `menus.*`
1169
+ // state var + cycle action only ever ran over TEXT a user typed, never over
1170
+ // this TS literal. Round-tripping through JSON is exactly what
1171
+ // test/default-dsl-config.test.ts's SERIALIZED-based tests already exercise,
1172
+ // so this is the same well-tested path, run once here instead of skipped.
1173
+ export const DEFAULT_DSL_CONFIG: DslConfig = mergeWithDefault(
1174
+ parseDslConfig(
1175
+ "<default>",
1176
+ JSON.stringify(RAW_DEFAULT_DSL_CONFIG),
1177
+ AUTHORED_PALETTE_NAMES,
1178
+ ),
1179
+ RAW_DEFAULT_DSL_CONFIG,
1180
+ );
@@ -30,7 +30,6 @@ import {
30
30
  type RawDslConfig,
31
31
  type ValidatedConfig,
32
32
  } from "./dsl-types.js";
33
- import { DEFAULT_DSL_CONFIG } from "./default-dsl-config.js";
34
33
  import { listResolvablePaletteNames } from "../themes/policy.js";
35
34
  import {
36
35
  ConfigError,
@@ -78,14 +77,23 @@ export {
78
77
  // ─── Three-stage pipeline ────────────────────────────────────────────────────
79
78
 
80
79
  /**
81
- * Load a JSON5 DSL config file from disk and merge it with the bundled
80
+ * Load a JSON5 DSL config file from disk and merge it with the given
82
81
  * default. Returns the effective DslConfig AND the raw source text.
83
82
  *
84
- * `path = null` means "no user file exists" — returns the default unchanged
85
- * (uniform merge against an empty raw, which is deep-equal to the default) and
83
+ * `path = null` means "no user file exists" — returns `dflt` unchanged
84
+ * (uniform merge against an empty raw, which is deep-equal to `dflt`) and
86
85
  * an empty source. No consumer branches on file presence; that branch lives
87
86
  * inside loadConfig exactly once.
88
87
  *
88
+ * [LAW:one-way-deps] `dflt` is a required parameter, not a default pointing at
89
+ * DEFAULT_DSL_CONFIG: this module is generic merge/parse machinery, and
90
+ * DEFAULT_DSL_CONFIG is a specific, higher-level instance built ON TOP of it
91
+ * (default-dsl-config.ts imports parseDslConfig/mergeWithDefault to
92
+ * synthesize itself — see that file). A default param here pointing back at
93
+ * DEFAULT_DSL_CONFIG would make this generic module depend on its own
94
+ * specific consumer — a cycle every caller who wants "the bundled default"
95
+ * resolves explicitly by importing DEFAULT_DSL_CONFIG themselves.
96
+ *
89
97
  * [LAW:one-source-of-truth] The source is returned alongside the config so the
90
98
  * caller can hand it to validateConfig — cross-ref diagnostics (line numbers,
91
99
  * the authored-surface discriminator) are derived from it, and the file is read
@@ -99,7 +107,7 @@ export {
99
107
  */
100
108
  export function loadConfig(
101
109
  path: string | null,
102
- dflt: DslConfig = DEFAULT_DSL_CONFIG,
110
+ dflt: DslConfig,
103
111
  allowedPalettes?: ReadonlySet<string>,
104
112
  ): { config: DslConfig; source: string } {
105
113
  const source = path === null ? "" : fs.readFileSync(path, "utf-8");
@@ -1,11 +1,19 @@
1
- // [LAW:one-source-of-truth] The single point that consults DEFAULT_DSL_CONFIG to
2
- // fill missing keys. A user file declares only what differs; the cascade here
3
- // (shallow-merge globals, by-name merge variables/segments/actions, wholesale
4
- // root replacement) is the one place "absent means inherit" is decided.
5
- // This file changes when the merge semantics change.
1
+ // [LAW:one-source-of-truth] The single point that merges a raw user config
2
+ // onto a default DslConfig to fill missing keys. A user file declares only
3
+ // what differs; the cascade here (shallow-merge globals, by-name merge
4
+ // variables/segments/actions, wholesale root replacement) is the one place
5
+ // "absent means inherit" is decided. This file changes when the merge
6
+ // semantics change.
7
+ //
8
+ // [LAW:one-way-deps] `dflt` is a required parameter — this module is generic
9
+ // merge machinery and does not know about DEFAULT_DSL_CONFIG, the specific
10
+ // bundled instance built ON TOP of it (default-dsl-config.ts imports this
11
+ // function to synthesize itself). A default param pointing back at
12
+ // DEFAULT_DSL_CONFIG would make this generic module depend on its own
13
+ // specific consumer, a cycle. Callers who want "the bundled default" import
14
+ // DEFAULT_DSL_CONFIG from default-dsl-config.ts and pass it explicitly.
6
15
 
7
16
  import { type DslConfig, type RawDslConfig } from "../dsl-types.js";
8
- import { DEFAULT_DSL_CONFIG } from "../default-dsl-config.js";
9
17
 
10
18
  /**
11
19
  * Merge a RawDslConfig on top of a default DslConfig. Pure function.
@@ -21,7 +29,7 @@ import { DEFAULT_DSL_CONFIG } from "../default-dsl-config.js";
21
29
  */
22
30
  export function mergeWithDefault(
23
31
  raw: RawDslConfig,
24
- dflt: DslConfig = DEFAULT_DSL_CONFIG,
32
+ dflt: DslConfig,
25
33
  ): DslConfig {
26
34
  return {
27
35
  globals: { ...dflt.globals, ...(raw.globals ?? {}) },
@@ -11,6 +11,7 @@ import {
11
11
  ConfigError,
12
12
  } from "../../config/dsl-loader.js";
13
13
  import type { ValidatedConfig } from "../../config/dsl-types.js";
14
+ import { DEFAULT_DSL_CONFIG } from "../../config/default-dsl-config.js";
14
15
  import { registerDslConfig, type CompiledConfig } from "../../dsl/render.js";
15
16
  import {
16
17
  deriveActionValidators,
@@ -272,7 +273,10 @@ export class RenderCache {
272
273
  // cross-ref diagnostics on the daemon path carry real line numbers and the
273
274
  // authored-surface (root vs layout) discriminator works — the file is read
274
275
  // once inside loadConfig, not re-read here.
275
- const { config: merged, source } = loadConfig(resolvedPath);
276
+ const { config: merged, source } = loadConfig(
277
+ resolvedPath,
278
+ DEFAULT_DSL_CONFIG,
279
+ );
276
280
  const config = validateConfig(merged, resolvedPath ?? "<default>", source);
277
281
 
278
282
  const store = new VariableStore();
package/src/demo/dsl.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  mergeWithDefault,
26
26
  validateConfig,
27
27
  } from "../config/dsl-loader.js";
28
+ import { DEFAULT_DSL_CONFIG } from "../config/default-dsl-config.js";
28
29
  import { VariableStore } from "../var-system/store.js";
29
30
  import { SourceRegistry } from "../var-system/sources.js";
30
31
  import { SessionState } from "../daemon/session-state.js";
@@ -60,7 +61,7 @@ const source = readFileSync(configPath, "utf-8");
60
61
  // only `ValidatedConfig`, so the chain is type-enforced.
61
62
  const ALLOWED = new Set(listResolvablePaletteNames());
62
63
  const raw = parseDslConfig(configPath, source, ALLOWED);
63
- const merged = mergeWithDefault(raw);
64
+ const merged = mergeWithDefault(raw, DEFAULT_DSL_CONFIG);
64
65
  const config = validateConfig(merged, configPath, source, ALLOWED);
65
66
 
66
67
  // One Claude Code status-line hook event, faked. The `input` vars in the