editor-shell 0.8.2 → 0.10.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.
@@ -23,6 +23,7 @@
23
23
  --es-gold-caret: var(--es-text, #111827);
24
24
  --es-gold-placeholder: var(--es-field-placeholder, #9ca3af);
25
25
  --es-gold-delim-opacity: 0.35;
26
+ --es-gold-delim-resting-opacity: 0.25;
26
27
  --es-gold-bold-shadow: 0 0 0.4px currentColor;
27
28
  --es-gold-italic-decoration: underline dotted;
28
29
  --es-gold-strike-decoration: line-through;
@@ -105,10 +106,16 @@
105
106
  /* — the marks — */
106
107
 
107
108
  /*
108
- * Quiet unless you are IN the box (card 8802.h). A merchant who is not typing
109
- * should not read `**` and `==` sitting inside their own page text — it looks
110
- * like a bug. While the caret is in here they are what is being aimed at, so
111
- * `:focus-within` brings them back at the token dim.
109
+ * A whisper at rest, louder while you are IN the box (cards 8802.h, 8807).
110
+ *
111
+ * A merchant who is not typing should not READ `**` and `==` sitting inside
112
+ * their own page text it looks like a bug. 8802.h took them to 0 for that,
113
+ * which worked, and left a second problem: the characters keep their full width
114
+ * either way (see below), so at 0 the space stayed and the explanation went. A
115
+ * short, heavily marked string spends about a quarter of its line on blanks
116
+ * nothing accounts for. At 0 that gap reads as a rendering bug; at a whisper it
117
+ * reads as punctuation. While the caret is in here the marks are what is being
118
+ * aimed at, so `:focus-within` brings them up to the louder dim.
112
119
  *
113
120
  * FADED, never removed, and done here rather than in React for the same reason.
114
121
  * The characters stay in the layer at their full width: dropping a `**` would
@@ -118,7 +125,7 @@
118
125
  * state, so focusing the box does not re-render it for a change that is paint.
119
126
  */
120
127
  .es-gold .es-gold-delim {
121
- opacity: 0;
128
+ opacity: var(--es-gold-delim-resting-opacity);
122
129
  }
123
130
 
124
131
  .es-gold:focus-within .es-gold-delim {
@@ -179,7 +179,15 @@ declare function goldSegments(value: string, rules: readonly InlineRule[]): Gold
179
179
  * - the caret and the placeholder defer to the shell layer's `--es-text` /
180
180
  * `--es-field-placeholder` (see `shell/tokens.ts`) with the same values as
181
181
  * their fallbacks;
182
- * - the dim is the rail's existing disabled opacity (0.35), not a new number.
182
+ * - the FOCUSED dim is the rail's existing disabled opacity (0.35), not a new
183
+ * number. Its RESTING sibling (0.25) is the one value in this file picked by
184
+ * eye rather than borrowed: nothing else in the product paints a mark that
185
+ * has to hold its width while it stops being read, so there was nothing to
186
+ * copy. The owner chose it against a picker rendering the candidates at
187
+ * heading and body size; 0.18 was tried there first and still read too
188
+ * faint (card 8807). That history is here on purpose: a later reader who
189
+ * finds 0.25 with nothing behind it will "fix" it to match some other
190
+ * number, and this is the paragraph that stops them.
183
191
  *
184
192
  * Every value is a STRING, unlike the rail's and shell's pixel magnitudes:
185
193
  * none of these is a length, so nothing here gains a unit on the way into CSS.
@@ -197,11 +205,20 @@ declare const goldTokens: {
197
205
  readonly caret: "var(--es-text, #111827)";
198
206
  /** The hint shown while the value is empty. */
199
207
  readonly placeholder: "var(--es-field-placeholder, #9ca3af)";
200
- /** How far the asterisks are dimmed WHILE THE BOX HAS FOCUS. Out of focus they
201
- * are faded out altogether (`gold.css`, `:focus-within`, card 8802.h) but
202
- * FADED, never removed: the characters stay in the layer at their full width,
203
- * which is the deal. Anything that took them out would move a glyph. */
208
+ /** How far the asterisks are dimmed WHILE THE BOX HAS FOCUS the moment they
209
+ * are what the merchant is aiming at, so this is the louder of the pair.
210
+ * Out of focus they drop to {@link goldTokens.delimiterRestingOpacity}. */
204
211
  readonly delimiterOpacity: "0.35";
212
+ /** How far they are dimmed AT REST, when nobody is typing (card 8807). A
213
+ * whisper, and deliberately not 0.
214
+ *
215
+ * FADED, never removed: the characters stay in the layer at their full width
216
+ * in BOTH states, which is the deal — anything that took them out would move
217
+ * a glyph. So the width is spent either way, and 0 spends it with nothing to
218
+ * show for it: a short, heavily marked string loses about a quarter of its
219
+ * line to blanks that read as a rendering bug. Faint, the same space reads as
220
+ * punctuation. */
221
+ readonly delimiterRestingOpacity: "0.25";
205
222
  /**
206
223
  * Faux-bold. A real `font-weight: 600` is wider, and a wider run down here
207
224
  * wraps a line earlier than the transparent box on top of it — after which
@@ -169,11 +169,20 @@ var goldTokens = {
169
169
  caret: "var(--es-text, #111827)",
170
170
  /** The hint shown while the value is empty. */
171
171
  placeholder: "var(--es-field-placeholder, #9ca3af)",
172
- /** How far the asterisks are dimmed WHILE THE BOX HAS FOCUS. Out of focus they
173
- * are faded out altogether (`gold.css`, `:focus-within`, card 8802.h) but
174
- * FADED, never removed: the characters stay in the layer at their full width,
175
- * which is the deal. Anything that took them out would move a glyph. */
172
+ /** How far the asterisks are dimmed WHILE THE BOX HAS FOCUS the moment they
173
+ * are what the merchant is aiming at, so this is the louder of the pair.
174
+ * Out of focus they drop to {@link goldTokens.delimiterRestingOpacity}. */
176
175
  delimiterOpacity: "0.35",
176
+ /** How far they are dimmed AT REST, when nobody is typing (card 8807). A
177
+ * whisper, and deliberately not 0.
178
+ *
179
+ * FADED, never removed: the characters stay in the layer at their full width
180
+ * in BOTH states, which is the deal — anything that took them out would move
181
+ * a glyph. So the width is spent either way, and 0 spends it with nothing to
182
+ * show for it: a short, heavily marked string loses about a quarter of its
183
+ * line to blanks that read as a rendering bug. Faint, the same space reads as
184
+ * punctuation. */
185
+ delimiterRestingOpacity: "0.25",
177
186
  /**
178
187
  * Faux-bold. A real `font-weight: 600` is wider, and a wider run down here
179
188
  * wraps a line earlier than the transparent box on top of it — after which
@@ -192,6 +201,7 @@ var goldCssVars = {
192
201
  caret: "--es-gold-caret",
193
202
  placeholder: "--es-gold-placeholder",
194
203
  delimiterOpacity: "--es-gold-delim-opacity",
204
+ delimiterRestingOpacity: "--es-gold-delim-resting-opacity",
195
205
  boldShadow: "--es-gold-bold-shadow",
196
206
  italicDecoration: "--es-gold-italic-decoration",
197
207
  strikeDecoration: "--es-gold-strike-decoration"
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/gold/segments.ts","../../src/gold/GoldLayer.tsx","../../src/gold/GoldTextInput.tsx","../../src/gold/tokens.ts"],"names":["STANDARD_MARKUP","jsx"],"mappings":"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","file":"index.js","sourcesContent":["import { tokenizeInline } from 'react-os-shell/markup';\nimport type { InlineKind, InlineRule } from 'react-os-shell/markup';\n\n/**\n * Cutting an authored string into the pieces the layer paints — WITHOUT losing a\n * character.\n *\n * The grammar is not re-implemented here and never will be (constitution E8):\n * {@link tokenizeInline} from `react-os-shell/markup` decides what is a run and\n * what is not, exactly as it does for the published page. What it does not\n * return is the delimiters — it hands back a run's INNER text, because every\n * other consumer wants the words without the asterisks.\n *\n * This layer wants the asterisks. It draws underneath a transparent box whose\n * caret walks the stored string, so the two surfaces must hold the same\n * characters in the same order: drop the `**` and every glyph after it sits two\n * columns left of the caret that is supposed to be inside it.\n *\n * So each parsed run is located back in the source and cut into three pieces —\n * opening delimiter, inner text, closing delimiter — and EVERY piece is sliced\n * out of the original string rather than rebuilt from the token. Nothing is\n * retyped, so nothing can be retyped wrong.\n */\n\n/** A piece of the source: a parsed run's kind, or the delimiters around one. */\nexport type GoldSegmentKind = InlineKind | 'delimiter';\n\nexport interface GoldSegment {\n kind: GoldSegmentKind;\n /** Verbatim source text. Concatenating every segment reproduces the input. */\n text: string;\n}\n\n/**\n * Split `value` into paint-able segments under `rules`.\n *\n * GUARANTEE: `segments.map(s => s.text).join('') === value`, always. If the walk\n * below cannot line a run up with the rule that produced it — which would mean\n * the grammar and this function disagree — the whole string comes back as one\n * plain `text` segment. Losing the gold is a disappointment; losing a character\n * puts the caret in the wrong place, so the fallback is never in doubt.\n */\nexport function goldSegments(value: string, rules: readonly InlineRule[]): GoldSegment[] {\n const out: GoldSegment[] = [];\n let at = 0;\n\n for (const token of tokenizeInline(value, rules)) {\n if (token.kind === 'text') {\n // Empty text tokens are part of the tokenizer's alternating contract; they\n // paint nothing, so they are dropped rather than drawn as empty spans.\n if (token.text.length > 0) {\n out.push({ kind: 'text', text: value.slice(at, at + token.text.length) });\n at += token.text.length;\n }\n continue;\n }\n\n const rule = ruleAt(value, at, token.kind, token.text, rules);\n if (!rule) return [{ kind: 'text', text: value }];\n\n const innerAt = at + rule.open.length;\n const closeAt = innerAt + token.text.length;\n const end = closeAt + rule.close.length;\n\n out.push({ kind: 'delimiter', text: value.slice(at, innerAt) });\n out.push({ kind: token.kind, text: value.slice(innerAt, closeAt) });\n out.push({ kind: 'delimiter', text: value.slice(closeAt, end) });\n at = end;\n }\n\n // The promise, checked rather than assumed — this is the one thing the whole\n // component rests on, and it costs one string compare per keystroke.\n if (out.map((s) => s.text).join('') !== value) return [{ kind: 'text', text: value }];\n return out;\n}\n\n/**\n * The rule that opened the run `tokenizeInline` reported at `at`.\n *\n * Found by shape, in the tokenizer's own rule order: the first rule of the right\n * kind whose `open`, inner text and `close` all sit where they would have to.\n * The tokenizer's extra guards (an intraword `_`, a `#` before a digit) only\n * ever make it SKIP a rule, and a skipped rule produces no run — so at a\n * position where a run exists, the first shape-match is the rule that made it.\n */\nfunction ruleAt(\n value: string,\n at: number,\n kind: InlineKind,\n inner: string,\n rules: readonly InlineRule[],\n): InlineRule | null {\n for (const rule of rules) {\n if (rule.kind !== kind) continue;\n if (!value.startsWith(rule.open, at)) continue;\n const innerAt = at + rule.open.length;\n if (!value.startsWith(inner, innerAt)) continue;\n if (!value.startsWith(rule.close, innerAt + inner.length)) continue;\n return rule;\n }\n return null;\n}\n","import { STANDARD_MARKUP } from 'react-os-shell/markup';\nimport { goldSegments } from './segments';\nimport type { GoldSegment } from './segments';\nimport type { GoldLayerProps } from './types';\n\n/**\n * The formatted text, drawn UNDER the box.\n *\n * It is a MARKER layer, not a preview of the page. That distinction is the whole\n * design, and it is why a bold run here is not drawn at weight 600:\n *\n * the caret, the selection and the line breaks all come from the transparent\n * textarea on top, which has ONE font. A heavier or slanted face down here is\n * wider, so a marked line would wrap a word earlier than the box does and\n * every line after it would sit on top of the wrong text.\n *\n * So the layer may only paint what cannot move a glyph — colour, opacity,\n * text-decoration, a shadow. A merchant sees WHERE the formatting starts and\n * ends and in which colour it will land; the real weight and slant appear on the\n * page the moment they click away. Full WYSIWYG (asterisks gone, real faces) is\n * a second text engine and is deliberately out of scope — see CLAUDE.md.\n *\n * The elements mirror the storefront's own renderer (`goldPhrases.tsx`):\n * `<strong>` for bold, `<em>` for italic and for the gold accent, `<s>` for\n * struck-out. The classes carry the paint; the tokens carry the values.\n */\nexport function GoldLayer({ value, rules = STANDARD_MARKUP, className, layerRef }: GoldLayerProps) {\n const segments = goldSegments(value, rules);\n return (\n <div\n ref={layerRef}\n className={['es-gold-layer', className ?? ''].filter(Boolean).join(' ')}\n // The box above holds the same characters and is what a screen reader\n // reads; announcing them twice would be a bug, not thoroughness.\n aria-hidden=\"true\"\n >\n {segments.map((segment, i) => paint(segment, i))}\n </div>\n );\n}\n\nfunction paint(segment: GoldSegment, key: number) {\n const { kind, text } = segment;\n switch (kind) {\n // The delimiters STAY — dimmed, never removed. See `segments.ts`.\n case 'delimiter':\n return (\n <span key={key} className=\"es-gold-delim\">\n {text}\n </span>\n );\n // `accent` (the legacy `*phrase*`) and `highlight` (`==phrase==`) paint the\n // same, exactly as the page paints them — which is what makes converting\n // stored copy from one to the other invisible here too.\n case 'accent':\n case 'highlight':\n return (\n <em key={key} className=\"es-gold-mark es-gold-accent\">\n {text}\n </em>\n );\n case 'bold':\n return (\n <strong key={key} className=\"es-gold-mark es-gold-bold\">\n {text}\n </strong>\n );\n case 'italic':\n return (\n <em key={key} className=\"es-gold-mark es-gold-italic\">\n {text}\n </em>\n );\n case 'strike':\n return (\n <s key={key} className=\"es-gold-mark es-gold-strike\">\n {text}\n </s>\n );\n // `text` — and `code`, which no product rule produces today, so a backtick\n // stays ordinary copy rather than becoming a chip nobody asked for.\n default:\n return (\n <span key={key} className=\"es-gold-text\">\n {text}\n </span>\n );\n }\n}\n","import { useRef } from 'react';\nimport type { ChangeEvent, KeyboardEvent, UIEvent } from 'react';\nimport { STANDARD_MARKUP } from 'react-os-shell/markup';\nimport { GoldLayer } from './GoldLayer';\nimport type { GoldTextInputProps } from './types';\n\n/**\n * Type where the text sits, and watch the gold appear as you type.\n *\n * The arrangement, in one paragraph: a textarea whose own text is TRANSPARENT\n * sits on top of a layer holding the same characters, painted. What you read is\n * the layer; what the caret walks is the box. Both take their type styles from\n * whatever the host renders them inside (`font: inherit` all the way down), so\n * the glyphs land on top of each other instead of near each other, and the box\n * inherits the page's own type rather than a size this package invented.\n *\n * The layer is the element IN FLOW and the box is absolutely positioned over it,\n * which is deliberate: the layer holds the same characters, so it wraps to the\n * same height, so the box grows as the merchant types without anyone measuring\n * anything.\n *\n * WHAT IT DOES NOT DO: write. It has no document, no section, no Puck, no shop.\n * `onCommit` says \"this edit is finished\"; what that means is the host's\n * business. That is what makes it shareable by both editors (constitution E2)\n * and testable without any of them.\n *\n * No `'use client'` — same reason as the rail: the directive is the HOST's to\n * place, and both editors' chrome are already client components. Unlike the\n * rail, though, this leaf has hooks, so it must be rendered inside that client\n * boundary; a server component can import the module but cannot render it.\n *\n * Requires `editor-shell/gold.css`, imported once from a client entry.\n */\nexport function GoldTextInput({\n value,\n onInput,\n onCommit,\n onCancel,\n multiline = false,\n placeholder,\n className,\n rules = STANDARD_MARKUP,\n ariaLabel,\n}: GoldTextInputProps) {\n const layerRef = useRef<HTMLDivElement | null>(null);\n\n /** The string this editing session started from — what Escape restores. */\n const openedWith = useRef(value);\n /**\n * Whether anything has been typed since focus. This one boolean carries two\n * owner rulings at once: a focus that types nothing writes nothing (6), and a\n * session that has already committed does not commit again on the blur that\n * follows (2 — one undo step per session).\n */\n const dirty = useRef(false);\n\n function handleFocus() {\n openedWith.current = value;\n dirty.current = false;\n }\n\n function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {\n dirty.current = true;\n onInput(event.currentTarget.value);\n }\n\n function commit(current: string) {\n if (!dirty.current) return;\n dirty.current = false;\n onCommit?.(current);\n }\n\n function handleBlur() {\n // Click-away commits (ruling 5) — or, after an Escape or an Enter that has\n // already committed, does nothing at all.\n commit(value);\n }\n\n function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n if (event.key === 'Escape') {\n // Escape ALWAYS cancels, even having typed nothing: it is the merchant\n // saying \"leave this alone\", and it must not fall through to a host that\n // reads Escape as \"close the editor\".\n event.preventDefault();\n dirty.current = false;\n onCancel?.(openedWith.current);\n return;\n }\n\n if (event.key === 'Enter' && !multiline) {\n // A single-line field never takes a newline — not even one that would be\n // thrown away by a commit that does not happen.\n event.preventDefault();\n commit(value);\n return;\n }\n\n // Multi-line Enter is left alone on purpose: the browser inserts the newline\n // and the ordinary change event carries it back. Nothing commits.\n }\n\n function handleScroll(event: UIEvent<HTMLTextAreaElement>) {\n // Only reachable when a host constrains the height — the box scrolls, so the\n // layer has to scroll with it or the two surfaces come apart.\n const layer = layerRef.current;\n if (!layer) return;\n layer.scrollTop = event.currentTarget.scrollTop;\n layer.scrollLeft = event.currentTarget.scrollLeft;\n }\n\n return (\n <div\n className={[\n 'es-gold',\n multiline ? 'es-gold-is-multiline' : 'es-gold-is-single',\n className ?? '',\n ]\n .filter(Boolean)\n .join(' ')}\n >\n <GoldLayer value={value} rules={rules} layerRef={layerRef} />\n {placeholder && value === '' ? (\n // Drawn as the layer's SIBLING, not inside it, so the layer's text stays\n // character-for-character the stored string — the invariant the whole\n // component rests on. It is painted here rather than by the box's own\n // `::placeholder` (which gold.css makes transparent) so the hint lands\n // in exactly the place the first typed character will.\n <div className=\"es-gold-placeholder\" aria-hidden=\"true\">\n {placeholder}\n </div>\n ) : null}\n <textarea\n className=\"es-gold-input\"\n value={value}\n placeholder={placeholder}\n aria-label={ariaLabel}\n onChange={handleChange}\n onFocus={handleFocus}\n onBlur={handleBlur}\n onKeyDown={handleKeyDown}\n onScroll={handleScroll}\n />\n </div>\n );\n}\n","/**\n * Gold-layer paint tokens.\n *\n * SHORT LIST BY DESIGN. The layer draws inside whatever the host renders it in\n * and inherits that context's type entirely — family, size, weight, line-height,\n * letter-spacing — because it has to sit on top of a box that inherits the same\n * (see `GoldTextInput`). So there is no type here to tokenise; what is left is\n * the paint, and only the paint that cannot move a glyph.\n *\n * NOTHING HERE IS INVENTED (CLAUDE.md — \"the tokens are not ours to invent\"):\n * - the gold defers to the page's own `--gold`, falling back to the\n * storefront's light-theme value (`efficient-shop/app/globals.css` → `--gold:\n * #c9a461`), so the mark in the box matches the mark on the page;\n * - the caret and the placeholder defer to the shell layer's `--es-text` /\n * `--es-field-placeholder` (see `shell/tokens.ts`) with the same values as\n * their fallbacks;\n * - the dim is the rail's existing disabled opacity (0.35), not a new number.\n *\n * Every value is a STRING, unlike the rail's and shell's pixel magnitudes:\n * none of these is a length, so nothing here gains a unit on the way into CSS.\n *\n * Exposed BOTH ways, per the same value: `goldTokens` (this object) and\n * `gold.css` (the stylesheet, which declares them on `.es-gold` — names in\n * {@link goldCssVars}). The parity gate in `tests/gold-leaf-safety.test.ts`\n * proves the two never drift.\n */\nexport const goldTokens = {\n /** The mark colour — `==phrase==` and the legacy `*phrase*`. */\n accent: 'var(--gold, #c9a461)',\n /** The caret. Set explicitly because the box's own text is transparent, and\n * `caret-color: auto` would make the caret transparent with it. */\n caret: 'var(--es-text, #111827)',\n /** The hint shown while the value is empty. */\n placeholder: 'var(--es-field-placeholder, #9ca3af)',\n /** How far the asterisks are dimmed WHILE THE BOX HAS FOCUS. Out of focus they\n * are faded out altogether (`gold.css`, `:focus-within`, card 8802.h) — but\n * FADED, never removed: the characters stay in the layer at their full width,\n * which is the deal. Anything that took them out would move a glyph. */\n delimiterOpacity: '0.35',\n /**\n * Faux-bold. A real `font-weight: 600` is wider, and a wider run down here\n * wraps a line earlier than the transparent box on top of it — after which\n * every following line is drawn over the wrong text. The shadow reads heavier\n * and moves nothing.\n */\n boldShadow: '0 0 0.4px currentColor',\n /** Italic, marked rather than slanted — an italic FACE has its own widths, and\n * the layer may not change a glyph's advance. Same reason as the bold. */\n italicDecoration: 'underline dotted',\n /** Strike-through is metric-safe, so it is drawn exactly as it will print. */\n strikeDecoration: 'line-through',\n} as const;\n\nexport type GoldTokens = typeof goldTokens;\n\n/**\n * The CSS custom-property name behind each token. `gold.css` sets these on\n * `.es-gold`, so overriding one on (or above) the box re-themes it without\n * shipping new CSS — e.g. `style={{ ['--es-gold-accent']: brand }}`.\n */\nexport const goldCssVars: Record<keyof GoldTokens, string> = {\n accent: '--es-gold-accent',\n caret: '--es-gold-caret',\n placeholder: '--es-gold-placeholder',\n delimiterOpacity: '--es-gold-delim-opacity',\n boldShadow: '--es-gold-bold-shadow',\n italicDecoration: '--es-gold-italic-decoration',\n strikeDecoration: '--es-gold-strike-decoration',\n};\n"]}
1
+ {"version":3,"sources":["../../src/gold/segments.ts","../../src/gold/GoldLayer.tsx","../../src/gold/GoldTextInput.tsx","../../src/gold/tokens.ts"],"names":["STANDARD_MARKUP","jsx"],"mappings":"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","file":"index.js","sourcesContent":["import { tokenizeInline } from 'react-os-shell/markup';\nimport type { InlineKind, InlineRule } from 'react-os-shell/markup';\n\n/**\n * Cutting an authored string into the pieces the layer paints — WITHOUT losing a\n * character.\n *\n * The grammar is not re-implemented here and never will be (constitution E8):\n * {@link tokenizeInline} from `react-os-shell/markup` decides what is a run and\n * what is not, exactly as it does for the published page. What it does not\n * return is the delimiters — it hands back a run's INNER text, because every\n * other consumer wants the words without the asterisks.\n *\n * This layer wants the asterisks. It draws underneath a transparent box whose\n * caret walks the stored string, so the two surfaces must hold the same\n * characters in the same order: drop the `**` and every glyph after it sits two\n * columns left of the caret that is supposed to be inside it.\n *\n * So each parsed run is located back in the source and cut into three pieces —\n * opening delimiter, inner text, closing delimiter — and EVERY piece is sliced\n * out of the original string rather than rebuilt from the token. Nothing is\n * retyped, so nothing can be retyped wrong.\n */\n\n/** A piece of the source: a parsed run's kind, or the delimiters around one. */\nexport type GoldSegmentKind = InlineKind | 'delimiter';\n\nexport interface GoldSegment {\n kind: GoldSegmentKind;\n /** Verbatim source text. Concatenating every segment reproduces the input. */\n text: string;\n}\n\n/**\n * Split `value` into paint-able segments under `rules`.\n *\n * GUARANTEE: `segments.map(s => s.text).join('') === value`, always. If the walk\n * below cannot line a run up with the rule that produced it — which would mean\n * the grammar and this function disagree — the whole string comes back as one\n * plain `text` segment. Losing the gold is a disappointment; losing a character\n * puts the caret in the wrong place, so the fallback is never in doubt.\n */\nexport function goldSegments(value: string, rules: readonly InlineRule[]): GoldSegment[] {\n const out: GoldSegment[] = [];\n let at = 0;\n\n for (const token of tokenizeInline(value, rules)) {\n if (token.kind === 'text') {\n // Empty text tokens are part of the tokenizer's alternating contract; they\n // paint nothing, so they are dropped rather than drawn as empty spans.\n if (token.text.length > 0) {\n out.push({ kind: 'text', text: value.slice(at, at + token.text.length) });\n at += token.text.length;\n }\n continue;\n }\n\n const rule = ruleAt(value, at, token.kind, token.text, rules);\n if (!rule) return [{ kind: 'text', text: value }];\n\n const innerAt = at + rule.open.length;\n const closeAt = innerAt + token.text.length;\n const end = closeAt + rule.close.length;\n\n out.push({ kind: 'delimiter', text: value.slice(at, innerAt) });\n out.push({ kind: token.kind, text: value.slice(innerAt, closeAt) });\n out.push({ kind: 'delimiter', text: value.slice(closeAt, end) });\n at = end;\n }\n\n // The promise, checked rather than assumed — this is the one thing the whole\n // component rests on, and it costs one string compare per keystroke.\n if (out.map((s) => s.text).join('') !== value) return [{ kind: 'text', text: value }];\n return out;\n}\n\n/**\n * The rule that opened the run `tokenizeInline` reported at `at`.\n *\n * Found by shape, in the tokenizer's own rule order: the first rule of the right\n * kind whose `open`, inner text and `close` all sit where they would have to.\n * The tokenizer's extra guards (an intraword `_`, a `#` before a digit) only\n * ever make it SKIP a rule, and a skipped rule produces no run — so at a\n * position where a run exists, the first shape-match is the rule that made it.\n */\nfunction ruleAt(\n value: string,\n at: number,\n kind: InlineKind,\n inner: string,\n rules: readonly InlineRule[],\n): InlineRule | null {\n for (const rule of rules) {\n if (rule.kind !== kind) continue;\n if (!value.startsWith(rule.open, at)) continue;\n const innerAt = at + rule.open.length;\n if (!value.startsWith(inner, innerAt)) continue;\n if (!value.startsWith(rule.close, innerAt + inner.length)) continue;\n return rule;\n }\n return null;\n}\n","import { STANDARD_MARKUP } from 'react-os-shell/markup';\nimport { goldSegments } from './segments';\nimport type { GoldSegment } from './segments';\nimport type { GoldLayerProps } from './types';\n\n/**\n * The formatted text, drawn UNDER the box.\n *\n * It is a MARKER layer, not a preview of the page. That distinction is the whole\n * design, and it is why a bold run here is not drawn at weight 600:\n *\n * the caret, the selection and the line breaks all come from the transparent\n * textarea on top, which has ONE font. A heavier or slanted face down here is\n * wider, so a marked line would wrap a word earlier than the box does and\n * every line after it would sit on top of the wrong text.\n *\n * So the layer may only paint what cannot move a glyph — colour, opacity,\n * text-decoration, a shadow. A merchant sees WHERE the formatting starts and\n * ends and in which colour it will land; the real weight and slant appear on the\n * page the moment they click away. Full WYSIWYG (asterisks gone, real faces) is\n * a second text engine and is deliberately out of scope — see CLAUDE.md.\n *\n * The elements mirror the storefront's own renderer (`goldPhrases.tsx`):\n * `<strong>` for bold, `<em>` for italic and for the gold accent, `<s>` for\n * struck-out. The classes carry the paint; the tokens carry the values.\n */\nexport function GoldLayer({ value, rules = STANDARD_MARKUP, className, layerRef }: GoldLayerProps) {\n const segments = goldSegments(value, rules);\n return (\n <div\n ref={layerRef}\n className={['es-gold-layer', className ?? ''].filter(Boolean).join(' ')}\n // The box above holds the same characters and is what a screen reader\n // reads; announcing them twice would be a bug, not thoroughness.\n aria-hidden=\"true\"\n >\n {segments.map((segment, i) => paint(segment, i))}\n </div>\n );\n}\n\nfunction paint(segment: GoldSegment, key: number) {\n const { kind, text } = segment;\n switch (kind) {\n // The delimiters STAY — dimmed, never removed. See `segments.ts`.\n case 'delimiter':\n return (\n <span key={key} className=\"es-gold-delim\">\n {text}\n </span>\n );\n // `accent` (the legacy `*phrase*`) and `highlight` (`==phrase==`) paint the\n // same, exactly as the page paints them — which is what makes converting\n // stored copy from one to the other invisible here too.\n case 'accent':\n case 'highlight':\n return (\n <em key={key} className=\"es-gold-mark es-gold-accent\">\n {text}\n </em>\n );\n case 'bold':\n return (\n <strong key={key} className=\"es-gold-mark es-gold-bold\">\n {text}\n </strong>\n );\n case 'italic':\n return (\n <em key={key} className=\"es-gold-mark es-gold-italic\">\n {text}\n </em>\n );\n case 'strike':\n return (\n <s key={key} className=\"es-gold-mark es-gold-strike\">\n {text}\n </s>\n );\n // `text` — and `code`, which no product rule produces today, so a backtick\n // stays ordinary copy rather than becoming a chip nobody asked for.\n default:\n return (\n <span key={key} className=\"es-gold-text\">\n {text}\n </span>\n );\n }\n}\n","import { useRef } from 'react';\nimport type { ChangeEvent, KeyboardEvent, UIEvent } from 'react';\nimport { STANDARD_MARKUP } from 'react-os-shell/markup';\nimport { GoldLayer } from './GoldLayer';\nimport type { GoldTextInputProps } from './types';\n\n/**\n * Type where the text sits, and watch the gold appear as you type.\n *\n * The arrangement, in one paragraph: a textarea whose own text is TRANSPARENT\n * sits on top of a layer holding the same characters, painted. What you read is\n * the layer; what the caret walks is the box. Both take their type styles from\n * whatever the host renders them inside (`font: inherit` all the way down), so\n * the glyphs land on top of each other instead of near each other, and the box\n * inherits the page's own type rather than a size this package invented.\n *\n * The layer is the element IN FLOW and the box is absolutely positioned over it,\n * which is deliberate: the layer holds the same characters, so it wraps to the\n * same height, so the box grows as the merchant types without anyone measuring\n * anything.\n *\n * WHAT IT DOES NOT DO: write. It has no document, no section, no Puck, no shop.\n * `onCommit` says \"this edit is finished\"; what that means is the host's\n * business. That is what makes it shareable by both editors (constitution E2)\n * and testable without any of them.\n *\n * No `'use client'` — same reason as the rail: the directive is the HOST's to\n * place, and both editors' chrome are already client components. Unlike the\n * rail, though, this leaf has hooks, so it must be rendered inside that client\n * boundary; a server component can import the module but cannot render it.\n *\n * Requires `editor-shell/gold.css`, imported once from a client entry.\n */\nexport function GoldTextInput({\n value,\n onInput,\n onCommit,\n onCancel,\n multiline = false,\n placeholder,\n className,\n rules = STANDARD_MARKUP,\n ariaLabel,\n}: GoldTextInputProps) {\n const layerRef = useRef<HTMLDivElement | null>(null);\n\n /** The string this editing session started from — what Escape restores. */\n const openedWith = useRef(value);\n /**\n * Whether anything has been typed since focus. This one boolean carries two\n * owner rulings at once: a focus that types nothing writes nothing (6), and a\n * session that has already committed does not commit again on the blur that\n * follows (2 — one undo step per session).\n */\n const dirty = useRef(false);\n\n function handleFocus() {\n openedWith.current = value;\n dirty.current = false;\n }\n\n function handleChange(event: ChangeEvent<HTMLTextAreaElement>) {\n dirty.current = true;\n onInput(event.currentTarget.value);\n }\n\n function commit(current: string) {\n if (!dirty.current) return;\n dirty.current = false;\n onCommit?.(current);\n }\n\n function handleBlur() {\n // Click-away commits (ruling 5) — or, after an Escape or an Enter that has\n // already committed, does nothing at all.\n commit(value);\n }\n\n function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {\n if (event.key === 'Escape') {\n // Escape ALWAYS cancels, even having typed nothing: it is the merchant\n // saying \"leave this alone\", and it must not fall through to a host that\n // reads Escape as \"close the editor\".\n event.preventDefault();\n dirty.current = false;\n onCancel?.(openedWith.current);\n return;\n }\n\n if (event.key === 'Enter' && !multiline) {\n // A single-line field never takes a newline — not even one that would be\n // thrown away by a commit that does not happen.\n event.preventDefault();\n commit(value);\n return;\n }\n\n // Multi-line Enter is left alone on purpose: the browser inserts the newline\n // and the ordinary change event carries it back. Nothing commits.\n }\n\n function handleScroll(event: UIEvent<HTMLTextAreaElement>) {\n // Only reachable when a host constrains the height — the box scrolls, so the\n // layer has to scroll with it or the two surfaces come apart.\n const layer = layerRef.current;\n if (!layer) return;\n layer.scrollTop = event.currentTarget.scrollTop;\n layer.scrollLeft = event.currentTarget.scrollLeft;\n }\n\n return (\n <div\n className={[\n 'es-gold',\n multiline ? 'es-gold-is-multiline' : 'es-gold-is-single',\n className ?? '',\n ]\n .filter(Boolean)\n .join(' ')}\n >\n <GoldLayer value={value} rules={rules} layerRef={layerRef} />\n {placeholder && value === '' ? (\n // Drawn as the layer's SIBLING, not inside it, so the layer's text stays\n // character-for-character the stored string — the invariant the whole\n // component rests on. It is painted here rather than by the box's own\n // `::placeholder` (which gold.css makes transparent) so the hint lands\n // in exactly the place the first typed character will.\n <div className=\"es-gold-placeholder\" aria-hidden=\"true\">\n {placeholder}\n </div>\n ) : null}\n <textarea\n className=\"es-gold-input\"\n value={value}\n placeholder={placeholder}\n aria-label={ariaLabel}\n onChange={handleChange}\n onFocus={handleFocus}\n onBlur={handleBlur}\n onKeyDown={handleKeyDown}\n onScroll={handleScroll}\n />\n </div>\n );\n}\n","/**\n * Gold-layer paint tokens.\n *\n * SHORT LIST BY DESIGN. The layer draws inside whatever the host renders it in\n * and inherits that context's type entirely — family, size, weight, line-height,\n * letter-spacing — because it has to sit on top of a box that inherits the same\n * (see `GoldTextInput`). So there is no type here to tokenise; what is left is\n * the paint, and only the paint that cannot move a glyph.\n *\n * NOTHING HERE IS INVENTED (CLAUDE.md — \"the tokens are not ours to invent\"):\n * - the gold defers to the page's own `--gold`, falling back to the\n * storefront's light-theme value (`efficient-shop/app/globals.css` → `--gold:\n * #c9a461`), so the mark in the box matches the mark on the page;\n * - the caret and the placeholder defer to the shell layer's `--es-text` /\n * `--es-field-placeholder` (see `shell/tokens.ts`) with the same values as\n * their fallbacks;\n * - the FOCUSED dim is the rail's existing disabled opacity (0.35), not a new\n * number. Its RESTING sibling (0.25) is the one value in this file picked by\n * eye rather than borrowed: nothing else in the product paints a mark that\n * has to hold its width while it stops being read, so there was nothing to\n * copy. The owner chose it against a picker rendering the candidates at\n * heading and body size; 0.18 was tried there first and still read too\n * faint (card 8807). That history is here on purpose: a later reader who\n * finds 0.25 with nothing behind it will \"fix\" it to match some other\n * number, and this is the paragraph that stops them.\n *\n * Every value is a STRING, unlike the rail's and shell's pixel magnitudes:\n * none of these is a length, so nothing here gains a unit on the way into CSS.\n *\n * Exposed BOTH ways, per the same value: `goldTokens` (this object) and\n * `gold.css` (the stylesheet, which declares them on `.es-gold` — names in\n * {@link goldCssVars}). The parity gate in `tests/gold-leaf-safety.test.ts`\n * proves the two never drift.\n */\nexport const goldTokens = {\n /** The mark colour — `==phrase==` and the legacy `*phrase*`. */\n accent: 'var(--gold, #c9a461)',\n /** The caret. Set explicitly because the box's own text is transparent, and\n * `caret-color: auto` would make the caret transparent with it. */\n caret: 'var(--es-text, #111827)',\n /** The hint shown while the value is empty. */\n placeholder: 'var(--es-field-placeholder, #9ca3af)',\n /** How far the asterisks are dimmed WHILE THE BOX HAS FOCUS — the moment they\n * are what the merchant is aiming at, so this is the louder of the pair.\n * Out of focus they drop to {@link goldTokens.delimiterRestingOpacity}. */\n delimiterOpacity: '0.35',\n /** How far they are dimmed AT REST, when nobody is typing (card 8807). A\n * whisper, and deliberately not 0.\n *\n * FADED, never removed: the characters stay in the layer at their full width\n * in BOTH states, which is the deal — anything that took them out would move\n * a glyph. So the width is spent either way, and 0 spends it with nothing to\n * show for it: a short, heavily marked string loses about a quarter of its\n * line to blanks that read as a rendering bug. Faint, the same space reads as\n * punctuation. */\n delimiterRestingOpacity: '0.25',\n /**\n * Faux-bold. A real `font-weight: 600` is wider, and a wider run down here\n * wraps a line earlier than the transparent box on top of it — after which\n * every following line is drawn over the wrong text. The shadow reads heavier\n * and moves nothing.\n */\n boldShadow: '0 0 0.4px currentColor',\n /** Italic, marked rather than slanted — an italic FACE has its own widths, and\n * the layer may not change a glyph's advance. Same reason as the bold. */\n italicDecoration: 'underline dotted',\n /** Strike-through is metric-safe, so it is drawn exactly as it will print. */\n strikeDecoration: 'line-through',\n} as const;\n\nexport type GoldTokens = typeof goldTokens;\n\n/**\n * The CSS custom-property name behind each token. `gold.css` sets these on\n * `.es-gold`, so overriding one on (or above) the box re-themes it without\n * shipping new CSS — e.g. `style={{ ['--es-gold-accent']: brand }}`.\n */\nexport const goldCssVars: Record<keyof GoldTokens, string> = {\n accent: '--es-gold-accent',\n caret: '--es-gold-caret',\n placeholder: '--es-gold-placeholder',\n delimiterOpacity: '--es-gold-delim-opacity',\n delimiterRestingOpacity: '--es-gold-delim-resting-opacity',\n boldShadow: '--es-gold-bold-shadow',\n italicDecoration: '--es-gold-italic-decoration',\n strikeDecoration: '--es-gold-strike-decoration',\n};\n"]}
@@ -13,7 +13,29 @@ import { ReactNode } from 'react';
13
13
  interface SettingsTabItem {
14
14
  /** What this tab IS, as `activeId` and `onSelect` name it. Not a DOM id. */
15
15
  id: string;
16
+ /**
17
+ * What the tab is CALLED — and, since card 8810, its name in every tier.
18
+ *
19
+ * Required, and a plain string, on purpose. Give the tab an `icon` and a
20
+ * narrow panel takes this word off the screen, but never out of the tab: it
21
+ * stays in the markup and it is carried as `title` and `aria-label` on the
22
+ * button at every width. An icon-only control with no name is the exact
23
+ * defect card 8801 shipped and had to have caught in review, and a separate
24
+ * optional `ariaLabel` prop is how that happens again. Here it cannot — the
25
+ * name is the field the tab has always had, and it is not optional.
26
+ */
16
27
  label: string;
28
+ /**
29
+ * An 18px glyph from `editor-shell/icons`, so the tab can spend less width
30
+ * than its word needs when the panel is dragged narrow (card 8810).
31
+ *
32
+ * Optional, and the strip is unchanged without it: a tab with no glyph has
33
+ * nothing to fall back to, so it keeps its word at every width and the strip
34
+ * wraps for it exactly as it did before. Pass icons for ALL the tabs in a
35
+ * strip or none of them — a row that is half words and half glyphs below
36
+ * 270px reads as a rendering fault, not as a decision.
37
+ */
38
+ icon?: ReactNode;
17
39
  badge?: number;
18
40
  /**
19
41
  * The DOM id of the element that shows this tab's settings — `aria-controls`.
@@ -166,6 +188,31 @@ type SettingsSwitchProps = SettingsSwitchBaseProps & SettingsSwitchLabel;
166
188
  * with nothing to point at renders no attribute at all; an empty
167
189
  * `aria-controls` is worse than a missing one, because it names an element
168
190
  * that does not exist instead of admitting there is none.
191
+ *
192
+ * THE THREE TIERS (card 8810). A tab keeps the width its label needs — it may
193
+ * not shrink and it may not truncate — so a narrow panel wraps the strip onto a
194
+ * second row, and at the 220px minimum the last bucket goes there alone. Given
195
+ * an `icon`, a tab can spend less width instead of more:
196
+ *
197
+ * 270px and up words, and no glyph at all. Exactly what shipped.
198
+ * 240 – 269px the glyphs come out; only the ACTIVE tab keeps its word.
199
+ * below 240px glyphs only.
200
+ *
201
+ * The glyph is absent from the widest tier on purpose, not by oversight: it
202
+ * costs a tab 22px, and four tabs carrying word AND glyph need 356px to hold
203
+ * one row — drawing them at full width would wrap the strip at the ordinary
204
+ * panel width, which is the bug this is here to remove.
205
+ *
206
+ * Which tier is a CSS decision, taken against the PANEL's width — see the
207
+ * `@container` blocks in `panel.css`, and the wrapper below that gives them
208
+ * something to measure. React cannot know the answer at render time, and it
209
+ * does not need to: BOTH parts are always in the markup, and CSS shows the one
210
+ * that fits. The word is only ever hidden, never dropped, and the label rides
211
+ * along as `title` and `aria-label` on every tab in every tier, so the glyph
212
+ * never has to speak for the tab.
213
+ *
214
+ * A tab with no icon is untouched at every width — it has nothing to fall back
215
+ * to, so it keeps its word and the strip wraps for it exactly as before.
169
216
  */
170
217
  declare function SettingsTabs({ items, activeId, onSelect, ariaLabel, className, }: SettingsTabsProps): react.JSX.Element;
171
218
 
@@ -9,34 +9,42 @@ function SettingsTabs({
9
9
  ariaLabel = "Settings",
10
10
  className
11
11
  }) {
12
- return /* @__PURE__ */ jsx(
13
- "div",
14
- {
15
- role: "tablist",
16
- "aria-label": ariaLabel,
17
- "aria-orientation": "horizontal",
18
- className: className ? `es-tabs ${className}` : "es-tabs",
19
- children: items.map((item) => {
20
- const active = item.id === activeId;
21
- return /* @__PURE__ */ jsxs(
22
- "button",
23
- {
24
- type: "button",
25
- role: "tab",
26
- id: item.htmlId,
27
- "aria-selected": active,
28
- "aria-controls": item.panelId,
29
- className: active ? "es-tab is-active" : "es-tab",
30
- onClick: () => onSelect(item.id),
31
- children: [
32
- item.label,
33
- typeof item.badge === "number" && item.badge > 0 ? /* @__PURE__ */ jsx("span", { className: "es-tab-badge", children: item.badge }) : null
34
- ]
35
- },
36
- item.id
37
- );
38
- })
39
- }
12
+ return (
13
+ // The container context is the SHELL's, not a host's to remember. A panel
14
+ // that forgot to declare one would silently pin the strip to one tier.
15
+ /* @__PURE__ */ jsx("div", { className: "es-tabs-fit", children: /* @__PURE__ */ jsx(
16
+ "div",
17
+ {
18
+ role: "tablist",
19
+ "aria-label": ariaLabel,
20
+ "aria-orientation": "horizontal",
21
+ className: className ? `es-tabs ${className}` : "es-tabs",
22
+ children: items.map((item) => {
23
+ const active = item.id === activeId;
24
+ const cls = `es-tab${item.icon ? " has-icon" : ""}${active ? " is-active" : ""}`;
25
+ return /* @__PURE__ */ jsxs(
26
+ "button",
27
+ {
28
+ type: "button",
29
+ role: "tab",
30
+ id: item.htmlId,
31
+ title: item.label,
32
+ "aria-label": item.label,
33
+ "aria-selected": active,
34
+ "aria-controls": item.panelId,
35
+ className: cls,
36
+ onClick: () => onSelect(item.id),
37
+ children: [
38
+ item.icon ? /* @__PURE__ */ jsx("span", { className: "es-tab-icon", "aria-hidden": "true", children: item.icon }) : null,
39
+ /* @__PURE__ */ jsx("span", { className: "es-tab-word", children: item.label }),
40
+ typeof item.badge === "number" && item.badge > 0 ? /* @__PURE__ */ jsx("span", { className: "es-tab-badge", children: item.badge }) : null
41
+ ]
42
+ },
43
+ item.id
44
+ );
45
+ })
46
+ }
47
+ ) })
40
48
  );
41
49
  }
42
50
  function SettingsGroup({
@@ -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"],"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 */\nexport function SettingsTabs({\n items,\n activeId,\n onSelect,\n ariaLabel = 'Settings',\n className,\n}: SettingsTabsProps) {\n return (\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 return (\n <button\n key={item.id}\n type=\"button\"\n role=\"tab\"\n id={item.htmlId}\n aria-selected={active}\n aria-controls={item.panelId}\n className={active ? 'es-tab is-active' : 'es-tab'}\n onClick={() => onSelect(item.id)}\n >\n {item.label}\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 );\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"]}
1
+ {"version":3,"sources":["../../src/panel/SettingsTabs.tsx","../../src/panel/SettingsGroup.tsx","../../src/panel/SettingsGroupHead.tsx","../../src/panel/SettingsSwitch.tsx","../../src/panel/SettingsField.tsx"],"names":["jsxs","jsx"],"mappings":";;;;AA8CO,SAAS,YAAA,CAAa;AAAA,EAC3B,KAAA;AAAA,EACA,QAAA;AAAA,EACA,QAAA;AAAA,EACA,SAAA,GAAY,UAAA;AAAA,EACZ;AACF,CAAA,EAAsB;AACpB,EAAA;AAAA;AAAA;AAAA,oBAGE,GAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,aAAA,EACb,QAAA,kBAAA,GAAA;AAAA,MAAC,KAAA;AAAA,MAAA;AAAA,QACC,IAAA,EAAK,SAAA;AAAA,QACL,YAAA,EAAY,SAAA;AAAA,QACZ,kBAAA,EAAiB,YAAA;AAAA,QACjB,SAAA,EAAW,SAAA,GAAY,CAAA,QAAA,EAAW,SAAS,CAAA,CAAA,GAAK,SAAA;AAAA,QAE/C,QAAA,EAAA,KAAA,CAAM,GAAA,CAAI,CAAC,IAAA,KAAS;AACnB,UAAA,MAAM,MAAA,GAAS,KAAK,EAAA,KAAO,QAAA;AAC3B,UAAA,MAAM,GAAA,GAAM,SAAS,IAAA,CAAK,IAAA,GAAO,cAAc,EAAE,CAAA,EAAG,MAAA,GAAS,YAAA,GAAe,EAAE,CAAA,CAAA;AAC9E,UAAA,uBACE,IAAA;AAAA,YAAC,QAAA;AAAA,YAAA;AAAA,cAEC,IAAA,EAAK,QAAA;AAAA,cACL,IAAA,EAAK,KAAA;AAAA,cACL,IAAI,IAAA,CAAK,MAAA;AAAA,cACT,OAAO,IAAA,CAAK,KAAA;AAAA,cACZ,cAAY,IAAA,CAAK,KAAA;AAAA,cACjB,eAAA,EAAe,MAAA;AAAA,cACf,iBAAe,IAAA,CAAK,OAAA;AAAA,cACpB,SAAA,EAAW,GAAA;AAAA,cACX,OAAA,EAAS,MAAM,QAAA,CAAS,IAAA,CAAK,EAAE,CAAA;AAAA,cAE9B,QAAA,EAAA;AAAA,gBAAA,IAAA,CAAK,IAAA,uBACH,MAAA,EAAA,EAAK,SAAA,EAAU,eAAc,aAAA,EAAY,MAAA,EACvC,QAAA,EAAA,IAAA,CAAK,IAAA,EACR,CAAA,GACE,IAAA;AAAA,gCACJ,GAAA,CAAC,MAAA,EAAA,EAAK,SAAA,EAAU,aAAA,EAAe,eAAK,KAAA,EAAM,CAAA;AAAA,gBACzC,OAAO,IAAA,CAAK,KAAA,KAAU,QAAA,IAAY,IAAA,CAAK,KAAA,GAAQ,CAAA,mBAC9C,GAAA,CAAC,MAAA,EAAA,EAAK,SAAA,EAAU,cAAA,EAAgB,QAAA,EAAA,IAAA,CAAK,OAAM,CAAA,GACzC;AAAA;AAAA,aAAA;AAAA,YAnBC,IAAA,CAAK;AAAA,WAoBZ;AAAA,QAEJ,CAAC;AAAA;AAAA,KACH,EACF;AAAA;AAEJ;AClFO,SAAS,aAAA,CAAc;AAAA,EAC5B,KAAA;AAAA,EACA,IAAA;AAAA,EACA,QAAA;AAAA,EACA,KAAA;AAAA,EACA,EAAA;AAAA,EACA,QAAA;AAAA,EACA;AACF,CAAA,EAAuB;AACrB,EAAA,MAAM,MAAA,GAAS,EAAA,GAAK,CAAA,EAAG,EAAE,CAAA,KAAA,CAAA,GAAU,MAAA;AACnC,EAAA,uBACEA,KAAC,KAAA,EAAA,EAAI,SAAA,EAAW,YAAY,CAAA,SAAA,EAAY,SAAS,KAAK,UAAA,EACpD,QAAA,EAAA;AAAA,oBAAAA,IAAAA;AAAA,MAAC,QAAA;AAAA,MAAA;AAAA,QACC,IAAA,EAAK,QAAA;AAAA,QACL,EAAA;AAAA,QACA,eAAA,EAAe,IAAA;AAAA,QACf,eAAA,EAAe,MAAA;AAAA,QACf,SAAA,EAAU,eAAA;AAAA,QACV,OAAA,EAAS,MAAM,QAAA,CAAS,CAAC,IAAI,CAAA;AAAA,QAE5B,QAAA,EAAA;AAAA,UAAA,KAAA;AAAA,UACA,OAAO,KAAA,KAAU,QAAA,IAAY,KAAA,GAAQ,CAAA,mBACpCC,GAAAA,CAAC,MAAA,EAAA,EAAK,SAAA,EAAU,gBAAA,EAAkB,QAAA,EAAA,KAAA,EAAM,CAAA,GACtC,IAAA;AAAA,0BACJA,GAAAA,CAAC,MAAA,EAAA,EAAK,SAAA,EAAU,eAAA,EAAgB,eAAY,MAAA,EAAO;AAAA;AAAA;AAAA,KACrD;AAAA,oBACAA,GAAAA,CAAC,KAAA,EAAA,EAAI,EAAA,EAAI,MAAA,EAAQ,WAAU,eAAA,EAAgB,MAAA,EAAQ,CAAC,IAAA,EACjD,QAAA,EACH;AAAA,GAAA,EACF,CAAA;AAEJ;ACZO,SAAS,iBAAA,CAAkB;AAAA,EAChC,KAAA;AAAA,EACA,KAAA,GAAQ,CAAA;AAAA,EACR,EAAA;AAAA,EACA;AACF,CAAA,EAA2B;AACzB,EAAA,MAAM,GAAA,GAAM,IAAI,KAAK,CAAA,CAAA;AACrB,EAAA,uBACEA,GAAAA,CAAC,GAAA,EAAA,EAAI,EAAA,EAAQ,SAAA,EAAW,YAAY,CAAA,cAAA,EAAiB,SAAS,CAAA,CAAA,GAAK,eAAA,EAChE,QAAA,EAAA,KAAA,EACH,CAAA;AAEJ;ACXO,SAAS,cAAA,CAAe;AAAA,EAC7B,OAAA;AAAA,EACA,QAAA;AAAA,EACA,QAAA;AAAA,EACA,EAAA;AAAA,EACA,SAAA;AAAA,EACA,cAAA;AAAA,EACA;AACF,CAAA,EAAwB;AACtB,EAAA,uBACEA,GAAAA;AAAA,IAAC,QAAA;AAAA,IAAA;AAAA,MACC,IAAA,EAAK,QAAA;AAAA,MACL,IAAA,EAAK,QAAA;AAAA,MACL,EAAA;AAAA,MACA,cAAA,EAAc,OAAA;AAAA,MACd,YAAA,EAAY,SAAA;AAAA,MACZ,iBAAA,EAAiB,cAAA;AAAA,MACjB,QAAA;AAAA,MACA,SAAA,EAAW,SAAA,GAAY,CAAA,UAAA,EAAa,SAAS,CAAA,CAAA,GAAK,WAAA;AAAA,MAClD,OAAA,EAAS,MAAM,QAAA,CAAS,CAAC,OAAO;AAAA;AAAA,GAClC;AAEJ;ACjCA,SAAS,aAAa,IAAA,EAA0B;AAC9C,EAAA,IAAI,MAAM,OAAA,CAAQ,IAAI,GAAG,OAAO,IAAA,CAAK,KAAK,YAAY,CAAA;AACtD,EAAA,OAAO,eAAe,IAAI,CAAA;AAC5B;AAqBA,SAAS,UAAA,CAAW,MAAA,EAAmB,KAAA,EAAe,OAAA,EAA6B;AACjF,EAAA,IAAI,CAAC,cAAA,CAAe,MAAM,KAAK,MAAA,CAAO,IAAA,KAAS,gBAAgB,OAAO,MAAA;AAEtE,EAAA,MAAM,QAAQ,MAAA,CAAO,KAAA;AACrB,EAAA,IAAI,KAAA,CAAM,SAAA,IAAa,KAAA,CAAM,cAAA,EAAgB,OAAO,MAAA;AAEpD,EAAA,OAAO,YAAA;AAAA,IACL,MAAA;AAAA,IACA,UAAU,EAAE,cAAA,EAAgB,SAAQ,GAAI,EAAE,WAAW,KAAA;AAAM,GAC7D;AACF;AAyBO,SAAS,aAAA,CAAc;AAAA,EAC5B,KAAA;AAAA,EACA,OAAA;AAAA,EACA,IAAA;AAAA,EACA,KAAA;AAAA,EACA,IAAA;AAAA,EACA,OAAA;AAAA,EACA,OAAA;AAAA,EACA,MAAA;AAAA,EACA,QAAA;AAAA,EACA;AACF,CAAA,EAAuB;AACrB,EAAA,MAAM,UAAA,GAAa,aAAa,QAAQ,CAAA;AACxC,EAAA,MAAM,SAAA,GAAY,aAAa,MAAM,CAAA;AACrC,EAAA,IAAI,CAAC,UAAA,IAAc,CAAC,SAAA,EAAW,OAAO,IAAA;AAEtC,EAAA,MAAM,OAAA,GAAU,CAAC,UAAU,CAAA;AAC3B,EAAA,IAAI,CAAC,UAAA,EAAY,OAAA,CAAQ,IAAA,CAAK,WAAW,CAAA;AACzC,EAAA,IAAI,OAAA,EAAS,OAAA,CAAQ,IAAA,CAAK,YAAY,CAAA;AACtC,EAAA,IAAI,SAAA,EAAW,OAAA,CAAQ,IAAA,CAAK,SAAS,CAAA;AAErC,EAAA,MAAM,SAAA,mBACJD,IAAAA,CAAA,QAAA,EAAA,EACG,QAAA,EAAA;AAAA,IAAA,IAAA,mBACCC,IAAC,MAAA,EAAA,EAAK,SAAA,EAAU,iBAAgB,aAAA,EAAY,MAAA,EACzC,gBACH,CAAA,GACE,IAAA;AAAA,oBACJA,GAAAA,CAAC,MAAA,EAAA,EAAK,WAAU,gBAAA,EAAiB,EAAA,EAAI,SAClC,QAAA,EAAA,KAAA,EACH;AAAA,GAAA,EACF,CAAA;AAGF,EAAA,uBACED,IAAAA,CAAC,KAAA,EAAA,EAAI,WAAW,OAAA,CAAQ,IAAA,CAAK,GAAG,CAAA,EAC9B,QAAA,EAAA;AAAA,oBAAAA,IAAAA,CAAC,KAAA,EAAA,EAAI,SAAA,EAAU,eAAA,EACZ,QAAA,EAAA;AAAA,MAAA,OAAA,mBACCC,GAAAA,CAAC,OAAA,EAAA,EAAM,SAAA,EAAU,eAAA,EAAgB,OAAA,EAC9B,QAAA,EAAA,SAAA,EACH,CAAA,mBAEAA,GAAAA,CAAC,MAAA,EAAA,EAAK,SAAA,EAAU,iBAAiB,QAAA,EAAA,SAAA,EAAU,CAAA;AAAA,MAE5C,KAAA,KAAU,MAAA,IAAa,KAAA,KAAU,IAAA,mBAChCA,IAAC,MAAA,EAAA,EAAK,SAAA,EAAU,gBAAA,EAAkB,QAAA,EAAA,KAAA,EAAM,CAAA,GACtC,IAAA;AAAA,MACH,SAAA,GAAY,UAAA,CAAW,MAAA,EAAQ,KAAA,EAAO,OAAO,CAAA,GAAI;AAAA,KAAA,EACpD,CAAA;AAAA,IACC,6BAAaA,GAAAA,CAAC,SAAI,SAAA,EAAU,cAAA,EAAgB,UAAS,CAAA,GAAS,IAAA;AAAA,IAC9D,uBAAOA,GAAAA,CAAC,OAAE,SAAA,EAAU,eAAA,EAAiB,gBAAK,CAAA,GAAO;AAAA,GAAA,EACpD,CAAA;AAEJ","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 * THE THREE TIERS (card 8810). A tab keeps the width its label needs — it may\n * not shrink and it may not truncate — so a narrow panel wraps the strip onto a\n * second row, and at the 220px minimum the last bucket goes there alone. Given\n * an `icon`, a tab can spend less width instead of more:\n *\n * 270px and up words, and no glyph at all. Exactly what shipped.\n * 240 – 269px the glyphs come out; only the ACTIVE tab keeps its word.\n * below 240px glyphs only.\n *\n * The glyph is absent from the widest tier on purpose, not by oversight: it\n * costs a tab 22px, and four tabs carrying word AND glyph need 356px to hold\n * one row — drawing them at full width would wrap the strip at the ordinary\n * panel width, which is the bug this is here to remove.\n *\n * Which tier 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 tier, 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 and the strip wraps for it exactly as before.\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"]}
@@ -18,6 +18,25 @@
18
18
 
19
19
  /* ── the tab strip ─────────────────────────────────────────────────────────── */
20
20
 
21
+ /* The strip's own container context (card 8810), and the reason `SettingsTabs`
22
+ renders a wrapper at all.
23
+
24
+ The three tiers below turn on the width of the PANEL, and `@container` is the
25
+ only thing that can ask for it. A media query asks the WINDOW, and the window
26
+ is the wrong box: the inspector is a column the merchant drags, so a 220px
27
+ panel on a 2560px screen is the ordinary case, not an edge one — a media
28
+ query would leave that panel in the widest tier, which is the exact state
29
+ this card exists to fix.
30
+
31
+ The context lives HERE rather than in a host's own panel rule so the shell is
32
+ self-contained: a host that never declares `container-type` would silently
33
+ pin the strip to one tier with nothing to see and nothing to fail. */
34
+ .es-tabs-fit {
35
+ container-type: inline-size;
36
+ /* The wrapper is now the flex item the strip used to be — see `.es-tabs`. */
37
+ flex: none;
38
+ }
39
+
21
40
  /* The strip WRAPS. Four tabs need 267px and the panel drags down to 220px, so
22
41
  on a narrow panel the last bucket used to sit past the edge and could not be
23
42
  reached at all — by dragging a handle the editor itself offers.
@@ -85,6 +104,70 @@
85
104
  justify-content: center;
86
105
  }
87
106
 
107
+ /* The glyph a tab falls back to: 16px of drawing, the same size a field's icon
108
+ takes, standing in an 18px box.
109
+
110
+ DRAWN ONLY WHEN IT IS NEEDED — `display: none` here is tier 1. At 270px and
111
+ up the strip is words and nothing else, exactly what ships today, and it has
112
+ to be: a glyph adds its 16px and a 6px gap to every tab, and four tabs
113
+ carrying both need 356px to hold one row. Turn the glyphs on at full width
114
+ and the strip wraps at the shop's ordinary panel width — the bug this card
115
+ exists to remove, moved somewhere worse. Measured, `tabs-tiers.html` below.
116
+
117
+ The 18 is not a second opinion about the glyph — it is the WORD's own line
118
+ box (12px/600 in `--es-font` lays out an 18px line). Give the box that height
119
+ and a tab is 37px tall whether it is showing a word, a glyph, or both, so the
120
+ strip stays one height through all three tiers and the panel head does not
121
+ jog as the merchant drags.
122
+
123
+ `flex: none` for the same reason `.es-tab` has it: a glyph that shrinks is
124
+ the squeezed tab all over again, in a smaller box. */
125
+ .es-tab-icon {
126
+ display: none;
127
+ width: 16px;
128
+ height: 18px;
129
+ flex: none;
130
+ align-items: center;
131
+ justify-content: center;
132
+ }
133
+
134
+ .es-tab-icon svg { width: 16px; height: 16px; display: block; }
135
+
136
+ /* ── the three tiers ───────────────────────────────────────────────────────── */
137
+
138
+ /* MEASURED in Chromium, `tests/fixtures/tabs-tiers.html`, against these
139
+ stylesheets and the shop's four buckets Content / Media / Style / Layout:
140
+ *
141
+ * words glyphs only glyph + active word
142
+ * 220px 2 rows 1 row 2 rows
143
+ * 240px 2 rows 1 row 1 row
144
+ * 260px 2 rows 1 row 1 row
145
+ * 270px 1 row 1 row 1 row
146
+ *
147
+ * Hence THREE tiers and not the simpler two: words alone do not survive 269px,
148
+ * and glyph-plus-one-word does not survive 220px. Neither state covers the
149
+ * whole range on its own, so neither is a tier the other can be folded into.
150
+ *
151
+ * TIER 1 is the absence of both blocks below.
152
+ *
153
+ * Only `.has-icon` is ever touched. A tab with no glyph has nothing to fall
154
+ * back to, so it keeps its word at every width and the strip wraps for it as
155
+ * before — a button holding neither word nor glyph is not a narrower tab, it is
156
+ * an empty one. */
157
+
158
+ /* TIER 2 — the glyphs come out, and the words go with them, except on the tab
159
+ being read: a strip of four glyphs with nothing named is a guessing game. */
160
+ @container (width < 270px) {
161
+ .es-tab.has-icon .es-tab-icon { display: inline-flex; }
162
+ .es-tab.has-icon:not(.is-active) .es-tab-word { display: none; }
163
+ }
164
+
165
+ /* TIER 3 — at the 220px minimum even one word is one too many. The NAME does
166
+ not go with it: it is still on the button as `title` and `aria-label`. */
167
+ @container (width < 240px) {
168
+ .es-tab.has-icon.is-active .es-tab-word { display: none; }
169
+ }
170
+
88
171
  /* ── a group: the soft pill row that folds ─────────────────────────────────── */
89
172
 
90
173
  .es-group + .es-group { margin-top: 6px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "editor-shell",
3
- "version": "0.8.2",
3
+ "version": "0.10.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",