@promptctl/cc-candybar 1.39.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 +106 -104
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +61 -1
- package/src/check.ts +14 -31
- package/src/config/default-dsl-config.ts +18 -0
- package/src/config/disclosure.ts +61 -0
- package/src/config/dsl-loader.ts +8 -1
- package/src/config/dsl-types.ts +23 -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/emit-schema.ts +2 -1
- package/src/config/loader/globals.ts +66 -32
- package/src/config/loader/layout.ts +8 -10
- package/src/config/loader/merge.ts +6 -0
- package/src/config/presets.ts +20 -1
- package/src/config/settings-menu.ts +107 -28
- package/src/daemon/render-payload.ts +98 -1
- package/src/daemon/server.ts +29 -88
- package/src/demo/dsl.ts +21 -37
- package/src/help-text.ts +28 -2
- package/src/themes/policy.ts +44 -4
|
@@ -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
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// cycles. Those stay SEMANTIC checks the loader carries — schema = shape, lint =
|
|
13
13
|
// meaning, the same complementary boundary `config-schema.test.ts` pins.
|
|
14
14
|
|
|
15
|
-
import { globalsJson } from "./globals.js";
|
|
15
|
+
import { editGlobalsJson, globalsJson } from "./globals.js";
|
|
16
16
|
import { variablesMapJson } from "./variables.js";
|
|
17
17
|
import { segmentsJson } from "./segments.js";
|
|
18
18
|
import { actionsJson } from "./actions.js";
|
|
@@ -50,6 +50,7 @@ export function emitConfigSchema(): JsonNode {
|
|
|
50
50
|
actions: actionsJson(),
|
|
51
51
|
looks: looksJson(),
|
|
52
52
|
presets: presetsJson(),
|
|
53
|
+
editGlobals: editGlobalsJson(),
|
|
53
54
|
helpers: { type: "object", additionalProperties: { type: "string" } },
|
|
54
55
|
},
|
|
55
56
|
definitions: {
|
|
@@ -104,43 +104,63 @@ const GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
|
104
104
|
fields: GLOBALS_FIELDS,
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
-
// [LAW:one-source-of-truth] A
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// a
|
|
107
|
+
// [LAW:one-source-of-truth] A globals FRAGMENT — a delta layered over the
|
|
108
|
+
// config's own globals at render time — may not carry `preset`: which preset is
|
|
109
|
+
// active has exactly one authority (session pick over globals.preset over the
|
|
110
|
+
// floor), and a fragment re-selecting a preset would be a second one. For a
|
|
111
|
+
// preset's own fragment that second authority is also cyclic; for edit mode's
|
|
112
|
+
// it would let a look-only fragment restage the whole layout, which edit chrome
|
|
113
|
+
// already owns. Same species of bespoke, migration-pointing rejection as
|
|
111
114
|
// colorCompatibility's "auto" above, and for the same reason: an author who
|
|
112
115
|
// writes it deserves to be told WHY, not handed a bare unknown-key message.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
};
|
|
116
|
+
//
|
|
117
|
+
// [LAW:one-type-per-behavior] Both fragments reject the field identically and
|
|
118
|
+
// differ only in the SUBJECT a diagnostic names, so this is one spec taking
|
|
119
|
+
// that noun as data — never two specs that could drift in what they reject.
|
|
120
|
+
function nestedPresetSpec(subject: string): FieldSpec<string> {
|
|
121
|
+
return {
|
|
122
|
+
required: false,
|
|
123
|
+
// Always-fail: JSON Schema's `not: {}` matches nothing, so an editor flags
|
|
124
|
+
// the key at the same moment the validator does.
|
|
125
|
+
json: {
|
|
126
|
+
not: {},
|
|
127
|
+
description: `not allowed here — ${subject} cannot select a preset`,
|
|
128
|
+
},
|
|
129
|
+
parse: (ctx, path, field, raw) => {
|
|
130
|
+
if (raw[field] !== undefined) {
|
|
131
|
+
ctx.issues.push({
|
|
132
|
+
path: `${path}.${field}`,
|
|
133
|
+
message:
|
|
134
|
+
`${path}.${field}: ${subject} cannot select a preset. Which preset is active is ` +
|
|
135
|
+
`resolved once, as session pick over globals.preset over "default"; a fragment ` +
|
|
136
|
+
`naming another would be a second authority over that. ` +
|
|
137
|
+
`Set the default arrangement in the top-level globals.preset instead.`,
|
|
138
|
+
line: findKeyLine(ctx.source, [...path.split("."), field]),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return undefined;
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
137
145
|
|
|
138
|
-
// [LAW:one-source-of-truth]
|
|
139
|
-
// table with exactly one field swapped for its rejection — not a
|
|
140
|
-
// subset that a future globals field could be forgotten from.
|
|
146
|
+
// [LAW:one-source-of-truth] Each fragment-scoped globals schema is the SAME
|
|
147
|
+
// field table with exactly one field swapped for its rejection — not a
|
|
148
|
+
// hand-listed subset that a future globals field could be forgotten from.
|
|
141
149
|
const PRESET_GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
142
150
|
noun: "preset globals key",
|
|
143
|
-
fields: { ...GLOBALS_FIELDS, preset: nestedPresetSpec },
|
|
151
|
+
fields: { ...GLOBALS_FIELDS, preset: nestedPresetSpec("a preset") },
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// [LAW:one-type-per-behavior] Edit mode's staged globals are the same shape one
|
|
155
|
+
// rung later in the precedence chain, so they reuse the same table rather than
|
|
156
|
+
// declaring which fields edit mode "supports" — a field added to Globals is
|
|
157
|
+
// edit-settable the same day, with no edit here.
|
|
158
|
+
const EDIT_GLOBALS_SCHEMA: RecordSchema<Globals> = {
|
|
159
|
+
noun: "editGlobals key",
|
|
160
|
+
fields: {
|
|
161
|
+
...GLOBALS_FIELDS,
|
|
162
|
+
preset: nestedPresetSpec("the editGlobals fragment"),
|
|
163
|
+
},
|
|
144
164
|
};
|
|
145
165
|
|
|
146
166
|
// An absent globals block is the empty default (no issue); a non-object is a
|
|
@@ -177,6 +197,20 @@ export function presetGlobalsJson(): JsonNode {
|
|
|
177
197
|
return recordJson(PRESET_GLOBALS_SCHEMA);
|
|
178
198
|
}
|
|
179
199
|
|
|
200
|
+
// Edit mode's twin of the two above: same interpreter, same field table, the
|
|
201
|
+
// schema whose `preset` rejection names the editGlobals fragment.
|
|
202
|
+
export function validateEditGlobals(
|
|
203
|
+
ctx: ValidateCtx,
|
|
204
|
+
path: string,
|
|
205
|
+
raw: unknown,
|
|
206
|
+
): Globals {
|
|
207
|
+
return record(ctx, EDIT_GLOBALS_SCHEMA, path, raw) ?? {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function editGlobalsJson(): JsonNode {
|
|
211
|
+
return recordJson(EDIT_GLOBALS_SCHEMA);
|
|
212
|
+
}
|
|
213
|
+
|
|
180
214
|
// [LAW:one-source-of-truth] THE membership check for "is this a real Globals
|
|
181
215
|
// field" — derived from GLOBALS_SCHEMA.fields, the same declaration
|
|
182
216
|
// validateGlobals/globalsJson interpret, so a `persist`/`reset` action'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
|
};
|
|
@@ -53,6 +53,12 @@ export function mergeWithDefault(
|
|
|
53
53
|
// (incl. the "default" empty-fragment floor effectivePresetName collapses
|
|
54
54
|
// to) survives every merge by construction, exactly as looks' "none" does.
|
|
55
55
|
presets: { ...dflt.presets, ...(raw.presets ?? {}) },
|
|
56
|
+
// [LAW:one-source-of-truth] editGlobals merges FIELD by field — the
|
|
57
|
+
// `globals` cascade above, not the by-name cascades around it, because it
|
|
58
|
+
// IS a globals fragment: a user retuning edit mode's separator says nothing
|
|
59
|
+
// about its `style`, exactly as a user setting `globals.padding` says
|
|
60
|
+
// nothing about `globals.charset`.
|
|
61
|
+
editGlobals: { ...dflt.editGlobals, ...(raw.editGlobals ?? {}) },
|
|
56
62
|
// [LAW:one-source-of-truth] helpers merge by name, same cascade — a user
|
|
57
63
|
// overrides one formatter helper by re-declaring its name; the rest inherit
|
|
58
64
|
// from the bundled default.
|
package/src/config/presets.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// place it is written down:
|
|
16
16
|
//
|
|
17
17
|
// bundled default < user config file < persisted overrides
|
|
18
|
-
// < ACTIVE PRESET < session pick
|
|
18
|
+
// < ACTIVE PRESET < session pick < EDIT MODE
|
|
19
19
|
//
|
|
20
20
|
// The preset's position is forced by its lifetime, not chosen. Everything to
|
|
21
21
|
// its left is resolved once per RenderCache entry (an entry serves many
|
|
@@ -26,6 +26,18 @@
|
|
|
26
26
|
// "compact" arrangement must actually change padding, even for a user who once
|
|
27
27
|
// persisted a padding they liked) while a session's own click still wins over
|
|
28
28
|
// the preset (a click is later still).
|
|
29
|
+
//
|
|
30
|
+
// The same rule places the last rung (candybar-settings-ui-aok.5): edit mode's
|
|
31
|
+
// `editGlobals` fragment is decided later than ANY session pick — a user picks
|
|
32
|
+
// a style, and only afterwards enters edit mode — so it is the new rightmost
|
|
33
|
+
// layer, and a session pick of "capsule" cannot survive into a mode whose whole
|
|
34
|
+
// job is to stop segments reading as one continuous strip. It differs from
|
|
35
|
+
// every rung to its left in LIFETIME rather than in kind: nothing writes it to
|
|
36
|
+
// SessionState or the overrides layer, so leaving edit mode restores the
|
|
37
|
+
// previous look with no save/restore path — the session's own pick was never
|
|
38
|
+
// overwritten, only out-ranked [LAW:dataflow-not-control-flow]. The rung itself
|
|
39
|
+
// is the `staged` parameter of effectiveGlobal (themes/policy.ts); this comment
|
|
40
|
+
// is the ONE place the order is written down.
|
|
29
41
|
|
|
30
42
|
// [LAW:one-way-deps] Type-only, so nothing is emitted and option-domain.ts (a
|
|
31
43
|
// leaf that deliberately never imports dsl-types.ts) can import PRESET_NAMES
|
|
@@ -87,7 +99,14 @@ export function effectivePresetName(
|
|
|
87
99
|
globalsPreset: string | undefined,
|
|
88
100
|
declaredPresets: Readonly<Record<string, PresetDecl>>,
|
|
89
101
|
): string {
|
|
102
|
+
// [LAW:types-are-the-program] No staged rung, and the absence is enforced
|
|
103
|
+
// rather than assumed: every globals fragment that could stage one — a
|
|
104
|
+
// preset's own `globals`, edit mode's `editGlobals` — has `preset` swapped for
|
|
105
|
+
// a rejection in its schema (loader/globals.ts), so "a fragment selected a
|
|
106
|
+
// preset" is unrepresentable and there is nothing here to resolve against.
|
|
107
|
+
// Which preset is active keeps exactly one authority.
|
|
90
108
|
return effectiveMemberName(
|
|
109
|
+
undefined,
|
|
91
110
|
sessionPreset,
|
|
92
111
|
globalsPreset,
|
|
93
112
|
PRESET_FLOOR,
|