@kolkrabbi/kol-styleguide 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-styleguide",
3
- "version": "0.6.0",
3
+ "version": "0.7.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-component": "^0.237.0",
23
- "@kolkrabbi/kol-icons": "^0.33.0",
24
- "@kolkrabbi/kol-framework": "^0.48.0",
25
- "@kolkrabbi/kol-theme": "^0.163.0"
23
+ "@kolkrabbi/kol-component": "^0.296.0",
24
+ "@kolkrabbi/kol-framework": "^0.53.0",
25
+ "@kolkrabbi/kol-icons": "^0.45.0",
26
+ "@kolkrabbi/kol-theme": "^0.184.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-helper-12 text-meta uppercase tracking-wider">
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,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
@@ -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/index.js CHANGED
@@ -49,3 +49,12 @@ 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 lab (2026-10-20): the generators a brand is MADE with — carried from kol-atlas's Assets pages.
54
+ // Each is controlled by one value, and the math is exported so a brand can be emitted without the UI.
55
+ export { default as ColorLab } from './ColorLab.jsx'
56
+ export { deriveColorLab, colorLabCss, blendRamp, neutralRamp, DEFAULT_COLOR_LAB, COLOR_ROLES, COLOR_SLOTS, COLOR_MIX } from './colorLab.js'
57
+ export { default as TypeScaleLab } from './TypeScaleLab.jsx'
58
+ export { typeScale, typeScaleCss, TYPE_RATIOS, TYPE_STEPS, DEFAULT_TYPE_SCALE } from './typeScale.js'
59
+ export { default as Lockup, LOCKUP_SCALES } from './Lockup.jsx'
60
+ export { default as LockupBuilder } from './LockupBuilder.jsx'
@@ -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
+ }