editor-shell 0.20.0 → 0.22.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.
@@ -62,6 +62,98 @@ interface SettingsTabsProps {
62
62
  ariaLabel?: string;
63
63
  className?: string;
64
64
  }
65
+ /**
66
+ * WHERE THIS STRIP'S WORDS STOP FITTING — the one thing the shell cannot know
67
+ * about a segmented control, measured by the consumer that owns the words.
68
+ *
69
+ * Both numbers are the width of TEXT ONLY, at the chip's own 12px/600, and
70
+ * neither includes any padding, glyph or gap: those are the chip's, they are
71
+ * named on the panel root, and `panel.css` adds them. That split is the point.
72
+ * A card that re-pads a chip — card 66138 already moved the track's side
73
+ * padding once — moves every threshold with it, instead of leaving each
74
+ * consumer holding a number that went stale silently.
75
+ *
76
+ * MEASURE THEM, do not count letters. The chip's font resolves to SF on a Mac
77
+ * and Segoe UI on Windows, so one table of letter widths would be wrong on most
78
+ * machines; measured against real labels a letter-count guess is out by up to
79
+ * 7%, which is enough to drop a word that fits. Render the strip and read the
80
+ * label spans — `tests/fixtures/segmented-fit.html` does exactly that and
81
+ * prints both numbers.
82
+ *
83
+ * AN OVER-ESTIMATE IS SAFE and an under-estimate is not: too large drops the
84
+ * words a little early, too small draws a segment past the panel edge. If a
85
+ * label is about to change and the number cannot be re-measured today, round it
86
+ * up.
87
+ */
88
+ interface SettingsSegmentedFit {
89
+ /** The sum of every option label's width, px. Decides where ALL the words go. */
90
+ labelsPx: number;
91
+ /**
92
+ * The WIDEST single option label, px. Decides where the chosen one goes too.
93
+ *
94
+ * The widest and not the current one: the merchant may be on any option, and
95
+ * a strip that fits while "Bold" is chosen and overflows on "Semibold" is a
96
+ * strip that overflows.
97
+ */
98
+ widestLabelPx: number;
99
+ }
100
+ /** One option in a segmented control. */
101
+ interface SettingsSegmentedOption {
102
+ /** The VALUE this option writes, as `value` and `onChange` name it. */
103
+ id: string;
104
+ /**
105
+ * What the option is CALLED — and its name at every width, exactly as a tab's
106
+ * label is. It rides on the button as `title` and `aria-label` in every
107
+ * state, so a strip reduced to glyphs still names all of its options.
108
+ */
109
+ label: string;
110
+ /**
111
+ * A 16px glyph from `editor-shell/icons`, so the option can spend less width
112
+ * than its word needs when the panel is dragged narrow.
113
+ *
114
+ * Optional, and the strip is unchanged without it: an option with no glyph
115
+ * has nothing to fall back to, so it keeps its word at every width. Pass
116
+ * glyphs for ALL the options or none of them — half words and half glyphs
117
+ * reads as a rendering fault, not as a decision.
118
+ */
119
+ icon?: ReactNode;
120
+ }
121
+ /**
122
+ * A SEGMENTED CONTROL — the tab strip's chip, with the other promise.
123
+ *
124
+ * `SettingsTabs` is a TABLIST: its chips switch a panel, which is what
125
+ * `role="tab"` promises. These chips switch nothing. They write a value and the
126
+ * panel stays where it is, so they are pressed buttons in a group, and the
127
+ * marker is `aria-pressed` — the same one `efficient-shop`'s own `Segmented`
128
+ * has carried since the 2026-08-06 audit.
129
+ *
130
+ * NOT `radiogroup` / `aria-checked`, and the reason is the keyboard rather than
131
+ * the taxonomy. `role="radio"` promises roving focus: one Tab stop for the set,
132
+ * arrows moving between the options and selecting as they go. This is a
133
+ * hook-free controlled leaf — it owns no focus and runs no key handler, and a
134
+ * host cannot add one to a `<button>` it did not render. Radio roles over
135
+ * plain tab-stopped buttons would promise a keyboard that is not there, which
136
+ * is the same defect as `role="tab"` over no panel.
137
+ */
138
+ interface SettingsSegmentedProps {
139
+ options: SettingsSegmentedOption[];
140
+ /** The `id` of the option currently set. */
141
+ value: string;
142
+ onChange: (next: string) => void;
143
+ /**
144
+ * What the group is called — "Weight", "Alignment". Required: a group of
145
+ * pressed buttons with no name is a set a screen reader cannot introduce.
146
+ */
147
+ ariaLabel: string;
148
+ /**
149
+ * Where this strip's words stop fitting. Optional, and WITHOUT IT NOTHING
150
+ * DROPS: an unmeasured strip keeps every word at every width, because the
151
+ * only alternative is guessing where to hide one. Give the options glyphs and
152
+ * this too, or the glyphs have nothing to fall back at.
153
+ */
154
+ fit?: SettingsSegmentedFit;
155
+ className?: string;
156
+ }
65
157
  interface SettingsGroupProps {
66
158
  /** The group's name — "Items", "Spacing", "Typeface". */
67
159
  title: string;
@@ -269,6 +361,54 @@ interface SettingsDrillProps {
269
361
  */
270
362
  declare function SettingsTabs({ items, activeId, onSelect, ariaLabel, className, }: SettingsTabsProps): react.JSX.Element;
271
363
 
364
+ /**
365
+ * A segmented control: one row of chips, one of them pressed.
366
+ *
367
+ * THE SAME CHIP AS `SettingsTabs`, AND A DIFFERENT PROMISE. Everything a
368
+ * merchant sees is shared — the grey track, the raised white chip for the one
369
+ * he is on, the glyph drawn at every width, the word that drops where it stops
370
+ * fitting. `panel.css` dresses both from one rule, so there is no second copy
371
+ * of the chip grammar to drift.
372
+ *
373
+ * What differs is what the control SAYS it does. `SettingsTabs` is a tablist
374
+ * and `role="tab"` promises that pressing a chip switches a panel. These chips
375
+ * switch nothing: they write a value and the panel stays where it is. So this
376
+ * is a group of pressed buttons, and its marker is `aria-pressed` — which is
377
+ * also what the storefront's own `Segmented` has carried since the 2026-08-06
378
+ * audit, so the two editors keep saying the same thing about the same control.
379
+ *
380
+ * That distinction is not decoration. Reach for `SettingsTabs` on a Weight row
381
+ * and a control that is correct today starts announcing "tab, 1 of 4, selected"
382
+ * over a panel that is not there — three times in one part panel. It is the
383
+ * same split the package already made between `SettingsGroup` and
384
+ * `SettingsGroupHead`: same pill, different promise, two components.
385
+ *
386
+ * Controlled: the host owns `value`. No hooks, no browser globals.
387
+ *
388
+ * ── WHERE THE WORD DROPS, AND WHY THE STRIP HAS TO SAY ───────────────────────
389
+ *
390
+ * The tab strip's tiers are keyed on how many tabs there are, which works
391
+ * because the shell knows those four words. It cannot work here. Measured
392
+ * (`tests/fixtures/segmented-fit.html`), Case — Normal / Uppercase / Lowercase
393
+ * — needs 296px for its words and Alignment — Left / Centre / Right — needs
394
+ * 221px. Both are three-option rows, and the panel drags between 181px and
395
+ * 281px, so a single three-option threshold is visibly wrong for one of them.
396
+ * Whether the words fit is a question about the SUM of the labels; the count is
397
+ * only a stand-in, and it stops standing in the moment a second strip arrives.
398
+ *
399
+ * So the strip carries its own measurement. `fit` is the width of its WORDS,
400
+ * nothing else — the chip's padding, glyph and gaps are the shell's, named on
401
+ * the panel root, and `panel.css` adds them. Those three custom properties
402
+ * below are that measurement and the count, handed to the stylesheet, which
403
+ * compares them against the panel's live width in `calc()`. A container query
404
+ * could not: its condition takes a literal length, so it cannot ask a question
405
+ * whose answer is different for every strip.
406
+ *
407
+ * Without `fit` nothing drops — an unmeasured strip keeps every word at every
408
+ * width, which is exactly what a strip with no glyphs already does.
409
+ */
410
+ declare function SettingsSegmented({ options, value, onChange, ariaLabel, fit, className, }: SettingsSegmentedProps): react.JSX.Element;
411
+
272
412
  /**
273
413
  * A group of settings under a soft pill row that folds with − / +.
274
414
  *
@@ -393,4 +533,4 @@ declare function SettingsSwitch({ checked, onChange, disabled, id, ariaLabel, ar
393
533
  */
394
534
  declare function SettingsDrill({ name, preview, offPage, status, onOpen, id, className, }: SettingsDrillProps): react.JSX.Element;
395
535
 
396
- export { SettingsDrill, type SettingsDrillProps, SettingsField, type SettingsFieldProps, SettingsGroup, SettingsGroupHead, type SettingsGroupHeadProps, type SettingsGroupProps, SettingsSwitch, type SettingsSwitchBaseProps, type SettingsSwitchLabel, type SettingsSwitchProps, type SettingsTabItem, SettingsTabs, type SettingsTabsProps };
536
+ export { SettingsDrill, type SettingsDrillProps, SettingsField, type SettingsFieldProps, SettingsGroup, SettingsGroupHead, type SettingsGroupHeadProps, type SettingsGroupProps, SettingsSegmented, type SettingsSegmentedFit, type SettingsSegmentedOption, type SettingsSegmentedProps, SettingsSwitch, type SettingsSwitchBaseProps, type SettingsSwitchLabel, type SettingsSwitchProps, type SettingsTabItem, SettingsTabs, type SettingsTabsProps };
@@ -47,6 +47,53 @@ function SettingsTabs({
47
47
  ) })
48
48
  );
49
49
  }
50
+ function SettingsSegmented({
51
+ options,
52
+ value,
53
+ onChange,
54
+ ariaLabel,
55
+ fit,
56
+ className
57
+ }) {
58
+ const measured = fit && {
59
+ ["--es-seg-count"]: options.length,
60
+ ["--es-seg-labels"]: `${fit.labelsPx}px`,
61
+ ["--es-seg-widest"]: `${fit.widestLabelPx}px`
62
+ };
63
+ return (
64
+ // The container context is the SHELL's, not a host's to remember — a panel
65
+ // that forgot to declare one would silently pin the strip to one state.
66
+ /* @__PURE__ */ jsx("div", { className: "es-segs-fit", children: /* @__PURE__ */ jsx(
67
+ "div",
68
+ {
69
+ role: "group",
70
+ "aria-label": ariaLabel,
71
+ className: `es-segs${measured ? " is-measured" : ""}${className ? ` ${className}` : ""}`,
72
+ style: measured,
73
+ children: options.map((option) => {
74
+ const pressed = option.id === value;
75
+ const cls = `es-seg${option.icon ? " has-icon" : ""}${pressed ? " is-active" : ""}`;
76
+ return /* @__PURE__ */ jsxs(
77
+ "button",
78
+ {
79
+ type: "button",
80
+ title: option.label,
81
+ "aria-label": option.label,
82
+ "aria-pressed": pressed,
83
+ className: cls,
84
+ onClick: () => onChange(option.id),
85
+ children: [
86
+ option.icon ? /* @__PURE__ */ jsx("span", { className: "es-seg-icon", "aria-hidden": "true", children: option.icon }) : null,
87
+ /* @__PURE__ */ jsx("span", { className: "es-seg-word", children: option.label })
88
+ ]
89
+ },
90
+ option.id
91
+ );
92
+ })
93
+ }
94
+ ) })
95
+ );
96
+ }
50
97
  function SettingsGroup({
51
98
  title,
52
99
  open,
@@ -185,6 +232,6 @@ function SettingsDrill({
185
232
  );
186
233
  }
187
234
 
188
- export { SettingsDrill, SettingsField, SettingsGroup, SettingsGroupHead, SettingsSwitch, SettingsTabs };
235
+ export { SettingsDrill, SettingsField, SettingsGroup, SettingsGroupHead, SettingsSegmented, SettingsSwitch, SettingsTabs };
189
236
  //# sourceMappingURL=index.js.map
190
237
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/panel/SettingsTabs.tsx","../../src/panel/SettingsGroup.tsx","../../src/panel/SettingsGroupHead.tsx","../../src/panel/SettingsSwitch.tsx","../../src/panel/SettingsField.tsx","../../src/panel/SettingsDrill.tsx"],"names":["jsxs","jsx"],"mappings":"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","file":"index.js","sourcesContent":["import type { SettingsTabsProps } from './types';\n\n/**\n * The tab strip under the panel head.\n *\n * It replaces the old stack of fold headers a merchant had to open and hunt\n * through: the buckets a section actually has become tabs, in one fixed order,\n * so the panel keeps the same shape on every section. A section shows only the\n * tabs it has — pass three items and three tabs render.\n *\n * Controlled: the host decides `activeId`. No hooks, no browser globals.\n *\n * A tab can be WIRED to what it shows (`panelId` / `htmlId` on the item). That\n * is not decoration: `role=\"tab\"` promises a panel, and a tab strip that names\n * none announces \"tab, 1 of 4, selected\" over nothing. The shell cannot supply\n * those ids — only the host knows the element the settings land in — but until\n * card 8802.d it could not ACCEPT them either, so no host could fix it. A tab\n * with nothing to point at renders no attribute at all; an empty\n * `aria-controls` is worse than a missing one, because it names an element\n * that does not exist instead of admitting there is none.\n *\n * A SEGMENTED CONTROL (card 66138). The strip is a grey track and the chosen\n * bucket is a raised white chip on it. Lewis, looking at a real panel: the tab\n * he is NOT on does not invite a click — it does not even look like a button.\n * The track is the answer to that, because it makes every segment look\n * pressable, where a mark under the chosen tab only ever made the chosen tab\n * louder. It replaces the underline strip and card 8811's tint ground; see the\n * long note on `.es-tab.is-active` in `panel.css`.\n *\n * A segment takes the width its OWN label needs and shares what is left over,\n * rather than every segment taking the width of the widest label. Equal\n * segments were measured and cost too much: three of them need a 267px track\n * and four need 355px, against the 281px the shop's default panel gives.\n *\n * THE WORD DROPS WHERE IT STOPS FITTING. Natural widths fit two and three\n * buckets across the whole panel-drag band; they do not fit four, where the\n * last segment ends 39px past the panel edge. So given an `icon`, a segment can\n * spend less width instead of more — the word goes and the glyph stays.\n *\n * Where that happens depends on HOW MANY buckets a section has, because with\n * natural widths the question is about the sum of the labels:\n *\n * two buckets both words, at every width the panel drags to.\n * three buckets words down to 240px, then glyph plus the chosen word.\n * four buckets glyph plus the chosen word under 320px; glyphs under 200px.\n *\n * Which state is a CSS decision, taken against the PANEL's width — see the\n * `@container` blocks in `panel.css`, and the wrapper below that gives them\n * something to measure. React cannot know the answer at render time, and it\n * does not need to: BOTH parts are always in the markup, and CSS shows the one\n * that fits. The word is only ever hidden, never dropped, and the label rides\n * along as `title` and `aria-label` on every tab in every state, so the glyph\n * never has to speak for the tab.\n *\n * A tab with no icon is untouched at every width — it has nothing to fall back\n * to, so it keeps its word.\n */\nexport function SettingsTabs({\n items,\n activeId,\n onSelect,\n ariaLabel = 'Settings',\n className,\n}: SettingsTabsProps) {\n return (\n // The container context is the SHELL's, not a host's to remember. A panel\n // that forgot to declare one would silently pin the strip to one tier.\n <div className=\"es-tabs-fit\">\n <div\n role=\"tablist\"\n aria-label={ariaLabel}\n aria-orientation=\"horizontal\"\n className={className ? `es-tabs ${className}` : 'es-tabs'}\n >\n {items.map((item) => {\n const active = item.id === activeId;\n const cls = `es-tab${item.icon ? ' has-icon' : ''}${active ? ' is-active' : ''}`;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"tab\"\n id={item.htmlId}\n title={item.label}\n aria-label={item.label}\n aria-selected={active}\n aria-controls={item.panelId}\n className={cls}\n onClick={() => onSelect(item.id)}\n >\n {item.icon ? (\n <span className=\"es-tab-icon\" aria-hidden=\"true\">\n {item.icon}\n </span>\n ) : null}\n <span className=\"es-tab-word\">{item.label}</span>\n {typeof item.badge === 'number' && item.badge > 0 ? (\n <span className=\"es-tab-badge\">{item.badge}</span>\n ) : null}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupProps } from './types';\n\n/**\n * A group of settings under a soft pill row that folds with − / +.\n *\n * The sign is drawn by `panel.css` from `aria-expanded`, so the open state has\n * exactly one home (the attribute assistive tech already reads) instead of a\n * second copy in the markup.\n *\n * `count` is the reason a fold here is safe: a folded group that holds changes\n * still says so on its pill, so nothing a merchant changed can hide.\n */\nexport function SettingsGroup({\n title,\n open,\n onToggle,\n count,\n id,\n children,\n className,\n}: SettingsGroupProps) {\n const bodyId = id ? `${id}-body` : undefined;\n return (\n <div className={className ? `es-group ${className}` : 'es-group'}>\n <button\n type=\"button\"\n id={id}\n aria-expanded={open}\n aria-controls={bodyId}\n className=\"es-group-head\"\n onClick={() => onToggle(!open)}\n >\n {title}\n {typeof count === 'number' && count > 0 ? (\n <span className=\"es-group-count\">{count}</span>\n ) : null}\n <span className=\"es-group-sign\" aria-hidden=\"true\" />\n </button>\n <div id={bodyId} className=\"es-group-body\" hidden={!open}>\n {children}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupHeadProps } from './types';\n\n/**\n * A group heading with NO group under it — the pill on its own.\n *\n * It exists because Puck lays the inspector out as a FLAT sibling list: a field\n * renders next to the heading above it, never inside it, so nothing here can\n * wrap the rows that follow. Heading a run of settings is therefore a job for a\n * component that heads and nothing else.\n *\n * `SettingsGroup` cannot do that job. It always renders its body div, and its\n * pill is always a <button> carrying `aria-expanded`, `aria-controls` and the\n * − / + sign. Used header-only it would draw a control that claims to expand\n * something, does nothing when clicked, and tells a screen reader the same lie.\n * A component that folds and a component that titles are two different things,\n * and this is the one that titles.\n *\n * So it is a HEADING, not a control: a real <h3> (the level is the host's, since\n * only the host knows the page outline around it) with no role bolted on, no\n * expanded state, no sign, no body. In a flat list the heading list is the only\n * structure a screen reader has left to navigate by, which is the whole reason\n * this renders a heading element rather than a styled <div>.\n *\n * It wears `.es-group-head` — the same pill as a real group's — so a section\n * that folds and a section that does not read as the same panel. `panel.css`\n * scopes the cursor and the hover tint to `button.es-group-head`, so the\n * heading does not offer a press it cannot honour.\n *\n * NO `count`. A folded group needs one because a fold can hide a change; a\n * heading folds nothing, so nothing can hide behind it.\n */\nexport function SettingsGroupHead({\n title,\n level = 3,\n id,\n className,\n}: SettingsGroupHeadProps) {\n const Tag = `h${level}` as const;\n return (\n <Tag id={id} className={className ? `es-group-head ${className}` : 'es-group-head'}>\n {title}\n </Tag>\n );\n}\n","import type { SettingsSwitchProps } from './types';\n\n/**\n * The yes-or-no control — ONE shape, both editors (card 8801).\n *\n * Point 5 of the approved settings design says one switch style everywhere.\n * Before this, the storefront editor drew a tick box and the campaign designer\n * drew a green switch, so a merchant met two shapes for the same question and\n * had to learn the control twice. Both now import this file, which is the only\n * thing that stops them drifting apart again.\n *\n * It is a real <button>, and that is the whole keyboard story: a button is\n * activated by Space and by Enter in every browser, and that activation IS a\n * click — so the one `onClick` below serves mouse and keyboard alike. A div\n * wearing `role=\"switch\"` would need a key handler of our own. Do not add one.\n * `type=\"button\"` keeps it from submitting a form it happens to sit in.\n *\n * It cannot exist without a NAME. `SettingsSwitchProps` demands `ariaLabel` or\n * `ariaLabelledBy` and forbids both at once, so an unnamed switch — which a\n * screen reader reads out as \"switch, on\" and nothing more — is a compile error\n * instead of something a docstring asks you to remember.\n *\n * ONE-WAY DEPENDENCY. `SettingsField` imports this module so it can recognise a\n * switch in its `action` slot and hand it the field's label as a name. This\n * module must NEVER import `SettingsField` back — that would close a cycle\n * between two siblings in the same folder. It imports `./types` and nothing\n * else, and `tests/settings-switch.test.tsx` (o) keeps it that way.\n *\n * CONTROLLED and hook-free like the other three: `checked` comes from the host\n * and the switch only ever ASKS to be flipped. That is what keeps this module a\n * directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsSwitch({\n checked,\n onChange,\n disabled,\n id,\n ariaLabel,\n ariaLabelledBy,\n className,\n}: SettingsSwitchProps) {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n id={id}\n aria-checked={checked}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n disabled={disabled}\n className={className ? `es-switch ${className}` : 'es-switch'}\n onClick={() => onChange(!checked)}\n />\n );\n}\n","import { cloneElement, isValidElement } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\n\nimport { SettingsSwitch } from './SettingsSwitch';\nimport type { SettingsFieldProps } from './types';\n\n/**\n * Does this slot hold a control?\n *\n * The rule is ELEMENT-OR-NOTHING, deliberately not a list of falsy values: a\n * list rots, and the next odd value nobody thought of walks straight through.\n * `{flag && <Slider/>}` yields `false`, `{count && <Slider/>}` yields `0` when\n * the count is zero — and React RENDERS that zero, a stray 0 sitting in the\n * panel where a control belongs. A bare string or number in a control slot is a\n * caller mistake in every case any of us can name.\n *\n * Arrays are walked rather than trusted: `Array.isArray([])` is true, so an\n * empty array — or one holding only `false` and `null`, which is what a list of\n * conditional controls collapses to — would otherwise draw the empty row this\n * check exists to prevent.\n */\nfunction hasControlIn(slot: ReactNode): boolean {\n if (Array.isArray(slot)) return slot.some(hasControlIn);\n return isValidElement(slot);\n}\n\n/** What a switch is named by. Read off an unknown element, so it stays loose. */\ninterface SwitchName {\n ariaLabel?: string;\n ariaLabelledBy?: string;\n}\n\n/**\n * Give a switch in the `action` slot the field's own label as its name.\n *\n * A switch carries no text, and `htmlFor` cannot bridge a <label> to a\n * <button>, so in a boolean row nothing connects the visible label to the\n * control unless something does it explicitly. Doing it HERE means the common\n * case is right with the caller saying nothing: the accessible name and the\n * visible name are the same string and cannot drift apart.\n *\n * It only ever fills a gap. A switch that names itself keeps its own name, and\n * anything that is not a switch — a reset dot, a badge — is left alone, since\n * labelling those with the field's name would be worse than not labelling them.\n */\nfunction nameSwitch(action: ReactNode, label: string, labelId?: string): ReactNode {\n if (!isValidElement(action) || action.type !== SettingsSwitch) return action;\n\n const named = action.props as SwitchName;\n if (named.ariaLabel || named.ariaLabelledBy) return action;\n\n return cloneElement(\n action as ReactElement<SwitchName>,\n labelId ? { ariaLabelledBy: labelId } : { ariaLabel: label },\n );\n}\n\n/**\n * One setting: a label row, then its control.\n *\n * The row is stacked rather than side-by-side so the control gets the panel's\n * full width — a 308px panel cannot afford a label column AND a usable slider.\n * The label reads small and quiet; the VALUE sits at the end of the same line in\n * the text colour, which is what a merchant scans for. That contrast is the\n * point: before this, label and value looked alike and the panel read as noise.\n *\n * `hint` is where a clause-long explanation goes. A label is a name, never a\n * sentence.\n *\n * A BOOLEAN row is the one exception to \"control on its own line\" (card 8801,\n * Option A, approved). A switch is small and it is the whole control, so it\n * rides the label line in the `action` slot and the row costs one line instead\n * of two — which is what lets a 308px panel show four settings where it used to\n * show three. That is the only reason `children` is optional: a field with no\n * children draws no control row, and its label line IS the row.\n *\n * A field with no children AND no action has no control on either slot. That is\n * a mistake at the call site, so it renders NOTHING — an empty row would hide\n * the mistake behind 20px of blank panel.\n */\nexport function SettingsField({\n label,\n labelId,\n icon,\n value,\n hint,\n changed,\n htmlFor,\n action,\n children,\n className,\n}: SettingsFieldProps) {\n const hasControl = hasControlIn(children);\n const hasAction = hasControlIn(action);\n if (!hasControl && !hasAction) return null;\n\n const classes = ['es-field'];\n if (!hasControl) classes.push('is-inline');\n if (changed) classes.push('is-changed');\n if (className) classes.push(className);\n\n const labelBody = (\n <>\n {icon ? (\n <span className=\"es-field-icon\" aria-hidden=\"true\">\n {icon}\n </span>\n ) : null}\n <span className=\"es-field-label\" id={labelId}>\n {label}\n </span>\n </>\n );\n\n return (\n <div className={classes.join(' ')}>\n <div className=\"es-field-head\">\n {htmlFor ? (\n <label className=\"es-field-name\" htmlFor={htmlFor}>\n {labelBody}\n </label>\n ) : (\n <span className=\"es-field-name\">{labelBody}</span>\n )}\n {value !== undefined && value !== null ? (\n <span className=\"es-field-value\">{value}</span>\n ) : null}\n {hasAction ? nameSwitch(action, label, labelId) : null}\n </div>\n {hasControl ? <div className=\"es-field-ctl\">{children}</div> : null}\n {hint ? <p className=\"es-field-hint\">{hint}</p> : null}\n </div>\n );\n}\n","import type { SettingsDrillProps } from './types';\n\n/**\n * A DRILL-IN ROW — a name, a one-line preview, and a chevron (card 8819).\n *\n * The storefront editor's text editing becomes a panel drill-down: the section\n * screen no longer holds a text box for every part; it LISTS the parts as these\n * rows, and pressing one takes the inspector down a level to that part's own\n * editor. A part the eye hid — or one that never draws on the page at all, like\n * a `Slide name` — has no words on the canvas to click, so this list is the only\n * way back to it. That is why the list exists, and why the row is the shell's,\n * not the shop's: the campaign designer draws the same list.\n *\n * IT IS ONE BUTTON, not a control beside some text. The name and the preview are\n * the button's own content, so they ARE its accessible name — a chevron floating\n * next to unlabelled text would be a press a screen reader could not describe.\n * The chevron is `aria-hidden` (pure direction); the status slot is NOT, because\n * its meaning is the host's to name — it passes an icon carrying its own label\n * (a `title`, a visually-hidden word), and hiding the slot would bury that.\n *\n * CONTROLLED and hook-free like the other four primitives: it owns no open state.\n * Pressing it calls `onOpen`, and the host decides what drilling down means —\n * which keeps this a directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsDrill({\n name,\n preview,\n offPage,\n status,\n onOpen,\n id,\n className,\n}: SettingsDrillProps) {\n const hasPreview = preview !== undefined && preview !== null;\n return (\n <button\n type=\"button\"\n id={id}\n className={className ? `es-drill ${className}` : 'es-drill'}\n onClick={onOpen}\n >\n <span className=\"es-drill-text\">\n <span className=\"es-drill-name\">{name}</span>\n {hasPreview ? (\n <span className={offPage ? 'es-drill-preview is-offpage' : 'es-drill-preview'}>\n {preview}\n </span>\n ) : null}\n </span>\n {status ? <span className=\"es-drill-status\">{status}</span> : null}\n <span className=\"es-drill-chev\" aria-hidden=\"true\" />\n </button>\n );\n}\n"]}
1
+ {"version":3,"sources":["../../src/panel/SettingsTabs.tsx","../../src/panel/SettingsSegmented.tsx","../../src/panel/SettingsGroup.tsx","../../src/panel/SettingsGroupHead.tsx","../../src/panel/SettingsSwitch.tsx","../../src/panel/SettingsField.tsx","../../src/panel/SettingsDrill.tsx"],"names":["jsx","jsxs"],"mappings":"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","file":"index.js","sourcesContent":["import type { SettingsTabsProps } from './types';\n\n/**\n * The tab strip under the panel head.\n *\n * It replaces the old stack of fold headers a merchant had to open and hunt\n * through: the buckets a section actually has become tabs, in one fixed order,\n * so the panel keeps the same shape on every section. A section shows only the\n * tabs it has — pass three items and three tabs render.\n *\n * Controlled: the host decides `activeId`. No hooks, no browser globals.\n *\n * A tab can be WIRED to what it shows (`panelId` / `htmlId` on the item). That\n * is not decoration: `role=\"tab\"` promises a panel, and a tab strip that names\n * none announces \"tab, 1 of 4, selected\" over nothing. The shell cannot supply\n * those ids — only the host knows the element the settings land in — but until\n * card 8802.d it could not ACCEPT them either, so no host could fix it. A tab\n * with nothing to point at renders no attribute at all; an empty\n * `aria-controls` is worse than a missing one, because it names an element\n * that does not exist instead of admitting there is none.\n *\n * A SEGMENTED CONTROL (card 66138). The strip is a grey track and the chosen\n * bucket is a raised white chip on it. Lewis, looking at a real panel: the tab\n * he is NOT on does not invite a click — it does not even look like a button.\n * The track is the answer to that, because it makes every segment look\n * pressable, where a mark under the chosen tab only ever made the chosen tab\n * louder. It replaces the underline strip and card 8811's tint ground; see the\n * long note on `.es-tab.is-active` in `panel.css`.\n *\n * A segment takes the width its OWN label needs and shares what is left over,\n * rather than every segment taking the width of the widest label. Equal\n * segments were measured and cost too much: three of them need a 267px track\n * and four need 355px, against the 281px the shop's default panel gives.\n *\n * THE WORD DROPS WHERE IT STOPS FITTING. Natural widths fit two and three\n * buckets across the whole panel-drag band; they do not fit four, where the\n * last segment ends 39px past the panel edge. So given an `icon`, a segment can\n * spend less width instead of more — the word goes and the glyph stays.\n *\n * Where that happens depends on HOW MANY buckets a section has, because with\n * natural widths the question is about the sum of the labels:\n *\n * two buckets both words, at every width the panel drags to.\n * three buckets words down to 240px, then glyph plus the chosen word.\n * four buckets glyph plus the chosen word under 320px; glyphs under 200px.\n *\n * Which state is a CSS decision, taken against the PANEL's width — see the\n * `@container` blocks in `panel.css`, and the wrapper below that gives them\n * something to measure. React cannot know the answer at render time, and it\n * does not need to: BOTH parts are always in the markup, and CSS shows the one\n * that fits. The word is only ever hidden, never dropped, and the label rides\n * along as `title` and `aria-label` on every tab in every state, so the glyph\n * never has to speak for the tab.\n *\n * A tab with no icon is untouched at every width — it has nothing to fall back\n * to, so it keeps its word.\n */\nexport function SettingsTabs({\n items,\n activeId,\n onSelect,\n ariaLabel = 'Settings',\n className,\n}: SettingsTabsProps) {\n return (\n // The container context is the SHELL's, not a host's to remember. A panel\n // that forgot to declare one would silently pin the strip to one tier.\n <div className=\"es-tabs-fit\">\n <div\n role=\"tablist\"\n aria-label={ariaLabel}\n aria-orientation=\"horizontal\"\n className={className ? `es-tabs ${className}` : 'es-tabs'}\n >\n {items.map((item) => {\n const active = item.id === activeId;\n const cls = `es-tab${item.icon ? ' has-icon' : ''}${active ? ' is-active' : ''}`;\n return (\n <button\n key={item.id}\n type=\"button\"\n role=\"tab\"\n id={item.htmlId}\n title={item.label}\n aria-label={item.label}\n aria-selected={active}\n aria-controls={item.panelId}\n className={cls}\n onClick={() => onSelect(item.id)}\n >\n {item.icon ? (\n <span className=\"es-tab-icon\" aria-hidden=\"true\">\n {item.icon}\n </span>\n ) : null}\n <span className=\"es-tab-word\">{item.label}</span>\n {typeof item.badge === 'number' && item.badge > 0 ? (\n <span className=\"es-tab-badge\">{item.badge}</span>\n ) : null}\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n","import type { CSSProperties } from 'react';\nimport type { SettingsSegmentedProps } from './types';\n\n/**\n * A segmented control: one row of chips, one of them pressed.\n *\n * THE SAME CHIP AS `SettingsTabs`, AND A DIFFERENT PROMISE. Everything a\n * merchant sees is shared — the grey track, the raised white chip for the one\n * he is on, the glyph drawn at every width, the word that drops where it stops\n * fitting. `panel.css` dresses both from one rule, so there is no second copy\n * of the chip grammar to drift.\n *\n * What differs is what the control SAYS it does. `SettingsTabs` is a tablist\n * and `role=\"tab\"` promises that pressing a chip switches a panel. These chips\n * switch nothing: they write a value and the panel stays where it is. So this\n * is a group of pressed buttons, and its marker is `aria-pressed` — which is\n * also what the storefront's own `Segmented` has carried since the 2026-08-06\n * audit, so the two editors keep saying the same thing about the same control.\n *\n * That distinction is not decoration. Reach for `SettingsTabs` on a Weight row\n * and a control that is correct today starts announcing \"tab, 1 of 4, selected\"\n * over a panel that is not there — three times in one part panel. It is the\n * same split the package already made between `SettingsGroup` and\n * `SettingsGroupHead`: same pill, different promise, two components.\n *\n * Controlled: the host owns `value`. No hooks, no browser globals.\n *\n * ── WHERE THE WORD DROPS, AND WHY THE STRIP HAS TO SAY ───────────────────────\n *\n * The tab strip's tiers are keyed on how many tabs there are, which works\n * because the shell knows those four words. It cannot work here. Measured\n * (`tests/fixtures/segmented-fit.html`), Case — Normal / Uppercase / Lowercase\n * — needs 296px for its words and Alignment — Left / Centre / Right — needs\n * 221px. Both are three-option rows, and the panel drags between 181px and\n * 281px, so a single three-option threshold is visibly wrong for one of them.\n * Whether the words fit is a question about the SUM of the labels; the count is\n * only a stand-in, and it stops standing in the moment a second strip arrives.\n *\n * So the strip carries its own measurement. `fit` is the width of its WORDS,\n * nothing else — the chip's padding, glyph and gaps are the shell's, named on\n * the panel root, and `panel.css` adds them. Those three custom properties\n * below are that measurement and the count, handed to the stylesheet, which\n * compares them against the panel's live width in `calc()`. A container query\n * could not: its condition takes a literal length, so it cannot ask a question\n * whose answer is different for every strip.\n *\n * Without `fit` nothing drops — an unmeasured strip keeps every word at every\n * width, which is exactly what a strip with no glyphs already does.\n */\nexport function SettingsSegmented({\n options,\n value,\n onChange,\n ariaLabel,\n fit,\n className,\n}: SettingsSegmentedProps) {\n // The measurement, handed to the stylesheet. `is-measured` is what the sheet\n // scopes the threshold arithmetic to, so a strip that declared nothing falls\n // back to no threshold at all rather than to a computed one over zeroes.\n const measured: CSSProperties | undefined = fit && {\n ['--es-seg-count' as string]: options.length,\n ['--es-seg-labels' as string]: `${fit.labelsPx}px`,\n ['--es-seg-widest' as string]: `${fit.widestLabelPx}px`,\n };\n\n return (\n // The container context is the SHELL's, not a host's to remember — a panel\n // that forgot to declare one would silently pin the strip to one state.\n <div className=\"es-segs-fit\">\n <div\n role=\"group\"\n aria-label={ariaLabel}\n className={`es-segs${measured ? ' is-measured' : ''}${className ? ` ${className}` : ''}`}\n style={measured}\n >\n {options.map((option) => {\n const pressed = option.id === value;\n const cls = `es-seg${option.icon ? ' has-icon' : ''}${pressed ? ' is-active' : ''}`;\n return (\n <button\n key={option.id}\n type=\"button\"\n title={option.label}\n aria-label={option.label}\n aria-pressed={pressed}\n className={cls}\n onClick={() => onChange(option.id)}\n >\n {option.icon ? (\n <span className=\"es-seg-icon\" aria-hidden=\"true\">\n {option.icon}\n </span>\n ) : null}\n <span className=\"es-seg-word\">{option.label}</span>\n </button>\n );\n })}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupProps } from './types';\n\n/**\n * A group of settings under a soft pill row that folds with − / +.\n *\n * The sign is drawn by `panel.css` from `aria-expanded`, so the open state has\n * exactly one home (the attribute assistive tech already reads) instead of a\n * second copy in the markup.\n *\n * `count` is the reason a fold here is safe: a folded group that holds changes\n * still says so on its pill, so nothing a merchant changed can hide.\n */\nexport function SettingsGroup({\n title,\n open,\n onToggle,\n count,\n id,\n children,\n className,\n}: SettingsGroupProps) {\n const bodyId = id ? `${id}-body` : undefined;\n return (\n <div className={className ? `es-group ${className}` : 'es-group'}>\n <button\n type=\"button\"\n id={id}\n aria-expanded={open}\n aria-controls={bodyId}\n className=\"es-group-head\"\n onClick={() => onToggle(!open)}\n >\n {title}\n {typeof count === 'number' && count > 0 ? (\n <span className=\"es-group-count\">{count}</span>\n ) : null}\n <span className=\"es-group-sign\" aria-hidden=\"true\" />\n </button>\n <div id={bodyId} className=\"es-group-body\" hidden={!open}>\n {children}\n </div>\n </div>\n );\n}\n","import type { SettingsGroupHeadProps } from './types';\n\n/**\n * A group heading with NO group under it — the pill on its own.\n *\n * It exists because Puck lays the inspector out as a FLAT sibling list: a field\n * renders next to the heading above it, never inside it, so nothing here can\n * wrap the rows that follow. Heading a run of settings is therefore a job for a\n * component that heads and nothing else.\n *\n * `SettingsGroup` cannot do that job. It always renders its body div, and its\n * pill is always a <button> carrying `aria-expanded`, `aria-controls` and the\n * − / + sign. Used header-only it would draw a control that claims to expand\n * something, does nothing when clicked, and tells a screen reader the same lie.\n * A component that folds and a component that titles are two different things,\n * and this is the one that titles.\n *\n * So it is a HEADING, not a control: a real <h3> (the level is the host's, since\n * only the host knows the page outline around it) with no role bolted on, no\n * expanded state, no sign, no body. In a flat list the heading list is the only\n * structure a screen reader has left to navigate by, which is the whole reason\n * this renders a heading element rather than a styled <div>.\n *\n * It wears `.es-group-head` — the same pill as a real group's — so a section\n * that folds and a section that does not read as the same panel. `panel.css`\n * scopes the cursor and the hover tint to `button.es-group-head`, so the\n * heading does not offer a press it cannot honour.\n *\n * NO `count`. A folded group needs one because a fold can hide a change; a\n * heading folds nothing, so nothing can hide behind it.\n */\nexport function SettingsGroupHead({\n title,\n level = 3,\n id,\n className,\n}: SettingsGroupHeadProps) {\n const Tag = `h${level}` as const;\n return (\n <Tag id={id} className={className ? `es-group-head ${className}` : 'es-group-head'}>\n {title}\n </Tag>\n );\n}\n","import type { SettingsSwitchProps } from './types';\n\n/**\n * The yes-or-no control — ONE shape, both editors (card 8801).\n *\n * Point 5 of the approved settings design says one switch style everywhere.\n * Before this, the storefront editor drew a tick box and the campaign designer\n * drew a green switch, so a merchant met two shapes for the same question and\n * had to learn the control twice. Both now import this file, which is the only\n * thing that stops them drifting apart again.\n *\n * It is a real <button>, and that is the whole keyboard story: a button is\n * activated by Space and by Enter in every browser, and that activation IS a\n * click — so the one `onClick` below serves mouse and keyboard alike. A div\n * wearing `role=\"switch\"` would need a key handler of our own. Do not add one.\n * `type=\"button\"` keeps it from submitting a form it happens to sit in.\n *\n * It cannot exist without a NAME. `SettingsSwitchProps` demands `ariaLabel` or\n * `ariaLabelledBy` and forbids both at once, so an unnamed switch — which a\n * screen reader reads out as \"switch, on\" and nothing more — is a compile error\n * instead of something a docstring asks you to remember.\n *\n * ONE-WAY DEPENDENCY. `SettingsField` imports this module so it can recognise a\n * switch in its `action` slot and hand it the field's label as a name. This\n * module must NEVER import `SettingsField` back — that would close a cycle\n * between two siblings in the same folder. It imports `./types` and nothing\n * else, and `tests/settings-switch.test.tsx` (o) keeps it that way.\n *\n * CONTROLLED and hook-free like the other three: `checked` comes from the host\n * and the switch only ever ASKS to be flipped. That is what keeps this module a\n * directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsSwitch({\n checked,\n onChange,\n disabled,\n id,\n ariaLabel,\n ariaLabelledBy,\n className,\n}: SettingsSwitchProps) {\n return (\n <button\n type=\"button\"\n role=\"switch\"\n id={id}\n aria-checked={checked}\n aria-label={ariaLabel}\n aria-labelledby={ariaLabelledBy}\n disabled={disabled}\n className={className ? `es-switch ${className}` : 'es-switch'}\n onClick={() => onChange(!checked)}\n />\n );\n}\n","import { cloneElement, isValidElement } from 'react';\nimport type { ReactElement, ReactNode } from 'react';\n\nimport { SettingsSwitch } from './SettingsSwitch';\nimport type { SettingsFieldProps } from './types';\n\n/**\n * Does this slot hold a control?\n *\n * The rule is ELEMENT-OR-NOTHING, deliberately not a list of falsy values: a\n * list rots, and the next odd value nobody thought of walks straight through.\n * `{flag && <Slider/>}` yields `false`, `{count && <Slider/>}` yields `0` when\n * the count is zero — and React RENDERS that zero, a stray 0 sitting in the\n * panel where a control belongs. A bare string or number in a control slot is a\n * caller mistake in every case any of us can name.\n *\n * Arrays are walked rather than trusted: `Array.isArray([])` is true, so an\n * empty array — or one holding only `false` and `null`, which is what a list of\n * conditional controls collapses to — would otherwise draw the empty row this\n * check exists to prevent.\n */\nfunction hasControlIn(slot: ReactNode): boolean {\n if (Array.isArray(slot)) return slot.some(hasControlIn);\n return isValidElement(slot);\n}\n\n/** What a switch is named by. Read off an unknown element, so it stays loose. */\ninterface SwitchName {\n ariaLabel?: string;\n ariaLabelledBy?: string;\n}\n\n/**\n * Give a switch in the `action` slot the field's own label as its name.\n *\n * A switch carries no text, and `htmlFor` cannot bridge a <label> to a\n * <button>, so in a boolean row nothing connects the visible label to the\n * control unless something does it explicitly. Doing it HERE means the common\n * case is right with the caller saying nothing: the accessible name and the\n * visible name are the same string and cannot drift apart.\n *\n * It only ever fills a gap. A switch that names itself keeps its own name, and\n * anything that is not a switch — a reset dot, a badge — is left alone, since\n * labelling those with the field's name would be worse than not labelling them.\n */\nfunction nameSwitch(action: ReactNode, label: string, labelId?: string): ReactNode {\n if (!isValidElement(action) || action.type !== SettingsSwitch) return action;\n\n const named = action.props as SwitchName;\n if (named.ariaLabel || named.ariaLabelledBy) return action;\n\n return cloneElement(\n action as ReactElement<SwitchName>,\n labelId ? { ariaLabelledBy: labelId } : { ariaLabel: label },\n );\n}\n\n/**\n * One setting: a label row, then its control.\n *\n * The row is stacked rather than side-by-side so the control gets the panel's\n * full width — a 308px panel cannot afford a label column AND a usable slider.\n * The label reads small and quiet; the VALUE sits at the end of the same line in\n * the text colour, which is what a merchant scans for. That contrast is the\n * point: before this, label and value looked alike and the panel read as noise.\n *\n * `hint` is where a clause-long explanation goes. A label is a name, never a\n * sentence.\n *\n * A BOOLEAN row is the one exception to \"control on its own line\" (card 8801,\n * Option A, approved). A switch is small and it is the whole control, so it\n * rides the label line in the `action` slot and the row costs one line instead\n * of two — which is what lets a 308px panel show four settings where it used to\n * show three. That is the only reason `children` is optional: a field with no\n * children draws no control row, and its label line IS the row.\n *\n * A field with no children AND no action has no control on either slot. That is\n * a mistake at the call site, so it renders NOTHING — an empty row would hide\n * the mistake behind 20px of blank panel.\n */\nexport function SettingsField({\n label,\n labelId,\n icon,\n value,\n hint,\n changed,\n htmlFor,\n action,\n children,\n className,\n}: SettingsFieldProps) {\n const hasControl = hasControlIn(children);\n const hasAction = hasControlIn(action);\n if (!hasControl && !hasAction) return null;\n\n const classes = ['es-field'];\n if (!hasControl) classes.push('is-inline');\n if (changed) classes.push('is-changed');\n if (className) classes.push(className);\n\n const labelBody = (\n <>\n {icon ? (\n <span className=\"es-field-icon\" aria-hidden=\"true\">\n {icon}\n </span>\n ) : null}\n <span className=\"es-field-label\" id={labelId}>\n {label}\n </span>\n </>\n );\n\n return (\n <div className={classes.join(' ')}>\n <div className=\"es-field-head\">\n {htmlFor ? (\n <label className=\"es-field-name\" htmlFor={htmlFor}>\n {labelBody}\n </label>\n ) : (\n <span className=\"es-field-name\">{labelBody}</span>\n )}\n {value !== undefined && value !== null ? (\n <span className=\"es-field-value\">{value}</span>\n ) : null}\n {hasAction ? nameSwitch(action, label, labelId) : null}\n </div>\n {hasControl ? <div className=\"es-field-ctl\">{children}</div> : null}\n {hint ? <p className=\"es-field-hint\">{hint}</p> : null}\n </div>\n );\n}\n","import type { SettingsDrillProps } from './types';\n\n/**\n * A DRILL-IN ROW — a name, a one-line preview, and a chevron (card 8819).\n *\n * The storefront editor's text editing becomes a panel drill-down: the section\n * screen no longer holds a text box for every part; it LISTS the parts as these\n * rows, and pressing one takes the inspector down a level to that part's own\n * editor. A part the eye hid — or one that never draws on the page at all, like\n * a `Slide name` — has no words on the canvas to click, so this list is the only\n * way back to it. That is why the list exists, and why the row is the shell's,\n * not the shop's: the campaign designer draws the same list.\n *\n * IT IS ONE BUTTON, not a control beside some text. The name and the preview are\n * the button's own content, so they ARE its accessible name — a chevron floating\n * next to unlabelled text would be a press a screen reader could not describe.\n * The chevron is `aria-hidden` (pure direction); the status slot is NOT, because\n * its meaning is the host's to name — it passes an icon carrying its own label\n * (a `title`, a visually-hidden word), and hiding the slot would bury that.\n *\n * CONTROLLED and hook-free like the other four primitives: it owns no open state.\n * Pressing it calls `onOpen`, and the host decides what drilling down means —\n * which keeps this a directive-free leaf a Next-16 server component can import.\n */\nexport function SettingsDrill({\n name,\n preview,\n offPage,\n status,\n onOpen,\n id,\n className,\n}: SettingsDrillProps) {\n const hasPreview = preview !== undefined && preview !== null;\n return (\n <button\n type=\"button\"\n id={id}\n className={className ? `es-drill ${className}` : 'es-drill'}\n onClick={onOpen}\n >\n <span className=\"es-drill-text\">\n <span className=\"es-drill-name\">{name}</span>\n {hasPreview ? (\n <span className={offPage ? 'es-drill-preview is-offpage' : 'es-drill-preview'}>\n {preview}\n </span>\n ) : null}\n </span>\n {status ? <span className=\"es-drill-status\">{status}</span> : null}\n <span className=\"es-drill-chev\" aria-hidden=\"true\" />\n </button>\n );\n}\n"]}
@@ -7,11 +7,13 @@
7
7
  * dark mode.
8
8
  *
9
9
  * Two details are deliberate and were decided by measurement, not taste:
10
- * · rows sit 28px apart while a label sits 7px from its own control, so a
11
- * setting reads as ONE thing with air around it. The 7px is the ONLY thing
12
- * saying which label owns which control widening the rows widened the
13
- * air BETWEEN settings and left that gap exactly where it was (card
14
- * 8802.d, approved from mockups/8802e-settings-spacing-mockup.html);
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;
15
17
  * · the label is small and grey, the value is larger and dark. Before that
16
18
  * contrast existed, a merchant could not tell a name from a number.
17
19
  */
@@ -63,23 +65,38 @@
63
65
  a heading, after the row above it 36px unchanged
64
66
 
65
67
  The three measured rhythms do not move because 16 is still under them, which
66
- is the floor doing exactly what it was built to do. */
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. */
67
81
  /* ── the rhythms: the numbers under the floor, given names (card 66228) ────── */
68
82
 
69
83
  /* THE FLOOR ABOVE IS NOT THE RHYTHM, and handing a consumer the floor was the
70
- whole problem. `--es-row-gap` answers 16 for all five gaps below alike; what
84
+ whole problem. `--es-row-gap` answers 20 for all five gaps below alike; what
71
85
  the storefront's Layers tree needs in order to "follow the shell's spacing"
72
- is the RHYTHM — 28 between two settings against 7 inside one. Under the floor
86
+ is the RHYTHM — 38 between two settings against 11 inside one. Under the floor
73
87
  those were bare literals, so the only way to follow them was to COPY them
74
88
  into `efficient-shop/components/storefront-editor.css`: a second source of
75
89
  truth for numbers this sheet was already the source of.
76
90
 
77
- THE VALUES DID NOT CHANGE. Every one is the number that was there before,
78
- moved and named; `tests/panel-layout.test.ts` (g) asserts each still resolves
79
- to what it resolved to, and (a) still reads the scale off the sheet.
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.
80
96
 
81
97
  NAMES, NOT ARITHMETIC, for the reason this sheet already gives one rule down:
82
- 28 was measured (card 8802.d), it is not "16 plus 12". `calc()` off the floor
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
83
100
  would claim a relationship nobody decided; a name states the value and the
84
101
  job it does.
85
102
 
@@ -87,28 +104,52 @@
87
104
  and `--es-rhythm-within` are the two halves of the one rule that has to
88
105
  survive being re-scaled: the gap INSIDE a thing stays plainly smaller than
89
106
  the gap BETWEEN things, because that inside gap is the ONLY thing saying
90
- which label owns which control. A tree at 28px a row would scroll forever, so
91
- a consumer is expected to scale these and (g) is what stops the two from
92
- drifting together while it does. */
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. */
93
111
  .es-editor {
94
112
  /* THE FLOOR (card 66206) — no row-to-row gap on this panel resolves below it. */
95
- --es-row-gap: 16px;
113
+ --es-row-gap: 20px;
96
114
 
97
115
  /* THE RHYTHMS. All five are declared in THIS rule and not a second
98
116
  `.es-editor` block: the panel has ONE root, and a second block would put
99
117
  half the panel's spacing somewhere a reader — and the spec, which reads the
100
118
  root by selector — would not look. */
101
119
  /* between one group heading and whatever is above it */
102
- --es-rhythm-group: 36px;
120
+ --es-rhythm-group: 48px;
103
121
  /* between a group heading and the first setting under it — closer, because
104
122
  that setting belongs to that heading */
105
- --es-rhythm-group-first: 14px;
123
+ --es-rhythm-group-first: 24px;
106
124
  /* BETWEEN two settings */
107
- --es-rhythm-between: 28px;
125
+ --es-rhythm-between: 38px;
108
126
  /* between two one-line rows, which are one line tall and need less air */
109
- --es-rhythm-between-tight: 16px;
127
+ --es-rhythm-between-tight: 26px;
110
128
  /* WITHIN one setting: its label to its own control */
111
- --es-rhythm-within: 7px;
129
+ --es-rhythm-within: 11px;
130
+
131
+ /* ── THE CHIP'S OWN METRICS (card 66248) ───────────────────────────────────
132
+
133
+ These five were literals inside the chip rules until a SECOND component
134
+ had to wear the same chip. They are named for one reason, and it is not
135
+ tidiness: the segmented control's fit thresholds are ARITHMETIC OVER THEM.
136
+
137
+ Its words drop where the words stop fitting, and where that is depends on
138
+ the room the chrome takes before any word is drawn — two chip paddings, a
139
+ glyph, the gap after it, the gaps between chips and the track's own
140
+ padding. Card 66138 has already moved one of these once (the track's side
141
+ padding, 14px → 3px). A threshold holding its own copy of that number would
142
+ have gone stale that day and said nothing. Read from here, it moves with
143
+ the chip.
144
+
145
+ They are DECLARED lengths and not measurements: the chip is drawn from
146
+ these, so they are the source, not a reading of one. The one thing that
147
+ cannot be named here is the width of a WORD — see `.es-segs` below. */
148
+ --es-chip-pad-x: 8px;
149
+ --es-chip-icon: 16px;
150
+ --es-chip-icon-gap: 6px;
151
+ --es-track-pad: 3px;
152
+ --es-track-gap: 3px;
112
153
  }
113
154
 
114
155
  /* ── the tab strip: a segmented control ────────────────────────────────────── */
@@ -126,7 +167,8 @@
126
167
  The context lives HERE rather than in a host's own panel rule so the shell is
127
168
  self-contained: a host that never declares `container-type` would silently
128
169
  pin the strip to one state with nothing to see and nothing to fail. */
129
- .es-tabs-fit {
170
+ .es-tabs-fit,
171
+ .es-segs-fit {
130
172
  container-type: inline-size;
131
173
  flex: none;
132
174
  }
@@ -154,11 +196,12 @@
154
196
  it is two controls. The guarantee that no bucket is ever out of reach is
155
197
  re-made out of different parts: segments that share the leftover space, and
156
198
  the drop rules below. */
157
- .es-tabs {
199
+ .es-tabs,
200
+ .es-segs {
158
201
  display: flex;
159
202
  flex-wrap: nowrap;
160
- gap: 3px;
161
- padding: 3px;
203
+ gap: var(--es-track-gap);
204
+ padding: var(--es-track-pad);
162
205
  background: var(--es-track-bg);
163
206
  border-radius: var(--es-row-radius);
164
207
  flex: none;
@@ -175,13 +218,14 @@
175
218
 
176
219
  `box-sizing` for the same reason the switch sets it: this file assumes no
177
220
  reset of the host's. */
178
- .es-tab {
221
+ .es-tab,
222
+ .es-seg {
179
223
  box-sizing: border-box;
180
224
  flex: 1 1 auto;
181
225
  border: 0;
182
226
  background: transparent;
183
227
  border-radius: 6px;
184
- padding: 6px 8px;
228
+ padding: 6px var(--es-chip-pad-x);
185
229
  font: inherit;
186
230
  font-size: 12px;
187
231
  font-weight: 600;
@@ -191,7 +235,7 @@
191
235
  display: inline-flex;
192
236
  align-items: center;
193
237
  justify-content: center;
194
- gap: 6px;
238
+ gap: var(--es-chip-icon-gap);
195
239
  transition: background 0.12s, color 0.12s, box-shadow 0.12s;
196
240
  }
197
241
 
@@ -199,11 +243,13 @@
199
243
  grey track sits at about 4.3:1 and misses AA; this is about 8.9:1. The track
200
244
  is what says "pressable" now, so the label no longer has to whisper to stay
201
245
  out of the chosen tab's way. */
202
- .es-tab:hover:not(.is-active) { color: var(--es-text); }
246
+ .es-tab:hover:not(.is-active),
247
+ .es-seg:hover:not(.is-active) { color: var(--es-text); }
203
248
 
204
249
  /* Keyboard focus, themed rather than left to the UA. Same ring as the switch,
205
250
  so the panel answers a keyboard the one way. */
206
- .es-tab:focus-visible {
251
+ .es-tab:focus-visible,
252
+ .es-seg:focus-visible {
207
253
  outline: none;
208
254
  box-shadow: 0 0 0 3px var(--es-accent-ring);
209
255
  }
@@ -226,7 +272,8 @@
226
272
  So the tint goes, the top-only corners go, and the 2px accent rule goes with
227
273
  them. The chip is told apart by ELEVATION now — a lit rule under a raised
228
274
  chip is a second answer to a question that already has one. */
229
- .es-tab.is-active {
275
+ .es-tab.is-active,
276
+ .es-seg.is-active {
230
277
  background: var(--es-raised);
231
278
  color: var(--es-text);
232
279
  box-shadow: var(--es-raised-shadow);
@@ -235,7 +282,8 @@
235
282
 
236
283
  /* Focused AND chosen: keep the lift, or the chip drops flat the moment a
237
284
  keyboard reaches it. */
238
- .es-tab.is-active:focus-visible {
285
+ .es-tab.is-active:focus-visible,
286
+ .es-seg.is-active:focus-visible {
239
287
  box-shadow: var(--es-raised-shadow), 0 0 0 3px var(--es-accent-ring);
240
288
  }
241
289
 
@@ -270,16 +318,18 @@
270
318
 
271
319
  `flex: none` because a glyph that shrinks is a squeezed segment in a smaller
272
320
  box. */
273
- .es-tab-icon {
321
+ .es-tab-icon,
322
+ .es-seg-icon {
274
323
  display: inline-flex;
275
- width: 16px;
324
+ width: var(--es-chip-icon);
276
325
  height: 18px;
277
326
  flex: none;
278
327
  align-items: center;
279
328
  justify-content: center;
280
329
  }
281
330
 
282
- .es-tab-icon svg { width: 16px; height: 16px; display: block; }
331
+ .es-tab-icon svg,
332
+ .es-seg-icon svg { width: var(--es-chip-icon); height: var(--es-chip-icon); display: block; }
283
333
 
284
334
  /* ── where the word stops fitting ──────────────────────────────────────────── */
285
335
 
@@ -293,10 +343,21 @@
293
343
  * Smallest container width with nothing drawn past the track edge:
294
344
  *
295
345
  * tabs words glyph + active word glyphs only
296
- * 2 168px 126px 120px
297
- * 3 239px 161px 120px
346
+ * 2 168px 126px 73px
347
+ * 3 239px 161px 108px
298
348
  * 4 320px 196px 143px
299
349
  *
350
+ * THE GLYPHS-ONLY COLUMN SAID 120px FOR BOTH 2 AND 3 TABS UNTIL CARD 66248,
351
+ * and it was wrong twice over. `tabs-segmented-fit.html` searched upward from
352
+ * 110px, so it could never have reported anything smaller than 110 whatever the
353
+ * truth was — and 120 is not even that, so a second hand had been on it. The
354
+ * truth is arithmetic and there is nothing to measure: a chip with no word is a
355
+ * glyph in its padding, so the strip is `n × 32px` plus `(n − 1)` gaps plus the
356
+ * track's own padding — 73, 108, 143. The 4-tab cell was right by luck of being
357
+ * above the floor. Re-measured with the search starting at 40px, all three
358
+ * agree with the arithmetic to the pixel; `(d)` now pins them to it, so the
359
+ * cell cannot drift from the sheet again.
360
+ *
300
361
  * The drag band gives containers from 181px (a 220px panel, the minimum) to
301
362
  * 281px (a 320px panel, the default — the panel minus 28px of body padding
302
363
  * minus the 11px reserved scrollbar gutter). Read against it:
@@ -340,6 +401,135 @@
340
401
  .es-tabs:has(.es-tab:nth-child(4)) .es-tab.has-icon.is-active .es-tab-word { display: none; }
341
402
  }
342
403
 
404
+ /* ── the segmented control: the same chip, a different promise ─────────────── */
405
+
406
+ /* WHY A SECOND COMPONENT AND NOT A SECOND CALLER OF `SettingsTabs` (card 66248).
407
+ `SettingsTabs` is a TABLIST, and `role="tab"` is the promise that pressing a
408
+ chip switches a panel. The rows this control is for switch nothing: Weight,
409
+ Case and Alignment WRITE A VALUE and stay where they are. Pointing the tab
410
+ strip at them would make three controls that are correct today start
411
+ announcing "tab, 1 of 4, selected" over nothing. Everything a merchant SEES
412
+ is shared above — one chip, one track, one raised-chip mark — and only the
413
+ promise differs. It is the split this sheet already made between
414
+ `.es-group-head` and `button.es-group-head`.
415
+
416
+ ── WHY THE @container TIERS ABOVE ARE NOT INHERITED ────────────────────────
417
+
418
+ Those three tiers are keyed on the tab COUNT, and that works for the strip
419
+ they were measured for because the shell KNOWS its four words: Content /
420
+ Media / Style / Layout. It cannot work here, and the reason is arithmetic,
421
+ not taste. MEASURED in Chromium, `tests/fixtures/segmented-fit.html`, the
422
+ width at which each row's words stop fitting:
423
+
424
+ row options words fit from
425
+ Case Normal / Uppercase / Lowercase 3 296px
426
+ Alignment Left / Centre / Right 3 221px
427
+
428
+ Two three-option rows, 75px apart. One threshold at three options is wrong
429
+ for one of them whichever number is picked — and the panel drags between
430
+ 181px and 281px, so the wrong one is wrong ON SCREEN: at the default 281px
431
+ panel Alignment's words fit and Case's do not.
432
+
433
+ Whether the words fit is a question about the SUM OF THE LABELS, which is
434
+ what this sheet already says out loud above, and a count is only ever a
435
+ stand-in for it. The stand-in holds while one strip's words are known and
436
+ breaks the moment a second strip brings its own.
437
+
438
+ ── SO THE THRESHOLD IS COMPUTED, PER STRIP ─────────────────────────────────
439
+
440
+ Everything in that sum except the words is the CHIP'S, and named on the
441
+ panel root above. Only the words belong to the consumer. So the strip
442
+ declares its own two measurements and this sheet adds the chrome:
443
+
444
+ words-from = the track + every chip with its glyph, its gap and its word
445
+ chosen-from = the track + every chip's glyph, plus one word — the widest,
446
+ because the merchant may be on any option and the strip may
447
+ not overflow on the one that costs most
448
+
449
+ WHY NOT `@container` HERE. A container query condition takes a literal
450
+ length; it cannot read a custom property, so it cannot ask a question whose
451
+ answer differs per strip. Container query UNITS can: `100cqi` is the panel's
452
+ own width, in a place `calc()` can use it. So the comparison is done in the
453
+ property instead of in the query — subtract the threshold from the width and
454
+ scale hard. Above the threshold that is a large positive length and the word
455
+ is unlimited; below it the result is negative, and `max-width` clamps a
456
+ negative to zero, which is the word gone. Same two tiers as the strip above,
457
+ measured continuously instead of at three fixed steps.
458
+
459
+ WHY NOT ESTIMATE THE WORDS FROM THEIR LETTERS, and skip the measuring. The
460
+ chip's font is `--es-font`, which resolves to SF on a Mac, Segoe UI on
461
+ Windows and something else again elsewhere — one shipped table of letter
462
+ widths would be wrong on two platforms out of three. Measured against these
463
+ labels the guess is out by up to 7% either way, which is 12px on a 170px sum:
464
+ enough to drop a word that fits, or keep one that does not.
465
+
466
+ WHY NOT MEASURE IT AT RUNTIME. That is a `ResizeObserver` and an effect, and
467
+ this module is a hook-free leaf a server component can import. The host could
468
+ do it, but then every consumer writes its own and they drift — which is the
469
+ thing this package exists to stop. */
470
+
471
+ /* NO MEASUREMENT, NO DROP. A strip that never declared where its words stop
472
+ fitting keeps every word at every width, exactly as a chip with no glyph
473
+ does: there is nothing to fall back TO and nowhere honest to fall back AT.
474
+ `is-measured` is the component's own answer to whether it was given a `fit`,
475
+ so the fallback below is reached by a strip that has one and never by a strip
476
+ that does not. */
477
+ .es-segs.is-measured {
478
+ /* one chip's chrome, with and without room for a word beside the glyph */
479
+ --es-seg-chip: calc(2 * var(--es-chip-pad-x) + var(--es-chip-icon));
480
+ /* the track's own padding, and the gaps between the chips on it */
481
+ --es-seg-track: calc(2 * var(--es-track-pad) + (var(--es-seg-count) - 1) * var(--es-track-gap));
482
+
483
+ --es-seg-words-from: calc(
484
+ var(--es-seg-track) +
485
+ var(--es-seg-count) * (var(--es-seg-chip) + var(--es-chip-icon-gap)) +
486
+ var(--es-seg-labels)
487
+ );
488
+ --es-seg-chosen-from: calc(
489
+ var(--es-seg-track) +
490
+ var(--es-seg-count) * var(--es-seg-chip) +
491
+ var(--es-chip-icon-gap) +
492
+ var(--es-seg-widest)
493
+ );
494
+ }
495
+
496
+ /* THE ROOM LEFT OVER, once this strip's own words are paid for. Positive and
497
+ large while they fit, negative below — and the two properties that read it
498
+ both clamp a negative to zero, which is what makes it a switch rather than a
499
+ width. The 10000 is what makes the change a step and not a fade: without it
500
+ the last pixel before the threshold would draw a one-pixel word. */
501
+ .es-segs {
502
+ --es-seg-room-all: calc((100cqi - var(--es-seg-words-from, 0px)) * 10000);
503
+ --es-seg-room-chosen: calc((100cqi - var(--es-seg-chosen-from, 0px)) * 10000);
504
+ }
505
+
506
+ /* The word is CLIPPED to nothing rather than `display: none`, because the
507
+ switch has to be a length for `max-width` to clamp it. Nothing is lost by
508
+ it: the label is on the button as `title` and `aria-label` in every state, so
509
+ the name a screen reader reads never depended on the span being drawn. */
510
+ .es-seg-word {
511
+ overflow: hidden;
512
+ max-width: var(--es-seg-room-all);
513
+ }
514
+
515
+ /* THE CHOSEN SEGMENT KEEPS ITS WORD LONGER — the second tier, and the same one
516
+ the tab strip drops to. A track of bare glyphs still reads, because the track
517
+ and the chips are what say "these are buttons"; a track that also still says
518
+ which value is set reads better, and it costs one word. */
519
+ .es-seg.is-active .es-seg-word { max-width: var(--es-seg-room-chosen); }
520
+
521
+ /* THE GAP GOES WITH THE WORD IT SEPARATES. The chip's `gap` is a flex gap, and
522
+ a flex gap does not care that the word is now zero wide — it would leave 6px
523
+ of nothing between the glyph and the panel edge and put every glyphs-only
524
+ strip 6px per chip over its real width. So the segment spends the gap as the
525
+ word's OWN margin and switches it with the same room. The tab strip is
526
+ untouched and keeps the flex gap, which is right there: its word is
527
+ `display: none`, so the gap goes with the element, and a tab also has a badge
528
+ on the other side of that word for the gap to separate. */
529
+ .es-seg { gap: 0; }
530
+ .es-seg.has-icon .es-seg-word { margin-left: clamp(0px, var(--es-seg-room-all), var(--es-chip-icon-gap)); }
531
+ .es-seg.has-icon.is-active .es-seg-word { margin-left: clamp(0px, var(--es-seg-room-chosen), var(--es-chip-icon-gap)); }
532
+
343
533
  /* ── a group: the soft pill row that folds ─────────────────────────────────── */
344
534
 
345
535
  /* WAS 6px, AND THE 6 NEVER APPLIED (card 66203). `.es-group` is a plain div, so
@@ -379,7 +569,7 @@
379
569
  border-radius: var(--es-row-radius);
380
570
  padding: 8px 11px;
381
571
  /* A heading needs more air above it than the settings it heads have between
382
- them (28px), or it reads as one more row. Floored on `--es-row-gap` so it
572
+ them (38px), or it reads as one more row. Floored on `--es-row-gap` so it
383
573
  rises with the rest of the panel (card 66203). */
384
574
  margin: max(var(--es-row-gap), var(--es-rhythm-group)) 0 0;
385
575
  }
@@ -423,21 +613,31 @@ button.es-group-head:hover { background: var(--es-chip-bg); }
423
613
  overriding the attribute's own display:none. */
424
614
  .es-group-body[hidden] { display: none; }
425
615
  /* The first setting under a head sits CLOSER than the settings below it do to
426
- each other — it belongs to that head, and 28px would cut it loose. */
616
+ each other — it belongs to that head, and 38px would cut it loose.
617
+
618
+ AND IT IS LIVE FOR THE FIRST TIME (card 66301). It declared 14 against a 16px
619
+ floor, so it has never once been 14 on screen — the floor answered every
620
+ time, and card 66206 measured that very move (14 → 16) and wrote it down as
621
+ the floor working, which it was. At 24 over a 20px floor the declared number
622
+ is finally the visible one, so this seam grows 16 → 24 rather than the 14 →
623
+ 24 the file appears to say. `tests/panel-layout.test.ts` (h) is what turns a
624
+ floored rhythm into a failure instead of a footnote. */
427
625
  .es-group-body > .es-field:first-child { margin-top: max(var(--es-row-gap), var(--es-rhythm-group-first)); }
428
626
 
429
627
  /* ── one setting ───────────────────────────────────────────────────────────── */
430
628
 
431
- /* 28 IS KEPT, AND IT IS NOT A THIRD OPINION ABOUT THE FLOOR. A setting is TWO
432
- lines — a label row and its control — held together by the 7px between them,
433
- and that 7px is the only thing saying which label owns which control. The gap
434
- between settings has to be plainly larger than the gap inside one or the
435
- panel stops being a list of settings and becomes a list of loose parts. It
436
- was measured at 28 and approved from mockups (card 8802.d); 20 was tried and
437
- was too tight.
438
-
439
- Floored, not replaced, and not `calc()`: 28 was measured, it is not "10 plus
440
- 18", so arithmetic off the token would claim a relationship nobody decided. */
629
+ /* 38 IS NOT A THIRD OPINION ABOUT THE FLOOR. A setting is TWO lines — a label
630
+ row and its control — held together by the 11px between them, and that 11px
631
+ is the only thing saying which label owns which control. The gap between
632
+ settings has to be plainly larger than the gap inside one or the panel stops
633
+ being a list of settings and becomes a list of loose parts. The pair was
634
+ measured at 28 and 7 and approved from mockups (card 8802.d) 20 was tried
635
+ there and was too tight — and re-scaled together to 38 and 11 (card 66301),
636
+ which is the same rule with more air, not a new one.
637
+
638
+ Floored, not replaced, and not `calc()`: 38 was measured and then re-scaled,
639
+ it is not "20 plus 18", so arithmetic off the token would claim a
640
+ relationship nobody decided. */
441
641
  .es-field { margin-top: max(var(--es-row-gap), var(--es-rhythm-between)); }
442
642
 
443
643
  .es-field-head {
@@ -505,8 +705,13 @@ button.es-group-head:hover { background: var(--es-chip-bg); }
505
705
  control row, the label line's 7px bottom margin would be a gap to nothing.
506
706
 
507
707
  A one-line row is one line tall, so it needs less air above it than a
508
- two-line setting does — 16, not 28 — and it is floored on the token for the
509
- same reason everything else here is (card 66203). */
708
+ two-line setting does — 26, not 38 — and it is floored on the token for the
709
+ same reason everything else here is (card 66203).
710
+
711
+ IT TIED WITH THE FLOOR UNTIL NOW (card 66301). At 16 against a 16px floor
712
+ nothing was wrong on screen, but the sheet could not tell you which of the
713
+ two numbers you were reading, and moving either one alone would have moved
714
+ nothing. 26 over 20 puts it plainly on its own number. */
510
715
  .es-field.is-inline { margin-top: max(var(--es-row-gap), var(--es-rhythm-between-tight)); }
511
716
  .es-field.is-inline .es-field-head { margin-bottom: 0; }
512
717
 
@@ -608,11 +813,25 @@ button.es-group-head:hover { background: var(--es-chip-bg); }
608
813
  hairline between them and adds no margin — so the air a merchant sees is
609
814
  each row's own vertical padding, on its own side of the line. MEASURED in
610
815
  Chromium against these stylesheets (`tests/fixtures/panel-row-spacing.html`):
611
- 17.40px above the rule, 18.00px below it, 35.40px between two rows' words.
612
- At the 10px the token shipped with (card 66203) those were 11.40, 12.00 and
613
- 23.40 — this list is the ONLY place on the panel where raising the token to
614
- 16 moves a measured distance rather than a declared one, and it is the row
615
- Lewis was looking at when he asked (card 66206).
816
+
817
+ token above the rule below it word to word recorded by
818
+ 10px 11.40px 12.00px 23.40px card 66203
819
+ 16px 17.40px 18.00px 35.40px card 66206
820
+ 16px 17.39px 17.00px 34.39px card 66301
821
+ 20px 21.39px 21.00px 42.39px card 66301
822
+
823
+ TWO ROWS FOR 16px, AND THEY DISAGREE BY A PIXEL. 66301's pair was taken in
824
+ one run of Chromium 124 (zenika/alpine-chrome) so its two rows compare; the
825
+ earlier rows are left as the cards recorded them rather than overwritten,
826
+ because the difference is the engine and the font it had, not this sheet.
827
+ Read a row against another row from the same card, never across cards.
828
+
829
+ THIS SEAM MOVES AT TWICE THE TOKEN'S RATE, and it is the only one that
830
+ does: both rows spend the gap, so +4 on the dial buys +8 of air. Under the
831
+ floor alone it was also the ONLY place raising the token moved a measured
832
+ distance rather than a declared one — every other seam was held by a bigger
833
+ rhythm — which is why it is the row Lewis was looking at when he asked for
834
+ card 66206. Card 66301 moves the rhythms too, so it is no longer alone.
616
835
 
617
836
  A MARGIN WOULD BE THE WRONG FIX. A border-top with space above it detaches
618
837
  from the row it belongs to and reads as a line floating in the panel rather
@@ -698,6 +917,7 @@ button.es-group-head:hover { background: var(--es-chip-bg); }
698
917
 
699
918
  @media (prefers-reduced-motion: reduce) {
700
919
  .es-tab,
920
+ .es-seg,
701
921
  /* `button.` and not `.es-group-head`: the transition it cancels is declared
702
922
  on `button.es-group-head`, and a plainer selector would lose to it. */
703
923
  button.es-group-head,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editor-shell",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Shared editor-chrome primitives for the EFFICIENT editors: EditorRail (a Next-16-safe left icon-rail leaf), the shell token layer, and GoldTextInput — type in place and watch the markup formatting appear as you type.",
5
5
  "license": "MIT",
6
6
  "author": "Lewis Liu",