@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
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
22
|
ACTION_KEYS,
|
|
23
|
+
PERSIST_WHEN,
|
|
23
24
|
type ActionDecl,
|
|
24
25
|
type ActionKey,
|
|
25
26
|
type OptionDomain,
|
|
@@ -103,6 +104,16 @@ function validateActionDecl(
|
|
|
103
104
|
);
|
|
104
105
|
return null;
|
|
105
106
|
}
|
|
107
|
+
// [LAW:dataflow-not-control-flow] A dual-destination action
|
|
108
|
+
// (candybar-settings-ui-aok.3) carries BOTH `set` and `persist`, so it
|
|
109
|
+
// cannot be reached through the exactly-one-of eliminator below —
|
|
110
|
+
// `persistWhen` is its own discriminator, and its presence selects the arm
|
|
111
|
+
// exactly as the presence of `set` selects that one. The dual arm owns its
|
|
112
|
+
// own siblings (the two destination keys plus its value source), like every
|
|
113
|
+
// other arm here.
|
|
114
|
+
if (PERSIST_WHEN in raw) {
|
|
115
|
+
return valueSourceAction(ctx, path, raw, "dual", DUAL_ARMS);
|
|
116
|
+
}
|
|
106
117
|
const present = (ACTION_KEYS as readonly string[]).filter((k) => k in raw);
|
|
107
118
|
if (present.length !== 1) {
|
|
108
119
|
issue(
|
|
@@ -124,24 +135,9 @@ function validateActionDecl(
|
|
|
124
135
|
// sibling. The present key indexes this map — the eliminator never branches on
|
|
125
136
|
// the key name.
|
|
126
137
|
const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
|
|
127
|
-
set: (ctx, path, raw) =>
|
|
128
|
-
valueSourceAction(
|
|
129
|
-
ctx,
|
|
130
|
-
path,
|
|
131
|
-
raw,
|
|
132
|
-
"set",
|
|
133
|
-
SET_ARMS,
|
|
134
|
-
"the SessionState key to write",
|
|
135
|
-
),
|
|
138
|
+
set: (ctx, path, raw) => valueSourceAction(ctx, path, raw, "set", SET_ARMS),
|
|
136
139
|
persist: (ctx, path, raw) =>
|
|
137
|
-
valueSourceAction(
|
|
138
|
-
ctx,
|
|
139
|
-
path,
|
|
140
|
-
raw,
|
|
141
|
-
"persist",
|
|
142
|
-
PERSIST_ARMS,
|
|
143
|
-
"the config globals field to write",
|
|
144
|
-
),
|
|
140
|
+
valueSourceAction(ctx, path, raw, "persist", PERSIST_ARMS),
|
|
145
141
|
copy: templateArm("copy"),
|
|
146
142
|
open: templateArm("open"),
|
|
147
143
|
reset: resetArm,
|
|
@@ -169,6 +165,7 @@ function actionDeclJson(): JsonNode {
|
|
|
169
165
|
anyOf: [
|
|
170
166
|
...SET_ARMS.map((arm) => arm.json),
|
|
171
167
|
...PERSIST_ARMS.map((arm) => arm.json),
|
|
168
|
+
...DUAL_ARMS.map((arm) => arm.json),
|
|
172
169
|
templateArmJson("copy"),
|
|
173
170
|
templateArmJson("open"),
|
|
174
171
|
templateArmJson("reset"),
|
|
@@ -305,37 +302,100 @@ function slashFreeString(
|
|
|
305
302
|
return v;
|
|
306
303
|
}
|
|
307
304
|
|
|
308
|
-
// [LAW:
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
305
|
+
// [LAW:types-are-the-program] Which KEYS a value-source action carries beside
|
|
306
|
+
// its value source, as data — one for a single-destination `set`/`persist`,
|
|
307
|
+
// three for a `dual` (both destination keys plus the selector naming which is
|
|
308
|
+
// written). Every consumer below (the key validation, the unknown-key
|
|
309
|
+
// allow-list, the emitted JSON schema, the reconstructed member) reads this
|
|
310
|
+
// one table, so adding the dual arm never meant a second dispatcher: the
|
|
311
|
+
// discriminator stopped being ONE key and became a LIST of them, and the
|
|
312
|
+
// existing machinery folds over the list [LAW:dataflow-not-control-flow].
|
|
313
|
+
type Discriminator = "set" | "persist" | "dual";
|
|
314
|
+
|
|
315
|
+
const DISCRIMINATOR_KEYS: Readonly<
|
|
316
|
+
Record<Discriminator, ReadonlyArray<readonly [string, string]>>
|
|
317
|
+
> = {
|
|
318
|
+
set: [["set", "the SessionState key to write"]],
|
|
319
|
+
persist: [["persist", "the config globals field to write"]],
|
|
320
|
+
dual: [
|
|
321
|
+
["set", "the SessionState key written while persistWhen is off"],
|
|
322
|
+
["persist", "the config globals field written while persistWhen is on"],
|
|
323
|
+
[
|
|
324
|
+
PERSIST_WHEN,
|
|
325
|
+
"the SessionState key whose boolean value chooses the destination",
|
|
326
|
+
],
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
// [LAW:dataflow-not-control-flow] The discriminator keys are validated once
|
|
331
|
+
// for every value source (they are shared across all arms of that
|
|
332
|
+
// discriminator), before the source is detected — so a bad key and an
|
|
333
|
+
// ambiguous source both surface in one pass. They are therefore NOT fields of
|
|
312
334
|
// any arm's `fields` map; the arm parses only the value-source payload, and
|
|
313
|
-
// the dispatcher re-attaches
|
|
314
|
-
|
|
335
|
+
// the dispatcher re-attaches them.
|
|
336
|
+
//
|
|
337
|
+
// [LAW:no-silent-failure] Returns null when ANY key fails, after reporting
|
|
338
|
+
// every one of them — the caller threads that null exactly as it threads a
|
|
339
|
+
// failed payload, so a partly-valid dual never reconstructs into a member
|
|
340
|
+
// missing a destination.
|
|
341
|
+
function validateDiscriminatorKeys(
|
|
315
342
|
ctx: ValidateCtx,
|
|
316
343
|
path: string,
|
|
317
344
|
raw: Record<string, unknown>,
|
|
318
|
-
discriminator:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
345
|
+
discriminator: Discriminator,
|
|
346
|
+
): Record<string, string> | null {
|
|
347
|
+
const out: Record<string, string> = {};
|
|
348
|
+
const keys = DISCRIMINATOR_KEYS[discriminator];
|
|
349
|
+
let ok = true;
|
|
350
|
+
for (const [key, noun] of keys) {
|
|
351
|
+
// [LAW:no-silent-failure] An ABSENT key gets the shape, not a type
|
|
352
|
+
// mismatch. A single-destination arm cannot reach this (its key is the
|
|
353
|
+
// discriminator that selected the arm), so this only ever fires on a dual
|
|
354
|
+
// that named one destination and not the other — where "persist must be a
|
|
355
|
+
// string, got undefined" describes the symptom and teaches nothing, and
|
|
356
|
+
// the author needs to be told the three keys travel together.
|
|
357
|
+
if (!(key in raw)) {
|
|
358
|
+
issue(
|
|
359
|
+
ctx,
|
|
360
|
+
path,
|
|
361
|
+
`${key} is required here (${noun}) — a dual-destination action declares ${keys
|
|
362
|
+
.map(([k]) => k)
|
|
363
|
+
.join(", ")} together, plus one value source`,
|
|
364
|
+
);
|
|
365
|
+
ok = false;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const value = slashFreeString(
|
|
369
|
+
ctx,
|
|
370
|
+
path,
|
|
371
|
+
key,
|
|
372
|
+
raw,
|
|
373
|
+
`${key} key must be non-empty (${noun})`,
|
|
374
|
+
(v) => `${key} key "${v}" contains "/" — keys must be slash-free`,
|
|
375
|
+
);
|
|
376
|
+
if (value === null) {
|
|
377
|
+
ok = false;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
out[key] = value;
|
|
381
|
+
}
|
|
382
|
+
// [LAW:no-silent-failure] Every failing key is reported before returning, so
|
|
383
|
+
// an author who omits two of a dual's three keys sees both in one pass —
|
|
384
|
+
// matching every other multi-issue check in this file, and matching what the
|
|
385
|
+
// comment above promises.
|
|
386
|
+
return ok ? out : null;
|
|
329
387
|
}
|
|
330
388
|
|
|
331
389
|
// [LAW:one-source-of-truth] The wire verb name a discriminator's writes
|
|
332
390
|
// travel over — `set-state` for `set` (SessionState), `set-config` for
|
|
333
|
-
// `persist` (the config-overrides layer)
|
|
334
|
-
//
|
|
335
|
-
//
|
|
391
|
+
// `persist` (the config-overrides layer), and BOTH for a dual, whose one
|
|
392
|
+
// value crosses whichever wire the selector names. Threaded into the shared
|
|
393
|
+
// field specs below so their "cannot be delivered on the X wire" messages
|
|
394
|
+
// name the wire the value actually crosses, and the field/value noun ("set
|
|
336
395
|
// value" / "persist value") names the actual action kind, not always `set`.
|
|
337
|
-
function wireName(discriminator:
|
|
338
|
-
|
|
396
|
+
function wireName(discriminator: Discriminator): string {
|
|
397
|
+
if (discriminator === "set") return "set-state";
|
|
398
|
+
return discriminator === "persist" ? "set-config" : "set-state/set-config";
|
|
339
399
|
}
|
|
340
400
|
|
|
341
401
|
// [LAW:types-are-the-program] Each value source's payload as a field map — the
|
|
@@ -366,6 +426,15 @@ const INT_FIELDS: FieldSpecMap<{ int: true }> = { int: intMarkerSpec() };
|
|
|
366
426
|
const CYCLE_FIELDS_SET: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
367
427
|
cycle: cycleSpec("set"),
|
|
368
428
|
};
|
|
429
|
+
const TO_FIELDS_DUAL: FieldSpecMap<{ to: string }> = {
|
|
430
|
+
to: setLiteralSpec("dual"),
|
|
431
|
+
};
|
|
432
|
+
const FROM_FIELDS_DUAL: FieldSpecMap<{ from: OptionDomain }> = {
|
|
433
|
+
from: fromSpec("dual"),
|
|
434
|
+
};
|
|
435
|
+
const CYCLE_FIELDS_DUAL: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
436
|
+
cycle: cycleSpec("dual"),
|
|
437
|
+
};
|
|
369
438
|
const CYCLE_FIELDS_PERSIST: FieldSpecMap<{ cycle: readonly string[] }> = {
|
|
370
439
|
cycle: cycleSpec("persist"),
|
|
371
440
|
};
|
|
@@ -466,13 +535,14 @@ interface ValueSourceArm {
|
|
|
466
535
|
// discriminator (the field no sibling arm carries) here; omit it when the
|
|
467
536
|
// field set already is disjoint from every sibling, as it is everywhere else.
|
|
468
537
|
function valueSourceArm<P extends object>(
|
|
469
|
-
discriminator:
|
|
538
|
+
discriminator: Discriminator,
|
|
470
539
|
fieldMap: FieldSpecMap<P>,
|
|
471
540
|
checks: ReadonlyArray<Refinement<P>> = [],
|
|
472
541
|
detectKeys?: readonly string[],
|
|
473
542
|
): ValueSourceArm {
|
|
474
543
|
const fullKeys = Object.keys(fieldMap);
|
|
475
544
|
const detect = detectKeys ?? fullKeys;
|
|
545
|
+
const keys = DISCRIMINATOR_KEYS[discriminator].map(([k]) => k);
|
|
476
546
|
const inner: ArmParse<P> = (ctx, path, raw) =>
|
|
477
547
|
fields(ctx, fieldMap, path, raw);
|
|
478
548
|
const source = objectJson(fieldMap) as {
|
|
@@ -481,12 +551,15 @@ function valueSourceArm<P extends object>(
|
|
|
481
551
|
};
|
|
482
552
|
return {
|
|
483
553
|
detect,
|
|
484
|
-
allowed: [
|
|
554
|
+
allowed: [...keys, ...fullKeys],
|
|
485
555
|
label: fullKeys.join("/"),
|
|
486
556
|
json: {
|
|
487
557
|
type: "object",
|
|
488
|
-
properties: {
|
|
489
|
-
|
|
558
|
+
properties: {
|
|
559
|
+
...Object.fromEntries(keys.map((k) => [k, { type: "string" }])),
|
|
560
|
+
...source.properties,
|
|
561
|
+
},
|
|
562
|
+
required: [...keys, ...(source.required ?? [])],
|
|
490
563
|
additionalProperties: false,
|
|
491
564
|
},
|
|
492
565
|
parse: (checks.length
|
|
@@ -531,12 +604,25 @@ const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
|
531
604
|
),
|
|
532
605
|
];
|
|
533
606
|
|
|
607
|
+
// [LAW:one-type-per-behavior] A dual declares any value source BOTH
|
|
608
|
+
// destinations share — `set` minus `int` (a page cursor has no durable
|
|
609
|
+
// meaning), which is also `persist` minus its structural-edit arms (those are
|
|
610
|
+
// persist-only by design, so they have no destination to choose between).
|
|
611
|
+
// The field maps are the SET ones with dual wording, so a dual's value obeys
|
|
612
|
+
// exactly the shape a `set` and a `persist` of that source each obey.
|
|
613
|
+
const DUAL_ARMS: readonly ValueSourceArm[] = [
|
|
614
|
+
valueSourceArm("dual", TO_FIELDS_DUAL),
|
|
615
|
+
valueSourceArm("dual", FROM_FIELDS_DUAL),
|
|
616
|
+
valueSourceArm("dual", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
617
|
+
valueSourceArm("dual", CYCLE_FIELDS_DUAL),
|
|
618
|
+
];
|
|
619
|
+
|
|
534
620
|
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
535
621
|
// data that varies per discriminator — the "or" belongs on the LAST clause
|
|
536
622
|
// only, and which clause is last differs between `set` (ends at cycle) and
|
|
537
623
|
// `persist` (ends at insertSegment), so building a list and joining it is
|
|
538
624
|
// what keeps that placement correct without a second copy of the sentence.
|
|
539
|
-
function valueSourceClauses(discriminator:
|
|
625
|
+
function valueSourceClauses(discriminator: Discriminator): string[] {
|
|
540
626
|
const clauses = [
|
|
541
627
|
`"to" (a literal value)`,
|
|
542
628
|
`"from" (an option domain — a registered domain name like "themes"/"styles"/"looks", or an inline array of literal values)`,
|
|
@@ -555,7 +641,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
|
|
|
555
641
|
return clauses;
|
|
556
642
|
}
|
|
557
643
|
|
|
558
|
-
function VALUE_SOURCE_MESSAGE(discriminator:
|
|
644
|
+
function VALUE_SOURCE_MESSAGE(discriminator: Discriminator): string {
|
|
559
645
|
const clauses = valueSourceClauses(discriminator);
|
|
560
646
|
const last = clauses[clauses.length - 1]!;
|
|
561
647
|
const list =
|
|
@@ -576,17 +662,10 @@ function valueSourceAction(
|
|
|
576
662
|
ctx: ValidateCtx,
|
|
577
663
|
path: string,
|
|
578
664
|
raw: Record<string, unknown>,
|
|
579
|
-
discriminator:
|
|
665
|
+
discriminator: Discriminator,
|
|
580
666
|
arms: readonly ValueSourceArm[],
|
|
581
|
-
keyNoun: string,
|
|
582
667
|
): ActionDecl | null {
|
|
583
|
-
const
|
|
584
|
-
ctx,
|
|
585
|
-
path,
|
|
586
|
-
raw,
|
|
587
|
-
discriminator,
|
|
588
|
-
keyNoun,
|
|
589
|
-
);
|
|
668
|
+
const keys = validateDiscriminatorKeys(ctx, path, raw, discriminator);
|
|
590
669
|
|
|
591
670
|
const present = arms.filter((arm) => arm.detect.some((k) => k in raw));
|
|
592
671
|
if (present.length !== 1) {
|
|
@@ -613,9 +692,9 @@ function valueSourceAction(
|
|
|
613
692
|
}
|
|
614
693
|
|
|
615
694
|
const payload = arm.parse(ctx, path, raw);
|
|
616
|
-
return
|
|
695
|
+
return keys === null || payload === null
|
|
617
696
|
? null
|
|
618
|
-
: ({
|
|
697
|
+
: ({ ...keys, ...payload } as unknown as ActionDecl);
|
|
619
698
|
}
|
|
620
699
|
|
|
621
700
|
// [LAW:no-silent-fallbacks] A literal `to` and the discriminator key share
|
|
@@ -624,7 +703,7 @@ function valueSourceAction(
|
|
|
624
703
|
// arm's, the shape is the shared enforcer's. Built once per discriminator
|
|
625
704
|
// (see TO_FIELDS_SET/TO_FIELDS_PERSIST) so a `persist` action's message names
|
|
626
705
|
// "persist value" and the set-config wire, never `set`'s wording.
|
|
627
|
-
function setLiteralSpec(discriminator:
|
|
706
|
+
function setLiteralSpec(discriminator: Discriminator): FieldSpec<string> {
|
|
628
707
|
const wire = wireName(discriminator);
|
|
629
708
|
return {
|
|
630
709
|
required: true,
|
|
@@ -654,7 +733,7 @@ function setLiteralSpec(discriminator: "set" | "persist"): FieldSpec<string> {
|
|
|
654
733
|
// symmetric to how a layout node's segment ref or a `{{ action }}` ref
|
|
655
734
|
// resolves post-merge. Built once per discriminator, same reason as
|
|
656
735
|
// setLiteralSpec.
|
|
657
|
-
function fromSpec(discriminator:
|
|
736
|
+
function fromSpec(discriminator: Discriminator): FieldSpec<OptionDomain> {
|
|
658
737
|
const wire = wireName(discriminator);
|
|
659
738
|
return {
|
|
660
739
|
required: true,
|
|
@@ -733,9 +812,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
733
812
|
// duplicated member is ambiguous). Members double as the derived allow-list
|
|
734
813
|
// gate, so a member this spec admits is a value the wire delivers, by
|
|
735
814
|
// construction. Built once per discriminator, same reason as setLiteralSpec.
|
|
736
|
-
function cycleSpec(
|
|
737
|
-
discriminator: "set" | "persist",
|
|
738
|
-
): FieldSpec<readonly string[]> {
|
|
815
|
+
function cycleSpec(discriminator: Discriminator): FieldSpec<readonly string[]> {
|
|
739
816
|
const wire = wireName(discriminator);
|
|
740
817
|
return {
|
|
741
818
|
required: true,
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
actionBindsRedo,
|
|
20
20
|
actionBindsReset,
|
|
21
21
|
actionBindsSet,
|
|
22
|
+
actionIsDual,
|
|
23
|
+
PERSIST_WHEN,
|
|
22
24
|
actionBindsUndo,
|
|
23
25
|
type ActionDecl,
|
|
24
26
|
} from "../action.js";
|
|
@@ -160,6 +162,30 @@ export function validateCrossReferences(
|
|
|
160
162
|
});
|
|
161
163
|
}
|
|
162
164
|
}
|
|
165
|
+
// [LAW:no-silent-failure] A dual action's `persistWhen` must name a key some
|
|
166
|
+
// `state` variable declares. The structural pass proves only that it is a
|
|
167
|
+
// deliverable wire key; whether it RESOLVES is a cross-ref concern, exactly
|
|
168
|
+
// as `from`'s domain is above.
|
|
169
|
+
//
|
|
170
|
+
// Without this, a typo loads perfectly cleanly and then does nothing
|
|
171
|
+
// forever: compileDual falls back to the raw key name, activeDestination
|
|
172
|
+
// reads it, finds nothing, and `parseSessionBoolean` answers null — which
|
|
173
|
+
// means "session". So the checkbox the author wired to the real key flips a
|
|
174
|
+
// value no control reads, the destination never changes, and there is no
|
|
175
|
+
// error anywhere to explain why. A silently-permanent session write is the
|
|
176
|
+
// worst possible shape for this failure, since it looks exactly like
|
|
177
|
+
// working software.
|
|
178
|
+
for (const [name, a] of Object.entries(cfg.actions)) {
|
|
179
|
+
if (!actionIsDual(a)) continue;
|
|
180
|
+
const selector = a[PERSIST_WHEN];
|
|
181
|
+
if (!declaresStateKey(cfg, selector)) {
|
|
182
|
+
ctx.issues.push({
|
|
183
|
+
path: `actions.${name}.${PERSIST_WHEN}`,
|
|
184
|
+
message: `actions.${name} ${PERSIST_WHEN}: "${selector}" is not a declared state key — a dual action's selector must name a { kind: "state", key: "${selector}" } variable, or the destination can never change`,
|
|
185
|
+
line: findKeyLine(ctx.source, ["actions", name, PERSIST_WHEN]),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
163
189
|
// [LAW:no-silent-failure] A `persist`/`reset` target must name a REAL
|
|
164
190
|
// Globals field OR a declared segment's `palette` (candybar-config-engine-
|
|
165
191
|
// 71o.6 — `segments.<name>.palette`, parsed by the one shared authority in
|
|
@@ -537,17 +563,29 @@ function checkPresetRootOpsTarget(
|
|
|
537
563
|
}
|
|
538
564
|
}
|
|
539
565
|
|
|
566
|
+
// [LAW:one-source-of-truth] Every `state` variable a config declares, in BOTH
|
|
567
|
+
// scopes — global `variables` and each segment's own `vars`. A segment-local
|
|
568
|
+
// state variable is fully legitimate: src/dsl/render.ts's stateKeyToVar
|
|
569
|
+
// registers them (as `<segment>.<var>`) and it is the map a dual's selector is
|
|
570
|
+
// resolved through at render, so a load-time check that scanned only the
|
|
571
|
+
// global scope would reject configs that work.
|
|
572
|
+
function stateVars(cfg: DslConfig): VariableDecl[] {
|
|
573
|
+
return [
|
|
574
|
+
...Object.values(cfg.variables),
|
|
575
|
+
...Object.values(cfg.segments).flatMap((seg) =>
|
|
576
|
+
Object.values(seg.vars ?? {}),
|
|
577
|
+
),
|
|
578
|
+
].filter((v) => v.kind === "state");
|
|
579
|
+
}
|
|
580
|
+
|
|
540
581
|
function hasStateKind(cfg: DslConfig): boolean {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
return false;
|
|
582
|
+
return stateVars(cfg).length > 0;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Does any declared `state` variable hold this key? The question a dual's
|
|
586
|
+
// `persistWhen` selector has to answer, asked over the same two scopes.
|
|
587
|
+
function declaresStateKey(cfg: DslConfig, key: string): boolean {
|
|
588
|
+
return stateVars(cfg).some((v) => v.kind === "state" && v.key === key);
|
|
551
589
|
}
|
|
552
590
|
|
|
553
591
|
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
@@ -7,6 +7,8 @@ import { type Globals } from "../dsl-types.js";
|
|
|
7
7
|
import {
|
|
8
8
|
CHARSETS,
|
|
9
9
|
COLOR_COMPATIBILITIES,
|
|
10
|
+
DEFAULT_PADDING,
|
|
11
|
+
PADDING_RANGE,
|
|
10
12
|
STRIP_STYLES,
|
|
11
13
|
type ColorCompatibility,
|
|
12
14
|
} from "../../themes/policy.js";
|
|
@@ -84,7 +86,11 @@ const GLOBALS_FIELDS: FieldSpecMap<Globals> = {
|
|
|
84
86
|
// drive an unbounded `" ".repeat` allocation in the daemon
|
|
85
87
|
// [LAW:no-silent-failure] — an absurd value is a loud load error, not a
|
|
86
88
|
// silently-huge render.
|
|
87
|
-
|
|
89
|
+
// [LAW:one-source-of-truth] The bound comes from PADDING_RANGE, the same
|
|
90
|
+
// literal the bundled default's stepper actions bound clicks by and the
|
|
91
|
+
// session-half parse admits values from — so the file, the click, and the
|
|
92
|
+
// session pick cannot end up honouring three different ranges.
|
|
93
|
+
padding: optionalIntSpec(PADDING_RANGE),
|
|
88
94
|
// [LAW:types-are-the-program] Closed enum like `style`: the joiner glyph
|
|
89
95
|
// vocabularies pickJoiner can render — validates by membership, emits a
|
|
90
96
|
// JSON-Schema `enum` from the same CHARSETS literal.
|
|
@@ -188,3 +194,42 @@ export function isGlobalsField(key: string): key is keyof Globals {
|
|
|
188
194
|
export function listGlobalsFieldNames(): readonly string[] {
|
|
189
195
|
return [...GLOBALS_FIELD_NAMES];
|
|
190
196
|
}
|
|
197
|
+
|
|
198
|
+
// [LAW:types-are-the-program] Every NUMERIC globals field with the value that
|
|
199
|
+
// renders when a config declares none, typed TOTAL over those fields — so
|
|
200
|
+
// adding a numeric globals field without a floor is a COMPILE error, not a
|
|
201
|
+
// silent seed-from-`min` (the bug this table exists to prevent, recurring for
|
|
202
|
+
// the new field). It lives here rather than beside DEFAULT_PADDING in
|
|
203
|
+
// themes/policy.ts because it must name `Globals`, and policy.ts is the leaf
|
|
204
|
+
// dsl-types imports FROM — a table that needs both belongs on this side of
|
|
205
|
+
// that edge [LAW:one-way-deps].
|
|
206
|
+
// The direction matters: `NonNullable<Globals[K]> extends number` asks "is this
|
|
207
|
+
// field's type a number", which is what the totality claim needs. The mirror
|
|
208
|
+
// (`number extends …`) happens to agree for a field declared as plain `number`
|
|
209
|
+
// — the two are symmetric there — but silently drops a field typed as a
|
|
210
|
+
// literal union (`zoom?: 1 | 2 | 3`), which is a perfectly ordinary way to
|
|
211
|
+
// declare a bounded numeric setting and exactly the case this guard exists for.
|
|
212
|
+
type NumericGlobalsField = {
|
|
213
|
+
[K in keyof Globals]-?: NonNullable<Globals[K]> extends number ? K : never;
|
|
214
|
+
}[keyof Globals];
|
|
215
|
+
|
|
216
|
+
const NUMERIC_GLOBALS_FLOORS: Readonly<Record<NumericGlobalsField, number>> = {
|
|
217
|
+
padding: DEFAULT_PADDING,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// [LAW:single-enforcer] THE seed for a bounded stepper over a globals field:
|
|
221
|
+
// what the bar renders with no write of any kind — the config's own value, or
|
|
222
|
+
// the field's floor when it declares none. Both write gates read it (the
|
|
223
|
+
// SessionState one through stateKeySeeds, the config-overrides one through
|
|
224
|
+
// configKeySeeds), so a session stepper and its durable twin can never start
|
|
225
|
+
// from different numbers, and neither can silently start from `min`.
|
|
226
|
+
export function numericGlobalsSeeds(
|
|
227
|
+
globals: Globals,
|
|
228
|
+
): ReadonlyMap<string, number> {
|
|
229
|
+
const seeds = new Map<string, number>();
|
|
230
|
+
for (const [field, floor] of Object.entries(NUMERIC_GLOBALS_FLOORS)) {
|
|
231
|
+
const declared = globals[field as keyof Globals];
|
|
232
|
+
seeds.set(field, typeof declared === "number" ? declared : floor);
|
|
233
|
+
}
|
|
234
|
+
return seeds;
|
|
235
|
+
}
|
package/src/config/presets.ts
CHANGED
|
@@ -168,9 +168,14 @@ export function presetGlobals(config: DslConfig, name: string): Globals {
|
|
|
168
168
|
// that is a typo in a file the author can see and fix — but a value that
|
|
169
169
|
// only exists because the overrides layer supplied it gets the SAME
|
|
170
170
|
// treatment a stale SessionState pick already gets from effectivePresetName:
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
171
|
+
// an undeclared name is not a pick at all, so the resolution moves on to the
|
|
172
|
+
// next rung of the precedence chain — visibly, never a throw. (For a stale
|
|
173
|
+
// SESSION pick that next rung is the config's own `globals.preset`, and only
|
|
174
|
+
// then the floor; for the field dropped here it is whatever the config file
|
|
175
|
+
// itself declares. Same rule, different starting rung —
|
|
176
|
+
// see effectiveGlobal in themes/policy.ts.) Dropping the field here (so it
|
|
177
|
+
// never reaches validateConfig) is what applies that rule to this layer too,
|
|
178
|
+
// instead of a fatal error replacing the whole bar.
|
|
174
179
|
export function sanitizePersistedPresetOverride(
|
|
175
180
|
globalsOverride: Partial<Globals>,
|
|
176
181
|
declaredPresets: Readonly<Record<string, PresetDecl>>,
|