@promptctl/cc-candybar 1.28.0 → 1.30.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 +77 -77
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +43 -0
- package/src/click/wire.ts +11 -0
- package/src/config/action.ts +24 -0
- package/src/config/default-dsl-config.ts +163 -26
- package/src/config/layout-ops.ts +156 -0
- package/src/config/loader/actions.ts +110 -3
- package/src/config/loader/cross-ref.ts +85 -1
- 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 +59 -9
- package/src/daemon/verbs/config-validators.ts +38 -0
- package/src/daemon/verbs/index.ts +50 -3
- package/src/render/action.ts +41 -1
|
@@ -324,6 +324,25 @@ const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
|
324
324
|
const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
325
325
|
cycle: cycleSpec("persist"),
|
|
326
326
|
};
|
|
327
|
+
// [LAW:one-type-per-behavior] brandon-layout-edit-2gc.1's two structural-edit
|
|
328
|
+
// arms — PERSIST-only (see action.ts's ActionDecl doc comment for why there
|
|
329
|
+
// is no `set` twin). Each field reuses layoutNameSpec: a segment/anchor name
|
|
330
|
+
// must be non-empty and free of both `/` (the click wire's own segment
|
|
331
|
+
// delimiter) and `:` (layout-ops.ts's op-token delimiter) — the SAME
|
|
332
|
+
// wire-safety diligence slashFreeString already applies to `to`/`cycle`
|
|
333
|
+
// members, one forbidden character wider.
|
|
334
|
+
const REMOVE_SEGMENT_FIELDS: FieldSpecMap<{ removeSegment: string }> = {
|
|
335
|
+
removeSegment: layoutNameSpec("removeSegment"),
|
|
336
|
+
};
|
|
337
|
+
const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
|
|
338
|
+
insertSegment: string;
|
|
339
|
+
anchor: string;
|
|
340
|
+
relation: "before" | "after";
|
|
341
|
+
}> = {
|
|
342
|
+
insertSegment: layoutNameSpec("insertSegment"),
|
|
343
|
+
anchor: layoutNameSpec("anchor"),
|
|
344
|
+
relation: relationSpec(),
|
|
345
|
+
};
|
|
327
346
|
|
|
328
347
|
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
329
348
|
// domain (min < max) and a non-zero integer increment (`by`; negative for a
|
|
@@ -418,16 +437,49 @@ const SET_ARMS: readonly ValueSourceArm[] = [
|
|
|
418
437
|
|
|
419
438
|
// [LAW:one-type-per-behavior] `persist` mirrors `set` minus the `int` arm — a
|
|
420
439
|
// page cursor is a UI-only paging concept with no meaning as a persisted
|
|
421
|
-
// config default (see action.ts's ActionDecl comment).
|
|
440
|
+
// config default (see action.ts's ActionDecl comment). `removeSegment`/
|
|
441
|
+
// `insertSegment` are ADDITIONAL persist-only arms with no `set` counterpart.
|
|
422
442
|
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
423
443
|
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
424
444
|
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
425
445
|
valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
426
446
|
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
447
|
+
valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
|
|
448
|
+
valueSourceArm("persist", INSERT_SEGMENT_FIELDS),
|
|
427
449
|
];
|
|
428
450
|
|
|
429
|
-
|
|
430
|
-
|
|
451
|
+
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
452
|
+
// data that varies per discriminator — the "or" belongs on the LAST clause
|
|
453
|
+
// only, and which clause is last differs between `set` (ends at cycle) and
|
|
454
|
+
// `persist` (ends at insertSegment), so building a list and joining it is
|
|
455
|
+
// what keeps that placement correct without a second copy of the sentence.
|
|
456
|
+
function valueSourceClauses(discriminator: "set" | "persist"): string[] {
|
|
457
|
+
const clauses = [
|
|
458
|
+
`"to" (a literal value)`,
|
|
459
|
+
`"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
|
|
460
|
+
`"min"/"max"/"by" (a bounded step)`,
|
|
461
|
+
];
|
|
462
|
+
if (discriminator === "set")
|
|
463
|
+
clauses.push(`"int" (an unbounded integer cursor)`);
|
|
464
|
+
clauses.push(`"cycle" (an enumerated domain stepped in order)`);
|
|
465
|
+
if (discriminator === "persist") {
|
|
466
|
+
clauses.push(
|
|
467
|
+
`"removeSegment" (remove a named segment from the layout)`,
|
|
468
|
+
`"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return clauses;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function VALUE_SOURCE_MESSAGE(discriminator: "set" | "persist"): string {
|
|
475
|
+
const clauses = valueSourceClauses(discriminator);
|
|
476
|
+
const last = clauses[clauses.length - 1]!;
|
|
477
|
+
const list =
|
|
478
|
+
clauses.length === 1
|
|
479
|
+
? last
|
|
480
|
+
: `${clauses.slice(0, -1).join(", ")}, or ${last}`;
|
|
481
|
+
return `a ${discriminator} action declares exactly one value source: ${list}`;
|
|
482
|
+
}
|
|
431
483
|
|
|
432
484
|
// [LAW:dataflow-not-control-flow] The set/persist sub-union eliminator:
|
|
433
485
|
// validate the shared discriminator key, count which value sources are
|
|
@@ -681,6 +733,61 @@ function intMarkerSpec(): FieldSpec<true> {
|
|
|
681
733
|
};
|
|
682
734
|
}
|
|
683
735
|
|
|
736
|
+
// [LAW:one-source-of-truth] A layout op's segment-name field (removeSegment /
|
|
737
|
+
// insertSegment / anchor) is non-empty and free of BOTH wire-structural
|
|
738
|
+
// characters: `/` (the click wire's own multi-arg segment delimiter, the
|
|
739
|
+
// same restriction slashFreeString already enforces for `to`/`cycle`) and
|
|
740
|
+
// `:` (layout-ops.ts's op-token delimiter — a name containing it would make
|
|
741
|
+
// encodeLayoutOp's output ambiguous to decode). One spec, three callsites,
|
|
742
|
+
// so the two-character restriction can't drift between them.
|
|
743
|
+
function layoutNameSpec(field: string): FieldSpec<string> {
|
|
744
|
+
return {
|
|
745
|
+
required: true,
|
|
746
|
+
json: { type: "string" },
|
|
747
|
+
parse: (ctx, path, f, raw) => {
|
|
748
|
+
const v = requireString(ctx, path, raw, f);
|
|
749
|
+
if (v === null) return undefined;
|
|
750
|
+
const at = `${path}.${f}`;
|
|
751
|
+
if (v === "") {
|
|
752
|
+
issue(ctx, at, `${field} must be non-empty (a segment name)`);
|
|
753
|
+
return undefined;
|
|
754
|
+
}
|
|
755
|
+
if (v.includes("/") || v.includes(":")) {
|
|
756
|
+
issue(
|
|
757
|
+
ctx,
|
|
758
|
+
at,
|
|
759
|
+
`${field} "${v}" contains "/" or ":" — segment names in a layout op must be free of both (the click wire's own delimiter and layout-ops.ts's op-token delimiter)`,
|
|
760
|
+
);
|
|
761
|
+
return undefined;
|
|
762
|
+
}
|
|
763
|
+
return v;
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// [LAW:types-are-the-program] `relation` is a closed two-value enum, not a
|
|
769
|
+
// free string — a typo (`"befor"`) is a load error, never a click-time
|
|
770
|
+
// surprise. Mirrors intMarkerSpec's "one legal literal" shape, widened to
|
|
771
|
+
// two.
|
|
772
|
+
function relationSpec(): FieldSpec<"before" | "after"> {
|
|
773
|
+
return {
|
|
774
|
+
required: true,
|
|
775
|
+
json: { enum: ["before", "after"] },
|
|
776
|
+
parse: (ctx, path, field, raw) => {
|
|
777
|
+
const v = raw[field];
|
|
778
|
+
if (v !== "before" && v !== "after") {
|
|
779
|
+
issue(
|
|
780
|
+
ctx,
|
|
781
|
+
`${path}.${field}`,
|
|
782
|
+
`relation must be "before" or "after", got ${describeValue(v)}`,
|
|
783
|
+
);
|
|
784
|
+
return undefined;
|
|
785
|
+
}
|
|
786
|
+
return v;
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
684
791
|
// [LAW:types-are-the-program] A required integer field — the field key (min / max
|
|
685
792
|
// / by) comes from the map, the message names it. A non-integer or absent value
|
|
686
793
|
// reports and fails the arm.
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
actionBindsPersist,
|
|
18
18
|
actionBindsReset,
|
|
19
19
|
actionBindsSet,
|
|
20
|
+
type ActionDecl,
|
|
20
21
|
} from "../action.js";
|
|
21
22
|
import {
|
|
22
23
|
knownOptionDomainNames,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
} from "../option-domain.js";
|
|
25
26
|
import { listGlobalsFieldNames } from "./globals.js";
|
|
26
27
|
import { parsePersistTarget } from "./persist-target.js";
|
|
28
|
+
import { presetNames } from "../presets.js";
|
|
27
29
|
import { findKeyLine } from "./diagnostics.js";
|
|
28
30
|
import { isPlainObject, type ValidateCtx } from "./validate-core.js";
|
|
29
31
|
import {
|
|
@@ -116,11 +118,23 @@ export function validateCrossReferences(
|
|
|
116
118
|
if (target === null) {
|
|
117
119
|
ctx.issues.push({
|
|
118
120
|
path: `actions.${name}.${discriminator}`,
|
|
119
|
-
message: `actions.${name}: "${key}" is not a config globals field (have: ${listGlobalsFieldNames().join(", ")})
|
|
121
|
+
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
122
|
line: findKeyLine(ctx.source, ["actions", name, discriminator]),
|
|
121
123
|
});
|
|
122
124
|
continue;
|
|
123
125
|
}
|
|
126
|
+
if (target.scope === "preset-root-ops") {
|
|
127
|
+
checkPresetRootOpsTarget(
|
|
128
|
+
ctx,
|
|
129
|
+
cfg,
|
|
130
|
+
name,
|
|
131
|
+
discriminator,
|
|
132
|
+
key,
|
|
133
|
+
target.preset,
|
|
134
|
+
a,
|
|
135
|
+
);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
124
138
|
if (target.scope !== "segment-palette") continue;
|
|
125
139
|
if (!Object.prototype.hasOwnProperty.call(cfg.segments, target.segment)) {
|
|
126
140
|
ctx.issues.push({
|
|
@@ -345,6 +359,76 @@ export function validateCrossReferences(
|
|
|
345
359
|
}
|
|
346
360
|
}
|
|
347
361
|
|
|
362
|
+
// [LAW:no-silent-failure] brandon-layout-edit-2gc.1's structural-edit target
|
|
363
|
+
// check, one arm of the persist/reset key cross-ref above. Three things must
|
|
364
|
+
// hold at load time, same spirit as the segment-palette check just above it:
|
|
365
|
+
// the preset name must be real (mirrors globals.preset's check earlier in
|
|
366
|
+
// this function), the arm pairing must make sense for this scope (only
|
|
367
|
+
// removeSegment/insertSegment address a tree — a `to`/`from`/cycle/bounded
|
|
368
|
+
// literal has no meaning as "the current op log"), and every segment name
|
|
369
|
+
// the op names must be declared.
|
|
370
|
+
function checkPresetRootOpsTarget(
|
|
371
|
+
ctx: ValidateCtx,
|
|
372
|
+
cfg: DslConfig,
|
|
373
|
+
name: string,
|
|
374
|
+
discriminator: "persist" | "reset",
|
|
375
|
+
key: string,
|
|
376
|
+
presetName: string,
|
|
377
|
+
a: ActionDecl,
|
|
378
|
+
): void {
|
|
379
|
+
const at = `actions.${name}.${discriminator}`;
|
|
380
|
+
const line = findKeyLine(ctx.source, ["actions", name, discriminator]);
|
|
381
|
+
if (!presetNames(cfg.presets).includes(presetName)) {
|
|
382
|
+
ctx.issues.push({
|
|
383
|
+
path: at,
|
|
384
|
+
message: `actions.${name}: "${key}" names preset "${presetName}" which is not declared (have: ${presetNames(cfg.presets).join(", ")})`,
|
|
385
|
+
line,
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// [LAW:one-source-of-truth] `reset` has no value-source arm to check — its
|
|
390
|
+
// shape is a bare `{ reset: key }` — so the arm-pairing/segment checks
|
|
391
|
+
// below are `persist`-only, exactly as the "reset" action's clean-slate
|
|
392
|
+
// undo is meant to be: it clears the whole op log regardless of what wrote
|
|
393
|
+
// it.
|
|
394
|
+
if (discriminator === "reset") return;
|
|
395
|
+
const hasRemove = "removeSegment" in a;
|
|
396
|
+
const hasInsert = "insertSegment" in a;
|
|
397
|
+
if (!hasRemove && !hasInsert) {
|
|
398
|
+
ctx.issues.push({
|
|
399
|
+
path: at,
|
|
400
|
+
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)`,
|
|
401
|
+
line,
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const missing = (segName: string): boolean =>
|
|
406
|
+
!Object.prototype.hasOwnProperty.call(cfg.segments, segName);
|
|
407
|
+
if (hasRemove && "removeSegment" in a && missing(a.removeSegment)) {
|
|
408
|
+
ctx.issues.push({
|
|
409
|
+
path: at,
|
|
410
|
+
message: `actions.${name}: removeSegment "${a.removeSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
411
|
+
line,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (hasInsert && "insertSegment" in a) {
|
|
415
|
+
if (missing(a.insertSegment)) {
|
|
416
|
+
ctx.issues.push({
|
|
417
|
+
path: at,
|
|
418
|
+
message: `actions.${name}: insertSegment "${a.insertSegment}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
419
|
+
line,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (missing(a.anchor)) {
|
|
423
|
+
ctx.issues.push({
|
|
424
|
+
path: at,
|
|
425
|
+
message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
426
|
+
line,
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
348
432
|
function hasStateKind(cfg: DslConfig): boolean {
|
|
349
433
|
for (const v of Object.values(cfg.variables)) {
|
|
350
434
|
if (v.kind === "state") return true;
|
|
@@ -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
|
);
|
|
@@ -100,7 +100,17 @@ export function coercePersistValue(
|
|
|
100
100
|
`coercePersistValue: "${key}" is not a valid persist target`,
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
|
-
|
|
103
|
+
// [LAW:one-type-per-behavior] Both non-globals scopes are always a NAME/
|
|
104
|
+
// TOKEN string — segment-palette's value is a palette name, preset-root-ops'
|
|
105
|
+
// is one op token appended by the daemon's apply-layout-op verb handler
|
|
106
|
+
// (never a bare `persist` write — see verbs/index.ts). Neither has a
|
|
107
|
+
// GLOBALS_FIELD_KIND row because neither is a Globals field.
|
|
108
|
+
if (
|
|
109
|
+
target.scope === "segment-palette" ||
|
|
110
|
+
target.scope === "preset-root-ops"
|
|
111
|
+
) {
|
|
112
|
+
return raw;
|
|
113
|
+
}
|
|
104
114
|
const kind = GLOBALS_FIELD_KIND[target.field];
|
|
105
115
|
if (kind === "string") return raw;
|
|
106
116
|
if (kind === "number") {
|
|
@@ -136,9 +146,7 @@ function isValidOverrides(
|
|
|
136
146
|
const target = parsePersistTarget(key);
|
|
137
147
|
if (target === null) return false;
|
|
138
148
|
const kind =
|
|
139
|
-
target.scope === "
|
|
140
|
-
? "string"
|
|
141
|
-
: GLOBALS_FIELD_KIND[target.field];
|
|
149
|
+
target.scope === "globals" ? GLOBALS_FIELD_KIND[target.field] : "string";
|
|
142
150
|
if (kind === "number" && typeof v !== "number") return false;
|
|
143
151
|
if (kind === "boolean" && typeof v !== "boolean") return false;
|
|
144
152
|
if (kind === "string" && typeof v !== "string") return false;
|
|
@@ -237,6 +245,46 @@ function projectSegmentPaletteOverrides(
|
|
|
237
245
|
return out;
|
|
238
246
|
}
|
|
239
247
|
|
|
248
|
+
// [LAW:one-source-of-truth] The preset-root-ops-scoped VIEW of the SAME raw
|
|
249
|
+
// dict — preset name -> the accumulated op-token LIST (brandon-layout-edit-
|
|
250
|
+
// 2gc.1's structural-edit log; see src/config/layout-ops.ts). This is a
|
|
251
|
+
// SHAPE check only (well-formed JSON array of strings) — decoding each
|
|
252
|
+
// token into a typed LayoutOp, and applying the ops to a tree, is presets.ts's
|
|
253
|
+
// job, not this storage-layer module's [LAW:decomposition]. A stored value
|
|
254
|
+
// that isn't a JSON array of strings drops for THAT preset only (a warn log,
|
|
255
|
+
// never a crash of the whole overrides file) — the identical "the world
|
|
256
|
+
// moved on since this was written" recovery projectSegmentPaletteOverrides
|
|
257
|
+
// already gets, one level narrower.
|
|
258
|
+
function projectPresetRootOpsOverrides(
|
|
259
|
+
raw: Readonly<Record<string, string | number | boolean>>,
|
|
260
|
+
logger: DaemonLogger,
|
|
261
|
+
): Readonly<Record<string, readonly string[]>> {
|
|
262
|
+
const out: Record<string, readonly string[]> = Object.create(null) as Record<
|
|
263
|
+
string,
|
|
264
|
+
readonly string[]
|
|
265
|
+
>;
|
|
266
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
267
|
+
const target = parsePersistTarget(key);
|
|
268
|
+
if (target?.scope !== "preset-root-ops" || typeof value !== "string") {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const parsed: unknown = JSON.parse(value);
|
|
273
|
+
if (Array.isArray(parsed) && parsed.every((t) => typeof t === "string")) {
|
|
274
|
+
out[target.preset] = parsed;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// fall through to the warn below
|
|
279
|
+
}
|
|
280
|
+
logger(
|
|
281
|
+
"warn",
|
|
282
|
+
`config-overrides: "${key}" is not a valid op-token list, dropping`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
240
288
|
export function loadConfigOverrides(
|
|
241
289
|
filePath: string,
|
|
242
290
|
logger: DaemonLogger = quietLogger,
|
|
@@ -251,14 +299,15 @@ export function loadSegmentPaletteOverrides(
|
|
|
251
299
|
return projectSegmentPaletteOverrides(loadRawOverrides(filePath, logger));
|
|
252
300
|
}
|
|
253
301
|
|
|
254
|
-
// [LAW:carrying-cost] RenderCache wants
|
|
255
|
-
// (buildState merges globals overrides,
|
|
256
|
-
// overrides) — calling
|
|
257
|
-
//
|
|
258
|
-
//
|
|
302
|
+
// [LAW:carrying-cost] RenderCache wants ALL THREE views on every reload
|
|
303
|
+
// (buildState merges globals overrides, overlays segment-palette overrides,
|
|
304
|
+
// then replays preset-root-ops overrides) — calling the scoped loaders back
|
|
305
|
+
// to back would read, parse, and shape-validate the same tiny file three
|
|
306
|
+
// times per reload for no reason. One read, three projections.
|
|
259
307
|
export interface Overrides {
|
|
260
308
|
readonly globals: Partial<Globals>;
|
|
261
309
|
readonly segmentPalette: Readonly<Record<string, string>>;
|
|
310
|
+
readonly presetRootOps: Readonly<Record<string, readonly string[]>>;
|
|
262
311
|
}
|
|
263
312
|
|
|
264
313
|
export function loadOverrides(
|
|
@@ -269,6 +318,7 @@ export function loadOverrides(
|
|
|
269
318
|
return {
|
|
270
319
|
globals: projectGlobalsOverrides(raw),
|
|
271
320
|
segmentPalette: projectSegmentPaletteOverrides(raw),
|
|
321
|
+
presetRootOps: projectPresetRootOpsOverrides(raw, logger),
|
|
272
322
|
};
|
|
273
323
|
}
|
|
274
324
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from "../../config/option-domain";
|
|
18
18
|
import type { DslConfig } from "../../config/dsl-types";
|
|
19
19
|
import { isGlobalsField } from "../config-overrides-store";
|
|
20
|
+
import { encodeLayoutOp } from "../../config/layout-ops";
|
|
20
21
|
import {
|
|
21
22
|
clampSeed,
|
|
22
23
|
createValidatorRegistry,
|
|
@@ -73,6 +74,43 @@ function actionKeySpecs(
|
|
|
73
74
|
if ("cycle" in a) {
|
|
74
75
|
return [{ key: a.persist, spec: { kind: "allow-list", allowed: a.cycle } }];
|
|
75
76
|
}
|
|
77
|
+
// [LAW:single-enforcer] brandon-layout-edit-2gc.1's structural-edit arms:
|
|
78
|
+
// the op is fully literal at config-author time (removeSegment's target,
|
|
79
|
+
// insertSegment's segment/anchor/relation), so — exactly like a literal
|
|
80
|
+
// `to` — there is exactly ONE legal value this declared action can ever
|
|
81
|
+
// request: its own encoded op token. Multiple layout actions targeting the
|
|
82
|
+
// same "presets.<name>.rootOps" key each contribute one allow-list member,
|
|
83
|
+
// unioned by mergeContributions below, same as multiple `to` actions on
|
|
84
|
+
// one key already do.
|
|
85
|
+
if ("removeSegment" in a) {
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
key: a.persist,
|
|
89
|
+
spec: {
|
|
90
|
+
kind: "allow-list",
|
|
91
|
+
allowed: [encodeLayoutOp({ op: "remove", target: a.removeSegment })],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
}
|
|
96
|
+
if ("insertSegment" in a) {
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
key: a.persist,
|
|
100
|
+
spec: {
|
|
101
|
+
kind: "allow-list",
|
|
102
|
+
allowed: [
|
|
103
|
+
encodeLayoutOp({
|
|
104
|
+
op: "insert",
|
|
105
|
+
segment: a.insertSegment,
|
|
106
|
+
anchor: a.anchor,
|
|
107
|
+
relation: a.relation,
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
76
114
|
return [
|
|
77
115
|
{
|
|
78
116
|
key: a.persist,
|
|
@@ -36,12 +36,15 @@ import {
|
|
|
36
36
|
coercePersistValue,
|
|
37
37
|
isGlobalsField,
|
|
38
38
|
loadConfigOverrides,
|
|
39
|
+
loadOverrides,
|
|
39
40
|
writeConfigOverride,
|
|
40
41
|
} from "../config-overrides-store";
|
|
41
42
|
import { configOverridesPath } from "../paths";
|
|
43
|
+
import { parsePersistTarget } from "../../config/loader/persist-target";
|
|
42
44
|
import {
|
|
43
45
|
decodeSegments,
|
|
44
46
|
parseEffects,
|
|
47
|
+
VERB_APPLY_LAYOUT_OP,
|
|
45
48
|
VERB_COPY,
|
|
46
49
|
VERB_DISPATCH,
|
|
47
50
|
VERB_OPEN_VSCODE,
|
|
@@ -444,6 +447,48 @@ const resetConfig: VerbHandler = (value, ctx) => {
|
|
|
444
447
|
ctx.dlog("info", `reset-config: ${key} (session=${sid})`);
|
|
445
448
|
};
|
|
446
449
|
|
|
450
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.1's structural-edit
|
|
451
|
+
// write: a THIRD config-overrides write shape beside setConfig's overwrite
|
|
452
|
+
// and stepConfig's numeric read-modify-write — read the current op-token
|
|
453
|
+
// list at `key`, append the validated op, write the whole list back. Gated
|
|
454
|
+
// by the SAME allow-list machinery setConfig uses (validateConfigWrite,
|
|
455
|
+
// derived from a config's declared removeSegment/insertSegment actions) —
|
|
456
|
+
// an op token no action declares is a loud BAD_REQUEST, never silently
|
|
457
|
+
// appended. `key` must resolve to the preset-root-ops scope specifically
|
|
458
|
+
// (never a globals/segment-palette key smuggled in through this verb) —
|
|
459
|
+
// checked here rather than trusted from the gate, since the gate only
|
|
460
|
+
// proves the VALUE is allowed for that key, not that the key's SCOPE
|
|
461
|
+
// matches this verb's read-modify-write shape.
|
|
462
|
+
const applyLayoutOp: VerbHandler = (rawValue, ctx) => {
|
|
463
|
+
const [sessionId = "", key = "", opToken = ""] = decodeWire(() =>
|
|
464
|
+
decodeSegments(rawValue),
|
|
465
|
+
);
|
|
466
|
+
const sid = requireSessionId(sessionId);
|
|
467
|
+
if (!key) {
|
|
468
|
+
throw new BadVerbArgs(
|
|
469
|
+
`apply-layout-op: <key>/<op> is required (have: ${listConfigKeys().join(", ")})`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
const result = validateConfigWrite(key, opToken);
|
|
473
|
+
if (!result.ok) throw new BadVerbArgs(`apply-layout-op: ${result.reason}`);
|
|
474
|
+
const target = parsePersistTarget(key);
|
|
475
|
+
if (target === null || target.scope !== "preset-root-ops") {
|
|
476
|
+
throw new BadVerbArgs(
|
|
477
|
+
`apply-layout-op: "${key}" is not a "presets.<name>.rootOps" target`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
const existing =
|
|
481
|
+
loadOverrides(configOverridesPath(), ctx.dlog).presetRootOps[
|
|
482
|
+
target.preset
|
|
483
|
+
] ?? [];
|
|
484
|
+
const next = JSON.stringify([...existing, result.value]);
|
|
485
|
+
writeConfigOverride(configOverridesPath(), key, next, ctx.dlog);
|
|
486
|
+
ctx.dlog(
|
|
487
|
+
"info",
|
|
488
|
+
`apply-layout-op: ${key} += ${result.value} (session=${sid})`,
|
|
489
|
+
);
|
|
490
|
+
};
|
|
491
|
+
|
|
447
492
|
// ─── Registry ───────────────────────────────────────────────────────────────
|
|
448
493
|
|
|
449
494
|
// [LAW:one-source-of-truth] The LEAF verbs — every click effect that does real
|
|
@@ -505,6 +550,7 @@ const LEAF_VERBS = new Map<string, VerbHandler>([
|
|
|
505
550
|
[VERB_SET_CONFIG, setConfig],
|
|
506
551
|
[VERB_STEP_CONFIG, stepConfig],
|
|
507
552
|
[VERB_RESET_CONFIG, resetConfig],
|
|
553
|
+
[VERB_APPLY_LAYOUT_OP, applyLayoutOp],
|
|
508
554
|
[VERB_SHOW_CONFIG_ERROR, showConfigError],
|
|
509
555
|
[VERB_SHOW_CONFIG_WARNING, showConfigWarning],
|
|
510
556
|
[VERB_TOOLBAR_TOGGLE, toolbarToggle],
|
|
@@ -536,9 +582,9 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
|
|
|
536
582
|
let sessionId: string | null = null;
|
|
537
583
|
for (const { verb, value } of parseEffects(rawValue)) {
|
|
538
584
|
// Extract session ID from the first session-bearing effect for error display.
|
|
539
|
-
// set-state, step-state, set-config, step-config, reset-config,
|
|
540
|
-
// toolbar-toggle all carry the session id as their
|
|
541
|
-
// failing step surfaces in the bar like any other.
|
|
585
|
+
// set-state, step-state, set-config, step-config, reset-config,
|
|
586
|
+
// apply-layout-op, and toolbar-toggle all carry the session id as their
|
|
587
|
+
// first segment, so a failing step surfaces in the bar like any other.
|
|
542
588
|
if (
|
|
543
589
|
!sessionId &&
|
|
544
590
|
(verb === VERB_SET_STATE ||
|
|
@@ -546,6 +592,7 @@ const dispatch: VerbHandler = (rawValue, ctx) => {
|
|
|
546
592
|
verb === VERB_SET_CONFIG ||
|
|
547
593
|
verb === VERB_STEP_CONFIG ||
|
|
548
594
|
verb === VERB_RESET_CONFIG ||
|
|
595
|
+
verb === VERB_APPLY_LAYOUT_OP ||
|
|
549
596
|
verb === VERB_TOOLBAR_TOGGLE)
|
|
550
597
|
) {
|
|
551
598
|
const parts = decodeSegments(value);
|