@promptctl/cc-candybar 1.38.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 +77 -77
- package/package.json +5 -5
- package/src/config/default-dsl-config.ts +13 -3
- package/src/config/disclosure.ts +54 -0
- package/src/config/edit-chrome.ts +15 -1
- package/src/config/loader/menu-synth.ts +69 -18
- package/src/config/settings-menu.ts +2 -1
- package/src/render/action.ts +11 -9
- package/src/render/menu.ts +75 -21
- package/src/render/picker.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.39.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.39.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.39.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.39.0"
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -28,6 +28,14 @@
|
|
|
28
28
|
|
|
29
29
|
import type { DslConfig, LayoutNode, SegmentDecl } from "./dsl-types.js";
|
|
30
30
|
import { parseDslConfig } from "./dsl-loader.js";
|
|
31
|
+
// [LAW:one-source-of-truth] The bundled drawer's menus author their own
|
|
32
|
+
// disclosure glyphs now that `{{ menu }}` appends none. Interpolating the
|
|
33
|
+
// shared constants keeps the stdlib bar reading like every other disclosure
|
|
34
|
+
// without restating the vocabulary in three string literals.
|
|
35
|
+
import {
|
|
36
|
+
DISCLOSURE_GLYPH_CLOSED,
|
|
37
|
+
DISCLOSURE_GLYPH_OPEN,
|
|
38
|
+
} from "./disclosure.js";
|
|
31
39
|
import { mergeWithDefault } from "./loader/merge.js";
|
|
32
40
|
|
|
33
41
|
// ─── Shared template fragments ───────────────────────────────────────────────
|
|
@@ -1133,14 +1141,16 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
1133
1141
|
charsetControl: {
|
|
1134
1142
|
template:
|
|
1135
1143
|
"{{ .charset.effective }} " +
|
|
1136
|
-
|
|
1144
|
+
`{{ menu "applyCharsetForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
|
|
1145
|
+
'{{ action "resetCharset" "↺" }}',
|
|
1137
1146
|
bg: "surface",
|
|
1138
1147
|
fg: "foreground",
|
|
1139
1148
|
},
|
|
1140
1149
|
colorCompatControl: {
|
|
1141
1150
|
template:
|
|
1142
1151
|
"{{ .colorCompatibility.effective }} " +
|
|
1143
|
-
|
|
1152
|
+
`{{ menu "applyColorCompatForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
|
|
1153
|
+
'{{ action "resetColorCompat" "↺" }}',
|
|
1144
1154
|
bg: "surface",
|
|
1145
1155
|
fg: "foreground",
|
|
1146
1156
|
},
|
|
@@ -1168,7 +1178,7 @@ export const RAW_DEFAULT_DSL_CONFIG = {
|
|
|
1168
1178
|
directoryPaletteControl: {
|
|
1169
1179
|
template:
|
|
1170
1180
|
"🎨 directory " +
|
|
1171
|
-
|
|
1181
|
+
`{{ menu "applyDirectoryPaletteForever" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" }} ` +
|
|
1172
1182
|
'{{ action "resetDirectoryPalette" "↺" }}',
|
|
1173
1183
|
bg: "surface",
|
|
1174
1184
|
fg: "foreground",
|
package/src/config/disclosure.ts
CHANGED
|
@@ -32,9 +32,63 @@ export const DISCLOSURE_CLOSED = "closed";
|
|
|
32
32
|
// [LAW:representation] The disclosure glyph vocabulary — one pair for the whole
|
|
33
33
|
// bar so every disclosure reads the same (trailing the label/content it gates,
|
|
34
34
|
// per pdu.8): collapsed ▸, expanded ▾.
|
|
35
|
+
//
|
|
36
|
+
// [LAW:one-source-of-truth] These are the AUTHORED default, never an emission.
|
|
37
|
+
// Every disclosure splices them into the template it synthesizes — group sugar
|
|
38
|
+
// (loader/layout.ts), the settings menu (settings-menu.ts), the bundled drawer
|
|
39
|
+
// — and a hand-authored config writes whichever glyph it likes, because the
|
|
40
|
+
// trigger's text is a display bound at the call site like any other. Until
|
|
41
|
+
// candybar-settings-ui-aok.4 `{{ menu }}` was the exception, appending ▸/▾ from
|
|
42
|
+
// its own runtime where no author could see or decline it, which is how edit
|
|
43
|
+
// mode's `+` came to render `+▸`.
|
|
35
44
|
export const DISCLOSURE_GLYPH_CLOSED = "▸";
|
|
36
45
|
export const DISCLOSURE_GLYPH_OPEN = "▾";
|
|
37
46
|
|
|
47
|
+
// [LAW:one-source-of-truth] The glyph that CLOSES an open disclosure. The
|
|
48
|
+
// picker body's ✕ has always been this; it lives here now because a trigger can
|
|
49
|
+
// wear it too — edit mode's `+` does, since a `+` whose only open-state cue was
|
|
50
|
+
// the ▸ this change removed would otherwise be indistinguishable from its
|
|
51
|
+
// siblings (three insertion points render byte-identically when one is open,
|
|
52
|
+
// and their dropped bodies are identical too, so row 0 is the only place the
|
|
53
|
+
// answer can live). Two affordances, one meaning, one glyph.
|
|
54
|
+
export const DISCLOSURE_GLYPH_CLOSE = "✕";
|
|
55
|
+
|
|
56
|
+
// [LAW:single-enforcer] THE display rule every multi-state trigger obeys: bind
|
|
57
|
+
// one display per member, or ONE static display that shows in every state. It
|
|
58
|
+
// lives here, beside the toggle machinery, because both disclosure kinds need
|
|
59
|
+
// it at different times — the loader can count a call's arguments statically
|
|
60
|
+
// and wants an ISSUE to report, the renderer holds the evaluated displays and
|
|
61
|
+
// wants to THROW — and a rule spelled once in each place is a rule that drifts.
|
|
62
|
+
// A `{{ menu }}` folds through it with two members (its `[closed, member]`
|
|
63
|
+
// cycle) and a cycle `{{ action }}` with as many as it declares; nothing about
|
|
64
|
+
// the rule is disclosure-specific beyond who calls it.
|
|
65
|
+
export function cycleDisplayIssue(
|
|
66
|
+
subject: string,
|
|
67
|
+
count: number,
|
|
68
|
+
members: number,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
if (count === 0) return `${subject} needs a display (the clickable text)`;
|
|
71
|
+
if (count !== 1 && count !== members) {
|
|
72
|
+
return `${subject} cycles ${members} members; bind one display per member (${members}) or one static display, got ${count}`;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// [LAW:dataflow-not-control-flow] Which display shows is a pure function of
|
|
78
|
+
// (bound displays, current member index): a single static display shows in
|
|
79
|
+
// every state, per-member displays index by the state. Throws the one rule's
|
|
80
|
+
// text rather than silently dropping or repeating an argument.
|
|
81
|
+
export function pickCycleDisplay(
|
|
82
|
+
subject: string,
|
|
83
|
+
displays: readonly string[],
|
|
84
|
+
members: number,
|
|
85
|
+
index: number,
|
|
86
|
+
): string {
|
|
87
|
+
const issue = cycleDisplayIssue(subject, displays.length, members);
|
|
88
|
+
if (issue !== undefined) throw new Error(issue);
|
|
89
|
+
return displays.length === 1 ? displays[0]! : displays[index]!;
|
|
90
|
+
}
|
|
91
|
+
|
|
38
92
|
// [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
|
|
39
93
|
// it holds the open member's name and defaults to `def` (the CLOSED sentinel for
|
|
40
94
|
// an independent disclosure, or an initially-open member for a group's
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
} from "./menu-keys.js";
|
|
51
51
|
import {
|
|
52
52
|
DISCLOSURE_CLOSED,
|
|
53
|
+
DISCLOSURE_GLYPH_CLOSE,
|
|
53
54
|
disclosureCycleAction,
|
|
54
55
|
disclosureStateVar,
|
|
55
56
|
} from "./disclosure.js";
|
|
@@ -211,8 +212,21 @@ function insertChrome(
|
|
|
211
212
|
artifacts.actions[identity] = disclosureCycleAction(stateKey, member);
|
|
212
213
|
artifacts.actions[pageKey] = { set: pageKey, int: true };
|
|
213
214
|
|
|
215
|
+
// The `+` IS the trigger — no appended arrow (candybar-settings-ui-aok.4).
|
|
216
|
+
// Beside a `-` that means something else entirely, a ▸ read as part of the
|
|
217
|
+
// affordance rather than as a disclosure hint, so the trigger names the ACTION
|
|
218
|
+
// its click performs instead: `+` inserts here, `✕` closes what `+` opened —
|
|
219
|
+
// the same glyph, and the same effect, as the body's own close cell.
|
|
220
|
+
//
|
|
221
|
+
// [LAW:no-silent-failure] It is deliberately NOT one static display. A preset
|
|
222
|
+
// has N insertion points whose rendered rows are byte-identical, and their
|
|
223
|
+
// dropped bodies are identical too — so with no per-state display, an open `+`
|
|
224
|
+
// is indistinguishable from the two beside it and the bar silently stops
|
|
225
|
+
// answering "which one did I open". The tint that marks other open menus
|
|
226
|
+
// (node-registry's `drops.length > 0`) cannot answer it either: this segment
|
|
227
|
+
// declares no bg, so there is nothing to tint.
|
|
214
228
|
artifacts.segments[chromeSegName] = {
|
|
215
|
-
template:
|
|
229
|
+
template: `{{ menu "${applyName}" "+" "${DISCLOSURE_GLYPH_CLOSE}" }}`,
|
|
216
230
|
when: EDIT_MODE_GATE,
|
|
217
231
|
};
|
|
218
232
|
return { kind: "segment", name: chromeSegName };
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
type MenuOptions,
|
|
43
43
|
} from "../menu-keys.js";
|
|
44
44
|
import {
|
|
45
|
+
cycleDisplayIssue,
|
|
45
46
|
DISCLOSURE_CLOSED,
|
|
46
47
|
disclosureCycleAction,
|
|
47
48
|
disclosureStateVar,
|
|
@@ -60,13 +61,16 @@ import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
|
60
61
|
const MENU_FUNC = "menu";
|
|
61
62
|
|
|
62
63
|
// [LAW:types-are-the-program] The `{{ menu }}` surface, mirroring the render
|
|
63
|
-
// helper's signature `menu "apply" [(dict …)]`: the apply name
|
|
64
|
-
// a required string literal)
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
64
|
+
// helper's signature `menu "apply" display… [(dict …)]`: the apply name
|
|
65
|
+
// (identity member, a required string literal), the trigger's authored display
|
|
66
|
+
// text (one per state or one static — the arity is statically countable, so it
|
|
67
|
+
// is checked here), and ONE optional trailing options dict — closeOnPick /
|
|
68
|
+
// paged / key, all statically readable via `staticDictEntries`. Displays
|
|
69
|
+
// themselves are NOT required to be literals; identity does not depend on
|
|
70
|
+
// them, exactly as a cycle `{{ action }}`'s displays are free. Every removed
|
|
71
|
+
// spelling is rejected with a migration-pointing error, never silently
|
|
68
72
|
// reinterpreted [LAW:no-silent-failure].
|
|
69
|
-
const MIGRATION = `the
|
|
73
|
+
const MIGRATION = `a menu binds its trigger text the way a cycle action binds a display — write {{ menu "applyTheme" "▸" "▾" }} (one per state) or {{ menu "insertHere" "+" }} (one static display for both), with the rare knobs in ONE trailing dict: {{ menu "applyTheme" "▸" "▾" (dict "closeOnPick" true "paged" false "key" "pickers") }} (defaults: closeOnPick false, paged true, no key). The renderer no longer appends ▸/▾ of its own (candybar-settings-ui-aok.4), and the older positional tail ("pageAction" closeOnPick paged "key") was removed — the page cursor is synthesized from the menu's identity`;
|
|
70
74
|
|
|
71
75
|
// [LAW:dataflow-not-control-flow] One total analysis of a `{{ menu }}` call site:
|
|
72
76
|
// every reachable argument shape lands in exactly one arm — a usable identity
|
|
@@ -80,33 +84,80 @@ type MenuAnalysis =
|
|
|
80
84
|
}
|
|
81
85
|
| { readonly kind: "issue"; readonly message: string };
|
|
82
86
|
|
|
87
|
+
type ArgExpr = ReferencedCall["argExprs"][number];
|
|
88
|
+
|
|
89
|
+
const isDictCall = (e: ArgExpr): boolean =>
|
|
90
|
+
e.kind === "call" && e.name === "dict";
|
|
91
|
+
|
|
92
|
+
// [LAW:one-source-of-truth] The two sides split the tail on different evidence —
|
|
93
|
+
// exprs here, evaluated values in `parseMenuArgs` — so the loader admits only
|
|
94
|
+
// call sites where those two readings PROVABLY coincide. The renderer's split
|
|
95
|
+
// asks one question of the last value, "is it an object", so a literal answers
|
|
96
|
+
// it here: a parse-time constant evaluates to itself and can never become the
|
|
97
|
+
// options dict. A literal `(dict …)` always does. Everything else in that slot
|
|
98
|
+
// is classified by whatever it happens to evaluate to.
|
|
99
|
+
const isNonObjectLiteral = (e: ArgExpr): boolean =>
|
|
100
|
+
e.kind === "literal" && typeof e.value !== "object";
|
|
101
|
+
|
|
102
|
+
// The display-arity rule used as a predicate; the message is the caller's
|
|
103
|
+
// business, so the subject never surfaces. [LAW:single-enforcer] — legality is
|
|
104
|
+
// read off the disclosure primitive, never restated as a count comparison.
|
|
105
|
+
const legalDisplayCount = (count: number): boolean =>
|
|
106
|
+
cycleDisplayIssue("", count, 2) === undefined;
|
|
107
|
+
|
|
83
108
|
function analyzeMenuCall(call: ReferencedCall): MenuAnalysis {
|
|
84
109
|
const issue = (message: string): MenuAnalysis => ({ kind: "issue", message });
|
|
85
|
-
const [applyArg,
|
|
110
|
+
const [applyArg, ...tail] = call.argExprs;
|
|
86
111
|
if (applyArg === undefined) {
|
|
87
112
|
return issue(
|
|
88
|
-
`with no arguments — it takes an apply-action name (e.g. {{ menu "applyTheme" }})`,
|
|
113
|
+
`with no arguments — it takes an apply-action name and its trigger text (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
|
|
89
114
|
);
|
|
90
115
|
}
|
|
91
|
-
if (call.argExprs.length > 2) {
|
|
92
|
-
return issue(`with more than two arguments — ${MIGRATION}`);
|
|
93
|
-
}
|
|
94
116
|
if (applyArg.kind !== "literal" || typeof applyArg.value !== "string") {
|
|
95
117
|
return issue(
|
|
96
|
-
`whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" }})`,
|
|
118
|
+
`whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "▸" "▾" }})`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
// [LAW:types-are-the-program] The dict is the LAST argument when present;
|
|
122
|
+
// everything before it is a display. Splitting on that one position is the
|
|
123
|
+
// whole grammar, and it is the same split `parseMenuArgs` performs on the
|
|
124
|
+
// evaluated tail at render — one shape, read twice from the two things each
|
|
125
|
+
// side has (exprs here, values there).
|
|
126
|
+
const last = tail[tail.length - 1];
|
|
127
|
+
const optsArg = last !== undefined && isDictCall(last) ? last : undefined;
|
|
128
|
+
const displays = optsArg === undefined ? tail : tail.slice(0, -1);
|
|
129
|
+
if (displays.some(isDictCall)) {
|
|
130
|
+
return issue(
|
|
131
|
+
`whose options (dict …) is not its last argument — ${MIGRATION}`,
|
|
97
132
|
);
|
|
98
133
|
}
|
|
134
|
+
// [LAW:no-silent-failure] The last slot is the one both readings can claim.
|
|
135
|
+
// When the expr there is not provably one or the other AND dropping it still
|
|
136
|
+
// leaves a legal display count, the renderer's value-based split can land on
|
|
137
|
+
// a DIFFERENT reading than this one — same call, two shapes, no error either
|
|
138
|
+
// side: the options dict skips `staticDictEntries` (so a dynamic `key` derives
|
|
139
|
+
// a state key with no synthesized var behind it, and the menu never opens) or
|
|
140
|
+
// a display vanishes into the static form. Reject that call site; an explicit
|
|
141
|
+
// trailing `(dict …)` disambiguates it and keeps dynamic displays legal.
|
|
142
|
+
// Where the alternate reading is an ILLEGAL count the renderer throws instead
|
|
143
|
+
// of diverging, so it stays accepted — loudness, not refusal, is the bar.
|
|
99
144
|
if (
|
|
100
|
-
|
|
101
|
-
|
|
145
|
+
last !== undefined &&
|
|
146
|
+
optsArg === undefined &&
|
|
147
|
+
!isNonObjectLiteral(last) &&
|
|
148
|
+
legalDisplayCount(displays.length - 1)
|
|
102
149
|
) {
|
|
103
|
-
// A literal (the old page-action string / positional bool), a dynamic value,
|
|
104
|
-
// or a non-dict call: none is an options dict — one migration error covers
|
|
105
|
-
// the whole family [LAW:one-type-per-behavior].
|
|
106
150
|
return issue(
|
|
107
|
-
`whose
|
|
151
|
+
`whose last argument is neither a literal nor a literal (dict …) — the renderer tells a display from the options dict by the value it evaluates to, so this call could be read as ${displays.length} displays or as ${displays.length - 1} plus options, and both are legal. Make the options explicit as a trailing (dict …) — {{ menu "${applyArg.value}" (printf "…") (printf "…") (dict) }} binds dynamic displays unambiguously — or bind the trigger text as literals`,
|
|
108
152
|
);
|
|
109
153
|
}
|
|
154
|
+
// [LAW:single-enforcer] The display-arity rule is the disclosure primitive's,
|
|
155
|
+
// the same one the renderer picks through — checked HERE too because the
|
|
156
|
+
// count is statically known, so an unauthored trigger is a load error naming
|
|
157
|
+
// the fix rather than a diagnostic glyph on the next render.
|
|
158
|
+
// "whose trigger …" completes the caller's `segment "X" has a {{ menu }} `.
|
|
159
|
+
const arity = cycleDisplayIssue("whose trigger", displays.length, 2);
|
|
160
|
+
if (arity !== undefined) return issue(`${arity} — ${MIGRATION}`);
|
|
110
161
|
const entries = optsArg === undefined ? {} : staticDictEntries(optsArg);
|
|
111
162
|
if (entries === null) {
|
|
112
163
|
return issue(
|
|
@@ -578,7 +578,8 @@ function declareSettingControls(artifacts: MenuArtifacts): void {
|
|
|
578
578
|
artifacts.segments[seg] = {
|
|
579
579
|
template:
|
|
580
580
|
`${c.glyph} {{ .${c.effectiveVar} }} ` +
|
|
581
|
-
`{{ menu "${apply}"
|
|
581
|
+
`{{ menu "${apply}" "${DISCLOSURE_GLYPH_CLOSED}" "${DISCLOSURE_GLYPH_OPEN}" ` +
|
|
582
|
+
`(dict "key" "${PICKER_KEY}" "closeOnPick" true) }} ` +
|
|
582
583
|
`{{ action "${controlReset(c.name)}" "↺" }}`,
|
|
583
584
|
bg: "surface",
|
|
584
585
|
fg: "foreground",
|
package/src/render/action.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type ActionDecl,
|
|
33
33
|
} from "../config/action.js";
|
|
34
34
|
import { resolveOptionDomain } from "../config/option-domain.js";
|
|
35
|
+
import { pickCycleDisplay } from "../config/disclosure.js";
|
|
35
36
|
import { encodeLayoutOp, type LayoutOp } from "../config/layout-ops.js";
|
|
36
37
|
import { parseSessionBoolean, type StripStyle } from "../themes/policy.js";
|
|
37
38
|
import {
|
|
@@ -800,15 +801,16 @@ function selectDisplay(
|
|
|
800
801
|
throw new Error(`action "${name}" needs a display (the clickable text)`);
|
|
801
802
|
}
|
|
802
803
|
if (action.kind === "set-cycle" || action.kind === "persist-cycle") {
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
displays
|
|
810
|
-
|
|
811
|
-
|
|
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
|
+
);
|
|
812
814
|
return { display, boundValue: undefined };
|
|
813
815
|
}
|
|
814
816
|
if (displays.length > 2) {
|
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
|
};
|
package/src/render/picker.ts
CHANGED
|
@@ -37,8 +37,8 @@ import {
|
|
|
37
37
|
type ActionRuntime,
|
|
38
38
|
type CompiledActionDecl,
|
|
39
39
|
} from "./action.js";
|
|
40
|
+
import { DISCLOSURE_GLYPH_CLOSE } from "../config/disclosure.js";
|
|
40
41
|
|
|
41
|
-
const PICKER_CLOSE = "✕";
|
|
42
42
|
const PICKER_PREV = "←";
|
|
43
43
|
const PICKER_NEXT = "→";
|
|
44
44
|
|
|
@@ -242,7 +242,7 @@ export function renderPicker(
|
|
|
242
242
|
2 * runtime.padding,
|
|
243
243
|
)
|
|
244
244
|
: Infinity;
|
|
245
|
-
const closeReserve = cellWidth(
|
|
245
|
+
const closeReserve = cellWidth(DISCLOSURE_GLYPH_CLOSE) + 1;
|
|
246
246
|
const arrowReserve = cellWidth(PICKER_PREV) + 1 + cellWidth(PICKER_NEXT) + 1;
|
|
247
247
|
const firstPass = paginate(widths, available, closeReserve);
|
|
248
248
|
const pages =
|
|
@@ -303,7 +303,9 @@ export function renderPicker(
|
|
|
303
303
|
: effectsUrl([...effects, ...closeEffect]);
|
|
304
304
|
};
|
|
305
305
|
|
|
306
|
-
const frags: RichText[] = [
|
|
306
|
+
const frags: RichText[] = [
|
|
307
|
+
linkFragment(DISCLOSURE_GLYPH_CLOSE, closeUrl, false),
|
|
308
|
+
];
|
|
307
309
|
if (pageIdx > 0) {
|
|
308
310
|
frags.push(linkFragment(PICKER_PREV, pageUrl(pageIdx - 1), false));
|
|
309
311
|
}
|