@promptctl/cc-candybar 1.37.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/click/wire.ts +10 -1
- package/src/config/action.ts +110 -1
- package/src/config/default-dsl-config.ts +64 -243
- package/src/config/loader/actions.ts +138 -61
- package/src/config/loader/cross-ref.ts +48 -10
- package/src/config/loader/globals.ts +40 -0
- package/src/config/settings-menu.ts +338 -16
- 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/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/themes/policy.ts +21 -4
package/src/render/action.ts
CHANGED
|
@@ -25,10 +25,15 @@ import type { FuncMap, Template } from "@promptctl/go-template-js";
|
|
|
25
25
|
import type { VariableStore } from "../var-system/store.js";
|
|
26
26
|
import { toString as varToString } from "../var-system/types.js";
|
|
27
27
|
import { buildScope } from "../template-engine/scope.js";
|
|
28
|
-
import
|
|
28
|
+
import {
|
|
29
|
+
actionDestinations,
|
|
30
|
+
actionIsDual,
|
|
31
|
+
PERSIST_WHEN,
|
|
32
|
+
type ActionDecl,
|
|
33
|
+
} from "../config/action.js";
|
|
29
34
|
import { resolveOptionDomain } from "../config/option-domain.js";
|
|
30
35
|
import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
|
|
31
|
-
import type
|
|
36
|
+
import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
|
|
32
37
|
import {
|
|
33
38
|
effectsUrl,
|
|
34
39
|
VERB_APPLY_LAYOUT_OP,
|
|
@@ -166,7 +171,24 @@ export type CompiledActionDecl =
|
|
|
166
171
|
// there is nothing to carry, since the history stack (not this action) is
|
|
167
172
|
// what decides which entry moves.
|
|
168
173
|
| { readonly kind: "undo" }
|
|
169
|
-
| { readonly kind: "redo" }
|
|
174
|
+
| { readonly kind: "redo" }
|
|
175
|
+
// [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.3's ONE control
|
|
176
|
+
// per setting. Both destinations are compiled here as the ordinary
|
|
177
|
+
// single-destination shapes they are, and `selector` names the session key
|
|
178
|
+
// whose boolean value picks between them at click time. The destination is
|
|
179
|
+
// therefore a VALUE flowing through `activeDestination` — every consumer
|
|
180
|
+
// (realize, the picker, selectDisplay) resolves it once at the top and then
|
|
181
|
+
// runs the code it has always run, so nothing downstream branches on
|
|
182
|
+
// "is this dual".
|
|
183
|
+
| {
|
|
184
|
+
readonly kind: "dual";
|
|
185
|
+
readonly selector: string;
|
|
186
|
+
readonly session: CompiledActionDecl;
|
|
187
|
+
readonly durable: CompiledActionDecl;
|
|
188
|
+
// The SessionState key the session half writes, carried so a durable
|
|
189
|
+
// click can clear it in the same dispatch (see realize's dual arm).
|
|
190
|
+
readonly sessionKey: string;
|
|
191
|
+
};
|
|
170
192
|
|
|
171
193
|
export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
172
194
|
|
|
@@ -185,6 +207,13 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
|
185
207
|
// highlight is inert (readVar falls back to "" since no such var exists).
|
|
186
208
|
const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
|
|
187
209
|
["palette", "theme.effective"],
|
|
210
|
+
// [LAW:one-source-of-truth] `preset` earns its entry here the moment a DUAL
|
|
211
|
+
// control writes it: compileDual makes BOTH halves read back through this
|
|
212
|
+
// map, so a field missing from it loses its current-selection mark on the
|
|
213
|
+
// session side too — and the preset picker sits on the settings menu's
|
|
214
|
+
// always-visible first row, where "which arrangement am I in" is the whole
|
|
215
|
+
// question the control answers.
|
|
216
|
+
["preset", "preset.effective"],
|
|
188
217
|
["look", "look.effective"],
|
|
189
218
|
["style", "style.effective"],
|
|
190
219
|
["charset", "charset.effective"],
|
|
@@ -263,6 +292,20 @@ function compileAction(
|
|
|
263
292
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
264
293
|
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
265
294
|
): CompiledActionDecl {
|
|
295
|
+
// [LAW:one-source-of-truth] A dual compiles as its own two destinations —
|
|
296
|
+
// the SAME explosion the validator derivations fold over
|
|
297
|
+
// (actionDestinations), so the click a dual realizes and the gate it derives
|
|
298
|
+
// come from one statement of what the two halves are. It is matched BEFORE
|
|
299
|
+
// the `set` arm because a dual carries `set` too.
|
|
300
|
+
if (actionIsDual(action)) {
|
|
301
|
+
const [session, durable] = actionDestinations(action);
|
|
302
|
+
return compileDual(
|
|
303
|
+
stateKeyToVar.get(action[PERSIST_WHEN]) ?? action[PERSIST_WHEN],
|
|
304
|
+
action.set,
|
|
305
|
+
compileAction(parse, name, session!, stateKeyToVar, perConfigDomains),
|
|
306
|
+
compileAction(parse, name, durable!, stateKeyToVar, perConfigDomains),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
266
309
|
if ("set" in action) {
|
|
267
310
|
const stateVar = stateKeyToVar.get(action.set) ?? action.set;
|
|
268
311
|
if ("to" in action) {
|
|
@@ -379,6 +422,55 @@ function compileAction(
|
|
|
379
422
|
return "undo" in action ? { kind: "undo" } : { kind: "redo" };
|
|
380
423
|
}
|
|
381
424
|
|
|
425
|
+
// [LAW:one-source-of-truth] A dual control shows ONE current value and writes
|
|
426
|
+
// relative to the value it showed — so both destinations read back through the
|
|
427
|
+
// DURABLE half's variable, which is the `.effective` projection the daemon
|
|
428
|
+
// resolved for this render (CONFIG_KEY_TO_EFFECTIVE_VAR above): the value the
|
|
429
|
+
// bar is actually rendering with, whatever chain produced it. Reading the
|
|
430
|
+
// session key instead would let a cycle's glyph name the effective state while
|
|
431
|
+
// its click stepped from an unwritten session key — the toggle would render
|
|
432
|
+
// "wrap: off" and write "false", a click that visibly does nothing. Arms that
|
|
433
|
+
// carry no `stateVar` (the bounded steppers) read nothing at render by design:
|
|
434
|
+
// their step is relative and resolved daemon-side.
|
|
435
|
+
function compileDual(
|
|
436
|
+
selectorVar: string,
|
|
437
|
+
sessionKey: string,
|
|
438
|
+
session: CompiledActionDecl,
|
|
439
|
+
durable: CompiledActionDecl,
|
|
440
|
+
): CompiledActionDecl {
|
|
441
|
+
const readBack =
|
|
442
|
+
"stateVar" in session && "stateVar" in durable
|
|
443
|
+
? { ...session, stateVar: durable.stateVar }
|
|
444
|
+
: session;
|
|
445
|
+
return {
|
|
446
|
+
kind: "dual",
|
|
447
|
+
selector: selectorVar,
|
|
448
|
+
session: readBack,
|
|
449
|
+
durable,
|
|
450
|
+
sessionKey,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// [LAW:dataflow-not-control-flow] THE destination fold: which store a dual
|
|
455
|
+
// action writes is the boolean value of its selector key, read from the same
|
|
456
|
+
// live store the rest of the render reads. Total over every compiled action —
|
|
457
|
+
// a single-destination action IS its own destination — so callers resolve
|
|
458
|
+
// through it unconditionally and never test for the dual kind.
|
|
459
|
+
//
|
|
460
|
+
// [LAW:one-source-of-truth] `parseSessionBoolean` is the one spelling of a
|
|
461
|
+
// boolean in SessionState (themes/policy.ts), the same parse `autoWrap`'s own
|
|
462
|
+
// session half goes through: an unwritten, malformed, or "false" selector all
|
|
463
|
+
// mean the session destination, and only a canonical "true" means durable.
|
|
464
|
+
export function activeDestination(
|
|
465
|
+
c: CompiledActionDecl,
|
|
466
|
+
store: VariableStore,
|
|
467
|
+
): CompiledActionDecl {
|
|
468
|
+
if (c.kind !== "dual") return c;
|
|
469
|
+
return parseSessionBoolean(readVar(store, c.selector)) === true
|
|
470
|
+
? c.durable
|
|
471
|
+
: c.session;
|
|
472
|
+
}
|
|
473
|
+
|
|
382
474
|
function parseActionTemplate(
|
|
383
475
|
parse: (src: string) => Template<RichText>,
|
|
384
476
|
src: string,
|
|
@@ -463,18 +555,18 @@ function cycleIndex(
|
|
|
463
555
|
// cell of an option picker, is pure waste). set-* arms read individual vars
|
|
464
556
|
// directly. This is data locality, not a control-flow guard: the scope simply
|
|
465
557
|
// flows into the arms that need it.
|
|
466
|
-
function realize(
|
|
558
|
+
export function realize(
|
|
467
559
|
c: CompiledActionDecl,
|
|
468
560
|
display: string,
|
|
469
561
|
boundValue: string | undefined,
|
|
470
562
|
store: VariableStore,
|
|
471
563
|
sessionId: string,
|
|
472
|
-
): {
|
|
564
|
+
): { effects: readonly Effect[]; active: boolean } {
|
|
473
565
|
switch (c.kind) {
|
|
474
566
|
case "set-literal": {
|
|
475
567
|
const current = readVar(store, c.stateVar);
|
|
476
568
|
return {
|
|
477
|
-
|
|
569
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] }],
|
|
478
570
|
active: current === c.value,
|
|
479
571
|
};
|
|
480
572
|
}
|
|
@@ -482,7 +574,7 @@ function realize(
|
|
|
482
574
|
const value = boundValue ?? display;
|
|
483
575
|
const current = readVar(store, c.stateVar);
|
|
484
576
|
return {
|
|
485
|
-
|
|
577
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
|
|
486
578
|
active: current === value,
|
|
487
579
|
};
|
|
488
580
|
}
|
|
@@ -499,7 +591,7 @@ function realize(
|
|
|
499
591
|
const value = boundValue ?? display;
|
|
500
592
|
const current = readVar(store, c.stateVar);
|
|
501
593
|
return {
|
|
502
|
-
|
|
594
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
|
|
503
595
|
active: current === value,
|
|
504
596
|
};
|
|
505
597
|
}
|
|
@@ -510,7 +602,7 @@ function realize(
|
|
|
510
602
|
// promised.
|
|
511
603
|
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
512
604
|
return {
|
|
513
|
-
|
|
605
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, next] }],
|
|
514
606
|
active: false,
|
|
515
607
|
};
|
|
516
608
|
}
|
|
@@ -522,27 +614,33 @@ function realize(
|
|
|
522
614
|
// single range gate. So the link is byte-identical across renders and N
|
|
523
615
|
// rapid clicks each accumulate (the idempotent absolute-write bug is gone).
|
|
524
616
|
return {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
617
|
+
effects: [
|
|
618
|
+
{
|
|
619
|
+
verb: VERB_STEP_STATE,
|
|
620
|
+
args: [sessionId, c.key, String(c.by)],
|
|
621
|
+
},
|
|
622
|
+
],
|
|
529
623
|
active: false,
|
|
530
624
|
};
|
|
531
625
|
}
|
|
532
626
|
case "copy":
|
|
533
627
|
return {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
628
|
+
effects: [
|
|
629
|
+
{
|
|
630
|
+
verb: VERB_COPY,
|
|
631
|
+
args: [evalTemplate(c.text, buildScope(store))],
|
|
632
|
+
},
|
|
633
|
+
],
|
|
538
634
|
active: false,
|
|
539
635
|
};
|
|
540
636
|
case "open":
|
|
541
637
|
return {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
638
|
+
effects: [
|
|
639
|
+
{
|
|
640
|
+
verb: VERB_OPEN_VSCODE,
|
|
641
|
+
args: [evalTemplate(c.target, buildScope(store))],
|
|
642
|
+
},
|
|
643
|
+
],
|
|
546
644
|
active: false,
|
|
547
645
|
};
|
|
548
646
|
// [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
|
|
@@ -553,7 +651,7 @@ function realize(
|
|
|
553
651
|
case "persist-literal": {
|
|
554
652
|
const current = readVar(store, c.stateVar);
|
|
555
653
|
return {
|
|
556
|
-
|
|
654
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] }],
|
|
557
655
|
active: current === c.value,
|
|
558
656
|
};
|
|
559
657
|
}
|
|
@@ -561,29 +659,31 @@ function realize(
|
|
|
561
659
|
const value = boundValue ?? display;
|
|
562
660
|
const current = readVar(store, c.stateVar);
|
|
563
661
|
return {
|
|
564
|
-
|
|
662
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] }],
|
|
565
663
|
active: current === value,
|
|
566
664
|
};
|
|
567
665
|
}
|
|
568
666
|
case "persist-cycle": {
|
|
569
667
|
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
570
668
|
return {
|
|
571
|
-
|
|
669
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] }],
|
|
572
670
|
active: false,
|
|
573
671
|
};
|
|
574
672
|
}
|
|
575
673
|
case "persist-bounded": {
|
|
576
674
|
return {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
675
|
+
effects: [
|
|
676
|
+
{
|
|
677
|
+
verb: VERB_STEP_CONFIG,
|
|
678
|
+
args: [sessionId, c.key, String(c.by)],
|
|
679
|
+
},
|
|
680
|
+
],
|
|
581
681
|
active: false,
|
|
582
682
|
};
|
|
583
683
|
}
|
|
584
684
|
case "reset":
|
|
585
685
|
return {
|
|
586
|
-
|
|
686
|
+
effects: [{ verb: VERB_RESET_CONFIG, args: [sessionId, c.key] }],
|
|
587
687
|
active: false,
|
|
588
688
|
};
|
|
589
689
|
// [LAW:one-source-of-truth] No key to carry — the click just says "step
|
|
@@ -592,12 +692,12 @@ function realize(
|
|
|
592
692
|
// a history step is a one-shot trigger, not a current-selection toggle.
|
|
593
693
|
case "undo":
|
|
594
694
|
return {
|
|
595
|
-
|
|
695
|
+
effects: [{ verb: VERB_UNDO, args: [sessionId] }],
|
|
596
696
|
active: false,
|
|
597
697
|
};
|
|
598
698
|
case "redo":
|
|
599
699
|
return {
|
|
600
|
-
|
|
700
|
+
effects: [{ verb: VERB_REDO, args: [sessionId] }],
|
|
601
701
|
active: false,
|
|
602
702
|
};
|
|
603
703
|
// [LAW:one-source-of-truth] The op is fixed at compile time (see
|
|
@@ -608,10 +708,12 @@ function realize(
|
|
|
608
708
|
// trigger, not a current-selection toggle.
|
|
609
709
|
case "layout-op":
|
|
610
710
|
return {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
711
|
+
effects: [
|
|
712
|
+
{
|
|
713
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
714
|
+
args: [sessionId, c.key, encodeLayoutOp(c.op)],
|
|
715
|
+
},
|
|
716
|
+
],
|
|
615
717
|
active: false,
|
|
616
718
|
};
|
|
617
719
|
// [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
|
|
@@ -620,6 +722,46 @@ function realize(
|
|
|
620
722
|
// emits, so the daemon's apply-layout-op handler and undo/redo need no
|
|
621
723
|
// knowledge of where the segment name came from. Never "active": a
|
|
622
724
|
// structural edit is a one-shot trigger, not a current-selection toggle.
|
|
725
|
+
// [LAW:dataflow-not-control-flow] The destination is resolved to a value
|
|
726
|
+
// and the SAME fold runs on it — a dual's realization is its chosen
|
|
727
|
+
// half's realization, with nothing about persistence duplicated here.
|
|
728
|
+
// Depth is structurally one: a dual's halves are the single-destination
|
|
729
|
+
// decls actionDestinations built, which can never be dual themselves.
|
|
730
|
+
//
|
|
731
|
+
// [LAW:no-silent-failure] A DURABLE click carries the session key to
|
|
732
|
+
// RELEASE as a trailing arg on its own write, so the daemon drops it only
|
|
733
|
+
// after that write succeeded. Without the release the write would be
|
|
734
|
+
// invisible to the session that made it — every settable global resolves
|
|
735
|
+
// session pick OVER durable default, so the workflow this menu invites
|
|
736
|
+
// ("try it here, then tick persist? to commit it") would set a default the
|
|
737
|
+
// user cannot see and leave the control dead for the rest of the session.
|
|
738
|
+
// Riding the write rather than sitting beside it is what makes the pair
|
|
739
|
+
// unsplittable: a click runs every effect it carries, so a rejected write
|
|
740
|
+
// must not be able to drop the pick on its own.
|
|
741
|
+
case "dual": {
|
|
742
|
+
const chosen = activeDestination(c, store);
|
|
743
|
+
const { effects, active } = realize(
|
|
744
|
+
chosen,
|
|
745
|
+
display,
|
|
746
|
+
boundValue,
|
|
747
|
+
store,
|
|
748
|
+
sessionId,
|
|
749
|
+
);
|
|
750
|
+
// The durable write carries the session key to RELEASE as one more
|
|
751
|
+
// segment on itself, so the daemon clears it only after its own write
|
|
752
|
+
// succeeded. A second effect beside it would not do: `dispatch` runs
|
|
753
|
+
// every effect in a click by design, so a rejected write would still
|
|
754
|
+
// wipe the session pick and leave nothing durable in its place.
|
|
755
|
+
return chosen === c.durable
|
|
756
|
+
? {
|
|
757
|
+
effects: effects.map((e) => ({
|
|
758
|
+
...e,
|
|
759
|
+
args: [...e.args, c.sessionKey],
|
|
760
|
+
})),
|
|
761
|
+
active,
|
|
762
|
+
}
|
|
763
|
+
: { effects, active };
|
|
764
|
+
}
|
|
623
765
|
case "layout-op-option": {
|
|
624
766
|
const segment = boundValue ?? display;
|
|
625
767
|
const op: LayoutOp = {
|
|
@@ -629,10 +771,12 @@ function realize(
|
|
|
629
771
|
relation: c.relation,
|
|
630
772
|
};
|
|
631
773
|
return {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
774
|
+
effects: [
|
|
775
|
+
{
|
|
776
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
777
|
+
args: [sessionId, c.key, encodeLayoutOp(op)],
|
|
778
|
+
},
|
|
779
|
+
],
|
|
636
780
|
active: false,
|
|
637
781
|
};
|
|
638
782
|
}
|
|
@@ -682,24 +826,34 @@ export function renderAction(
|
|
|
682
826
|
displays: readonly string[],
|
|
683
827
|
runtime: ActionRuntime,
|
|
684
828
|
): RichText {
|
|
685
|
-
const
|
|
829
|
+
const declared = runtime.compiled.get(name);
|
|
686
830
|
// [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
|
|
687
831
|
// reference resolves to a declared action, and compileActions compiled every
|
|
688
832
|
// declared action for THIS config's engine. A miss is a caller/wiring bug.
|
|
689
|
-
if (!
|
|
833
|
+
if (!declared) {
|
|
690
834
|
throw new Error(`action "${name}" is not declared in this config`);
|
|
691
835
|
}
|
|
692
836
|
const store = runtime.store;
|
|
693
|
-
|
|
837
|
+
// [LAW:dataflow-not-control-flow] DISPLAY selection reads the resolved half
|
|
838
|
+
// (a cycle's glyph is the current member's, whichever store it will write),
|
|
839
|
+
// while REALIZATION is handed the declaration itself — a dual realizes as
|
|
840
|
+
// its chosen half PLUS the session clear that keeps a durable write visible,
|
|
841
|
+
// and that pairing belongs to the one fold that owns the union.
|
|
842
|
+
const { display, boundValue } = selectDisplay(
|
|
843
|
+
name,
|
|
844
|
+
activeDestination(declared, store),
|
|
845
|
+
displays,
|
|
846
|
+
store,
|
|
847
|
+
);
|
|
694
848
|
const sessionId = readVar(store, "session.id");
|
|
695
|
-
const {
|
|
696
|
-
|
|
849
|
+
const { effects, active } = realize(
|
|
850
|
+
declared,
|
|
697
851
|
display,
|
|
698
852
|
boundValue,
|
|
699
853
|
store,
|
|
700
854
|
sessionId,
|
|
701
855
|
);
|
|
702
|
-
return linkFragment(display, effectsUrl(
|
|
856
|
+
return linkFragment(display, effectsUrl(effects), active);
|
|
703
857
|
}
|
|
704
858
|
|
|
705
859
|
// ─── FuncMap entry ─────────────────────────────────────────────────────────────
|
package/src/render/picker.ts
CHANGED
|
@@ -28,15 +28,11 @@ import type { FuncMap } from "@promptctl/go-template-js";
|
|
|
28
28
|
import { toNumber } from "../var-system/types.js";
|
|
29
29
|
import { stripChromeCols } from "./strip.js";
|
|
30
30
|
import { TERM_COLS_VAR } from "../config/dsl-types.js";
|
|
31
|
+
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
31
32
|
import {
|
|
32
|
-
|
|
33
|
-
VERB_APPLY_LAYOUT_OP,
|
|
34
|
-
VERB_SET_CONFIG,
|
|
35
|
-
VERB_SET_STATE,
|
|
36
|
-
} from "../click/wire.js";
|
|
37
|
-
import { encodeLayoutOp } from "../config/layout-ops.js";
|
|
38
|
-
import {
|
|
33
|
+
activeDestination,
|
|
39
34
|
linkFragment,
|
|
35
|
+
realize,
|
|
40
36
|
readVar,
|
|
41
37
|
type ActionRuntime,
|
|
42
38
|
type CompiledActionDecl,
|
|
@@ -160,7 +156,15 @@ function requireOptionKind(
|
|
|
160
156
|
CompiledActionDecl,
|
|
161
157
|
{ kind: "set-option" | "persist-option" | "layout-op-option" }
|
|
162
158
|
> {
|
|
163
|
-
|
|
159
|
+
// [LAW:dataflow-not-control-flow] A dual-destination action resolves to the
|
|
160
|
+
// half its selector names BEFORE the kind check, so a picker over a dual is
|
|
161
|
+
// a picker over whichever option kind is live — the grid, its current-mark,
|
|
162
|
+
// and its close folding are the code they already were. The check below then
|
|
163
|
+
// still names a real, single-destination kind in its error.
|
|
164
|
+
const declared = runtime.compiled.get(name);
|
|
165
|
+
const action = declared
|
|
166
|
+
? activeDestination(declared, runtime.store)
|
|
167
|
+
: declared;
|
|
164
168
|
if (
|
|
165
169
|
!action ||
|
|
166
170
|
(action.kind !== "set-option" &&
|
|
@@ -194,6 +198,13 @@ export function renderPicker(
|
|
|
194
198
|
runtime: ActionRuntime,
|
|
195
199
|
): RichText {
|
|
196
200
|
const apply = requireOptionKind(runtime, applyName);
|
|
201
|
+
// [LAW:one-source-of-truth] The GRID reads the resolved half above (its
|
|
202
|
+
// options, its current-mark); the CLICK is realized from the declaration
|
|
203
|
+
// itself, through the same fold `{{ action }}` uses. That is what carries a
|
|
204
|
+
// dual's session clear into a picked option — the picker never learns what a
|
|
205
|
+
// dual is, and there is no second projection of "what does this option
|
|
206
|
+
// write" to drift from realize's.
|
|
207
|
+
const declared = runtime.compiled.get(applyName)!;
|
|
197
208
|
const store = runtime.store;
|
|
198
209
|
const sessionId = readVar(store, "session.id");
|
|
199
210
|
// [LAW:no-defensive-null-guards] layout-op-option carries no `stateVar` —
|
|
@@ -275,30 +286,21 @@ export function renderPicker(
|
|
|
275
286
|
? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
|
|
276
287
|
: [];
|
|
277
288
|
const optionUrl = (option: string): string => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
...closeEffect,
|
|
294
|
-
]);
|
|
295
|
-
}
|
|
296
|
-
return effectsUrl([
|
|
297
|
-
{
|
|
298
|
-
verb: VERB_SET_STATE,
|
|
299
|
-
args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
|
|
300
|
-
},
|
|
301
|
-
]);
|
|
289
|
+
const { effects } = realize(declared, option, option, store, sessionId);
|
|
290
|
+
// [LAW:one-source-of-truth] A plain session pick folds its close pairs into
|
|
291
|
+
// the SAME set-state (setState is variadic), so closing and applying are
|
|
292
|
+
// one write. Every other shape — a durable write, a structural op, a dual
|
|
293
|
+
// carrying its session clear — takes more than one effect already, so its
|
|
294
|
+
// close rides as its own effect in the same atomic dispatch.
|
|
295
|
+
const solo = effects.length === 1 ? effects[0]! : undefined;
|
|
296
|
+
return solo?.verb === VERB_SET_STATE
|
|
297
|
+
? effectsUrl([
|
|
298
|
+
{
|
|
299
|
+
verb: VERB_SET_STATE,
|
|
300
|
+
args: [...solo.args, ...(closeOnPick ? closeFlat : [])],
|
|
301
|
+
},
|
|
302
|
+
])
|
|
303
|
+
: effectsUrl([...effects, ...closeEffect]);
|
|
302
304
|
};
|
|
303
305
|
|
|
304
306
|
const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
|
package/src/themes/policy.ts
CHANGED
|
@@ -270,7 +270,16 @@ export const DEFAULT_WRAP = true;
|
|
|
270
270
|
// boolean globals field — the same two members the bundled default's
|
|
271
271
|
// `cycle: [...]` toggle writes and the parse below reads. Spelled once so a
|
|
272
272
|
// toggle cannot write a member the resolver refuses to parse.
|
|
273
|
-
|
|
273
|
+
// [LAW:one-source-of-truth] The two members, named, because their ORDER is
|
|
274
|
+
// meaningful and differs per control: a cycle's members are ordered
|
|
275
|
+
// default-state-first (an unwritten key counts as the first member and clicks
|
|
276
|
+
// to the second), so `autoWrap` — on by default — cycles ["true","false"]
|
|
277
|
+
// while `persist?` — off by default — cycles [BOOLEAN_FALSE, BOOLEAN_TRUE].
|
|
278
|
+
// Spelling the members rather than reversing the pair keeps each declaration's
|
|
279
|
+
// default state readable at its own site.
|
|
280
|
+
export const BOOLEAN_TRUE = "true";
|
|
281
|
+
export const BOOLEAN_FALSE = "false";
|
|
282
|
+
export const BOOLEAN_MEMBERS = [BOOLEAN_TRUE, BOOLEAN_FALSE] as const;
|
|
274
283
|
|
|
275
284
|
// [LAW:one-source-of-truth] The one statement of the globals.padding default
|
|
276
285
|
// (one space per side inside each segment cell — current behavior, matching the
|
|
@@ -287,8 +296,16 @@ export const PADDING_RANGE = { min: 0, max: 16 } as const;
|
|
|
287
296
|
// [LAW:parse-dont-validate] A SessionState string to a boolean, or null for
|
|
288
297
|
// anything else. `??` in effectiveGlobal (never `||`) is what keeps a parsed
|
|
289
298
|
// `false` a real answer rather than falling through to the default.
|
|
290
|
-
|
|
291
|
-
|
|
299
|
+
//
|
|
300
|
+
// [LAW:one-source-of-truth] Exported because SessionState holds strings and
|
|
301
|
+
// BOOLEAN_MEMBERS above is the one spelling of a boolean in that store — so
|
|
302
|
+
// every reader of a boolean session key parses it HERE, not with its own
|
|
303
|
+
// truthiness rule. The second reader is the dual-destination action's
|
|
304
|
+
// `persistWhen` selector (src/render/action.ts): "is persist? checked" is the
|
|
305
|
+
// same question `autoWrap`'s toggle asks of its own key, and a bespoke
|
|
306
|
+
// `raw !== ""` there would accept values this parse rejects.
|
|
307
|
+
export function parseSessionBoolean(raw: string): boolean | null {
|
|
308
|
+
return raw === BOOLEAN_TRUE ? true : raw === BOOLEAN_FALSE ? false : null;
|
|
292
309
|
}
|
|
293
310
|
|
|
294
311
|
// [LAW:parse-dont-validate] A SessionState string to a padding value inside the
|
|
@@ -313,7 +330,7 @@ export function effectiveAutoWrap(
|
|
|
313
330
|
sessionAutoWrap,
|
|
314
331
|
globalsAutoWrap,
|
|
315
332
|
DEFAULT_WRAP,
|
|
316
|
-
|
|
333
|
+
parseSessionBoolean,
|
|
317
334
|
);
|
|
318
335
|
}
|
|
319
336
|
|