@promptctl/cc-candybar 1.37.0 → 1.39.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 +77 -246
- package/src/config/disclosure.ts +54 -0
- package/src/config/edit-chrome.ts +15 -1
- 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/loader/menu-synth.ts +69 -18
- package/src/config/settings-menu.ts +339 -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 +210 -54
- package/src/render/menu.ts +75 -21
- package/src/render/picker.ts +39 -35
- package/src/themes/policy.ts +21 -4
package/src/render/action.ts
CHANGED
|
@@ -25,10 +25,16 @@ 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";
|
|
35
|
+
import { pickCycleDisplay } from "../config/disclosure.js";
|
|
30
36
|
import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
|
|
31
|
-
import type
|
|
37
|
+
import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
|
|
32
38
|
import {
|
|
33
39
|
effectsUrl,
|
|
34
40
|
VERB_APPLY_LAYOUT_OP,
|
|
@@ -166,7 +172,24 @@ export type CompiledActionDecl =
|
|
|
166
172
|
// there is nothing to carry, since the history stack (not this action) is
|
|
167
173
|
// what decides which entry moves.
|
|
168
174
|
| { readonly kind: "undo" }
|
|
169
|
-
| { readonly kind: "redo" }
|
|
175
|
+
| { readonly kind: "redo" }
|
|
176
|
+
// [LAW:dataflow-not-control-flow] candybar-settings-ui-aok.3's ONE control
|
|
177
|
+
// per setting. Both destinations are compiled here as the ordinary
|
|
178
|
+
// single-destination shapes they are, and `selector` names the session key
|
|
179
|
+
// whose boolean value picks between them at click time. The destination is
|
|
180
|
+
// therefore a VALUE flowing through `activeDestination` — every consumer
|
|
181
|
+
// (realize, the picker, selectDisplay) resolves it once at the top and then
|
|
182
|
+
// runs the code it has always run, so nothing downstream branches on
|
|
183
|
+
// "is this dual".
|
|
184
|
+
| {
|
|
185
|
+
readonly kind: "dual";
|
|
186
|
+
readonly selector: string;
|
|
187
|
+
readonly session: CompiledActionDecl;
|
|
188
|
+
readonly durable: CompiledActionDecl;
|
|
189
|
+
// The SessionState key the session half writes, carried so a durable
|
|
190
|
+
// click can clear it in the same dispatch (see realize's dual arm).
|
|
191
|
+
readonly sessionKey: string;
|
|
192
|
+
};
|
|
170
193
|
|
|
171
194
|
export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
172
195
|
|
|
@@ -185,6 +208,13 @@ export type CompiledActions = ReadonlyMap<string, CompiledActionDecl>;
|
|
|
185
208
|
// highlight is inert (readVar falls back to "" since no such var exists).
|
|
186
209
|
const CONFIG_KEY_TO_EFFECTIVE_VAR: ReadonlyMap<string, string> = new Map([
|
|
187
210
|
["palette", "theme.effective"],
|
|
211
|
+
// [LAW:one-source-of-truth] `preset` earns its entry here the moment a DUAL
|
|
212
|
+
// control writes it: compileDual makes BOTH halves read back through this
|
|
213
|
+
// map, so a field missing from it loses its current-selection mark on the
|
|
214
|
+
// session side too — and the preset picker sits on the settings menu's
|
|
215
|
+
// always-visible first row, where "which arrangement am I in" is the whole
|
|
216
|
+
// question the control answers.
|
|
217
|
+
["preset", "preset.effective"],
|
|
188
218
|
["look", "look.effective"],
|
|
189
219
|
["style", "style.effective"],
|
|
190
220
|
["charset", "charset.effective"],
|
|
@@ -263,6 +293,20 @@ function compileAction(
|
|
|
263
293
|
stateKeyToVar: ReadonlyMap<string, string>,
|
|
264
294
|
perConfigDomains: ReadonlyMap<string, readonly string[]>,
|
|
265
295
|
): CompiledActionDecl {
|
|
296
|
+
// [LAW:one-source-of-truth] A dual compiles as its own two destinations —
|
|
297
|
+
// the SAME explosion the validator derivations fold over
|
|
298
|
+
// (actionDestinations), so the click a dual realizes and the gate it derives
|
|
299
|
+
// come from one statement of what the two halves are. It is matched BEFORE
|
|
300
|
+
// the `set` arm because a dual carries `set` too.
|
|
301
|
+
if (actionIsDual(action)) {
|
|
302
|
+
const [session, durable] = actionDestinations(action);
|
|
303
|
+
return compileDual(
|
|
304
|
+
stateKeyToVar.get(action[PERSIST_WHEN]) ?? action[PERSIST_WHEN],
|
|
305
|
+
action.set,
|
|
306
|
+
compileAction(parse, name, session!, stateKeyToVar, perConfigDomains),
|
|
307
|
+
compileAction(parse, name, durable!, stateKeyToVar, perConfigDomains),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
266
310
|
if ("set" in action) {
|
|
267
311
|
const stateVar = stateKeyToVar.get(action.set) ?? action.set;
|
|
268
312
|
if ("to" in action) {
|
|
@@ -379,6 +423,55 @@ function compileAction(
|
|
|
379
423
|
return "undo" in action ? { kind: "undo" } : { kind: "redo" };
|
|
380
424
|
}
|
|
381
425
|
|
|
426
|
+
// [LAW:one-source-of-truth] A dual control shows ONE current value and writes
|
|
427
|
+
// relative to the value it showed — so both destinations read back through the
|
|
428
|
+
// DURABLE half's variable, which is the `.effective` projection the daemon
|
|
429
|
+
// resolved for this render (CONFIG_KEY_TO_EFFECTIVE_VAR above): the value the
|
|
430
|
+
// bar is actually rendering with, whatever chain produced it. Reading the
|
|
431
|
+
// session key instead would let a cycle's glyph name the effective state while
|
|
432
|
+
// its click stepped from an unwritten session key — the toggle would render
|
|
433
|
+
// "wrap: off" and write "false", a click that visibly does nothing. Arms that
|
|
434
|
+
// carry no `stateVar` (the bounded steppers) read nothing at render by design:
|
|
435
|
+
// their step is relative and resolved daemon-side.
|
|
436
|
+
function compileDual(
|
|
437
|
+
selectorVar: string,
|
|
438
|
+
sessionKey: string,
|
|
439
|
+
session: CompiledActionDecl,
|
|
440
|
+
durable: CompiledActionDecl,
|
|
441
|
+
): CompiledActionDecl {
|
|
442
|
+
const readBack =
|
|
443
|
+
"stateVar" in session && "stateVar" in durable
|
|
444
|
+
? { ...session, stateVar: durable.stateVar }
|
|
445
|
+
: session;
|
|
446
|
+
return {
|
|
447
|
+
kind: "dual",
|
|
448
|
+
selector: selectorVar,
|
|
449
|
+
session: readBack,
|
|
450
|
+
durable,
|
|
451
|
+
sessionKey,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// [LAW:dataflow-not-control-flow] THE destination fold: which store a dual
|
|
456
|
+
// action writes is the boolean value of its selector key, read from the same
|
|
457
|
+
// live store the rest of the render reads. Total over every compiled action —
|
|
458
|
+
// a single-destination action IS its own destination — so callers resolve
|
|
459
|
+
// through it unconditionally and never test for the dual kind.
|
|
460
|
+
//
|
|
461
|
+
// [LAW:one-source-of-truth] `parseSessionBoolean` is the one spelling of a
|
|
462
|
+
// boolean in SessionState (themes/policy.ts), the same parse `autoWrap`'s own
|
|
463
|
+
// session half goes through: an unwritten, malformed, or "false" selector all
|
|
464
|
+
// mean the session destination, and only a canonical "true" means durable.
|
|
465
|
+
export function activeDestination(
|
|
466
|
+
c: CompiledActionDecl,
|
|
467
|
+
store: VariableStore,
|
|
468
|
+
): CompiledActionDecl {
|
|
469
|
+
if (c.kind !== "dual") return c;
|
|
470
|
+
return parseSessionBoolean(readVar(store, c.selector)) === true
|
|
471
|
+
? c.durable
|
|
472
|
+
: c.session;
|
|
473
|
+
}
|
|
474
|
+
|
|
382
475
|
function parseActionTemplate(
|
|
383
476
|
parse: (src: string) => Template<RichText>,
|
|
384
477
|
src: string,
|
|
@@ -463,18 +556,18 @@ function cycleIndex(
|
|
|
463
556
|
// cell of an option picker, is pure waste). set-* arms read individual vars
|
|
464
557
|
// directly. This is data locality, not a control-flow guard: the scope simply
|
|
465
558
|
// flows into the arms that need it.
|
|
466
|
-
function realize(
|
|
559
|
+
export function realize(
|
|
467
560
|
c: CompiledActionDecl,
|
|
468
561
|
display: string,
|
|
469
562
|
boundValue: string | undefined,
|
|
470
563
|
store: VariableStore,
|
|
471
564
|
sessionId: string,
|
|
472
|
-
): {
|
|
565
|
+
): { effects: readonly Effect[]; active: boolean } {
|
|
473
566
|
switch (c.kind) {
|
|
474
567
|
case "set-literal": {
|
|
475
568
|
const current = readVar(store, c.stateVar);
|
|
476
569
|
return {
|
|
477
|
-
|
|
570
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, c.value] }],
|
|
478
571
|
active: current === c.value,
|
|
479
572
|
};
|
|
480
573
|
}
|
|
@@ -482,7 +575,7 @@ function realize(
|
|
|
482
575
|
const value = boundValue ?? display;
|
|
483
576
|
const current = readVar(store, c.stateVar);
|
|
484
577
|
return {
|
|
485
|
-
|
|
578
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
|
|
486
579
|
active: current === value,
|
|
487
580
|
};
|
|
488
581
|
}
|
|
@@ -499,7 +592,7 @@ function realize(
|
|
|
499
592
|
const value = boundValue ?? display;
|
|
500
593
|
const current = readVar(store, c.stateVar);
|
|
501
594
|
return {
|
|
502
|
-
|
|
595
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, value] }],
|
|
503
596
|
active: current === value,
|
|
504
597
|
};
|
|
505
598
|
}
|
|
@@ -510,7 +603,7 @@ function realize(
|
|
|
510
603
|
// promised.
|
|
511
604
|
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
512
605
|
return {
|
|
513
|
-
|
|
606
|
+
effects: [{ verb: VERB_SET_STATE, args: [sessionId, c.key, next] }],
|
|
514
607
|
active: false,
|
|
515
608
|
};
|
|
516
609
|
}
|
|
@@ -522,27 +615,33 @@ function realize(
|
|
|
522
615
|
// single range gate. So the link is byte-identical across renders and N
|
|
523
616
|
// rapid clicks each accumulate (the idempotent absolute-write bug is gone).
|
|
524
617
|
return {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
618
|
+
effects: [
|
|
619
|
+
{
|
|
620
|
+
verb: VERB_STEP_STATE,
|
|
621
|
+
args: [sessionId, c.key, String(c.by)],
|
|
622
|
+
},
|
|
623
|
+
],
|
|
529
624
|
active: false,
|
|
530
625
|
};
|
|
531
626
|
}
|
|
532
627
|
case "copy":
|
|
533
628
|
return {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
629
|
+
effects: [
|
|
630
|
+
{
|
|
631
|
+
verb: VERB_COPY,
|
|
632
|
+
args: [evalTemplate(c.text, buildScope(store))],
|
|
633
|
+
},
|
|
634
|
+
],
|
|
538
635
|
active: false,
|
|
539
636
|
};
|
|
540
637
|
case "open":
|
|
541
638
|
return {
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
639
|
+
effects: [
|
|
640
|
+
{
|
|
641
|
+
verb: VERB_OPEN_VSCODE,
|
|
642
|
+
args: [evalTemplate(c.target, buildScope(store))],
|
|
643
|
+
},
|
|
644
|
+
],
|
|
546
645
|
active: false,
|
|
547
646
|
};
|
|
548
647
|
// [LAW:one-source-of-truth] The persist-* arms mirror set-*'s realization
|
|
@@ -553,7 +652,7 @@ function realize(
|
|
|
553
652
|
case "persist-literal": {
|
|
554
653
|
const current = readVar(store, c.stateVar);
|
|
555
654
|
return {
|
|
556
|
-
|
|
655
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, c.value] }],
|
|
557
656
|
active: current === c.value,
|
|
558
657
|
};
|
|
559
658
|
}
|
|
@@ -561,29 +660,31 @@ function realize(
|
|
|
561
660
|
const value = boundValue ?? display;
|
|
562
661
|
const current = readVar(store, c.stateVar);
|
|
563
662
|
return {
|
|
564
|
-
|
|
663
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, value] }],
|
|
565
664
|
active: current === value,
|
|
566
665
|
};
|
|
567
666
|
}
|
|
568
667
|
case "persist-cycle": {
|
|
569
668
|
const next = c.members[(cycleIndex(c, store) + 1) % c.members.length]!;
|
|
570
669
|
return {
|
|
571
|
-
|
|
670
|
+
effects: [{ verb: VERB_SET_CONFIG, args: [sessionId, c.key, next] }],
|
|
572
671
|
active: false,
|
|
573
672
|
};
|
|
574
673
|
}
|
|
575
674
|
case "persist-bounded": {
|
|
576
675
|
return {
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
676
|
+
effects: [
|
|
677
|
+
{
|
|
678
|
+
verb: VERB_STEP_CONFIG,
|
|
679
|
+
args: [sessionId, c.key, String(c.by)],
|
|
680
|
+
},
|
|
681
|
+
],
|
|
581
682
|
active: false,
|
|
582
683
|
};
|
|
583
684
|
}
|
|
584
685
|
case "reset":
|
|
585
686
|
return {
|
|
586
|
-
|
|
687
|
+
effects: [{ verb: VERB_RESET_CONFIG, args: [sessionId, c.key] }],
|
|
587
688
|
active: false,
|
|
588
689
|
};
|
|
589
690
|
// [LAW:one-source-of-truth] No key to carry — the click just says "step
|
|
@@ -592,12 +693,12 @@ function realize(
|
|
|
592
693
|
// a history step is a one-shot trigger, not a current-selection toggle.
|
|
593
694
|
case "undo":
|
|
594
695
|
return {
|
|
595
|
-
|
|
696
|
+
effects: [{ verb: VERB_UNDO, args: [sessionId] }],
|
|
596
697
|
active: false,
|
|
597
698
|
};
|
|
598
699
|
case "redo":
|
|
599
700
|
return {
|
|
600
|
-
|
|
701
|
+
effects: [{ verb: VERB_REDO, args: [sessionId] }],
|
|
601
702
|
active: false,
|
|
602
703
|
};
|
|
603
704
|
// [LAW:one-source-of-truth] The op is fixed at compile time (see
|
|
@@ -608,10 +709,12 @@ function realize(
|
|
|
608
709
|
// trigger, not a current-selection toggle.
|
|
609
710
|
case "layout-op":
|
|
610
711
|
return {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
712
|
+
effects: [
|
|
713
|
+
{
|
|
714
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
715
|
+
args: [sessionId, c.key, encodeLayoutOp(c.op)],
|
|
716
|
+
},
|
|
717
|
+
],
|
|
615
718
|
active: false,
|
|
616
719
|
};
|
|
617
720
|
// [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
|
|
@@ -620,6 +723,46 @@ function realize(
|
|
|
620
723
|
// emits, so the daemon's apply-layout-op handler and undo/redo need no
|
|
621
724
|
// knowledge of where the segment name came from. Never "active": a
|
|
622
725
|
// structural edit is a one-shot trigger, not a current-selection toggle.
|
|
726
|
+
// [LAW:dataflow-not-control-flow] The destination is resolved to a value
|
|
727
|
+
// and the SAME fold runs on it — a dual's realization is its chosen
|
|
728
|
+
// half's realization, with nothing about persistence duplicated here.
|
|
729
|
+
// Depth is structurally one: a dual's halves are the single-destination
|
|
730
|
+
// decls actionDestinations built, which can never be dual themselves.
|
|
731
|
+
//
|
|
732
|
+
// [LAW:no-silent-failure] A DURABLE click carries the session key to
|
|
733
|
+
// RELEASE as a trailing arg on its own write, so the daemon drops it only
|
|
734
|
+
// after that write succeeded. Without the release the write would be
|
|
735
|
+
// invisible to the session that made it — every settable global resolves
|
|
736
|
+
// session pick OVER durable default, so the workflow this menu invites
|
|
737
|
+
// ("try it here, then tick persist? to commit it") would set a default the
|
|
738
|
+
// user cannot see and leave the control dead for the rest of the session.
|
|
739
|
+
// Riding the write rather than sitting beside it is what makes the pair
|
|
740
|
+
// unsplittable: a click runs every effect it carries, so a rejected write
|
|
741
|
+
// must not be able to drop the pick on its own.
|
|
742
|
+
case "dual": {
|
|
743
|
+
const chosen = activeDestination(c, store);
|
|
744
|
+
const { effects, active } = realize(
|
|
745
|
+
chosen,
|
|
746
|
+
display,
|
|
747
|
+
boundValue,
|
|
748
|
+
store,
|
|
749
|
+
sessionId,
|
|
750
|
+
);
|
|
751
|
+
// The durable write carries the session key to RELEASE as one more
|
|
752
|
+
// segment on itself, so the daemon clears it only after its own write
|
|
753
|
+
// succeeded. A second effect beside it would not do: `dispatch` runs
|
|
754
|
+
// every effect in a click by design, so a rejected write would still
|
|
755
|
+
// wipe the session pick and leave nothing durable in its place.
|
|
756
|
+
return chosen === c.durable
|
|
757
|
+
? {
|
|
758
|
+
effects: effects.map((e) => ({
|
|
759
|
+
...e,
|
|
760
|
+
args: [...e.args, c.sessionKey],
|
|
761
|
+
})),
|
|
762
|
+
active,
|
|
763
|
+
}
|
|
764
|
+
: { effects, active };
|
|
765
|
+
}
|
|
623
766
|
case "layout-op-option": {
|
|
624
767
|
const segment = boundValue ?? display;
|
|
625
768
|
const op: LayoutOp = {
|
|
@@ -629,10 +772,12 @@ function realize(
|
|
|
629
772
|
relation: c.relation,
|
|
630
773
|
};
|
|
631
774
|
return {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
775
|
+
effects: [
|
|
776
|
+
{
|
|
777
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
778
|
+
args: [sessionId, c.key, encodeLayoutOp(op)],
|
|
779
|
+
},
|
|
780
|
+
],
|
|
636
781
|
active: false,
|
|
637
782
|
};
|
|
638
783
|
}
|
|
@@ -656,15 +801,16 @@ function selectDisplay(
|
|
|
656
801
|
throw new Error(`action "${name}" needs a display (the clickable text)`);
|
|
657
802
|
}
|
|
658
803
|
if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
displays
|
|
666
|
-
|
|
667
|
-
|
|
804
|
+
// [LAW:single-enforcer] The arity rule and the pick are the disclosure
|
|
805
|
+
// primitive's, not this file's — `{{ menu }}` resolves its own trigger
|
|
806
|
+
// through the same function over its `[closed, member]` cycle, so the two
|
|
807
|
+
// disclosure kinds cannot disagree about what a display binding means.
|
|
808
|
+
const display = pickCycleDisplay(
|
|
809
|
+
`action "${name}"`,
|
|
810
|
+
displays,
|
|
811
|
+
action.members.length,
|
|
812
|
+
cycleIndex(action, store),
|
|
813
|
+
);
|
|
668
814
|
return { display, boundValue: undefined };
|
|
669
815
|
}
|
|
670
816
|
if (displays.length > 2) {
|
|
@@ -682,24 +828,34 @@ export function renderAction(
|
|
|
682
828
|
displays: readonly string[],
|
|
683
829
|
runtime: ActionRuntime,
|
|
684
830
|
): RichText {
|
|
685
|
-
const
|
|
831
|
+
const declared = runtime.compiled.get(name);
|
|
686
832
|
// [LAW:no-defensive-null-guards] The loader validates every `{{ action "x" }}`
|
|
687
833
|
// reference resolves to a declared action, and compileActions compiled every
|
|
688
834
|
// declared action for THIS config's engine. A miss is a caller/wiring bug.
|
|
689
|
-
if (!
|
|
835
|
+
if (!declared) {
|
|
690
836
|
throw new Error(`action "${name}" is not declared in this config`);
|
|
691
837
|
}
|
|
692
838
|
const store = runtime.store;
|
|
693
|
-
|
|
839
|
+
// [LAW:dataflow-not-control-flow] DISPLAY selection reads the resolved half
|
|
840
|
+
// (a cycle's glyph is the current member's, whichever store it will write),
|
|
841
|
+
// while REALIZATION is handed the declaration itself — a dual realizes as
|
|
842
|
+
// its chosen half PLUS the session clear that keeps a durable write visible,
|
|
843
|
+
// and that pairing belongs to the one fold that owns the union.
|
|
844
|
+
const { display, boundValue } = selectDisplay(
|
|
845
|
+
name,
|
|
846
|
+
activeDestination(declared, store),
|
|
847
|
+
displays,
|
|
848
|
+
store,
|
|
849
|
+
);
|
|
694
850
|
const sessionId = readVar(store, "session.id");
|
|
695
|
-
const {
|
|
696
|
-
|
|
851
|
+
const { effects, active } = realize(
|
|
852
|
+
declared,
|
|
697
853
|
display,
|
|
698
854
|
boundValue,
|
|
699
855
|
store,
|
|
700
856
|
sessionId,
|
|
701
857
|
);
|
|
702
|
-
return linkFragment(display, effectsUrl(
|
|
858
|
+
return linkFragment(display, effectsUrl(effects), active);
|
|
703
859
|
}
|
|
704
860
|
|
|
705
861
|
// ─── FuncMap entry ─────────────────────────────────────────────────────────────
|
package/src/render/menu.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
// [LAW:locality-or-seam] The runtime half of the `{{ menu }}` seam — sibling to
|
|
2
2
|
// `{{ action }}`/`{{ picker }}`. A menu is a self-contained disclosure: an inline
|
|
3
|
-
//
|
|
3
|
+
// TRIGGER that toggles open/closed, and (when open) its body — a picker grid —
|
|
4
4
|
// that DROPS onto the line(s) below the enclosing row. The body is the one picker
|
|
5
|
-
// renderer (`renderPicker`); the
|
|
5
|
+
// renderer (`renderPicker`); the trigger is a coupled set-state the menu composes
|
|
6
6
|
// directly (like the picker's closeOnPick) — it toggles the open-state AND resets
|
|
7
7
|
// the page cursor in one atomic batch, gated by the synthesized cycle action.
|
|
8
8
|
//
|
|
9
|
+
// [LAW:one-source-of-truth] The trigger's TEXT is authored, never emitted here.
|
|
10
|
+
// This module used to append ▸/▾ from the glyph constants, while the codebase's
|
|
11
|
+
// other disclosure — group sugar — spliced those same constants into the
|
|
12
|
+
// template it synthesized, where an author could see and change them. Two
|
|
13
|
+
// policies for one fact; the docs sided with the visible one ("the trigger is
|
|
14
|
+
// any template content you like") while a menu appended a glyph nobody wrote,
|
|
15
|
+
// which is why edit mode's `+` rendered `+▸`. A menu's disclosure IS a
|
|
16
|
+
// two-member cycle, so its trigger now binds displays exactly as a cycle
|
|
17
|
+
// `{{ action }}` does, through the same `pickCycleDisplay` (candybar-settings-
|
|
18
|
+
// ui-aok.4).
|
|
19
|
+
//
|
|
9
20
|
// [LAW:effects-at-boundaries] The helper is a PURE function of its inputs (the
|
|
10
21
|
// walk-published placement + the live store): it computes the inline glyph and,
|
|
11
22
|
// when open, the body, and RETURNS them together — the glyph as the fragment, the
|
|
@@ -42,11 +53,7 @@ import {
|
|
|
42
53
|
parseMenuOptions,
|
|
43
54
|
type MenuOptions,
|
|
44
55
|
} from "../config/menu-keys.js";
|
|
45
|
-
import {
|
|
46
|
-
DISCLOSURE_CLOSED,
|
|
47
|
-
DISCLOSURE_GLYPH_CLOSED,
|
|
48
|
-
DISCLOSURE_GLYPH_OPEN,
|
|
49
|
-
} from "../config/disclosure.js";
|
|
56
|
+
import { DISCLOSURE_CLOSED, pickCycleDisplay } from "../config/disclosure.js";
|
|
50
57
|
import { effectsUrl, VERB_SET_STATE } from "../click/wire.js";
|
|
51
58
|
import { linkFragment, readVar, type ActionRuntime } from "./action.js";
|
|
52
59
|
import { renderPicker } from "./picker.js";
|
|
@@ -94,9 +101,10 @@ export function collectMenuDrops(
|
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
// Realize a `{{ menu }}` against the live placement + state: return its inline
|
|
97
|
-
//
|
|
104
|
+
// trigger, carrying the (open) body as out-of-band metadata for the boundary.
|
|
98
105
|
function renderMenu(
|
|
99
106
|
applyName: string,
|
|
107
|
+
displays: readonly string[],
|
|
100
108
|
options: MenuOptions,
|
|
101
109
|
runtime: MenuRuntime,
|
|
102
110
|
): RichText {
|
|
@@ -139,8 +147,14 @@ function renderMenu(
|
|
|
139
147
|
// coupled batch passes the same wire gate every click does [LAW:single-enforcer].
|
|
140
148
|
const sessionId = readVar(action.store, "session.id");
|
|
141
149
|
const successor = open ? DISCLOSURE_CLOSED : member;
|
|
150
|
+
// [LAW:one-source-of-truth] The trigger's text is AUTHORED, resolved through
|
|
151
|
+
// the one display rule a cycle `{{ action }}` uses — a menu's disclosure is a
|
|
152
|
+
// two-member cycle, so binding `"▸" "▾"` gives the per-state form and binding
|
|
153
|
+
// `"+"` gives the static one. Nothing is appended here: a disclosure glyph an
|
|
154
|
+
// author never wrote is a glyph they cannot decline, which is exactly how
|
|
155
|
+
// edit mode's `+` came to read `+▸`.
|
|
142
156
|
const glyph = linkFragment(
|
|
143
|
-
open ?
|
|
157
|
+
pickCycleDisplay(`{{ menu "${applyName}" }}`, displays, 2, open ? 1 : 0),
|
|
144
158
|
effectsUrl([
|
|
145
159
|
{
|
|
146
160
|
verb: VERB_SET_STATE,
|
|
@@ -178,25 +192,65 @@ function renderMenu(
|
|
|
178
192
|
return glyph;
|
|
179
193
|
}
|
|
180
194
|
|
|
195
|
+
// [LAW:parse-dont-validate] THE crossing for a `{{ menu }}`'s argument tail.
|
|
196
|
+
// The engine cannot type these slots for us — displays are strings and the
|
|
197
|
+
// optional trailing knobs are a dict, so one declared slot type would refuse
|
|
198
|
+
// one of them — so the tail arrives as opaque values and leaves here as a
|
|
199
|
+
// record whose shape the renderer can no longer doubt: displays are strings,
|
|
200
|
+
// options are parsed. Every rejected shape names the legal one.
|
|
201
|
+
interface MenuArgs {
|
|
202
|
+
readonly displays: readonly string[];
|
|
203
|
+
readonly options: MenuOptions;
|
|
204
|
+
}
|
|
205
|
+
// [LAW:one-source-of-truth] This splits the tail on VALUES; the loader splits
|
|
206
|
+
// the same tail on EXPRS (`menu-synth.ts`). They agree because the loader admits
|
|
207
|
+
// only call sites where they provably must: a last argument that is neither a
|
|
208
|
+
// string literal nor a literal `(dict …)` is a load error whenever both readings
|
|
209
|
+
// would be legal, so what reaches here can only match the loader's reading or
|
|
210
|
+
// throw below.
|
|
211
|
+
const isDict = (v: unknown): v is Record<string, unknown> =>
|
|
212
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
213
|
+
|
|
214
|
+
function parseMenuArgs(applyName: string, tail: readonly unknown[]): MenuArgs {
|
|
215
|
+
// The dict is the LAST argument when present; everything before it is a
|
|
216
|
+
// display. One position, so a reader never has to count.
|
|
217
|
+
const last = tail[tail.length - 1];
|
|
218
|
+
const optsArg = isDict(last) ? last : undefined;
|
|
219
|
+
const displayArgs = optsArg === undefined ? tail : tail.slice(0, -1);
|
|
220
|
+
const bad = displayArgs.findIndex((d) => typeof d !== "string");
|
|
221
|
+
if (bad !== -1) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`{{ menu "${applyName}" }} display #${bad + 1} is not text (${JSON.stringify(displayArgs[bad])}) — a menu binds its trigger text, then an optional trailing (dict …) of options`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
displays: displayArgs as readonly string[],
|
|
228
|
+
// [LAW:one-source-of-truth] The same option reader the loader folds over
|
|
229
|
+
// the static dict — vocabulary, types, defaults live once.
|
|
230
|
+
options: parseMenuOptions(optsArg ?? {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
181
234
|
// [LAW:dataflow-not-control-flow] One func; the apply-action NAME is the menu's
|
|
182
|
-
// whole identity (the page cursor is derived from it, not passed),
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
235
|
+
// whole identity (the page cursor is derived from it, not passed), the TRIGGER
|
|
236
|
+
// TEXT is bound like a cycle action's display (one per state, or one static),
|
|
237
|
+
// and the rare knobs travel as ONE optional trailing `(dict …)` — closeOnPick
|
|
238
|
+
// (default false: stay-open), paged (default true: a drop menu wants bounded
|
|
239
|
+
// height), key (accordion grouping: omitted ⇒ independent, present ⇒ mutually
|
|
240
|
+
// exclusive with siblings sharing it). Values, not modes. The loader gates the
|
|
241
|
+
// same dict statically (staticDictEntries), so an old positional tail never
|
|
242
|
+
// reaches this fn — it is a migration-pointing load error.
|
|
189
243
|
//
|
|
190
244
|
// [LAW:one-way-deps] Injected into the engine by registerDslConfig as data; the
|
|
191
245
|
// generic engine never imports this module.
|
|
192
246
|
export function menuFuncs(runtime: MenuRuntime): FuncMap {
|
|
193
247
|
return {
|
|
194
248
|
menu: {
|
|
195
|
-
fn: (applyName: string,
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
argTypes: ["string", "
|
|
249
|
+
fn: (applyName: string, ...tail: unknown[]) => {
|
|
250
|
+
const { displays, options } = parseMenuArgs(applyName, tail);
|
|
251
|
+
return renderMenu(applyName, displays, options, runtime);
|
|
252
|
+
},
|
|
253
|
+
argTypes: ["string", "value"],
|
|
200
254
|
returnType: "T",
|
|
201
255
|
},
|
|
202
256
|
};
|