@kolkrabbi/kol-component 0.95.0 → 0.97.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.95.0",
3
+ "version": "0.97.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",
@@ -30,7 +30,7 @@
30
30
  "react-router-dom": "^6.0.0 || ^7.0.0"
31
31
  },
32
32
  "devDependencies": {
33
- "@kolkrabbi/kol-icons": "^0.18.0"
33
+ "@kolkrabbi/kol-icons": "^0.19.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
package/src/index.js CHANGED
@@ -140,6 +140,7 @@ export { default as MediaViewer } from './organisms/MediaViewer.jsx'
140
140
  export { default as SettingsPanel, SettingsRow, SettingsSwitch, SettingsChoice, 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
+ export { default as ColumnBrowser } from './organisms/ColumnBrowser.jsx'
143
144
  export { default as RecordManager } from './organisms/RecordManager.jsx'
144
145
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
145
146
  export { default as Table } from './organisms/Table.jsx'
@@ -40,7 +40,10 @@ const RATIOS = {
40
40
  /* ruled box values per variant, verbatim from the §3 reference cards —
41
41
  * paddings in tokens: pad-card-sm 12 · pad-card-md 16 · pad-card-lg 24 */
42
42
  const BOX = {
43
- default: { layout: 'stack', border: 'var(--kol-fg-12)', bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
43
+ /* no border (ColumnBrowser round, user 2026-08-27: "I don't like the border, I feel
44
+ * like I've said that before" — the ListingCardThumbBorder ruling): the selected
45
+ * state reads from the checked ToggleCheckbox, not a fg-64 border */
46
+ default: { layout: 'stack', border: null, bg: 'var(--kol-fg-02)', pad: 'var(--kol-pad-card-sm)' },
44
47
  catalog: { layout: 'fill-card', border: 'var(--kol-fg-04)', bg: 'var(--kol-fg-04)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, plateBg: 'var(--kol-surface-primary)' },
45
48
  /* flip: PrintGridCard's 3D turn on `isFlipped` → `selected` (ContentRowsAndPrintCard, 2026-08-27) */
46
49
  print: { layout: 'fill-card', border: null, bg: 'var(--kol-surface-secondary)', pad: 'var(--kol-pad-card-sm) var(--kol-pad-card-md)', plateTop: true, flip: true },
@@ -70,8 +70,9 @@ import AssetPlaceholder from '../utilities/AssetPlaceholder.jsx'
70
70
  * @param {ReactNode} children the real media
71
71
  */
72
72
  const FIT = {
73
- cover: '[&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover',
74
- natural: '[&>img]:h-full [&>img]:w-full [&>img]:object-contain [&>video]:h-full [&>video]:w-full [&>video]:object-contain',
73
+ /* a consumer WRAPPER div fills the frame as an img/video does (ColumnBrowser round, 2026-08-27 — kol-r2b2's wrapped thumb fell back to the image's intrinsic size) */
74
+ cover: '[&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>video]:h-full [&>video]:w-full [&>video]:object-cover [&>div]:h-full [&>div]:w-full',
75
+ natural: '[&>img]:h-full [&>img]:w-full [&>img]:object-contain [&>video]:h-full [&>video]:w-full [&>video]:object-contain [&>div]:h-full [&>div]:w-full',
75
76
  compact: 'grid place-items-center [&>img]:max-h-[70%] [&>img]:max-w-[70%] [&>img]:object-contain',
76
77
  }
77
78
 
@@ -25,10 +25,10 @@ import { Icon } from '@kolkrabbi/kol-icons'
25
25
  * author strings in their final case at the call site.
26
26
  *
27
27
  * @param {ReactNode} title header heading (kol-helper-16)
28
- * @param {string} icon Icon name for the header (16px); also the 96px no-visual fallback
29
- * @param {string|ReactNode} visual image URL, `.svg` mask URL, or inline node
28
+ * @param {string} icon Icon name for the header (16px)
29
+ * @param {string|ReactNode} visual image URL, `.svg` mask URL, or inline node; NONE = a text-only tile (title + description, min-h 180, the .feature-card frame/hover)
30
30
  * @param {ReactNode} description footer line (kol-mono-12, muted)
31
- * @param {string} backgroundColor card background utility class
31
+ * @param {string} backgroundColor card background utility class (default: bg-surface-primary with a visual, transparent text-only)
32
32
  * @param {string} href link target; `http*`/`mailto` → new tab, else plain same-tab anchor
33
33
  * @param {Function} onNavigate (event) => void — click seam on the same-tab anchor (SPA intercept)
34
34
  * @param {'auto'|'9/6'|'10/6'|'16/9'|'1/1'} imageAspectRatio aspect class on the visual middle
@@ -39,7 +39,7 @@ export default function SectionCardItem({
39
39
  icon,
40
40
  visual,
41
41
  description,
42
- backgroundColor = 'bg-surface-primary',
42
+ backgroundColor,
43
43
  href,
44
44
  onNavigate,
45
45
  imageAspectRatio = 'auto',
@@ -48,6 +48,11 @@ export default function SectionCardItem({
48
48
  style,
49
49
  }) {
50
50
  const isSvgUrl = typeof visual === 'string' && visual.endsWith('.svg')
51
+ /* TEXT-ONLY (FoundrySpecimenSections, 2026-08-27): no `visual` = a title +
52
+ * description tile — the frame and hover of kol-website's .feature-card, no
53
+ * 96px icon fallback. Hover only, never a persistent selected state. */
54
+ const textOnly = visual == null
55
+ const bg = backgroundColor ?? (textOnly ? 'bg-transparent' : 'bg-surface-primary')
51
56
 
52
57
  const aspectClasses = {
53
58
  'auto': '',
@@ -58,7 +63,15 @@ export default function SectionCardItem({
58
63
  }
59
64
  const aspectClass = aspectClasses[imageAspectRatio] || ''
60
65
 
61
- const content = (
66
+ const content = textOnly ? (
67
+ <>
68
+ <div className="w-full flex items-start justify-between gap-2">
69
+ <h3 className="kol-helper-16">{title}</h3>
70
+ {icon && <Icon name={icon} size={16} className="shrink-0" />}
71
+ </div>
72
+ <p className="kol-mono-12 text-fg-48">{description}</p>
73
+ </>
74
+ ) : (
62
75
  <>
63
76
  <div className="w-full flex items-center justify-between gap-2">
64
77
  <h3 className="kol-helper-16">{title}</h3>
@@ -106,7 +119,9 @@ export default function SectionCardItem({
106
119
  </>
107
120
  )
108
121
 
109
- const baseClasses = `kol-card-feature w-full flex-1 h-[304px] md:h-72 p-4 md:p-6 gap-4 ${backgroundColor} rounded border border-fg-08 flex flex-col justify-between items-start overflow-hidden ${className}`.trim()
122
+ const baseClasses = textOnly
123
+ ? `kol-card-feature kol-card-feature--text w-full flex-1 min-h-[180px] p-4 md:p-5 lg:p-6 gap-2 ${bg} rounded border border-fg-08 flex flex-col justify-between items-start overflow-hidden ${className}`
124
+ : `kol-card-feature w-full flex-1 h-[304px] md:h-72 p-4 md:p-6 gap-4 ${bg} rounded border border-fg-08 flex flex-col justify-between items-start overflow-hidden ${className}`.trim()
110
125
 
111
126
  if (href) {
112
127
  const isExternal = href.startsWith('http') || href.startsWith('mailto')
@@ -0,0 +1,288 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+
4
+ /**
5
+ * ColumnBrowser — Finder-style Miller columns over a flat key space (kol-r2b2's
6
+ * `src/ColumnBrowser.jsx`, built and ruled there first — user 2026-08-27: "make
7
+ * it locally first, then ship it" — and shipped verbatim; ticket ColumnBrowser).
8
+ * The listing surface's FOLDER view: it replaces the folder rows above the
9
+ * files, it is not a card layout.
10
+ *
11
+ * One column per path segment of `prefix`: column 0 is the root level, column
12
+ * k the contents of the k-th folder on the path. Picking a folder sets the
13
+ * prefix to it (and so truncates deeper columns); picking a file highlights it
14
+ * and opens a PREVIEW column on the right — the image and its facts, as Finder
15
+ * does.
16
+ *
17
+ * Keyboard, Finder's: ↑/↓ move within the column (a folder opens its column as
18
+ * you land on it, a file previews), → steps into the open folder's column,
19
+ * ← steps back to the parent column. Space = Quick Look: `onQuickLook({ files,
20
+ * index })` over the column's files; while it is open ↑/↓ step the file off
21
+ * `window` (the overlay holds focus) and space closes — the opening keystroke
22
+ * bubbles from here and is ignored.
23
+ *
24
+ * Rows wear the DS Table's cell metrics (12px 16px, mono 12, an oq-08 hairline
25
+ * between rows, none after the last). Selected and cursor rows draw the HOVER
26
+ * fill (`bg-fg-04`) — user ruling: "make hover state the selected state".
27
+ * Height is fixed at 528px = 12 rows × 44px (user ruling); columns scroll.
28
+ *
29
+ * The app-owned bits are SEAMS with working defaults: `urlOf(o)` (the preview
30
+ * image — no URL, no image), `kindOf(o)` (image / video / audio / file from
31
+ * `contentType`), `kindLabel`, `formatSize`, `partition(objects, level)`.
32
+ *
33
+ * @param {Array<{key: string, size?: number, uploaded?: string, contentType?: string}>} objects the flat key space
34
+ * @param {string} prefix the open folder path ('' = root, 'a/b/' = two deep)
35
+ * @param {Function} onPrefix (prefix) => void
36
+ * @param {{files: object[], index: number}|null} quickLook the open Quick Look, or null
37
+ * @param {Function} onQuickLook (quickLook|null) => void — space opens / closes, ↑/↓ step while open
38
+ * @param {Function} urlOf (o) => string — the object's public URL for the preview image
39
+ * @param {Function} kindOf (o) => 'image' | 'video' | 'audio' | string
40
+ * @param {Object} kindLabel kind → label shown when there is no visual preview
41
+ * @param {Function} formatSize (bytes) => string
42
+ * @param {Function} partition (objects, level) => { folders: string[], files: object[] }
43
+ * @param {string} className extra classes on the browser
44
+ */
45
+
46
+ const defaultKindOf = (o) => {
47
+ const ct = o.contentType || ''
48
+ if (ct.startsWith('image/')) return 'image'
49
+ if (ct.startsWith('video/')) return 'video'
50
+ if (ct.startsWith('audio/')) return 'audio'
51
+ return 'file'
52
+ }
53
+ const DEFAULT_KIND_LABEL = { image: 'image', video: 'video', audio: 'audio', file: 'file' }
54
+ const defaultFormatSize = (bytes) => {
55
+ if (bytes == null) return '—'
56
+ if (bytes < 1024) return `${bytes} B`
57
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
58
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
59
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
60
+ }
61
+ /* kol-r2b2's partition: the next path segment is a folder, the rest are files */
62
+ const defaultPartition = (objects, prefix) => {
63
+ const folderSet = new Set()
64
+ const files = []
65
+ for (const o of objects) {
66
+ const rel = prefix ? o.key.slice(prefix.length) : o.key
67
+ const slash = rel.indexOf('/')
68
+ if (slash !== -1) folderSet.add(rel.slice(0, slash + 1))
69
+ else files.push({ ...o, displayKey: o.displayKey ?? rel })
70
+ }
71
+ return { folders: [...folderSet].sort(), files }
72
+ }
73
+ const isImage = (o) => (o.contentType || '').startsWith('image/')
74
+
75
+ /* kol-icons has no `audio` glyph yet — audio rows wear `file` until it does */
76
+ const COL_ICON = { image: 'image', video: 'video', audio: 'file' }
77
+
78
+ function Row({ icon, label, active, cursor = false, trailing, onClick, muted = false }) {
79
+ return (
80
+ <li
81
+ /* Row metrics are the DS Table's (kol-components-organisms.css .kol-table-cell-*):
82
+ * 12px 16px padding, mono 12, an oq-08 hairline between rows, none after the last. */
83
+ /* `cursor` = the keyboard row, drawn with the hover fill so ↑/↓ always shows where you are. */
84
+ className={`kol-column-browser-row flex items-center gap-2 px-4 py-3 border-b last:border-b-0 cursor-pointer transition-colors ${
85
+ active || cursor ? 'bg-fg-04 text-fg-default' : muted ? 'text-fg-48 hover:bg-fg-04' : 'text-fg-default hover:bg-fg-04'
86
+ }`}
87
+ style={{ borderColor: 'var(--kol-oq-08)' }}
88
+ onClick={onClick}
89
+ >
90
+ <span className="w-5 shrink-0 flex items-center justify-center text-fg-48">
91
+ <Icon name={icon} size={14} />
92
+ </span>
93
+ <span className="kol-mono-12 flex-1 truncate">{label}</span>
94
+ {trailing}
95
+ </li>
96
+ )
97
+ }
98
+
99
+ function Preview({ o, urlOf, kindOf, kindLabel, formatSize }) {
100
+ // Pixel size comes from the loaded image itself — the bucket stores none.
101
+ const [dims, setDims] = useState(null)
102
+ const src = isImage(o) ? urlOf?.(o) : null
103
+ const facts = [
104
+ ['Kind', kindLabel[kindOf(o)] || 'file'],
105
+ ['Type', o.contentType || '—'],
106
+ ['Size', formatSize(o.size)],
107
+ ...(src ? [['Dimensions', dims ? `${dims.w} × ${dims.h} px` : '…']] : []),
108
+ ['Date', o.uploaded ? new Date(o.uploaded).toISOString().slice(0, 10) : '—'],
109
+ ]
110
+ return (
111
+ <div className="kol-column-browser-preview w-[320px] shrink-0 overflow-y-auto p-4 flex flex-col gap-4">
112
+ <div className="w-full aspect-square bg-fg-04 rounded flex items-center justify-center overflow-hidden">
113
+ {src ? (
114
+ <img
115
+ src={src}
116
+ alt=""
117
+ className="max-w-full max-h-full object-contain"
118
+ loading="lazy"
119
+ onLoad={(e) => setDims({ w: e.target.naturalWidth, h: e.target.naturalHeight })}
120
+ />
121
+ ) : (
122
+ <span className="kol-mono-12 text-fg-48">{kindLabel[kindOf(o)] || 'file'}</span>
123
+ )}
124
+ </div>
125
+ <p className="kol-mono-12 text-fg-default break-all">{o.displayKey ?? o.key}</p>
126
+ <dl className="flex flex-col gap-1">
127
+ {facts.map(([k, v]) => (
128
+ <div key={k} className="flex justify-between gap-4 kol-mono-12">
129
+ <dt className="text-fg-48">{k}</dt>
130
+ <dd className="text-fg-default text-right break-all">{v}</dd>
131
+ </div>
132
+ ))}
133
+ </dl>
134
+ </div>
135
+ )
136
+ }
137
+
138
+ export default function ColumnBrowser({
139
+ objects = [],
140
+ prefix = '',
141
+ onPrefix = () => {},
142
+ quickLook = null,
143
+ onQuickLook,
144
+ urlOf,
145
+ kindOf = defaultKindOf,
146
+ kindLabel = DEFAULT_KIND_LABEL,
147
+ formatSize = defaultFormatSize,
148
+ partition = defaultPartition,
149
+ className = '',
150
+ }) {
151
+ const [picked, setPicked] = useState(null)
152
+ // Keyboard cursor: which column, which row. Clicks keep it in sync.
153
+ const [cursor, setCursor] = useState({ col: 0, idx: 0 })
154
+ const rootRef = useRef(null)
155
+
156
+ const levelsOf = (pfx) => {
157
+ const out = ['']
158
+ if (pfx) {
159
+ const segs = pfx.replace(/\/$/, '').split('/')
160
+ segs.forEach((_, i) => out.push(segs.slice(0, i + 1).join('/') + '/'))
161
+ }
162
+ return out
163
+ }
164
+ const itemsAt = (level) => {
165
+ const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
166
+ return [...folders.map((f) => ({ type: 'folder', name: f })), ...files.map((o) => ({ type: 'file', o }))]
167
+ }
168
+ const land = (level, item) => {
169
+ if (!item) return
170
+ if (item.type === 'folder') { setPicked(null); onPrefix(level + item.name) }
171
+ else setPicked(item.o)
172
+ }
173
+
174
+ const onKeyDown = (e) => {
175
+ // Space = Quick Look over the picked column's files, starting at the picked one.
176
+ if (e.key === ' ' && picked) {
177
+ e.preventDefault()
178
+ const siblings = itemsAt(levelsOf(prefix)[cursor.col] ?? '').filter((it) => it.type === 'file').map((it) => it.o)
179
+ onQuickLook?.({ files: siblings, index: Math.max(0, siblings.findIndex((o) => o.key === picked.key)) })
180
+ return
181
+ }
182
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) return
183
+ e.preventDefault()
184
+ const lv = levelsOf(prefix)
185
+ const col = Math.min(cursor.col, lv.length - 1)
186
+ const items = itemsAt(lv[col])
187
+ if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
188
+ const idx = Math.max(0, Math.min(items.length - 1, cursor.idx + (e.key === 'ArrowDown' ? 1 : -1)))
189
+ setCursor({ col, idx })
190
+ land(lv[col], items[idx])
191
+ } else if (e.key === 'ArrowRight') {
192
+ // Into the open folder's column, first row.
193
+ if (lv[col + 1]) {
194
+ const next = itemsAt(lv[col + 1])
195
+ setCursor({ col: col + 1, idx: 0 })
196
+ land(lv[col + 1], next[0])
197
+ }
198
+ } else if (col > 0) {
199
+ // Back to the parent column, on the folder we came out of.
200
+ const parentItems = itemsAt(lv[col - 1])
201
+ const opened = lv[col].slice(lv[col - 1].length)
202
+ const idx = Math.max(0, parentItems.findIndex((it) => it.type === 'folder' && it.name === opened))
203
+ setCursor({ col: col - 1, idx })
204
+ setPicked(null)
205
+ onPrefix(lv[col])
206
+ }
207
+ }
208
+ // While Quick Look is open the overlay holds focus, so the same keys are read
209
+ // off window: ↑/↓ step the picked file (overlay follows), space closes. The
210
+ // opening keystroke bubbles from this component and is ignored.
211
+ useEffect(() => {
212
+ if (!quickLook) return
213
+ const onKey = (e) => {
214
+ if (rootRef.current?.contains(e.target)) return
215
+ if (e.key === ' ') { e.preventDefault(); onQuickLook?.(null); return }
216
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return
217
+ e.preventDefault()
218
+ const lv = levelsOf(prefix)
219
+ const col = Math.min(cursor.col, lv.length - 1)
220
+ const items = itemsAt(lv[col])
221
+ let idx = cursor.idx
222
+ const step = e.key === 'ArrowDown' ? 1 : -1
223
+ do { idx += step } while (items[idx] && items[idx].type !== 'file')
224
+ if (!items[idx]) return
225
+ setCursor({ col, idx })
226
+ setPicked(items[idx].o)
227
+ const siblings = items.filter((it) => it.type === 'file').map((it) => it.o)
228
+ onQuickLook?.({ files: siblings, index: siblings.findIndex((o) => o.key === items[idx].o.key) })
229
+ }
230
+ window.addEventListener('keydown', onKey)
231
+ return () => window.removeEventListener('keydown', onKey)
232
+ })
233
+
234
+ const levels = levelsOf(prefix) // '' → ['']; 'a/b/' → ['', 'a/', 'a/b/']
235
+ // While Quick Look is open the overlay's index is the truth for the highlight.
236
+ const shown = quickLook ? quickLook.files[quickLook.index] : picked
237
+
238
+ return (
239
+ <div
240
+ ref={rootRef}
241
+ tabIndex={0}
242
+ onKeyDown={onKeyDown}
243
+ // Fixed height — 12 rows of 44px (user ruling 2026-08-27), so the browser never jumps as columns change; each column scrolls.
244
+ className={`kol-column-browser flex overflow-x-auto h-[528px] border rounded outline-none ${className}`.trim()}
245
+ style={{ borderColor: 'var(--kol-oq-08)' }}
246
+ >
247
+ {levels.map((level, k) => {
248
+ const { folders, files } = partition(objects.filter((o) => o.key.startsWith(level)), level)
249
+ const next = levels[k + 1]
250
+ const activeFolder = next ? next.slice(level.length) : null
251
+ return (
252
+ <ul
253
+ key={level || '/'}
254
+ className="kol-column-browser-column w-[260px] shrink-0 overflow-y-auto border-r last:border-r-0"
255
+ style={{ borderColor: 'var(--kol-oq-08)' }}
256
+ >
257
+ {folders.map((f, i) => (
258
+ <Row
259
+ key={f}
260
+ icon="folder"
261
+ label={f.replace(/\/$/, '')}
262
+ active={f === activeFolder}
263
+ cursor={cursor.col === k && cursor.idx === i}
264
+ trailing={<Icon name="chevron-right" size={12} className="text-fg-32" />}
265
+ onClick={() => { setCursor({ col: k, idx: i }); rootRef.current?.focus(); setPicked(null); onPrefix(level + f) }}
266
+ />
267
+ ))}
268
+ {files.map((o, i) => (
269
+ <Row
270
+ key={o.key}
271
+ icon={COL_ICON[kindOf(o)] || 'file'}
272
+ label={o.displayKey ?? o.key}
273
+ muted={shown?.key !== o.key}
274
+ active={shown?.key === o.key}
275
+ cursor={cursor.col === k && cursor.idx === folders.length + i}
276
+ onClick={() => { setCursor({ col: k, idx: folders.length + i }); rootRef.current?.focus(); setPicked(o) }}
277
+ />
278
+ ))}
279
+ {folders.length === 0 && files.length === 0 && (
280
+ <li className="kol-mono-12 text-fg-32 px-4 py-3">empty</li>
281
+ )}
282
+ </ul>
283
+ )
284
+ })}
285
+ {shown && <Preview key={shown.key} o={shown} urlOf={urlOf} kindOf={kindOf} kindLabel={kindLabel} formatSize={formatSize} />}
286
+ </div>
287
+ )
288
+ }
@@ -295,7 +295,8 @@ const ContentFilters = ({
295
295
  * nothing, so it must not wear a button's chrome. The atom exists
296
296
  * for exactly this (lobby ruling 2026-07-30 — "icons only, NO
297
297
  * states"); the span here was the same defect that promoted it. */}
298
- <h2 className="flex items-center gap-2">
298
+ {/* the icon gap matches the specimen header — 16 from md (FoundrySpecimenSections, 2026-08-27) */}
299
+ <h2 className="flex items-center gap-2 md:gap-4">
299
300
  {titleIcon && <IconFrame name={titleIcon} variant="secondary" size="md" />}
300
301
  {/* `pr-4` by rule (ContentFiltersTitleGap, kol-website 2026-08-27): the
301
302
  * divider sat 24px from the title's text edge but 32 from the