@kolkrabbi/kol-styleguide 0.4.0 → 0.5.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,8 +1,8 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-styleguide",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
- "description": "KOL style-guide / brand-guide component set — the visual specimens a brand manual is built from: color anatomy + combination lab, logo construction / clearspace / scaling, mood tiles, type blocks, and asset spec tables. Raided from the apps/brand styleguide. Sits above @kolkrabbi/kol-{theme,icons,component}.",
5
+ "description": "KOL style-guide / brand-guide component set — the visual specimens a brand manual is built from: color anatomy + combination lab, logo construction / clearspace / scaling, mood tiles, type blocks, and asset spec tables. Raided from the apps/brand styleguide. Also the brand tool itself — `Brand` (BrandBook ⇄ BrandAssets) renders a whole brand book from one brand manifest. Sits above @kolkrabbi/kol-{theme,icons,component,framework}.",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "./src/index.js",
@@ -11,16 +11,18 @@
11
11
  ".": "./src/index.js"
12
12
  },
13
13
  "peerDependencies": {
14
- "@kolkrabbi/kol-component": ">=0.70.0",
14
+ "@kolkrabbi/kol-component": ">=0.188.0",
15
+ "@kolkrabbi/kol-framework": ">=0.39.0",
15
16
  "@kolkrabbi/kol-icons": ">=0.18.0",
16
17
  "@kolkrabbi/kol-theme": ">=0.53.0",
17
18
  "react": "^18.3.0 || ^19.0.0",
18
19
  "react-dom": "^18.3.0 || ^19.0.0"
19
20
  },
20
21
  "devDependencies": {
21
- "@kolkrabbi/kol-component": "^0.182.0",
22
- "@kolkrabbi/kol-icons": "^0.25.0",
23
- "@kolkrabbi/kol-theme": "^0.139.0"
22
+ "@kolkrabbi/kol-component": "^0.225.0",
23
+ "@kolkrabbi/kol-framework": "^0.44.0",
24
+ "@kolkrabbi/kol-icons": "^0.27.1",
25
+ "@kolkrabbi/kol-theme": "^0.151.0"
24
26
  },
25
27
  "files": [
26
28
  "src",
package/src/Brand.jsx ADDED
@@ -0,0 +1,42 @@
1
+ import { useState } from 'react'
2
+ import { DocsToc, ViewToggle } from '@kolkrabbi/kol-component'
3
+ import BrandBook from './BrandBook.jsx'
4
+ import BrandAssets from './BrandAssets.jsx'
5
+ import { BRAND_VIEWS, brandToc, scrollToAnchor } from './brandBook.js'
6
+
7
+ /* taxonomy-ok: organism — the brand tool: BrandBook ⇄ BrandAssets under a view switch, with the page's section rail */
8
+
9
+ /**
10
+ * Brand — the whole brand tool: the brand book's two pages over one manifest, BRAND (the identity)
11
+ * and ASSETS (what you download or reproduce), a switch between them, and the page's sections on a
12
+ * rail. ONE component so every app that carries a brand renders the same tool (apps/brand alone,
13
+ * media-shell's Brand tab) — kol-notes' `Notes` shape.
14
+ *
15
+ * The manifest is `@kolkrabbi/kol-brand-template`'s schema; its `book` field carries the copy. The
16
+ * marks come from `Logo` (a component taking `variant`) or `logoSources` (id → raw SVG, which the
17
+ * Logos table also needs for dimensions and downloads — pass it either way).
18
+ *
19
+ * @param {Object} brand the manifest
20
+ * @param {Function} Logo optional `<Logo variant="wordmark" />`
21
+ * @param {Object} logoSources `{ [logoId]: rawSvg }`
22
+ * @param {'brand'|'assets'} view · onViewChange controlled page; or internal
23
+ * @param {string} className classes on the root
24
+ */
25
+ export default function Brand({ brand, Logo, logoSources, view: viewProp, onViewChange, className = '' }) {
26
+ const [viewState, setViewState] = useState('brand')
27
+ const view = viewProp ?? viewState
28
+ const setView = (v) => { if (viewProp === undefined) setViewState(v); onViewChange?.(v) }
29
+ const Page = view === 'assets' ? BrandAssets : BrandBook
30
+
31
+ return (
32
+ /* `xl:pr-32` — the rail is `position: fixed` on the right edge from xl up, and the pages' grids
33
+ run to the frame's edge; the padding keeps the last column out from under it */
34
+ <div className={`xl:pr-32 ${className}`.trim()}>
35
+ <div className="kol-page flex justify-end pt-6 pb-0">
36
+ <ViewToggle variant="text" options={BRAND_VIEWS} viewMode={view} onViewChange={setView} />
37
+ </div>
38
+ <Page brand={brand} Logo={Logo} logoSources={logoSources} />
39
+ <DocsToc key={view} variant="rail" toc={brandToc(brand, view)} onNavigate={scrollToAnchor} className="hidden xl:flex" />
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,152 @@
1
+ import { useState } from 'react'
2
+ import { ContentCollection, FullscreenOverlay, Table } from '@kolkrabbi/kol-component'
3
+ import PageSection from '@kolkrabbi/kol-framework/src/PageSection.jsx'
4
+ import AssetTable from './AssetTable.jsx'
5
+ import AssetCard from './AssetCard.jsx'
6
+ import { ProfileAvatar } from './SocialMocks.jsx'
7
+ import { BusinessCardBack, BusinessCardFront, EmailSignature, Envelope, Letterhead } from './StationeryMocks.jsx'
8
+ import BrandMark from './BrandMark.jsx'
9
+ import { Prose, SectionIndex } from './BrandBook.jsx'
10
+ import {
11
+ brandSections, brandInfo, STATIONERY_MARKS, BRANDED_ASSET_ROWS, svgDims, downloadRecolored,
12
+ } from './brandBook.js'
13
+
14
+ /* taxonomy-ok: organism — the brand book's assets page: PageSection chapters over AssetTable, Table, AssetCard and the stationery / social mocks */
15
+
16
+ /* The pair the Logos table's Color column swings between. */
17
+ const INK_TOKENS = { ink: '--kol-surface-on-primary', surface: '--kol-surface-primary' }
18
+
19
+ const TokenName = ({ children }) => (
20
+ <code className="kol-helper-12 text-emphasis">{children}</code>
21
+ )
22
+
23
+ const brandedAssetCols = [
24
+ { accessor: 'item', header: 'Item', render: (r) => <TokenName>{r.item}</TokenName> },
25
+ { accessor: 'aspect', header: 'Aspect' },
26
+ { accessor: 'surface', header: 'Surface' },
27
+ { accessor: 'status', header: 'Status', render: (r) => <span className="kol-helper-12 text-meta uppercase tracking-widest">{r.status}</span> },
28
+ { accessor: 'note', header: 'Note' },
29
+ ]
30
+
31
+ /**
32
+ * BrandAssets — the ASSETS page of the brand tool: what you download or reproduce. Logos (the
33
+ * mark table, with the ink toggle, zoom and recoloured download), the branded-asset register,
34
+ * stationery, social post sizes and profile avatars.
35
+ *
36
+ * kol-olina's apps/brand `pages/assets/*`, carried class-for-class; the client's marks, contact
37
+ * details, templates and avatar grounds come from the manifest (`logos`, `meta`, `stationery.marks`,
38
+ * `social`). Sections with nothing to show are left out — see `brandSections`.
39
+ *
40
+ * @param {Object} brand a brand manifest (`@kolkrabbi/kol-brand-template` schema)
41
+ * @param {Function} Logo optional mark component, `<Logo variant="wordmark" />`
42
+ * @param {Object} logoSources `{ [logoId]: rawSvg }` — the table's dimensions and downloads, and
43
+ * the marks themselves when no `Logo` is given
44
+ */
45
+ export default function BrandAssets({ brand, Logo, logoSources }) {
46
+ const sections = brandSections(brand, 'assets')
47
+ const mark = (id, props) => <BrandMark id={id} Logo={Logo} sources={logoSources} {...props} />
48
+ const index = sections.filter((s) => s.nav)
49
+ const marks = { ...STATIONERY_MARKS, ...(brand?.stationery?.marks ?? {}) }
50
+ const info = brandInfo(brand)
51
+ const [zoom, setZoom] = useState(null)
52
+
53
+ const logoRows = (brand?.logos ?? []).map((logo) => {
54
+ const raw = logoSources?.[logo.id]
55
+ return {
56
+ id: logo.id,
57
+ name: logo.id.replace(/-/g, ' / '),
58
+ preview: mark(logo.id),
59
+ previewWidth: logo.previewWidth ?? 48,
60
+ path: logo.file?.replace(/^\.\//, ''),
61
+ format: 'SVG',
62
+ dimensions: svgDims(raw),
63
+ raw,
64
+ onDownload: raw ? (row, ink) => downloadRecolored(row.raw, ink, `${logo.id}.svg`) : undefined,
65
+ }
66
+ })
67
+
68
+ return (
69
+ <>
70
+ {sections.map((s) => {
71
+ const head = { id: s.id, label: s.label, title: s.title, body: s.lede }
72
+ switch (s.key) {
73
+ case 'assetsOverview': return (
74
+ <PageSection key={s.key} {...head}>
75
+ <SectionIndex sections={index} />
76
+ </PageSection>
77
+ )
78
+ case 'logos': return (
79
+ <PageSection key={s.key} {...head}>
80
+ <AssetTable
81
+ caption="Logos"
82
+ rows={logoRows}
83
+ inkTokens={INK_TOKENS}
84
+ onPreviewZoom={(row, ink) => setZoom({ row, ink })}
85
+ />
86
+ <FullscreenOverlay open={!!zoom} onClose={() => setZoom(null)}>
87
+ {zoom && (
88
+ <span
89
+ className="inline-flex items-center p-12"
90
+ style={{ color: zoom.ink, width: 'min(60vw, 480px)' }}
91
+ >
92
+ {zoom.row.preview}
93
+ </span>
94
+ )}
95
+ </FullscreenOverlay>
96
+ </PageSection>
97
+ )
98
+ case 'branded': return (
99
+ <PageSection key={s.key} {...head}>
100
+ <Table caption="Branded assets" columns={brandedAssetCols} rows={BRANDED_ASSET_ROWS} className="mt-8" />
101
+ </PageSection>
102
+ )
103
+ case 'stationery': return (
104
+ <PageSection key={s.key} {...head}>
105
+ <div className="kol-grid mt-8">
106
+ <div className="col-span-2"><AssetCard><BusinessCardFront mark={mark(marks.card, { className: 'max-h-[70%]' })} /></AssetCard></div>
107
+ <div className="col-span-2"><AssetCard><Envelope mark={mark(marks.envelope)} info={info} /></AssetCard></div>
108
+ <div className="col-span-2 flex flex-col gap-3">
109
+ <AssetCard><BusinessCardBack info={info} /></AssetCard>
110
+ <AssetCard><EmailSignature mark={mark(marks.signature)} info={info} /></AssetCard>
111
+ </div>
112
+ <div className="col-span-2"><AssetCard><Letterhead mark={mark(marks.letter, { style: { height: '100%', width: 'auto' } })} info={info} /></AssetCard></div>
113
+ </div>
114
+ </PageSection>
115
+ )
116
+ case 'social': return (
117
+ <PageSection key={s.key} {...head}>
118
+ <ContentCollection cols={3} className="mt-8 items-start">
119
+ {brand.social.templates.map(({ caption, ratio, src }) => (
120
+ <AssetCard key={caption ?? src} caption={caption}>
121
+ <div className="relative overflow-hidden" style={{ aspectRatio: ratio }}>
122
+ <img src={src} alt="" loading="lazy" className="absolute inset-0 w-full h-full object-cover" />
123
+ <div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/60 to-transparent" aria-hidden="true" />
124
+ <div className="absolute bottom-[6%] left-[6%] w-[42%] text-fg-inverse">
125
+ {mark(brand.social.mark ?? 'wordmark', { title: brand?.meta?.name })}
126
+ </div>
127
+ </div>
128
+ </AssetCard>
129
+ ))}
130
+ </ContentCollection>
131
+ </PageSection>
132
+ )
133
+ case 'profile': return (
134
+ <PageSection key={s.key} {...head}>
135
+ <div className="kol-grid mt-8 items-start">
136
+ {brand.social.avatars.map(({ bg, polarity }, i) => (
137
+ <AssetCard key={i}><ProfileAvatar mark={mark(marks.avatar, { className: 'w-1/2' })} bg={bg} polarity={polarity} /></AssetCard>
138
+ ))}
139
+ </div>
140
+ </PageSection>
141
+ )
142
+ default: return (
143
+ <PageSection key={s.key} {...head}>
144
+ <Prose blocks={s.blocks} />
145
+ <Prose blocks={s.after} />
146
+ </PageSection>
147
+ )
148
+ }
149
+ })}
150
+ </>
151
+ )
152
+ }
@@ -0,0 +1,255 @@
1
+ import { ContentCollection } from '@kolkrabbi/kol-component'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import PageHero from '@kolkrabbi/kol-framework/src/PageHero.jsx'
4
+ import PageSection from '@kolkrabbi/kol-framework/src/PageSection.jsx'
5
+ import LogoCard from './LogoCard.jsx'
6
+ import Swatch from './Swatch.jsx'
7
+ import BrandMark from './BrandMark.jsx'
8
+ import { brandSections, scrollToAnchor } from './brandBook.js'
9
+ import { typeSections } from './typeSpecimen.js'
10
+
11
+ /* taxonomy-ok: organism — the brand book's identity page: PageHero + PageSection chapters over LogoCard, Swatch and the type specimens */
12
+
13
+ /**
14
+ * BrandBook — the BRAND page of the brand tool: the identity, one scrolling page of chapters
15
+ * (About · Tone · Look · Logo · Lockups · Color · Typography) under a hero and a chapter index.
16
+ *
17
+ * kol-olina's apps/brand `pages/brand/*`, carried class-for-class; what was that client's copy now
18
+ * comes from the manifest (`brand.book`, `brand.ramps`, `brand.type`, `brand.logos`). Sections with
19
+ * nothing to show for the manifest are left out — see `brandSections`.
20
+ *
21
+ * @param {Object} brand a brand manifest (`@kolkrabbi/kol-brand-template` schema)
22
+ * @param {Function} Logo optional mark component, `<Logo variant="wordmark" />`
23
+ * @param {Object} logoSources `{ [logoId]: rawSvg }` — drawn when no `Logo` is given
24
+ */
25
+ export default function BrandBook({ brand, Logo, logoSources }) {
26
+ const sections = brandSections(brand, 'brand')
27
+ const mark = (id, props) => <BrandMark id={id} Logo={Logo} sources={logoSources} {...props} />
28
+ const index = sections.filter((s) => s.nav)
29
+
30
+ return (
31
+ <>
32
+ {sections.map((s) => {
33
+ switch (s.key) {
34
+ case 'hero': return <PageHero key={s.key} id={s.id} label={s.label} title={s.title} lede={s.lede} />
35
+ case 'overview': return (
36
+ <PageSection key={s.key} id={s.id} label={s.label} title={s.title} body={s.lede}>
37
+ <SectionIndex sections={index} />
38
+ </PageSection>
39
+ )
40
+ case 'logo': return (
41
+ <PageSection key={s.key} id={s.id} label={s.label} title={s.title} body={s.lede}>
42
+ <Prose blocks={s.blocks} />
43
+ <div className="kol-grid mt-12">
44
+ {s.marks.map((id) => <LogoCard key={id} logo={mark(id)} frame={false} />)}
45
+ </div>
46
+ <Prose blocks={s.after} />
47
+ </PageSection>
48
+ )
49
+ case 'lockups': return (
50
+ <PageSection key={s.key} id={s.id} label={s.label} title={s.title} body={s.lede}>
51
+ <Prose blocks={s.blocks} />
52
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-12">
53
+ {s.marks.map((id) => (
54
+ <LogoCard
55
+ key={id}
56
+ logo={mark(id)}
57
+ caption={brand?.logos?.find((l) => l.id === id)?.name ?? id}
58
+ className="[&_figcaption]:uppercase"
59
+ />
60
+ ))}
61
+ </div>
62
+ <Prose blocks={s.after} />
63
+ </PageSection>
64
+ )
65
+ case 'color': return <ColorSection key={s.key} s={s} ramps={brand?.ramps ?? []} />
66
+ case 'typography': return <TypographySection key={s.key} s={s} brand={brand} />
67
+ default: return (
68
+ <PageSection key={s.key} id={s.id} label={s.label} title={s.title} body={s.lede}>
69
+ <Prose blocks={s.blocks} />
70
+ <Prose blocks={s.after} />
71
+ </PageSection>
72
+ )
73
+ }
74
+ })}
75
+ </>
76
+ )
77
+ }
78
+
79
+ /** Book copy → `.kol-prose`: a string is a paragraph, `{ h }` a sub-heading, `{ lead, text }` a
80
+ * paragraph opening on a bold lead. `head` renders inside the same block, after the copy. */
81
+ export function Prose({ blocks, head, className = 'mt-12' }) {
82
+ if (!blocks?.length && !head) return null
83
+ return (
84
+ <div className={`kol-prose ${className}`}>
85
+ {(blocks ?? []).map((b, i) => {
86
+ if (typeof b === 'string') return <p key={i}>{b}</p>
87
+ if (b.h) return <h3 key={i}>{b.h}</h3>
88
+ return <p key={i}><strong>{b.lead}</strong> {b.text}</p>
89
+ })}
90
+ {head}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ /** The page's chapters as cards — olina's CategoryIndex, read from the sections instead of a nav
96
+ * tree. The links are the sections' anchors; the click scrolls instead of setting the hash. */
97
+ export function SectionIndex({ sections }) {
98
+ if (!sections.length) return null
99
+ return (
100
+ <ul className="mt-12 grid gap-2 grid-cols-1 md:grid-cols-2 max-w-panel">
101
+ {sections.map((s) => (
102
+ <li key={s.id}>
103
+ <a
104
+ href={`#${s.id}`}
105
+ onClick={scrollToAnchor}
106
+ className="kol-card kol-card-interactive flex items-center justify-between gap-4 px-4 py-3 no-underline group"
107
+ >
108
+ <span className="kol-mono-14 text-emphasis">{s.nav}</span>
109
+ <Icon name="arrow-right" size={16} className="text-oq-48 group-hover:text-emphasis transition-colors" />
110
+ </a>
111
+ </li>
112
+ ))}
113
+ </ul>
114
+ )
115
+ }
116
+
117
+ /* Colour — the copy, then one swatch row per ramp under its label and note. The first ramp's
118
+ * heading sits in the copy's own prose block (olina's Concept + Greyscale were one block); each
119
+ * further ramp opens its own, as kol-website's hue ramps did. */
120
+ function ColorSection({ s, ramps }) {
121
+ const heading = (r) => <><h3>{r.label}</h3>{r.note && <p>{r.note}</p>}</>
122
+ const swatches = (r) => (
123
+ <ContentCollection cols={{ sm: 2, md: 5 }} gap={16} className="mt-8">
124
+ {(r.stops ?? []).map((st) => (
125
+ <Swatch key={st.stop} hex={st.value} name={`${r.id}-${st.stop}`} anchor={st.stop === r.anchor} />
126
+ ))}
127
+ </ContentCollection>
128
+ )
129
+ return (
130
+ <PageSection id={s.id} label={s.label} title={s.title} body={s.lede}>
131
+ {ramps.map((r, i) => (
132
+ <div key={r.id ?? i}>
133
+ {i === 0
134
+ ? <Prose blocks={s.blocks} head={heading(r)} />
135
+ : <div className="kol-prose mt-12">{heading(r)}</div>}
136
+ {swatches(r)}
137
+ </div>
138
+ ))}
139
+ {!ramps.length && <Prose blocks={s.blocks} />}
140
+ <Prose blocks={s.after} />
141
+ </PageSection>
142
+ )
143
+ }
144
+
145
+ function TypographySection({ s, brand }) {
146
+ return (
147
+ <PageSection id={s.id} label={s.label} title={s.title} body={s.lede}>
148
+ <Prose blocks={s.blocks} />
149
+ {/* Sans italic at display size — real italic cuts, fired by plain font-style: italic. */}
150
+ <div className="mt-12 flex flex-col gap-2 py-3 border-b border-fg-08">
151
+ <span className="kol-helper-12 text-meta uppercase tracking-wider">
152
+ .kol-prose-display-md · sans narrow · roman + italic
153
+ </span>
154
+ <p className="kol-prose-display-md m-0">
155
+ The quick <span className="italic">brown</span> fox jumps over the lazy dog
156
+ </p>
157
+ </div>
158
+
159
+ {typeSections(brand).map((section) => (
160
+ <div key={section.id} className="mt-12">
161
+ <div className="kol-prose max-w-measure">
162
+ <h3>{section.title}</h3>
163
+ <p>{section.intro}</p>
164
+ {section.reasoning && (
165
+ <p className="text-meta italic">{section.reasoning}</p>
166
+ )}
167
+ </div>
168
+ <div className="mt-8 flex flex-col gap-6">
169
+ {section.rows.map((row, i) => (
170
+ <TypeShowcase key={`${section.id}-${i}`} sectionId={section.id} row={row} />
171
+ ))}
172
+ </div>
173
+ </div>
174
+ ))}
175
+ <Prose blocks={s.after} />
176
+ </PageSection>
177
+ )
178
+ }
179
+
180
+ /* One specimen row, adapted to its section (olina's brand-bits `TypeShowcase`, verbatim). */
181
+ function TypeShowcase({ sectionId, row }) {
182
+ if (sectionId === 'sans-families') {
183
+ return (
184
+ <div className="flex flex-col gap-2 py-3 border-b border-fg-08">
185
+ <span className="kol-helper-12 text-meta uppercase tracking-wider">{row.token}</span>
186
+ <span className="text-emphasis" style={{ fontFamily: `var(${row.token})`, fontSize: 28 }}>
187
+ {row.cut}
188
+ </span>
189
+ <span className="kol-helper-10 text-subtle">{row.role}</span>
190
+ </div>
191
+ )
192
+ }
193
+
194
+ if (sectionId === 'sans-atomic') {
195
+ const cls = row.cls.replace(/^\./, '')
196
+ return (
197
+ <div className="flex flex-col gap-2 py-3 border-b border-fg-08">
198
+ <span className="kol-helper-12 text-meta uppercase tracking-wider">
199
+ {row.cls} · {row.family} · {row.weight}
200
+ </span>
201
+ <div className={cls}>The quick brown fox jumps over the lazy dog</div>
202
+ </div>
203
+ )
204
+ }
205
+
206
+ if (sectionId === 'prose') {
207
+ const tag = (row.class.match(/h[1-6]|p|code|pre/) || [])[0]
208
+ const word = row.role
209
+ let sample
210
+ if (tag === 'h1') sample = <h1>{word}</h1>
211
+ else if (tag === 'h2') sample = <h2>{word}</h2>
212
+ else if (tag === 'h3') sample = <h3>{word}</h3>
213
+ else if (tag === 'h4') sample = <h4>{word}</h4>
214
+ else if (tag === 'h5') sample = <h5>{word}</h5>
215
+ else if (tag === 'h6') sample = <h6>{word}</h6>
216
+ else if (tag === 'code') sample = <p>Inline <code>code</code> in body copy.</p>
217
+ else if (tag === 'pre') sample = <pre>{`code block\nlines preserved`}</pre>
218
+ else sample = <span className={row.class.replace(/^\./, '').replace(/^kol-prose /, '')}>{word}</span>
219
+ return (
220
+ <div className="flex flex-col gap-2 py-3 border-b border-fg-08">
221
+ <span className="kol-helper-12 text-meta uppercase tracking-wider">
222
+ {row.class} · {row.family} · {row.weight}
223
+ </span>
224
+ <div className="kol-prose">{sample}</div>
225
+ </div>
226
+ )
227
+ }
228
+
229
+ if (sectionId === 'mono') {
230
+ const cls = row.cls.replace(/^\./, '')
231
+ return (
232
+ <div className="flex flex-col gap-2 py-3 border-b border-fg-08">
233
+ <span className="kol-helper-10 text-meta uppercase tracking-wider">
234
+ {row.cls} · weight {row.weight} · LH {typeof row.lh === 'number' ? `${row.lh}px` : row.lh} · LS {row.ls}
235
+ </span>
236
+ <span className={cls}>The quick brown fox jumps over the lazy dog</span>
237
+ </div>
238
+ )
239
+ }
240
+
241
+ if (sectionId === 'opacity' && row.name && row.pct !== undefined) {
242
+ return (
243
+ <div className="flex flex-col gap-2 py-3 border-b border-fg-08">
244
+ <span className="kol-helper-12 text-meta uppercase tracking-wider">
245
+ .text-{row.name} · {row.pct}% · {row.role}
246
+ </span>
247
+ <span className={`text-${row.name}`} style={{ fontSize: 18 }}>
248
+ The quick brown fox jumps over the lazy dog
249
+ </span>
250
+ </div>
251
+ )
252
+ }
253
+
254
+ return null
255
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * BrandMark — a logo by id, for the brand tool. Two sources, in order:
3
+ *
4
+ * 1. `Logo` — the consumer's own mark component (`<Logo variant="wordmark" />`), e.g. an svgr
5
+ * loader. It wins when given, so an app that already renders its marks keeps that DOM.
6
+ * 2. `sources` — `{ [id]: rawSvg }`. The markup is injected so `currentColor` cascades in; the
7
+ * same strings feed the Logos table's dimensions and recoloured downloads. The wrapper fills
8
+ * its box and the svg fills the wrapper, so a sized parent (a LogoCard layer) fits the mark
9
+ * and an unsized one takes its height from the viewBox.
10
+ *
11
+ * Neither holds the id → nothing renders (a missing mark is a gap, not a crash).
12
+ */
13
+ export default function BrandMark({ id, Logo, sources, className = '', style, title }) {
14
+ if (Logo) return <Logo variant={id} className={className} style={style} title={title} />
15
+ const raw = sources?.[id]
16
+ if (!raw) return null
17
+ return (
18
+ <span
19
+ className={`kol-logo block w-full h-full [&>svg]:block [&>svg]:w-full [&>svg]:h-full ${className}`.trim()}
20
+ style={style}
21
+ role={title ? 'img' : undefined}
22
+ aria-label={title || undefined}
23
+ aria-hidden={title ? undefined : true}
24
+ dangerouslySetInnerHTML={{ __html: raw }}
25
+ />
26
+ )
27
+ }
package/src/Swatch.jsx CHANGED
@@ -1,25 +1,30 @@
1
1
  import { ColorSwatch } from '@kolkrabbi/kol-component'
2
2
 
3
3
  /**
4
- * Swatch — the DOCUMENTED swatch: chip + meta row (name, hex), with an
5
- * optional canonical-anchor dot.
4
+ * Swatch — the DOCUMENTED swatch: a specimen bar over its meta row (name, hex),
5
+ * with an optional canonical-anchor dot.
6
6
  *
7
- * `ColorSwatch` (kol-component) is the chip alone — a fixed-size pressable
8
- * atom for paint bars and inspectors. A colour PAGE needs the chip plus its
9
- * label and value, which is the form both brand apps had built locally
7
+ * `ColorSwatch` (kol-component) is the chip alone — a fixed-size pressable atom
8
+ * for paint bars and inspectors. A colour PAGE needs the specimen plus its label
9
+ * and value, which is the form both brand apps had built locally
10
10
  * (`brand-book-mocks-two-consumers`, 2026-09-03: 23 lines, byte-identical in
11
11
  * kol-client-olina and kol-website). This composes the atom rather than
12
12
  * redrawing it, per the ticket.
13
13
  *
14
- * ⚠ The forks were rendering `.kol-swatch` / `.kol-swatch-chip` /
15
- * `.kol-swatch-meta` — classes that exist in NO stylesheet in the estate
16
- * (checked kol-theme, both apps, the showcase; only `.kol-swatch-none-marker`
17
- * is real). The chip was a bare div with a background and no dimensions, so it
18
- * collapsed to zero height and the swatch has been showing its meta row over
19
- * nothing. The ticket reported the CSS as already shipping in
20
- * `kol-components-molecules.css`; it does not. So there is no verbatim
21
- * rendering to carry through here — the chip gets a real box from
22
- * `ColorSwatch`, and the dead hooks are dropped.
14
+ * GEOMETRY IS CARRIED THROUGH. The forks wore `.kol-swatch` /
15
+ * `.kol-swatch-chip` / `.kol-swatch-meta`, and those rules are real — they ship
16
+ * in **`@kolkrabbi/kol-framework`** (`kol-framework.css`, the "Swatch / Ramp
17
+ * (color specimens)" block), not in kol-theme: a 6px-gap column, a **96px-tall**
18
+ * chip at radius 4, and a row-wrapped meta line on the baseline. They are
19
+ * stragglers — `.kol-mood-tile-*` left that same block for
20
+ * `kol-theme/kol-components-styleguide.css` on 2026-07-10 and these did not.
21
+ *
22
+ * So this component draws that geometry ITSELF rather than wearing the classes.
23
+ * kol-framework is not a peer of kol-styleguide and must not become one — a
24
+ * component whose look depends on another package's stylesheet being imported
25
+ * is the defect `styleguide-barrel-is-unimportable` was filed about. A
26
+ * consumer swapping its fork onto this gets the same render with no new
27
+ * install, and `height` is the seam if a page wants a different specimen depth.
23
28
  *
24
29
  * <Swatch hex="#131316" name="Ink" anchor />
25
30
  *
@@ -27,16 +32,20 @@ import { ColorSwatch } from '@kolkrabbi/kol-component'
27
32
  * copy; `name` renders exactly as authored.
28
33
  *
29
34
  * @param {string} hex - The colour, e.g. '#131316' (required — it is both the paint and the printed value)
30
- * @param {ReactNode} name - Label above the value; omitted, only the hex renders
35
+ * @param {ReactNode} name - Label beside the value; omitted, only the hex renders
31
36
  * @param {boolean} anchor - Mark this stop as the palette's canonical anchor — a difference-blended dot centred on the chip (default: false)
32
- * @param {number|'fill'|'stretch'} size - Forwarded to ColorSwatch; 'fill' (default) squares the chip to the column
37
+ * @param {number|string} height - Specimen depth, the forks' `.kol-swatch-chip` height (default: 96)
33
38
  * @param {string} className - Extra classes on the wrapper
34
39
  */
35
- export default function Swatch({ hex, name, anchor = false, size = 'fill', className = '' }) {
40
+ export default function Swatch({ hex, name, anchor = false, height = 96, className = '' }) {
36
41
  return (
37
- <div className={`flex flex-col gap-2 ${className}`.trim()}>
38
- <div className="relative">
39
- <ColorSwatch hex={hex} size={size} />
42
+ /* gap 6 — .kol-swatch */
43
+ <div className={`flex flex-col ${className}`.trim()} style={{ gap: 6 }}>
44
+ {/* .kol-swatch-chip: full column width, 96 tall, radius 4. ColorSwatch's
45
+ 'stretch' size fills this box, so the atom owns the paint and the
46
+ frame while the specimen depth stays the page's decision. */}
47
+ <div className="relative w-full" style={{ height }}>
48
+ <ColorSwatch hex={hex} size="stretch" radius="sm" />
40
49
  {anchor && (
41
50
  <span
42
51
  aria-label="Canonical anchor"
@@ -51,7 +60,11 @@ export default function Swatch({ hex, name, anchor = false, size = 'fill', class
51
60
  </span>
52
61
  )}
53
62
  </div>
54
- <div className="kol-helper-10 flex flex-col">
63
+ {/* .kol-swatch-meta: row, space-between, baseline, wrapping */}
64
+ <div
65
+ className="kol-helper-10 flex flex-row flex-wrap items-baseline justify-between"
66
+ style={{ gap: '4px 12px' }}
67
+ >
55
68
  {name && <span className="text-meta">{name}</span>}
56
69
  <span className="text-strong font-semibold">{hex.toUpperCase()}</span>
57
70
  </div>
@@ -0,0 +1,126 @@
1
+ /**
2
+ * brandBook — the brand tool's section registry and the manifest helpers behind it.
3
+ *
4
+ * The brand book as it ran in kol-olina's apps/brand (itself kol-website's apps/brand, copied
5
+ * 2026-09-02): two scrolling pages — BRAND documents the identity, ASSETS holds what you download
6
+ * or reproduce — each a stack of sections. The section ids, eyebrows, titles and ledes below are
7
+ * that app's, verbatim; anything that was the CLIENT's copy (About, Tone, Look, the logo concept,
8
+ * the colour concept) is not here — it comes from the manifest's `book` field, section by section
9
+ * (`@kolkrabbi/kol-brand-template` schema). A manifest key overrides the default of the same name.
10
+ */
11
+
12
+ /* `nav` is the sidebar / rail label; a section without one (the hero, the overviews) is not indexed.
13
+ * `copy` marks a section that is ONLY its copy — with none in the manifest it is not rendered. */
14
+ export const BOOK_SECTIONS = [
15
+ { key: 'hero', id: 'hero', label: 'brand guidelines', lede: 'Client-facing identity guidelines — chapter-structured for handoff. Mirrors the shape of the deliverable PDF.' },
16
+ { key: 'overview', id: 'brand-overview', label: 'Overview', title: 'Chapters', lede: 'Each chapter is its own page. They read in order, but every one stands alone for handoff.' },
17
+ { key: 'about', id: 'about', nav: 'About', label: '01 — about', copy: true },
18
+ { key: 'tone', id: 'voice', nav: 'Tone', label: '02 — tone', title: 'Tone', copy: true },
19
+ { key: 'look', id: 'look', nav: 'Look', label: '03 — look', title: 'Look', copy: true },
20
+ { key: 'logo', id: 'logos-concept', nav: 'Logo', label: '04 — logos · concept', title: 'The mark', marks: ['wordmark', 'logomark'] },
21
+ { key: 'lockups', id: 'logos-types', nav: 'Lockups', label: '05 — logos · types', title: 'Marks and lockups', lede: 'Logomark, wordmark, and two primary lockups.', marks: ['logomark', 'wordmark', 'lockup-hori', 'lockup-vert'] },
22
+ { key: 'color', id: 'color', nav: 'Color', label: '06 — color', title: 'Palette' },
23
+ { key: 'typography', id: 'typography', nav: 'Typography', label: '07 — typography', title: 'Type' },
24
+ ]
25
+
26
+ export const ASSET_SECTIONS = [
27
+ { key: 'assetsOverview', id: 'assets-overview', label: 'Assets', title: 'Downloads and reproduction', lede: 'Everything you download or reproduce. Brand documents the identity; this holds the files and the specs for making them.' },
28
+ { key: 'logos', id: 'logos', nav: 'Logos', label: '01 — logos', title: 'Logos', lede: 'Every mark in src/brand/logos/svg/. Click a row to open the overlay; toggle the color dot to swap ink vs surface; download recolored on the fly.' },
29
+ { key: 'branded', id: 'branded-assets', nav: 'Branded', label: '04 — branded', title: 'Branded assets', lede: 'Stationery. Mocks live in src/components/styleguide/StationeryMocks.jsx and render in /styleguide chapter 6.' },
30
+ { key: 'stationery', id: 'assets-stationery', nav: 'Stationery', label: '08 — assets · stationery', title: 'Stationery', lede: 'Standard correspondence — business card, envelope, letterhead, email signature. Quiet typography, generous space, monochrome restraint.' },
31
+ { key: 'social', id: 'social-sizes', nav: 'Social', label: '12 — social · sizes', title: 'Post sizes', lede: 'One template at each of the three Instagram aspect ratios — square feed (1:1), portrait feed (4:5), and story / reel (9:16). Editorial photography, restrained typography, a deliberate cadence.' },
32
+ { key: 'profile', id: 'social-profile', nav: 'Profile', label: '13 — social · profile', title: 'Profile', lede: 'Avatar treatment for profile pictures across platforms — round-cropped on burgundy, signature centered.' },
33
+ ]
34
+
35
+ /* the two pages, for a view switch */
36
+ export const BRAND_VIEWS = [
37
+ { value: 'brand', label: 'Brand' },
38
+ { value: 'assets', label: 'Assets' },
39
+ ]
40
+
41
+ const hasCopy = (s) => Boolean(s.lede || s.blocks?.length || s.after?.length)
42
+
43
+ /* Whether a section has anything to show for this manifest. Copy-only sections need copy; the
44
+ * specimen sections need the data they render. */
45
+ function shows(s, brand) {
46
+ if (s.copy) return hasCopy(s)
47
+ if (s.key === 'color') return Boolean(brand?.ramps?.length) || hasCopy(s)
48
+ if (s.key === 'logos') return Boolean(brand?.logos?.length)
49
+ if (s.key === 'social') return Boolean(brand?.social?.templates?.length)
50
+ if (s.key === 'profile') return Boolean(brand?.social?.avatars?.length)
51
+ return true
52
+ }
53
+
54
+ /** One page's sections for a manifest — defaults merged with `brand.book`, the empty ones dropped.
55
+ * `[{ key, id, nav?, label, title, lede, blocks, after, marks }]` */
56
+ export function brandSections(brand, page = 'brand') {
57
+ const defaults = page === 'assets' ? ASSET_SECTIONS : BOOK_SECTIONS
58
+ const name = brand?.meta?.name
59
+ return defaults
60
+ .map((d) => {
61
+ const s = { ...d, ...(brand?.book?.[d.key] ?? {}) }
62
+ if (d.key === 'hero' && !s.title) s.title = name
63
+ if (d.key === 'about' && !s.title && name) s.title = `About ${name}`
64
+ return s
65
+ })
66
+ .filter((s) => shows(s, brand))
67
+ }
68
+
69
+ /** A page's rail / index entries — `[{ id, label }]`. */
70
+ export const brandToc = (brand, page) =>
71
+ brandSections(brand, page).filter((s) => s.nav).map((s) => ({ id: s.id, label: s.nav }))
72
+
73
+ /** The manifest's `meta` in the shape the stationery mocks read (`DEFAULT_BRAND_INFO`). A field the
74
+ * manifest does not carry is an EMPTY STRING, so the mocks render it blank rather than falling back
75
+ * to their placeholder values. */
76
+ export function brandInfo(brand) {
77
+ const m = brand?.meta ?? {}
78
+ const loc = m.location ?? {}
79
+ return {
80
+ identity: { founder: m.founder ?? '', role: m.role ?? '', established: m.founded ?? '', name: m.name ?? '', nameShort: m.nameShort ?? '' },
81
+ contact: { email: m.email ?? '', phone: m.phone ?? '', web: (m.url ?? '').replace(/^https?:\/\//, '').replace(/\/$/, '') },
82
+ social: { instagram: m.socials?.instagram ?? '', youtube: m.socials?.youtube ?? '', tiktok: m.socials?.tiktok ?? '' },
83
+ studio: { street: loc.street ?? '', postcode: loc.postcode ?? '', country: loc.country ?? '', city: loc.city ?? '', locShort: loc.locShort ?? '' },
84
+ legal: { entity: m.legalName ?? '', kt: '', vat: '' },
85
+ labels: { madeIn: '', handmade: '', handBy: '', manifesto: '' },
86
+ }
87
+ }
88
+
89
+ /* which logo each stationery mock carries — the variants olina's brandBook.jsx drew */
90
+ export const STATIONERY_MARKS = { card: 'lockup-hori', envelope: 'logomark', letter: 'wordmark', signature: 'logomark', avatar: 'logomark' }
91
+
92
+ /* The Branded table — the five stationery pieces the mocks draw. */
93
+ export const BRANDED_ASSET_ROWS = [
94
+ { item: 'Business card · front', aspect: '85.6 / 53.85 (ID-1)', surface: 'paper', status: 'mocked', note: 'StationeryMocks.BusinessCardFront' },
95
+ { item: 'Business card · back', aspect: '85.6 / 53.85', surface: 'ink', status: 'mocked', note: 'StationeryMocks.BusinessCardBack' },
96
+ { item: 'Envelope · DL', aspect: '220 / 110', surface: 'paper', status: 'mocked', note: 'StationeryMocks.Envelope' },
97
+ { item: 'Letterhead · A4', aspect: '210 / 297', surface: 'paper', status: 'mocked', note: 'StationeryMocks.Letterhead' },
98
+ { item: 'Email signature', aspect: 'free', surface: 'paper', status: 'mocked', note: 'StationeryMocks.EmailSignature' },
99
+ ]
100
+
101
+ /** An in-page anchor click that scrolls instead of setting the hash — the host may route on the hash
102
+ * (media-shell does; apps/brand keeps its page there), so an anchor must not replace it. */
103
+ export function scrollToAnchor(e) {
104
+ e.preventDefault()
105
+ const id = e.currentTarget.getAttribute('href')?.slice(1)
106
+ if (id) document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
107
+ }
108
+
109
+ /* viewBox → "W × H" */
110
+ export const svgDims = (raw) => {
111
+ const m = /viewBox="[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)"/.exec(raw ?? '')
112
+ return m ? `${Math.round(+m[1])} × ${Math.round(+m[2])}` : undefined
113
+ }
114
+
115
+ /* `currentColor` → the resolved ink the table hands over, into a Blob, so a white mark downloads white. */
116
+ export function downloadRecolored(raw, color, filename) {
117
+ const blob = new Blob([raw.replace(/currentColor/gi, color)], { type: 'image/svg+xml' })
118
+ const url = URL.createObjectURL(blob)
119
+ const a = document.createElement('a')
120
+ a.href = url
121
+ a.download = filename
122
+ document.body.appendChild(a)
123
+ a.click()
124
+ a.remove()
125
+ URL.revokeObjectURL(url)
126
+ }
package/src/index.js CHANGED
@@ -42,3 +42,10 @@ export {
42
42
  // that wanted one logo card could not build. Import them from their own homes:
43
43
  // AssetGrid, FeatureSplit, ProsePreview, SpectrumGrid → @kolkrabbi/kol-component
44
44
  // TypeSample, TypeSpecCard → @kolkrabbi/kol-foundry
45
+
46
+ // The brand tool (brand-as-a-tool, 2026-09-27): the brand book's two pages over one manifest —
47
+ // kol-olina's apps/brand, carried in. `Brand` is the tool; the pages render alone too.
48
+ export { default as Brand } from './Brand.jsx'
49
+ export { default as BrandBook } from './BrandBook.jsx'
50
+ export { default as BrandAssets } from './BrandAssets.jsx'
51
+ export { brandSections, brandToc, brandInfo, BRAND_VIEWS, BOOK_SECTIONS, ASSET_SECTIONS } from './brandBook.js'
@@ -0,0 +1,166 @@
1
+ /**
2
+ * typeSpecimen — the brand book's Typography chapter as data.
3
+ *
4
+ * kol-olina's apps/brand `data/typography.js`, verbatim, minus its two brand-specific tables: the
5
+ * FAMILIES come from the manifest (`type.families`, `type.note`), and the cuts table was never
6
+ * rendered by the chapter. Everything else describes the KOL type system — classes, prose, the mono
7
+ * split, the reading hierarchy — which every brand on the system shares, because its type rides the
8
+ * family tokens.
9
+ */
10
+
11
+ const sansDisplayRows = [
12
+ { cls: '.kol-sans-display-01', tokenName: '--kol-text-display-01', family: 'sans-narrow', weight: 600 },
13
+ { cls: '.kol-sans-display-02', tokenName: '--kol-text-display-02', family: 'sans-narrow', weight: 600 },
14
+ ]
15
+
16
+ const sansHeadingRows = [
17
+ { cls: '.kol-sans-heading-01', tokenName: '--kol-text-heading-01', family: 'sans-narrow', weight: 500 },
18
+ { cls: '.kol-sans-heading-02', tokenName: '--kol-text-heading-02', family: 'sans-compact', weight: 500 },
19
+ { cls: '.kol-sans-heading-03', tokenName: '--kol-text-heading-03', family: 'sans-compact', weight: 500 },
20
+ { cls: '.kol-sans-heading-04', tokenName: '--kol-text-heading-04', family: 'sans-compact', weight: 500 },
21
+ { cls: '.kol-sans-heading-05', tokenName: '--kol-text-heading-05', family: 'sans-compact', weight: 500 },
22
+ ]
23
+
24
+ const sansBodyRows = [
25
+ { cls: '.kol-sans-body-01', tokenName: '--kol-text-body-01', family: 'sans (base)', weight: 400 },
26
+ { cls: '.kol-sans-body-02', tokenName: '--kol-text-body-02', family: 'sans (base)', weight: 400 },
27
+ { cls: '.kol-sans-body-03', tokenName: '--kol-text-body-03', family: 'sans (base)', weight: 400 },
28
+ ]
29
+
30
+ const proseRows = [
31
+ { role: 'Display', class: '.kol-prose-display', family: 'sans-narrow', weight: 500 },
32
+ { role: 'Display (md)',class: '.kol-prose-display-md', family: 'sans-narrow', weight: 500 },
33
+ { role: 'Title', class: '.kol-prose-title', family: 'sans-narrow', weight: 500 },
34
+ { role: 'H1', class: '.kol-prose h1', family: 'sans-narrow', weight: 500, tokenName: '--kol-text-heading-01' },
35
+ { role: 'H2', class: '.kol-prose h2', family: 'sans-compact', weight: 400, tokenName: '--kol-text-heading-03' },
36
+ { role: 'H3', class: '.kol-prose h3', family: 'sans-compact', weight: 400, tokenName: '--kol-text-heading-04' },
37
+ { role: 'H4', class: '.kol-prose h4', family: 'sans-compact', weight: 400, tokenName: '--kol-text-heading-05' },
38
+ { role: 'H5', class: '.kol-prose h5', family: 'sans-compact', weight: 500, tokenName: '--kol-text-heading-06' },
39
+ { role: 'H6', class: '.kol-prose h6', family: 'sans-compact', weight: 500, tokenName: '--kol-text-heading-06' },
40
+ { role: 'Lede', class: '.kol-prose-lede', family: 'sans-compact', weight: 400 },
41
+ { role: 'Tagline', class: '.kol-prose-tagline', family: 'sans-narrow', weight: 500 },
42
+ { role: 'Body', class: '.kol-prose p', family: 'sans (base)', weight: 300 },
43
+ { role: 'Quote', class: '.kol-prose blockquote p', family: 'sans (base)', weight: 600 },
44
+ { role: 'Cite', class: '.kol-prose blockquote cite',family: 'sans (base)', weight: 500 },
45
+ { role: 'Label', class: '.kol-prose-label', family: 'mono', weight: 500 },
46
+ { role: 'Code (inline)',class: '.kol-prose code', family: 'mono', weight: 400 },
47
+ { role: 'Code (block)', class: '.kol-prose pre', family: 'mono', weight: 400 },
48
+ ]
49
+
50
+ const monoRows = [
51
+ { cls: '.kol-mono-20', size: 20, lh: 26, weight: 400, ls: 'normal' },
52
+ { cls: '.kol-mono-16', size: 16, lh: 22, weight: 400, ls: 'normal' },
53
+ { cls: '.kol-mono-14', size: 14, lh: 18, weight: 400, ls: 'normal' },
54
+ { cls: '.kol-mono-12', size: 12, lh: 16, weight: 400, ls: 'normal' },
55
+ { cls: '.kol-mono-10', size: 10, lh: 14, weight: 400, ls: 'normal' },
56
+ { cls: '.kol-mono-8', size: 8, lh: 12, weight: 400, ls: 'normal' },
57
+ ]
58
+
59
+ const helperRows = [
60
+ { cls: '.kol-helper-20', size: 20, lh: '1', weight: 500, ls: '0.06em' },
61
+ { cls: '.kol-helper-16', size: 16, lh: '1', weight: 500, ls: '0.06em' },
62
+ { cls: '.kol-helper-14', size: 14, lh: '1', weight: 500, ls: '0.06em' },
63
+ { cls: '.kol-helper-12', size: 12, lh: '1', weight: 500, ls: '0.06em' },
64
+ { cls: '.kol-helper-10', size: 10, lh: '1', weight: 500, ls: '0.10em' },
65
+ { cls: '.kol-helper-8', size: 8, lh: '1', weight: 500, ls: '0.10em' },
66
+ ]
67
+
68
+ const opacityDescriptorRows = [
69
+ { name: 'subtle', pct: 24, token: '--kol-fg-subtle', role: 'Dividers, disabled hints, faintest visible' },
70
+ { name: 'meta', pct: 48, token: '--kol-fg-meta', role: 'Labels, eyebrows, captions' },
71
+ { name: 'body', pct: 64, token: '--kol-fg-body', role: 'Running copy, links' },
72
+ { name: 'strong', pct: 80, token: '--kol-fg-strong', role: 'Emphasized body, <strong>' },
73
+ { name: 'emphasis', pct: 100, token: '--kol-fg-emphasis', role: 'Headings, max ink' },
74
+ ]
75
+
76
+ const SYSTEM_SECTIONS = [
77
+ {
78
+ id: 'sans-atomic',
79
+ title: 'Sans atomic classes',
80
+ intro:
81
+ "Display, heading, body — explicit per-element classes for chrome / " +
82
+ "page sections. Distinct from .kol-prose, which cascades into HTML " +
83
+ "elements for rich-text containers.",
84
+ reasoning:
85
+ "Numbered tokens (01–06), not t-shirt sizes. Numbers describe a fixed " +
86
+ "position in the scale (01 = anchor, ascending = smaller); t-shirt names " +
87
+ "carry no ordering, you have to remember which is bigger. 01 = largest " +
88
+ "follows HTML's H1 = biggest convention. Class prefix is family-first " +
89
+ "(.kol-sans-) to mirror mono naming and read naturally — \"the sans " +
90
+ "system, display role, stop 01\". H1 uses Narrow (condensed for impact); " +
91
+ "H2–H5 use Compact (slightly wider, mid-tier); body uses base. Atomic body " +
92
+ "weight is 400 (regular) for chrome; prose body is 300 (light) for " +
93
+ "editorial reading copy — different defaults per context.",
94
+ rows: [...sansDisplayRows, ...sansHeadingRows, ...sansBodyRows],
95
+ },
96
+ {
97
+ id: 'prose',
98
+ title: 'Prose container',
99
+ intro:
100
+ ".kol-prose is a rich-text container that cascades to native HTML " +
101
+ "elements (h1–h6, p, ul, blockquote, code, pre). Wrap CMS / blog / " +
102
+ "markdown output in it; the container styles every element via " +
103
+ "descendant selectors.",
104
+ reasoning:
105
+ "H2 intentionally skips heading-02 (40px) and uses heading-03 (32px). " +
106
+ "48 → 40 reads as \"same family\"; 48 → 32 reads as \"structural break\" " +
107
+ "— better visual rhythm at H1 → H2. H6 ships uppercase with tracking " +
108
+ "by default since H6 is rare and benefits from a label-like rendering. " +
109
+ "Code (inline + block) consumes the mono token so a future swap cascades " +
110
+ "into prose code automatically.",
111
+ rows: proseRows,
112
+ },
113
+ {
114
+ id: 'mono',
115
+ title: 'Mono',
116
+ intro:
117
+ "JetBrains Mono. Two scales at the same px stops, distinguished by " +
118
+ "intent + visual weight. .kol-mono-N for multi-line body / values " +
119
+ "(weight 400, with leading). .kol-helper-N for single-line labels " +
120
+ "(weight 500, line-height 1, letter-spaced).",
121
+ reasoning:
122
+ "Same px size, different weight = visible hierarchy without size-jumps. " +
123
+ "Common pattern: a label sits above a control or value at the same stop " +
124
+ "— label is helper (medium), control text is mono (regular). Both 12px, " +
125
+ "but the label \"reads first\" by weight. Tight UI grids stay compact " +
126
+ "without further compressing sizes. Px-literal suffixes (10/12/14/16/20) " +
127
+ "instead of numbered indices because mono is utility chrome where exact " +
128
+ "px matters — the number IS the value, no decoder ring. Wider letter-" +
129
+ "spacing (0.10em) on the smallest two stops is a typographic principle: " +
130
+ "sub-12px mono needs more tracking to stay legible.",
131
+ rows: [...monoRows, ...helperRows],
132
+ },
133
+ {
134
+ id: 'opacity',
135
+ title: 'Reading hierarchy (descriptors)',
136
+ intro:
137
+ "Five-stop ink hierarchy: subtle / meta / body / strong / emphasis. " +
138
+ "Aliases over the numeric --kol-fg-NN ramp (UI Colors / Opacity " +
139
+ "primitives). Pick descriptors when intent matters; pick numeric stops " +
140
+ "when exact percentage matters. Text-only — bg / border / ring " +
141
+ "descriptor families retired 2026-04-30 (consumers used numeric stops).",
142
+ reasoning:
143
+ "Five stops anchor the typography reading hierarchy. The retired " +
144
+ "`mute` (32%) sat awkwardly between subtle (24%, dividers) and meta " +
145
+ "(48%, labels) and had zero component consumers. Descriptors are now " +
146
+ "text-only — for surface tints, borders, and rings, reach for the " +
147
+ "numeric --kol-fg-NN family (bg-fg-08, border-fg-12, etc.).",
148
+ rows: opacityDescriptorRows,
149
+ },
150
+ ]
151
+
152
+ /** The chapter's sections for a manifest: its families first (when it declares any), then the
153
+ * system's. `[{ id, title, intro, reasoning?, rows }]` */
154
+ export function typeSections(brand) {
155
+ const families = brand?.type?.families ?? []
156
+ const head = families.length ? [{
157
+ id: 'sans-families',
158
+ title: 'Family tokens',
159
+ intro:
160
+ "Three sans cuts and one mono. Edit a family token to swap any cut " +
161
+ "without touching consumers.",
162
+ reasoning: brand?.type?.note,
163
+ rows: families.map(({ token, role, cut }) => ({ token, role, cut })),
164
+ }] : []
165
+ return [...head, ...SYSTEM_SECTIONS]
166
+ }