@promptctl/cc-candybar 1.36.0 → 1.38.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/dist/index.mjs +96 -93
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +124 -0
- package/src/check.ts +4 -4
- package/src/click/wire.ts +10 -1
- package/src/config/action.ts +110 -1
- package/src/config/default-dsl-config.ts +64 -228
- package/src/config/loader/actions.ts +138 -61
- package/src/config/loader/cross-ref.ts +48 -10
- package/src/config/loader/globals.ts +46 -1
- package/src/config/presets.ts +8 -3
- package/src/config/settings-menu.ts +338 -16
- package/src/daemon/render-payload.ts +7 -5
- package/src/daemon/server.ts +20 -7
- package/src/daemon/verbs/config-validators.ts +14 -11
- package/src/daemon/verbs/index.ts +39 -3
- package/src/daemon/verbs/state-validators.ts +26 -8
- package/src/demo/dsl.ts +7 -6
- package/src/help-text.ts +6 -3
- package/src/install/index.ts +2 -2
- package/src/render/action.ts +199 -45
- package/src/render/picker.ts +34 -32
- package/src/render/strip.ts +6 -10
- package/src/themes/index.ts +2 -0
- package/src/themes/policy.ts +199 -40
|
@@ -63,6 +63,13 @@ import {
|
|
|
63
63
|
menuStateKey,
|
|
64
64
|
} from "./menu-keys.js";
|
|
65
65
|
import { presetByName, presetNames, presetRoot } from "./presets.js";
|
|
66
|
+
import type { OptionDomain } from "./option-domain.js";
|
|
67
|
+
import {
|
|
68
|
+
BOOLEAN_FALSE,
|
|
69
|
+
BOOLEAN_MEMBERS,
|
|
70
|
+
BOOLEAN_TRUE,
|
|
71
|
+
PADDING_RANGE,
|
|
72
|
+
} from "../themes/policy.js";
|
|
66
73
|
|
|
67
74
|
// [LAW:one-source-of-truth] The reserved namespace every artifact this pass
|
|
68
75
|
// mints lives under, mirroring `groups.`/`menus.`/`edit.`. Reserved at parse
|
|
@@ -83,13 +90,189 @@ export const SETTINGS_ANCHOR = `${SETTINGS_NS}menu`;
|
|
|
83
90
|
// toggle — a binary disclosure holds the CLOSED sentinel or this.
|
|
84
91
|
const SETTINGS_OPEN = EDIT_MODE_OPEN;
|
|
85
92
|
|
|
86
|
-
// The body's
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
// which is the child that owns them.
|
|
90
|
-
const PRESETS_SEG = `${SETTINGS_NS}presets`;
|
|
93
|
+
// The body's content segments. `.1` scoped the body to what its acceptance
|
|
94
|
+
// names — switch presets, enter edit mode; `.3` adds the persist? selector
|
|
95
|
+
// beside them and the config menu below them.
|
|
91
96
|
const EDIT_SEG = `${SETTINGS_NS}edit`;
|
|
92
|
-
|
|
97
|
+
|
|
98
|
+
// ─── The config menu (candybar-settings-ui-aok.3) ───────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// [LAW:one-source-of-truth] ONE control per setting. The drawer used to spell
|
|
101
|
+
// each of theme/style/look/preset TWICE — `{{ menu "applyTheme" }}` for the
|
|
102
|
+
// session beside `📌{{ menu "applyThemeForever" }}` for the durable default —
|
|
103
|
+
// two controls a reader had to reconcile at every glance, and two declarations
|
|
104
|
+
// an author had to keep in agreement. Here each setting is one control bound
|
|
105
|
+
// to one DUAL action, and the `persist?` selector beside them chooses which
|
|
106
|
+
// store every one of those controls writes [LAW:dataflow-not-control-flow].
|
|
107
|
+
//
|
|
108
|
+
// [LAW:no-mode-explosion] persist? is not a mode: it is a value in
|
|
109
|
+
// SessionState that the compiled action reads at click time. Nothing branches
|
|
110
|
+
// on it — not the synthesis (which mints the same tree either way), not the
|
|
111
|
+
// render walk, and not the daemon's writers, which are the same two writers
|
|
112
|
+
// they were before this menu existed.
|
|
113
|
+
//
|
|
114
|
+
// The selector sits in the menu's FIRST row, above and beside every control it
|
|
115
|
+
// governs, so it never stands over a row it cannot affect: every setting under
|
|
116
|
+
// it — preset here, theme/look/style/wrap/padding in the config row — is dual.
|
|
117
|
+
// `charset` and `colorCompatibility` are deliberately absent: they describe the
|
|
118
|
+
// TERMINAL (glyph coverage, colour depth), not a taste that varies between
|
|
119
|
+
// sessions, so they have no session half to choose and stay config-file
|
|
120
|
+
// settings (see CHARSETS in themes/policy.ts).
|
|
121
|
+
const PERSIST_SEG = `${SETTINGS_NS}persist`;
|
|
122
|
+
const CONFIG_SEG = `${SETTINGS_NS}config`;
|
|
123
|
+
|
|
124
|
+
// The selector's own state key, session-scoped and unchecked by default: you
|
|
125
|
+
// arrive in experimentation mode, and committing a value to every future
|
|
126
|
+
// session is a deliberate act. It also means a checkbox left armed yesterday
|
|
127
|
+
// cannot silently write a durable default today — SessionState is per session.
|
|
128
|
+
const PERSIST_KEY = PERSIST_SEG;
|
|
129
|
+
|
|
130
|
+
// [LAW:one-source-of-truth] The config menu's own disclosure, spelled the same
|
|
131
|
+
// way the anchor above is: ONE name that is the segment, the state variable and
|
|
132
|
+
// the cycle action, so its toggle's click and its body's `when` cannot address
|
|
133
|
+
// different keys.
|
|
134
|
+
const CONFIG_OPEN_GATE = `{{ and (eq .${SETTINGS_ANCHOR} "${SETTINGS_OPEN}") (eq .${CONFIG_SEG} "${SETTINGS_OPEN}") }}`;
|
|
135
|
+
|
|
136
|
+
// [LAW:one-source-of-truth] One accordion key for every picker in the menu:
|
|
137
|
+
// one key holds one open member, so opening a theme picker closes the look
|
|
138
|
+
// picker. The settings menu is a narrow panel — two open drop-downs would
|
|
139
|
+
// overflow it — and this is the same shared-key mechanism group sugar uses,
|
|
140
|
+
// selected by a value, not a mode.
|
|
141
|
+
const PICKER_KEY = `${SETTINGS_NS}pickers`;
|
|
142
|
+
|
|
143
|
+
// [LAW:types-are-the-program] One row of the config menu, as data: everything
|
|
144
|
+
// that differs between "theme" and "padding" is a field here, so the six
|
|
145
|
+
// controls below are six VALUES and the synthesis that mints them is written
|
|
146
|
+
// once. A control names the two keys its dual action writes (they differ where
|
|
147
|
+
// history made them differ — SessionState "theme" over globals field
|
|
148
|
+
// "palette"), the variable whose value it displays, and its value source.
|
|
149
|
+
interface SettingControl {
|
|
150
|
+
readonly name: string;
|
|
151
|
+
readonly sessionKey: string;
|
|
152
|
+
readonly configKey: string;
|
|
153
|
+
// The `.effective` projection the daemon resolved for this render — the
|
|
154
|
+
// value the bar is ACTUALLY rendering with, whatever produced it. A control
|
|
155
|
+
// labels itself with this rather than with its own session key, so the label
|
|
156
|
+
// can never name a value the bar is not in.
|
|
157
|
+
readonly effectiveVar: string;
|
|
158
|
+
readonly glyph: string;
|
|
159
|
+
readonly domain: OptionDomain;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// [LAW:one-type-per-behavior] Four settings, one control shape: a glyph, the
|
|
163
|
+
// current value, a picker over a domain, and the ↺ that forgets the durable
|
|
164
|
+
// default. They differ only in which keys they write and which domain they
|
|
165
|
+
// range — configuration, so they are four VALUES of one synthesis, not four
|
|
166
|
+
// hand-written segments. `theme`'s two keys differ (SessionState "theme" over
|
|
167
|
+
// globals field "palette") for the historical reason recorded in
|
|
168
|
+
// state-validators.ts's baseline table; carrying BOTH keys as data is what
|
|
169
|
+
// makes that difference expressible without a special case.
|
|
170
|
+
//
|
|
171
|
+
// They are split into two lists by WHERE they render, because that is a fact
|
|
172
|
+
// about each control, not something the layout should recover by comparing
|
|
173
|
+
// names [LAW:dataflow-not-control-flow]. Switching arrangement is what people
|
|
174
|
+
// open this menu for, so the preset picker sits one click from the toggle;
|
|
175
|
+
// the display settings sit one disclosure deeper, which is what keeps the
|
|
176
|
+
// menu narrow when opened.
|
|
177
|
+
const PRIMARY_CONTROLS: readonly SettingControl[] = [
|
|
178
|
+
{
|
|
179
|
+
name: "preset",
|
|
180
|
+
sessionKey: "preset",
|
|
181
|
+
configKey: "preset",
|
|
182
|
+
effectiveVar: "preset.effective",
|
|
183
|
+
glyph: "▦",
|
|
184
|
+
domain: "presets",
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
const CONFIG_CONTROLS: readonly SettingControl[] = [
|
|
189
|
+
{
|
|
190
|
+
name: "theme",
|
|
191
|
+
sessionKey: "theme",
|
|
192
|
+
configKey: "palette",
|
|
193
|
+
effectiveVar: "theme.effective",
|
|
194
|
+
glyph: "🎨",
|
|
195
|
+
domain: "themes",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "look",
|
|
199
|
+
sessionKey: "look",
|
|
200
|
+
configKey: "look",
|
|
201
|
+
effectiveVar: "look.effective",
|
|
202
|
+
glyph: "◐",
|
|
203
|
+
domain: "looks",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "style",
|
|
207
|
+
sessionKey: "style",
|
|
208
|
+
configKey: "style",
|
|
209
|
+
effectiveVar: "style.effective",
|
|
210
|
+
glyph: "✦",
|
|
211
|
+
domain: "styles",
|
|
212
|
+
},
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
// The two settings whose affordance is not a picker: wrapping is a toggle (two
|
|
216
|
+
// members, so a menu would be a drop-down over a binary) and padding is a
|
|
217
|
+
// stepper over a range (16 picker cells for a value you nudge). Both are dual
|
|
218
|
+
// exactly like the pickers — only the affordance differs, so they carry the
|
|
219
|
+
// same key record and only their `domain` is absent.
|
|
220
|
+
//
|
|
221
|
+
// [LAW:one-source-of-truth] Declared as records rather than typed inline at
|
|
222
|
+
// each use, so every key in SETTINGS_WRITTEN_KEYS below traces to one
|
|
223
|
+
// declaration. When these two were string literals repeated across the set,
|
|
224
|
+
// the segment and the action, a rename in one place would have silently
|
|
225
|
+
// misclassified the key rather than failing.
|
|
226
|
+
interface KeyedSetting {
|
|
227
|
+
readonly name: string;
|
|
228
|
+
readonly sessionKey: string;
|
|
229
|
+
readonly configKey: string;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const WRAP: KeyedSetting = {
|
|
233
|
+
name: "wrap",
|
|
234
|
+
sessionKey: "autoWrap",
|
|
235
|
+
configKey: "autoWrap",
|
|
236
|
+
};
|
|
237
|
+
const PADDING: KeyedSetting = {
|
|
238
|
+
name: "padding",
|
|
239
|
+
sessionKey: "padding",
|
|
240
|
+
configKey: "padding",
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const WRAP_SEG = `${SETTINGS_NS}${WRAP.name}`;
|
|
244
|
+
const PADDING_SEG = `${SETTINGS_NS}${PADDING.name}`;
|
|
245
|
+
|
|
246
|
+
// Every picker control, wherever it renders — minting one is the same job in
|
|
247
|
+
// both rows, so the synthesis folds over this and the placement lists above
|
|
248
|
+
// decide only where each lands.
|
|
249
|
+
const PICKER_CONTROLS: readonly SettingControl[] = [
|
|
250
|
+
...PRIMARY_CONTROLS,
|
|
251
|
+
...CONFIG_CONTROLS,
|
|
252
|
+
];
|
|
253
|
+
|
|
254
|
+
// [LAW:one-source-of-truth] Every PLAIN key the settings menu writes — both
|
|
255
|
+
// destinations of every control it mints. Unlike the `settings.` names, these
|
|
256
|
+
// are ordinary words a config can own (`theme`, `padding`, …), so a reader
|
|
257
|
+
// cannot tell from the key alone whether the menu or the author wrote it. This
|
|
258
|
+
// set is the menu's own answer to "which keys do I write", derived from the
|
|
259
|
+
// same records the controls are minted from, so a consumer pairing it with an
|
|
260
|
+
// authorship check (test/helpers/ambient-chrome.ts) can never drift from what
|
|
261
|
+
// the synthesis actually declares.
|
|
262
|
+
export const SETTINGS_WRITTEN_KEYS: ReadonlySet<string> = new Set(
|
|
263
|
+
[...PICKER_CONTROLS, WRAP, PADDING].flatMap((c) => [
|
|
264
|
+
c.sessionKey,
|
|
265
|
+
c.configKey,
|
|
266
|
+
]),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
// [LAW:one-source-of-truth] A control's three names, derived from its one
|
|
270
|
+
// name — the segment that shows it, the action its picker applies, and the
|
|
271
|
+
// action its ↺ resets. Derived rather than declared so a control record can
|
|
272
|
+
// never name a segment whose picker writes a different setting.
|
|
273
|
+
const controlSeg = (name: string): string => `${SETTINGS_NS}${name}`;
|
|
274
|
+
const controlApply = (name: string): string => `${SETTINGS_NS}apply.${name}`;
|
|
275
|
+
const controlReset = (name: string): string => `${SETTINGS_NS}reset.${name}`;
|
|
93
276
|
|
|
94
277
|
// [LAW:one-source-of-truth] The predicate the body container gates on, derived
|
|
95
278
|
// from the same anchor string the toggle's cycle writes — spelled once here,
|
|
@@ -204,15 +387,44 @@ function expandAnchor(node: AnchoredRoot | LayoutNode): LayoutNode {
|
|
|
204
387
|
direction: "vertical",
|
|
205
388
|
children: [
|
|
206
389
|
node,
|
|
390
|
+
// Row one: what the menu is FOR — the persist? selector that says
|
|
391
|
+
// where every setting below it lands, the preset switcher, the
|
|
392
|
+
// door into the config menu, and the door into edit mode.
|
|
207
393
|
{
|
|
208
394
|
kind: "container",
|
|
209
395
|
direction: "horizontal",
|
|
210
396
|
children: [
|
|
211
|
-
{ kind: "segment", name:
|
|
397
|
+
{ kind: "segment", name: PERSIST_SEG },
|
|
398
|
+
...PRIMARY_CONTROLS.map(
|
|
399
|
+
(c): LayoutNode => ({
|
|
400
|
+
kind: "segment",
|
|
401
|
+
name: controlSeg(c.name),
|
|
402
|
+
}),
|
|
403
|
+
),
|
|
404
|
+
{ kind: "segment", name: CONFIG_SEG },
|
|
212
405
|
{ kind: "segment", name: EDIT_SEG },
|
|
213
406
|
],
|
|
214
407
|
when: SETTINGS_OPEN_GATE,
|
|
215
408
|
},
|
|
409
|
+
// Row two: the display settings, behind their own disclosure so
|
|
410
|
+
// the menu opens narrow. Gated on BOTH keys — a config row left
|
|
411
|
+
// open yesterday must not render beside a closed menu today; one
|
|
412
|
+
// gate per disclosure, and this row is inside two of them.
|
|
413
|
+
{
|
|
414
|
+
kind: "container",
|
|
415
|
+
direction: "horizontal",
|
|
416
|
+
children: [
|
|
417
|
+
...CONFIG_CONTROLS.map(
|
|
418
|
+
(c): LayoutNode => ({
|
|
419
|
+
kind: "segment",
|
|
420
|
+
name: controlSeg(c.name),
|
|
421
|
+
}),
|
|
422
|
+
),
|
|
423
|
+
{ kind: "segment", name: WRAP_SEG },
|
|
424
|
+
{ kind: "segment", name: PADDING_SEG },
|
|
425
|
+
],
|
|
426
|
+
when: CONFIG_OPEN_GATE,
|
|
427
|
+
},
|
|
216
428
|
],
|
|
217
429
|
}
|
|
218
430
|
: node;
|
|
@@ -237,9 +449,14 @@ function declareHostedMenu(
|
|
|
237
449
|
segName: string,
|
|
238
450
|
applyName: string,
|
|
239
451
|
artifacts: MenuArtifacts,
|
|
452
|
+
// The accordion key the menu shares with its siblings, or undefined for a
|
|
453
|
+
// menu that toggles only itself — the same `key` option `{{ menu }}` takes,
|
|
454
|
+
// threaded here so the synthesized artifacts and the rendered disclosure
|
|
455
|
+
// derive one identity from one value [LAW:one-source-of-truth].
|
|
456
|
+
sharedKey?: string,
|
|
240
457
|
): void {
|
|
241
458
|
const member = menuMember(applyName);
|
|
242
|
-
const stateKey = menuStateKey(segName, applyName,
|
|
459
|
+
const stateKey = menuStateKey(segName, applyName, sharedKey);
|
|
243
460
|
const pageKey = menuPageKey(stateKey);
|
|
244
461
|
artifacts.variables[stateKey] = disclosureStateVar(
|
|
245
462
|
stateKey,
|
|
@@ -266,11 +483,15 @@ function settingsArtifacts(): MenuArtifacts {
|
|
|
266
483
|
},
|
|
267
484
|
actions: {
|
|
268
485
|
[SETTINGS_ANCHOR]: disclosureCycleAction(SETTINGS_ANCHOR, SETTINGS_OPEN),
|
|
269
|
-
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
|
|
486
|
+
[CONFIG_SEG]: disclosureCycleAction(CONFIG_SEG, SETTINGS_OPEN),
|
|
487
|
+
// [LAW:one-source-of-truth] The selector is an ordinary session cycle
|
|
488
|
+
// over the one boolean spelling SessionState uses — off first, because
|
|
489
|
+
// an unwritten key counts as the first member and the menu opens in
|
|
490
|
+
// experimentation mode.
|
|
491
|
+
[PERSIST_SEG]: {
|
|
492
|
+
set: PERSIST_KEY,
|
|
493
|
+
cycle: [BOOLEAN_FALSE, BOOLEAN_TRUE],
|
|
494
|
+
},
|
|
274
495
|
},
|
|
275
496
|
segments: {
|
|
276
497
|
// [LAW:representation] The glyph trails the label it gates, per the
|
|
@@ -280,8 +501,36 @@ function settingsArtifacts(): MenuArtifacts {
|
|
|
280
501
|
bg: "surface",
|
|
281
502
|
fg: "foreground",
|
|
282
503
|
},
|
|
283
|
-
[
|
|
284
|
-
|
|
504
|
+
// [LAW:representation] The checkbox states what the NEXT write does,
|
|
505
|
+
// which is why the glyph and the word live together: "☑ persist?" is
|
|
506
|
+
// the whole explanation of where the click below it lands.
|
|
507
|
+
[PERSIST_SEG]: {
|
|
508
|
+
template: `{{ action "${PERSIST_SEG}" "☐ persist?" "☑ persist?" }}`,
|
|
509
|
+
bg: "surface",
|
|
510
|
+
fg: "foreground",
|
|
511
|
+
},
|
|
512
|
+
[CONFIG_SEG]: {
|
|
513
|
+
template: `{{ action "${CONFIG_SEG}" "⚙ config ${DISCLOSURE_GLYPH_CLOSED}" "⚙ config ${DISCLOSURE_GLYPH_OPEN}" }}`,
|
|
514
|
+
bg: "surface",
|
|
515
|
+
fg: "foreground",
|
|
516
|
+
},
|
|
517
|
+
// [LAW:one-type-per-behavior] Both non-picker controls read the same
|
|
518
|
+
// `.effective` projection their picker siblings read, and write the
|
|
519
|
+
// same two stores through the same dual arm — a toggle and a stepper
|
|
520
|
+
// are affordances over one behavior, not two kinds of setting.
|
|
521
|
+
[WRAP_SEG]: {
|
|
522
|
+
template:
|
|
523
|
+
`{{ action "${controlApply("wrap")}" "wrap: on" "wrap: off" }} ` +
|
|
524
|
+
`{{ action "${controlReset("wrap")}" "↺" }}`,
|
|
525
|
+
bg: "surface",
|
|
526
|
+
fg: "foreground",
|
|
527
|
+
},
|
|
528
|
+
[PADDING_SEG]: {
|
|
529
|
+
template:
|
|
530
|
+
`{{ action "${controlApply("padding")}.down" "◀" }} ` +
|
|
531
|
+
"padding {{ .padding.effective }} " +
|
|
532
|
+
`{{ action "${controlApply("padding")}.up" "▶" }} ` +
|
|
533
|
+
`{{ action "${controlReset("padding")}" "↺" }}`,
|
|
285
534
|
bg: "surface",
|
|
286
535
|
fg: "foreground",
|
|
287
536
|
},
|
|
@@ -296,10 +545,83 @@ function settingsArtifacts(): MenuArtifacts {
|
|
|
296
545
|
},
|
|
297
546
|
},
|
|
298
547
|
};
|
|
299
|
-
|
|
548
|
+
artifacts.variables[PERSIST_KEY] = {
|
|
549
|
+
kind: "state",
|
|
550
|
+
key: PERSIST_KEY,
|
|
551
|
+
default: BOOLEAN_FALSE,
|
|
552
|
+
};
|
|
553
|
+
artifacts.variables[CONFIG_SEG] = disclosureStateVar(
|
|
554
|
+
CONFIG_SEG,
|
|
555
|
+
DISCLOSURE_CLOSED,
|
|
556
|
+
);
|
|
557
|
+
declareSettingControls(artifacts);
|
|
300
558
|
return artifacts;
|
|
301
559
|
}
|
|
302
560
|
|
|
561
|
+
// [LAW:one-source-of-truth] Every setting the menu offers, minted from the one
|
|
562
|
+
// table that describes them. A picker control is a glyph, its live value, a
|
|
563
|
+
// `{{ menu }}` over its domain, and the ↺ that forgets its durable default;
|
|
564
|
+
// wrap and padding differ only in affordance. Every apply action here is DUAL
|
|
565
|
+
// — one declaration naming both destination keys and the selector that chooses
|
|
566
|
+
// between them — so the panel spells each setting exactly once and the click
|
|
567
|
+
// carries the destination as data [LAW:dataflow-not-control-flow].
|
|
568
|
+
//
|
|
569
|
+
// [LAW:single-enforcer] Nothing here declares a gate. `deriveActionValidators`
|
|
570
|
+
// and `deriveConfigActionValidators` each explode these dual declarations
|
|
571
|
+
// (actionDestinations) and derive the same specs they would have derived from
|
|
572
|
+
// the pair of single-destination actions this replaces — so the writable-key
|
|
573
|
+
// surface is byte-for-byte what it was when the drawer spelled both halves.
|
|
574
|
+
function declareSettingControls(artifacts: MenuArtifacts): void {
|
|
575
|
+
for (const c of PICKER_CONTROLS) {
|
|
576
|
+
const seg = controlSeg(c.name);
|
|
577
|
+
const apply = controlApply(c.name);
|
|
578
|
+
artifacts.segments[seg] = {
|
|
579
|
+
template:
|
|
580
|
+
`${c.glyph} {{ .${c.effectiveVar} }} ` +
|
|
581
|
+
`{{ menu "${apply}" (dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
|
|
582
|
+
`{{ action "${controlReset(c.name)}" "↺" }}`,
|
|
583
|
+
bg: "surface",
|
|
584
|
+
fg: "foreground",
|
|
585
|
+
};
|
|
586
|
+
artifacts.actions[apply] = {
|
|
587
|
+
set: c.sessionKey,
|
|
588
|
+
persist: c.configKey,
|
|
589
|
+
persistWhen: PERSIST_KEY,
|
|
590
|
+
from: c.domain,
|
|
591
|
+
};
|
|
592
|
+
// [LAW:one-source-of-truth] ↺ clears the DURABLE default only — the one
|
|
593
|
+
// write the user cannot otherwise take back, since a session value dies
|
|
594
|
+
// with the session. Its target is the config key the dual's durable half
|
|
595
|
+
// writes, read from the same record, so the two can never name different
|
|
596
|
+
// settings.
|
|
597
|
+
artifacts.actions[controlReset(c.name)] = { reset: c.configKey };
|
|
598
|
+
declareHostedMenu(seg, apply, artifacts, PICKER_KEY);
|
|
599
|
+
}
|
|
600
|
+
artifacts.actions[controlApply(WRAP.name)] = {
|
|
601
|
+
set: WRAP.sessionKey,
|
|
602
|
+
persist: WRAP.configKey,
|
|
603
|
+
persistWhen: PERSIST_KEY,
|
|
604
|
+
cycle: [...BOOLEAN_MEMBERS],
|
|
605
|
+
};
|
|
606
|
+
artifacts.actions[controlReset(WRAP.name)] = { reset: WRAP.configKey };
|
|
607
|
+
// [LAW:one-source-of-truth] The stepper's bounds are PADDING_RANGE, the same
|
|
608
|
+
// range the loader validates a config-file `padding` against and the same one
|
|
609
|
+
// both write gates enforce — a click can never reach a value the file could
|
|
610
|
+
// not have held.
|
|
611
|
+
for (const by of [-1, 1]) {
|
|
612
|
+
artifacts.actions[
|
|
613
|
+
`${controlApply(PADDING.name)}.${by < 0 ? "down" : "up"}`
|
|
614
|
+
] = {
|
|
615
|
+
set: PADDING.sessionKey,
|
|
616
|
+
persist: PADDING.configKey,
|
|
617
|
+
persistWhen: PERSIST_KEY,
|
|
618
|
+
...PADDING_RANGE,
|
|
619
|
+
by,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
artifacts.actions[controlReset(PADDING.name)] = { reset: PADDING.configKey };
|
|
623
|
+
}
|
|
624
|
+
|
|
303
625
|
// [LAW:one-source-of-truth] Edit mode's toggle, ensured rather than duplicated:
|
|
304
626
|
// both this pass and synthesizeEditModeToggle produce it by calling the same two
|
|
305
627
|
// disclosure functions on the same two exported constants, so the two mints are
|
|
@@ -48,11 +48,13 @@ import type {
|
|
|
48
48
|
// BuildLineOptions), so the value a trigger label displays and the value
|
|
49
49
|
// that actually shaped the render can never disagree — the same reasoning
|
|
50
50
|
// theme/look already followed, generalized to every globals field a menu or
|
|
51
|
-
// stepper can persist. `theme`/`look`/`style` compose
|
|
52
|
-
// config default (a session pick can diverge from the
|
|
53
|
-
// its own session); `charset
|
|
54
|
-
// no SessionState half
|
|
55
|
-
//
|
|
51
|
+
// stepper can persist. `theme`/`look`/`style`/`autoWrap`/`padding` compose
|
|
52
|
+
// SessionState over the config default (a session pick can diverge from the
|
|
53
|
+
// persisted default for its own session); `charset` and `colorCompatibility`
|
|
54
|
+
// have no SessionState half — they describe the terminal (glyph coverage,
|
|
55
|
+
// colour depth) rather than a per-session taste, so the resolved config global
|
|
56
|
+
// over its floor constant is their whole resolution. See CHARSETS in
|
|
57
|
+
// themes/policy.ts for why that is a decision rather than a gap.
|
|
56
58
|
export interface EffectiveGlobals {
|
|
57
59
|
readonly theme: string;
|
|
58
60
|
readonly look: string;
|
package/src/daemon/server.ts
CHANGED
|
@@ -65,6 +65,8 @@ import { expandHome } from "../config/dsl-loader.js";
|
|
|
65
65
|
import { renderDsl } from "../dsl/render.js";
|
|
66
66
|
import {
|
|
67
67
|
effectiveStripStyle,
|
|
68
|
+
effectiveAutoWrap,
|
|
69
|
+
effectivePadding,
|
|
68
70
|
effectiveThemeName,
|
|
69
71
|
effectiveLookName,
|
|
70
72
|
lookKeyByName,
|
|
@@ -867,11 +869,12 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
867
869
|
//
|
|
868
870
|
// [LAW:one-source-of-truth] Every globals field a menu/stepper can
|
|
869
871
|
// persist (candybar-config-engine-71o.3), resolved ONCE into one
|
|
870
|
-
// struct: theme/look/style compose the session's click
|
|
871
|
-
// over the config default over a floor — a click
|
|
872
|
-
// whole bar on the next render; charset
|
|
873
|
-
//
|
|
874
|
-
//
|
|
872
|
+
// struct: theme/look/style/autoWrap/padding compose the session's click
|
|
873
|
+
// (SessionState) over the config default over a floor — a click
|
|
874
|
+
// recolors/reshapes the whole bar on the next render; charset and
|
|
875
|
+
// colorCompatibility describe the TERMINAL rather than a taste and so
|
|
876
|
+
// have no SessionState half at all, making the config global over its
|
|
877
|
+
// floor constant their whole resolution. This struct feeds
|
|
875
878
|
// BOTH the payload's `*.effective` fields (trigger labels) AND
|
|
876
879
|
// renderOpts below (the actual render) — one resolution, two readers,
|
|
877
880
|
// so a label can never disagree with what was rendered.
|
|
@@ -918,8 +921,18 @@ async function handleRequest(req: Request): Promise<HandledRequest> {
|
|
|
918
921
|
sessionState.get(req.hookData.session_id, "style"),
|
|
919
922
|
globals.style,
|
|
920
923
|
),
|
|
921
|
-
autoWrap:
|
|
922
|
-
|
|
924
|
+
autoWrap: effectiveAutoWrap(
|
|
925
|
+
sessionState.get(req.hookData.session_id, "autoWrap"),
|
|
926
|
+
globals.autoWrap,
|
|
927
|
+
),
|
|
928
|
+
padding: effectivePadding(
|
|
929
|
+
sessionState.get(req.hookData.session_id, "padding"),
|
|
930
|
+
globals.padding,
|
|
931
|
+
),
|
|
932
|
+
// charset and colorCompatibility have no session half by design —
|
|
933
|
+
// they describe the terminal, not a taste (see CHARSETS in
|
|
934
|
+
// themes/policy.ts) — so the config global over its floor is their
|
|
935
|
+
// whole resolution.
|
|
923
936
|
charset: globals.charset ?? DEFAULT_CHARSET,
|
|
924
937
|
colorCompatibility:
|
|
925
938
|
globals.colorCompatibility ?? DEFAULT_COLOR_COMPATIBILITY,
|
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
// goal, realized more strictly here than SessionState's legacy baseline
|
|
11
11
|
// theme/style/toolbar-expanded keys.
|
|
12
12
|
|
|
13
|
-
import type
|
|
13
|
+
import { actionDestinations, type ActionDecl } from "../../config/action";
|
|
14
14
|
import {
|
|
15
15
|
perConfigDomainsFor,
|
|
16
16
|
resolveOptionDomain,
|
|
17
17
|
} from "../../config/option-domain";
|
|
18
18
|
import { addableSegmentDomains } from "../../config/edit-chrome";
|
|
19
19
|
import type { DslConfig } from "../../config/dsl-types";
|
|
20
|
-
import {
|
|
20
|
+
import { numericGlobalsSeeds } from "../../config/loader/globals";
|
|
21
21
|
import { encodeLayoutOp } from "../../config/layout-ops";
|
|
22
22
|
import {
|
|
23
23
|
parsePersistTarget,
|
|
@@ -163,13 +163,7 @@ function actionKeySpecs(
|
|
|
163
163
|
// applied. Mirrors stateKeySeeds' "the bar's current display, not silently
|
|
164
164
|
// min" rule for SessionState steppers.
|
|
165
165
|
function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
166
|
-
|
|
167
|
-
for (const [key, value] of Object.entries(config.globals)) {
|
|
168
|
-
if (isGlobalsField(key) && typeof value === "number") {
|
|
169
|
-
seeds.set(key, value);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return seeds;
|
|
166
|
+
return numericGlobalsSeeds(config.globals);
|
|
173
167
|
}
|
|
174
168
|
|
|
175
169
|
// [LAW:one-source-of-truth] Every preset a config's action table ALREADY
|
|
@@ -197,7 +191,7 @@ function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
|
197
191
|
// SOME action names it).
|
|
198
192
|
function presetRootOpsContributions(config: DslConfig): KeySpecContribution[] {
|
|
199
193
|
const presets = new Set<string>();
|
|
200
|
-
for (const a of
|
|
194
|
+
for (const a of writeDestinations(config)) {
|
|
201
195
|
const key = "persist" in a ? a.persist : "reset" in a ? a.reset : null;
|
|
202
196
|
if (key === null) continue;
|
|
203
197
|
const target = parsePersistTarget(key);
|
|
@@ -223,12 +217,21 @@ function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
|
223
217
|
]);
|
|
224
218
|
return [
|
|
225
219
|
...presetRootOpsContributions(config),
|
|
226
|
-
...
|
|
220
|
+
...writeDestinations(config).flatMap((a) =>
|
|
227
221
|
actionKeySpecs(a, seeds, perConfigDomains),
|
|
228
222
|
),
|
|
229
223
|
];
|
|
230
224
|
}
|
|
231
225
|
|
|
226
|
+
// [LAW:single-enforcer] Every action as the single-destination declarations it
|
|
227
|
+
// writes through — the SAME explosion state-validators.ts folds over, so a
|
|
228
|
+
// dual-destination action (candybar-settings-ui-aok.3) contributes exactly the
|
|
229
|
+
// `persist` spec its durable half would have contributed alone. One statement
|
|
230
|
+
// of "what are this action's destinations", two derivations reading it.
|
|
231
|
+
function writeDestinations(config: DslConfig): readonly ActionDecl[] {
|
|
232
|
+
return Object.values(config.actions).flatMap(actionDestinations);
|
|
233
|
+
}
|
|
234
|
+
|
|
232
235
|
// [LAW:single-enforcer] The SOLE install-site derivation: a config's
|
|
233
236
|
// persistent-config-writable-key surface is the merge of every `persist`
|
|
234
237
|
// ACTION it declares, through the SAME coherence pass deriveActionValidators
|
|
@@ -297,6 +297,40 @@ function wrapStep(n: number, min: number, max: number): number {
|
|
|
297
297
|
return n > max ? min : n < min ? max : n;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
// [LAW:no-ambient-temporal-coupling] The RELEASE half of a durable write, run
|
|
301
|
+
// by the durable handlers themselves AFTER their own write succeeded — never
|
|
302
|
+
// as a separate effect beside them.
|
|
303
|
+
//
|
|
304
|
+
// A dual-destination control commits "make this the durable default AND stop
|
|
305
|
+
// overriding it in this session". Those are one intent, and the session half
|
|
306
|
+
// is destructive: dropping the session pick is only correct if the durable
|
|
307
|
+
// value actually landed. Emitted as two effects, `dispatch` would run the
|
|
308
|
+
// clear even when the persist failed (it runs every effect in a click by
|
|
309
|
+
// design, for independent ones like "write value + close menu") — wiping the
|
|
310
|
+
// user's pick with nothing durable in its place, a lost update whose error
|
|
311
|
+
// message would not even mention it. Ordering that matters belongs inside one
|
|
312
|
+
// handler, not in a hope about the dispatcher.
|
|
313
|
+
//
|
|
314
|
+
// Gated by key MEMBERSHIP (listStateKeys), exactly as reset-config is over the
|
|
315
|
+
// config keyspace: there is no value to validate, only a legitimate target to
|
|
316
|
+
// clear. Absent segment = nothing to release, which is every ordinary persist
|
|
317
|
+
// click [LAW:dataflow-not-control-flow].
|
|
318
|
+
function releaseSessionKey(
|
|
319
|
+
release: string,
|
|
320
|
+
sid: string,
|
|
321
|
+
ctx: VerbContext,
|
|
322
|
+
verb: string,
|
|
323
|
+
): void {
|
|
324
|
+
if (!release) return;
|
|
325
|
+
if (!listStateKeys().includes(release)) {
|
|
326
|
+
throw new BadVerbArgs(
|
|
327
|
+
`${verb}: unknown session key "${release}" to release (have: ${listStateKeys().join(", ")})`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
ctx.sessionState.clear(sid, release);
|
|
331
|
+
ctx.dlog("info", `${verb}: released session key ${release} (session=${sid})`);
|
|
332
|
+
}
|
|
333
|
+
|
|
300
334
|
// [LAW:one-source-of-truth] A RELATIVE nudge to a bounded state key. The link
|
|
301
335
|
// carries ONLY the irreducible intent `[sessionId, key, by]` (no `current`
|
|
302
336
|
// snapshot), so the SAME link string fires every render and N rapid clicks each
|
|
@@ -374,8 +408,8 @@ function assertGlobalsField(key: string): asserts key is keyof Globals {
|
|
|
374
408
|
// BAD_REQUEST — the SAME gate `set-state` uses (validateConfigWrite),
|
|
375
409
|
// derived from the SAME action table (deriveConfigActionValidators).
|
|
376
410
|
const setConfig: VerbHandler = (rawValue, ctx) => {
|
|
377
|
-
const [sessionId = "", key = "", incoming = ""] = decodeWire(
|
|
378
|
-
decodeSegments(rawValue),
|
|
411
|
+
const [sessionId = "", key = "", incoming = "", release = ""] = decodeWire(
|
|
412
|
+
() => decodeSegments(rawValue),
|
|
379
413
|
);
|
|
380
414
|
const sid = requireSessionId(sessionId);
|
|
381
415
|
if (!key) {
|
|
@@ -388,6 +422,7 @@ const setConfig: VerbHandler = (rawValue, ctx) => {
|
|
|
388
422
|
const typed = coercePersistValue(key, result.value);
|
|
389
423
|
writeConfigOverride(configOverridesPath(), key, typed, ctx.dlog);
|
|
390
424
|
ctx.dlog("info", `set-config: ${key}=${result.value} (session=${sid})`);
|
|
425
|
+
releaseSessionKey(release, sid, ctx, "set-config");
|
|
391
426
|
};
|
|
392
427
|
|
|
393
428
|
// [LAW:one-source-of-truth] `persist`'s twin of stepState: a RELATIVE nudge
|
|
@@ -395,7 +430,7 @@ const setConfig: VerbHandler = (rawValue, ctx) => {
|
|
|
395
430
|
// unset — rangeParamsForConfig's seed), wrapped and re-validated through the
|
|
396
431
|
// SAME range gate, then written durably.
|
|
397
432
|
const stepConfig: VerbHandler = (rawValue, ctx) => {
|
|
398
|
-
const [sessionId = "", key = "", byRaw = ""] = decodeWire(() =>
|
|
433
|
+
const [sessionId = "", key = "", byRaw = "", release = ""] = decodeWire(() =>
|
|
399
434
|
decodeSegments(rawValue),
|
|
400
435
|
);
|
|
401
436
|
const sid = requireSessionId(sessionId);
|
|
@@ -433,6 +468,7 @@ const stepConfig: VerbHandler = (rawValue, ctx) => {
|
|
|
433
468
|
"info",
|
|
434
469
|
`step-config: ${key} ${current}→${result.value} (by ${by}, session=${sid})`,
|
|
435
470
|
);
|
|
471
|
+
releaseSessionKey(release, sid, ctx, "step-config");
|
|
436
472
|
};
|
|
437
473
|
|
|
438
474
|
// [LAW:one-source-of-truth] The gated undo for `persist`: clears one
|