@promptctl/cc-candybar 1.40.0 → 1.41.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 +94 -92
- package/package.json +5 -5
- package/src/config/disclosure.ts +61 -0
- package/src/config/edit-chrome.ts +114 -21
- package/src/config/help.ts +151 -0
- package/src/config/loader/edit-mode.ts +15 -3
- package/src/config/loader/layout.ts +8 -10
- package/src/config/settings-menu.ts +107 -28
- package/src/help-text.ts +28 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.41.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.41.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.41.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.41.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.41.0"
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/config/disclosure.ts
CHANGED
|
@@ -89,6 +89,67 @@ export function pickCycleDisplay(
|
|
|
89
89
|
return displays.length === 1 ? displays[0]! : displays[index]!;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// [LAW:one-source-of-truth] Go-template string-literal escaping for any DISPLAY
|
|
93
|
+
// text a synthesis splices INSIDE a quoted `{{ }}` argument of a template it
|
|
94
|
+
// emits — a group's label, a preset name in the reset banner, a `(?)` trigger's
|
|
95
|
+
// closed/open glyphs. NOT for a template's own body text, which is source rather
|
|
96
|
+
// than a splice: a help line is assigned verbatim (help.ts) because escaping one
|
|
97
|
+
// would put a backslash on the bar. It lives here, beside the two splices
|
|
98
|
+
// that need it most, because it was already two verbatim copies (loader/layout.ts
|
|
99
|
+
// and edit-chrome.ts, whose comment deferred the merge until "one small rule"
|
|
100
|
+
// earned its own home). The `(?)` affordance was the third caller, so it did.
|
|
101
|
+
export function escapeTemplateLiteral(s: string): string {
|
|
102
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// [LAW:types-are-the-program] One open disclosure, named by the two strings that
|
|
106
|
+
// decide it: the VARIABLE a body reads and the MEMBER value that means "this one
|
|
107
|
+
// is open". They are distinct because a group's variable is per-group
|
|
108
|
+
// (`groups.<name>`) while its state KEY may be shared with accordion siblings —
|
|
109
|
+
// so the pair, never a lone key, is what identifies an open state.
|
|
110
|
+
export interface DisclosureRef {
|
|
111
|
+
readonly variable: string;
|
|
112
|
+
readonly member: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// [LAW:single-enforcer] THE body predicate a disclosure implies. Variadic
|
|
116
|
+
// because nesting is conjunction and nothing else: a row inside two disclosures
|
|
117
|
+
// is open when both are, which is one list, not a compound spelling. Callers
|
|
118
|
+
// that used to hand-write `{{ eq .x "open" }}` beside `{{ and (eq .x "open")
|
|
119
|
+
// (eq .y "open") }}` now pass one ref or two to one function — the shape stops
|
|
120
|
+
// varying with the depth [LAW:dataflow-not-control-flow].
|
|
121
|
+
//
|
|
122
|
+
// `and` is variadic in Go templates and returns its sole argument when given
|
|
123
|
+
// one, so the single-disclosure case needs no separate spelling.
|
|
124
|
+
//
|
|
125
|
+
// [LAW:types-are-the-program] The first ref is a separate parameter so a gate
|
|
126
|
+
// over ZERO disclosures — which would emit an argument-less `{{ and }}` and gate
|
|
127
|
+
// on nothing — is unrepresentable, with no runtime guard to state it.
|
|
128
|
+
export function disclosureGate(
|
|
129
|
+
first: DisclosureRef,
|
|
130
|
+
...rest: readonly DisclosureRef[]
|
|
131
|
+
): string {
|
|
132
|
+
const terms = [first, ...rest]
|
|
133
|
+
.map((o) => `(eq .${o.variable} "${escapeTemplateLiteral(o.member)}")`)
|
|
134
|
+
.join(" ");
|
|
135
|
+
return `{{ and ${terms} }}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// [LAW:single-enforcer] THE trigger template a disclosure's toggle segment
|
|
139
|
+
// carries: one `{{ action }}` over the cycle action, binding the author's text
|
|
140
|
+
// per state — closed first, matching the cycle's own closed-first member order
|
|
141
|
+
// so the display index and the member index are the same number. Since .4 every
|
|
142
|
+
// trigger authors its own text (the runtime appends no glyph), which makes this
|
|
143
|
+
// the one place the binding is spelled; before it, five sites spelled it and the
|
|
144
|
+
// `+▸` double-glyph bug lived in the gap between two of them.
|
|
145
|
+
export function disclosureTrigger(
|
|
146
|
+
action: string,
|
|
147
|
+
closed: string,
|
|
148
|
+
open: string,
|
|
149
|
+
): string {
|
|
150
|
+
return `{{ action "${action}" "${escapeTemplateLiteral(closed)}" "${escapeTemplateLiteral(open)}" }}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
92
153
|
// [LAW:single-enforcer] THE backing `state` variable a disclosure key implies:
|
|
93
154
|
// it holds the open member's name and defaults to `def` (the CLOSED sentinel for
|
|
94
155
|
// an independent disclosure, or an initially-open member for a group's
|
|
@@ -36,9 +36,12 @@ import { presetRootOpsKey } from "./loader/persist-target.js";
|
|
|
36
36
|
import { ident } from "./ident.js";
|
|
37
37
|
import {
|
|
38
38
|
EDIT_MODE_GATE,
|
|
39
|
+
EDIT_MODE_REF,
|
|
39
40
|
EDIT_NS,
|
|
40
41
|
EDIT_TOGGLE_ACTION,
|
|
41
42
|
} from "./loader/edit-mode.js";
|
|
43
|
+
import { declareHelp, type HelpDisclosure } from "./help.js";
|
|
44
|
+
import { EDIT_MODE_HELP } from "../help-text.js";
|
|
42
45
|
import { GROUP_NS } from "./loader/layout.js";
|
|
43
46
|
import { SETTINGS_NS } from "./settings-menu.js";
|
|
44
47
|
import {
|
|
@@ -51,6 +54,7 @@ import {
|
|
|
51
54
|
import {
|
|
52
55
|
DISCLOSURE_CLOSED,
|
|
53
56
|
DISCLOSURE_GLYPH_CLOSE,
|
|
57
|
+
escapeTemplateLiteral,
|
|
54
58
|
disclosureCycleAction,
|
|
55
59
|
disclosureStateVar,
|
|
56
60
|
} from "./disclosure.js";
|
|
@@ -88,17 +92,6 @@ function isChromeExempt(name: string): boolean {
|
|
|
88
92
|
);
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
// [LAW:no-silent-failure] Go-template string-literal escaping for a preset
|
|
92
|
-
// NAME spliced into DISPLAY text (prependCustomizedBanner) rather than an
|
|
93
|
-
// identifier — the same hazard loader/layout.ts's group `label` synthesis
|
|
94
|
-
// already guards against, reimplemented here since that copy is
|
|
95
|
-
// module-private and this one small rule doesn't warrant its own shared
|
|
96
|
-
// module the way `ident` (checked for agreement across three sites —
|
|
97
|
-
// see ./ident.ts) did.
|
|
98
|
-
function escapeTemplateLiteral(s: string): string {
|
|
99
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
100
|
-
}
|
|
101
|
-
|
|
102
95
|
// [LAW:one-source-of-truth] Every synthesized decl this pass produces, keyed
|
|
103
96
|
// by its final name — one accumulator threaded through every preset's splice
|
|
104
97
|
// so cross-preset names (disambiguated by `presetIdent`) can never collide.
|
|
@@ -312,17 +305,23 @@ function spliceContainer(
|
|
|
312
305
|
// synthesized the SAME way (one reset action targeting this preset's exact
|
|
313
306
|
// `persist` key, one segment hosting `{{ action }}`), UNCONDITIONALLY, with
|
|
314
307
|
// visibility carried entirely by PRESET_CUSTOMIZED_GATE
|
|
315
|
-
// [LAW:dataflow-not-control-flow].
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
308
|
+
// [LAW:dataflow-not-control-flow]. It gets its own ROW (not a slot in the
|
|
309
|
+
// row-interleaved chrome spliceContainer builds) because it is not bound to any
|
|
310
|
+
// one segment gap — it is a fact about the whole tree — visible or not by the
|
|
311
|
+
// SAME `when` every other synthesized affordance here already uses.
|
|
312
|
+
//
|
|
313
|
+
// candybar-settings-ui-aok.6 hangs edit mode's `(?)` off the same content — its
|
|
314
|
+
// BODY is a per-preset row on the same footing as the banner, so this function
|
|
315
|
+
// now brackets the content rather than only preceding it, which is what its name
|
|
316
|
+
// says and why it is no longer "prepend". Its TRIGGER is not a row; see
|
|
317
|
+
// withTrailingCell.
|
|
318
|
+
function wrapWithPresetRows(
|
|
321
319
|
splicedRoot: LayoutNode,
|
|
322
320
|
presetName: string,
|
|
323
321
|
presetIdent: string,
|
|
324
322
|
rootOpsKey: string,
|
|
325
323
|
artifacts: ChromeArtifacts,
|
|
324
|
+
help: HelpDisclosure,
|
|
326
325
|
): LayoutNode {
|
|
327
326
|
const actionName = `${EDIT_NS}${presetIdent}.resetLayout`;
|
|
328
327
|
const chromeSegName = `${EDIT_NS}${presetIdent}.customized`;
|
|
@@ -365,11 +364,85 @@ function prependCustomizedBanner(
|
|
|
365
364
|
return {
|
|
366
365
|
kind: "container",
|
|
367
366
|
direction: "vertical",
|
|
368
|
-
children: [
|
|
367
|
+
children: [
|
|
368
|
+
{ kind: "segment", name: chromeSegName },
|
|
369
|
+
withTrailingCell(splicedRoot, help.trigger),
|
|
370
|
+
// The body is a ROW of its own, and only while the disclosure is open —
|
|
371
|
+
// dropping BELOW the row that revealed it, like every other disclosure
|
|
372
|
+
// body in this codebase.
|
|
373
|
+
help.body,
|
|
374
|
+
],
|
|
369
375
|
...(splicedRoot.when !== undefined && { when: splicedRoot.when }),
|
|
370
376
|
};
|
|
371
377
|
}
|
|
372
378
|
|
|
379
|
+
// [LAW:one-source-of-truth] `HelpDisclosure.trigger` is a CELL, and its contract
|
|
380
|
+
// is that the caller joins it to a row it ALREADY HAS — the settings menu pushes
|
|
381
|
+
// it into the row holding `persist?`. Edit mode's rows are the ones
|
|
382
|
+
// spliceContainer just built, so the trigger joins the last of them. Two
|
|
383
|
+
// constraints pin that placement and nothing else satisfies both:
|
|
384
|
+
//
|
|
385
|
+
// - Closed help must cost no LINE. A trigger given its own vertical slot is a
|
|
386
|
+
// permanent row for the whole time edit mode is on, since a trigger's `when`
|
|
387
|
+
// is its host surface's, never its own open state (a trigger you must open in
|
|
388
|
+
// order to see could never be opened).
|
|
389
|
+
// - Closed help must cost no COLOUR. The hue cursor (src/dsl/render.ts:696)
|
|
390
|
+
// advances in pre-order over every segment leaf — VISIBLE OR NOT, so that
|
|
391
|
+
// toggling a disclosure never recolours the bar — which means a leaf inserted
|
|
392
|
+
// AHEAD of the content shifts the hue index of everything after it. An
|
|
393
|
+
// earlier draft put the `(?)` first and recoloured every cell of the bundled
|
|
394
|
+
// default (status row 33;41;59 → 49;36;52) with edit mode still OFF. Trailing
|
|
395
|
+
// the last row is the one position that moves no other leaf.
|
|
396
|
+
//
|
|
397
|
+
// Those two together disqualify the reset-banner row, which sits above the
|
|
398
|
+
// content.
|
|
399
|
+
//
|
|
400
|
+
// A THIRD requirement decides how far the descent may go, and it outranks the
|
|
401
|
+
// other two: the trigger must be visible exactly when EDIT MODE is, since a
|
|
402
|
+
// trigger you must already have opened something else to reach is not a trigger.
|
|
403
|
+
// A container's `when` reaches every descendant, so descending into a
|
|
404
|
+
// `when`-bearing container would silently make its gate the trigger's gate.
|
|
405
|
+
// `kind: "group"` is the shape that makes this concrete rather than theoretical:
|
|
406
|
+
// lowerGroup emits `{vertical, children: [toggle, {…, when: groupGate}]}`, so a
|
|
407
|
+
// preset root ending in a group — ordinary authoring the A-grammar endorses —
|
|
408
|
+
// would otherwise put the `(?)` INSIDE that group's collapsible body, gated on
|
|
409
|
+
// edit mode AND a disclosure most groups default closed. Pairing beside a gated
|
|
410
|
+
// SEGMENT is a different act and stays allowed: `{h: [gatedSeg, cell]}` puts the
|
|
411
|
+
// cell beside the gate rather than under it.
|
|
412
|
+
//
|
|
413
|
+
// For a root whose last row is gated the three requirements are jointly
|
|
414
|
+
// unsatisfiable, so the priority is stated rather than left to whichever branch
|
|
415
|
+
// the recursion happens to reach: visible (always) > no recolour (always, since
|
|
416
|
+
// appending is still after every existing leaf) > no extra line (surrendered
|
|
417
|
+
// here, in exactly the configs where riding a row was never possible).
|
|
418
|
+
//
|
|
419
|
+
// [LAW:dataflow-not-control-flow] Total over the node shapes with no guard: a
|
|
420
|
+
// segment is a row of one that cannot hold a second cell, so it pairs into one;
|
|
421
|
+
// a vertical container's rows are its children, so it descends into the last one
|
|
422
|
+
// it may; anything else appends. An empty container has no last child and
|
|
423
|
+
// appends, which is the same answer.
|
|
424
|
+
function withTrailingCell(node: LayoutNode, cell: LayoutNode): LayoutNode {
|
|
425
|
+
if (node.kind === "segment") {
|
|
426
|
+
return {
|
|
427
|
+
kind: "container",
|
|
428
|
+
direction: "horizontal",
|
|
429
|
+
children: [node, cell],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const last = node.children.at(-1);
|
|
433
|
+
if (
|
|
434
|
+
node.direction === "vertical" &&
|
|
435
|
+
last !== undefined &&
|
|
436
|
+
(last.kind === "segment" || last.when === undefined)
|
|
437
|
+
) {
|
|
438
|
+
return {
|
|
439
|
+
...node,
|
|
440
|
+
children: [...node.children.slice(0, -1), withTrailingCell(last, cell)],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return { ...node, children: [...node.children, cell] };
|
|
444
|
+
}
|
|
445
|
+
|
|
373
446
|
// One preset's chrome-spliced root. A bare-segment root (the A-grammar
|
|
374
447
|
// collapses a single top-level segment ref to `{ kind: "segment", name }`
|
|
375
448
|
// with no enclosing container) is wrapped in a synthetic horizontal
|
|
@@ -379,6 +452,7 @@ function spliceEditChromeForPreset(
|
|
|
379
452
|
config: DslConfig,
|
|
380
453
|
presetName: string,
|
|
381
454
|
artifacts: ChromeArtifacts,
|
|
455
|
+
help: HelpDisclosure,
|
|
382
456
|
): LayoutNode {
|
|
383
457
|
const { node } = presetRoot(config, presetName);
|
|
384
458
|
const rootOpsKey = presetRootOpsKey(presetName);
|
|
@@ -387,7 +461,7 @@ function spliceEditChromeForPreset(
|
|
|
387
461
|
const posCounter = { n: 0 };
|
|
388
462
|
// [LAW:no-silent-failure] The bare-segment-root case (the A-grammar's
|
|
389
463
|
// `{ seg, when }` shorthand is a legal PresetDecl.root) carries its OWN
|
|
390
|
-
// `when` onto this synthetic wrapper too —
|
|
464
|
+
// `when` onto this synthetic wrapper too — wrapWithPresetRows's own
|
|
391
465
|
// when-carry-up reads `splicedRoot.when`, which is this wrapper's `when`
|
|
392
466
|
// once spliceContainer's `{...node, children}` passes it through
|
|
393
467
|
// unchanged; without copying it here, a bare-segment preset root's own
|
|
@@ -410,12 +484,13 @@ function spliceEditChromeForPreset(
|
|
|
410
484
|
artifacts,
|
|
411
485
|
posCounter,
|
|
412
486
|
);
|
|
413
|
-
return
|
|
487
|
+
return wrapWithPresetRows(
|
|
414
488
|
spliced,
|
|
415
489
|
presetName,
|
|
416
490
|
presetIdent,
|
|
417
491
|
rootOpsKey,
|
|
418
492
|
artifacts,
|
|
493
|
+
help,
|
|
419
494
|
);
|
|
420
495
|
}
|
|
421
496
|
|
|
@@ -444,9 +519,27 @@ export function synthesizeEditChrome(config: DslConfig): DslConfig {
|
|
|
444
519
|
actions: {},
|
|
445
520
|
segments: {},
|
|
446
521
|
};
|
|
522
|
+
// [LAW:one-source-of-truth] Edit mode's `(?)` is minted ONCE and merely
|
|
523
|
+
// REFERENCED from every preset root — the same move the settings menu makes
|
|
524
|
+
// with its anchor, and for the same reason: one disclosure means one open
|
|
525
|
+
// state, so switching presets cannot land you beside a second `(?)` that
|
|
526
|
+
// disagrees about whether help is showing. The text is identical for every
|
|
527
|
+
// preset because what `+` and `-` do is a fact about edit mode, not about a
|
|
528
|
+
// layout.
|
|
529
|
+
const help = declareHelp(
|
|
530
|
+
`${EDIT_NS}help`,
|
|
531
|
+
EDIT_MODE_HELP,
|
|
532
|
+
[EDIT_MODE_REF],
|
|
533
|
+
artifacts,
|
|
534
|
+
);
|
|
447
535
|
const presets: Record<string, PresetDecl> = { ...config.presets };
|
|
448
536
|
for (const name of presetNames(config.presets)) {
|
|
449
|
-
const splicedRoot = spliceEditChromeForPreset(
|
|
537
|
+
const splicedRoot = spliceEditChromeForPreset(
|
|
538
|
+
config,
|
|
539
|
+
name,
|
|
540
|
+
artifacts,
|
|
541
|
+
help,
|
|
542
|
+
);
|
|
450
543
|
presets[name] = {
|
|
451
544
|
...presetByName(config.presets, name),
|
|
452
545
|
root: splicedRoot,
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// [LAW:one-type-per-behavior] candybar-settings-ui-aok.6 — the `(?)` affordance.
|
|
2
|
+
// It is NOT a fourth kind of disclosure. A `(?)` is the SAME binary toggle
|
|
3
|
+
// `kind: "group"`, `{{ menu }}`, edit mode's trigger and the global settings menu
|
|
4
|
+
// all are; it differs from a group in exactly two VALUES — the text its trigger
|
|
5
|
+
// binds (`(?)`/`✕` rather than `label ▸`/`label ▾`) and what its body contains
|
|
6
|
+
// (help lines rather than arbitrary layout). So every artifact below comes out
|
|
7
|
+
// of `disclosure.ts`, and this module contributes no state, no gate, and no
|
|
8
|
+
// glyph rule of its own — only the two values and the shape they fill.
|
|
9
|
+
//
|
|
10
|
+
// The ticket that asked for this predicted the alternative: "if it arrives as
|
|
11
|
+
// another hand-rolled set of four artifacts, the finding to report is that the
|
|
12
|
+
// disclosure itself wants to be one thing". It did. `disclosure.ts` had
|
|
13
|
+
// single-sourced only the two artifacts that are DATA (the state var, the cycle
|
|
14
|
+
// action) and left the two that are STRINGS — the trigger template and the body
|
|
15
|
+
// gate — hand-written at five sites, which is where the `+▸` double-glyph bug of
|
|
16
|
+
// .4 lived. `disclosureTrigger`/`disclosureGate` finish that extraction, and
|
|
17
|
+
// this file is the first caller that never had a copy to begin with.
|
|
18
|
+
//
|
|
19
|
+
// [LAW:one-way-deps] Content is a PARAMETER, never an import: this module knows
|
|
20
|
+
// how to mint a help disclosure and nothing about what any particular one says.
|
|
21
|
+
// The bundled sentences live in `src/help-text.ts` — the corpus `--help` prints
|
|
22
|
+
// — and the two synthesis passes that place a `(?)` pass them in. That is what
|
|
23
|
+
// keeps the bar's help and the CLI's help one set of strings rather than two
|
|
24
|
+
// hand-maintained copies [LAW:one-source-of-truth].
|
|
25
|
+
|
|
26
|
+
import type { ActionDecl } from "./action.js";
|
|
27
|
+
import type { LayoutNode, SegmentDecl, VariableDecl } from "./dsl-types.js";
|
|
28
|
+
import {
|
|
29
|
+
DISCLOSURE_CLOSED,
|
|
30
|
+
DISCLOSURE_GLYPH_CLOSE,
|
|
31
|
+
disclosureCycleAction,
|
|
32
|
+
disclosureGate,
|
|
33
|
+
disclosureStateVar,
|
|
34
|
+
disclosureTrigger,
|
|
35
|
+
type DisclosureRef,
|
|
36
|
+
} from "./disclosure.js";
|
|
37
|
+
|
|
38
|
+
// [LAW:one-source-of-truth] The trigger's closed text, one spelling for the
|
|
39
|
+
// whole bar so a reader learns the affordance once. Its OPEN text is the shared
|
|
40
|
+
// `DISCLOSURE_GLYPH_CLOSE` — the same ✕ a picker's close cell and edit mode's
|
|
41
|
+
// opened `+` wear, because it is the same meaning: click to close what this
|
|
42
|
+
// opened.
|
|
43
|
+
//
|
|
44
|
+
// [LAW:no-silent-failure] Deliberately per-state rather than one static `(?)`.
|
|
45
|
+
// Several `(?)` triggers can be visible at once (edit mode's and the config
|
|
46
|
+
// menu's, whenever both surfaces are open), and the tint that distinguishes
|
|
47
|
+
// other open disclosures — node-registry's `drops.length > 0` — recolours a
|
|
48
|
+
// segment's RESOLVED BACKGROUND, so it is a no-op on any segment declaring no
|
|
49
|
+
// bg. .5 gave edit mode a look at the GLOBALS level only, which left its insert
|
|
50
|
+
// chrome without one; the trigger's own text is therefore the only place the
|
|
51
|
+
// open state can be read, exactly as it is for the `+` beside it.
|
|
52
|
+
export const HELP_GLYPH_CLOSED = "(?)";
|
|
53
|
+
|
|
54
|
+
// The open member of a help disclosure's binary key: it holds this or the shared
|
|
55
|
+
// CLOSED sentinel, like every other binary disclosure in the bar.
|
|
56
|
+
const HELP_OPEN = "open";
|
|
57
|
+
|
|
58
|
+
// [LAW:one-source-of-truth] The declarations a `(?)` contributes, keyed by final
|
|
59
|
+
// name — the same accumulator shape `ChromeArtifacts`/`MenuArtifacts` already
|
|
60
|
+
// thread through their synthesis passes, so a caller merges one more set of
|
|
61
|
+
// artifacts the way it already merges its own.
|
|
62
|
+
export interface HelpArtifacts {
|
|
63
|
+
readonly variables: Record<string, VariableDecl>;
|
|
64
|
+
readonly actions: Record<string, ActionDecl>;
|
|
65
|
+
readonly segments: Record<string, SegmentDecl>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// [LAW:types-are-the-program] The two nodes a caller must place, returned
|
|
69
|
+
// separately because they belong in different rows and only the caller knows
|
|
70
|
+
// which: the trigger is a CELL that joins a row the caller already has (so
|
|
71
|
+
// opening help never widens the bar and closed help costs no row), and the body
|
|
72
|
+
// is a ROW of its own that exists only while the disclosure is open.
|
|
73
|
+
export interface HelpDisclosure {
|
|
74
|
+
readonly trigger: LayoutNode;
|
|
75
|
+
readonly body: LayoutNode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Mint one `(?)` and its body.
|
|
79
|
+
//
|
|
80
|
+
// `name` is the reserved-namespace base every artifact derives from — the
|
|
81
|
+
// trigger segment, the state variable and the cycle action all take it
|
|
82
|
+
// verbatim, the same one-name-four-artifacts convention `groups.<name>` and
|
|
83
|
+
// `settings.menu` already use, so the toggle's click and the body's gate cannot
|
|
84
|
+
// address different keys [LAW:one-source-of-truth].
|
|
85
|
+
//
|
|
86
|
+
// `within` names the disclosures this help sits INSIDE (edit mode, the settings
|
|
87
|
+
// menu). Both nodes inherit those gates, so a `(?)` opened inside a surface that
|
|
88
|
+
// is then closed cannot leave its body stranded on the bar — the body's gate is
|
|
89
|
+
// its own ref AND every enclosing one, which is what "open" actually means for a
|
|
90
|
+
// nested disclosure [LAW:dataflow-not-control-flow].
|
|
91
|
+
export function declareHelp(
|
|
92
|
+
name: string,
|
|
93
|
+
lines: readonly string[],
|
|
94
|
+
within: readonly DisclosureRef[],
|
|
95
|
+
out: HelpArtifacts,
|
|
96
|
+
surface?: { readonly bg: string; readonly fg: string },
|
|
97
|
+
): HelpDisclosure {
|
|
98
|
+
const self: DisclosureRef = { variable: name, member: HELP_OPEN };
|
|
99
|
+
out.variables[name] = disclosureStateVar(name, DISCLOSURE_CLOSED);
|
|
100
|
+
out.actions[name] = disclosureCycleAction(name, HELP_OPEN);
|
|
101
|
+
out.segments[name] = {
|
|
102
|
+
template: disclosureTrigger(
|
|
103
|
+
name,
|
|
104
|
+
HELP_GLYPH_CLOSED,
|
|
105
|
+
DISCLOSURE_GLYPH_CLOSE,
|
|
106
|
+
),
|
|
107
|
+
...surface,
|
|
108
|
+
// The trigger is visible wherever its host surface is. An unnested `(?)`
|
|
109
|
+
// (`within` empty) is always visible, and declares no `when` at all.
|
|
110
|
+
...gateOf(within),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// [LAW:one-source-of-truth] One line is one SEGMENT whose template is that
|
|
114
|
+
// line VERBATIM — no wrapper text, no joining, no reformatting, and no
|
|
115
|
+
// escaping. A `template` IS template source, so its static runs reach the bar
|
|
116
|
+
// unchanged; escaping belongs to text spliced INSIDE a quoted `{{ }}` argument
|
|
117
|
+
// (what `disclosureTrigger` above does, and this is not), and applying it here
|
|
118
|
+
// would put backslashes on the bar the moment a sentence used a quote. That is
|
|
119
|
+
// what makes "the bar's help IS the corpus" checkable as identity rather than
|
|
120
|
+
// as string similarity: the declaration a test reads and the sentence
|
|
121
|
+
// `src/help-text.ts` exports are one value, byte for byte.
|
|
122
|
+
const bodyGate = disclosureGate(self, ...within);
|
|
123
|
+
const children = lines.map((line, i): LayoutNode => {
|
|
124
|
+
const lineName = `${name}.${i}`;
|
|
125
|
+
// [LAW:single-enforcer] The gate lives on the body container below and
|
|
126
|
+
// nowhere else — the render walk ANDs an ancestor's `when` into every
|
|
127
|
+
// descendant (src/dsl/render.ts:764), so a copy here would be a second
|
|
128
|
+
// enforcer of one predicate with nothing keeping the two equal.
|
|
129
|
+
out.segments[lineName] = { template: line, ...surface };
|
|
130
|
+
return { kind: "segment", name: lineName };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
trigger: { kind: "segment", name },
|
|
135
|
+
body: {
|
|
136
|
+
kind: "container",
|
|
137
|
+
direction: "horizontal",
|
|
138
|
+
children,
|
|
139
|
+
when: bodyGate,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The enclosing gate as an optional `when` field — the codebase's standard
|
|
145
|
+
// spelling for "this field exists only when there is something to put in it",
|
|
146
|
+
// keeping `disclosureGate`'s first argument required so a gate over nothing
|
|
147
|
+
// stays unrepresentable.
|
|
148
|
+
function gateOf(within: readonly DisclosureRef[]): { when?: string } {
|
|
149
|
+
const [first, ...rest] = within;
|
|
150
|
+
return first === undefined ? {} : { when: disclosureGate(first, ...rest) };
|
|
151
|
+
}
|
|
@@ -51,7 +51,9 @@ import type { ActionDecl } from "../action.js";
|
|
|
51
51
|
import {
|
|
52
52
|
DISCLOSURE_CLOSED,
|
|
53
53
|
disclosureCycleAction,
|
|
54
|
+
disclosureGate,
|
|
54
55
|
disclosureStateVar,
|
|
56
|
+
type DisclosureRef,
|
|
55
57
|
} from "../disclosure.js";
|
|
56
58
|
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
57
59
|
|
|
@@ -71,10 +73,20 @@ export const EDIT_MODE_KEY = "edit.mode";
|
|
|
71
73
|
export const EDIT_TOGGLE_ACTION = "edit.toggle";
|
|
72
74
|
export const EDIT_MODE_OPEN = "open";
|
|
73
75
|
|
|
76
|
+
// [LAW:one-source-of-truth] Edit mode AS a disclosure, which is what it has
|
|
77
|
+
// always been: a binary toggle over one SessionState key. Naming it as a ref
|
|
78
|
+
// lets anything nested inside edit mode (a `(?)` and its body) derive its own
|
|
79
|
+
// gate by conjunction with this one, instead of concatenating gate strings.
|
|
80
|
+
export const EDIT_MODE_REF: DisclosureRef = {
|
|
81
|
+
variable: EDIT_MODE_KEY,
|
|
82
|
+
member: EDIT_MODE_OPEN,
|
|
83
|
+
};
|
|
84
|
+
|
|
74
85
|
// [LAW:one-source-of-truth] The predicate every synthesized +/- chrome
|
|
75
|
-
// segment gates on —
|
|
76
|
-
//
|
|
77
|
-
|
|
86
|
+
// segment gates on — derived from the ref above through the same function
|
|
87
|
+
// every other disclosure's gate comes from, so edit-mode chrome and a group
|
|
88
|
+
// body are gated by one rule rather than by two spellings that agree today.
|
|
89
|
+
export const EDIT_MODE_GATE = disclosureGate(EDIT_MODE_REF);
|
|
78
90
|
|
|
79
91
|
// [LAW:single-enforcer] The ONE detector for "does this file want edit mode":
|
|
80
92
|
// a literal `{{ action "edit.toggle" … }}` call somewhere a segment's
|
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
DISCLOSURE_GLYPH_CLOSED,
|
|
36
36
|
DISCLOSURE_GLYPH_OPEN,
|
|
37
37
|
disclosureCycleAction,
|
|
38
|
+
disclosureGate,
|
|
38
39
|
disclosureStateVar,
|
|
40
|
+
disclosureTrigger,
|
|
39
41
|
} from "../disclosure.js";
|
|
40
42
|
import { findKeyLine } from "./diagnostics.js";
|
|
41
43
|
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
@@ -535,20 +537,13 @@ function lowerGroup(g: GroupNodeInput): LayoutNode {
|
|
|
535
537
|
kind: "container",
|
|
536
538
|
direction: g.direction ?? "vertical",
|
|
537
539
|
children: g.children,
|
|
538
|
-
when:
|
|
540
|
+
when: disclosureGate({ variable: ref, member: g.name }),
|
|
539
541
|
},
|
|
540
542
|
],
|
|
541
543
|
...(g.when !== undefined && { when: g.when }),
|
|
542
544
|
};
|
|
543
545
|
}
|
|
544
546
|
|
|
545
|
-
// Go-template string-literal escaping for the synthesized toggle template — the
|
|
546
|
-
// label is a plain display string (dynamic labels are raw-grammar territory),
|
|
547
|
-
// so backslashes and quotes are the only characters that could break the splice.
|
|
548
|
-
function escapeTemplateLiteral(s: string): string {
|
|
549
|
-
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
550
|
-
}
|
|
551
|
-
|
|
552
547
|
function groupIssue(ctx: ValidateCtx, path: string, message: string): void {
|
|
553
548
|
ctx.issues.push({
|
|
554
549
|
path,
|
|
@@ -641,7 +636,6 @@ export function synthesizeGroupDecls(
|
|
|
641
636
|
for (const g of groups) {
|
|
642
637
|
const name = GROUP_NS + g.name;
|
|
643
638
|
const key = groupStateKey(g);
|
|
644
|
-
const label = escapeTemplateLiteral(g.label);
|
|
645
639
|
// [LAW:dataflow-not-control-flow] Depth is a value derivable from the paths
|
|
646
640
|
// already in ctx.groups — no extra threading. Strict-prefix count gives
|
|
647
641
|
// nesting depth; the indent embeds as a string constant in the template.
|
|
@@ -662,7 +656,11 @@ export function synthesizeGroupDecls(
|
|
|
662
656
|
// structural left-margin (nesting depth) and stays leading; the glyph is a
|
|
663
657
|
// trailing affordance on the label, never a prefix.
|
|
664
658
|
segments[name] = {
|
|
665
|
-
template:
|
|
659
|
+
template: disclosureTrigger(
|
|
660
|
+
name,
|
|
661
|
+
`${indent}${g.label} ${DISCLOSURE_GLYPH_CLOSED}`,
|
|
662
|
+
`${indent}${g.label} ${DISCLOSURE_GLYPH_OPEN}`,
|
|
663
|
+
),
|
|
666
664
|
...(g.bg !== undefined && { bg: g.bg }),
|
|
667
665
|
...(g.fg !== undefined && { fg: g.fg }),
|
|
668
666
|
};
|