@kolkrabbi/kol-component 0.102.3 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.102.3",
3
+ "version": "0.104.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -137,7 +137,7 @@ export { default as LoaderOverlay } from './utilities/LoaderOverlay.jsx'
137
137
  export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPicker, MediaBrowser } from './organisms/MediaLibrary.jsx'
138
138
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
139
139
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
140
- export { default as SettingsPanel, SettingsSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
140
+ export { default as SettingsPanel, SettingsSection, SettingsRow, SettingsSwitch, SettingsChoice, SettingsMulti, SettingsChipRow, SettingsFooter } from './organisms/SettingsPanel.jsx'
141
141
  export { default as SectionNewsletter } from './organisms/SectionNewsletter.jsx'
142
142
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
143
143
  export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
@@ -43,6 +43,9 @@ export default function ShellDrawer({
43
43
  backdrop = true,
44
44
  children,
45
45
  className = '',
46
+ /* SettingsPanelApproved (2026-08-27): the settings drawer has neither */
47
+ edge = true,
48
+ shadow = true,
46
49
  }) {
47
50
  const reduced = usePrefersReducedMotion()
48
51
  const panelRef = useRef(null)
@@ -149,8 +152,10 @@ export default function ShellDrawer({
149
152
  role="dialog"
150
153
  aria-modal="true"
151
154
  tabIndex={-1}
152
- className={`fixed inset-y-0 z-[200] flex max-w-full flex-col bg-surface-primary px-4 py-4 outline-none md:px-5 lg:px-6 ${backdrop ? 'shadow-2xl' : ''} ${
153
- side === 'right' ? 'right-0 border-l' : 'left-0 border-r'
155
+ /* `edge` / `shadow` (SettingsPanelApproved, 2026-08-27): the approved settings
156
+ * drawer has neither the sheet meets the page flat */
157
+ className={`fixed inset-y-0 z-[200] flex max-w-full flex-col bg-surface-primary px-4 py-4 outline-none md:px-5 lg:px-6 ${backdrop && shadow ? 'shadow-2xl' : ''} ${
158
+ side === 'right' ? `right-0 ${edge ? 'border-l' : ''}` : `left-0 ${edge ? 'border-r' : ''}`
154
159
  } border-oq-08 ${width == null ? 'w-full' : ''} ${motionPanel} ${className}`}
155
160
  style={width != null ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
156
161
  >
@@ -163,7 +163,17 @@ export default function ColumnBrowser({
163
163
  * a Finder-style breadcrumb — fired whenever it changes; a folder pick or an
164
164
  * outside prefix change fires null */
165
165
  const pick = (o) => { setPicked(o); onPick?.(o) }
166
- useEffect(() => { if (picked) pick(null) }, [prefix]) // eslint-disable-line react-hooks/exhaustive-deps
166
+ /* picking a FILE in a column that has an open folder closes that folder
167
+ * (Finder: one highlight per column) — the prefix collapses to the file's
168
+ * level, and the cursor STAYS on the file instead of re-seeding
169
+ * (SettingsPanelApproved §4, kol-r2b2 2026-08-27) */
170
+ const keepCursor = useRef(false)
171
+ const pickFile = (level, o) => {
172
+ pick(o)
173
+ if (prefix !== level) { keepCursor.current = true; pickedByCollapse.current = true; onPrefix(level) }
174
+ }
175
+ const pickedByCollapse = useRef(false)
176
+ useEffect(() => { if (pickedByCollapse.current) { pickedByCollapse.current = false; return } if (picked) pick(null) }, [prefix]) // eslint-disable-line react-hooks/exhaustive-deps
167
177
  const levelsOf = (pfx) => {
168
178
  const out = ['']
169
179
  if (pfx) {
@@ -190,7 +200,10 @@ export default function ColumnBrowser({
190
200
  return { col, idx: Math.max(0, idx) }
191
201
  }
192
202
  const [cursor, setCursor] = useState(() => seedCursor(prefix))
193
- useEffect(() => { setCursor(seedCursor(prefix)) }, [prefix, objects.length]) // eslint-disable-line react-hooks/exhaustive-deps
203
+ useEffect(() => {
204
+ if (keepCursor.current) { keepCursor.current = false; return }
205
+ setCursor(seedCursor(prefix))
206
+ }, [prefix, objects.length]) // eslint-disable-line react-hooks/exhaustive-deps
194
207
  /* the cursor is NOT drawn until the keyboard is used (ColumnBrowserCursorStart,
195
208
  * kol-r2b2 2026-08-27): at rest it sat on row 0 beside the open folder and
196
209
  * read as a second selection. Arrows arm it; a click seeds it. */
@@ -200,7 +213,7 @@ export default function ColumnBrowser({
200
213
  const land = (level, item) => {
201
214
  if (!item) return
202
215
  if (item.type === 'folder') { pick(null); onPrefix(level + item.name) }
203
- else pick(item.o)
216
+ else pickFile(level, item.o)
204
217
  }
205
218
 
206
219
  const onKeyDown = (e) => {
@@ -309,7 +322,7 @@ export default function ColumnBrowser({
309
322
  muted={shown?.key !== o.key}
310
323
  active={shown?.key === o.key}
311
324
  cursor={cursorActive && cursor.col === k && cursor.idx === folders.length + i}
312
- onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pick(o) }}
325
+ onClick={() => { setCursor({ col: k, idx: folders.length + i }); setCursorActive(true); rootRef.current?.focus(); pickFile(level, o) }}
313
326
  />
314
327
  ))}
315
328
  {folders.length === 0 && files.length === 0 && (
@@ -1,4 +1,5 @@
1
1
  import SectionCardItem from '../molecules/SectionCardItem.jsx'
2
+ import { surfaceClass } from './sectionSurface.js'
2
3
  import SectionText, { HEADLINE_ROLE } from '../molecules/SectionText.jsx'
3
4
  import useSectionTheme from '../hooks/useSectionTheme.js'
4
5
  import { minHeightClass } from './sectionHeights.js'
@@ -29,9 +30,12 @@ import { minHeightClass } from './sectionHeights.js'
29
30
  * @param {object|Function} itemStyle inline style per card, or `(index) => style`
30
31
  * @param {object} slotClass · slotStyle per-slot class / style on the header text
31
32
  * @param {string} sectionClassName · wrapperClassName · cardsWrapperClassName · actionsClassName · headerClassName · headerTextWidthClass layout seams
33
+ * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
34
+ * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
32
35
  */
33
36
  export default function SectionCards({
34
37
  theme,
38
+ background,
35
39
  height = '60',
36
40
  features = [],
37
41
  eyebrow,
@@ -60,7 +64,7 @@ export default function SectionCards({
60
64
  const eb = eyebrow ?? label
61
65
  const [themeRef, themeStamp] = useSectionTheme(theme)
62
66
  return (
63
- <section ref={themeRef} data-theme={themeStamp} className={`w-full flex flex-col justify-center ${minHeightClass(height)} ${theme ? 'bg-surface-primary' : ''} ${sectionClassName}`.replace(/\s+/g, ' ').trim()}>
67
+ <section ref={themeRef} data-theme={themeStamp} className={`w-full flex flex-col justify-center ${minHeightClass(height)} ${surfaceClass(background, theme ? 'primary' : 'none')} ${sectionClassName}`.replace(/\s+/g, ' ').trim()}>
64
68
  <div className={wrapperClassName}>
65
69
  {(eb || headline || body) && (
66
70
  <SectionText
@@ -1,4 +1,5 @@
1
1
  import Button from '../atoms/Button.jsx'
2
+ import { surfaceClass } from './sectionSurface.js'
2
3
  import SectionText from '../molecules/SectionText.jsx'
3
4
  import { minHeightClass } from './sectionHeights.js'
4
5
 
@@ -26,9 +27,12 @@ import { minHeightClass } from './sectionHeights.js'
26
27
  * @param {string} email contact-row value + `mailto:` target; omit to drop the row
27
28
  * @param {{label: ReactNode, value: ReactNode, href?: string}[]} secondaryRows extra rows between prompt and contact
28
29
  * @param {string} className extra classes on the section
30
+ * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
31
+ * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
29
32
  */
30
33
  export default function SectionCta({
31
34
  variant = 'editorial',
35
+ background,
32
36
  height = '60',
33
37
  headline,
34
38
  body,
@@ -45,7 +49,7 @@ export default function SectionCta({
45
49
  }) {
46
50
  if (variant === 'centered') {
47
51
  return (
48
- <section className={`w-full flex flex-col justify-center py-24 ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
52
+ <section className={`w-full flex flex-col justify-center py-24 ${surfaceClass(background, 'auto')} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
49
53
  <div className="w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto">
50
54
  <div className="w-32 h-px bg-fg-24 mx-auto mb-8" />
51
55
  <SectionText
@@ -72,7 +76,7 @@ export default function SectionCta({
72
76
  ...(email ? [{ label: contactLabel, value: email, href: `mailto:${email}` }] : []),
73
77
  ]
74
78
  return (
75
- <section className={`w-full bg-auto flex flex-col justify-center ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
79
+ <section className={`w-full ${surfaceClass(background, 'auto')} flex flex-col justify-center ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
76
80
  {/* the family's ONE cap — the shell's --kol-container-max ladder (user
77
81
  * ruling 2026-08-26; the 1600 SectionCtaEditorial asked for was a third
78
82
  * number beside split's 1200 and cards' 1400) — the surface stays full
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { surfaceClass } from './sectionSurface.js'
2
3
  import { Accordion, AccordionPanel } from '../molecules/Accordion.jsx'
3
4
  import SectionText from '../molecules/SectionText.jsx'
4
5
  import { minHeightClass } from './sectionHeights.js'
@@ -17,6 +18,8 @@ import { minHeightClass } from './sectionHeights.js'
17
18
  * @param {boolean} [singleOpen=false] opening one panel closes the others
18
19
  * @param {number} [defaultOpen] index open on mount (singleOpen) — omit for all closed
19
20
  * @param {string} className · innerClassName layout seams
21
+ * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
22
+ * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
20
23
  */
21
24
  export default function SectionFaq({
22
25
  eyebrow,
@@ -29,6 +32,7 @@ export default function SectionFaq({
29
32
  singleOpen = false,
30
33
  defaultOpen,
31
34
  height = '60',
35
+ background,
32
36
  slotClass,
33
37
  slotStyle,
34
38
  className = '',
@@ -40,7 +44,7 @@ export default function SectionFaq({
40
44
  const eb = eyebrow ?? label
41
45
  const [open, setOpen] = useState(defaultOpen ?? null)
42
46
  return (
43
- <section className={`kol-section-faq w-full flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}>
47
+ <section className={`kol-section-faq w-full flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${className}`.replace(/\s+/g, ' ').trim()}>
44
48
  <div className="w-full max-w-[var(--kol-container-max,var(--kol-content-shell,1800px))] mx-auto">
45
49
  <div className={innerClassName}>
46
50
  {(eb || headline || body || actions) && (
@@ -1,4 +1,5 @@
1
1
  import { isValidElement } from 'react'
2
+ import { surfaceClass } from './sectionSurface.js'
2
3
  import HlsVideo from '../atoms/HlsVideo.jsx'
3
4
  import Image from '../atoms/Image.jsx'
4
5
  import OverlayGlassPanel from '../utilities/OverlayGlassPanel.jsx'
@@ -143,10 +144,13 @@ function MediaLayer({ media }) {
143
144
  * split: 'left' | 'right' — the media side
144
145
  * @param {boolean} fullBleed span the full viewport width (100vw breakout) — split
145
146
  * @param {string} className extra classes on the section
147
+ * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
148
+ * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
146
149
  */
147
150
  export default function SectionHero({
148
151
  variant = 'media',
149
152
  theme,
153
+ background,
150
154
  fullBleed = false,
151
155
  media,
152
156
  overlayOpacity = 0,
@@ -189,7 +193,7 @@ export default function SectionHero({
189
193
  /* `label` = alias of `eyebrow` (2026-08-27) */
190
194
  const eb = eyebrow ?? label
191
195
  const [themeRef, themeStamp] = useSectionTheme(theme)
192
- const themed = theme ? 'bg-surface-primary' : ''
196
+ const themed = surfaceClass(background, theme ? 'primary' : 'none')
193
197
 
194
198
  if (variant === 'split') {
195
199
  const mediaFirst = align !== 'right'
@@ -1,4 +1,5 @@
1
1
  import { useId, useState } from 'react'
2
+ import { surfaceClass } from './sectionSurface.js'
2
3
  import Input from '../atoms/Input.jsx'
3
4
  import Button from '../atoms/Button.jsx'
4
5
  import SectionText from '../molecules/SectionText.jsx'
@@ -44,10 +45,13 @@ import { minHeightClass } from './sectionHeights.js'
44
45
  * @param {string} inputId id override for the email input (default useId-generated)
45
46
  * @param {object} slotClass · slotStyle per-slot class / style on the SectionText (reveal seam)
46
47
  * @param {string} className extra classes on the section
48
+ * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
49
+ * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
47
50
  */
48
51
  export default function SectionNewsletter({
49
52
  height = '60',
50
53
  theme,
54
+ background,
51
55
  eyebrow,
52
56
  label,
53
57
  headline,
@@ -99,7 +103,7 @@ export default function SectionNewsletter({
99
103
  id={id}
100
104
  ref={themeRef}
101
105
  data-theme={themeStamp}
102
- className={`kol-section-newsletter w-full flex flex-col justify-center py-24 ${theme ? 'bg-surface-primary text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
106
+ className={`kol-section-newsletter w-full flex flex-col justify-center py-24 ${surfaceClass(background, theme ? 'primary' : 'none')} ${theme ? 'text-auto' : ''} ${minHeightClass(height)} ${className}`.replace(/\s+/g, ' ').trim()}
103
107
  >
104
108
  {/* the family's ONE cap — the shell's --kol-container-max ladder — and
105
109
  * inside it the lede's MEASURE on a wrapper (SectionNewsletterForm,
@@ -1,4 +1,5 @@
1
1
  import SectionText from '../molecules/SectionText.jsx'
2
+ import { surfaceClass } from './sectionSurface.js'
2
3
  import useSectionTheme from '../hooks/useSectionTheme.js'
3
4
  import { minHeightClass } from './sectionHeights.js'
4
5
 
@@ -50,10 +51,13 @@ import { minHeightClass } from './sectionHeights.js'
50
51
  * @param {boolean} fullBleed span the full viewport width
51
52
  * @param {object} slotClass · slotStyle per-slot class / style on the text block (reveal seam)
52
53
  * @param {string} className · innerClassName · columnClassName layout seams
54
+ * @param {'primary'|'secondary'|'tertiary'|'inverse'|'auto'|'none'|string} background the section's surface
55
+ * (SectionBackgroundProp, 2026-08-27) — a named surface, `none`, or a raw utility / token string; default = what it painted before
53
56
  */
54
57
 
55
58
  export default function SectionSplit({
56
59
  theme,
60
+ background,
57
61
  eyebrow,
58
62
  label,
59
63
  headline,
@@ -93,7 +97,7 @@ export default function SectionSplit({
93
97
  <section
94
98
  ref={themeRef}
95
99
  data-theme={themeStamp}
96
- className={`kol-section-split flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 [--kol-section-py:4rem] md:[--kol-section-py:6rem] lg:[--kol-section-py:8rem] ${minHeightClass(height)} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
100
+ className={`kol-section-split flex flex-col justify-center px-5 py-16 md:px-8 md:py-24 lg:px-14 lg:py-32 [--kol-section-py:4rem] md:[--kol-section-py:6rem] lg:[--kol-section-py:8rem] ${minHeightClass(height)} ${surfaceClass(background, 'none')} ${bleed} ${className}`.replace(/\s+/g, ' ').trim()}
97
101
  style={sectionStyle}
98
102
  >
99
103
  {/* ONE cap for the whole section family (user ruling 2026-08-26): the shell's
@@ -1,6 +1,8 @@
1
1
  import Button from '../atoms/Button.jsx'
2
2
  import Dropdown from '../molecules/Dropdown.jsx'
3
- import SectionLabel from '../atoms/SectionLabel.jsx'
3
+ import Divider from '../atoms/Divider.jsx'
4
+ import IconFrame from '../atoms/IconFrame.jsx'
5
+ import LabeledControl from '../molecules/LabeledControl.jsx'
4
6
  import ToggleSwitch from '../atoms/ToggleSwitch.jsx'
5
7
  import ShellDrawer from '../molecules/ShellDrawer.jsx'
6
8
  import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
@@ -35,8 +37,8 @@ import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
35
37
  * @param {'drawer'|'overlay'} variant presentation
36
38
  * @param {number|string} width panel width (default 380)
37
39
  * @param {string} title header line 1
38
- * @param {string} subtitle header line 2 the context, e.g. the bucket label
39
- * @param {string} intro the one-line note under the header
40
+ * @param {string} subtitle header line 2 (the approved drawer passes none)
41
+ * @param {string} intro a note under the header (the approved drawer passes none)
40
42
  * @param {Function} onClose × · scrim · Escape
41
43
  * @param {ReactNode} footer the foot slot — `SettingsFooter` is the default shape
42
44
  * @param {ReactNode} children the sections
@@ -90,55 +92,53 @@ export default function SettingsPanel({
90
92
  }
91
93
 
92
94
  return (
93
- <ShellDrawer open={open} onClose={onClose} side="right" width={width} header={header} className={className}>
94
- <div className="flex flex-col gap-4">{body}</div>
95
+ /* THE APPROVED DRAWER (SettingsPanelApproved, kol-r2b2 2026-08-27 — user: "LOCK
96
+ THIS"): no edge, no shadow, a Divider under the header, the sections. */
97
+ <ShellDrawer open={open} onClose={onClose} side="right" width={width} header={header} className={className} edge={false} shadow={false}>
98
+ <div className="flex flex-col gap-4"><Divider />{body}</div>
95
99
  </ShellDrawer>
96
100
  )
97
101
  }
98
102
 
99
103
  /**
100
- * SettingsRow — label + hint on the left, the control on the right. A GRID,
101
- * not a flex pair: the source's `justify-between` let a long hint wrap UNDER
102
- * the control and parked the control at the top of a two-line hint. Here the
103
- * text column is `minmax(0, 1fr)` so it wraps inside itself, and the control
104
- * centres on the row.
104
+ * SettingsRow — a `LabeledControl inline` (SettingsPanelApproved, 2026-08-27):
105
+ * uppercase label (`kol-helper-10` tracked, meta ink) in a 160px column, the
106
+ * control fills the rest. A switch sits at the far right (`align="end"`, the
107
+ * default); a dropdown fills the row (`align="fill"`). No hint sentences on the
108
+ * page `hint` rides the control's `title`.
105
109
  */
106
- export function SettingsRow({ label, hint, children }) {
110
+ export function SettingsRow({ label, hint, align = 'end', labelWidth = 160, children }) {
107
111
  return (
108
- <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-6 py-1">
109
- <div className="flex min-w-0 flex-col">
110
- <span className="kol-mono-12 text-emphasis">{label}</span>
111
- {hint && <span className="kol-mono-12 text-meta">{hint}</span>}
112
- </div>
113
- <div className="shrink-0">{children}</div>
114
- </div>
112
+ <LabeledControl inline label={typeof label === 'string' ? label.toUpperCase() : label} labelWidth={labelWidth}>
113
+ <span title={hint} className={`inline-flex w-full ${align === 'fill' ? '' : 'justify-end'}`.trim()}>{children}</span>
114
+ </LabeledControl>
115
115
  )
116
116
  }
117
117
 
118
118
  /** SettingsSwitch — the row's on/off control: the DS ToggleSwitch, bare, sm.
119
119
  * `disabledHint` rides `title` so a switch that cannot act says why. */
120
- export function SettingsSwitch({ on = false, onChange, disabled = false, disabledHint, label }) {
120
+ export function SettingsSwitch({ on = false, onChange, disabled = false, disabledHint, label, title }) {
121
121
  return (
122
122
  <ToggleSwitch
123
123
  size="sm"
124
124
  checked={on}
125
125
  onChange={onChange}
126
126
  disabled={disabled}
127
- title={disabled ? disabledHint : undefined}
127
+ title={title ?? (disabled ? disabledHint : undefined)}
128
128
  aria-label={label}
129
129
  />
130
130
  )
131
131
  }
132
132
 
133
- /** SettingsSection — a section of the panel: the estate's section label (the DS
134
- * `SectionLabel`, sm SettingsPanelEyebrowAndDropdowns, user 2026-08-27: "it's
135
- * literally SECTION LABEL, SUPER COMMON PATTERN") over its rows.
136
- * @param {string} label · @param {boolean} divided a hairline above (between sections) */
137
- export function SettingsSection({ label, divided = false, children, className = '' }) {
133
+ /** SettingsSection — a section of the panel (SettingsPanelApproved, 2026-08-27):
134
+ * the EYEBROW (`kol-eyebrow text-fg-80`) standing apart from the rows (gap-3),
135
+ * the rows in their own stack `rowGap` 1 for switch rows (24 tall already),
136
+ * 2 for dropdown rows. `divided` = a hairline above (between sections). */
137
+ export function SettingsSection({ label, divided = false, rowGap = 2, children, className = '' }) {
138
138
  return (
139
139
  <div className={`flex flex-col gap-3 ${divided ? 'kol-section--divided' : ''} ${className}`.replace(/\s+/g, ' ').trim()}>
140
- {label && <SectionLabel text={label} size="sm" />}
141
- {children}
140
+ {label && <p className="kol-eyebrow text-fg-80">{label}</p>}
141
+ <div className={`flex flex-col ${rowGap === 1 ? 'gap-1' : 'gap-2'}`}>{children}</div>
142
142
  </div>
143
143
  )
144
144
  }
@@ -149,7 +149,7 @@ export function SettingsSection({ label, divided = false, children, className =
149
149
  * width is the call site's (`className="w-40"`). */
150
150
  export function SettingsChoice({ options = [], value, onChange, ariaLabel, className = '' }) {
151
151
  const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
152
- return <Dropdown size="sm" variant="primary" value={value} onChange={onChange} options={opts} className={className} aria-label={ariaLabel} />
152
+ return <Dropdown size="sm" variant="primary" value={value} onChange={onChange} options={opts} className={`w-full ${className}`.trim()} aria-label={ariaLabel} />
153
153
  }
154
154
 
155
155
  /* THE CONTROL CHIP (SettingsPanelCompliance, user 2026-08-27: "WROOOONG" on the
@@ -160,6 +160,24 @@ export function SettingsChoice({ options = [], value, onChange, ariaLabel, class
160
160
  export const CHIP_CLS = 'kol-control kol-control-sm kol-mono-12'
161
161
  export const chipCls = (on) => `${CHIP_CLS} ${on ? 'kol-control--filled' : 'text-meta hover:text-emphasis'}`
162
162
 
163
+ /**
164
+ * SettingsMulti — a many-of-N control as ONE Dropdown (SettingsPanelApproved,
165
+ * 2026-08-27 — the kinds row): the trigger reads `N of M <noun>`, every entry
166
+ * toggles and `✓` marks the ones that are on. No grouping.
167
+ * @param {Array} options [{ value, label }]
168
+ * @param {Array|Set} selected
169
+ * @param {Function} onToggle (value) => void
170
+ * @param {string} noun the trigger's noun (default 'kinds')
171
+ */
172
+ export function SettingsMulti({ options = [], selected = [], onToggle, noun = 'kinds', className = '' }) {
173
+ const on = selected instanceof Set ? selected : new Set(selected)
174
+ const opts = [
175
+ { value: '__summary', label: `${on.size} of ${options.length} ${noun}` },
176
+ ...options.map((o) => ({ value: o.value, label: `${on.has(o.value) ? '✓ ' : ''}${o.label ?? String(o.value)}` })),
177
+ ]
178
+ return <Dropdown size="sm" variant="primary" value="__summary" options={opts} onChange={(v) => (v === '__summary' ? null : onToggle?.(v))} className={`w-full ${className}`.trim()} />
179
+ }
180
+
163
181
  /**
164
182
  * SettingsChipRow — a wrap of toggle chips with optional counts (an
165
183
  * allow-list: every chip sets a default, never a gate). `allChip` puts an
@@ -198,12 +216,16 @@ export function SettingsChipRow({ options = [], selected = [], onToggle, allChip
198
216
  )
199
217
  }
200
218
 
201
- /** SettingsFooter — the state word and the reset action. */
202
- export function SettingsFooter({ customised = false, onReset, resetLabel = 'Reset to defaults' }) {
219
+ /** SettingsFooter — a Divider, then one `IconFrame refresh` (primary · sm) at the
220
+ * right = reset to defaults (SettingsPanelApproved, 2026-08-27). No status word,
221
+ * no text button. `customised` is accepted for compatibility and unused. */
222
+ export function SettingsFooter({ onReset, resetLabel = 'Reset to defaults' }) {
203
223
  return (
204
- <div className="flex items-center justify-between">
205
- <span className="kol-mono-12 text-meta">{customised ? 'customised' : 'defaults'}</span>
206
- {onReset && <Button variant="ghost" size="sm" onClick={onReset}>{resetLabel}</Button>}
224
+ <div className="flex flex-col gap-4">
225
+ <Divider />
226
+ <div className="flex justify-end">
227
+ {onReset && <IconFrame name="refresh" variant="primary" size="sm" onClick={onReset} title={resetLabel} aria-label={resetLabel} />}
228
+ </div>
207
229
  </div>
208
230
  )
209
231
  }
@@ -0,0 +1,17 @@
1
+ /* ONE surface prop across the section family (SectionBackgroundProp, kol-website
2
+ * 2026-08-27 — user: "ALL section family should pass bg as props"). A named
3
+ * surface, `none`, or a raw utility / token string for the odd case
4
+ * (`'bg-fg-absolute-16'`). Each section's default is what it painted before,
5
+ * so nothing moves on the bump. `theme` keeps scoping ink; this is the paint. */
6
+ export const SURFACES = {
7
+ primary: 'bg-surface-primary',
8
+ secondary: 'bg-surface-secondary',
9
+ tertiary: 'bg-surface-tertiary',
10
+ inverse: 'bg-surface-inverse',
11
+ auto: 'bg-auto',
12
+ none: '',
13
+ }
14
+ export const surfaceClass = (background, fallback = 'none') => {
15
+ const key = background ?? fallback
16
+ return key in SURFACES ? SURFACES[key] : key
17
+ }