@kolkrabbi/kol-styleguide 0.6.0 → 0.8.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 +7 -6
- package/src/BrandBook.jsx +1 -1
- package/src/BrandGuide.jsx +313 -0
- package/src/ColorLab.jsx +144 -0
- package/src/Lockup.jsx +93 -0
- package/src/LockupBuilder.jsx +60 -0
- package/src/TypeScaleLab.jsx +71 -0
- package/src/brandBook.js +8 -0
- package/src/colorLab.js +81 -0
- package/src/guideBook.js +56 -0
- package/src/index.js +14 -0
- package/src/typeScale.js +45 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-styleguide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Brand guide components and the brand tool",
|
|
6
6
|
"license": "MIT",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"main": "./src/index.js",
|
|
9
9
|
"module": "./src/index.js",
|
|
10
10
|
"exports": {
|
|
11
|
-
".": "./src/index.js"
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./src/*": "./src/*"
|
|
12
13
|
},
|
|
13
14
|
"peerDependencies": {
|
|
14
15
|
"@kolkrabbi/kol-component": ">=0.188.0",
|
|
@@ -19,10 +20,10 @@
|
|
|
19
20
|
"react-dom": "^18.3.0 || ^19.0.0"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
|
-
"@kolkrabbi/kol-
|
|
23
|
-
"@kolkrabbi/kol-
|
|
24
|
-
"@kolkrabbi/kol-
|
|
25
|
-
"@kolkrabbi/kol-theme": "^0.
|
|
23
|
+
"@kolkrabbi/kol-framework": "^0.57.0",
|
|
24
|
+
"@kolkrabbi/kol-component": "^0.305.0",
|
|
25
|
+
"@kolkrabbi/kol-icons": "^0.47.0",
|
|
26
|
+
"@kolkrabbi/kol-theme": "^0.191.0"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"src",
|
package/src/BrandBook.jsx
CHANGED
|
@@ -148,7 +148,7 @@ function TypographySection({ s, brand }) {
|
|
|
148
148
|
<Prose blocks={s.blocks} />
|
|
149
149
|
{/* Sans italic at display size — real italic cuts, fired by plain font-style: italic. */}
|
|
150
150
|
<div className="mt-12 flex flex-col gap-2 py-3 border-b border-fg-08">
|
|
151
|
-
<span className="kol-
|
|
151
|
+
<span className="kol-mono-12 text-meta uppercase tracking-wider">
|
|
152
152
|
.kol-prose-display-md · sans narrow · roman + italic
|
|
153
153
|
</span>
|
|
154
154
|
<p className="kol-prose-display-md m-0">
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/* BrandGuide — kol-branding's guide renderer (src/template/book/BrandBook.jsx + Brand.jsx, 2026-10-24), carried
|
|
2
|
+
* class-for-class for the brand guide outline (BrandGuideOutlineTwoVersions, kol-hq 2026-10-27). Two changes, both
|
|
3
|
+
* because a package cannot carry them: the research line under each chapter (`Source`, kol-branding's own citations)
|
|
4
|
+
* stays there, and the Contents card is an in-page anchor only (no router in this package). */
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { ColorRamp, ContentCollection, DocsToc, FullscreenOverlay, Table } from '@kolkrabbi/kol-component'
|
|
7
|
+
import { Icon } from '@kolkrabbi/kol-icons'
|
|
8
|
+
import PageHero from '@kolkrabbi/kol-framework/src/PageHero.jsx'
|
|
9
|
+
import PageSection from '@kolkrabbi/kol-framework/src/PageSection.jsx'
|
|
10
|
+
import LogoCard from './LogoCard.jsx'
|
|
11
|
+
import AssetTable from './AssetTable.jsx'
|
|
12
|
+
import AssetCard from './AssetCard.jsx'
|
|
13
|
+
import BrandMark from './BrandMark.jsx'
|
|
14
|
+
import { BrandAvatar } from './SocialMocks.jsx'
|
|
15
|
+
import { BusinessCardBack, BusinessCardFront, EmailSignature, Envelope, Letterhead } from './StationeryMocks.jsx'
|
|
16
|
+
import { brandInfo, STATIONERY_MARKS, BRANDED_ASSET_ROWS, scrollToAnchor, svgDims, downloadRecolored } from './brandBook.js'
|
|
17
|
+
import { guideSections, guideToc, stopHex, contrast } from './guideBook.js'
|
|
18
|
+
|
|
19
|
+
/* taxonomy-ok: organism — a brand guide: PageHero + one PageSection per chapter, each chapter the same anatomy, the chapters on a rail */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* BrandGuide — A brand guide. one scrolling page: the hero, then one chapter per row of the list it is handed, with the
|
|
23
|
+
* chapters on a rail. Every chapter has one anatomy — eyebrow · title · lede · prose · rules · the specimen its `kind`
|
|
24
|
+
* draws · spec table · do / don't · word list · closing prose — and only the specimen differs by kind. The outline is
|
|
25
|
+
* `@kolkrabbi/kol-brand-template`'s `GUIDE_SECTIONS`; its blank copy is `GUIDE_TEMPLATE`.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} brand a brand manifest (`@kolkrabbi/kol-brand-template` schema; `book` carries the copy)
|
|
28
|
+
* @param {Array} sections the chapter list (see `GUIDE_SECTIONS` for a row)
|
|
29
|
+
* @param {Function} Logo optional mark component, `<Logo variant="wordmark" />`
|
|
30
|
+
* @param {Object} logoSources `{ [logoId]: rawSvg }` — drawn when no `Logo` is given
|
|
31
|
+
* @param {Object} structure `{ [logoId]: { logo, grid, keyline } }` raw svgs — Lockups' clear-space toggle
|
|
32
|
+
* @param {string} className classes on the root
|
|
33
|
+
*/
|
|
34
|
+
export default function BrandGuide({ brand, sections, Logo, logoSources, structure, className = '' }) {
|
|
35
|
+
const list = guideSections(brand, sections)
|
|
36
|
+
const mark = (id, props) => <BrandMark id={id} Logo={Logo} sources={logoSources} {...props} />
|
|
37
|
+
const index = list.filter((s) => s.nav)
|
|
38
|
+
return (
|
|
39
|
+
/* `xl:pr-32` — the rail is `position: fixed` on the right edge from xl up, and the pages' grids
|
|
40
|
+
run to the frame's edge; the padding keeps the last column out from under it */
|
|
41
|
+
<div className={`xl:pr-32 ${className}`.trim()}>
|
|
42
|
+
{list.map((s) => (s.kind === 'hero'
|
|
43
|
+
? <PageHero key={s.key} id={s.id} label={s.label} title={s.title} lede={s.lede} />
|
|
44
|
+
: <Chapter key={s.key} s={s} brand={brand} mark={mark} index={index} logoSources={logoSources} structure={structure} />))}
|
|
45
|
+
<DocsToc variant="rail" toc={guideToc(brand, sections)} onNavigate={scrollToAnchor} className="hidden xl:flex" />
|
|
46
|
+
</div>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function Chapter({ s, brand, mark, index, logoSources, structure }) {
|
|
51
|
+
const Specimen = SPECIMENS[s.kind ?? 'prose']
|
|
52
|
+
return (
|
|
53
|
+
<PageSection id={s.id} label={s.label} title={s.title} body={s.lede}>
|
|
54
|
+
<Prose blocks={s.blocks} />
|
|
55
|
+
{s.rules?.length > 0 && <Rules items={s.rules} />}
|
|
56
|
+
{Specimen && <Specimen s={s} brand={brand} mark={mark} index={index} logoSources={logoSources} structure={structure} />}
|
|
57
|
+
{s.spec && <Spec spec={s.spec} />}
|
|
58
|
+
{(s.dos?.length > 0 || s.donts?.length > 0) && <Pair left={{ title: 'Do', items: s.dos }} right={{ title: "Don't", items: s.donts }} />}
|
|
59
|
+
{s.words && <Pair left={{ title: 'Words we use', items: s.words.use }} right={{ title: 'Words we avoid', items: s.words.avoid }} />}
|
|
60
|
+
<Prose blocks={s.after} />
|
|
61
|
+
</PageSection>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Book copy → `.kol-prose`: a string is a paragraph, `{ h }` a sub-heading, `{ lead, text }` a
|
|
66
|
+
* paragraph opening on a bold lead. `head` renders inside the same block, after the copy. */
|
|
67
|
+
function Prose({ blocks, head, className = 'mt-12' }) {
|
|
68
|
+
if (!blocks?.length && !head) return null
|
|
69
|
+
return (
|
|
70
|
+
<div className={`kol-prose ${className}`}>
|
|
71
|
+
{(blocks ?? []).map((b, i) => {
|
|
72
|
+
if (typeof b === 'string') return <p key={i}>{b}</p>
|
|
73
|
+
if (b.h) return <h3 key={i}>{b.h}</h3>
|
|
74
|
+
return <p key={i}><strong>{b.lead}</strong> {b.text}</p>
|
|
75
|
+
})}
|
|
76
|
+
{head}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* The rules, as a list in the prose voice. */
|
|
82
|
+
function Rules({ items }) {
|
|
83
|
+
return <div className="kol-prose mt-8"><ul>{items.map((r, i) => <li key={i}>{r}</li>)}</ul></div>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Two lists side by side — do / don't, use / avoid. */
|
|
87
|
+
/* Two lists side by side (do / don't, words). A side with no items is left out; the titles are labels, not headings. */
|
|
88
|
+
function Pair({ left, right }) {
|
|
89
|
+
const cols = [left, right].filter((c) => c.items?.length)
|
|
90
|
+
if (!cols.length) return null
|
|
91
|
+
return (
|
|
92
|
+
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-2 max-w-panel">
|
|
93
|
+
{cols.map(({ title, items }) => (
|
|
94
|
+
<div key={title} className="flex flex-col gap-3">
|
|
95
|
+
<h3 className="kol-prose-label m-0">{title}</h3>
|
|
96
|
+
<div className="kol-prose"><ul>{items.map((t, i) => <li key={i}>{t}</li>)}</ul></div>
|
|
97
|
+
</div>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* A measured spec — columns and rows from the manifest, on the DS table. */
|
|
104
|
+
function Spec({ spec }) {
|
|
105
|
+
const columns = spec.columns.map((h, i) => ({ accessor: `c${i}`, header: h, wrap: true }))
|
|
106
|
+
const rows = spec.rows.map((r, n) => Object.fromEntries([['id', n], ...r.map((v, i) => [`c${i}`, v])]))
|
|
107
|
+
return <Table variant="simple" caption={spec.caption ?? 'Specification'} columns={columns} rows={rows} className="mt-8" />
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* The contents: every indexed chapter as a row, its eyebrow carrying the number; grouped by `part` when the list has parts. */
|
|
111
|
+
function Contents({ index }) {
|
|
112
|
+
if (!index.length) return null
|
|
113
|
+
const parts = [...new Set(index.map((s) => s.part ?? ''))]
|
|
114
|
+
const row = (s) => (
|
|
115
|
+
<li key={s.id}>
|
|
116
|
+
<IndexCard href={`#${s.id}`} onClick={scrollToAnchor} label={s.label} title={s.title ?? s.nav} />
|
|
117
|
+
</li>
|
|
118
|
+
)
|
|
119
|
+
return (
|
|
120
|
+
<div className="mt-12 flex flex-col gap-10 max-w-panel">
|
|
121
|
+
{parts.map((p) => (
|
|
122
|
+
<div key={p || 'all'} className="flex flex-col gap-4">
|
|
123
|
+
{p && <h3 className="kol-prose-label m-0">{p}</h3>}
|
|
124
|
+
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 md:grid-cols-2">{index.filter((s) => (s.part ?? '') === p).map(row)}</ul>
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* The book's index card — the Contents row: a label, the title, one small line and the arrow (kol-branding's IndexCard,
|
|
132
|
+
* the in-page anchor half). */
|
|
133
|
+
function IndexCard({ href, onClick, label, title, meta }) {
|
|
134
|
+
return (
|
|
135
|
+
<a href={href} onClick={onClick} className="kol-card kol-card-interactive flex items-center justify-between gap-4 px-4 py-3 no-underline group">
|
|
136
|
+
<span className="flex min-w-0 flex-col gap-1">
|
|
137
|
+
{label && <span className="kol-mono-12 text-meta uppercase">{label}</span>}
|
|
138
|
+
<span className="kol-mono-14 text-emphasis">{title}</span>
|
|
139
|
+
{meta && <span className="kol-mono-10 text-meta mt-1">{meta}</span>}
|
|
140
|
+
</span>
|
|
141
|
+
<Icon name="arrow-right" size={16} className="shrink-0 text-oq-48 group-hover:text-emphasis transition-colors" />
|
|
142
|
+
</a>
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* ── the specimens, by kind ───────────────────────────────────────────────────────────────────── */
|
|
147
|
+
|
|
148
|
+
function Marks({ s, mark }) {
|
|
149
|
+
return <div className="kol-grid mt-12">{(s.marks ?? []).map((id) => <LogoCard key={id} logo={mark(id)} frame={false} />)}</div>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* A raw svg string as a layer node — ClearspaceDiagram stretches the svg inside to its box. */
|
|
153
|
+
const Raw = ({ svg }) => <span dangerouslySetInnerHTML={{ __html: svg }} />
|
|
154
|
+
|
|
155
|
+
/* With a mark's construction (`structure[id]`) the card draws the padded diagram and offers the clear-space toggle. */
|
|
156
|
+
function Lockups({ s, brand, mark, structure }) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-2 max-w-panel">
|
|
159
|
+
{(s.marks ?? []).map((id) => {
|
|
160
|
+
const st = structure?.[id]
|
|
161
|
+
return (
|
|
162
|
+
<LogoCard key={id} logo={st ? <Raw svg={st.logo} /> : mark(id)} grid={st && <Raw svg={st.grid} />} keyline={st && <Raw svg={st.keyline} />}
|
|
163
|
+
caption={brand?.logos?.find((l) => l.id === id)?.name ?? id} className="[&_figcaption]:uppercase" />
|
|
164
|
+
)
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* Colour — one compact ramp row per family (its name, its role, the anchor dot), then the approved pairs with their contrast. */
|
|
171
|
+
function Color({ s, brand }) {
|
|
172
|
+
const ramps = brand?.ramps ?? []
|
|
173
|
+
return (
|
|
174
|
+
<>
|
|
175
|
+
{/* chips at one fixed height instead of ColorRamp's squares, so a family is a strip, not a screen */}
|
|
176
|
+
<div className="mt-12 max-w-panel [&_.aspect-square]:aspect-auto [&_.aspect-square]:h-14">
|
|
177
|
+
{ramps.map((r) => (
|
|
178
|
+
<ColorRamp key={r.id} label={r.label} note={r.note} anchor={`${r.id}-${r.anchor}`}
|
|
179
|
+
colors={(r.stops ?? []).map((st) => [`${r.id}-${st.stop}`, st.value])} />
|
|
180
|
+
))}
|
|
181
|
+
</div>
|
|
182
|
+
{s.pairs?.length > 0 && <Pairs pairs={s.pairs} brand={brand} />}
|
|
183
|
+
</>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Approved text / background pairs, contrast computed from the ramps — the accessibility rule, kept where the values are. */
|
|
188
|
+
function Pairs({ pairs, brand }) {
|
|
189
|
+
const grade = (r) => (r >= 7 ? 'AAA' : r >= 4.5 ? 'AA' : r >= 3 ? 'AA, large text only' : 'fails')
|
|
190
|
+
const rows = pairs.map((p, i) => {
|
|
191
|
+
const [t, b] = [stopHex(brand, p.text), stopHex(brand, p.bg)]
|
|
192
|
+
const r = contrast(t, b)
|
|
193
|
+
return { id: i, text: `${p.text}${t ? ` · ${t}` : ''}`, bg: `${p.bg}${b ? ` · ${b}` : ''}`, ratio: r ? `${r.toFixed(2)} : 1` : '—', passes: r ? grade(r) : '—', use: p.use ?? '' }
|
|
194
|
+
})
|
|
195
|
+
const columns = [{ accessor: 'text', header: 'Text' }, { accessor: 'bg', header: 'Background' }, { accessor: 'ratio', header: 'Contrast' }, { accessor: 'passes', header: 'WCAG 2' }, { accessor: 'use', header: 'Use', wrap: true }]
|
|
196
|
+
return <Table variant="simple" caption="Approved color pairs" columns={columns} rows={rows} className="mt-8" />
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Type — the brand's families, each set in its own cut. The brand's scale is copy (`spec` or `after`), not KOL's system. */
|
|
200
|
+
function Typography({ brand }) {
|
|
201
|
+
const families = brand?.type?.families ?? []
|
|
202
|
+
if (!families.length) return null
|
|
203
|
+
return (
|
|
204
|
+
<div className="mt-12 flex flex-col gap-6">
|
|
205
|
+
{families.map((f) => (
|
|
206
|
+
<div key={f.token} className="flex flex-col gap-2 border-b border-fg-08 py-3">
|
|
207
|
+
<span className="kol-mono-12 text-meta uppercase tracking-wider">{f.token}</span>
|
|
208
|
+
<span className="text-emphasis" style={{ fontFamily: `var(${f.token})`, fontSize: 28 }}>{f.cut}</span>
|
|
209
|
+
<span className="kol-mono-12 text-subtle">{f.role}</span>
|
|
210
|
+
</div>
|
|
211
|
+
))}
|
|
212
|
+
{brand?.type?.note && <p className="kol-mono-14 text-meta m-0">{brand.type.note}</p>}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* The pair the Logos table's Color column swings between. */
|
|
218
|
+
const INK_TOKENS = { ink: '--kol-surface-on-primary', surface: '--kol-surface-primary' }
|
|
219
|
+
|
|
220
|
+
/* The logo asset table — ink toggle, zoom, recoloured download. */
|
|
221
|
+
function LogosTable({ brand, mark, logoSources }) {
|
|
222
|
+
const [zoom, setZoom] = useState(null)
|
|
223
|
+
const rows = (brand?.logos ?? []).map((logo) => {
|
|
224
|
+
const raw = logoSources?.[logo.id]
|
|
225
|
+
return {
|
|
226
|
+
id: logo.id, name: logo.id.replace(/-/g, ' / '), preview: mark(logo.id), previewWidth: logo.previewWidth ?? 48,
|
|
227
|
+
path: logo.file?.replace(/^\.\//, ''), format: 'SVG', dimensions: svgDims(raw), raw,
|
|
228
|
+
onDownload: raw ? (row, ink) => downloadRecolored(row.raw, ink, `${logo.id}.svg`) : undefined,
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
return (
|
|
232
|
+
<>
|
|
233
|
+
<AssetTable caption="Logos" rows={rows} inkTokens={INK_TOKENS} onPreviewZoom={(row, ink) => setZoom({ row, ink })} className="mt-8" />
|
|
234
|
+
<FullscreenOverlay open={!!zoom} onClose={() => setZoom(null)}>
|
|
235
|
+
{zoom && <span className="inline-flex items-center p-12" style={{ color: zoom.ink, width: 'min(60vw, 480px)' }}>{zoom.row.preview}</span>}
|
|
236
|
+
</FullscreenOverlay>
|
|
237
|
+
</>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const TokenName = ({ children }) => <code className="kol-helper-12 text-emphasis">{children}</code>
|
|
242
|
+
const brandedAssetCols = [
|
|
243
|
+
{ accessor: 'item', header: 'Item', render: (r) => <TokenName>{r.item}</TokenName> },
|
|
244
|
+
{ accessor: 'aspect', header: 'Aspect' },
|
|
245
|
+
{ accessor: 'surface', header: 'Surface' },
|
|
246
|
+
{ accessor: 'status', header: 'Status', render: (r) => <span className="kol-helper-12 text-meta uppercase tracking-widest">{r.status}</span> },
|
|
247
|
+
{ accessor: 'note', header: 'Note' },
|
|
248
|
+
]
|
|
249
|
+
function Branded() {
|
|
250
|
+
return <Table variant="simple" caption="Branded assets" columns={brandedAssetCols} rows={BRANDED_ASSET_ROWS} className="mt-8" />
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* Stationery — the five mocks with the manifest's marks and contact details. */
|
|
254
|
+
function Stationery({ brand, mark }) {
|
|
255
|
+
const marks = { ...STATIONERY_MARKS, ...(brand?.stationery?.marks ?? {}) }
|
|
256
|
+
const info = brandInfo(brand)
|
|
257
|
+
return (
|
|
258
|
+
<div className="kol-grid mt-8">
|
|
259
|
+
<div className="col-span-2"><AssetCard caption="Business card, front"><div className="aspect-[85/55]"><BusinessCardFront mark={mark(marks.card, { className: 'max-h-[70%]' })} /></div></AssetCard></div>
|
|
260
|
+
<div className="col-span-2"><AssetCard caption="Envelope"><div className="aspect-[220/110]"><Envelope mark={mark(marks.envelope)} info={info} /></div></AssetCard></div>
|
|
261
|
+
<div className="col-span-2 flex flex-col gap-3">
|
|
262
|
+
<AssetCard caption="Business card, back"><div className="aspect-[85/55]"><BusinessCardBack info={info} /></div></AssetCard>
|
|
263
|
+
<AssetCard caption="Email signature"><EmailSignature mark={mark(marks.signature)} info={info} /></AssetCard>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="col-span-2"><AssetCard caption="Letterhead"><div className="aspect-[210/297]"><Letterhead mark={mark(marks.letter, { style: { height: '100%', width: 'auto' } })} info={info} /></div></AssetCard></div>
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* Social — one frame per template at its ratio; the image when the manifest has one, the mark on a plain ground when not. */
|
|
271
|
+
function Social({ brand, mark }) {
|
|
272
|
+
const templates = brand?.social?.templates ?? []
|
|
273
|
+
if (!templates.length) return null
|
|
274
|
+
return (
|
|
275
|
+
<ContentCollection cols={3} className="mt-8 items-start">
|
|
276
|
+
{templates.map(({ caption, ratio, src }) => (
|
|
277
|
+
<AssetCard key={caption ?? src} caption={caption}>
|
|
278
|
+
<div className={`relative overflow-hidden ${src ? '' : 'bg-fg-08'}`} style={{ aspectRatio: ratio }}>
|
|
279
|
+
{src && <img src={src} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />}
|
|
280
|
+
{src && <div className="absolute inset-x-0 bottom-0 h-1/3 bg-gradient-to-t from-black/60 to-transparent" aria-hidden="true" />}
|
|
281
|
+
<div className={`absolute bottom-[6%] left-[6%] w-[42%] ${src ? 'text-fg-inverse' : 'text-emphasis'}`}>
|
|
282
|
+
{mark(brand.social.mark ?? 'wordmark', { title: brand?.meta?.name })}
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</AssetCard>
|
|
286
|
+
))}
|
|
287
|
+
</ContentCollection>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function Profile({ brand, mark }) {
|
|
292
|
+
const avatars = brand?.social?.avatars ?? []
|
|
293
|
+
const marks = { ...STATIONERY_MARKS, ...(brand?.stationery?.marks ?? {}) }
|
|
294
|
+
if (!avatars.length) return null
|
|
295
|
+
return (
|
|
296
|
+
<div className="kol-grid mt-8 items-start">
|
|
297
|
+
{avatars.map(({ bg, polarity }, i) => <AssetCard key={i}><BrandAvatar mark={mark(marks.avatar, { className: 'w-1/2' })} bg={bg} polarity={polarity} /></AssetCard>)}
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const SPECIMENS = {
|
|
303
|
+
contents: Contents,
|
|
304
|
+
logo: Marks,
|
|
305
|
+
lockups: Lockups,
|
|
306
|
+
color: Color,
|
|
307
|
+
typography: Typography,
|
|
308
|
+
'logos-table': LogosTable,
|
|
309
|
+
branded: Branded,
|
|
310
|
+
stationery: Stationery,
|
|
311
|
+
social: Social,
|
|
312
|
+
profile: Profile,
|
|
313
|
+
}
|
package/src/ColorLab.jsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ColorPickerPopover, Dropdown, HARMONIES, PaletteHarmonyWheel, ToggleSwitch, hslToHex } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { COLOR_MIX, COLOR_ROLES, COLOR_SLOTS, DEFAULT_COLOR_LAB, deriveColorLab, rgbText } from './colorLab.js'
|
|
4
|
+
import { fgOn } from './comboMath.js'
|
|
5
|
+
|
|
6
|
+
/* taxonomy-ok: organism — the color builder: harmony wheel, roles, slots, palette, ramps and the 60/30/10 strip over one value */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ColorLab — an interactive palette builder from one base color.
|
|
10
|
+
*
|
|
11
|
+
* Carried from kol-atlas's Color page (2026-10-20). Six sections over ONE value: Harmony (base +
|
|
12
|
+
* rule on KOL's wheel), Roles (the harmony's five), Brand slots (four, two dark two light), Palette
|
|
13
|
+
* (ten hues 36° apart + blue-grey and grey ramps), Ramps (five blend steps per hue) and Composition
|
|
14
|
+
* (60/30/10). Select a slot or a composition share, then click any swatch to fill it.
|
|
15
|
+
*
|
|
16
|
+
* The math is `colorLab.js` (`deriveColorLab`, `colorLabCss`), so a consumer can save the value into
|
|
17
|
+
* a brand and emit it as CSS without rendering the lab.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} value `{ base, harmony, blueBase, greyBase, blueLinked, slots, picks }` — controlled; omit for internal
|
|
20
|
+
* @param {Function} onChange `(next) => void`
|
|
21
|
+
* @param {string} className classes on the root
|
|
22
|
+
*/
|
|
23
|
+
const LABEL = 'kol-helper-10 tracking-widest text-meta uppercase'
|
|
24
|
+
const BARE_SWITCH = '[&>.toggle-switch-label]:hidden'
|
|
25
|
+
const PICKER = '[&>*]:!size-3'
|
|
26
|
+
/* ten columns capped so the 5-row ramp fits the window — a swatch is the same size in every section */
|
|
27
|
+
const ROW_MAX = 'calc((100vh - 200px) * 2)'
|
|
28
|
+
const isHex = (hex) => /^#[0-9a-f]{6}/i.test(hex)
|
|
29
|
+
|
|
30
|
+
function Swatch({ hex, name, selected, onClick, info }) {
|
|
31
|
+
return (
|
|
32
|
+
<button type="button" onClick={onClick} title={`${name} ${hex}`} className={`group relative aspect-square cursor-pointer overflow-hidden rounded ${selected ? 'ring-2 ring-[var(--kol-accent-primary)]' : ''}`} style={{ background: hex }}>
|
|
33
|
+
<span className={`absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-surface-primary px-1.5 py-1 text-left kol-mono-10 leading-tight transition-opacity ${info ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
|
34
|
+
<span className="truncate text-emphasis">{name}</span>
|
|
35
|
+
<span className="truncate text-meta">{hex}</span>
|
|
36
|
+
<span className="truncate text-meta">{rgbText(hex)}</span>
|
|
37
|
+
</span>
|
|
38
|
+
</button>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Row = ({ children }) => <div className="grid grid-cols-10 gap-2" style={{ maxWidth: ROW_MAX }}>{children}</div>
|
|
43
|
+
|
|
44
|
+
const Switch = ({ label, checked, onChange }) => (
|
|
45
|
+
<span className="flex items-center gap-2"><span className={LABEL}>{label}</span><ToggleSwitch size="sm" className={BARE_SWITCH} checked={checked} onChange={onChange} /></span>
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
function Section({ title, info, onInfo, extra, children }) {
|
|
49
|
+
return (
|
|
50
|
+
<section className="flex flex-col gap-3">
|
|
51
|
+
<div className="flex items-center gap-6">
|
|
52
|
+
<h3 className="m-0 kol-eyebrow text-fg-80">{title}</h3>
|
|
53
|
+
{onInfo && <Switch label="Info" checked={info} onChange={onInfo} />}
|
|
54
|
+
{extra}
|
|
55
|
+
</div>
|
|
56
|
+
{children}
|
|
57
|
+
</section>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function ColorLab({ value: valueProp, onChange, className = '' }) {
|
|
62
|
+
const [own, setOwn] = useState(DEFAULT_COLOR_LAB)
|
|
63
|
+
const value = valueProp ?? own
|
|
64
|
+
const set = (patch) => { const next = { ...value, ...patch }; if (valueProp === undefined) setOwn(next); onChange?.(next) }
|
|
65
|
+
const [slot, setSlot] = useState(null)
|
|
66
|
+
const [info, setInfo] = useState({ roles: false, brand: false, palette: false, ramps: false, mix: false })
|
|
67
|
+
const infoFor = (k) => ({ info: info[k], onInfo: (v) => setInfo((x) => ({ ...x, [k]: v })) })
|
|
68
|
+
const c = deriveColorLab(value)
|
|
69
|
+
|
|
70
|
+
/* a swatch click fills the selected slot (0–3) or composition share (4–6) */
|
|
71
|
+
const assign = (hex) => {
|
|
72
|
+
if (slot === null) return
|
|
73
|
+
if (slot >= 4) set({ picks: [0, 1, 2].map((i) => (i === slot - 4 ? hex : value.picks?.[i] ?? null)) })
|
|
74
|
+
else set({ slots: c.slots.map((v, i) => (i === slot ? hex : v)) })
|
|
75
|
+
setSlot(null)
|
|
76
|
+
}
|
|
77
|
+
const toggleSlot = (i) => setSlot(slot === i ? null : i)
|
|
78
|
+
const picked = (hex) => c.slots.includes(hex)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className={`flex flex-col gap-10 ${className}`.trim()}>
|
|
82
|
+
<Section title="Harmony">
|
|
83
|
+
<div className="flex flex-wrap items-start gap-x-8 gap-y-4">
|
|
84
|
+
<div className="flex flex-col gap-2"><span className={LABEL}>Base</span><span className="flex h-8 items-center gap-2"><ColorPickerPopover size="sm" className={PICKER} color={c.base} onChange={(hex) => isHex(hex) && set({ base: hex.slice(0, 7).toUpperCase() })} label="Base color" /><span className="kol-mono-10 text-meta">{c.base}</span></span></div>
|
|
85
|
+
<div className="flex flex-col gap-2"><span className={LABEL}>Harmony</span><Dropdown size="sm" value={c.harmony} onChange={(v) => set({ harmony: v })} options={HARMONIES.map((x) => ({ value: x.id, label: x.label }))} /></div>
|
|
86
|
+
<div className="flex flex-col gap-2"><span className={LABEL}>Info</span><span className="flex h-8 items-center"><ToggleSwitch size="sm" className={BARE_SWITCH} checked={Object.values(info).every(Boolean)} onChange={(v) => setInfo({ roles: v, brand: v, palette: v, ramps: v, mix: v })} /></span></div>
|
|
87
|
+
</div>
|
|
88
|
+
<PaletteHarmonyWheel size={220} hue={c.h} harmony={c.harmony} saturation={c.s} lightness={c.l} onHueChange={(hh) => set({ base: hslToHex(hh, c.s, c.l).toUpperCase() })} />
|
|
89
|
+
</Section>
|
|
90
|
+
|
|
91
|
+
<Section title="Roles" {...infoFor('roles')}>
|
|
92
|
+
<Row>{c.roles.map((hex, i) => <Swatch key={i} info={info.roles} hex={hex} name={COLOR_ROLES[i]} onClick={() => assign(hex)} />)}</Row>
|
|
93
|
+
</Section>
|
|
94
|
+
|
|
95
|
+
<Section title="Brand slots" {...infoFor('brand')}>
|
|
96
|
+
<Row>{c.slots.map((hex, i) => <Swatch key={i} info={info.brand} hex={hex} name={COLOR_SLOTS[i]} selected={slot === i} onClick={() => toggleSlot(i)} />)}</Row>
|
|
97
|
+
</Section>
|
|
98
|
+
|
|
99
|
+
<Section title="Palette" {...infoFor('palette')} extra={<Switch label="Link blue" checked={c.blueLinked} onChange={(v) => set({ blueLinked: v })} />}>
|
|
100
|
+
<Row>{c.hues.map((x) => <Swatch key={x.name + x.hex} info={info.palette} hex={x.hex} name={x.name} selected={picked(x.hex)} onClick={() => assign(x.hex)} />)}</Row>
|
|
101
|
+
{[['Blue-grey', c.blueGrey, c.blueBase, 'blueBase', true], ['Grey', c.grey, c.greyBase, 'greyBase', false]].map(([row, ramp, rb, key, linkable]) => (
|
|
102
|
+
<div key={row} className="mt-4 flex flex-col gap-2">
|
|
103
|
+
<span className="flex h-4 items-center gap-3"><span className={`${LABEL} w-[120px]`}>{row} base</span>
|
|
104
|
+
{!(linkable && c.blueLinked) && <><ColorPickerPopover size="sm" className={PICKER} color={rb} onChange={(hex) => isHex(hex) && set({ [key]: hex.slice(0, 7).toUpperCase() })} label={`${row} base`} /><span className="kol-mono-10 text-meta">{rb}</span></>}</span>
|
|
105
|
+
<Row>{ramp.map((hex, i) => <Swatch key={row + i} info={info.palette} hex={hex} name={`${row} ${(i + 1) * 100}`} selected={picked(hex)} onClick={() => assign(hex)} />)}</Row>
|
|
106
|
+
</div>
|
|
107
|
+
))}
|
|
108
|
+
</Section>
|
|
109
|
+
|
|
110
|
+
<Section title="Ramps" {...infoFor('ramps')}>
|
|
111
|
+
<Row>
|
|
112
|
+
{[0, 1, 2, 3, 4].flatMap((step) => c.ramps.map((hue) => {
|
|
113
|
+
const hex = hue.steps[step]
|
|
114
|
+
return <Swatch key={hue.hex + step} info={info.ramps} hex={hex} name={`${hue.name} ${(step + 1) * 100}`} selected={picked(hex)} onClick={() => assign(hex)} />
|
|
115
|
+
}))}
|
|
116
|
+
</Row>
|
|
117
|
+
</Section>
|
|
118
|
+
|
|
119
|
+
<Section title="Composition" {...infoFor('mix')}>
|
|
120
|
+
<div style={{ maxWidth: ROW_MAX }}>
|
|
121
|
+
<div className="flex h-40 overflow-hidden rounded">
|
|
122
|
+
{COLOR_MIX.map(([share, label], i) => (
|
|
123
|
+
<button key={label} type="button" onClick={() => toggleSlot(4 + i)} title={`${label} ${c.mix[i]}`} className={`flex min-w-0 cursor-pointer flex-col justify-between p-3 text-left transition-colors ${slot === 4 + i ? 'ring-2 ring-inset ring-[var(--kol-accent-primary)]' : ''}`} style={{ flex: share, background: c.mix[i], color: fgOn(c.mix[i]) }}>
|
|
124
|
+
<span className="kol-sans-heading-03 leading-none">{share * 10}</span>
|
|
125
|
+
<span className={`truncate kol-mono-10 leading-tight transition-opacity ${info.mix ? 'opacity-100' : 'opacity-0'}`}>{c.mix[i]}</span>
|
|
126
|
+
</button>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-3">
|
|
130
|
+
{COLOR_MIX.map(([, label, note], i) => (
|
|
131
|
+
<div key={label}>
|
|
132
|
+
<div className="mb-1.5 flex items-center gap-2.5">
|
|
133
|
+
<span className="size-4 rounded-[3px] border border-fg-16" style={{ background: c.mix[i] }} />
|
|
134
|
+
<span className="kol-sans-body-02 text-emphasis">{label}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<p className="m-0 kol-sans-body-03 text-meta">{note}</p>
|
|
137
|
+
</div>
|
|
138
|
+
))}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</Section>
|
|
142
|
+
</div>
|
|
143
|
+
)
|
|
144
|
+
}
|
package/src/Lockup.jsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* taxonomy-ok: molecule — a mark beside a parent name, a product name and descriptors */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lockup — A branded-house lockup of two names. The branded-house lockup: a parent name over a product name and an optional uppercase
|
|
5
|
+
* descriptor, beside the mark.
|
|
6
|
+
*
|
|
7
|
+
* Carried from kol-atlas `components/lockup/Lockup.jsx` (itself kol-client-canalix's branded-house
|
|
8
|
+
* Lockup), 2026-10-20 — the parent name, the mark and the face are props now, not Atlas's.
|
|
9
|
+
*
|
|
10
|
+
* horizontal — mark + stacked column · hero — big anatomy, framed mark, second descriptor · wordmark — no mark
|
|
11
|
+
*
|
|
12
|
+
* Ink is currentColor, so it follows the surface. Without a `mark` a placeholder square stands in.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} parent the parent brand name, e.g. "Kolkrabbi"
|
|
15
|
+
* @param {string} product the product name; empty → the parent takes the product slot (horizontal)
|
|
16
|
+
* @param {string} descriptor uppercase descriptor, e.g. "IS"
|
|
17
|
+
* @param {string} descriptor2 hero only — a second descriptor
|
|
18
|
+
* @param {'sm'|'md'|'lg'|'xl'} scale
|
|
19
|
+
* @param {'horizontal'|'hero'|'wordmark'} variant
|
|
20
|
+
* @param {ReactNode} mark the mark, drawn at the scale's mark size
|
|
21
|
+
* @param {string} font CSS font-family for the names; inherits by default
|
|
22
|
+
*/
|
|
23
|
+
export const LOCKUP_SCALES = {
|
|
24
|
+
sm: { mark: 32, wordmark: 12, product: 24, descriptor: 16, gap: 8, wordmarkLH: 16 },
|
|
25
|
+
md: { mark: 48, wordmark: 14, product: 36, descriptor: 24, gap: 12, wordmarkLH: 18 },
|
|
26
|
+
lg: { mark: 80, wordmark: 20, product: 56, descriptor: 36, gap: 16, wordmarkLH: 24 },
|
|
27
|
+
xl: { mark: 128, wordmark: 40, product: 96, descriptor: 56, gap: 20, wordmarkLH: 48 },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const parentCls = 'font-bold whitespace-nowrap'
|
|
31
|
+
const productCls = 'font-bold leading-none whitespace-nowrap tracking-[-0.01em]'
|
|
32
|
+
const descriptorCls = 'font-normal leading-none whitespace-nowrap uppercase tracking-[0.01em]'
|
|
33
|
+
|
|
34
|
+
function Mark({ size, framed, mark }) {
|
|
35
|
+
return (
|
|
36
|
+
<div className={`shrink-0 box-border ${framed ? 'border border-current/16' : ''}`} style={{ width: size, height: size, padding: framed ? size * 0.12 : 0, borderRadius: framed ? Math.max(4, size * 0.08) : 0 }}>
|
|
37
|
+
{mark
|
|
38
|
+
? <div className="flex size-full items-center justify-center [&_svg]:size-full">{mark}</div>
|
|
39
|
+
: <div className="size-full rounded-[18%] bg-current opacity-80" />}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default function Lockup({ parent = 'Brand', product = '', descriptor = '', descriptor2 = '', scale = 'md', variant = 'horizontal', mark, font, className = '' }) {
|
|
45
|
+
const d = LOCKUP_SCALES[scale] ?? LOCKUP_SCALES.md
|
|
46
|
+
const style = font ? { fontFamily: font } : undefined
|
|
47
|
+
|
|
48
|
+
if (variant === 'hero') {
|
|
49
|
+
const x = LOCKUP_SCALES.xl
|
|
50
|
+
return (
|
|
51
|
+
<div className={`inline-flex items-start ${className}`.trim()} style={{ ...style, gap: x.gap * 1.5, padding: x.gap * 3 }}>
|
|
52
|
+
<Mark size={x.mark} framed mark={mark} />
|
|
53
|
+
<div className="flex flex-col justify-between" style={{ gap: x.gap }}>
|
|
54
|
+
<span className={parentCls} style={{ fontSize: x.wordmark }}>{parent}</span>
|
|
55
|
+
<div className="flex items-start" style={{ gap: x.gap * 0.75 }}>
|
|
56
|
+
<div className="flex flex-col" style={{ gap: x.gap * 0.5 }}>
|
|
57
|
+
{product && <span className={`${productCls} tracking-[-0.015em]`} style={{ fontSize: x.product }}>{product}</span>}
|
|
58
|
+
{descriptor && <span className={`${descriptorCls} font-medium opacity-40 text-right`} style={{ fontSize: x.descriptor }}>{descriptor}</span>}
|
|
59
|
+
</div>
|
|
60
|
+
{descriptor2 && <span className={`${descriptorCls} font-medium opacity-40`} style={{ fontSize: x.descriptor * 1.2, paddingTop: x.gap }}>{descriptor2}</span>}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const row = (
|
|
68
|
+
<div className="flex items-end" style={{ gap: d.gap / 2 }}>
|
|
69
|
+
{(product || variant === 'horizontal') && <span className={productCls} style={{ fontSize: d.product }}>{product || parent}</span>}
|
|
70
|
+
{descriptor && <span className={descriptorCls} style={{ fontSize: d.descriptor, paddingBottom: d.descriptor * 0.05 }}>{descriptor}</span>}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if (variant === 'wordmark') {
|
|
75
|
+
return (
|
|
76
|
+
<div className={`inline-flex flex-col items-start ${className}`.trim()} style={{ ...style, padding: d.gap * 2, gap: d.gap / 2 }}>
|
|
77
|
+
<span className={parentCls} style={{ fontSize: d.wordmark }}>{parent}</span>
|
|
78
|
+
{row}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* horizontal: no product → the parent name takes the product slot */
|
|
84
|
+
return (
|
|
85
|
+
<div className={`inline-flex items-end ${className}`.trim()} style={{ ...style, gap: d.gap, padding: d.gap * 2 }}>
|
|
86
|
+
<Mark size={d.mark} mark={mark} />
|
|
87
|
+
<div className={`flex flex-col ${product ? 'justify-between' : 'justify-end'}`} style={{ minHeight: d.mark, gap: d.gap / 2 }}>
|
|
88
|
+
{product && <span className={parentCls} style={{ fontSize: d.wordmark, lineHeight: `${d.wordmarkLH}px` }}>{parent}</span>}
|
|
89
|
+
{row}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Input, Label, SegmentedToggle } from '@kolkrabbi/kol-component'
|
|
3
|
+
import Lockup from './Lockup.jsx'
|
|
4
|
+
|
|
5
|
+
/* taxonomy-ok: organism — the live lockup builder: name / descriptor / variant / scale controls beside a Lockup stage, preset rows below */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LockupBuilder — Build a product lockup from a name. Type a product name, add descriptors, pick a variant and scale; the presets run a
|
|
9
|
+
* brand's naming levels through the same Lockup.
|
|
10
|
+
*
|
|
11
|
+
* Carried from kol-atlas `components/lockup/LiveLockup.jsx` (2026-10-20); the parent, the mark, the
|
|
12
|
+
* face and the presets are the brand's, passed in.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} parent the parent brand name
|
|
15
|
+
* @param {ReactNode} mark the mark (see Lockup)
|
|
16
|
+
* @param {string} font CSS font-family for the names
|
|
17
|
+
* @param {Object} presets `{ [level]: string[] }` — e.g. `{ Products: ['Hosting', …] }`
|
|
18
|
+
* @param {string} className classes on the root
|
|
19
|
+
*/
|
|
20
|
+
const VARIANTS = [{ value: 'horizontal', label: 'Horizontal' }, { value: 'hero', label: 'Hero' }, { value: 'wordmark', label: 'Wordmark' }]
|
|
21
|
+
const SCALES = ['sm', 'md', 'lg', 'xl'].map((s) => ({ value: s, label: s.toUpperCase() }))
|
|
22
|
+
|
|
23
|
+
const Field = ({ label, children }) => <div className="flex flex-col gap-2"><Label>{label}</Label>{children}</div>
|
|
24
|
+
|
|
25
|
+
export default function LockupBuilder({ parent = 'Brand', mark, font, presets = {}, className = '' }) {
|
|
26
|
+
const first = Object.values(presets)[0]?.[0] ?? ''
|
|
27
|
+
const [s, setS] = useState({ product: first, descriptor: '', descriptor2: '', variant: 'horizontal', scale: 'lg' })
|
|
28
|
+
const set = (patch) => setS((p) => ({ ...p, ...patch }))
|
|
29
|
+
const brand = { parent, mark, font }
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
33
|
+
<div className="grid gap-6 md:grid-cols-[300px_minmax(0,1fr)]">
|
|
34
|
+
<div className="flex flex-col gap-5 self-start rounded-lg border border-fg-08 p-5">
|
|
35
|
+
<Field label="Product name"><Input value={s.product} onChange={(e) => set({ product: e.target.value })} placeholder="e.g. Hosting" /></Field>
|
|
36
|
+
<Field label="Descriptor"><Input value={s.descriptor} onChange={(e) => set({ descriptor: e.target.value })} placeholder="e.g. IS, EU" /></Field>
|
|
37
|
+
{s.variant === 'hero' && <Field label="Descriptor 2"><Input value={s.descriptor2} onChange={(e) => set({ descriptor2: e.target.value })} placeholder="e.g. Solution" /></Field>}
|
|
38
|
+
<Field label="Variant"><SegmentedToggle size="sm" value={s.variant} onChange={(v) => set({ variant: v })} options={VARIANTS} /></Field>
|
|
39
|
+
<Field label="Scale"><SegmentedToggle size="sm" value={s.scale} onChange={(v) => set({ scale: v })} options={SCALES} /></Field>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="flex min-h-[320px] items-center justify-center overflow-auto rounded-lg border border-fg-08 text-emphasis">
|
|
42
|
+
<Lockup {...brand} {...s} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
{Object.entries(presets).map(([group, names]) => (
|
|
46
|
+
<div key={group}>
|
|
47
|
+
<Label>{group}</Label>
|
|
48
|
+
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-3">
|
|
49
|
+
{names.map((n) => (
|
|
50
|
+
<button key={n} type="button" onClick={() => set({ product: n, descriptor: '', variant: 'horizontal' })}
|
|
51
|
+
className="flex min-h-[96px] cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-fg-08 text-left text-emphasis hover:bg-fg-04">
|
|
52
|
+
<Lockup {...brand} product={n} scale="sm" />
|
|
53
|
+
</button>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
))}
|
|
58
|
+
</div>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Dropdown, Input } from '@kolkrabbi/kol-component'
|
|
3
|
+
import { DEFAULT_TYPE_SCALE, TYPE_RATIOS, typeScale } from './typeScale.js'
|
|
4
|
+
|
|
5
|
+
/* taxonomy-ok: organism — the type-scale generator: base + ratio controls over a spec row and a live sample per step */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TypeScaleLab — a modular type scale to try ratios on.
|
|
9
|
+
*
|
|
10
|
+
* Carried from kol-atlas's Typography page (2026-10-20). A base size and a musical ratio set every
|
|
11
|
+
* step (`typeScale.js`); each row is a spec label (role · token · face · weight · size · line height)
|
|
12
|
+
* over a live sample. The faces are the BRAND's, passed in — the lab never sets KOL's type.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} value `{ base, ratio }` — controlled; omit for internal
|
|
15
|
+
* @param {Function} onChange `(next) => void`
|
|
16
|
+
* @param {Object} faces `{ display, heading, text }` — `{ family, label, weight }` each
|
|
17
|
+
* @param {Object} samples `{ headline, text }` — the sample strings
|
|
18
|
+
* @param {string} className classes on the root
|
|
19
|
+
*/
|
|
20
|
+
const LABEL = 'kol-helper-10 tracking-widest text-meta uppercase'
|
|
21
|
+
const FACES = {
|
|
22
|
+
display: { family: 'var(--kol-font-family-sans-narrow)', label: 'Right Grotesk Narrow', weight: 500 },
|
|
23
|
+
heading: { family: 'var(--kol-font-family-sans-compact)', label: 'Right Grotesk Compact', weight: 500 },
|
|
24
|
+
text: { family: 'var(--kol-font-family-sans)', label: 'Right Grotesk', weight: 400 },
|
|
25
|
+
}
|
|
26
|
+
const SAMPLES = {
|
|
27
|
+
headline: 'The quick brown fox jumps over the lazy dog',
|
|
28
|
+
text: 'A modular scale sets every size from one base and one ratio, so the steps keep the same proportion from the smallest note to the largest headline.',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function TypeScaleLab({ value: valueProp, onChange, faces = FACES, samples = SAMPLES, className = '' }) {
|
|
32
|
+
const [own, setOwn] = useState(DEFAULT_TYPE_SCALE)
|
|
33
|
+
const value = valueProp ?? own
|
|
34
|
+
const set = (patch) => { const next = { ...value, ...patch }; if (valueProp === undefined) setOwn(next); onChange?.(next) }
|
|
35
|
+
const r = TYPE_RATIOS.find((x) => x.id === value.ratio) ?? TYPE_RATIOS[3]
|
|
36
|
+
const face = (k) => ({ ...FACES[k], ...faces[k] })
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
40
|
+
<div className="flex max-w-[724px] flex-col gap-2">
|
|
41
|
+
<h3 className="m-0 kol-sans-heading-04 text-emphasis">{r.label} {r.note}</h3>
|
|
42
|
+
<p className="m-0 kol-sans-body-02 text-fg-80">{r.about}</p>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="flex flex-wrap items-end gap-x-8 gap-y-4">
|
|
45
|
+
<div className="flex flex-col gap-2"><span className={LABEL}>Base</span><Input type="number" size="sm" unit="px" chars={4} value={value.base} onChange={(e) => { const v = Number(e.target.value); if (v >= 8 && v <= 32) set({ base: v }) }} /></div>
|
|
46
|
+
<div className="flex flex-col gap-2"><span className={LABEL}>Ratio</span><Dropdown size="sm" value={r.id} onChange={(v) => set({ ratio: v })} options={TYPE_RATIOS.map((x) => ({ value: x.id, label: `${x.label} · ${x.note}` }))} /></div>
|
|
47
|
+
</div>
|
|
48
|
+
{/* the spec rows keep their columns on a phone and scroll sideways */}
|
|
49
|
+
<div className="overflow-x-auto overscroll-x-contain"><div className="min-w-[720px]">
|
|
50
|
+
{typeScale(value).map((s) => {
|
|
51
|
+
const f = face(s.face)
|
|
52
|
+
return (
|
|
53
|
+
<div key={s.id} className="border-t border-fg-08 py-5">
|
|
54
|
+
<div className="mb-3 grid grid-cols-[120px_minmax(0,1.6fr)_minmax(0,2fr)_repeat(3,minmax(0,1fr))] items-center gap-x-6">
|
|
55
|
+
<span className={LABEL}>{s.role}</span>
|
|
56
|
+
<code className="kol-doc-code-inline justify-self-start whitespace-nowrap">--type-{s.id}</code>
|
|
57
|
+
<span className={LABEL}>{f.label}</span>
|
|
58
|
+
<span className={LABEL}>W: {f.weight}</span>
|
|
59
|
+
<span className={LABEL}>S: <span className="normal-case">{s.size}px</span></span>
|
|
60
|
+
<span className={LABEL}>LH: <span className="normal-case">{s.lh}px</span></span>
|
|
61
|
+
</div>
|
|
62
|
+
<p className="m-0 truncate text-emphasis" style={{ fontFamily: f.family, fontWeight: f.weight, fontSize: s.size, lineHeight: `${s.lh}px` }}>
|
|
63
|
+
{s.face === 'text' ? samples.text : samples.headline}
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
})}
|
|
68
|
+
</div></div>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
package/src/brandBook.js
CHANGED
|
@@ -16,6 +16,7 @@ export const BOOK_SECTIONS = [
|
|
|
16
16
|
{ key: 'hero', id: 'hero', label: 'brand guidelines', lede: 'Client-facing identity guidelines — chapter-structured for handoff. Mirrors the shape of the deliverable PDF.' },
|
|
17
17
|
{ 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.' },
|
|
18
18
|
{ key: 'about', id: 'about', nav: 'About', label: '01 — about', copy: true },
|
|
19
|
+
{ key: 'messages', id: 'key-messages', nav: 'Key messages', label: '01 — about · key messages', title: 'Key messages', copy: true },
|
|
19
20
|
{ key: 'tone', id: 'voice', nav: 'Tone', label: '02 — tone', title: 'Tone', copy: true },
|
|
20
21
|
{ key: 'look', id: 'look', nav: 'Look', label: '03 — look', title: 'Look', copy: true },
|
|
21
22
|
{ key: 'logo', id: 'logos-concept', nav: 'Logo', label: '04 — logos · concept', title: 'The mark', marks: ['wordmark', 'logomark'] },
|
|
@@ -29,8 +30,13 @@ export const ASSET_SECTIONS = [
|
|
|
29
30
|
{ key: 'logos', id: 'logos', nav: 'Logos', label: '01 — logos', title: 'Logos' },
|
|
30
31
|
{ key: 'branded', id: 'branded-assets', nav: 'Branded', label: '04 — branded', title: 'Branded assets' },
|
|
31
32
|
{ 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.' },
|
|
33
|
+
{ key: 'labels', id: 'labels', nav: 'Labels', label: '09 — labels', title: 'Labels', copy: true },
|
|
34
|
+
{ key: 'packaging', id: 'packaging', nav: 'Packaging', label: '10 — packaging', title: 'Packaging', copy: true },
|
|
35
|
+
{ key: 'mockups', id: 'mockups', nav: 'Mockups', label: '11 — mockups', title: 'Mockups', copy: true },
|
|
32
36
|
{ 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.' },
|
|
33
37
|
{ 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.' },
|
|
38
|
+
{ key: 'generators', id: 'generators', nav: 'Generators', label: '14 — generators', title: 'Generators', copy: true },
|
|
39
|
+
{ key: 'deck', id: 'slide-deck', nav: 'Slide deck', label: '15 — slide deck', title: 'Slide deck', copy: true },
|
|
34
40
|
]
|
|
35
41
|
|
|
36
42
|
/* the two pages, for a view switch */
|
|
@@ -47,6 +53,8 @@ function shows(s, brand) {
|
|
|
47
53
|
if (s.copy) return hasCopy(s)
|
|
48
54
|
if (s.key === 'color') return Boolean(brand?.ramps?.length) || hasCopy(s)
|
|
49
55
|
if (s.key === 'logos') return Boolean(brand?.logos?.length)
|
|
56
|
+
/* the marks sections draw the manifest's logos — their default lede is not enough to show empty frames */
|
|
57
|
+
if (s.key === 'logo' || s.key === 'lockups') return Boolean(brand?.logos?.length || s.blocks?.length || s.after?.length)
|
|
50
58
|
if (s.key === 'social') return Boolean(brand?.social?.templates?.length)
|
|
51
59
|
if (s.key === 'profile') return Boolean(brand?.social?.avatars?.length)
|
|
52
60
|
return true
|
package/src/colorLab.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* colorLab — the math behind ColorLab: one base color + a harmony rule → the whole palette.
|
|
3
|
+
*
|
|
4
|
+
* Carried from kol-atlas `components/graphics/ColorLab.jsx` (2026-10-20), its store lifted out
|
|
5
|
+
* into a plain value so the lab is controlled and the result can be saved into a brand.
|
|
6
|
+
*
|
|
7
|
+
* Palette ten hues 36° apart at the base's saturation and lightness; blue-grey and grey ramps
|
|
8
|
+
* of ten steps, each from its own base (blue-grey follows the base hue while linked).
|
|
9
|
+
* Ramps five steps per hue by the "Material Girl" blend: the base, Screen (lighter) and
|
|
10
|
+
* Multiply (darker) of the base over itself at 50%, applied once and twice.
|
|
11
|
+
* Slots four brand slots, two dark and two light, picked from any swatch.
|
|
12
|
+
* Mix 60 / 30 / 10 by area, following Light · Secondary · Primary until a share has its own pick.
|
|
13
|
+
*/
|
|
14
|
+
import { generateHarmony, hexToHsl, hslToHex } from '@kolkrabbi/kol-component'
|
|
15
|
+
|
|
16
|
+
/* the twelve standard hue names, 30° apart; hues 36° apart never share one */
|
|
17
|
+
const HUE_NAMES = ['Red', 'Orange', 'Yellow', 'Chartreuse', 'Green', 'Spring', 'Cyan', 'Azure', 'Blue', 'Violet', 'Magenta', 'Rose']
|
|
18
|
+
export const hueName = (h) => HUE_NAMES[Math.round(h / 30) % 12]
|
|
19
|
+
|
|
20
|
+
const toRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
21
|
+
const toHex = (rgb) => '#' + rgb.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()
|
|
22
|
+
const screen = (c) => c + 0.5 * (1 - (1 - c) ** 2 - c)
|
|
23
|
+
const multiply = (c) => c + 0.5 * (c * c - c)
|
|
24
|
+
export const rgbText = (hex) => `rgb(${toRgb(hex).map((c) => Math.round(c * 255)).join(', ')})`
|
|
25
|
+
|
|
26
|
+
/** five steps, 100 lightest … 500 darkest, the base at 300 */
|
|
27
|
+
export function blendRamp(hex) {
|
|
28
|
+
const base = toRgb(hex)
|
|
29
|
+
const s1 = base.map(screen), m1 = base.map(multiply)
|
|
30
|
+
return [s1.map(screen), s1, base, m1, m1.map(multiply)].map(toHex)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** ten steps of one hue and saturation, lightness 97 → 12 */
|
|
34
|
+
export const neutralRamp = (h, s) => Array.from({ length: 10 }, (_, i) => hslToHex(h, s, Math.round(97 - i * 9.5)).toUpperCase())
|
|
35
|
+
|
|
36
|
+
export const COLOR_SLOTS = ['Brand dark', 'Brand black', 'Light', 'Light 2']
|
|
37
|
+
export const COLOR_ROLES = ['Primary', 'Secondary', 'Light', 'Dark', 'Accent']
|
|
38
|
+
export const COLOR_MIX = [
|
|
39
|
+
[6, '60% Neutral', 'Surface, page canvas.'],
|
|
40
|
+
[3, '30% Secondary', 'Support. Blocks, containers, structural chrome.'],
|
|
41
|
+
[1, '10% Primary', 'Signature. Brand moment, CTA, identity plate.'],
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
/** the lab's starting value; `slots` / `picks` null means "follow the derived default" */
|
|
45
|
+
export const DEFAULT_COLOR_LAB = {
|
|
46
|
+
base: '#3D7FF0', harmony: 'analogous', blueBase: '#6B7A99', greyBase: '#808080', blueLinked: true,
|
|
47
|
+
slots: null, picks: [null, null, null],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** everything the lab draws, from its value */
|
|
51
|
+
export function deriveColorLab(value) {
|
|
52
|
+
const v = { ...DEFAULT_COLOR_LAB, ...value }
|
|
53
|
+
const { h, s, l } = hexToHsl(v.base)
|
|
54
|
+
const hues = Array.from({ length: 10 }, (_, i) => { const hh = (h + i * 36) % 360; return { name: hueName(hh), hex: hslToHex(hh, s, l).toUpperCase() } })
|
|
55
|
+
const nb = hexToHsl(v.blueBase), ng = hexToHsl(v.greyBase)
|
|
56
|
+
const blueGrey = v.blueLinked ? neutralRamp(h, 20) : neutralRamp(nb.h, nb.s)
|
|
57
|
+
const grey = neutralRamp(ng.h, ng.s)
|
|
58
|
+
const slots = v.slots ?? [blueGrey[7], grey[9], grey[0], grey[1]]
|
|
59
|
+
const roles = generateHarmony(v.base, v.harmony)
|
|
60
|
+
const mix = [slots[2], roles[1], roles[0]].map((hex, i) => v.picks?.[i] ?? hex)
|
|
61
|
+
const ramps = hues.map((x) => ({ ...x, steps: blendRamp(x.hex) }))
|
|
62
|
+
return { ...v, h, s, l, hues, blueGrey, grey, slots, roles, mix, ramps }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
66
|
+
|
|
67
|
+
/** the lab's result as CSS custom properties — one `:root` block, brand-prefixed */
|
|
68
|
+
export function colorLabCss(value, prefix = 'brand') {
|
|
69
|
+
const c = deriveColorLab(value)
|
|
70
|
+
const line = (name, hex) => ` --${prefix}-${name}: ${hex};`
|
|
71
|
+
return [
|
|
72
|
+
':root {',
|
|
73
|
+
...COLOR_ROLES.map((r, i) => line(`role-${slug(r)}`, c.roles[i])),
|
|
74
|
+
...COLOR_SLOTS.map((r, i) => line(`slot-${slug(r)}`, c.slots[i])),
|
|
75
|
+
...COLOR_MIX.map(([, label], i) => line(`mix-${slug(label).replace(/^\d+-/, '')}`, c.mix[i])),
|
|
76
|
+
...c.ramps.flatMap((x) => x.steps.map((hex, i) => line(`${slug(x.name)}-${(i + 1) * 100}`, hex))),
|
|
77
|
+
...c.blueGrey.map((hex, i) => line(`blue-grey-${(i + 1) * 100}`, hex)),
|
|
78
|
+
...c.grey.map((hex, i) => line(`grey-${(i + 1) * 100}`, hex)),
|
|
79
|
+
'}',
|
|
80
|
+
].join('\n')
|
|
81
|
+
}
|
package/src/guideBook.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* The brand guide's chapter model — kol-branding's src/template/book/brandBook.js (2026-10-24), carried for `BrandGuide`
|
|
2
|
+
* (BrandGuideOutlineTwoVersions, 2026-10-27). The book owns no chapter list: it renders the one it is handed —
|
|
3
|
+
* `@kolkrabbi/kol-brand-template`'s `GUIDE_SECTIONS`, or a client's own. The helpers the identity book already
|
|
4
|
+
* exports (brandInfo, the stationery marks, the anchor scroll, the svg helpers) stay in ./brandBook.js. */
|
|
5
|
+
|
|
6
|
+
const hasCopy = (s) => Boolean(s.lede || s.blocks?.length || s.after?.length || s.rules?.length || s.spec || s.dos?.length || s.donts?.length || s.words || s.pairs?.length)
|
|
7
|
+
|
|
8
|
+
/* Whether a chapter has anything to show for this manifest. The specimen kinds need the data they draw;
|
|
9
|
+
* everything else shows when it has copy. */
|
|
10
|
+
export function shows(s, brand) {
|
|
11
|
+
const kind = s.kind ?? 'prose'
|
|
12
|
+
if (kind === 'hero' || kind === 'contents' || kind === 'branded' || kind === 'stationery') return true
|
|
13
|
+
if (kind === 'logos-table') return Boolean(brand?.logos?.length)
|
|
14
|
+
if (kind === 'logo' || kind === 'lockups') return Boolean(brand?.logos?.length) || hasCopy(s)
|
|
15
|
+
if (kind === 'color') return Boolean(brand?.ramps?.length) || hasCopy(s)
|
|
16
|
+
if (kind === 'typography') return Boolean(brand?.type?.families?.length) || hasCopy(s)
|
|
17
|
+
if (kind === 'social') return Boolean(brand?.social?.templates?.length) || hasCopy(s)
|
|
18
|
+
if (kind === 'profile') return Boolean(brand?.social?.avatars?.length) || hasCopy(s)
|
|
19
|
+
return hasCopy(s)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The guide's chapters for a manifest — the list's rows merged with `brand.book`, the empty ones dropped. */
|
|
23
|
+
export function guideSections(brand, sections = []) {
|
|
24
|
+
const name = brand?.meta?.name
|
|
25
|
+
return sections
|
|
26
|
+
.map((d) => {
|
|
27
|
+
const s = { ...d, ...(brand?.book?.[d.key] ?? {}) }
|
|
28
|
+
if (d.kind === 'hero' && !s.title) s.title = name
|
|
29
|
+
if (d.key === 'about' && !s.title && name) s.title = `About ${name}`
|
|
30
|
+
return s
|
|
31
|
+
})
|
|
32
|
+
.filter((s) => shows(s, brand))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The rail's entries — `[{ id, label }]`. */
|
|
36
|
+
export const guideToc = (brand, sections) =>
|
|
37
|
+
guideSections(brand, sections).filter((s) => s.nav).map((s) => ({ id: s.id, label: s.nav }))
|
|
38
|
+
|
|
39
|
+
/* The ramp stop a pair names (`teal-300`) → its hex, from the manifest's ramps. */
|
|
40
|
+
export function stopHex(brand, ref) {
|
|
41
|
+
const m = /^(.*)-(\d+)$/.exec(ref ?? '')
|
|
42
|
+
if (!m) return undefined
|
|
43
|
+
return brand?.ramps?.find((r) => r.id === m[1])?.stops?.find((s) => String(s.stop) === m[2])?.value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* WCAG 2 contrast ratio of two hexes — the number a color chapter's approved pairs carry. */
|
|
47
|
+
export function contrast(hexA, hexB) {
|
|
48
|
+
const lum = (hex) => {
|
|
49
|
+
const n = parseInt(hex.replace('#', ''), 16)
|
|
50
|
+
const c = [16, 8, 0].map((sh) => ((n >> sh) & 255) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
51
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
|
|
52
|
+
}
|
|
53
|
+
if (!hexA || !hexB) return undefined
|
|
54
|
+
const [a, b] = [lum(hexA), lum(hexB)]
|
|
55
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
|
|
56
|
+
}
|
package/src/index.js
CHANGED
|
@@ -49,3 +49,17 @@ export { default as Brand } from './Brand.jsx'
|
|
|
49
49
|
export { default as BrandBook } from './BrandBook.jsx'
|
|
50
50
|
export { default as BrandAssets } from './BrandAssets.jsx'
|
|
51
51
|
export { brandSections, brandToc, brandInfo, BRAND_VIEWS, BOOK_SECTIONS, ASSET_SECTIONS } from './brandBook.js'
|
|
52
|
+
|
|
53
|
+
// The brand guide (2026-10-27): kol-branding's guide renderer over a chapter list it is handed —
|
|
54
|
+
// kol-brand-template's GUIDE_SECTIONS, blank (GUIDE_TEMPLATE) or filled by a client's manifest.
|
|
55
|
+
export { default as BrandGuide } from './BrandGuide.jsx'
|
|
56
|
+
export { guideSections, guideToc } from './guideBook.js'
|
|
57
|
+
|
|
58
|
+
// The brand lab (2026-10-20): the generators a brand is MADE with — carried from kol-atlas's Assets pages.
|
|
59
|
+
// Each is controlled by one value, and the math is exported so a brand can be emitted without the UI.
|
|
60
|
+
export { default as ColorLab } from './ColorLab.jsx'
|
|
61
|
+
export { deriveColorLab, colorLabCss, blendRamp, neutralRamp, DEFAULT_COLOR_LAB, COLOR_ROLES, COLOR_SLOTS, COLOR_MIX } from './colorLab.js'
|
|
62
|
+
export { default as TypeScaleLab } from './TypeScaleLab.jsx'
|
|
63
|
+
export { typeScale, typeScaleCss, TYPE_RATIOS, TYPE_STEPS, DEFAULT_TYPE_SCALE } from './typeScale.js'
|
|
64
|
+
export { default as Lockup, LOCKUP_SCALES } from './Lockup.jsx'
|
|
65
|
+
export { default as LockupBuilder } from './LockupBuilder.jsx'
|
package/src/typeScale.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* typeScale — a modular type scale from two inputs: a base size and a musical ratio.
|
|
3
|
+
*
|
|
4
|
+
* Carried from kol-atlas `components/graphics/ScaleLab.jsx` (2026-10-20). Each step is base × ratio^n,
|
|
5
|
+
* rounded to a whole px; line heights snap up to the 4px grid (1.2 for display and headings, 1.5 for
|
|
6
|
+
* text). The ramp owes nothing to KOL's own type scale — it is a brand's.
|
|
7
|
+
*/
|
|
8
|
+
export const TYPE_RATIOS = [
|
|
9
|
+
{ id: 'M2', label: 'Major 2nd', ratio: 9 / 8, note: '9:8', about: 'The gentlest step, with neighbouring sizes close together. It suits dense interfaces and long documents, where hierarchy comes from weight and spacing more than from size.' },
|
|
10
|
+
{ id: 'm3', label: 'Minor 3rd', ratio: 6 / 5, note: '6:5', about: 'A modest step with clear but quiet contrast between levels. It works for product interfaces and editorial text that need order and calm rather than drama.' },
|
|
11
|
+
{ id: 'M3', label: 'Major 3rd', ratio: 5 / 4, note: '5:4', about: 'A balanced step and a common default for websites. Headings stand apart from body text, while the largest sizes stay practical for headlines and page titles.' },
|
|
12
|
+
{ id: 'P4', label: 'Perfect 4th', ratio: 4 / 3, note: '4:3', about: 'A confident step with strong contrast between levels. It suits marketing pages and brand material, where headlines lead and body text supports them below.' },
|
|
13
|
+
{ id: 'A4', label: 'Augmented 4th', ratio: Math.SQRT2, note: '1:√2', about: 'The ratio of A-series paper, set between a fourth and a fifth. Its large jumps read as deliberate and architectural, suited to print and structured layouts.' },
|
|
14
|
+
{ id: 'P5', label: 'Perfect 5th', ratio: 3 / 2, note: '3:2', about: 'A wide step, with few sizes far apart. It suits posters, decks and expressive layouts, where display type carries the page and body text stays secondary.' },
|
|
15
|
+
{ id: 'G', label: 'Golden ratio', ratio: (1 + Math.sqrt(5)) / 2, note: '1:φ', about: 'The widest step here, where sizes grow fast. It is best kept to display and editorial work, since the text end of the scale thins out quickly.' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
/* step n from the base; `face` is which of the brand's faces sets it, `k` the line-height factor */
|
|
19
|
+
export const TYPE_STEPS = [
|
|
20
|
+
{ id: 'display-1', role: 'Display 1', n: 6, face: 'display', k: 1.2 },
|
|
21
|
+
{ id: 'display-2', role: 'Display 2', n: 5, face: 'display', k: 1.2 },
|
|
22
|
+
{ id: 'heading-1', role: 'Heading 1', n: 4, face: 'display', k: 1.2 },
|
|
23
|
+
{ id: 'heading-2', role: 'Heading 2', n: 3, face: 'heading', k: 1.2 },
|
|
24
|
+
{ id: 'heading-3', role: 'Heading 3', n: 2, face: 'heading', k: 1.2 },
|
|
25
|
+
{ id: 'heading-4', role: 'Heading 4', n: 1, face: 'heading', k: 1.2 },
|
|
26
|
+
{ id: 'body', role: 'Body', n: 0, face: 'text', k: 1.5 },
|
|
27
|
+
{ id: 'small', role: 'Small', n: -1, face: 'text', k: 1.5 },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const GRID = 4
|
|
31
|
+
export const DEFAULT_TYPE_SCALE = { base: 16, ratio: 'P4' }
|
|
32
|
+
|
|
33
|
+
/** the steps for `{ base, ratio }` (ratio is a TYPE_RATIOS id) — each with `size` and `lh` in px */
|
|
34
|
+
export function typeScale({ base, ratio } = DEFAULT_TYPE_SCALE) {
|
|
35
|
+
const r = (TYPE_RATIOS.find((x) => x.id === ratio) ?? TYPE_RATIOS[3]).ratio
|
|
36
|
+
return TYPE_STEPS.map((s) => {
|
|
37
|
+
const size = Math.round(base * r ** s.n)
|
|
38
|
+
return { ...s, size, lh: Math.ceil((size * s.k) / GRID) * GRID }
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** the scale as CSS custom properties — `--<prefix>-<step>-size` / `-lh` */
|
|
43
|
+
export function typeScaleCss(value, prefix = 'type') {
|
|
44
|
+
return [':root {', ...typeScale(value).flatMap((s) => [` --${prefix}-${s.id}-size: ${s.size}px;`, ` --${prefix}-${s.id}-lh: ${s.lh}px;`]), '}'].join('\n')
|
|
45
|
+
}
|