@kolkrabbi/kol-styleguide 0.16.0 → 0.17.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.16.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "description": "Brand guide components and the brand tool",
6
6
  "license": "MIT",
@@ -9,6 +9,7 @@
9
9
  "module": "./src/index.js",
10
10
  "exports": {
11
11
  ".": "./src/index.js",
12
+ "./lab": "./src/lab/index.js",
12
13
  "./src/*": "./src/*"
13
14
  },
14
15
  "peerDependencies": {
@@ -21,11 +22,11 @@
21
22
  "react-dom": "^18.3.0 || ^19.0.0"
22
23
  },
23
24
  "devDependencies": {
24
- "@kolkrabbi/kol-component": "^0.315.0",
25
+ "@kolkrabbi/kol-component": "^0.316.0",
26
+ "@kolkrabbi/kol-print": "^0.1.1",
25
27
  "@kolkrabbi/kol-framework": "^0.64.0",
26
28
  "@kolkrabbi/kol-icons": "^0.52.0",
27
- "@kolkrabbi/kol-theme": "^0.198.0",
28
- "@kolkrabbi/kol-print": "^0.1.1"
29
+ "@kolkrabbi/kol-theme": "^0.199.0"
29
30
  },
30
31
  "files": [
31
32
  "src",
package/src/ColorLab.jsx CHANGED
@@ -1,21 +1,24 @@
1
1
  import { useState } from 'react'
2
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'
3
+ import { COLOR_MIX, COLOR_ROLES, COLOR_SLOTS, DEFAULT_COLOR_LAB, INK_LEVELS, colorLab, inkOn, rgbText } from './colorLab.js'
5
4
 
6
5
  /* taxonomy-ok: organism — the color builder: harmony wheel, roles, slots, palette, ramps and the 60/30/10 strip over one value */
7
6
 
8
7
  /**
9
- * ColorLab — an interactive palette builder from one base color.
8
+ * ColorLab — A brand's colour, generated from one base colour.
10
9
  *
11
10
  * Carried from kol-atlas's Color page (2026-10-20). Six sections over ONE value: Harmony (base +
12
11
  * rule on KOL's wheel), Roles (the harmony's five), Brand slots (four, two dark two light), Palette
13
12
  * (ten hues 36° apart + blue-grey and grey ramps), Ramps (five blend steps per hue) and Composition
14
13
  * (60/30/10). Select a slot or a composition share, then click any swatch to fill it.
15
14
  *
16
- * The math is `colorLab.js` (`deriveColorLab`, `colorLabCss`), so a consumer can save the value into
15
+ * The math is `colorLab.js` (`colorLab`, `colorLabCss`), so a consumer can save the value into
17
16
  * a brand and emit it as CSS without rendering the lab.
18
17
  *
18
+ * A COLOUR DEFINITION (BrandLabToolGaps, kol-atlas 2026-10-29): every swatch's info carries its contrast against the
19
+ * brand's own ink (ratio and WCAG grade); Ink shows the brand's text levels on its light and dark grounds; Dark shows
20
+ * the roles, slots and composition as the dark theme takes them.
21
+ *
19
22
  * @param {Object} value `{ base, harmony, blueBase, greyBase, blueLinked, slots, picks }` — controlled; omit for internal
20
23
  * @param {Function} onChange `(next) => void`
21
24
  * @param {string} className classes on the root
@@ -26,14 +29,18 @@ const PICKER = '[&>*]:!size-3'
26
29
  /* ten columns capped so the 5-row ramp fits the window — a swatch is the same size in every section */
27
30
  const ROW_MAX = 'calc((100vh - 200px) * 2)'
28
31
  const isHex = (hex) => /^#[0-9a-f]{6}/i.test(hex)
32
+ /* a ratio floored, never rounded — 4.497 must not read as 4.5 beside a grade that says it misses AA */
33
+ const ratioText = (r) => (Math.floor(r * 10) / 10).toFixed(1)
29
34
 
30
- function Swatch({ hex, name, selected, onClick, info }) {
35
+ function Swatch({ hex, name, selected, onClick, info, inks, ring }) {
36
+ const ink = inks ? inkOn(hex, inks) : null
31
37
  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 }}>
38
+ <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, boxShadow: ring && !selected ? `inset 0 0 0 1px ${ring}` : undefined }}>
33
39
  <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
40
  <span className="truncate text-emphasis">{name}</span>
35
41
  <span className="truncate text-meta">{hex}</span>
36
42
  <span className="truncate text-meta">{rgbText(hex)}</span>
43
+ {ink && <span className="truncate text-meta">{ratioText(ink.ratio)} · {ink.grade}</span>}
37
44
  </span>
38
45
  </button>
39
46
  )
@@ -63,9 +70,9 @@ export default function ColorLab({ value: valueProp, onChange, className = '' })
63
70
  const value = valueProp ?? own
64
71
  const set = (patch) => { const next = { ...value, ...patch }; if (valueProp === undefined) setOwn(next); onChange?.(next) }
65
72
  const [slot, setSlot] = useState(null)
66
- const [info, setInfo] = useState({ roles: false, brand: false, palette: false, ramps: false, mix: false })
73
+ const [info, setInfo] = useState({ roles: false, brand: false, palette: false, ramps: false, mix: false, dark: false })
67
74
  const infoFor = (k) => ({ info: info[k], onInfo: (v) => setInfo((x) => ({ ...x, [k]: v })) })
68
- const c = deriveColorLab(value)
75
+ const c = colorLab(value)
69
76
 
70
77
  /* a swatch click fills the selected slot (0–3) or composition share (4–6) */
71
78
  const assign = (hex) => {
@@ -83,26 +90,26 @@ export default function ColorLab({ value: valueProp, onChange, className = '' })
83
90
  <div className="flex flex-wrap items-start gap-x-8 gap-y-4">
84
91
  <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
92
  <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>
93
+ <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, dark: v })} /></span></div>
87
94
  </div>
88
95
  <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
96
  </Section>
90
97
 
91
98
  <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>
99
+ <Row>{c.roles.map((hex, i) => <Swatch inks={c.inks} key={i} info={info.roles} hex={hex} name={COLOR_ROLES[i]} onClick={() => assign(hex)} />)}</Row>
93
100
  </Section>
94
101
 
95
102
  <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>
103
+ <Row>{c.slots.map((hex, i) => <Swatch inks={c.inks} key={i} info={info.brand} hex={hex} name={COLOR_SLOTS[i]} selected={slot === i} onClick={() => toggleSlot(i)} />)}</Row>
97
104
  </Section>
98
105
 
99
106
  <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>
107
+ <Row>{c.hues.map((x) => <Swatch inks={c.inks} key={x.name + x.hex} info={info.palette} hex={x.hex} name={x.name} selected={picked(x.hex)} onClick={() => assign(x.hex)} />)}</Row>
101
108
  {[['Blue-grey', c.blueGrey, c.blueBase, 'blueBase', true], ['Grey', c.grey, c.greyBase, 'greyBase', false]].map(([row, ramp, rb, key, linkable]) => (
102
109
  <div key={row} className="mt-4 flex flex-col gap-2">
103
110
  <span className="flex h-4 items-center gap-3"><span className={`${LABEL} w-[120px]`}>{row} base</span>
104
111
  {!(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>
112
+ <Row>{ramp.map((hex, i) => <Swatch inks={c.inks} key={row + i} info={info.palette} hex={hex} name={`${row} ${(i + 1) * 100}`} selected={picked(hex)} onClick={() => assign(hex)} />)}</Row>
106
113
  </div>
107
114
  ))}
108
115
  </Section>
@@ -111,7 +118,7 @@ export default function ColorLab({ value: valueProp, onChange, className = '' })
111
118
  <Row>
112
119
  {[0, 1, 2, 3, 4].flatMap((step) => c.ramps.map((hue) => {
113
120
  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)} />
121
+ return <Swatch inks={c.inks} key={hue.hex + step} info={info.ramps} hex={hex} name={`${hue.name} ${(step + 1) * 100}`} selected={picked(hex)} onClick={() => assign(hex)} />
115
122
  }))}
116
123
  </Row>
117
124
  </Section>
@@ -120,7 +127,7 @@ export default function ColorLab({ value: valueProp, onChange, className = '' })
120
127
  <div style={{ maxWidth: ROW_MAX }}>
121
128
  <div className="flex h-40 overflow-hidden rounded">
122
129
  {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]) }}>
130
+ <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: inkOn(c.mix[i], c.inks).ink }}>
124
131
  <span className="kol-sans-heading-03 leading-none">{share * 10}</span>
125
132
  <span className={`truncate kol-mono-10 leading-tight transition-opacity ${info.mix ? 'opacity-100' : 'opacity-0'}`}>{c.mix[i]}</span>
126
133
  </button>
@@ -139,6 +146,33 @@ export default function ColorLab({ value: valueProp, onChange, className = '' })
139
146
  </div>
140
147
  </div>
141
148
  </Section>
149
+
150
+ {/* THE BRAND'S INK: its text levels on its own grounds, light and dark, each with its contrast */}
151
+ <Section title="Ink">
152
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2" style={{ maxWidth: ROW_MAX }}>
153
+ {[['Light', c.slots[2], c.ink.light, c.inkGrades.light], ['Dark', c.dark.slots[2], c.ink.dark, c.inkGrades.dark]].map(([mode, ground, ink, grades]) => (
154
+ <div key={mode} className="flex flex-col gap-2 rounded p-4" style={{ background: ground }}>
155
+ {INK_LEVELS.map(([id]) => (
156
+ <span key={id} className="flex items-baseline justify-between gap-3" style={{ color: ink[id] }}>
157
+ <span className="kol-sans-body-02">{mode} · {id}</span>
158
+ <span className="kol-mono-10">{ink[id]} · {ratioText(grades[id].ratio)} {grades[id].grade}</span>
159
+ </span>
160
+ ))}
161
+ </div>
162
+ ))}
163
+ </div>
164
+ </Section>
165
+
166
+ {/* THE DARK THEME: the slots swapped, each role moved along its ramp until it passes AA on the dark ground */}
167
+ <Section title="Dark" {...infoFor('dark')}>
168
+ <div className="flex flex-col gap-4 rounded p-4" style={{ background: c.dark.slots[2], maxWidth: ROW_MAX }}>
169
+ <Row>{c.dark.roles.map((hex, i) => <Swatch ring={`${c.inks.light}40`} inks={c.inks} key={`dr${i}`} info={info.dark} hex={hex} name={COLOR_ROLES[i]} />)}</Row>
170
+ <Row>{c.dark.slots.map((hex, i) => <Swatch ring={`${c.inks.light}40`} inks={c.inks} key={`ds${i}`} info={info.dark} hex={hex} name={COLOR_SLOTS[i]} />)}</Row>
171
+ <div className="flex h-16 overflow-hidden rounded border" style={{ borderColor: `${c.inks.light}40` }}>
172
+ {COLOR_MIX.map(([share, label], i) => <span key={label} style={{ flex: share, background: c.dark.mix[i] }} title={`${label} ${c.dark.mix[i]}`} />)}
173
+ </div>
174
+ </div>
175
+ </Section>
142
176
  </div>
143
177
  )
144
178
  }
package/src/ComboLab.jsx CHANGED
@@ -2,6 +2,7 @@ import { useState } from 'react'
2
2
  import { ViewToggle } from '@kolkrabbi/kol-component'
3
3
  import { LAYOUT_COMPONENTS, COMBO_LAYOUTS } from './comboLayouts.jsx'
4
4
  import { generatePalette } from './comboMath.js'
5
+ import { DEFAULT_COMBO_LAB } from './comboLab.js'
5
6
 
6
7
  /**
7
8
  * ComboLab — the 60 / 30 / 10 color-combination playground.
@@ -20,6 +21,8 @@ import { generatePalette } from './comboMath.js'
20
21
  * `palettes` nor `pool`, Randomize derives a fresh palette from
21
22
  * the current primary (seed mode).
22
23
  * - `logo` — a React node dropped into the logo slots.
24
+ * - `onChange` — `(palette) => void` when Randomize or the Palette toggle picks one (the lab family's
25
+ * one value — `comboLabCss(palette)` emits it; BrandLabToolGaps 2026-10-29).
23
26
  *
24
27
  * Chrome (borders, type, state) is DS-owned via `.kol-combo-*` +
25
28
  * `kol-helper-*` + `text-*` utilities; the only inline values are the palette's
@@ -28,19 +31,9 @@ import { generatePalette } from './comboMath.js'
28
31
  * See the monorepo severance notes in lobby/ComboLab.md.
29
32
  */
30
33
 
31
- /* Canonical Kolkrabbi composition. Literal hex (this is a plain module with no
32
- CSS-var resolver) mirroring the DS brand tokens:
33
- yellow-300 / red-200 / cream-100 / blue-400 / orange-300. */
34
- export const DEFAULT_PALETTE = {
35
- id: 'kolkrabbi',
36
- label: 'Kolkrabbi',
37
- description: 'Yellow hero · rust secondary · navy ink on cream — the canonical composition',
38
- primary: '#FFCF33',
39
- secondary: '#AD5038',
40
- light: '#FAF7F0',
41
- dark: '#222D3D',
42
- accent: '#DF760B',
43
- }
34
+ /* the canonical Kolkrabbi composition is comboLab.js's DEFAULT_COMBO_LAB; DEFAULT_PALETTE is its old name */
35
+ /** @deprecated 2026-10-29 — DEFAULT_COMBO_LAB */
36
+ export const DEFAULT_PALETTE = DEFAULT_COMBO_LAB
44
37
 
45
38
  const ROLE_LABELS = ['Primary', 'Secondary', 'Light', 'Dark', 'Accent']
46
39
 
@@ -95,12 +88,15 @@ export default function ComboLab({
95
88
  logo = null,
96
89
  pool,
97
90
  mode = 'random',
91
+ onChange,
98
92
  className = '',
99
93
  }) {
100
- const initialPalette = palette ?? palettes?.[0] ?? DEFAULT_PALETTE
94
+ const initialPalette = palette ?? palettes?.[0] ?? DEFAULT_COMBO_LAB
101
95
 
102
96
  const [layoutId, setLayoutId] = useState(layout ?? layouts[0]?.id ?? COMBO_LAYOUTS[0].id)
103
- const [active, setActive] = useState(initialPalette)
97
+ const [active, setOwn] = useState(initialPalette)
98
+ /* the palette reported as it changes, so a draft can keep it (the lab family's one-value contract, 2026-10-29) */
99
+ const setActive = (p) => { setOwn(p); onChange?.(p) }
104
100
  const [logoOn, setLogoOn] = useState(Boolean(logo))
105
101
 
106
102
  const LayoutComponent = LAYOUT_COMPONENTS[layoutId] ?? LAYOUT_COMPONENTS[COMBO_LAYOUTS[0].id]
@@ -1,60 +1,10 @@
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 */
1
+ import LockupLab from './LockupLab.jsx'
6
2
 
7
3
  /**
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
4
+ * @deprecated 2026-10-29 — `LockupLab` (the lab family's name, BrandLabToolGaps). Same component, same props; drops when
5
+ * nobody imports it (04-retirements.md).
6
+ * LockupBuilder — A product lockup, generated from a name. The old name of LockupLab.
19
7
  */
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
- )
8
+ export default function LockupBuilder(props) {
9
+ return <LockupLab {...props} />
60
10
  }
@@ -0,0 +1,61 @@
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 lockup generator: name / descriptor / variant / scale controls beside a Lockup stage, preset rows below */
6
+
7
+ /**
8
+ * LockupLab — A product lockup, generated from a name. Was `LockupBuilder` — renamed into the lab family beside TypeLab,
9
+ * ColorLab and ComboLab (BrandLabToolGaps, 2026-10-29); the props are the same. Type a product name, add descriptors, pick a variant and scale; the presets run a
10
+ * brand's naming levels through the same Lockup.
11
+ *
12
+ * Carried from kol-atlas `components/lockup/LiveLockup.jsx` (2026-10-20); the parent, the mark, the
13
+ * face and the presets are the brand's, passed in.
14
+ *
15
+ * @param {string} parent the parent brand name
16
+ * @param {ReactNode} mark the mark (see Lockup)
17
+ * @param {string} font CSS font-family for the names
18
+ * @param {Object} presets `{ [level]: string[] }` — e.g. `{ Products: ['Hosting', …] }`
19
+ * @param {string} className classes on the root
20
+ */
21
+ const VARIANTS = [{ value: 'horizontal', label: 'Horizontal' }, { value: 'hero', label: 'Hero' }, { value: 'wordmark', label: 'Wordmark' }]
22
+ const SCALES = ['sm', 'md', 'lg', 'xl'].map((s) => ({ value: s, label: s.toUpperCase() }))
23
+
24
+ const Field = ({ label, children }) => <div className="flex flex-col gap-2"><Label>{label}</Label>{children}</div>
25
+
26
+ export default function LockupLab({ parent = 'Brand', mark, font, presets = {}, className = '' }) {
27
+ const first = Object.values(presets)[0]?.[0] ?? ''
28
+ const [s, setS] = useState({ product: first, descriptor: '', descriptor2: '', variant: 'horizontal', scale: 'lg' })
29
+ const set = (patch) => setS((p) => ({ ...p, ...patch }))
30
+ const brand = { parent, mark, font }
31
+
32
+ return (
33
+ <div className={`flex flex-col gap-6 ${className}`.trim()}>
34
+ <div className="grid gap-6 md:grid-cols-[300px_minmax(0,1fr)]">
35
+ <div className="flex flex-col gap-5 self-start rounded-lg border border-fg-08 p-5">
36
+ <Field label="Product name"><Input value={s.product} onChange={(e) => set({ product: e.target.value })} placeholder="e.g. Hosting" /></Field>
37
+ <Field label="Descriptor"><Input value={s.descriptor} onChange={(e) => set({ descriptor: e.target.value })} placeholder="e.g. IS, EU" /></Field>
38
+ {s.variant === 'hero' && <Field label="Descriptor 2"><Input value={s.descriptor2} onChange={(e) => set({ descriptor2: e.target.value })} placeholder="e.g. Solution" /></Field>}
39
+ <Field label="Variant"><SegmentedToggle size="sm" value={s.variant} onChange={(v) => set({ variant: v })} options={VARIANTS} /></Field>
40
+ <Field label="Scale"><SegmentedToggle size="sm" value={s.scale} onChange={(v) => set({ scale: v })} options={SCALES} /></Field>
41
+ </div>
42
+ <div className="flex min-h-[320px] items-center justify-center overflow-auto rounded-lg border border-fg-08 text-emphasis">
43
+ <Lockup {...brand} {...s} />
44
+ </div>
45
+ </div>
46
+ {Object.entries(presets).map(([group, names]) => (
47
+ <div key={group}>
48
+ <Label>{group}</Label>
49
+ <div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-3">
50
+ {names.map((n) => (
51
+ <button key={n} type="button" onClick={() => set({ product: n, descriptor: '', variant: 'horizontal' })}
52
+ 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">
53
+ <Lockup {...brand} product={n} scale="sm" />
54
+ </button>
55
+ ))}
56
+ </div>
57
+ </div>
58
+ ))}
59
+ </div>
60
+ )
61
+ }
@@ -0,0 +1,108 @@
1
+ import { useState } from 'react'
2
+ import { Dropdown, Input } from '@kolkrabbi/kol-component'
3
+ import { DEFAULT_TYPE_LAB, TYPE_CASES, TYPE_FACES, TYPE_INKS, TYPE_RATIOS, TYPE_STEPS, typeLab } from './typeLab.js'
4
+
5
+ /* taxonomy-ok: organism — the type generator: base + ratio controls, a spec row and live sample per step, the roles table */
6
+
7
+ /**
8
+ * TypeLab — A brand's type, generated. A base size and a musical ratio set every step; each step's line height is the
9
+ * brand's to tune; sixteen roles sit on the steps, each with its face, weight, tracking, case and ink (`typeLab.js`).
10
+ * Was `TypeScaleLab` (carried from kol-atlas's Typography page, 2026-10-20), which stopped at sizes — renamed into the lab
11
+ * family and grown to a brand's whole type (BrandLabToolGaps, kol-atlas 2026-10-29). The faces are the BRAND's, passed in —
12
+ * the lab never sets KOL's type.
13
+ *
14
+ * @param {Object} value `{ base, ratio, leading, roles }` — controlled; omit for internal
15
+ * @param {Function} onChange `(next) => void`
16
+ * @param {Object} faces `{ display, heading, text, mono }` — `{ 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
+ mono: { family: 'var(--kol-font-family-mono)', label: 'JetBrains Mono', weight: 400 },
26
+ }
27
+ const SAMPLES = {
28
+ headline: 'The quick brown fox jumps over the lazy dog',
29
+ 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.',
30
+ }
31
+ const INK = { emphasis: 'var(--kol-fg-emphasis)', strong: 'var(--kol-fg-strong)', lede: 'var(--kol-fg-lede)', body: 'var(--kol-fg-body)', meta: 'var(--kol-fg-meta)' }
32
+ const CASE = { none: 'none', upper: 'uppercase', lower: 'lowercase', title: 'capitalize' }
33
+ const opts = (list) => list.map((x) => ({ value: x, label: x }))
34
+
35
+ export default function TypeLab({ value: valueProp, onChange, faces = FACES, samples = SAMPLES, className = '' }) {
36
+ const [own, setOwn] = useState(DEFAULT_TYPE_LAB)
37
+ const value = { ...DEFAULT_TYPE_LAB, ...(valueProp ?? own) }
38
+ const set = (patch) => { const next = { ...value, ...patch }; if (valueProp === undefined) setOwn(next); onChange?.(next) }
39
+ const setRole = (id, patch) => set({ roles: { ...value.roles, [id]: { ...value.roles?.[id], ...patch } } })
40
+ const t = typeLab(value)
41
+ const face = (k) => ({ ...FACES[k], ...faces[k] })
42
+
43
+ return (
44
+ <div className={`flex flex-col gap-10 ${className}`.trim()}>
45
+ <div className="flex flex-col gap-6">
46
+ <div className="flex max-w-[724px] flex-col gap-2">
47
+ <h3 className="m-0 kol-sans-heading-04 text-emphasis">{t.ratio.label} {t.ratio.note}</h3>
48
+ <p className="m-0 kol-sans-body-02 text-fg-80">{t.ratio.about}</p>
49
+ </div>
50
+ <div className="flex flex-wrap items-end gap-x-8 gap-y-4">
51
+ <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>
52
+ <div className="flex flex-col gap-2"><span className={LABEL}>Ratio</span><Dropdown size="sm" value={t.ratio.id} onChange={(v) => set({ ratio: v })} options={TYPE_RATIOS.map((x) => ({ value: x.id, label: `${x.label} · ${x.note}` }))} /></div>
53
+ </div>
54
+ {/* the spec rows keep their columns on a phone and scroll sideways */}
55
+ <div className="overflow-x-auto overscroll-x-contain"><div className="min-w-[760px]">
56
+ {t.steps.map((s) => {
57
+ const f = face(s.face)
58
+ return (
59
+ <div key={s.id} className="border-t border-fg-08 py-5">
60
+ <div className="mb-3 grid grid-cols-[120px_minmax(0,1.4fr)_minmax(0,1.8fr)_repeat(3,minmax(0,1fr))_auto] items-center gap-x-6">
61
+ <span className={LABEL}>{s.role}</span>
62
+ <code className="kol-doc-code-inline justify-self-start whitespace-nowrap">--type-{s.id}</code>
63
+ <span className={LABEL}>{f.label}</span>
64
+ <span className={LABEL}>W: {f.weight}</span>
65
+ <span className={LABEL}>S: <span className="normal-case">{s.size}px</span></span>
66
+ <span className={LABEL}>LH: <span className="normal-case">{s.lh}px</span></span>
67
+ {/* the step's line-height factor, the brand's to tune */}
68
+ <Input type="number" size="sm" chars={4} step={0.05} aria-label={`${s.role} line height`} value={s.k}
69
+ onChange={(e) => { const k = Number(e.target.value); if (k >= 0.8 && k <= 2.4) set({ leading: { ...value.leading, [s.id]: k } }) }} />
70
+ </div>
71
+ <p className="m-0 truncate text-emphasis" style={{ fontFamily: f.family, fontWeight: f.weight, fontSize: s.size, lineHeight: `${s.lh}px` }}>
72
+ {s.face === 'text' ? samples.text : samples.headline}
73
+ </p>
74
+ </div>
75
+ )
76
+ })}
77
+ </div></div>
78
+ </div>
79
+
80
+ <section className="flex flex-col gap-3">
81
+ <h3 className="m-0 kol-eyebrow text-fg-80">Roles</h3>
82
+ <p className="m-0 max-w-[724px] kol-sans-body-03 text-meta">What the brand sets text as: each role on a step, in a face, with its weight, tracking, case and ink.</p>
83
+ <div className="overflow-x-auto overscroll-x-contain"><div className="min-w-[960px]">
84
+ <div className="grid grid-cols-[100px_repeat(6,minmax(0,1fr))_minmax(0,2.2fr)] items-center gap-x-4 border-b border-fg-08 pb-2">
85
+ {['Role', 'Step', 'Face', 'Weight', 'Tracking %', 'Case', 'Ink', 'Sample'].map((h) => <span key={h} className={LABEL}>{h}</span>)}
86
+ </div>
87
+ {t.roles.map((r) => {
88
+ const f = face(r.face)
89
+ return (
90
+ <div key={r.id} className="grid grid-cols-[100px_repeat(6,minmax(0,1fr))_minmax(0,2.2fr)] items-center gap-x-4 border-b border-fg-08 py-2">
91
+ <span className={LABEL}>{r.label}</span>
92
+ <Dropdown size="sm" value={r.step} onChange={(v) => setRole(r.id, { step: v })} options={TYPE_STEPS.map((s) => ({ value: s.id, label: s.role }))} />
93
+ <Dropdown size="sm" value={r.face} onChange={(v) => setRole(r.id, { face: v })} options={opts(TYPE_FACES)} />
94
+ <Input type="number" size="sm" chars={4} step={100} aria-label={`${r.label} weight`} value={r.weight} onChange={(e) => { const w = Number(e.target.value); if (w >= 100 && w <= 900) setRole(r.id, { weight: w }) }} />
95
+ <Input type="number" size="sm" chars={4} step={0.5} aria-label={`${r.label} tracking`} value={Math.round(r.tracking * 1000) / 10} onChange={(e) => { const p = Number(e.target.value); if (p >= -10 && p <= 30) setRole(r.id, { tracking: p / 100 }) }} />
96
+ <Dropdown size="sm" value={r.case} onChange={(v) => setRole(r.id, { case: v })} options={opts(TYPE_CASES)} />
97
+ <Dropdown size="sm" value={r.ink} onChange={(v) => setRole(r.id, { ink: v })} options={opts(TYPE_INKS)} />
98
+ <span className="truncate" style={{ fontFamily: f.family, fontWeight: r.weight, fontSize: Math.min(r.size, 40), lineHeight: 1.2, letterSpacing: `${r.tracking}em`, textTransform: CASE[r.case], color: INK[r.ink] }}>
99
+ {r.label} {r.size}/{r.lh}
100
+ </span>
101
+ </div>
102
+ )
103
+ })}
104
+ </div></div>
105
+ </section>
106
+ </div>
107
+ )
108
+ }
@@ -1,71 +1,10 @@
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 */
1
+ import TypeLab from './TypeLab.jsx'
6
2
 
7
3
  /**
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
4
+ * @deprecated 2026-10-29 — `TypeLab` (the lab family's name, BrandLabToolGaps), which also sets the roles. Same value
5
+ * (`{ base, ratio }` still works); drops when nobody imports it (04-retirements.md).
6
+ * TypeScaleLab — A brand's type, generated. The old name of TypeLab.
19
7
  */
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
- )
8
+ export default function TypeScaleLab(props) {
9
+ return <TypeLab {...props} />
71
10
  }
package/src/colorLab.js CHANGED
@@ -10,6 +10,15 @@
10
10
  * Multiply (darker) of the base over itself at 50%, applied once and twice.
11
11
  * Slots four brand slots, two dark and two light, picked from any swatch.
12
12
  * Mix 60 / 30 / 10 by area, following Light · Secondary · Primary until a share has its own pick.
13
+ *
14
+ * A COLOUR DEFINITION, NOT ONLY A PALETTE (BrandLabToolGaps, kol-atlas 2026-10-29), and renamed into the lab family
15
+ * (`colorLab` · `colorLabCss` · `DEFAULT_COLOR_LAB`; `deriveColorLab` is its old name):
16
+ * Contrast every colour against the brand's own ink — the Brand black slot on light, the Light slot on dark — with
17
+ * its WCAG ratio and grade (`contrastRatio` · `contrastGrade` · `inkOn`).
18
+ * Ink the brand's text levels, emphasis · strong · lede · body · meta, from its ink at KOL's opacities, for light
19
+ * and for dark — a brand no longer borrows `--kol-fg-*`.
20
+ * Dark the slots swapped (the dark slots become the ground), and each role and composition share moved along its
21
+ * own blend ramp until it passes AA on the dark ground.
13
22
  */
14
23
  import { generateHarmony, hexToHsl, hslToHex } from '@kolkrabbi/kol-component'
15
24
 
@@ -47,8 +56,41 @@ export const DEFAULT_COLOR_LAB = {
47
56
  slots: null, picks: [null, null, null],
48
57
  }
49
58
 
50
- /** everything the lab draws, from its value */
51
- export function deriveColorLab(value) {
59
+ /* ── contrast (WCAG 2) ── */
60
+ const lin = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)
61
+ const luminance = (hex) => { const [r, g, b] = toRgb(hex).map(lin); return 0.2126 * r + 0.7152 * g + 0.0722 * b }
62
+ /** the WCAG contrast ratio of two hex colours, 1 … 21 */
63
+ export function contrastRatio(a, b) {
64
+ const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m)
65
+ return (x + 0.05) / (y + 0.05)
66
+ }
67
+ /** `AAA` (7) · `AA` (4.5) · `AA large` (3) · `fail` — the grade for body text, large text at 3 */
68
+ export const contrastGrade = (ratio) => (ratio >= 7 ? 'AAA' : ratio >= 4.5 ? 'AA' : ratio >= 3 ? 'AA large' : 'fail')
69
+ /** the brand's ink that reads best on `hex`, its ratio and grade — `inks` is `{ dark, light }` */
70
+ export function inkOn(hex, inks) {
71
+ const [ink, ratio] = [inks.dark, inks.light].map((i) => [i, contrastRatio(hex, i)]).sort((m, n) => n[1] - m[1])[0]
72
+ return { ink, ratio, grade: contrastGrade(ratio) }
73
+ }
74
+
75
+ /* the brand's text levels at KOL's opacities (kol-opacity.css: emphasis full, strong 80, lede 72, body 64, meta 48) */
76
+ export const INK_LEVELS = [['emphasis', 1], ['strong', 0.8], ['lede', 0.72], ['body', 0.64], ['meta', 0.48]]
77
+ const alpha = (hex, a) => (a >= 1 ? hex : `${hex}${Math.round(a * 255).toString(16).padStart(2, '0').toUpperCase()}`)
78
+ const inkRamp = (hex) => Object.fromEntries(INK_LEVELS.map(([id, a]) => [id, alpha(hex, a)]))
79
+ /* the ink at its opacity over its ground, as the eye sees it — what its contrast is measured on */
80
+ const over = (fg, bg, a) => toHex(toRgb(fg).map((c, i) => c * a + toRgb(bg)[i] * (1 - a)))
81
+ const inkContrast = (hex, ground) => Object.fromEntries(INK_LEVELS.map(([id, a]) => {
82
+ const r = contrastRatio(over(hex, ground, a), ground)
83
+ return [id, { ratio: r, grade: contrastGrade(r) }]
84
+ }))
85
+
86
+ /* a role on the dark ground: along its own blend ramp from the base toward the lightest, the first step at AA */
87
+ const forDark = (hex, ground) => {
88
+ const ramp = blendRamp(hex)
89
+ return [2, 1, 0].map((i) => ramp[i]).find((x) => contrastRatio(x, ground) >= 4.5) ?? ramp[0]
90
+ }
91
+
92
+ /** everything the lab draws, from its value — `deriveColorLab` before 2026-10-29 */
93
+ export function colorLab(value) {
52
94
  const v = { ...DEFAULT_COLOR_LAB, ...value }
53
95
  const { h, s, l } = hexToHsl(v.base)
54
96
  const hues = Array.from({ length: 10 }, (_, i) => { const hh = (h + i * 36) % 360; return { name: hueName(hh), hex: hslToHex(hh, s, l).toUpperCase() } })
@@ -59,23 +101,44 @@ export function deriveColorLab(value) {
59
101
  const roles = generateHarmony(v.base, v.harmony)
60
102
  const mix = [slots[2], roles[1], roles[0]].map((hex, i) => v.picks?.[i] ?? hex)
61
103
  const ramps = hues.map((x) => ({ ...x, steps: blendRamp(x.hex) }))
62
- return { ...v, h, s, l, hues, blueGrey, grey, slots, roles, mix, ramps }
104
+ /* the ink: Brand black on the light ground, Light on the dark one */
105
+ const inks = { dark: slots[1], light: slots[2] }
106
+ const darkSlots = [slots[2], slots[3], slots[0], slots[1]]
107
+ const darkGround = darkSlots[2]
108
+ const dark = {
109
+ slots: darkSlots,
110
+ roles: roles.map((x) => forDark(x, darkGround)),
111
+ mix: [darkGround, forDark(mix[1], darkGround), forDark(mix[2], darkGround)],
112
+ }
113
+ const ink = { light: inkRamp(inks.dark), dark: inkRamp(inks.light) }
114
+ const inkGrades = { light: inkContrast(inks.dark, slots[2]), dark: inkContrast(inks.light, darkGround) }
115
+ return { ...v, h, s, l, hues, blueGrey, grey, slots, roles, mix, ramps, inks, ink, inkGrades, dark }
63
116
  }
117
+ /** @deprecated 2026-10-29 — `colorLab` (the lab family's name) */
118
+ export const deriveColorLab = colorLab
64
119
 
65
120
  const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
66
121
 
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)
122
+ /** the lab's result as CSS custom properties — brand-prefixed: the `:root` block (roles, slots, mix, ink, the ramps), then
123
+ * the dark block under `darkSelector` (roles, slots, mix and ink for the dark ground) */
124
+ export function colorLabCss(value, prefix = 'brand', { darkSelector = '[data-theme="dark"]' } = {}) {
125
+ const c = colorLab(value)
70
126
  const line = (name, hex) => ` --${prefix}-${name}: ${hex};`
127
+ const themed = (t, ink) => [
128
+ ...COLOR_ROLES.map((r, i) => line(`role-${slug(r)}`, t.roles[i])),
129
+ ...COLOR_SLOTS.map((r, i) => line(`slot-${slug(r)}`, t.slots[i])),
130
+ ...COLOR_MIX.map(([, label], i) => line(`mix-${slug(label).replace(/^\d+-/, '')}`, t.mix[i])),
131
+ ...INK_LEVELS.map(([id]) => line(`ink-${id}`, ink[id])),
132
+ ]
71
133
  return [
72
134
  ':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])),
135
+ ...themed(c, c.ink.light),
76
136
  ...c.ramps.flatMap((x) => x.steps.map((hex, i) => line(`${slug(x.name)}-${(i + 1) * 100}`, hex))),
77
137
  ...c.blueGrey.map((hex, i) => line(`blue-grey-${(i + 1) * 100}`, hex)),
78
138
  ...c.grey.map((hex, i) => line(`grey-${(i + 1) * 100}`, hex)),
79
139
  '}',
140
+ `${darkSelector} {`,
141
+ ...themed(c.dark, c.ink.dark),
142
+ '}',
80
143
  ].join('\n')
81
144
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * comboLab — the math behind ComboLab in the lab family's shape (BrandLabToolGaps, kol-atlas 2026-10-29 — the user:
3
+ * *"the group of those tools that generate should ship together … and have unified name system"*): `comboLab` makes a
4
+ * palette, `comboLabCss` emits one, `DEFAULT_COMBO_LAB` is where ComboLab starts. The engine is `comboMath.js`'s
5
+ * `generatePalette`, unchanged.
6
+ *
7
+ * A palette is five roles — primary · secondary · light · dark · accent — the 60/30/10 composition's.
8
+ */
9
+ import { generatePalette } from './comboMath.js'
10
+
11
+ /* Canonical Kolkrabbi composition — yellow-300 / red-200 / cream-100 / blue-400 / orange-300 */
12
+ export const DEFAULT_COMBO_LAB = {
13
+ id: 'kolkrabbi',
14
+ label: 'Kolkrabbi',
15
+ description: 'Yellow hero · rust secondary · navy ink on cream — the canonical composition',
16
+ primary: '#FFCF33',
17
+ secondary: '#AD5038',
18
+ light: '#FAF7F0',
19
+ dark: '#222D3D',
20
+ accent: '#DF760B',
21
+ }
22
+ export const COMBO_ROLES = ['primary', 'secondary', 'light', 'dark', 'accent']
23
+
24
+ /** a new palette: from `pool` (hexes to sample) by `mode` (a GENERATION_MODES id), keeping the roles `locks` marks
25
+ * (booleans in COMBO_ROLES order) from `current`; with no pool, seeded from `base` (default `current.primary`) */
26
+ export function comboLab({ pool = [], mode = 'random', current = DEFAULT_COMBO_LAB, locks = [], base } = {}) {
27
+ const colors = generatePalette(pool, mode, COMBO_ROLES.map((r) => current[r]), locks, pool.length ? undefined : (base ?? current.primary))
28
+ return { ...current, id: 'generated', label: 'Generated', ...Object.fromEntries(COMBO_ROLES.map((r, i) => [r, colors[i] ?? current[r]])) }
29
+ }
30
+
31
+ /** a palette as CSS custom properties — `--<prefix>-combo-<role>` */
32
+ export function comboLabCss(palette = DEFAULT_COMBO_LAB, prefix = 'brand') {
33
+ return [':root {', ...COMBO_ROLES.map((r) => ` --${prefix}-combo-${r}: ${palette[r]};`), '}'].join('\n')
34
+ }
package/src/index.js CHANGED
@@ -15,7 +15,7 @@ export { WEIGHTS, CUTS, CASES, familyFor } from './typographyCuts.js'
15
15
  export { default as LogoCard } from './LogoCard.jsx'
16
16
  export { default as ClearspaceDiagram, hasFramework } from './ClearspaceDiagram.jsx'
17
17
  export { default as LogoScaling } from './LogoScaling.jsx'
18
- export { default as ComboLab, DEFAULT_PALETTE } from './ComboLab.jsx'
18
+ export { DEFAULT_PALETTE } from './ComboLab.jsx'
19
19
  export {
20
20
  RatioBar, Tower, QuadSplit, CardRow, StripeRow, AppliedCard,
21
21
  LAYOUT_COMPONENTS, COMBO_LAYOUTS,
@@ -61,11 +61,17 @@ export { brandSections, brandToc, brandInfo, BRAND_VIEWS, BOOK_SECTIONS, ASSET_S
61
61
  export { default as BrandGuide } from './BrandGuide.jsx'
62
62
  export { guideSections, guideToc } from './guideBook.js'
63
63
 
64
- // The brand lab (2026-10-20): the generators a brand is MADE with — carried from kol-atlas's Assets pages.
65
- // Each is controlled by one value, and the math is exported so a brand can be emitted without the UI.
66
- export { default as ColorLab } from './ColorLab.jsx'
67
- export { deriveColorLab, colorLabCss, blendRamp, neutralRamp, DEFAULT_COLOR_LAB, COLOR_ROLES, COLOR_SLOTS, COLOR_MIX } from './colorLab.js'
64
+ // THE LAB (2026-10-20; one name system and its own subpath 2026-10-29, BrandLabToolGaps): the generators a brand is MADE
65
+ // with — TypeLab · ColorLab · ComboLab · LockupLab, each `<x>Lab(value)` · `<x>LabCss` · `DEFAULT_<X>_LAB`. The same
66
+ // names ship from `@kolkrabbi/kol-styleguide/lab`.
67
+ export {
68
+ TypeLab, typeLab, typeLabCss, DEFAULT_TYPE_LAB, TYPE_RATIOS, TYPE_STEPS, TYPE_ROLES, TYPE_FACES, TYPE_CASES, TYPE_INKS,
69
+ ColorLab, colorLab, colorLabCss, DEFAULT_COLOR_LAB, COLOR_ROLES, COLOR_SLOTS, COLOR_MIX, INK_LEVELS, contrastRatio, contrastGrade, inkOn, blendRamp, neutralRamp,
70
+ ComboLab, comboLab, comboLabCss, DEFAULT_COMBO_LAB, COMBO_ROLES,
71
+ LockupLab, Lockup, LOCKUP_SCALES,
72
+ } from './lab/index.js'
73
+ // the names before the lab family (deprecated 2026-10-29, 04-retirements.md)
68
74
  export { default as TypeScaleLab } from './TypeScaleLab.jsx'
69
- export { typeScale, typeScaleCss, TYPE_RATIOS, TYPE_STEPS, DEFAULT_TYPE_SCALE } from './typeScale.js'
70
- export { default as Lockup, LOCKUP_SCALES } from './Lockup.jsx'
75
+ export { typeScale, typeScaleCss, DEFAULT_TYPE_SCALE } from './typeLab.js'
76
+ export { deriveColorLab } from './colorLab.js'
71
77
  export { default as LockupBuilder } from './LockupBuilder.jsx'
@@ -0,0 +1,22 @@
1
+ /* THE LAB — the generators a brand is MADE with, shipped together under one name system (BrandLabToolGaps, kol-atlas
2
+ * 2026-10-29 — the user: *"the group of those tools that generate should ship together with combolab and logo lockup
3
+ * generator, and have unified name system"*). `@kolkrabbi/kol-styleguide/lab`; the main barrel exports the same names.
4
+ *
5
+ * <X>Lab the generator's UI, controlled by one value
6
+ * <x>Lab(value) everything it draws, from the value
7
+ * <x>LabCss(…) the result as CSS custom properties
8
+ * DEFAULT_<X>_LAB where it starts
9
+ *
10
+ * type TypeLab · typeLab · typeLabCss · DEFAULT_TYPE_LAB
11
+ * colour ColorLab · colorLab · colorLabCss · DEFAULT_COLOR_LAB
12
+ * combo ComboLab · comboLab · comboLabCss · DEFAULT_COMBO_LAB
13
+ * lockup LockupLab (it draws a lockup around the brand's mark; there is no value to emit) */
14
+ export { default as TypeLab } from '../TypeLab.jsx'
15
+ export { typeLab, typeLabCss, DEFAULT_TYPE_LAB, TYPE_RATIOS, TYPE_STEPS, TYPE_ROLES, TYPE_FACES, TYPE_CASES, TYPE_INKS } from '../typeLab.js'
16
+ export { default as ColorLab } from '../ColorLab.jsx'
17
+ export { colorLab, colorLabCss, DEFAULT_COLOR_LAB, COLOR_ROLES, COLOR_SLOTS, COLOR_MIX, INK_LEVELS, contrastRatio, contrastGrade, inkOn, blendRamp, neutralRamp } from '../colorLab.js'
18
+ export { default as ComboLab } from '../ComboLab.jsx'
19
+ export { comboLab, comboLabCss, DEFAULT_COMBO_LAB, COMBO_ROLES } from '../comboLab.js'
20
+ export { GENERATION_MODES } from '../comboMath.js'
21
+ export { default as LockupLab } from '../LockupLab.jsx'
22
+ export { default as Lockup, LOCKUP_SCALES } from '../Lockup.jsx'
package/src/typeLab.js ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * typeLab — the math behind TypeLab: a brand's type, from a base size and a ratio to every role it sets.
3
+ *
4
+ * Carried from kol-atlas `components/graphics/ScaleLab.jsx` (2026-10-20) as `typeScale`; grown into a brand's whole type
5
+ * definition and renamed into the lab family (BrandLabToolGaps, kol-atlas 2026-10-29 — the user: *"the group of those
6
+ * tools that generate should ship together … and have unified name system"*: `TypeLab` · `typeLab` · `typeLabCss` ·
7
+ * `DEFAULT_TYPE_LAB`, beside the colour, combo and lockup labs).
8
+ *
9
+ * Steps base × ratio^n, rounded to a whole px; the line height is the step's factor × size, snapped up to the 4px
10
+ * grid. The factor is 1.2 for display and headings, 1.5 for text, and the brand's to tune (`leading`).
11
+ * Roles what a brand sets text AS — sixteen, each on a step with its face (display · heading · text · mono), weight,
12
+ * tracking, case and ink (the colour lab's text levels). `roles` overrides any of them.
13
+ *
14
+ * The ramp owes nothing to KOL's own type scale — it is a brand's.
15
+ */
16
+ export const TYPE_RATIOS = [
17
+ { 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.' },
18
+ { 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.' },
19
+ { 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.' },
20
+ { 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.' },
21
+ { 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.' },
22
+ { 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.' },
23
+ { 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.' },
24
+ ]
25
+
26
+ /* step n from the base; `face` is which of the brand's faces sets it, `k` the default line-height factor */
27
+ export const TYPE_STEPS = [
28
+ { id: 'display-1', role: 'Display 1', n: 6, face: 'display', k: 1.2 },
29
+ { id: 'display-2', role: 'Display 2', n: 5, face: 'display', k: 1.2 },
30
+ { id: 'heading-1', role: 'Heading 1', n: 4, face: 'display', k: 1.2 },
31
+ { id: 'heading-2', role: 'Heading 2', n: 3, face: 'heading', k: 1.2 },
32
+ { id: 'heading-3', role: 'Heading 3', n: 2, face: 'heading', k: 1.2 },
33
+ { id: 'heading-4', role: 'Heading 4', n: 1, face: 'heading', k: 1.2 },
34
+ { id: 'body', role: 'Body', n: 0, face: 'text', k: 1.5 },
35
+ { id: 'small', role: 'Small', n: -1, face: 'text', k: 1.5 },
36
+ ]
37
+
38
+ export const TYPE_FACES = ['display', 'heading', 'text', 'mono']
39
+ export const TYPE_CASES = ['none', 'upper', 'lower', 'title']
40
+ /* the colour lab's ink levels (colorLab `INK_LEVELS`), strongest first */
41
+ export const TYPE_INKS = ['emphasis', 'strong', 'lede', 'body', 'meta']
42
+
43
+ /* THE ROLES (kol-atlas apps/lab roles.css, 2026-10-29): sixteen roles on the eight steps — several share one. Tracking
44
+ * is in em (−0.015 = −1.5 %). */
45
+ export const TYPE_ROLES = [
46
+ { id: 'display-1', label: 'Display 1', step: 'display-1', face: 'display', weight: 700, tracking: -0.02, case: 'none', ink: 'emphasis' },
47
+ { id: 'display-2', label: 'Display 2', step: 'display-2', face: 'display', weight: 700, tracking: -0.02, case: 'none', ink: 'emphasis' },
48
+ { id: 'h1', label: 'Heading 1', step: 'heading-1', face: 'display', weight: 700, tracking: -0.015, case: 'none', ink: 'emphasis' },
49
+ { id: 'h2', label: 'Heading 2', step: 'heading-2', face: 'heading', weight: 600, tracking: -0.01, case: 'none', ink: 'emphasis' },
50
+ { id: 'h3', label: 'Heading 3', step: 'heading-3', face: 'heading', weight: 600, tracking: -0.005, case: 'none', ink: 'emphasis' },
51
+ { id: 'h4', label: 'Heading 4', step: 'heading-4', face: 'heading', weight: 600, tracking: 0, case: 'none', ink: 'emphasis' },
52
+ { id: 'h5', label: 'Heading 5', step: 'body', face: 'heading', weight: 600, tracking: 0, case: 'none', ink: 'emphasis' },
53
+ { id: 'h6', label: 'Heading 6', step: 'small', face: 'heading', weight: 600, tracking: 0, case: 'none', ink: 'strong' },
54
+ { id: 'quote', label: 'Quote', step: 'heading-4', face: 'heading', weight: 400, tracking: 0, case: 'none', ink: 'lede' },
55
+ { id: 'lead', label: 'Lead', step: 'heading-4', face: 'text', weight: 400, tracking: 0, case: 'none', ink: 'lede' },
56
+ { id: 'body', label: 'Body', step: 'body', face: 'text', weight: 400, tracking: 0, case: 'none', ink: 'body' },
57
+ { id: 'small', label: 'Small', step: 'small', face: 'text', weight: 400, tracking: 0, case: 'none', ink: 'body' },
58
+ { id: 'caption', label: 'Caption', step: 'small', face: 'text', weight: 400, tracking: 0, case: 'none', ink: 'meta' },
59
+ { id: 'eyebrow', label: 'Eyebrow', step: 'small', face: 'mono', weight: 500, tracking: 0.08, case: 'upper', ink: 'meta' },
60
+ { id: 'label', label: 'Label', step: 'small', face: 'mono', weight: 500, tracking: 0.04, case: 'upper', ink: 'body' },
61
+ { id: 'data', label: 'Data', step: 'small', face: 'mono', weight: 400, tracking: 0, case: 'none', ink: 'body' },
62
+ ]
63
+
64
+ const GRID = 4
65
+ /** the lab's starting value; `leading` (stepId → factor) and `roles` (roleId → partial role) override the defaults */
66
+ export const DEFAULT_TYPE_LAB = { base: 16, ratio: 'P4', leading: {}, roles: {} }
67
+
68
+ /** everything the lab draws, from its value: `{ ...value, ratio, steps, roles }` — steps with `size` and `lh` in px, roles
69
+ * resolved onto their step */
70
+ export function typeLab(value) {
71
+ const v = { ...DEFAULT_TYPE_LAB, ...value }
72
+ const ratio = TYPE_RATIOS.find((x) => x.id === v.ratio) ?? TYPE_RATIOS[3]
73
+ const steps = TYPE_STEPS.map((s) => {
74
+ const k = v.leading?.[s.id] ?? s.k
75
+ const size = Math.round(v.base * ratio.ratio ** s.n)
76
+ return { ...s, k, size, lh: Math.ceil((size * k) / GRID) * GRID }
77
+ })
78
+ const roles = TYPE_ROLES.map((r) => {
79
+ const role = { ...r, ...v.roles?.[r.id] }
80
+ const step = steps.find((s) => s.id === role.step) ?? steps[6]
81
+ return { ...role, size: step.size, lh: step.lh }
82
+ })
83
+ return { ...v, ratio, steps, roles }
84
+ }
85
+
86
+ const CASE_CSS = { none: 'none', upper: 'uppercase', lower: 'lowercase', title: 'capitalize' }
87
+
88
+ /** the type as CSS: the faces, each step's size and line height, each role's settings as variables, and a `.type-<role>`
89
+ * class per role that reads them. `faces` (`{ display: { family }, … }`) fills the family variables; the ink reads the
90
+ * colour lab's `--<inkPrefix>-ink-<level>` */
91
+ export function typeLabCss(value, { prefix = 'type', faces = value?.faces, inkPrefix = 'brand' } = {}) {
92
+ const t = typeLab(value)
93
+ const v = (name, val) => ` --${prefix}-${name}: ${val};`
94
+ return [
95
+ ':root {',
96
+ ...TYPE_FACES.filter((f) => faces?.[f]?.family).map((f) => v(`face-${f}`, faces[f].family)),
97
+ ...t.steps.flatMap((s) => [v(`${s.id}-size`, `${s.size}px`), v(`${s.id}-lh`, `${s.lh}px`)]),
98
+ ...t.roles.flatMap((r) => [
99
+ v(`${r.id}-weight`, r.weight),
100
+ v(`${r.id}-tracking`, `${r.tracking}em`),
101
+ ]),
102
+ '}',
103
+ ...t.roles.map((r) => [
104
+ `.${prefix}-${r.id} {`,
105
+ ` font-family: var(--${prefix}-face-${r.face});`,
106
+ ` font-size: var(--${prefix}-${r.step}-size);`,
107
+ ` line-height: var(--${prefix}-${r.step}-lh);`,
108
+ ` font-weight: var(--${prefix}-${r.id}-weight);`,
109
+ ` letter-spacing: var(--${prefix}-${r.id}-tracking);`,
110
+ ` text-transform: ${CASE_CSS[r.case] ?? 'none'};`,
111
+ ` color: var(--${inkPrefix}-ink-${r.ink});`,
112
+ '}',
113
+ ].join('\n')),
114
+ ].join('\n')
115
+ }
116
+
117
+ /* ── the names before the lab family (deprecated 2026-10-29, on the retirement ledger) ── */
118
+ /** @deprecated use `typeLab(value).steps` */
119
+ export const typeScale = (value) => typeLab(value).steps
120
+ /** @deprecated use `typeLabCss` — this emits the steps alone, as it did */
121
+ export function typeScaleCss(value, prefix = 'type') {
122
+ return [':root {', ...typeScale(value).flatMap((s) => [` --${prefix}-${s.id}-size: ${s.size}px;`, ` --${prefix}-${s.id}-lh: ${s.lh}px;`]), '}'].join('\n')
123
+ }
124
+ /** @deprecated use DEFAULT_TYPE_LAB */
125
+ export const DEFAULT_TYPE_SCALE = { base: 16, ratio: 'P4' }
package/src/typeScale.js DELETED
@@ -1,45 +0,0 @@
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
- }