@promptctl/cc-candybar 1.31.0 → 1.33.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 +26 -26
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +41 -0
- package/src/config/action.ts +25 -0
- package/src/config/default-dsl-config.ts +27 -2
- package/src/config/dsl-loader.ts +18 -1
- package/src/config/edit-chrome.ts +329 -0
- package/src/config/layout-ops.ts +21 -0
- package/src/config/loader/actions.ts +52 -13
- package/src/config/loader/cross-ref.ts +13 -2
- package/src/config/loader/edit-mode.ts +111 -0
- package/src/daemon/verbs/config-validators.ts +39 -1
- package/src/dsl/render.ts +10 -1
- package/src/render/action.ts +49 -0
- package/src/render/picker.ts +71 -38
|
@@ -388,6 +388,20 @@ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
|
|
|
388
388
|
anchor: layoutNameSpec("anchor"),
|
|
389
389
|
relation: relationSpec(),
|
|
390
390
|
};
|
|
391
|
+
// [LAW:one-type-per-behavior] `insertSegmentFrom`'s payload mirrors
|
|
392
|
+
// `insertSegment`'s verbatim except the segment name is a `from`-shaped
|
|
393
|
+
// OptionDomain (fromSpec, the SAME field `set`/`persist … from` already
|
|
394
|
+
// validate) instead of a literal layout name — the "to" vs "from" split every
|
|
395
|
+
// other value source already draws, one arm over.
|
|
396
|
+
const INSERT_SEGMENT_FROM_FIELDS: FieldSpecMap<{
|
|
397
|
+
insertSegmentFrom: OptionDomain;
|
|
398
|
+
anchor: string;
|
|
399
|
+
relation: "before" | "after";
|
|
400
|
+
}> = {
|
|
401
|
+
insertSegmentFrom: fromSpec("persist"),
|
|
402
|
+
anchor: layoutNameSpec("anchor"),
|
|
403
|
+
relation: relationSpec(),
|
|
404
|
+
};
|
|
391
405
|
|
|
392
406
|
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
393
407
|
// domain (min < max) and a non-zero integer increment (`by`; negative for a
|
|
@@ -440,12 +454,25 @@ interface ValueSourceArm {
|
|
|
440
454
|
readonly parse: ArmParse<Partial<ActionDecl>>;
|
|
441
455
|
}
|
|
442
456
|
|
|
457
|
+
// [LAW:no-mode-explosion] `detectKeys` narrows WHICH of an arm's fields the
|
|
458
|
+
// present-count dispatch keys off, independent of `allowed`/`label` (still
|
|
459
|
+
// the full field set — what the arm PERMITS and is NAMED by never changes).
|
|
460
|
+
// Every arm before insertSegmentFrom had a field set disjoint from every
|
|
461
|
+
// other arm's, so `Object.keys(fieldMap)` was a safe default for both jobs
|
|
462
|
+
// at once. insertSegmentFrom breaks that: it shares `anchor`/`relation` with
|
|
463
|
+
// insertSegment (same POSITION shape, different segment-name SOURCE), so
|
|
464
|
+
// dispatching on the full set would make an ordinary `insertSegment` action
|
|
465
|
+
// spuriously match both arms via those shared keys. Pass the true
|
|
466
|
+
// discriminator (the field no sibling arm carries) here; omit it when the
|
|
467
|
+
// field set already is disjoint from every sibling, as it is everywhere else.
|
|
443
468
|
function valueSourceArm<P extends object>(
|
|
444
469
|
discriminator: "set" | "persist",
|
|
445
470
|
fieldMap: FieldSpecMap<P>,
|
|
446
|
-
|
|
471
|
+
checks: ReadonlyArray<Refinement<P>> = [],
|
|
472
|
+
detectKeys?: readonly string[],
|
|
447
473
|
): ValueSourceArm {
|
|
448
|
-
const
|
|
474
|
+
const fullKeys = Object.keys(fieldMap);
|
|
475
|
+
const detect = detectKeys ?? fullKeys;
|
|
449
476
|
const inner: ArmParse<P> = (ctx, path, raw) =>
|
|
450
477
|
fields(ctx, fieldMap, path, raw);
|
|
451
478
|
const source = objectJson(fieldMap) as {
|
|
@@ -454,8 +481,8 @@ function valueSourceArm<P extends object>(
|
|
|
454
481
|
};
|
|
455
482
|
return {
|
|
456
483
|
detect,
|
|
457
|
-
allowed: [discriminator, ...
|
|
458
|
-
label:
|
|
484
|
+
allowed: [discriminator, ...fullKeys],
|
|
485
|
+
label: fullKeys.join("/"),
|
|
459
486
|
json: {
|
|
460
487
|
type: "object",
|
|
461
488
|
properties: { [discriminator]: { type: "string" }, ...source.properties },
|
|
@@ -475,7 +502,7 @@ function valueSourceArm<P extends object>(
|
|
|
475
502
|
const SET_ARMS: readonly ValueSourceArm[] = [
|
|
476
503
|
valueSourceArm("set", TO_FIELDS_SET),
|
|
477
504
|
valueSourceArm("set", FROM_FIELDS_SET),
|
|
478
|
-
valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
505
|
+
valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
479
506
|
valueSourceArm("set", INT_FIELDS),
|
|
480
507
|
valueSourceArm("set", CYCLE_FIELDS_SET),
|
|
481
508
|
];
|
|
@@ -487,10 +514,21 @@ const SET_ARMS: readonly ValueSourceArm[] = [
|
|
|
487
514
|
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
488
515
|
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
489
516
|
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
490
|
-
valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
517
|
+
valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
491
518
|
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
492
519
|
valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
|
|
493
|
-
|
|
520
|
+
// [LAW:no-mode-explosion] Both insertSegment arms narrow detectKeys to
|
|
521
|
+
// their own discriminating field — see valueSourceArm's own comment. They
|
|
522
|
+
// share "anchor"/"relation" (same position shape, different segment-name
|
|
523
|
+
// source), so dispatching on the full field set would make EITHER arm
|
|
524
|
+
// spuriously match an action declaring the other.
|
|
525
|
+
valueSourceArm("persist", INSERT_SEGMENT_FIELDS, [], ["insertSegment"]),
|
|
526
|
+
valueSourceArm(
|
|
527
|
+
"persist",
|
|
528
|
+
INSERT_SEGMENT_FROM_FIELDS,
|
|
529
|
+
[],
|
|
530
|
+
["insertSegmentFrom"],
|
|
531
|
+
),
|
|
494
532
|
];
|
|
495
533
|
|
|
496
534
|
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
@@ -511,6 +549,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
|
|
|
511
549
|
clauses.push(
|
|
512
550
|
`"removeSegment" (remove a named segment from the layout)`,
|
|
513
551
|
`"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
|
|
552
|
+
`"insertSegmentFrom"/"anchor"/"relation" (insert a segment PICKED from an option domain before/after an existing one)`,
|
|
514
553
|
);
|
|
515
554
|
}
|
|
516
555
|
return clauses;
|
|
@@ -635,7 +674,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
635
674
|
const at = `${path}.${field}`;
|
|
636
675
|
if (typeof from === "string") {
|
|
637
676
|
if (from === "") {
|
|
638
|
-
issue(ctx, at,
|
|
677
|
+
issue(ctx, at, `${field} must be a non-empty domain name`);
|
|
639
678
|
return undefined;
|
|
640
679
|
}
|
|
641
680
|
return from;
|
|
@@ -646,7 +685,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
646
685
|
issue(
|
|
647
686
|
ctx,
|
|
648
687
|
at,
|
|
649
|
-
|
|
688
|
+
`${field} must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
|
|
650
689
|
);
|
|
651
690
|
return undefined;
|
|
652
691
|
}
|
|
@@ -654,7 +693,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
654
693
|
issue(
|
|
655
694
|
ctx,
|
|
656
695
|
at,
|
|
657
|
-
|
|
696
|
+
`${field} array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
658
697
|
);
|
|
659
698
|
return undefined;
|
|
660
699
|
}
|
|
@@ -663,7 +702,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
663
702
|
issue(
|
|
664
703
|
ctx,
|
|
665
704
|
at,
|
|
666
|
-
|
|
705
|
+
`${field} array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
667
706
|
);
|
|
668
707
|
return undefined;
|
|
669
708
|
}
|
|
@@ -671,7 +710,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
671
710
|
issue(
|
|
672
711
|
ctx,
|
|
673
712
|
at,
|
|
674
|
-
|
|
713
|
+
`${field} array members must be unique — a duplicated value would render the same picker option twice`,
|
|
675
714
|
);
|
|
676
715
|
return undefined;
|
|
677
716
|
}
|
|
@@ -680,7 +719,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
680
719
|
issue(
|
|
681
720
|
ctx,
|
|
682
721
|
at,
|
|
683
|
-
|
|
722
|
+
`${field} must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
|
|
684
723
|
);
|
|
685
724
|
return undefined;
|
|
686
725
|
},
|
|
@@ -396,10 +396,14 @@ function checkPresetRootOpsTarget(
|
|
|
396
396
|
if (discriminator === "reset") return;
|
|
397
397
|
const hasRemove = "removeSegment" in a;
|
|
398
398
|
const hasInsert = "insertSegment" in a;
|
|
399
|
-
|
|
399
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
400
|
+
// sibling — the segment name is picked at render, so only `anchor` (still
|
|
401
|
+
// literal at author time) needs the declared-segment check below.
|
|
402
|
+
const hasInsertFrom = "insertSegmentFrom" in a;
|
|
403
|
+
if (!hasRemove && !hasInsert && !hasInsertFrom) {
|
|
400
404
|
ctx.issues.push({
|
|
401
405
|
path: at,
|
|
402
|
-
message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment" or "
|
|
406
|
+
message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment", "insertSegment", or "insertSegmentFrom" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
|
|
403
407
|
line,
|
|
404
408
|
});
|
|
405
409
|
return;
|
|
@@ -429,6 +433,13 @@ function checkPresetRootOpsTarget(
|
|
|
429
433
|
});
|
|
430
434
|
}
|
|
431
435
|
}
|
|
436
|
+
if (hasInsertFrom && "insertSegmentFrom" in a && missing(a.anchor)) {
|
|
437
|
+
ctx.issues.push({
|
|
438
|
+
path: at,
|
|
439
|
+
message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
440
|
+
line,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
432
443
|
}
|
|
433
444
|
|
|
434
445
|
function hasStateKind(cfg: DslConfig): boolean {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's TOGGLE half of edit
|
|
2
|
+
// mode — the disclosure primitive's third body-kind, one register down from
|
|
3
|
+
// group sugar and `{{ menu }}`: where those two synthesize a whole trigger +
|
|
4
|
+
// body, edit mode synthesizes only the on/off state + toggle ACTION here
|
|
5
|
+
// (`edit.mode` / `edit.toggle`), so a hand-authored `{{ action "edit.toggle"
|
|
6
|
+
// "✎" }}` cross-ref-checks and compiles exactly like any other action — no
|
|
7
|
+
// bespoke "this action always exists" carve-out anywhere downstream. The
|
|
8
|
+
// per-segment +/- CHROME is a separate, LATER pass
|
|
9
|
+
// (src/config/edit-chrome.ts) that runs on the fully merged, preset-resolved,
|
|
10
|
+
// rootOps-replayed config (inside validateConfig, not here) because it needs
|
|
11
|
+
// data — which segments are in which preset's CURRENT tree — that does not
|
|
12
|
+
// exist yet at this per-file parse stage. Splitting the two halves across two
|
|
13
|
+
// synthesis points is not incidental: the toggle is authorable/cross-ref-able
|
|
14
|
+
// content (like a group's name or a menu's apply action), the chrome is
|
|
15
|
+
// derived data (like a group's lowered body), and each belongs at the stage
|
|
16
|
+
// that has what it needs.
|
|
17
|
+
//
|
|
18
|
+
// [LAW:carrying-cost] DEMAND-DRIVEN, not unconditional — this is the one place
|
|
19
|
+
// this pass diverges from group/menu synthesis's OWN precedent of "reserve
|
|
20
|
+
// unconditionally, synthesize on demand" and leans fully into the "on demand"
|
|
21
|
+
// half: a config that references `{{ action "edit.toggle" … }}` nowhere gets
|
|
22
|
+
// NEITHER the toggle var/action NOR (edit-chrome.ts checks for the SAME
|
|
23
|
+
// action's presence) any per-segment chrome. This matters concretely, not just
|
|
24
|
+
// as a purity concern — `edit.mode` is a `state` variable and `edit.toggle` is
|
|
25
|
+
// a `set` action, and cross-ref.ts requires a global `session.id` variable the
|
|
26
|
+
// instant ANY state var or set action exists anywhere in a config. Synthesizing
|
|
27
|
+
// either unconditionally would force session.id onto every purely-static,
|
|
28
|
+
// non-interactive bar in the corpus — exactly the regression an early version
|
|
29
|
+
// of this pass caused. The reserved namespace stays reserved unconditionally
|
|
30
|
+
// (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
|
|
31
|
+
// conditional.
|
|
32
|
+
|
|
33
|
+
import { createEngine } from "@promptctl/go-template-js";
|
|
34
|
+
import type { Mutable, ValidateCtx } from "./validate-core.js";
|
|
35
|
+
import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
|
|
36
|
+
import type { ActionDecl } from "../action.js";
|
|
37
|
+
import {
|
|
38
|
+
DISCLOSURE_CLOSED,
|
|
39
|
+
disclosureCycleAction,
|
|
40
|
+
disclosureStateVar,
|
|
41
|
+
} from "../disclosure.js";
|
|
42
|
+
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
43
|
+
|
|
44
|
+
// [LAW:one-source-of-truth] The reserved namespace every edit-mode artifact —
|
|
45
|
+
// this toggle AND edit-chrome.ts's per-position +/- actions/segments — lives
|
|
46
|
+
// under, mirroring `groups.`/`menus.`. Exported so edit-chrome.ts's LATER
|
|
47
|
+
// synthesis (and its `isChromeExempt` exclusion of edit-mode's own chrome
|
|
48
|
+
// from being treated as ordinary, removable/addable content) reads the same
|
|
49
|
+
// string, never a second copy.
|
|
50
|
+
export const EDIT_NS = "edit.";
|
|
51
|
+
|
|
52
|
+
// [LAW:single-enforcer] The SessionState key edit mode's on/off state lives
|
|
53
|
+
// at, and the toggle action's identity member. Both edit-chrome.ts (every
|
|
54
|
+
// synthesized affordance's `when` gate) and a hand-authored trigger segment
|
|
55
|
+
// read/write these same two names — one declaration, no drift.
|
|
56
|
+
export const EDIT_MODE_KEY = "edit.mode";
|
|
57
|
+
export const EDIT_TOGGLE_ACTION = "edit.toggle";
|
|
58
|
+
export const EDIT_MODE_OPEN = "open";
|
|
59
|
+
|
|
60
|
+
// [LAW:one-source-of-truth] The predicate every synthesized +/- chrome
|
|
61
|
+
// segment gates on — spelled once here so edit-chrome.ts never hand-rolls
|
|
62
|
+
// the template string a second time.
|
|
63
|
+
export const EDIT_MODE_GATE = `{{ eq .${EDIT_MODE_KEY} "${EDIT_MODE_OPEN}" }}`;
|
|
64
|
+
|
|
65
|
+
// [LAW:single-enforcer] The ONE detector for "does this file want edit mode":
|
|
66
|
+
// a literal `{{ action "edit.toggle" … }}` call somewhere a segment's
|
|
67
|
+
// template/bg/fg can reach — the SAME AST-based approach
|
|
68
|
+
// menu-synth.ts's segmentReferencesMenu uses (robust against whitespace,
|
|
69
|
+
// pipelines, and lookalike text a source-string scan would false-positive
|
|
70
|
+
// or false-negative on), one function name over. A bare engine purely for
|
|
71
|
+
// introspection: it never evaluates, so a malformed template simply yields
|
|
72
|
+
// no match here (registerDslConfig re-parses and reports the real error;
|
|
73
|
+
// [LAW:no-silent-failure] this pass just isn't the one that reports it).
|
|
74
|
+
function referencesEditToggle(template: string): boolean {
|
|
75
|
+
const engine = createEngine<string>({ fromString: (s) => s });
|
|
76
|
+
try {
|
|
77
|
+
return engine
|
|
78
|
+
.parse(template)
|
|
79
|
+
.referencedCalls()
|
|
80
|
+
.some((c) => c.name === "action" && c.args[0] === EDIT_TOGGLE_ACTION);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fileWantsEditMode(out: Readonly<RawDslConfig>): boolean {
|
|
87
|
+
for (const seg of Object.values(out.segments ?? {})) {
|
|
88
|
+
for (const field of [seg.template, seg.bg, seg.fg] as const) {
|
|
89
|
+
if (typeof field === "string" && referencesEditToggle(field)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function synthesizeEditModeToggle(
|
|
98
|
+
ctx: ValidateCtx,
|
|
99
|
+
out: Mutable<RawDslConfig>,
|
|
100
|
+
): void {
|
|
101
|
+
reservedNamespaceCollisions(ctx, out, EDIT_NS, "edit mode");
|
|
102
|
+
if (!fileWantsEditMode(out)) return;
|
|
103
|
+
const variables: Record<string, VariableDecl> = {
|
|
104
|
+
[EDIT_MODE_KEY]: disclosureStateVar(EDIT_MODE_KEY, DISCLOSURE_CLOSED),
|
|
105
|
+
};
|
|
106
|
+
const actions: Record<string, ActionDecl> = {
|
|
107
|
+
[EDIT_TOGGLE_ACTION]: disclosureCycleAction(EDIT_MODE_KEY, EDIT_MODE_OPEN),
|
|
108
|
+
};
|
|
109
|
+
out.variables = { ...(out.variables ?? {}), ...variables };
|
|
110
|
+
out.actions = { ...(out.actions ?? {}), ...actions };
|
|
111
|
+
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
perConfigDomainsFor,
|
|
16
16
|
resolveOptionDomain,
|
|
17
17
|
} from "../../config/option-domain";
|
|
18
|
+
import { addableSegmentDomains } from "../../config/edit-chrome";
|
|
18
19
|
import type { DslConfig } from "../../config/dsl-types";
|
|
19
20
|
import { isGlobalsField } from "../config-overrides-store";
|
|
20
21
|
import { encodeLayoutOp } from "../../config/layout-ops";
|
|
@@ -111,6 +112,34 @@ function actionKeySpecs(
|
|
|
111
112
|
},
|
|
112
113
|
];
|
|
113
114
|
}
|
|
115
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
116
|
+
// sibling: the allow-list is the ENCODED op token for every domain member,
|
|
117
|
+
// not the raw member — mirroring how a literal `insertSegment` contributes
|
|
118
|
+
// its own single encoded token above. A click carrying an option this
|
|
119
|
+
// domain never named — or naming a real segment but the wrong anchor/
|
|
120
|
+
// relation — cannot decode to a member of this list, so it is rejected the
|
|
121
|
+
// same loud way an unknown literal op token already is.
|
|
122
|
+
if ("insertSegmentFrom" in a) {
|
|
123
|
+
return [
|
|
124
|
+
{
|
|
125
|
+
key: a.persist,
|
|
126
|
+
spec: {
|
|
127
|
+
kind: "allow-list",
|
|
128
|
+
allowed: resolveOptionDomain(
|
|
129
|
+
a.insertSegmentFrom,
|
|
130
|
+
perConfigDomains,
|
|
131
|
+
).map((segment) =>
|
|
132
|
+
encodeLayoutOp({
|
|
133
|
+
op: "insert",
|
|
134
|
+
segment,
|
|
135
|
+
anchor: a.anchor,
|
|
136
|
+
relation: a.relation,
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
114
143
|
return [
|
|
115
144
|
{
|
|
116
145
|
key: a.persist,
|
|
@@ -141,7 +170,16 @@ function configKeySeeds(config: DslConfig): ReadonlyMap<string, number> {
|
|
|
141
170
|
|
|
142
171
|
function actionContributions(config: DslConfig): KeySpecContribution[] {
|
|
143
172
|
const seeds = configKeySeeds(config);
|
|
144
|
-
|
|
173
|
+
// [LAW:one-source-of-truth] The "addable segment" domains
|
|
174
|
+
// (edit-chrome.ts's `addableSegmentDomains`) merge in here alongside
|
|
175
|
+
// looks/presets — the same per-preset seam `insertSegmentFrom` resolves
|
|
176
|
+
// through at render (render.ts's registerDslConfig merges the identical
|
|
177
|
+
// map), so the rendered picker options and the derived click gate can
|
|
178
|
+
// never diverge over what's addable.
|
|
179
|
+
const perConfigDomains = new Map([
|
|
180
|
+
...perConfigDomainsFor(config),
|
|
181
|
+
...addableSegmentDomains(config),
|
|
182
|
+
]);
|
|
145
183
|
return Object.values(config.actions).flatMap((a) =>
|
|
146
184
|
actionKeySpecs(a, seeds, perConfigDomains),
|
|
147
185
|
);
|
package/src/dsl/render.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
import { HUE_STEP_VAR } from "../config/dsl-types.js";
|
|
24
24
|
import { perConfigDomainsFor } from "../config/option-domain.js";
|
|
25
25
|
import { PRESET_FLOOR, presetNames, presetRoot } from "../config/presets.js";
|
|
26
|
+
import { addableSegmentDomains } from "../config/edit-chrome.js";
|
|
26
27
|
import type { VariableStore } from "../var-system/store.js";
|
|
27
28
|
import type { SourceRegistry } from "../var-system/sources.js";
|
|
28
29
|
import {
|
|
@@ -340,7 +341,15 @@ export function registerDslConfig(
|
|
|
340
341
|
// one source.
|
|
341
342
|
const lookNames = Object.keys(config.looks);
|
|
342
343
|
const presetOptions = presetNames(config.presets);
|
|
343
|
-
|
|
344
|
+
// [LAW:one-source-of-truth] The "addable segment" per-preset domains merge
|
|
345
|
+
// in here — the SAME map config-validators.ts's deriveConfigActionValidators
|
|
346
|
+
// merges — so a synthesized `insertSegmentFrom` action's rendered options
|
|
347
|
+
// and its derived click gate resolve from one source, never two
|
|
348
|
+
// independently-computed sets.
|
|
349
|
+
const perConfigDomains = new Map([
|
|
350
|
+
...perConfigDomainsFor(config),
|
|
351
|
+
...addableSegmentDomains(config),
|
|
352
|
+
]);
|
|
344
353
|
const engine = createCcCandybarEngine(
|
|
345
354
|
{
|
|
346
355
|
...actionFuncs(actionRuntime),
|
package/src/render/action.ts
CHANGED
|
@@ -145,6 +145,22 @@ export type CompiledActionDecl =
|
|
|
145
145
|
// template-bound option, unlike persist-option), so `op` is precomputed
|
|
146
146
|
// here rather than reconstructed from raw fields at every realize() call.
|
|
147
147
|
| { readonly kind: "layout-op"; readonly key: string; readonly op: LayoutOp }
|
|
148
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
149
|
+
// sibling of layout-op: `anchor`/`relation` are fixed at compile time (the
|
|
150
|
+
// POSITION is author-time data) but the segment name comes from the
|
|
151
|
+
// template's bound option — the option-picking shape `persist-option`
|
|
152
|
+
// already has, minus the value being written VERBATIM. `requireOptionKind`
|
|
153
|
+
// (render/picker.ts) admits this kind alongside set-option/persist-option
|
|
154
|
+
// so a `{{ menu }}`/`{{ picker }}` can drive it with zero picker changes;
|
|
155
|
+
// only the WRITE (realize(), below) differs — it encodes the picked option
|
|
156
|
+
// into a LayoutOp instead of persisting it as-is.
|
|
157
|
+
| {
|
|
158
|
+
readonly kind: "layout-op-option";
|
|
159
|
+
readonly key: string;
|
|
160
|
+
readonly anchor: string;
|
|
161
|
+
readonly relation: "before" | "after";
|
|
162
|
+
readonly options: readonly string[];
|
|
163
|
+
}
|
|
148
164
|
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history
|
|
149
165
|
// step over the overrides layer — `reset`'s fine-grained sibling. No key:
|
|
150
166
|
// there is nothing to carry, since the history stack (not this action) is
|
|
@@ -328,6 +344,17 @@ function compileAction(
|
|
|
328
344
|
},
|
|
329
345
|
};
|
|
330
346
|
}
|
|
347
|
+
if ("insertSegmentFrom" in action) {
|
|
348
|
+
return {
|
|
349
|
+
kind: "layout-op-option",
|
|
350
|
+
key: action.persist,
|
|
351
|
+
anchor: action.anchor,
|
|
352
|
+
relation: action.relation,
|
|
353
|
+
options: [
|
|
354
|
+
...resolveOptionDomain(action.insertSegmentFrom, perConfigDomains),
|
|
355
|
+
],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
331
358
|
return {
|
|
332
359
|
kind: "persist-bounded",
|
|
333
360
|
key: action.persist,
|
|
@@ -587,6 +614,28 @@ function realize(
|
|
|
587
614
|
},
|
|
588
615
|
active: false,
|
|
589
616
|
};
|
|
617
|
+
// [LAW:one-source-of-truth] The picked option (boundValue ?? display — the
|
|
618
|
+
// SAME resolution persist-option uses) becomes the op's `segment`; anchor/
|
|
619
|
+
// relation are the compiled literals. Same wire shape a literal layout-op
|
|
620
|
+
// emits, so the daemon's apply-layout-op handler and undo/redo need no
|
|
621
|
+
// knowledge of where the segment name came from. Never "active": a
|
|
622
|
+
// structural edit is a one-shot trigger, not a current-selection toggle.
|
|
623
|
+
case "layout-op-option": {
|
|
624
|
+
const segment = boundValue ?? display;
|
|
625
|
+
const op: LayoutOp = {
|
|
626
|
+
op: "insert",
|
|
627
|
+
segment,
|
|
628
|
+
anchor: c.anchor,
|
|
629
|
+
relation: c.relation,
|
|
630
|
+
};
|
|
631
|
+
return {
|
|
632
|
+
effect: {
|
|
633
|
+
verb: VERB_APPLY_LAYOUT_OP,
|
|
634
|
+
args: [sessionId, c.key, encodeLayoutOp(op)],
|
|
635
|
+
},
|
|
636
|
+
active: false,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
590
639
|
}
|
|
591
640
|
}
|
|
592
641
|
|
package/src/render/picker.ts
CHANGED
|
@@ -28,7 +28,13 @@ 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 {
|
|
31
|
+
import {
|
|
32
|
+
effectsUrl,
|
|
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";
|
|
32
38
|
import {
|
|
33
39
|
linkFragment,
|
|
34
40
|
readVar,
|
|
@@ -136,26 +142,33 @@ function requireKind<K extends CompiledActionDecl["kind"]>(
|
|
|
136
142
|
return action as Extract<CompiledActionDecl, { kind: K }>;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
|
-
// [LAW:one-source-of-truth] The apply action a picker grid binds to is
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
145
|
+
// [LAW:one-source-of-truth] The apply action a picker grid binds to is one of
|
|
146
|
+
// THREE option-domain-driven kinds (src/render/action.ts): set-option/
|
|
147
|
+
// persist-option (a picked value is WRITTEN VERBATIM — set-option's two
|
|
148
|
+
// durability twins, differing only in wire verb VERB_SET_CONFIG vs
|
|
149
|
+
// VERB_SET_STATE) or layout-op-option (a picked value is ENCODED into a
|
|
150
|
+
// structural LayoutOp before writing — brandon-layout-edit-2gc.3's
|
|
151
|
+
// `insertSegmentFrom`). All three share the same option-domain gate
|
|
152
|
+
// (deriveActionValidators/deriveConfigActionValidators) and the same "pick a
|
|
153
|
+
// cell, apply it" shape; only WHAT the click writes differs, which is
|
|
154
|
+
// realize()'s job, not the picker's. Rejecting any of the three here would be
|
|
155
|
+
// an artificial gap — there is nothing about "picker" that excludes one kind.
|
|
148
156
|
function requireOptionKind(
|
|
149
157
|
runtime: ActionRuntime,
|
|
150
158
|
name: string,
|
|
151
|
-
): Extract<
|
|
159
|
+
): Extract<
|
|
160
|
+
CompiledActionDecl,
|
|
161
|
+
{ kind: "set-option" | "persist-option" | "layout-op-option" }
|
|
162
|
+
> {
|
|
152
163
|
const action = runtime.compiled.get(name);
|
|
153
164
|
if (
|
|
154
165
|
!action ||
|
|
155
|
-
(action.kind !== "set-option" &&
|
|
166
|
+
(action.kind !== "set-option" &&
|
|
167
|
+
action.kind !== "persist-option" &&
|
|
168
|
+
action.kind !== "layout-op-option")
|
|
156
169
|
) {
|
|
157
170
|
throw new Error(
|
|
158
|
-
`picker references action "${name}" which must be a set-option or
|
|
171
|
+
`picker references action "${name}" which must be a set-option, persist-option, or layout-op-option action ({ set, from }, { persist, from }, or { persist, insertSegmentFrom, anchor, relation }), got ${action ? `a ${action.kind} action` : "no such action"}`,
|
|
159
172
|
);
|
|
160
173
|
}
|
|
161
174
|
return action;
|
|
@@ -183,7 +196,13 @@ export function renderPicker(
|
|
|
183
196
|
const apply = requireOptionKind(runtime, applyName);
|
|
184
197
|
const store = runtime.store;
|
|
185
198
|
const sessionId = readVar(store, "session.id");
|
|
186
|
-
|
|
199
|
+
// [LAW:no-defensive-null-guards] layout-op-option carries no `stateVar` —
|
|
200
|
+
// a structural insert is a one-shot trigger, not a persisted single value,
|
|
201
|
+
// so there is no "current selection" to mark. `undefined` here (never a
|
|
202
|
+
// magic sentinel string) makes every option's `option === current` compare
|
|
203
|
+
// false below, structurally rather than by accident.
|
|
204
|
+
const current =
|
|
205
|
+
"stateVar" in apply ? readVar(store, apply.stateVar) : undefined;
|
|
187
206
|
const widths = apply.options.map(cellWidth);
|
|
188
207
|
|
|
189
208
|
// ✕ is always present; ←/→ appear only on a multi-page menu. Reserve arrow
|
|
@@ -243,30 +262,44 @@ export function renderPicker(
|
|
|
243
262
|
{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] },
|
|
244
263
|
]);
|
|
245
264
|
// [LAW:one-source-of-truth] A set-option apply folds its closeOnPick pairs
|
|
246
|
-
// into ONE set-state batch (setState is variadic — see daemon/verbs).
|
|
247
|
-
// persist-option
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
265
|
+
// into ONE set-state batch (setState is variadic — see daemon/verbs).
|
|
266
|
+
// persist-option and layout-op-option cannot: setConfig/apply-layout-op
|
|
267
|
+
// each take exactly one (key, value) pair, so their close pairs (always
|
|
268
|
+
// SessionState — open/page live there regardless of the apply's
|
|
269
|
+
// durability) ride as a SECOND effect in the same dispatch, still one
|
|
270
|
+
// atomic click via effectsUrl's array. layout-op-option's "value" is the
|
|
271
|
+
// ENCODED op (segment=option, anchor/relation from the compiled action),
|
|
272
|
+
// not the option verbatim — the one place this kind's write differs from
|
|
273
|
+
// persist-option's.
|
|
274
|
+
const closeEffect = closeOnPick
|
|
275
|
+
? [{ verb: VERB_SET_STATE, args: [sessionId, ...closeFlat] }]
|
|
276
|
+
: [];
|
|
277
|
+
const optionUrl = (option: string): string => {
|
|
278
|
+
if (apply.kind === "persist-option") {
|
|
279
|
+
return effectsUrl([
|
|
280
|
+
{ verb: VERB_SET_CONFIG, args: [sessionId, apply.key, option] },
|
|
281
|
+
...closeEffect,
|
|
282
|
+
]);
|
|
283
|
+
}
|
|
284
|
+
if (apply.kind === "layout-op-option") {
|
|
285
|
+
const op = encodeLayoutOp({
|
|
286
|
+
op: "insert",
|
|
287
|
+
segment: option,
|
|
288
|
+
anchor: apply.anchor,
|
|
289
|
+
relation: apply.relation,
|
|
290
|
+
});
|
|
291
|
+
return effectsUrl([
|
|
292
|
+
{ verb: VERB_APPLY_LAYOUT_OP, args: [sessionId, apply.key, op] },
|
|
293
|
+
...closeEffect,
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
return effectsUrl([
|
|
297
|
+
{
|
|
298
|
+
verb: VERB_SET_STATE,
|
|
299
|
+
args: [sessionId, apply.key, option, ...(closeOnPick ? closeFlat : [])],
|
|
300
|
+
},
|
|
301
|
+
]);
|
|
302
|
+
};
|
|
270
303
|
|
|
271
304
|
const frags: RichText[] = [linkFragment(PICKER_CLOSE, closeUrl, false)];
|
|
272
305
|
if (pageIdx > 0) {
|