efficient-easel 0.0.0 → 0.96.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.
Files changed (119) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +723 -4
  3. package/dist/autosave/index.d.ts +101 -0
  4. package/dist/autosave/index.js +1 -0
  5. package/dist/blocks/index.d.ts +295 -0
  6. package/dist/blocks/index.js +0 -0
  7. package/dist/buckets-1vRlQceY.d.ts +13 -0
  8. package/dist/canvas/index.d.ts +194 -0
  9. package/dist/canvas/index.js +1 -0
  10. package/dist/chrome/chrome.css +570 -0
  11. package/dist/chrome/index.d.ts +219 -0
  12. package/dist/chrome/index.js +21 -0
  13. package/dist/chunk-2LUSK32B.js +1 -0
  14. package/dist/chunk-2OAZYSWJ.js +1 -0
  15. package/dist/chunk-67VZRQLG.js +13 -0
  16. package/dist/chunk-6JDII7N3.js +1 -0
  17. package/dist/chunk-6OPSJGAI.js +1 -0
  18. package/dist/chunk-6WY6ZXJG.js +1 -0
  19. package/dist/chunk-7A7O2JWQ.js +1 -0
  20. package/dist/chunk-BZ5ASXOL.js +1 -0
  21. package/dist/chunk-CERTFXCV.js +13 -0
  22. package/dist/chunk-EFTTSIMK.js +1 -0
  23. package/dist/chunk-FI23ALVJ.js +1 -0
  24. package/dist/chunk-FJE4IDBQ.js +1 -0
  25. package/dist/chunk-GGTPNGFJ.js +1 -0
  26. package/dist/chunk-H4B72XCJ.js +1 -0
  27. package/dist/chunk-H6DDIBQ4.js +1 -0
  28. package/dist/chunk-KDIVA6WD.js +1 -0
  29. package/dist/chunk-LPRQDDQO.js +1 -0
  30. package/dist/chunk-MCLBHENY.js +1 -0
  31. package/dist/chunk-MFIRYGHW.js +1 -0
  32. package/dist/chunk-NKB3KVAB.js +1 -0
  33. package/dist/chunk-O2NLQBFX.js +1 -0
  34. package/dist/chunk-OGZFDJ6G.js +1 -0
  35. package/dist/chunk-OWBO4WKA.js +0 -0
  36. package/dist/chunk-QB3BZIRA.js +5 -0
  37. package/dist/chunk-RE4LP46F.js +1 -0
  38. package/dist/chunk-STFMZJKU.js +14 -0
  39. package/dist/chunk-SUO5627P.js +1 -0
  40. package/dist/chunk-TDA4HH54.js +1 -0
  41. package/dist/chunk-WK3AFPXY.js +1 -0
  42. package/dist/chunk-YIMRTIBN.js +3 -0
  43. package/dist/chunk-YNZZYHA4.js +450 -0
  44. package/dist/chunk-ZAOM6PFH.js +1 -0
  45. package/dist/chunk-ZNCEOSXO.js +1 -0
  46. package/dist/copy/copy.css +282 -0
  47. package/dist/copy/index.d.ts +336 -0
  48. package/dist/copy/index.js +10 -0
  49. package/dist/data/index.d.ts +208 -0
  50. package/dist/data/index.js +1 -0
  51. package/dist/drag/drag.css +360 -0
  52. package/dist/drag/index.d.ts +379 -0
  53. package/dist/drag/index.js +18 -0
  54. package/dist/edit-lock/index.d.ts +354 -0
  55. package/dist/edit-lock/index.js +1 -0
  56. package/dist/editor/editor.css +58 -0
  57. package/dist/editor/index.d.ts +361 -0
  58. package/dist/editor/index.js +9 -0
  59. package/dist/fields/fields.css +207 -0
  60. package/dist/fields/index.d.ts +273 -0
  61. package/dist/fields/index.js +46 -0
  62. package/dist/gold/gold.css +196 -0
  63. package/dist/gold/index.d.ts +280 -0
  64. package/dist/gold/index.js +1 -0
  65. package/dist/icons/index.d.ts +474 -0
  66. package/dist/icons/index.js +1 -0
  67. package/dist/index.d.ts +6 -0
  68. package/dist/index.js +1 -0
  69. package/dist/markup/index.d.ts +452 -0
  70. package/dist/markup/index.js +1 -0
  71. package/dist/menu/index.d.ts +49 -0
  72. package/dist/menu/index.js +15 -0
  73. package/dist/menu/menu.css +254 -0
  74. package/dist/node-ops/index.d.ts +91 -0
  75. package/dist/node-ops/index.js +1 -0
  76. package/dist/panel/index.d.ts +1088 -0
  77. package/dist/panel/index.js +16 -0
  78. package/dist/panel/panel.css +1460 -0
  79. package/dist/pick/index.d.ts +153 -0
  80. package/dist/pick/index.js +14 -0
  81. package/dist/pick/pick.css +235 -0
  82. package/dist/plan-DtNw2VuI.d.ts +88 -0
  83. package/dist/rail/index.d.ts +145 -0
  84. package/dist/rail/index.js +1 -0
  85. package/dist/rail/rail.css +175 -0
  86. package/dist/render/index.d.ts +117 -0
  87. package/dist/render/index.js +5 -0
  88. package/dist/resolve-DpH8YQOA.d.ts +57 -0
  89. package/dist/shell/index.d.ts +159 -0
  90. package/dist/shell/index.js +1 -0
  91. package/dist/shell/shell.css +122 -0
  92. package/dist/strings/catalogue.d.ts +141 -0
  93. package/dist/strings/catalogue.js +1 -0
  94. package/dist/strings/index.d.ts +56 -0
  95. package/dist/strings/index.js +2 -0
  96. package/dist/style-clipboard/index.d.ts +184 -0
  97. package/dist/style-clipboard/index.js +1 -0
  98. package/dist/tree/index.d.ts +173 -0
  99. package/dist/tree/index.js +1 -0
  100. package/dist/tree/tree.css +618 -0
  101. package/dist/types-CEqUFH2j.d.ts +129 -0
  102. package/dist/types-CuNiSnX6.d.ts +204 -0
  103. package/dist/types-DNrn_w4V.d.ts +88 -0
  104. package/dist/types-DziKCwsN.d.ts +105 -0
  105. package/dist/types-_Vayx8ov.d.ts +153 -0
  106. package/dist/types-pGBXevBU.d.ts +131 -0
  107. package/dist/useNodeOps-6IAej9jy.d.ts +68 -0
  108. package/dist/watch-DmasQA61.d.ts +60 -0
  109. package/dist/wordFit-9B9nqyf3.d.ts +35 -0
  110. package/dist/words-B2YLaKEr.d.ts +30 -0
  111. package/dist/words-B87GIFWH.d.ts +86 -0
  112. package/dist/words-BK-npta-.d.ts +27 -0
  113. package/dist/words-CD3WADyb.d.ts +51 -0
  114. package/dist/words-CsRWXkY4.d.ts +129 -0
  115. package/dist/words-D9ChvbNC.d.ts +52 -0
  116. package/dist/words-DOhF-4D4.d.ts +35 -0
  117. package/dist/words-DagJdwIA.d.ts +78 -0
  118. package/dist/words-DfxEp_RH.d.ts +142 -0
  119. package/package.json +202 -3
@@ -0,0 +1,1460 @@
1
+ /**
2
+ * efficient-easel/panel.css — the settings-panel grammar.
3
+ *
4
+ * Values come from the shell token layer (`efficient-easel/shell.css`), never from
5
+ * literals: this file is the storefront editor's approved panel design expressed
6
+ * once, so both editors read the same rules. Light only — neither editor has a
7
+ * dark mode.
8
+ *
9
+ * Two details are deliberate and were decided by measurement, not taste:
10
+ * · rows sit 38px apart while a label sits 11px from its own control, so a
11
+ * setting reads as ONE thing with air around it. That inside gap is the
12
+ * ONLY thing saying which label owns which control, and the two are a
13
+ * RATIO rather than a pair of numbers: measured at 28 and 7 (card 8802.d,
14
+ * approved from mockups/8802e-settings-spacing-mockup.html) and re-scaled
15
+ * together to 38 and 11 (card 66301), which keeps the gap between settings
16
+ * 3.5x the gap inside one;
17
+ * · the label is small and grey, the value is larger and dark. Before that
18
+ * contrast existed, a merchant could not tell a name from a number.
19
+ */
20
+
21
+ /* ── the row floor: one setting, and nothing falls below it (card 66203) ───── */
22
+
23
+ /* Lewis, with a screenshot of the Site styles Text tab: the rows are too tight
24
+ top to bottom, and he wants ONE global setting keeping every settings row at
25
+ least 10px from the next.
26
+
27
+ THIS IS THAT SETTING, and it is the only place the number lives. Before it,
28
+ the panel's row rhythm was six hand-written numbers in five rules with no
29
+ token behind any of them: there was nowhere to change it once, and no floor
30
+ under any of it.
31
+
32
+ ON `.es-editor`, because the panel HAS no root of its own. Puck lays the
33
+ inspector out as a flat sibling list, so the primitives render no wrapper —
34
+ and `.es-editor` is the chrome root both editors already put on their own
35
+ chrome and the root every colour in this sheet is already read from. A host
36
+ overrides the whole panel's rhythm by setting this one var on, or above, that
37
+ root. Declaring it HERE rather than in `shell.css` follows `rail.css`, which
38
+ owns its own `--es-rail-*`: the sheet that owns the rules owns its tokens, and
39
+ `shell.css` stays a verbatim mirror of the storefront editor's `--sf-*` block.
40
+
41
+ HOW A LARGER RHYTHM KEEPS THE FLOOR. Three of the gaps below are deliberately
42
+ bigger than 10 and were decided by measurement, not by taste (card 8802.d).
43
+ They are written `max(var(--es-row-gap), <rhythm>)` — never as a bare number
44
+ beside the token, which would look governed and would not be, and never as
45
+ `calc()` off it, which would claim an arithmetic relationship that does not
46
+ exist: 28 was measured, it is not "10 plus 18". The max() is what makes this a
47
+ FLOOR rather than a default — raise the token past a rhythm and that row rises
48
+ with it instead of quietly staying tighter than everything around it.
49
+
50
+ NOW 16, AND 10 WAS WORTH NOTHING ON SCREEN (card 66206). 66203 shipped the
51
+ token at 10 and Lewis could see no change at all, which is not a bug in it —
52
+ it is what a floor of 10 was always going to do here. Four of the six gaps
53
+ (36, 28, 16, 14) were already above it, and of the two that were not, one is
54
+ the dead `.es-group + .es-group` rule below. So 10 could only ever reach the
55
+ drill list, and there it landed on the number that list already had.
56
+
57
+ 16 is Lewis's own, picked on 2026-08-28 once that was shown to him, and it is
58
+ the first value that moves anything a merchant looks at. MEASURED in Chromium
59
+ against these stylesheets (`tests/fixtures/panel-row-spacing.html`):
60
+
61
+ one drill row to the next, word to word 23.40px → 35.40px
62
+ a group head to its first setting 14px → 16px
63
+ one setting to the next 28px unchanged
64
+ a one-line boolean row 16px unchanged
65
+ a heading, after the row above it 36px unchanged
66
+
67
+ The three measured rhythms do not move because 16 is still under them, which
68
+ is the floor doing exactly what it was built to do.
69
+
70
+ NOW 20, AND THE RHYTHMS MOVED WITH IT (card 66301). Lewis, 2026-08-31: the
71
+ vertical spacing is too tight, open it up, roughly 10px more. Asked whether
72
+ the tightest gap should take the full 10 as well he handed the numbers over —
73
+ "you decide, whatever feels comfortable" — so the six values below are this
74
+ desk's, made under that.
75
+
76
+ NOT A FLAT +10 ON EVERY TOKEN, which is what was first asked for. A flat rise
77
+ takes the widest gap against the tightest from 36:7 to 46:17 — 5.1x down to
78
+ 2.7x — so it opens the panel and FLATTENS it at the same time, and "these
79
+ belong together" stops reading differently from "these are separate". The six
80
+ values keep it at 4.4x while every seam still grows visibly. */
81
+ /* ── the rhythms: the numbers under the floor, given names (card 66228) ────── */
82
+
83
+ /* THE FLOOR ABOVE IS NOT THE RHYTHM, and handing a consumer the floor was the
84
+ whole problem. `--es-row-gap` answers 20 for all five gaps below alike; what
85
+ the storefront's Layers tree needs in order to "follow the shell's spacing"
86
+ is the RHYTHM — 38 between two settings against 11 inside one. Under the floor
87
+ those were bare literals, so the only way to follow them was to COPY them
88
+ into `efficient-shop/components/storefront-editor.css`: a second source of
89
+ truth for numbers this sheet was already the source of.
90
+
91
+ THE VALUES DID NOT CHANGE WHEN THEY WERE NAMED. Every one was the number
92
+ that was there before, moved and named; (g) asserted each still resolved to
93
+ what it resolved to, and (a) still read the scale off the sheet. Card 66301
94
+ is the first card to turn them, and it turned all six at once — which is what
95
+ naming them was for.
96
+
97
+ NAMES, NOT ARITHMETIC, for the reason this sheet already gives one rule down:
98
+ 38 was measured and then re-scaled (cards 8802.d, 66301), it is not "20 plus
99
+ 18" and never was "16 plus 12". `calc()` off the floor
100
+ would claim a relationship nobody decided; a name states the value and the
101
+ job it does.
102
+
103
+ WHAT A CONSUMER IS ACTUALLY BUYING is the pair at the end. `--es-rhythm-between`
104
+ and `--es-rhythm-within` are the two halves of the one rule that has to
105
+ survive being re-scaled: the gap INSIDE a thing stays plainly smaller than
106
+ the gap BETWEEN things, because that inside gap is the ONLY thing saying
107
+ which label owns which control. Card 66301 re-scaled all six and is the proof
108
+ the pair travels: 28:7 became 38:11, both up, the ratio held. A tree at 38px a
109
+ row would scroll forever, so a consumer is expected to scale these — and (g)
110
+ is what stops the two from drifting together while it does. */
111
+ /* ⛔ CUSTOM PROPERTIES ONLY IN THIS BLOCK. Never a paint property.
112
+
113
+ `.es-editor` is not this package's own element — it is the class each editor
114
+ puts on ITS OWN chrome root, and the campaign designer puts it on a root that
115
+ lives INSIDE the admin portal's DOM (card 66024). A custom property here is
116
+ inert until something reads it; a `background`, a `padding`, a `font` —
117
+ anything that PAINTS — applies to that root the moment this sheet is
118
+ imported, in every editor that imports it.
119
+
120
+ Audited 2026-09-02 across every sheet here that opens an `.es-editor` block:
121
+ shell.css 67/67 custom, panel.css 11/11, menu.css 7/7 — zero paint in all
122
+ three. ⚠️ Nothing enforces it. If you are adding a declaration here, you are
123
+ the check. */
124
+ .es-editor {
125
+ /* THE FLOOR (card 66206) — no row-to-row gap on this panel resolves below it. */
126
+ --es-row-gap: 20px;
127
+
128
+ /* THE RHYTHMS. All five are declared in THIS rule and not a second
129
+ `.es-editor` block: the panel has ONE root, and a second block would put
130
+ half the panel's spacing somewhere a reader — and the spec, which reads the
131
+ root by selector — would not look. */
132
+ /* between one group heading and whatever is above it */
133
+ --es-rhythm-group: 48px;
134
+ /* between a group heading and the first setting under it — closer, because
135
+ that setting belongs to that heading */
136
+ --es-rhythm-group-first: 24px;
137
+ /* BETWEEN two settings */
138
+ --es-rhythm-between: 38px;
139
+ /* between two one-line rows, which are one line tall and need less air */
140
+ --es-rhythm-between-tight: 26px;
141
+ /* WITHIN one setting: its label to its own control */
142
+ --es-rhythm-within: 11px;
143
+
144
+ /* ── THE CHIP'S OWN METRICS (card 66248) ───────────────────────────────────
145
+
146
+ These five were literals inside the chip rules until a SECOND component
147
+ had to wear the same chip. They are named for one reason, and it is not
148
+ tidiness: the segmented control's fit thresholds are ARITHMETIC OVER THEM.
149
+
150
+ Its words drop where the words stop fitting, and where that is depends on
151
+ the room the chrome takes before any word is drawn — two chip paddings, a
152
+ glyph, the gap after it, the gaps between chips and the track's own
153
+ padding. Card 66138 has already moved one of these once (the track's side
154
+ padding, 14px → 3px). A threshold holding its own copy of that number would
155
+ have gone stale that day and said nothing. Read from here, it moves with
156
+ the chip.
157
+
158
+ They are DECLARED lengths and not measurements: the chip is drawn from
159
+ these, so they are the source, not a reading of one. The one thing that
160
+ cannot be named here is the width of a WORD — see `.es-segs` below. */
161
+ --es-chip-pad-x: 8px;
162
+ --es-chip-icon: 16px;
163
+ --es-chip-icon-gap: 6px;
164
+ --es-track-pad: 3px;
165
+ --es-track-gap: 3px;
166
+ }
167
+
168
+ /* ── the tab strip: a segmented control ────────────────────────────────────── */
169
+
170
+ /* The strip's own container context (card 8810), and the reason `SettingsTabs`
171
+ renders a wrapper at all.
172
+
173
+ The drop rules further down turn on the width of the PANEL, and `@container`
174
+ is the only thing that can ask for it. A media query asks the WINDOW, and the
175
+ window is the wrong box: the inspector is a column the merchant drags, so a
176
+ 220px panel on a 2560px screen is the ordinary case, not an edge one — a
177
+ media query would answer for that panel with the width of the screen behind
178
+ it.
179
+
180
+ The context lives HERE rather than in a host's own panel rule so the shell is
181
+ self-contained: a host that never declares `container-type` would silently
182
+ pin the strip to one state with nothing to see and nothing to fail.
183
+
184
+ `.es-rail-fit` is the top-bar rail's column (`TopBarRail`), which reads the
185
+ word-fit rule below: its container is the left panel's own width. */
186
+ .es-tabs-fit,
187
+ .es-segs-fit,
188
+ .es-rail-fit {
189
+ container-type: inline-size;
190
+ flex: none;
191
+ }
192
+
193
+ /* …EXCEPT IN THE ONE SLOT THIS PACKAGE HANDS CONSUMERS (card 66516).
194
+
195
+ `SettingsField` stacks its control into `.es-field-ctl`, and that slot is
196
+ `display: flex`. So `flex: none` above stops meaning "do not stretch" and
197
+ starts meaning "size to your own content" — and `container-type:
198
+ inline-size` is size containment on that axis, so this wrapper HAS no
199
+ content width to size to. The strip comes out at ZERO.
200
+
201
+ ⚠️ NEITHER DECLARATION ABOVE IS WRONG, WHICH IS WHY THIS IS AN EXCEPTION AND
202
+ NOT AN EDIT. The containment is what lets `100cqi` mean the strip's own
203
+ width for the word-drop arithmetic further down; `flex: none` is what keeps
204
+ the strip from stretching in the contexts it was written for. They collide
205
+ only here.
206
+
207
+ MEASURED, `tests/fixtures/field-slot-basis.html`, Chromium, before this rule:
208
+
209
+ panel field content strip in .es-field-ctl strip in a block row
210
+ 320px 320px 0px 320px
211
+ 220px 220px 0px 220px
212
+
213
+ The block-row column is the control: it is where every one of these strips
214
+ lived until now — a consumer's own block row, where `flex: none` is inert
215
+ and the box fills the line. That is why this looked right for a year and
216
+ why it broke for the first consumer that took this package's own advice.
217
+
218
+ ⚠️ FOUND BY A PERSON LOOKING, NOT BY A GUARD. Card 66508 shipped 1198 green
219
+ checks over this: every button present, `aria-pressed` correct, exactly one
220
+ chip active, the glyph in its slot, `tsc` clean, the PR MERGEABLE. All of
221
+ that is true of a control 0px wide. Our checks ask whether markup EXISTS;
222
+ this one asks whether it OCCUPIES SPACE, which is the gap that let it ship.
223
+
224
+ `flex: 1 1 auto` rather than a percentage width: the slot is a flex row that
225
+ may hold a value readout or a second control beside the strip, and a
226
+ percentage would overflow it the day it does. `min-width: 0` because a flex
227
+ item's automatic minimum is its content size, and the containment above
228
+ makes that meaningless here — without it the strip cannot shrink below the
229
+ basis the parent gives it.
230
+
231
+ BOTH `-fit` WRAPPERS, though only the segmented one has a live consumer
232
+ today. Measured in the shop at the time of writing: 1845 custom fields
233
+ rendered, 68 draw a tab strip, 0 of them inside `.es-field-ctl`. But
234
+ `PanelTabs` already draws a strip AMONG the fields and is one refactor from
235
+ the slot, and the two wrappers share the declaration that causes this — a
236
+ rule that fixed one and left the other would be a trap wearing a fix. */
237
+ .es-field-ctl > .es-tabs-fit,
238
+ .es-field-ctl > .es-segs-fit {
239
+ flex: 1 1 auto;
240
+ min-width: 0;
241
+ }
242
+
243
+ /* THE TRACK (card 66138). Lewis, looking at a real settings panel: the tab he
244
+ is NOT on does not invite a click — it does not even look like a button. He
245
+ was shown six treatments in `mockups/settings-tab-strip-options.html` and
246
+ picked B, the segmented control.
247
+
248
+ This REPLACES the underline strip, and with it card 8811's accent-tint ground
249
+ (see `.es-tab.is-active` below). The grey track is the part doing the work
250
+ Lewis asked for: it is what makes every segment look pressable, where a mark
251
+ under the chosen tab only ever made the chosen tab louder.
252
+
253
+ FULL WIDTH, no side inset. The old strip carried `padding: 0 14px` because
254
+ underline tabs must start inset from the panel edge. A track is a FILLED row,
255
+ and every other filled row here — `.es-group-head` at `width: 100%` — runs
256
+ the full width of the body's content box; a track inset 14px further than the
257
+ rows beneath it is visibly out of line. It is also worth 28px of room, which
258
+ is the difference between three segments fitting the shop's default panel and
259
+ not.
260
+
261
+ NOWRAP, unlike the strip it replaces. A segmented control is one row by
262
+ definition — a second row of track under the first is not a wrapped control,
263
+ it is two controls. The guarantee that no bucket is ever out of reach is
264
+ re-made out of different parts: segments that share the leftover space, and
265
+ the drop rules below. */
266
+ .es-tabs,
267
+ .es-segs {
268
+ display: flex;
269
+ flex-wrap: nowrap;
270
+ gap: var(--es-track-gap);
271
+ padding: var(--es-track-pad);
272
+ background: var(--es-track-bg);
273
+ border-radius: var(--es-row-radius);
274
+ flex: none;
275
+ }
276
+
277
+ /* A SEGMENT TAKES ITS OWN WIDTH, THEN SHARES WHAT IS LEFT.
278
+
279
+ `1 1 auto` and not `1 1 0`: equal segments were measured and are too
280
+ expensive. Every equal segment takes the width of the WIDEST label, and
281
+ "Content" is 85.36px at 12px/600, so three equal segments need a 267px track
282
+ and four need 355px — against the 281px the shop's default panel gives. With
283
+ natural widths the track still reaches both edges, and "Style" is not forced
284
+ to be as wide as "Content" to get there.
285
+
286
+ `box-sizing` for the same reason the switch sets it: this file assumes no
287
+ reset of the host's. */
288
+ .es-tab,
289
+ .es-seg {
290
+ box-sizing: border-box;
291
+ flex: 1 1 auto;
292
+ border: 0;
293
+ background: transparent;
294
+ border-radius: 6px;
295
+ padding: 6px var(--es-chip-pad-x);
296
+ font: inherit;
297
+ font-size: 12px;
298
+ font-weight: 600;
299
+ color: var(--es-text-secondary);
300
+ white-space: nowrap;
301
+ cursor: pointer;
302
+ display: inline-flex;
303
+ align-items: center;
304
+ justify-content: center;
305
+ gap: var(--es-chip-icon-gap);
306
+ transition: background 0.12s, color 0.12s, box-shadow 0.12s;
307
+ }
308
+
309
+ /* A quiet segment is READABLE, not faint. It was `--es-muted`, which on the new
310
+ grey track sits at about 4.3:1 and misses AA; this is about 8.9:1. The track
311
+ is what says "pressable" now, so the label no longer has to whisper to stay
312
+ out of the chosen tab's way. */
313
+ .es-tab:hover:not(.is-active),
314
+ .es-seg:hover:not(.is-active) { color: var(--es-text); }
315
+
316
+ /* Keyboard focus, themed rather than left to the UA. Same ring as the switch,
317
+ so the panel answers a keyboard the one way. */
318
+ .es-tab:focus-visible,
319
+ .es-seg:focus-visible {
320
+ outline: none;
321
+ box-shadow: 0 0 0 3px var(--es-accent-ring);
322
+ }
323
+
324
+ /* THE CHOSEN CHIP IS THE DARKEST THING IN THE ROW (card 66302).
325
+
326
+ IT WAS A RAISED WHITE CHIP, AND THAT WAS NOT A MISTAKE. Card 66138 put white
327
+ on the grey track with a soft lift, and Lewis picked that look himself from
328
+ six treatments. He picked it BY EYE, AT A WIDTH WHERE THE WORDS WERE STILL
329
+ THERE — which is the whole reason this is a revision and not a correction.
330
+
331
+ WHAT CHANGED IS THE CONDITION, TWICE.
332
+
333
+ First the words went. A four-tab strip is already wordless below 200px, and
334
+ the panel's drag band bottoms out at 181px, so that state is live in the
335
+ editor today; card 66246 then made the shop's Weight row show one word of
336
+ four at the DEFAULT panel and none at the narrow end. A chip that used to sit
337
+ beside a word became the only thing saying which option is set.
338
+
339
+ Then the second mark went, and nobody noticed. Card 66248 moved the strip
340
+ into this package and the quiet label darkened from the muted grey to
341
+ `--es-text-secondary`. That darkening is right on its own terms — it is card
342
+ 66138's AA fix, 4.39:1 to 9.37:1 on the track — but it also cut the
343
+ chosen-vs-quiet GLYPH contrast from 3.67:1 to 1.72:1. The strip lost the one
344
+ state cue that was already above 3:1, and the change was described as
345
+ cosmetic when it merged.
346
+
347
+ So the raised white chip was left carrying the whole message alone, at
348
+ 1.10:1 against its own track, where WCAG 1.4.11 asks 3:1 of anything
349
+ identifying a control's STATE.
350
+
351
+ WHY DARK AND NOT A RING. Four treatments were drawn and measured for Lewis;
352
+ two of the routes were closed by arithmetic before he saw them (darkening the
353
+ track drops the quiet label to 3.40:1 and fails AA; pushing the glyph delta
354
+ only reaches 3:1 by putting that label back where 66138 moved it FROM). Of
355
+ what was left, this is the one that survives the DISABLED state:
356
+ `.es-segs[data-disabled]` fades the strip to 0.5 over the panel, and a ring
357
+ at 4.39:1 composites down to 1.89:1 while this holds 3.24:1. A cue that only
358
+ works while the control is live is a cue with a hole in it.
359
+
360
+ ⚠️ THE LIFT STAYS, AND IT IS NO LONGER THE MESSAGE. `--es-raised-shadow`
361
+ still seats the chip on the track. What it must not go back to being is the
362
+ thing telling the two states apart — a shadow is not a colour, so no
363
+ contrast ratio can score it, and that is exactly how this got to 1.10:1
364
+ without any guard noticing. `tests/panel-chip-contrast.test.ts` pins the
365
+ RATIO and not this colour, so a better answer is free to replace it. */
366
+ .es-tab.is-active,
367
+ .es-seg.is-active {
368
+ background: var(--es-text);
369
+ color: var(--es-on-accent);
370
+ box-shadow: var(--es-raised-shadow);
371
+ border-radius: 6px;
372
+ }
373
+
374
+ /* Focused AND chosen: keep the lift, or the chip drops flat the moment a
375
+ keyboard reaches it. */
376
+ .es-tab.is-active:focus-visible,
377
+ .es-seg.is-active:focus-visible {
378
+ box-shadow: var(--es-raised-shadow), 0 0 0 3px var(--es-accent-ring);
379
+ }
380
+
381
+ .es-tab-badge {
382
+ min-width: 16px;
383
+ height: 16px;
384
+ padding: 0 5px;
385
+ border-radius: 999px;
386
+ background: var(--es-accent);
387
+ color: var(--es-on-accent);
388
+ font-size: 10px;
389
+ font-weight: 700;
390
+ display: inline-flex;
391
+ align-items: center;
392
+ justify-content: center;
393
+ }
394
+
395
+ /* The glyph in the chip: 16px of drawing, the same size a field's icon takes,
396
+ standing in an 18px box.
397
+
398
+ DRAWN AT EVERY WIDTH, unlike the strip this replaces, where the glyph only
399
+ appeared once the words started failing. In a segmented control the glyph is
400
+ part of the chip — it is what the chip still says when its word has gone, and
401
+ mockup B draws it in both states.
402
+
403
+ The 18 is not a second opinion about the glyph — it is the WORD's own line
404
+ box (12px/600 in `--es-font` lays out an 18px line). Give the box that height
405
+ and a chip is the same height whether it is showing a word, a glyph, or both,
406
+ so the track keeps one height as the merchant drags the panel past a
407
+ threshold. It is also what keeps a badge from moving anything: the badge is
408
+ 16px, so it is never the tallest thing in the chip.
409
+
410
+ `flex: none` because a glyph that shrinks is a squeezed segment in a smaller
411
+ box. */
412
+ .es-tab-icon,
413
+ .es-seg-icon {
414
+ display: inline-flex;
415
+ width: var(--es-chip-icon);
416
+ height: 18px;
417
+ flex: none;
418
+ align-items: center;
419
+ justify-content: center;
420
+ }
421
+
422
+ .es-tab-icon svg,
423
+ .es-seg-icon svg { width: var(--es-chip-icon); height: var(--es-chip-icon); display: block; }
424
+
425
+ /* ── where the word stops fitting ──────────────────────────────────────────── */
426
+
427
+ /* MEASURED in Chromium, `tests/fixtures/tabs-segmented-fit.html`, against these
428
+ * stylesheets and the shop's four buckets Content / Media / Style / Layout, by
429
+ * EDGE GEOMETRY — the right edge of the last segment against the right content
430
+ * edge of the track. Not `scrollWidth`: a flex button reports a `scrollWidth`
431
+ * equal to its `clientWidth` while its own content is drawn outside it, so it
432
+ * says "fits" about a strip that visibly does not.
433
+ *
434
+ * Smallest container width with nothing drawn past the track edge:
435
+ *
436
+ * tabs words glyph + active word glyphs only
437
+ * 2 168px 126px 73px
438
+ * 3 239px 161px 108px
439
+ * 4 320px 196px 143px
440
+ *
441
+ * THE GLYPHS-ONLY COLUMN SAID 120px FOR BOTH 2 AND 3 TABS UNTIL CARD 66248,
442
+ * and it was wrong twice over. `tabs-segmented-fit.html` searched upward from
443
+ * 110px, so it could never have reported anything smaller than 110 whatever the
444
+ * truth was — and 120 is not even that, so a second hand had been on it. The
445
+ * truth is arithmetic and there is nothing to measure: a chip with no word is a
446
+ * glyph in its padding, so the strip is `n × 32px` plus `(n − 1)` gaps plus the
447
+ * track's own padding — 73, 108, 143. The 4-tab cell was right by luck of being
448
+ * above the floor. Re-measured with the search starting at 40px, all three
449
+ * agree with the arithmetic to the pixel; `(d)` now pins them to it, so the
450
+ * cell cannot drift from the sheet again.
451
+ *
452
+ * The drag band gives containers from 181px (a 220px panel, the minimum) to
453
+ * 281px (a 320px panel, the default — the panel minus 28px of body padding
454
+ * minus the 11px reserved scrollbar gutter). Read against it:
455
+ *
456
+ * · TWO tabs never drop a word. 168px is under the 181px floor, so both words
457
+ * survive the whole band — and this is the case Lewis is looking at.
458
+ * · THREE tabs carry their words down to 239px.
459
+ * · FOUR tabs need 320px, which the band never reaches, so a four-bucket
460
+ * section is glyph-plus-one-word at the default panel, and below 196px even
461
+ * that one word stops fitting.
462
+ *
463
+ * WHY THE RULES ARE KEYED ON THE TAB COUNT. With natural widths, whether the
464
+ * words fit is a question about the SUM of the labels, so the answer moves with
465
+ * how many buckets a section has. A width-only rule would have to answer for
466
+ * the worst case — four tabs — and would then strip the word off a TWO-tab
467
+ * strip at the default panel width. That is the exact bug this card exists to
468
+ * remove, so it cannot be the fix for it.
469
+ *
470
+ * Only `.has-icon` is ever touched. A tab with no glyph has nothing to fall
471
+ * back to, so it keeps its word at every width — a button holding neither word
472
+ * nor glyph is not a narrower segment, it is an empty one. */
473
+
474
+ /* THREE OR MORE, under 240px: the words on the tabs he is not reading go. The
475
+ NAME does not go with them — it is still on the button as `title` and
476
+ `aria-label`, in every state. */
477
+ @container (width < 240px) {
478
+ .es-tabs:has(.es-tab:nth-child(3)) .es-tab.has-icon:not(.is-active) .es-tab-word { display: none; }
479
+ }
480
+
481
+ /* FOUR OR MORE: a fourth label is another 78px of track, so the same words stop
482
+ fitting a long way sooner. */
483
+ @container (width < 320px) {
484
+ .es-tabs:has(.es-tab:nth-child(4)) .es-tab.has-icon:not(.is-active) .es-tab-word { display: none; }
485
+ }
486
+
487
+ /* FOUR OR MORE at the narrow end: the last word goes as well, and the strip is
488
+ four glyphs on a track. It reads — the track and the chips are what say
489
+ "these are buttons", which is more than the bare glyphs it replaces ever
490
+ said. */
491
+ @container (width < 200px) {
492
+ .es-tabs:has(.es-tab:nth-child(4)) .es-tab.has-icon.is-active .es-tab-word { display: none; }
493
+ }
494
+
495
+ /* ── the segmented control: the same chip, a different promise ─────────────── */
496
+
497
+ /* WHY A SECOND COMPONENT AND NOT A SECOND CALLER OF `SettingsTabs` (card 66248).
498
+ `SettingsTabs` is a TABLIST, and `role="tab"` is the promise that pressing a
499
+ chip switches a panel. The rows this control is for switch nothing: Weight,
500
+ Case and Alignment WRITE A VALUE and stay where they are. Pointing the tab
501
+ strip at them would make three controls that are correct today start
502
+ announcing "tab, 1 of 4, selected" over nothing. Everything a merchant SEES
503
+ is shared above — one chip, one track, one raised-chip mark — and only the
504
+ promise differs. It is the split this sheet already made between
505
+ `.es-group-head` and `button.es-group-head`.
506
+
507
+ ── WHY THE @container TIERS ABOVE ARE NOT INHERITED ────────────────────────
508
+
509
+ Those three tiers are keyed on the tab COUNT, and that works for the strip
510
+ they were measured for because the shell KNOWS its four words: Content /
511
+ Media / Style / Layout. It cannot work here, and the reason is arithmetic,
512
+ not taste. MEASURED in Chromium, `tests/fixtures/segmented-fit.html`, the
513
+ width at which each row's words stop fitting:
514
+
515
+ row options words fit from
516
+ Case Normal / Uppercase / Lowercase 3 296px
517
+ Alignment Left / Centre / Right 3 221px
518
+
519
+ Two three-option rows, 75px apart. One threshold at three options is wrong
520
+ for one of them whichever number is picked — and the panel drags between
521
+ 181px and 281px, so the wrong one is wrong ON SCREEN: at the default 281px
522
+ panel Alignment's words fit and Case's do not.
523
+
524
+ Whether the words fit is a question about the SUM OF THE LABELS, which is
525
+ what this sheet already says out loud above, and a count is only ever a
526
+ stand-in for it. The stand-in holds while one strip's words are known and
527
+ breaks the moment a second strip brings its own.
528
+
529
+ ── SO THE THRESHOLD IS COMPUTED, PER STRIP ─────────────────────────────────
530
+
531
+ Everything in that sum except the words is the CHIP'S, and named on the
532
+ panel root above. Only the words belong to the consumer. So the strip
533
+ declares its own two measurements and this sheet adds the chrome:
534
+
535
+ words-from = the track + every chip with its glyph, its gap and its word
536
+ chosen-from = the track + every chip's glyph, plus one word — the widest,
537
+ because the merchant may be on any option and the strip may
538
+ not overflow on the one that costs most
539
+
540
+ WHY NOT `@container` HERE. A container query condition takes a literal
541
+ length; it cannot read a custom property, so it cannot ask a question whose
542
+ answer differs per strip. Container query UNITS can: `100cqi` is the panel's
543
+ own width, in a place `calc()` can use it. So the comparison is done in the
544
+ property instead of in the query — subtract the threshold from the width and
545
+ scale hard. Above the threshold that is a large positive length and the word
546
+ is unlimited; below it the result is negative, and `max-width` clamps a
547
+ negative to zero, which is the word gone. Same two tiers as the strip above,
548
+ measured continuously instead of at three fixed steps.
549
+
550
+ WHY NOT ESTIMATE THE WORDS FROM THEIR LETTERS, and skip the measuring. The
551
+ chip's font is `--es-font`, which resolves to SF on a Mac, Segoe UI on
552
+ Windows and something else again elsewhere — one shipped table of letter
553
+ widths would be wrong on two platforms out of three. Measured against these
554
+ labels the guess is out by up to 7% either way, which is 12px on a 170px sum:
555
+ enough to drop a word that fits, or keep one that does not.
556
+
557
+ WHY NOT MEASURE IT AT RUNTIME. That is a `ResizeObserver` and an effect, and
558
+ this module is a hook-free leaf a server component can import. The host could
559
+ do it, but then every consumer writes its own and they drift — which is the
560
+ thing this package exists to stop. */
561
+
562
+ /* ── WORD FIT: ONE RULE FOR EVERY STRIP WHOSE WORDS ARE THE HOST'S ─────────
563
+
564
+ Two strips read it: this segmented control, and the top-bar rail
565
+ (`efficient-easel/chrome` `TopBarRail`, owner 2026-09-18: "the rule must be the
566
+ same as the top tabs of the right settings panel"). Both have the tab strip's
567
+ THREE states — every word; the glyphs plus the chosen word; glyphs only —
568
+ and both have words the shell has never seen, so both need the computed
569
+ threshold above rather than the tab strip's count-keyed tiers.
570
+
571
+ It is written ONCE, here, with neutral names. Each strip declares its own
572
+ chrome as four inputs, and the component hands in the three measured ones
573
+ (`wordFitVars`, `src/fit/wordFit.ts`):
574
+
575
+ --es-fit-chip one button with its glyph and no word
576
+ --es-fit-word-gap glyph to word
577
+ --es-fit-gap between two buttons
578
+ --es-fit-ends the strip's own inset at EACH end
579
+ --es-fit-count · --es-fit-labels · --es-fit-widest measured
580
+
581
+ words-from = 2 × ends + (count − 1) × gap + count × (chip + word-gap) + labels
582
+ chosen-from = 2 × ends + (count − 1) × gap + count × chip + word-gap + widest
583
+
584
+ NO MEASUREMENT, NO THRESHOLD. `is-measured` is the component's own answer to
585
+ whether it was given a `fit`, so the arithmetic below is reached by a strip
586
+ that has one and never by a strip that does not. What an unmeasured strip
587
+ does is the strip's own call: a segmented control keeps every word (there is
588
+ nowhere honest to drop one), a top-bar rail stays icon-only (always fits). */
589
+ .es-segs {
590
+ --es-fit-chip: calc(2 * var(--es-chip-pad-x) + var(--es-chip-icon));
591
+ --es-fit-word-gap: var(--es-chip-icon-gap);
592
+ --es-fit-gap: var(--es-track-gap);
593
+ --es-fit-ends: var(--es-track-pad);
594
+ }
595
+
596
+ .es-segs.is-measured,
597
+ .es-rail-row.is-measured {
598
+ --es-fit-strip: calc(2 * var(--es-fit-ends) + (var(--es-fit-count) - 1) * var(--es-fit-gap));
599
+
600
+ --es-fit-words-from: calc(
601
+ var(--es-fit-strip) +
602
+ var(--es-fit-count) * (var(--es-fit-chip) + var(--es-fit-word-gap)) +
603
+ var(--es-fit-labels)
604
+ );
605
+ --es-fit-chosen-from: calc(
606
+ var(--es-fit-strip) +
607
+ var(--es-fit-count) * var(--es-fit-chip) +
608
+ var(--es-fit-word-gap) +
609
+ var(--es-fit-widest)
610
+ );
611
+ }
612
+
613
+ /* THE ROOM LEFT OVER, once this strip's own words are paid for. Positive and
614
+ large while they fit, negative below — and the two properties that read it
615
+ both clamp a negative to zero, which is what makes it a switch rather than a
616
+ width. The 10000 is what makes the change a step and not a fade: without it
617
+ the last pixel before the threshold would draw a one-pixel word.
618
+
619
+ `100cqi` stays a unit until a word USES it, so it is the width of the
620
+ container around that word — the strip's own `-fit` wrapper. */
621
+ .es-segs,
622
+ .es-rail-row.is-measured {
623
+ --es-fit-room-all: calc((100cqi - var(--es-fit-words-from, 0px)) * 10000);
624
+ --es-fit-room-chosen: calc((100cqi - var(--es-fit-chosen-from, 0px)) * 10000);
625
+ }
626
+
627
+ /* The word is CLIPPED to nothing rather than `display: none`, because the
628
+ switch has to be a length for `max-width` to clamp it. Nothing is lost by
629
+ it: the label is on the button as `title` and `aria-label` in every state, so
630
+ the name a screen reader reads never depended on the span being drawn. */
631
+ .es-seg-word,
632
+ .es-rail-row.is-measured .es-rail-word {
633
+ overflow: hidden;
634
+ max-width: var(--es-fit-room-all);
635
+ }
636
+
637
+ /* THE CHOSEN SEGMENT KEEPS ITS WORD LONGER — the second tier, and the same one
638
+ the tab strip drops to. A track of bare glyphs still reads, because the track
639
+ and the chips are what say "these are buttons"; a track that also still says
640
+ which value is set reads better, and it costs one word. On the rail "chosen"
641
+ is the button whose panel is open. */
642
+ .es-seg.is-active .es-seg-word,
643
+ .es-rail-row.is-measured .es-rail-btn.is-active .es-rail-word { max-width: var(--es-fit-room-chosen); }
644
+
645
+ /* THE GAP GOES WITH THE WORD IT SEPARATES. The chip's `gap` is a flex gap, and
646
+ a flex gap does not care that the word is now zero wide — it would leave 6px
647
+ of nothing between the glyph and the panel edge and put every glyphs-only
648
+ strip 6px per chip over its real width. So the segment spends the gap as the
649
+ word's OWN margin and switches it with the same room. The tab strip is
650
+ untouched and keeps the flex gap, which is right there: its word is
651
+ `display: none`, so the gap goes with the element, and a tab also has a badge
652
+ on the other side of that word for the gap to separate. */
653
+ .es-seg { gap: 0; }
654
+ .es-seg.has-icon .es-seg-word,
655
+ .es-rail-row.is-measured .es-rail-word { margin-left: clamp(0px, var(--es-fit-room-all), var(--es-fit-word-gap)); }
656
+ .es-seg.has-icon.is-active .es-seg-word,
657
+ .es-rail-row.is-measured .es-rail-btn.is-active .es-rail-word { margin-left: clamp(0px, var(--es-fit-room-chosen), var(--es-fit-word-gap)); }
658
+
659
+ /* ── a segment out of force (card 66303; the RULE is card 66216's) ──────────
660
+
661
+ The setting still EXISTS and still shows the merchant's saved answer. Another
662
+ setting has simply taken the decision away — the Header's "Text colour at the
663
+ top" under a pinned Background, which cannot be transparent. DIMMED, NEVER
664
+ HIDDEN: a control that disappears reads as a feature that was removed, and
665
+ leaves the merchant no way to see what to change to get it back.
666
+
667
+ THE STATE IS ON THE BUTTONS, and `SettingsSegmented` renders it as their own
668
+ `disabled` rather than as a fade a host paints over them. A faded parent
669
+ leaves every segment focusable and announcing as ENABLED, which is the defect
670
+ the 2026-08-06 audit and card 66034 closed; it is also why the prop had to
671
+ come here at all, since nothing outside this component owns those buttons.
672
+
673
+ `--es-disabled` is the shell's existing token for exactly this state, already
674
+ worn by the drill row's quiet glyph. Nothing here is a literal, and nothing
675
+ here removes anything: the track keeps its border and the pressed chip keeps
676
+ its lift, so the strip still READS as a strip with an answer in it. */
677
+ .es-seg:disabled { cursor: not-allowed; color: var(--es-disabled); }
678
+
679
+ /* The chosen one stays the loudest thing on the track. It is still the
680
+ merchant's answer — a pressed chip faded to the same grey as the ones beside
681
+ it stops saying which answer that was.
682
+
683
+ ⚠️ THIS COLOUR BELONGS TO THE CHIP'S GROUND, NOT TO THE TRACK (card 66302).
684
+ It was `--es-text-secondary`, which was right while the chip was white:
685
+ 10.31:1 on it. The moment the chip went dark that same rule drew
686
+ `--es-text-secondary` on `--es-text` — 1.72:1, an unreadable label on a
687
+ control that had just been made MORE visible. Nothing about the rule changed; what it sat on moved underneath
688
+ it. So it reads `--es-on-accent` for the same reason the enabled chip does,
689
+ and the strip says which option is out of force through the track's fade and
690
+ its quiet siblings rather than by dimming the one word still worth reading. */
691
+ .es-seg:disabled.is-active { color: var(--es-on-accent); }
692
+
693
+ /* And none of them answers the pointer any more: the hover rule above would
694
+ otherwise keep lighting a segment that cannot be chosen. */
695
+ .es-seg:disabled:hover:not(.is-active) { color: var(--es-disabled); }
696
+
697
+ /* THE WHOLE CONTROL, when every option went at once — one statement instead of
698
+ three identically faded chips. The track is the part that says "these buttons
699
+ are one control", so it is the right place to say the control is not in force.
700
+ A single option out of force leaves the track alone, because the strip still
701
+ is. */
702
+ .es-segs[data-disabled] { opacity: 0.5; cursor: not-allowed; }
703
+
704
+ /* ── a group: the soft pill row that folds ─────────────────────────────────── */
705
+
706
+ /* WAS 6px, AND THE 6 NEVER APPLIED (card 66203). `.es-group` is a plain div, so
707
+ this margin is adjoining `.es-group-head`'s own 36px below and the two
708
+ COLLAPSE to the larger — measured in Chromium against these stylesheets, two
709
+ stacked groups sit 36.00px apart, not 6. The rule was dead as written.
710
+
711
+ It is kept, at the floor, because it is a landmine rather than a non-issue: a
712
+ host that gives `.es-group` a border or padding through `className` stops the
713
+ collapsing, and then the 6 is real and is the tightest gap in the panel. At
714
+ `var(--es-row-gap)` the worst case is the floor instead. */
715
+ .es-group + .es-group { margin-top: var(--es-row-gap); }
716
+
717
+ /* ONE pill, two elements. `SettingsGroup` renders a <button> that folds;
718
+ `SettingsGroupHead` renders a heading that folds nothing, because Puck's
719
+ inspector is a flat sibling list and a heading there cannot wrap the rows it
720
+ heads. Everything a merchant SEES is shared here; the press — the pointer
721
+ and the hover tint — is scoped to the button below, so the heading never
722
+ offers a press it cannot honour.
723
+
724
+ `box-sizing` for the same reason the switch sets it: this file assumes no
725
+ reset of the host's, and `width: 100%` plus 22px of padding would otherwise
726
+ run the pill past the panel. */
727
+ .es-group-head {
728
+ box-sizing: border-box;
729
+ width: 100%;
730
+ display: flex;
731
+ align-items: center;
732
+ gap: 8px;
733
+ border: 0;
734
+ text-align: left;
735
+ font: inherit;
736
+ font-size: 12px;
737
+ font-weight: 600;
738
+ color: var(--es-text);
739
+ background: var(--es-hover);
740
+ border-radius: var(--es-row-radius);
741
+ padding: 8px 11px;
742
+ /* A heading needs more air above it than the settings it heads have between
743
+ them (38px), or it reads as one more row. Floored on `--es-row-gap` so it
744
+ rises with the rest of the panel (card 66203). */
745
+ margin: max(var(--es-row-gap), var(--es-rhythm-group)) 0 0;
746
+ }
747
+
748
+ /* THE QUIET TIER (card 66300) — a heading one rung under the pill.
749
+ NO GROUND AND NO RULE, which is the whole point of it. The pill separates
750
+ itself from the settings around it with a fill; this separates itself with
751
+ case and colour, so the two can sit in one panel without competing. And a
752
+ heading takes no hairline under it: a rule there separates a heading from the
753
+ rows it heads, which is the opposite of what a heading is for. The storefront
754
+ ruled BOTH its tiers that way and the owner's words on 2026-08-31 were
755
+ "these lines, I still find them annoying to look at".
756
+
757
+ THE TYPE IS NOT INVENTED HERE. It is `.sf-field-group` and `.sf-group-label`
758
+ from `efficient-shop`, which are the same look to the decimal, hand-rolled in
759
+ two panels of one editor. Owning it here is what stops a third copy.
760
+
761
+ FLUSH, not inset. `.es-field` has no horizontal padding, so a heading that
762
+ started 11px in would sit to the RIGHT of the rows it heads and read as those
763
+ rows being outdented from it. The pill's text is inset only because a pill has
764
+ padding; this has no such excuse.
765
+
766
+ ONE NOTCH UNDER THE PILL IN AIR AS WELL AS IN TYPE. `--es-rhythm-between`
767
+ rather than the pill's `--es-rhythm-group`, so the tiers stay in order when a
768
+ reader is skimming rather than reading. Written as NAMES: every one of these
769
+ tokens grew between 0.20.0 and 0.23.0 (group 36->48, between 28->38,
770
+ group-first 14->24) and the ladder survived untouched because it reads names.
771
+ Pinned numbers would have crossed. */
772
+ .es-group-sub {
773
+ box-sizing: border-box;
774
+ font-size: 10.5px;
775
+ font-weight: 600;
776
+ letter-spacing: 0.07em;
777
+ text-transform: uppercase;
778
+ color: var(--es-muted);
779
+ margin: max(var(--es-row-gap), var(--es-rhythm-between)) 0
780
+ max(var(--es-row-gap), var(--es-rhythm-group-first));
781
+ }
782
+
783
+ /* Directly under a pill it belongs TO that pill, so it does not take a second
784
+ helping of the air the pill already put above itself. */
785
+ .es-group-head + .es-group-sub { margin-top: max(var(--es-row-gap), var(--es-rhythm-group-first)); }
786
+
787
+ button.es-group-head {
788
+ cursor: pointer;
789
+ transition: background 0.12s;
790
+ }
791
+
792
+ button.es-group-head:hover { background: var(--es-chip-bg); }
793
+
794
+ .es-group-count {
795
+ min-width: 16px;
796
+ height: 16px;
797
+ padding: 0 5px;
798
+ border-radius: 999px;
799
+ background: var(--es-accent);
800
+ color: var(--es-on-accent);
801
+ font-size: 10px;
802
+ font-weight: 700;
803
+ display: inline-flex;
804
+ align-items: center;
805
+ justify-content: center;
806
+ }
807
+
808
+ /* The sign is drawn from aria-expanded so the open state has ONE home. */
809
+ .es-group-sign {
810
+ margin-left: auto;
811
+ width: 16px;
812
+ text-align: center;
813
+ color: var(--es-muted);
814
+ font-size: 14px;
815
+ line-height: 1;
816
+ flex: none;
817
+ }
818
+
819
+ .es-group-head[aria-expanded='true'] .es-group-sign::before { content: '−'; }
820
+ .es-group-head[aria-expanded='false'] .es-group-sign::before { content: '+'; }
821
+
822
+ /* `hidden` on the body is the fold; this only stops a flex/grid parent from
823
+ overriding the attribute's own display:none. */
824
+ .es-group-body[hidden] { display: none; }
825
+ /* The first setting under a head sits CLOSER than the settings below it do to
826
+ each other — it belongs to that head, and 38px would cut it loose.
827
+
828
+ AND IT IS LIVE FOR THE FIRST TIME (card 66301). It declared 14 against a 16px
829
+ floor, so it has never once been 14 on screen — the floor answered every
830
+ time, and card 66206 measured that very move (14 → 16) and wrote it down as
831
+ the floor working, which it was. At 24 over a 20px floor the declared number
832
+ is finally the visible one, so this seam grows 16 → 24 rather than the 14 →
833
+ 24 the file appears to say. `tests/panel-layout.test.ts` (h) is what turns a
834
+ floored rhythm into a failure instead of a footnote. */
835
+ .es-group-body > .es-field:first-child { margin-top: max(var(--es-row-gap), var(--es-rhythm-group-first)); }
836
+
837
+ /* ── one setting ───────────────────────────────────────────────────────────── */
838
+
839
+ /* 38 IS NOT A THIRD OPINION ABOUT THE FLOOR. A setting is TWO lines — a label
840
+ row and its control — held together by the 11px between them, and that 11px
841
+ is the only thing saying which label owns which control. The gap between
842
+ settings has to be plainly larger than the gap inside one or the panel stops
843
+ being a list of settings and becomes a list of loose parts. The pair was
844
+ measured at 28 and 7 and approved from mockups (card 8802.d) — 20 was tried
845
+ there and was too tight — and re-scaled together to 38 and 11 (card 66301),
846
+ which is the same rule with more air, not a new one.
847
+
848
+ Floored, not replaced, and not `calc()`: 38 was measured and then re-scaled,
849
+ it is not "20 plus 18", so arithmetic off the token would claim a
850
+ relationship nobody decided. */
851
+ .es-field { margin-top: max(var(--es-row-gap), var(--es-rhythm-between)); }
852
+
853
+ .es-field-head {
854
+ display: flex;
855
+ align-items: center;
856
+ gap: 8px;
857
+ /* THE ONE THAT SAYS WHICH LABEL OWNS WHICH CONTROL. Named so a consumer can
858
+ read it (card 66228) — it is half of the rule, and the half that is easiest
859
+ to lose when someone re-scales the other half. */
860
+ margin-bottom: var(--es-rhythm-within);
861
+ min-height: 16px;
862
+ }
863
+
864
+ .es-field-name {
865
+ display: inline-flex;
866
+ align-items: center;
867
+ gap: 8px;
868
+ min-width: 0;
869
+ }
870
+
871
+ .es-field-icon {
872
+ width: 16px;
873
+ height: 16px;
874
+ flex: none;
875
+ color: var(--es-icon);
876
+ display: inline-flex;
877
+ align-items: center;
878
+ justify-content: center;
879
+ }
880
+
881
+ .es-field-icon svg { width: 16px; height: 16px; }
882
+
883
+ .es-field-label {
884
+ font-size: 11px;
885
+ font-weight: 600;
886
+ color: var(--es-muted);
887
+ }
888
+
889
+ .es-field.is-changed .es-field-label { color: var(--es-accent-text); }
890
+
891
+ .es-field-value {
892
+ margin-left: auto;
893
+ font-size: 12px;
894
+ color: var(--es-text);
895
+ font-variant-numeric: tabular-nums;
896
+ }
897
+
898
+ .es-field-ctl {
899
+ display: flex;
900
+ align-items: center;
901
+ gap: 8px;
902
+ min-width: 0;
903
+ }
904
+
905
+ .es-field-hint {
906
+ margin: 7px 0 0;
907
+ padding-left: 24px;
908
+ font-size: 11px;
909
+ line-height: 1.45;
910
+ color: var(--es-muted);
911
+ }
912
+
913
+ /* A boolean row is ONE line (card 8801, Option A). The switch rides the label
914
+ line in the `action` slot, so there is no control row under it — and with no
915
+ control row, the label line's 7px bottom margin would be a gap to nothing.
916
+
917
+ A one-line row is one line tall, so it needs less air above it than a
918
+ two-line setting does — 26, not 38 — and it is floored on the token for the
919
+ same reason everything else here is (card 66203).
920
+
921
+ IT TIED WITH THE FLOOR UNTIL NOW (card 66301). At 16 against a 16px floor
922
+ nothing was wrong on screen, but the sheet could not tell you which of the
923
+ two numbers you were reading, and moving either one alone would have moved
924
+ nothing. 26 over 20 puts it plainly on its own number. */
925
+ .es-field.is-inline { margin-top: max(var(--es-row-gap), var(--es-rhythm-between-tight)); }
926
+ .es-field.is-inline .es-field-head { margin-bottom: 0; }
927
+
928
+ /* ── the switch: one yes-or-no shape, both editors ─────────────────────────── */
929
+
930
+ .es-switch {
931
+ appearance: none;
932
+ -webkit-appearance: none;
933
+ box-sizing: border-box;
934
+ margin: 0;
935
+ padding: 0;
936
+ border: 0;
937
+ flex: none;
938
+ position: relative;
939
+ width: 34px;
940
+ height: 20px;
941
+ border-radius: 999px;
942
+ background: var(--es-toggle-off);
943
+ cursor: pointer;
944
+ transition: background 0.15s ease;
945
+ }
946
+
947
+ /* The knob. `box-sizing` is load-bearing, not tidiness: the locked knob below
948
+ takes a 1px border, and under content-box that border would grow it to 18px
949
+ and eat the 2px gap the whole shape is built on. */
950
+ .es-switch::after {
951
+ content: '';
952
+ box-sizing: border-box;
953
+ position: absolute;
954
+ top: 2px;
955
+ left: 2px;
956
+ width: 16px;
957
+ height: 16px;
958
+ border-radius: 50%;
959
+ background: var(--es-knob);
960
+ /* The knob has to lift off the track, and the shadow is a plain token on
961
+ purpose. A softer mix reads slightly better, but if color-mix() ever fails
962
+ to parse the WHOLE declaration drops and the knob goes flat with no
963
+ warning — not a risk worth carrying in a leaf both editors render
964
+ thousands of times for the sake of a 1px shadow. */
965
+ box-shadow: 0 1px 2px var(--es-input-border);
966
+ transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.3, 1);
967
+ }
968
+
969
+ /* On is the accent — the same blue as an active tab, so the panel keeps ONE
970
+ accent. The travel is 14px: 34 track − 16 knob − 2 gap − 2 gap. */
971
+ .es-switch[aria-checked='true'] { background: var(--es-accent); }
972
+ .es-switch[aria-checked='true']::after { transform: translateX(14px); }
973
+
974
+ .es-switch:hover:not(:disabled) { filter: brightness(0.96); }
975
+
976
+ .es-switch:focus-visible {
977
+ outline: none;
978
+ box-shadow: 0 0 0 3px var(--es-accent-ring);
979
+ }
980
+
981
+ /* Held down: the knob stretches the way a physical one would.
982
+
983
+ ON, it must stretch LEFT. The knob is 16px at translateX(14px), so growing it
984
+ to 19px in place would put its right edge at 2 + 14 + 19 = 35px inside a 34px
985
+ track and poke it past the round end. Pulling the travel back to 11px lands
986
+ the right edge on 32 and keeps the 2px gap on both sides. */
987
+ .es-switch:active:not(:disabled)::after { width: 19px; }
988
+ .es-switch[aria-checked='true']:active:not(:disabled)::after { transform: translateX(11px); }
989
+
990
+ /* Locked. Still readable as on or off, but flat and grey, and the knob trades
991
+ its shadow for a hairline so it does not look pressable. */
992
+ .es-switch:disabled { background: var(--es-divider); cursor: not-allowed; }
993
+ .es-switch:disabled[aria-checked='true'] { background: var(--es-input-border); }
994
+ .es-switch:disabled::after { box-shadow: none; border: 1px solid var(--es-divider); }
995
+
996
+ /* ── a drill-in row: name, preview, chevron (card 8819) ────────────────────── */
997
+
998
+ /* The row a section lists a text part as. Pressing it drills the inspector down
999
+ a level to that part's own editor.
1000
+
1001
+ A FULL-WIDTH LIST ROW, not the raised pill a group's head is. These stack into
1002
+ a list at the bottom of the section panel: flat rows, a quiet hover tint, no
1003
+ chip elevation.
1004
+
1005
+ ⚠️ THEY ARE SEPARATED BY AIR, NOT BY INK (card 66350). Up to 0.29.0 a hairline
1006
+ was drawn between adjacent rows, `.es-drill + .es-drill`. The owner asked for
1007
+ the settings panel's divider lines to go, repeatedly; card 66334 removed every
1008
+ one the storefront itself draws, and this was the largest source left that the
1009
+ shop could not reach, because it lives here. Sixteen painted lines in one
1010
+ editor, measured. The shop overrode it locally in the meantime, which made
1011
+ this package and its consumer two sources of truth for one line — this
1012
+ deletion is what lets that override go.
1013
+
1014
+ The air between rows does not change with it. It was never the hairline's; it
1015
+ is each row's own padding, on its own side of the seam.
1016
+
1017
+ `box-sizing` for the same reason the switch and the group head set it: this
1018
+ file assumes no reset of the host's, and `width: 100%` plus the padding would
1019
+ otherwise run the row past the panel. */
1020
+ .es-drill {
1021
+ box-sizing: border-box;
1022
+ width: 100%;
1023
+ display: flex;
1024
+ align-items: center;
1025
+ gap: 10px;
1026
+ border: 0;
1027
+ text-align: left;
1028
+ font: inherit;
1029
+ background: transparent;
1030
+ /* THE DRILL LIST SPENDS ITS ROW GAP HERE, AS PADDING, AND THAT IS DELIBERATE
1031
+ (card 66203). The rows' boxes touch and nothing between them adds a margin,
1032
+ so the air a merchant sees is each row's own vertical padding, on its own
1033
+ side of the seam. That was true when a hairline sat on the seam and it is
1034
+ true now that none does — card 66350 removed the INK and moved no spacing,
1035
+ so every number below still stands as its card recorded it. MEASURED in
1036
+ Chromium against these stylesheets (`tests/fixtures/panel-row-spacing.html`):
1037
+
1038
+ token above the seam below it word to word recorded by
1039
+ (each row taken while the
1040
+ hairline was still drawn)
1041
+ 10px 11.40px 12.00px 23.40px card 66203
1042
+ 16px 17.40px 18.00px 35.40px card 66206
1043
+ 16px 17.39px 17.00px 34.39px card 66301
1044
+ 20px 21.39px 21.00px 42.39px card 66301
1045
+
1046
+ TWO ROWS FOR 16px, AND THEY DISAGREE BY A PIXEL. 66301's pair was taken in
1047
+ one run of Chromium 124 (zenika/alpine-chrome) so its two rows compare; the
1048
+ earlier rows are left as the cards recorded them rather than overwritten,
1049
+ because the difference is the engine and the font it had, not this sheet.
1050
+ Read a row against another row from the same card, never across cards.
1051
+
1052
+ THIS SEAM MOVES AT TWICE THE TOKEN'S RATE, and it is the only one that
1053
+ does: both rows spend the gap, so +4 on the dial buys +8 of air. Under the
1054
+ floor alone it was also the ONLY place raising the token moved a measured
1055
+ distance rather than a declared one — every other seam was held by a bigger
1056
+ rhythm — which is why it is the row Lewis was looking at when he asked for
1057
+ card 66206. Card 66301 moves the rhythms too, so it is no longer alone.
1058
+
1059
+ A MARGIN WOULD STILL BE THE WRONG FIX, and the reason outlived the line it
1060
+ was first written about. Adjacent vertical margins COLLAPSE, so spending the
1061
+ gap that way makes the floor something two rows negotiate rather than
1062
+ something each row owns on its own side. So the gap stays padding — it just reads the token now, which is the whole point: before
1063
+ this, raising the panel's rhythm moved every other row and left the drill
1064
+ list exactly where it was. */
1065
+ padding: var(--es-row-gap) 11px;
1066
+ cursor: pointer;
1067
+ transition: background 0.12s;
1068
+ }
1069
+
1070
+ .es-drill:hover { background: var(--es-hover); }
1071
+
1072
+ /* An INSET ring, unlike the switch's and the tab's outset one: a full-width row
1073
+ has neighbours hard against its edges, and an outset ring would be clipped by
1074
+ the row above and below it. */
1075
+ .es-drill:focus-visible {
1076
+ outline: none;
1077
+ box-shadow: inset 0 0 0 2px var(--es-accent-ring);
1078
+ }
1079
+
1080
+ /* The two lines, stacked, and allowed to shrink so the preview can ellipsise
1081
+ instead of shoving the chevron off the row. */
1082
+ .es-drill-text {
1083
+ display: flex;
1084
+ flex-direction: column;
1085
+ gap: 2px;
1086
+ min-width: 0;
1087
+ flex: 1;
1088
+ }
1089
+
1090
+ .es-drill-name {
1091
+ font-size: 12px;
1092
+ font-weight: 600;
1093
+ color: var(--es-text-strong);
1094
+ }
1095
+
1096
+ /* The preview is one line, clipped. It never wraps: a part's copy can be a
1097
+ paragraph, and a row that grows to fit it stops being a row. */
1098
+ .es-drill-preview {
1099
+ font-size: 11px;
1100
+ color: var(--es-muted);
1101
+ white-space: nowrap;
1102
+ overflow: hidden;
1103
+ text-overflow: ellipsis;
1104
+ }
1105
+
1106
+ /* A part with no place on the page — an identifier the eye never draws. Quiet
1107
+ and italic, so it reads as a note about the part rather than as its copy. */
1108
+ .es-drill-preview.is-offpage {
1109
+ font-style: italic;
1110
+ color: var(--es-disabled);
1111
+ }
1112
+
1113
+ /* The status glyph (the "hidden on the page" eye). Not `aria-hidden` here — the
1114
+ host owns its meaning and names it; this only places it. */
1115
+ .es-drill-status {
1116
+ flex: none;
1117
+ display: inline-flex;
1118
+ align-items: center;
1119
+ color: var(--es-muted);
1120
+ }
1121
+
1122
+ /* The chevron is drawn from CSS `content`, the way the group sign is: it is pure
1123
+ direction, carries no text of its own, and stays out of the accessible name. */
1124
+ .es-drill-chev {
1125
+ flex: none;
1126
+ width: 16px;
1127
+ text-align: center;
1128
+ color: var(--es-muted);
1129
+ font-size: 16px;
1130
+ line-height: 1;
1131
+ }
1132
+
1133
+ .es-drill-chev::before { content: '\203A'; }
1134
+
1135
+ @media (prefers-reduced-motion: reduce) {
1136
+ .es-tab,
1137
+ .es-seg,
1138
+ /* `button.` and not `.es-group-head`: the transition it cancels is declared
1139
+ on `button.es-group-head`, and a plainer selector would lose to it. */
1140
+ button.es-group-head,
1141
+ .es-drill,
1142
+ .es-switch,
1143
+ .es-switch::after { transition: none; }
1144
+ }
1145
+
1146
+ /* ── SettingsPlacement — the 3×3 position grid ─────────────────────────────
1147
+ One control for two questions a merchant asks the same way: which part of a
1148
+ cropped picture stays in view, and where on the thing behind it a block sits.
1149
+ Both are one of nine positions, and both want the corner PRESSED.
1150
+
1151
+ Promoted out of efficient-shop (`.sf-focal`) the day its own rule fired —
1152
+ card 66189 wrote "the day a second section wants it, that is the card that
1153
+ promotes it"; 66214 declined it early and said why; the sections that hold
1154
+ boxes take it past six callers. The values below are the shop's, token for
1155
+ token, so every existing caller draws exactly what it drew: the only change
1156
+ is that `--sf-*` aliases have become the `--es-*` names they always aliased. */
1157
+ .es-placement {
1158
+ display: grid;
1159
+ grid-template-columns: repeat(3, 1fr);
1160
+ grid-template-rows: repeat(3, 1fr);
1161
+ gap: 4px;
1162
+ width: 96px;
1163
+ height: 96px;
1164
+ padding: 4px;
1165
+ background: var(--es-track-bg);
1166
+ border: 1px solid var(--es-input-border);
1167
+ border-radius: 9px;
1168
+ }
1169
+ .es-placement-cell {
1170
+ border: none;
1171
+ background: transparent;
1172
+ border-radius: 6px;
1173
+ cursor: pointer;
1174
+ padding: 0;
1175
+ display: flex;
1176
+ align-items: center;
1177
+ justify-content: center;
1178
+ transition: background 0.12s;
1179
+ }
1180
+ .es-placement-cell:hover { background: var(--es-hover); }
1181
+ /* The dot is the whole of the cell's state: a lit cell grows it and paints it
1182
+ the accent. Size and colour move together so the chosen square reads at a
1183
+ glance and still reads for someone who cannot separate the two colours. */
1184
+ .es-placement-dot {
1185
+ width: 7px;
1186
+ height: 7px;
1187
+ border-radius: 50%;
1188
+ background: var(--es-muted);
1189
+ opacity: 0.5;
1190
+ transition: transform 0.12s, background 0.12s, opacity 0.12s;
1191
+ }
1192
+ .es-placement-cell:hover .es-placement-dot { opacity: 0.85; }
1193
+ /* ⚠️ THE CHOSEN CELL IS THE PANEL'S CHIP, NOT THE SHOP'S TINT — and that is a
1194
+ VISIBLE change to the control the shop ships today. `.sf-focal` marked its
1195
+ chosen square with `--sf-accent-tint` and a bigger accent dot; that is card
1196
+ 8811's answer, which cards 66138 and 66302 replaced everywhere else in this
1197
+ panel. The strip tells the chosen chip apart by being the DARKEST thing in
1198
+ the row on a grey track, and a nine-square asks the identical question —
1199
+ which one am I on — so it gets the identical answer. Two answers to one
1200
+ question, one panel apart, is the thing this package exists to prevent, and
1201
+ `panel-layout.test.ts` bans the tint token package-wide to keep it that way.
1202
+ The dot inverts with the ground, or it disappears into it. */
1203
+ .es-placement-cell.is-active {
1204
+ background: var(--es-text);
1205
+ box-shadow: var(--es-raised-shadow);
1206
+ }
1207
+ .es-placement-cell.is-active .es-placement-dot {
1208
+ background: var(--es-on-accent);
1209
+ opacity: 1;
1210
+ transform: scale(1.5);
1211
+ }
1212
+ /* A position the caller has closed. It keeps its square — nine that became
1213
+ eight would read as a broken control — and stops answering the pointer; the
1214
+ reason is on the button's `title`. */
1215
+ .es-placement-cell.is-off { cursor: default; }
1216
+ .es-placement-cell.is-off:hover { background: transparent; }
1217
+ .es-placement-cell.is-off .es-placement-dot { opacity: 0.22; }
1218
+ /* A cell that draws a GLYPH instead of its dot — the Scrolling Bar's
1219
+ `Scroll direction`, where four live cells mean a direction of travel and a
1220
+ dot cannot say which way. It follows the cell through every state the dot
1221
+ does, or the chosen square goes dark and the arrow on it stays muted and
1222
+ disappears into the ground it is drawn on.
1223
+ 13px, sized against the CELL and not against the settings column: a cell is
1224
+ 26.67px across (96px box, 3×3, gap 4, padding 4) and the dot it stands in for
1225
+ is 7px, so 13px reads as a direction without crowding the 6px cell radius. */
1226
+ .es-placement-icon {
1227
+ display: flex;
1228
+ align-items: center;
1229
+ justify-content: center;
1230
+ color: var(--es-muted);
1231
+ opacity: 0.5;
1232
+ transition: color 0.12s, opacity 0.12s;
1233
+ }
1234
+ .es-placement-icon svg { width: 13px; height: 13px; display: block; }
1235
+ .es-placement-cell:hover .es-placement-icon { opacity: 0.85; }
1236
+ /* ⚠️ IT DOES NOT GROW THE WAY THE DOT DOES. The dot scales to 1.5 on the chosen
1237
+ cell because a dot has no other way to say "chosen"; an arrow says its whole
1238
+ meaning by its shape, and growing it would crowd the square and make it read
1239
+ heavier than its three siblings. Colour alone marks it, on the inverted
1240
+ ground the cell already paints. */
1241
+ .es-placement-cell.is-active .es-placement-icon {
1242
+ color: var(--es-on-accent);
1243
+ opacity: 1;
1244
+ }
1245
+ .es-placement-cell.is-off .es-placement-icon { opacity: 0.22; }
1246
+ /* Keyboard focus is visible, like every other control in this panel. */
1247
+ .es-placement-cell:focus-visible {
1248
+ outline: 2px solid var(--es-accent-ring);
1249
+ outline-offset: 1px;
1250
+ }
1251
+ @media (prefers-reduced-motion: reduce) {
1252
+ .es-placement-cell,
1253
+ .es-placement-icon,
1254
+ .es-placement-dot { transition: none; }
1255
+ }
1256
+
1257
+ /* ── SettingsCorners — four corners of a box, any number of them lit ─────────
1258
+ The sibling of `.es-placement` above and deliberately built from its parts:
1259
+ the same track, the same chip on the chosen cell, the same focus ring. A
1260
+ merchant who has met the nine-square should not have to learn this one.
1261
+
1262
+ TWO COLUMNS, NOT THREE. The grid is 2×2 and each cell sits in the corner it
1263
+ stands for, so the control has the shape of the thing it edits. */
1264
+ .es-corners {
1265
+ position: relative;
1266
+ display: grid;
1267
+ grid-template-columns: repeat(2, 1fr);
1268
+ grid-template-rows: repeat(2, 1fr);
1269
+ gap: 4px;
1270
+ width: 96px;
1271
+ height: 96px;
1272
+ padding: 4px;
1273
+ background: var(--es-track-bg);
1274
+ border-radius: 8px;
1275
+ }
1276
+ /* The BOX the four corners belong to. Inset so the cells sit ON its corners
1277
+ rather than beside it, dashed so it reads as the outline of the thing being
1278
+ edited rather than as a fifth control. See the note in SettingsCorners.tsx:
1279
+ without this the cells are four switches, not one box. */
1280
+ .es-corners-frame {
1281
+ position: absolute;
1282
+ inset: 22px;
1283
+ border: 1.5px dashed var(--es-muted);
1284
+ opacity: 0.45;
1285
+ pointer-events: none;
1286
+ }
1287
+ .es-corners-cell {
1288
+ position: relative;
1289
+ border: none;
1290
+ background: transparent;
1291
+ border-radius: 6px;
1292
+ cursor: pointer;
1293
+ padding: 0;
1294
+ display: flex;
1295
+ align-items: center;
1296
+ justify-content: center;
1297
+ }
1298
+ .es-corners-cell:hover { background: var(--es-hover); }
1299
+ /* THE MARK IS THE ANSWER, drawn rather than labelled. Each cell holds a square
1300
+ whose MATCHING corner is rounded when the corner is on and square when it is
1301
+ off, so the state is carried by SHAPE as well as by fill — which is what
1302
+ keeps it readable for someone who cannot separate the accent from the
1303
+ ground. The per-corner radius is set by the four rules below. */
1304
+ .es-corners-mark {
1305
+ width: 18px;
1306
+ height: 18px;
1307
+ background: var(--es-muted);
1308
+ opacity: 0.5;
1309
+ transition: border-radius 0.12s, background 0.12s, opacity 0.12s;
1310
+ }
1311
+ .es-corners-cell:hover .es-corners-mark { opacity: 0.85; }
1312
+ /* ⚠️ THE FOUR RULES ARE WRITTEN OUT, ONE PER CORNER, and must stay written out.
1313
+ The corner a cell stands for is in its class name (`es-corners-tl`), and a
1314
+ radius built by interpolating that name is a string no stylesheet contains —
1315
+ the same trap the shop documents on its own card wells. Four rules is the
1316
+ whole cost of never having that bug. */
1317
+ .es-corners-cell.is-on .es-corners-mark {
1318
+ background: var(--es-accent);
1319
+ opacity: 1;
1320
+ }
1321
+ .es-corners-tl.is-on .es-corners-mark { border-radius: 8px 0 0 0; }
1322
+ .es-corners-tr.is-on .es-corners-mark { border-radius: 0 8px 0 0; }
1323
+ .es-corners-br.is-on .es-corners-mark { border-radius: 0 0 8px 0; }
1324
+ .es-corners-bl.is-on .es-corners-mark { border-radius: 0 0 0 8px; }
1325
+ /* A corner the caller has closed. It keeps its cell — four that became three
1326
+ would read as a broken control — and stops answering the pointer; the reason
1327
+ is on the button's `title`. */
1328
+ .es-corners-cell.is-off { cursor: default; }
1329
+ .es-corners-cell.is-off:hover { background: transparent; }
1330
+ .es-corners-cell.is-off .es-corners-mark { opacity: 0.22; }
1331
+ /* Keyboard focus is visible, like every other control in this panel. */
1332
+ .es-corners-cell:focus-visible {
1333
+ outline: 2px solid var(--es-accent-ring);
1334
+ outline-offset: 1px;
1335
+ }
1336
+ @media (prefers-reduced-motion: reduce) {
1337
+ .es-corners-mark { transition: none; }
1338
+ }
1339
+
1340
+ /* ── THE ACTION BUTTON (`[ADD] A Panel Button That Does One Thing`) ─────────────────────────────────────────
1341
+ The full-width press under a group's rows that COMMITS — "Apply to all
1342
+ cards". Measured against `.es-drill`, the panel's other full-width row, and
1343
+ NOT against the shop's `.sf-add-card`: this is a settings row like the rest
1344
+ of the panel, so it takes the panel's row width and the panel's radius.
1345
+
1346
+ IT LOOKS LIKE A BUTTON AND NOT LIKE A ROW, deliberately. The drill is flat
1347
+ with a hover tint, because a list of doorways is a list. This one is a single
1348
+ press that writes, so it carries a border and a field ground: a merchant
1349
+ should be able to tell at a glance that the thing at the bottom of the group
1350
+ is not the twelfth setting.
1351
+
1352
+ NO TOP RULE. The air above it is the separator — the same discipline card
1353
+ 66350 applied to the drill list, and `panel-no-divider-lines.test.ts` is what
1354
+ keeps it. */
1355
+ .es-action-wrap {
1356
+ /* IT BELONGS TO THE ROWS ABOVE IT, so it sits closer than one setting sits to
1357
+ the next (38) and plainly apart from the last row. The floor rides
1358
+ underneath as `max()`, like every other row-to-row seam on this panel. */
1359
+ margin-top: max(var(--es-row-gap), var(--es-rhythm-between-tight));
1360
+ }
1361
+
1362
+ .es-action {
1363
+ box-sizing: border-box;
1364
+ width: 100%;
1365
+ display: flex;
1366
+ align-items: center;
1367
+ justify-content: center;
1368
+ gap: 8px;
1369
+ padding: 10px 11px;
1370
+ border: 1px solid var(--es-btn-border);
1371
+ border-radius: var(--es-row-radius);
1372
+ background: var(--es-field-bg);
1373
+ color: var(--es-btn-text);
1374
+ font: inherit;
1375
+ font-size: 12px;
1376
+ font-weight: 600;
1377
+ cursor: pointer;
1378
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
1379
+ }
1380
+
1381
+ .es-action:hover:not(:disabled) {
1382
+ background: var(--es-hover);
1383
+ }
1384
+
1385
+ /* An INSET ring, for the reason the drill gives: a full-width row has the
1386
+ panel's rows hard against its edges, and an outset ring would be clipped. */
1387
+ .es-action:focus-visible {
1388
+ outline: none;
1389
+ box-shadow: inset 0 0 0 2px var(--es-accent-ring);
1390
+ }
1391
+
1392
+ /* UNAVAILABLE, AND STILL THERE. Dimmed rather than hidden — a control that
1393
+ vanishes leaves a merchant hunting for something they remember, and takes the
1394
+ reason away with it. The reason is the note below, which a screen reader
1395
+ reaches through `aria-describedby` whether the button answers a pointer or
1396
+ not; a `title` here would reach neither. */
1397
+ .es-action:disabled {
1398
+ cursor: default;
1399
+ color: var(--es-disabled);
1400
+ background: var(--es-track-bg);
1401
+ border-color: var(--es-line);
1402
+ }
1403
+
1404
+ /* THE PRESS HAS LANDED.
1405
+
1406
+ THE WORD IS THE STATE, and the colour agrees with it — the button says
1407
+ "Applied to 12 cards" and the live region announces the same string, so
1408
+ nothing here is carried by colour alone.
1409
+
1410
+ ⚠️ NOT A TINTED GROUND. `--es-accent-tint` is card 8811's retired look and
1411
+ this sheet no longer draws it anywhere; `panel-layout.test.ts` sweeps the
1412
+ whole file for it. So the confirmation is drawn the way the rest of the panel
1413
+ draws a positive: the text and the edge move, the ground does not. */
1414
+ .es-action.is-done {
1415
+ color: var(--es-success);
1416
+ border-color: var(--es-success);
1417
+ }
1418
+
1419
+ .es-action-icon {
1420
+ flex: none;
1421
+ display: inline-flex;
1422
+ align-items: center;
1423
+ color: currentColor;
1424
+ }
1425
+
1426
+ .es-action-label {
1427
+ min-width: 0;
1428
+ }
1429
+
1430
+ /* Why the press is unavailable, or what it is about to rewrite. Quiet, under
1431
+ the button, and wrapped — this is a clause, not a name, the same split
1432
+ `.es-field-hint` makes. */
1433
+ .es-action-note {
1434
+ margin: 6px 0 0;
1435
+ font-size: 11px;
1436
+ line-height: 1.4;
1437
+ color: var(--es-muted);
1438
+ }
1439
+
1440
+ /* THE ANNOUNCEMENT, and nothing on screen. The button already shows the state;
1441
+ this exists so a merchant who cannot see the button HEARS that eleven cards
1442
+ they were not looking at have changed. Taken off screen the standard way —
1443
+ `display: none` and `visibility: hidden` are both skipped by screen readers,
1444
+ which would leave this rendering nothing at all. */
1445
+ .es-action-live {
1446
+ position: absolute;
1447
+ width: 1px;
1448
+ height: 1px;
1449
+ margin: -1px;
1450
+ padding: 0;
1451
+ overflow: hidden;
1452
+ clip: rect(0 0 0 0);
1453
+ clip-path: inset(50%);
1454
+ white-space: nowrap;
1455
+ border: 0;
1456
+ }
1457
+
1458
+ @media (prefers-reduced-motion: reduce) {
1459
+ .es-action { transition: none; }
1460
+ }