@promptctl/cc-candybar 1.31.0 → 1.32.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/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
|
@@ -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) {
|