@promptctl/cc-candybar 1.29.0 → 1.31.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 +75 -75
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +67 -0
- package/src/click/wire.ts +20 -0
- package/src/config/action.ts +64 -2
- package/src/config/layout-ops.ts +156 -0
- package/src/config/loader/actions.ts +155 -3
- package/src/config/loader/cross-ref.ts +101 -8
- package/src/config/loader/persist-target.ts +20 -4
- package/src/config/presets.ts +42 -0
- package/src/daemon/cache/render.ts +17 -2
- package/src/daemon/config-overrides-store.ts +367 -26
- package/src/daemon/verbs/config-validators.ts +38 -0
- package/src/daemon/verbs/index.ts +88 -3
- package/src/render/action.ts +67 -2
|
@@ -15,8 +15,11 @@ import {
|
|
|
15
15
|
} from "../dsl-types.js";
|
|
16
16
|
import {
|
|
17
17
|
actionBindsPersist,
|
|
18
|
+
actionBindsRedo,
|
|
18
19
|
actionBindsReset,
|
|
19
20
|
actionBindsSet,
|
|
21
|
+
actionBindsUndo,
|
|
22
|
+
type ActionDecl,
|
|
20
23
|
} from "../action.js";
|
|
21
24
|
import {
|
|
22
25
|
knownOptionDomainNames,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
} from "../option-domain.js";
|
|
25
28
|
import { listGlobalsFieldNames } from "./globals.js";
|
|
26
29
|
import { parsePersistTarget } from "./persist-target.js";
|
|
30
|
+
import { presetNames } from "../presets.js";
|
|
27
31
|
import { findKeyLine } from "./diagnostics.js";
|
|
28
32
|
import { isPlainObject, type ValidateCtx } from "./validate-core.js";
|
|
29
33
|
import {
|
|
@@ -116,11 +120,23 @@ export function validateCrossReferences(
|
|
|
116
120
|
if (target === null) {
|
|
117
121
|
ctx.issues.push({
|
|
118
122
|
path: `actions.${name}.${discriminator}`,
|
|
119
|
-
message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")})
|
|
123
|
+
message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")}), a "segments.<name>.palette" target, or a "presets.<name>.rootOps" target`,
|
|
120
124
|
line: findKeyLine(ctx.source, ["actions", name, discriminator]),
|
|
121
125
|
});
|
|
122
126
|
continue;
|
|
123
127
|
}
|
|
128
|
+
if (target.scope === "preset-root-ops") {
|
|
129
|
+
checkPresetRootOpsTarget(
|
|
130
|
+
ctx,
|
|
131
|
+
cfg,
|
|
132
|
+
name,
|
|
133
|
+
discriminator,
|
|
134
|
+
key,
|
|
135
|
+
target.preset,
|
|
136
|
+
a,
|
|
137
|
+
);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
124
140
|
if (target.scope !== "segment-palette") continue;
|
|
125
141
|
if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
|
|
126
142
|
ctx.issues.push({
|
|
@@ -345,6 +361,76 @@ export function validateCrossReferences(
|
|
|
345
361
|
}
|
|
346
362
|
}
|
|
347
363
|
|
|
364
|
+
// [LAW:no-silent-failure] brandon-layout-edit-2gc.1's structural-edit target
|
|
365
|
+
// check, one arm of the persist/reset key cross-ref above. Three things must
|
|
366
|
+
// hold at load time, same spirit as the segment-palette check just above it:
|
|
367
|
+
// the preset name must be real (mirrors globals.preset's check earlier in
|
|
368
|
+
// this function), the arm pairing must make sense for this scope (only
|
|
369
|
+
// removeSegment/insertSegment address a tree — a `to`/`from`/cycle/bounded
|
|
370
|
+
// literal has no meaning as "the current op log"), and every segment name
|
|
371
|
+
// the op names must be declared.
|
|
372
|
+
function checkPresetRootOpsTarget(
|
|
373
|
+
ctx: ValidateCtx,
|
|
374
|
+
cfg: DslConfig,
|
|
375
|
+
name: string,
|
|
376
|
+
discriminator: "persist" | "reset",
|
|
377
|
+
key: string,
|
|
378
|
+
presetName: string,
|
|
379
|
+
a: ActionDecl,
|
|
380
|
+
): void {
|
|
381
|
+
const at = `actions.${name}.${discriminator}`;
|
|
382
|
+
const line = findKeyLine(ctx.source, ["actions", name, discriminator]);
|
|
383
|
+
if (!presetNames(cfg.presets).includes(presetName)) {
|
|
384
|
+
ctx.issues.push({
|
|
385
|
+
path: at,
|
|
386
|
+
message: `actions.${name}: "${key}" names preset "${presetName}" which is not declared (have: ${presetNames(cfg.presets).join(", ")})`,
|
|
387
|
+
line,
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
// [LAW:one-source-of-truth] `reset` has no value-source arm to check — its
|
|
392
|
+
// shape is a bare `{ reset: key }` — so the arm-pairing/segment checks
|
|
393
|
+
// below are `persist`-only, exactly as the "reset" action's clean-slate
|
|
394
|
+
// undo is meant to be: it clears the whole op log regardless of what wrote
|
|
395
|
+
// it.
|
|
396
|
+
if (discriminator === "reset") return;
|
|
397
|
+
const hasRemove = "removeSegment" in a;
|
|
398
|
+
const hasInsert = "insertSegment" in a;
|
|
399
|
+
if (!hasRemove && !hasInsert) {
|
|
400
|
+
ctx.issues.push({
|
|
401
|
+
path: at,
|
|
402
|
+
message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment" or "insertSegment" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
|
|
403
|
+
line,
|
|
404
|
+
});
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const missing = (segName: string): boolean =>
|
|
408
|
+
!Object.prototype.hasOwnProperty.call(cfg.segments, segName);
|
|
409
|
+
if (hasRemove && "removeSegment" in a && missing(a.removeSegment)) {
|
|
410
|
+
ctx.issues.push({
|
|
411
|
+
path: at,
|
|
412
|
+
message: `actions.${name}: removeSegment "${a.removeSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
413
|
+
line,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (hasInsert && "insertSegment" in a) {
|
|
417
|
+
if (missing(a.insertSegment)) {
|
|
418
|
+
ctx.issues.push({
|
|
419
|
+
path: at,
|
|
420
|
+
message: `actions.${name}: insertSegment "${a.insertSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
421
|
+
line,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (missing(a.anchor)) {
|
|
425
|
+
ctx.issues.push({
|
|
426
|
+
path: at,
|
|
427
|
+
message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
428
|
+
line,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
348
434
|
function hasStateKind(cfg: DslConfig): boolean {
|
|
349
435
|
for (const v of Object.values(cfg.variables)) {
|
|
350
436
|
if (v.kind === "state") return true;
|
|
@@ -358,15 +444,22 @@ function hasStateKind(cfg: DslConfig): boolean {
|
|
|
358
444
|
return false;
|
|
359
445
|
}
|
|
360
446
|
|
|
361
|
-
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
362
|
-
// reset-config click — and so needs session.id — when any
|
|
363
|
-
// a `set` (literal/option/bounded/cycle), a `persist`
|
|
364
|
-
// twin),
|
|
365
|
-
// the
|
|
366
|
-
//
|
|
447
|
+
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
448
|
+
// reset-config, undo, OR redo click — and so needs session.id — when any
|
|
449
|
+
// declared action is a `set` (literal/option/bounded/cycle), a `persist`
|
|
450
|
+
// (its config-overrides twin), a `reset` (persist's gated undo), or an
|
|
451
|
+
// `undo`/`redo` (the overrides layer's global history step) — all five
|
|
452
|
+
// carry session.id on the wire for click-error surfacing (an empty history
|
|
453
|
+
// stack is a loud, session-scoped miss, not a silent no-op). copy/open
|
|
454
|
+
// actions write nothing, so they embed no session.id.
|
|
367
455
|
function hasActionSetAction(cfg: DslConfig): boolean {
|
|
368
456
|
return Object.values(cfg.actions).some(
|
|
369
|
-
(a) =>
|
|
457
|
+
(a) =>
|
|
458
|
+
actionBindsSet(a) ||
|
|
459
|
+
actionBindsPersist(a) ||
|
|
460
|
+
actionBindsReset(a) ||
|
|
461
|
+
actionBindsUndo(a) ||
|
|
462
|
+
actionBindsRedo(a),
|
|
370
463
|
);
|
|
371
464
|
}
|
|
372
465
|
|
|
@@ -16,17 +16,33 @@ import { isGlobalsField } from "./globals.js";
|
|
|
16
16
|
|
|
17
17
|
export type PersistTarget =
|
|
18
18
|
| { readonly scope: "globals"; readonly field: keyof Globals }
|
|
19
|
-
| { readonly scope: "segment-palette"; readonly segment: string }
|
|
19
|
+
| { readonly scope: "segment-palette"; readonly segment: string }
|
|
20
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's structural-edit
|
|
21
|
+
// target — the accumulated op LOG for one preset's root (see
|
|
22
|
+
// src/config/layout-ops.ts), never the tree itself: a scalar-shaped value
|
|
23
|
+
// (a JSON-encoded string[] of op tokens) so it rides the SAME flat-dict
|
|
24
|
+
// overrides file with no shape change to that store's core writer.
|
|
25
|
+
| { readonly scope: "preset-root-ops"; readonly preset: string };
|
|
20
26
|
|
|
21
27
|
// [LAW:locality-or-seam] `segments.<name>.palette` reuses the SAME dotted
|
|
22
28
|
// namespacing SegmentDecl.vars already uses for segment-local variables
|
|
23
29
|
// (`<segment>.<var>`, declared in src/dsl/render.ts) — one idiom for "a name
|
|
24
30
|
// scoped under a segment", not a bespoke second syntax invented for persist
|
|
25
|
-
// targets alone.
|
|
31
|
+
// targets alone. `presets.<name>.rootOps` mirrors it one level up (a name
|
|
32
|
+
// scoped under a preset) — deliberately spelled `rootOps`, not `root`, so it
|
|
33
|
+
// never reads as the same string as presetRoot()'s load-time diagnostic path
|
|
34
|
+
// `presets.<name>.root` (src/config/presets.ts), a different namespace this
|
|
35
|
+
// key must never be confused with.
|
|
26
36
|
const SEGMENT_PALETTE_KEY = /^segments\.([^.]+)\.palette$/;
|
|
37
|
+
const PRESET_ROOT_OPS_KEY = /^presets\.([^.]+)\.rootOps$/;
|
|
27
38
|
|
|
28
39
|
export function parsePersistTarget(key: string): PersistTarget | null {
|
|
29
40
|
if (isGlobalsField(key)) return { scope: "globals", field: key };
|
|
30
|
-
const
|
|
31
|
-
|
|
41
|
+
const segmentMatch = SEGMENT_PALETTE_KEY.exec(key);
|
|
42
|
+
if (segmentMatch)
|
|
43
|
+
return { scope: "segment-palette", segment: segmentMatch[1]! };
|
|
44
|
+
const presetMatch = PRESET_ROOT_OPS_KEY.exec(key);
|
|
45
|
+
return presetMatch
|
|
46
|
+
? { scope: "preset-root-ops", preset: presetMatch[1]! }
|
|
47
|
+
: null;
|
|
32
48
|
}
|
package/src/config/presets.ts
CHANGED
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
PresetDecl,
|
|
38
38
|
} from "./dsl-types.js";
|
|
39
39
|
import { effectiveMemberName } from "../themes/policy.js";
|
|
40
|
+
import { applyLayoutOps, decodeLayoutOp } from "./layout-ops.js";
|
|
40
41
|
|
|
41
42
|
// [LAW:one-source-of-truth] The floor preset's name, spelled once. `looks` has
|
|
42
43
|
// `"none"` (the identity adaptation); presets have `"default"` (the identity
|
|
@@ -186,3 +187,44 @@ export function sanitizePersistedPresetOverride(
|
|
|
186
187
|
delete rest.preset;
|
|
187
188
|
return rest;
|
|
188
189
|
}
|
|
190
|
+
|
|
191
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step — the
|
|
192
|
+
// SAME "patch an already-merged config" shape applySegmentPaletteOverrides
|
|
193
|
+
// (src/config/loader/merge.ts) uses one field over, run at the SAME point in
|
|
194
|
+
// RenderCache.buildState (after the globals/segment-palette overrides, before
|
|
195
|
+
// validateConfig): for every preset with an accumulated op log, resolve its
|
|
196
|
+
// CURRENT root the normal way (presetRoot — bundled/user root, or the
|
|
197
|
+
// preset's own declared fragment) and replay the ops on top, writing the
|
|
198
|
+
// result back as that preset's `root`. Every later reader (presetRoot,
|
|
199
|
+
// registerDslConfig's per-preset compile, validateConfig's cross-ref walk)
|
|
200
|
+
// sees the patched tree as if it had been authored that way — no second
|
|
201
|
+
// resolution path [LAW:locality-or-seam].
|
|
202
|
+
//
|
|
203
|
+
// [LAW:no-silent-failure] exception: an op whose target/anchor names a
|
|
204
|
+
// segment absent from the CURRENT tree is a no-op (layout-ops.ts's own
|
|
205
|
+
// documented policy) — a validated action can only ever name a segment the
|
|
206
|
+
// config declares at the time it was clicked, so a miss here only happens
|
|
207
|
+
// after a LATER edit (a config change, or an earlier op in the same list)
|
|
208
|
+
// already removed it. A malformed individual token (decodeLayoutOp -> null;
|
|
209
|
+
// can only arise from hand-edited or previous-version state, never from this
|
|
210
|
+
// process's own encodeLayoutOp) is filtered the same way, never applied.
|
|
211
|
+
export function applyPresetRootOpsOverrides(
|
|
212
|
+
config: DslConfig,
|
|
213
|
+
presetRootOps: Readonly<Record<string, readonly string[]>>,
|
|
214
|
+
): DslConfig {
|
|
215
|
+
const entries = Object.entries(presetRootOps).filter(
|
|
216
|
+
([, tokens]) => tokens.length > 0,
|
|
217
|
+
);
|
|
218
|
+
if (entries.length === 0) return config;
|
|
219
|
+
const presets: Record<string, PresetDecl> = { ...config.presets };
|
|
220
|
+
for (const [name, tokens] of entries) {
|
|
221
|
+
const ops = tokens.map(decodeLayoutOp).filter((op) => op !== null);
|
|
222
|
+
if (ops.length === 0) continue;
|
|
223
|
+
const { node } = presetRoot(config, name);
|
|
224
|
+
presets[name] = {
|
|
225
|
+
...presetByName(config.presets, name),
|
|
226
|
+
root: applyLayoutOps(node, ops),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { ...config, presets };
|
|
230
|
+
}
|
|
@@ -25,7 +25,10 @@ import {
|
|
|
25
25
|
} from "../verbs/config-validators.js";
|
|
26
26
|
import { loadOverrides } from "../config-overrides-store.js";
|
|
27
27
|
import { configOverridesPath } from "../paths.js";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
applyPresetRootOpsOverrides,
|
|
30
|
+
sanitizePersistedPresetOverride,
|
|
31
|
+
} from "../../config/presets.js";
|
|
29
32
|
import { VariableStore } from "../../var-system/store.js";
|
|
30
33
|
import { SourceRegistry } from "../../var-system/sources.js";
|
|
31
34
|
import type { GitDataProvider } from "./git.js";
|
|
@@ -324,8 +327,20 @@ export class RenderCache {
|
|
|
324
327
|
withGlobalsOverrides,
|
|
325
328
|
overrides.segmentPalette,
|
|
326
329
|
);
|
|
327
|
-
|
|
330
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's replay step —
|
|
331
|
+
// the SAME "patch an already-merged config" cascade as the segment-
|
|
332
|
+
// palette overlay above, one field over (a preset's `root` instead of a
|
|
333
|
+
// segment's `palette`). Runs last so validateConfig's cross-ref walk
|
|
334
|
+
// proves the OPS-PATCHED tree, not the pre-edit one — a structural edit
|
|
335
|
+
// that referenced a segment removed by a later config change is caught
|
|
336
|
+
// exactly like a hand-authored preset root naming the same segment would
|
|
337
|
+
// be.
|
|
338
|
+
const withPresetRootOps = applyPresetRootOpsOverrides(
|
|
328
339
|
withOverrides,
|
|
340
|
+
overrides.presetRootOps,
|
|
341
|
+
);
|
|
342
|
+
const config = validateConfig(
|
|
343
|
+
withPresetRootOps,
|
|
329
344
|
resolvedPath ?? "<default>",
|
|
330
345
|
source,
|
|
331
346
|
);
|