@kolkrabbi/kol-styleguide 0.3.0 → 0.4.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.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "KOL style-guide / brand-guide component set — the visual specimens a brand manual is built from: color anatomy + combination lab, logo construction / clearspace / scaling, mood tiles, type blocks, and asset spec tables. Raided from the apps/brand styleguide. Sits above @kolkrabbi/kol-{theme,icons,component}.",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@
18
18
  "react-dom": "^18.3.0 || ^19.0.0"
19
19
  },
20
20
  "devDependencies": {
21
- "@kolkrabbi/kol-component": "^0.181.0",
21
+ "@kolkrabbi/kol-component": "^0.182.0",
22
22
  "@kolkrabbi/kol-icons": "^0.25.0",
23
23
  "@kolkrabbi/kol-theme": "^0.139.0"
24
24
  },
@@ -0,0 +1,46 @@
1
+ /**
2
+ * AssetCard — frame wrapper for stationery / branded asset mocks.
3
+ *
4
+ * Just a backdrop with padding + caption above. The asset child renders at its
5
+ * natural size, driven by the parent column width and its own aspect-ratio. No
6
+ * scaling — content stays at intended proportions.
7
+ *
8
+ * Group items with similar aspects in the same grid row so heights align.
9
+ * Extreme-aspect items (very tall or very wide) should go in their own row or
10
+ * a full-width container.
11
+ *
12
+ * Lifted verbatim from the two brand apps (`brand-book-mocks-two-consumers`,
13
+ * 2026-09-03) — the file was byte-identical in kol-client-olina and
14
+ * kol-website, 25 lines each. Nothing here was client-specific, which is why
15
+ * it is a straight carry-through: same elements, same classes, same order.
16
+ * `backdrop` and `className` are the only additions.
17
+ *
18
+ * <AssetCard caption="Business card">
19
+ * <div style={{ aspectRatio: '85 / 55' }}><BusinessCardFront … /></div>
20
+ * </AssetCard>
21
+ *
22
+ * Caption text is authored in the case it should render — the component applies
23
+ * no text-transform beyond the `uppercase` the original carried.
24
+ *
25
+ * @param {ReactNode} caption - Label above the frame; omitted, no figcaption renders
26
+ * @param {string} backdrop - CSS colour for the frame ground (default: the fg-04 wash)
27
+ * @param {string} className - Extra classes on the <figure>
28
+ * @param {ReactNode} children - The mock
29
+ */
30
+ export default function AssetCard({ caption, backdrop, className = '', children }) {
31
+ return (
32
+ <figure className={className}>
33
+ {caption && (
34
+ <figcaption className="kol-helper-12 uppercase tracking-widest text-meta mb-3">
35
+ {caption}
36
+ </figcaption>
37
+ )}
38
+ <div
39
+ className={`rounded-sm p-6 ${backdrop ? '' : 'bg-fg-04'}`.trim()}
40
+ style={backdrop ? { background: backdrop } : undefined}
41
+ >
42
+ {children}
43
+ </div>
44
+ </figure>
45
+ )
46
+ }
@@ -1,6 +1,16 @@
1
+ import { useState } from 'react'
1
2
  import { Table } from '@kolkrabbi/kol-component'
2
3
  import { Icon } from '@kolkrabbi/kol-icons'
3
4
 
5
+ /* Resolve a CSS custom property off the document root. The download seam is
6
+ * handed a COLOUR, not a token — a consumer swapping `currentColor` in raw SVG
7
+ * cannot use `var(--x)`, and resolving it at the call site is how two brand
8
+ * apps ended up with the same four-line helper. SSR-safe: '' when no document. */
9
+ function resolveToken(token) {
10
+ if (!token || typeof document === 'undefined') return ''
11
+ return getComputedStyle(document.documentElement).getPropertyValue(token).trim()
12
+ }
13
+
4
14
  /**
5
15
  * AssetTable — mark / asset spec table.
6
16
  *
@@ -9,35 +19,50 @@ import { Icon } from '@kolkrabbi/kol-icons'
9
19
  * download control per row. Rows are consumer-injected; the standard column set
10
20
  * is built here so a consumer only supplies data.
11
21
  *
12
- * Ported from the monorepo apps/brand styleguide AssetTable (2026-07). All
13
- * app-specific wiring is dropped: the `import.meta.glob` SVG harvesting, the
14
- * KolLogo / Graphic loaders, the ink-token toggle, the fullscreen overlay, and
15
- * the `Blob` recolour-and-download. Download is now a plain per-row seam —
16
- * pass `href` (a download link) OR `onDownload` (a callback). Preview is a
17
- * consumer-supplied node.
22
+ * Ported from the monorepo apps/brand styleguide AssetTable (2026-07). The
23
+ * `import.meta.glob` harvesting, the mark loaders and the `Blob` recolour are
24
+ * app code and stay out. **Three behaviours came back as seams in 0.4.0**
25
+ * (`assettable-package-dropped-its-wiring`, kol-client-olina): the port had
26
+ * dropped the ink toggle, the zoom preview and the recolour-aware download and
27
+ * still read as a replacement, so its one consumer measured it and reverted.
28
+ * The state is the table's; the `Blob` and the overlay stay the consumer's.
29
+ *
30
+ * - **`inkTokens`** — a `{ ink, surface }` token pair turns on the Color column
31
+ * and a per-row toggle dot: the row's preview paints in the live token, the
32
+ * column shows which one, the dot flips it. Unset, none of that renders.
33
+ * - **`onPreviewZoom(row, resolvedInk)`** — makes the preview cell a zoom
34
+ * button. The consumer opens its own overlay (`FullscreenOverlay` in both
35
+ * brand apps) at the ink it is handed.
36
+ * - **`row.onDownload(row, resolvedInk)`** — the second argument is the
37
+ * COMPUTED colour, not the token, so a consumer can swap `currentColor` for
38
+ * it and hand back a white mark that downloads white.
18
39
  *
19
- * Columns adapt to the data: the Preview column appears only when a row carries
20
- * a `preview` node, and the Download column only when a row carries `href` or
21
- * `onDownload`. Name / format / dimensions always show, falling back to an
22
- * em-dash (DS Table behaviour) when a field is absent.
40
+ * Columns adapt to the data: Preview appears only when a row carries a
41
+ * `preview` node, Path only when a row carries `path`, Color only under
42
+ * `inkTokens`, and Download only when a row carries `href` or `onDownload`.
43
+ * Name / format / dimensions always show, falling back to an em-dash (DS Table
44
+ * behaviour) when a field is absent.
23
45
  *
24
46
  * Row shape:
25
- * { id, name, preview?, format?, dimensions?, href?, filename?, onDownload? }
26
- * id React key (falls back to array index)
27
- * name asset name (string or node)
28
- * preview specimen node rendered in the Preview cell
29
- * format e.g. 'SVG' · 'PNG @2x'
30
- * dimensions e.g. '48 × 48'
31
- * href download URL → renders an <a download>
32
- * filename download filename for the <a download> attribute
33
- * onDownload click handler → renders a <button> (takes precedence over href)
47
+ * { id, name, preview?, previewWidth?, path?, format?, dimensions?, href?, filename?, onDownload? }
48
+ * id React key (falls back to array index)
49
+ * name asset name (string or node)
50
+ * preview specimen node rendered in the Preview cell
51
+ * previewWidth px width for the preview box — a wordmark is not a logomark
52
+ * path source path, rendered in a <code> cell
53
+ * format e.g. 'SVG' · 'PNG @2x'
54
+ * dimensions e.g. '48 × 48'
55
+ * href download URL → renders an <a download>
56
+ * filename download filename for the <a download> attribute
57
+ * onDownload click handler → renders a <button> (takes precedence over href)
34
58
  *
35
- * Props:
36
- * rows Array<row> (required)
37
- * caption sr-only <caption> for the table (a11y)
38
- * variant 'default' | 'simple' → forwarded to DS Table
39
- * className extra classes on the table wrapper
40
- * emptyLabel message shown when rows is empty (default 'No assets.')
59
+ * @param {Array<Object>} rows - The assets (required)
60
+ * @param {string} caption - sr-only <caption> for the table (a11y)
61
+ * @param {'default'|'simple'} variant - Forwarded to DS Table
62
+ * @param {string} className - Extra classes on the table wrapper
63
+ * @param {string} emptyLabel - Message shown when rows is empty (default 'No assets.')
64
+ * @param {{ink: string, surface: string}} inkTokens - CSS custom-property pair the ink toggle swings between; unset disables the Color column and the toggle
65
+ * @param {Function} onPreviewZoom - `(row, resolvedInk)` — supplied, the preview becomes a zoom button and the consumer owns the overlay
41
66
  */
42
67
  export default function AssetTable({
43
68
  rows = [],
@@ -45,28 +70,51 @@ export default function AssetTable({
45
70
  variant = 'default',
46
71
  className = '',
47
72
  emptyLabel = 'No assets.',
73
+ inkTokens = null,
74
+ onPreviewZoom,
48
75
  }) {
76
+ /* Per-row ink, keyed by row id. Hooks run before the empty-rows return so the
77
+ * order never changes with the data. */
78
+ const [inks, setInks] = useState({})
79
+
49
80
  if (!rows.length) {
50
81
  return <p className="kol-mono-14 text-fg-48">{emptyLabel}</p>
51
82
  }
52
83
 
53
84
  const withId = rows.map((row, i) => ({ ...row, id: row.id ?? i }))
54
85
  const hasPreview = withId.some((row) => row.preview != null)
86
+ const hasPath = withId.some((row) => row.path != null)
55
87
  const hasDownload = withId.some((row) => row.href || row.onDownload)
56
88
 
89
+ const tokenFor = (id) => (inkTokens ? inks[id] ?? inkTokens.ink : null)
90
+ const toggleInk = (id) =>
91
+ setInks((prev) => ({
92
+ ...prev,
93
+ [id]: (prev[id] ?? inkTokens.ink) === inkTokens.ink ? inkTokens.surface : inkTokens.ink,
94
+ }))
95
+
57
96
  const columns = [
58
97
  hasPreview && {
59
98
  accessor: 'preview',
60
99
  header: 'Preview',
61
- className: 'kol-table-cell-text',
100
+ /* Off-ink rows get a wash so a surface-coloured mark stays visible
101
+ against the table's own ground — the consumer's behaviour, kept. */
102
+ className: (row) =>
103
+ `kol-table-cell-text${inkTokens && tokenFor(row.id) !== inkTokens.ink ? ' bg-fg-04' : ''}`,
62
104
  style: { minWidth: 160 },
63
- render: (row) => row.preview ?? null,
105
+ render: (row) => <PreviewCell row={row} token={tokenFor(row.id)} onZoom={onPreviewZoom} />,
64
106
  },
65
107
  {
66
108
  accessor: 'name',
67
109
  header: 'Name',
68
110
  className: 'kol-table-cell-text',
69
111
  },
112
+ hasPath && {
113
+ accessor: 'path',
114
+ header: 'Path',
115
+ className: 'kol-table-cell-meta',
116
+ render: (row) => (row.path ? <code>{row.path}</code> : null),
117
+ },
70
118
  {
71
119
  accessor: 'format',
72
120
  header: 'Format',
@@ -77,50 +125,105 @@ export default function AssetTable({
77
125
  header: 'Dimensions',
78
126
  className: 'kol-table-cell-meta',
79
127
  },
128
+ inkTokens && {
129
+ accessor: 'color',
130
+ header: 'Color',
131
+ className: 'kol-table-cell-meta',
132
+ render: (row) => <code>{tokenFor(row.id)}</code>,
133
+ },
80
134
  hasDownload && {
81
135
  accessor: 'download',
82
136
  header: 'Download',
83
137
  style: { width: 96 },
84
- render: (row) => <DownloadControl row={row} />,
138
+ render: (row) => (
139
+ <DownloadControl
140
+ row={row}
141
+ token={tokenFor(row.id)}
142
+ onToggleInk={inkTokens ? () => toggleInk(row.id) : null}
143
+ />
144
+ ),
85
145
  },
86
146
  ].filter(Boolean)
87
147
 
88
148
  return <Table caption={caption} columns={columns} rows={withId} variant={variant} className={className} />
89
149
  }
90
150
 
151
+ /* Preview cell. With `onZoom` it is a zoom button; without, the bare node —
152
+ * a table that only lists assets should not claim a click. `token` paints the
153
+ * node through currentColor, which is what makes one SVG serve both inks. */
154
+ function PreviewCell({ row, token, onZoom }) {
155
+ if (row.preview == null) return null
156
+
157
+ const style = {
158
+ ...(token ? { color: `var(${token})` } : {}),
159
+ ...(row.previewWidth ? { width: row.previewWidth } : {}),
160
+ }
161
+
162
+ if (!onZoom) {
163
+ return Object.keys(style).length ? <span className="inline-flex items-center" style={style}>{row.preview}</span> : row.preview
164
+ }
165
+
166
+ return (
167
+ <button
168
+ type="button"
169
+ onClick={() => onZoom(row, resolveToken(token))}
170
+ className="inline-flex items-center cursor-zoom-in"
171
+ style={style}
172
+ aria-label={`Open ${typeof row.name === 'string' ? row.name : 'asset'} overlay`}
173
+ >
174
+ {row.preview}
175
+ </button>
176
+ )
177
+ }
178
+
91
179
  /* Per-row download affordance. onDownload → button; href → anchor; else nothing.
92
180
  * The DS `download` icon inherits currentColor; aria-label composes the asset
93
- * name for screen readers (an a11y string, not rendered content). */
94
- function DownloadControl({ row }) {
181
+ * name for screen readers (an a11y string, not rendered content). Under
182
+ * `inkTokens` a toggle dot rides beside it — the ink a download will carry is
183
+ * chosen where the download is, not in a separate control. */
184
+ function DownloadControl({ row, token, onToggleInk }) {
95
185
  const label = `Download ${typeof row.name === 'string' ? row.name : 'asset'}`
96
186
 
97
- if (row.onDownload) {
98
- return (
187
+ const control = row.onDownload ? (
188
+ <button
189
+ type="button"
190
+ onClick={() => row.onDownload(row, resolveToken(token))}
191
+ className="inline-flex items-center text-fg-64"
192
+ aria-label={label}
193
+ title="Download"
194
+ >
195
+ <Icon name="download" size={16} />
196
+ </button>
197
+ ) : row.href ? (
198
+ <a
199
+ href={row.href}
200
+ download={row.filename ?? true}
201
+ className="inline-flex items-center text-fg-64"
202
+ aria-label={label}
203
+ title="Download"
204
+ >
205
+ <Icon name="download" size={16} />
206
+ </a>
207
+ ) : null
208
+
209
+ if (!control) return null
210
+ if (!onToggleInk) return control
211
+
212
+ return (
213
+ <span className="inline-flex items-center gap-2">
214
+ {control}
99
215
  <button
100
216
  type="button"
101
- onClick={() => row.onDownload(row)}
102
- className="inline-flex items-center text-fg-64"
103
- aria-label={label}
104
- title="Download"
217
+ onClick={onToggleInk}
218
+ className="inline-flex items-center justify-center w-6 h-6"
219
+ aria-label={`Toggle download color — current ${token}`}
220
+ title={`Toggle (currently ${token})`}
105
221
  >
106
- <Icon name="download" size={16} />
222
+ <span
223
+ className="inline-block rounded-full border border-fg-24"
224
+ style={{ width: 8, height: 8, background: `var(${token})` }}
225
+ />
107
226
  </button>
108
- )
109
- }
110
-
111
- if (row.href) {
112
- return (
113
- <a
114
- href={row.href}
115
- download={row.filename ?? true}
116
- className="inline-flex items-center text-fg-64"
117
- aria-label={label}
118
- title="Download"
119
- >
120
- <Icon name="download" size={16} />
121
- </a>
122
- )
123
- }
124
-
125
- return null
227
+ </span>
228
+ )
126
229
  }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * SocialMocks — a mark in situ on social surfaces: feed posts at 1:1, stories
3
+ * at 9:16, and the round profile avatar.
4
+ *
5
+ * Lifted from the two brand apps (`brand-book-mocks-two-consumers`,
6
+ * 2026-09-03 — 136 lines in kol-client-olina, 136 in kol-website, four lines
7
+ * of diff between them). ASSET-AGNOSTIC the way `LogoCard` is: every mark is a
8
+ * consumer-injected node and every string is a prop. What the forks baked in
9
+ * and this does not:
10
+ *
11
+ * - `KolLogo` imported from the app → the `mark` node.
12
+ * - A seven-stop client palette (champagne / sand / burgundy / maroon / wine)
13
+ * → `palette`, defaulting to the theme's own ink and paper. A brand supplies
14
+ * its own; the DS ships no colour.
15
+ * - `'Bricolage Grotesque'` and `'JetBrains Mono'` by name → `fonts`,
16
+ * defaulting to `--kol-font-family-sans-narrow` / `--kol-font-family-mono`.
17
+ * A foreign family by name in a package is how a consumer's type silently
18
+ * stops being the system's.
19
+ * - The client's own copy ("Made by hand, made to last.", "Edda Coat",
20
+ * "AW 2026", "Quiet, considered.") → text props with neutral placeholders.
21
+ *
22
+ * The geometry is carried through class-for-class: the aspect boxes, the mark
23
+ * fractions (w-1/4 · w-1/5 · w-1/6 · w-1/3 · w-1/2) and the corner insets are
24
+ * the forks' numbers, not new ones.
25
+ *
26
+ * const brand = { mark: <Asset name="logomark" />, palette: { accent: '#F3F1EC' } }
27
+ * <PostPhoto {...brand} />
28
+ * <StoryType {...brand} quote={<>Quiet,<br/>considered.</>} />
29
+ * <ProfileAvatar {...brand} />
30
+ *
31
+ * One object spread into each mock is the intended call shape — no context, no
32
+ * provider: a brand book renders these in a grid and already holds the object.
33
+ */
34
+
35
+ /* Neutral defaults. `accent` is the only stop a brand normally overrides; the
36
+ * rest track the theme so an un-themed render is still legible. */
37
+ export const DEFAULT_MOCK_PALETTE = {
38
+ paper: '#FCFBFB',
39
+ ink: '#131316',
40
+ accent: '#F3F1EC',
41
+ photo: '#363639',
42
+ }
43
+
44
+ export const DEFAULT_MOCK_FONTS = {
45
+ display: 'var(--kol-font-family-sans-narrow)',
46
+ mono: 'var(--kol-font-family-mono)',
47
+ }
48
+
49
+ const capsStyle = (fonts) => ({
50
+ fontFamily: fonts.mono,
51
+ fontWeight: 500,
52
+ letterSpacing: '0.1em',
53
+ textTransform: 'uppercase',
54
+ })
55
+
56
+ /* Every mock resolves its palette and fonts the same way, so a consumer can
57
+ * pass one, both or neither and never get a half-themed frame. */
58
+ const resolve = (palette, fonts) => [
59
+ { ...DEFAULT_MOCK_PALETTE, ...palette },
60
+ { ...DEFAULT_MOCK_FONTS, ...fonts },
61
+ ]
62
+
63
+ /**
64
+ * PostPhoto — feed post, photo ground with the mark in the corner (1:1).
65
+ *
66
+ * @param {ReactNode} mark - The brand mark
67
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE
68
+ * @param {Object} fonts - Partial override of DEFAULT_MOCK_FONTS
69
+ * @param {ReactNode} media - Node behind the mark — an <img>, a video frame; omitted, the `photo` stop fills it
70
+ * @param {string} className - Extra classes on the frame
71
+ */
72
+ export function PostPhoto({ mark, palette, fonts, media, className = '' }) {
73
+ const [pal] = resolve(palette, fonts)
74
+ return (
75
+ <div
76
+ className={`aspect-square relative rounded-sm overflow-hidden ${className}`.trim()}
77
+ style={{ background: pal.photo, color: pal.paper }}
78
+ >
79
+ {media}
80
+ <span className="absolute bottom-4 right-4 w-1/4">{mark}</span>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ /**
86
+ * PostType — feed post, type-driven quote on the accent ground (1:1).
87
+ *
88
+ * @param {ReactNode} mark - The brand mark
89
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE
90
+ * @param {Object} fonts - Partial override of DEFAULT_MOCK_FONTS
91
+ * @param {ReactNode} quote - The line; authored with its own <br/> breaks
92
+ * @param {string} className - Extra classes on the frame
93
+ */
94
+ export function PostType({ mark, palette, fonts, quote = 'Your line here.', className = '' }) {
95
+ const [pal, font] = resolve(palette, fonts)
96
+ return (
97
+ <div
98
+ className={`aspect-square relative flex items-center justify-center p-8 rounded-sm ${className}`.trim()}
99
+ style={{ background: pal.accent, color: pal.ink }}
100
+ >
101
+ <p style={{ fontFamily: font.display, fontSize: 20, fontWeight: 500, lineHeight: 1.15, textAlign: 'center', letterSpacing: '-0.01em' }}>
102
+ {quote}
103
+ </p>
104
+ <span className="absolute bottom-4 left-4 w-1/5">{mark}</span>
105
+ </div>
106
+ )
107
+ }
108
+
109
+ /**
110
+ * PostProduct — feed post, product flat with a caption row (1:1).
111
+ *
112
+ * @param {ReactNode} mark - The brand mark
113
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE
114
+ * @param {Object} fonts - Partial override of DEFAULT_MOCK_FONTS
115
+ * @param {ReactNode} media - Fills the product panel; omitted, the `placeholder` label sits in the accent stop
116
+ * @param {string} placeholder - Label shown in an empty product panel (default: 'product')
117
+ * @param {string} eyebrow - Small caps line above the product name (default: 'Style')
118
+ * @param {ReactNode} name - The product name
119
+ * @param {string} className - Extra classes on the frame
120
+ */
121
+ export function PostProduct({
122
+ mark,
123
+ palette,
124
+ fonts,
125
+ media,
126
+ placeholder = 'product',
127
+ eyebrow = 'Style',
128
+ name = 'Product name',
129
+ className = '',
130
+ }) {
131
+ const [pal, font] = resolve(palette, fonts)
132
+ const caps = capsStyle(font)
133
+ return (
134
+ <div
135
+ className={`aspect-square relative flex flex-col p-6 rounded-sm ${className}`.trim()}
136
+ style={{ background: pal.paper, color: pal.ink }}
137
+ >
138
+ <div
139
+ className="flex-1 flex items-center justify-center rounded-sm overflow-hidden"
140
+ style={{ background: pal.accent }}
141
+ >
142
+ {media ?? <span style={{ ...caps, fontSize: 8, opacity: 0.35 }}>{placeholder}</span>}
143
+ </div>
144
+ <div className="mt-4 flex items-end justify-between">
145
+ <div>
146
+ <p style={{ ...caps, fontSize: 7, opacity: 0.5 }}>{eyebrow}</p>
147
+ <p style={{ fontFamily: font.display, fontSize: 14, fontWeight: 500 }}>{name}</p>
148
+ </div>
149
+ <span className="w-1/6">{mark}</span>
150
+ </div>
151
+ </div>
152
+ )
153
+ }
154
+
155
+ /**
156
+ * PostEditorial — feed post, half photo / half type (1:1).
157
+ *
158
+ * @param {ReactNode} mark - The brand mark
159
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE
160
+ * @param {Object} fonts - Partial override of DEFAULT_MOCK_FONTS
161
+ * @param {ReactNode} media - Fills the photo half; omitted, the `photo` stop does
162
+ * @param {string} eyebrow - Small caps line at the top of the type half (default: 'Season')
163
+ * @param {ReactNode} title - The headline; authored with its own <br/> breaks
164
+ * @param {string} className - Extra classes on the frame
165
+ */
166
+ export function PostEditorial({
167
+ mark,
168
+ palette,
169
+ fonts,
170
+ media,
171
+ eyebrow = 'Season',
172
+ title = 'Your headline',
173
+ className = '',
174
+ }) {
175
+ const [pal, font] = resolve(palette, fonts)
176
+ const caps = capsStyle(font)
177
+ return (
178
+ <div className={`aspect-square grid grid-cols-2 rounded-sm overflow-hidden ${className}`.trim()}>
179
+ <div style={{ background: pal.photo }}>{media}</div>
180
+ <div
181
+ className="relative flex flex-col justify-between p-5"
182
+ style={{ background: pal.accent, color: pal.ink }}
183
+ >
184
+ <p style={{ ...caps, fontSize: 7, opacity: 0.5 }}>{eyebrow}</p>
185
+ <div>
186
+ <p style={{ fontFamily: font.display, fontSize: 16, fontWeight: 500, lineHeight: 1.1 }}>
187
+ {title}
188
+ </p>
189
+ <span className="block w-1/3 mt-3">{mark}</span>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ /**
197
+ * StoryPhoto — story frame, full-bleed photo with the mark low-left (9:16).
198
+ *
199
+ * @param {ReactNode} mark - The brand mark
200
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE
201
+ * @param {Object} fonts - Partial override of DEFAULT_MOCK_FONTS
202
+ * @param {ReactNode} media - Fills the frame; omitted, the `photo` stop does
203
+ * @param {string} className - Extra classes on the frame
204
+ */
205
+ export function StoryPhoto({ mark, palette, fonts, media, className = '' }) {
206
+ const [pal] = resolve(palette, fonts)
207
+ return (
208
+ <div
209
+ className={`aspect-[9/16] relative rounded-sm overflow-hidden ${className}`.trim()}
210
+ style={{ background: pal.photo, color: pal.paper }}
211
+ >
212
+ {media}
213
+ <span className="absolute bottom-6 left-6 w-1/3">{mark}</span>
214
+ </div>
215
+ )
216
+ }
217
+
218
+ /**
219
+ * StoryType — story frame, type-driven with the mark centred low (9:16).
220
+ *
221
+ * @param {ReactNode} mark - The brand mark
222
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE — `ink` is the ground here and `accent` the type
223
+ * @param {Object} fonts - Partial override of DEFAULT_MOCK_FONTS
224
+ * @param {ReactNode} quote - The line; authored with its own <br/> breaks
225
+ * @param {string} className - Extra classes on the frame
226
+ */
227
+ export function StoryType({ mark, palette, fonts, quote = 'Your line here.', className = '' }) {
228
+ const [pal, font] = resolve(palette, fonts)
229
+ return (
230
+ <div
231
+ className={`aspect-[9/16] relative flex items-center justify-center p-8 rounded-sm ${className}`.trim()}
232
+ style={{ background: pal.ink, color: pal.accent }}
233
+ >
234
+ <p style={{ fontFamily: font.display, fontSize: 18, fontWeight: 500, lineHeight: 1.2, textAlign: 'center', letterSpacing: '-0.01em' }}>
235
+ {quote}
236
+ </p>
237
+ <span className="absolute bottom-6 left-1/2 -translate-x-1/2 w-1/3">{mark}</span>
238
+ </div>
239
+ )
240
+ }
241
+
242
+ /**
243
+ * ProfileAvatar — the round profile mark (1:1).
244
+ *
245
+ * The forks called it `Avatar`, which is already a kol-component atom (a
246
+ * PERSON's avatar). Two packages exporting one name is a collision the roster
247
+ * gate catches; this is the social-profile frame, so it takes the longer name.
248
+ *
249
+ * @param {ReactNode} mark - The brand mark
250
+ * @param {Object} palette - Partial override of DEFAULT_MOCK_PALETTE
251
+ * @param {string} bg - Ground colour; unset, the `paper` stop
252
+ * @param {'dark'|'light'} polarity - Which way the mark inks against that ground (default: 'dark')
253
+ * @param {string} className - Extra classes on the frame
254
+ */
255
+ export function ProfileAvatar({ mark, palette, bg, polarity = 'dark', className = '' }) {
256
+ const [pal] = resolve(palette)
257
+ return (
258
+ <div
259
+ className={`aspect-square rounded-full flex items-center justify-center overflow-hidden ${className}`.trim()}
260
+ style={{ background: bg ?? pal.paper, color: polarity === 'light' ? pal.paper : pal.ink }}
261
+ >
262
+ <span className="w-1/2">{mark}</span>
263
+ </div>
264
+ )
265
+ }
@@ -0,0 +1,386 @@
1
+ /**
2
+ * StationeryMocks — the printed stationery set a brand manual shows: business
3
+ * card front/back, DL envelope, letterhead (plain and correspondence), and the
4
+ * email signature.
5
+ *
6
+ * Lifted from the two brand apps (`brand-book-mocks-two-consumers`,
7
+ * 2026-09-03). ASSET-AGNOSTIC like `LogoCard` and `SocialMocks`: the mark is a
8
+ * consumer-injected node, the brand's own details arrive as `info`, and every
9
+ * body string is a prop or a child. The geometry — paddings, type sizes, the
10
+ * rules and the grids — is carried through from the forks unchanged.
11
+ *
12
+ * ⚠ **SIX of the fork's TWENTY-SEVEN exports are here.** The other 21 are the
13
+ * garment / packaging kit (hangtags, swing tags, care · neck · size labels,
14
+ * edition cards, dust bags, garment bags, gift boxes, and their B variants —
15
+ * ~500 lines). They are NOT ported yet, deliberately: that file had accreted
16
+ * three clients' content in one place — a customer's real name and street
17
+ * address in the letter body, prices in €, another brand's woven-label
18
+ * references, and a palette its own source comment attributes to *"another
19
+ * client, carried in with the app"*. Carrying that through verbatim would ship
20
+ * one client's correspondence inside a shared package; stripping it is not a
21
+ * port but a new API of 21 components with four to eight text props each. The
22
+ * remainder is recorded on the ticket for the user's ruling.
23
+ *
24
+ * const brand = { mark: <Asset name="logomark" />, info: BRAND_INFO }
25
+ * <AssetCard caption="Business card">
26
+ * <div style={{ aspectRatio: '85 / 55' }}><BusinessCardFront {...brand} /></div>
27
+ * </AssetCard>
28
+ *
29
+ * Every mock fills its container (`w-full h-full`) and takes its proportion
30
+ * from the wrapper — the forks' contract, so a card is sized by an 85×55 box
31
+ * and a letterhead by an A4 one, never by the component.
32
+ */
33
+
34
+ /* Neutral placeholders. Nothing here is a real brand's data — a consumer
35
+ * passes its own `info` and the defaults exist so an un-fed mock still shows
36
+ * the layout rather than collapsing. */
37
+ export const DEFAULT_BRAND_INFO = {
38
+ identity: { name: 'Brand name', role: 'Role', founder: 'Founder name' },
39
+ contact: { email: 'hello@example.com', phone: '+00 000 0000', web: 'example.com' },
40
+ studio: { street: 'Street 1', postcode: '000 City', city: 'City', country: 'Country', locShort: 'City' },
41
+ legal: { entity: 'Entity name', kt: '000000-0000' },
42
+ labels: { madeIn: 'Made in' },
43
+ }
44
+
45
+ export const DEFAULT_STATIONERY_PALETTE = {
46
+ paper: '#FCFBFB',
47
+ ink: '#131316',
48
+ accent: '#F3F1EC',
49
+ }
50
+
51
+ export const DEFAULT_STATIONERY_FONTS = {
52
+ display: 'var(--kol-font-family-sans-narrow)',
53
+ text: 'var(--kol-font-family-sans)',
54
+ mono: 'var(--kol-font-family-mono)',
55
+ }
56
+
57
+ /* One resolver for all six so a consumer can pass a partial `info` (just
58
+ * `contact`, say) without the rest going undefined mid-render. */
59
+ function resolve(info, palette, fonts) {
60
+ const merged = {
61
+ identity: { ...DEFAULT_BRAND_INFO.identity, ...info?.identity },
62
+ contact: { ...DEFAULT_BRAND_INFO.contact, ...info?.contact },
63
+ studio: { ...DEFAULT_BRAND_INFO.studio, ...info?.studio },
64
+ legal: { ...DEFAULT_BRAND_INFO.legal, ...info?.legal },
65
+ labels: { ...DEFAULT_BRAND_INFO.labels, ...info?.labels },
66
+ }
67
+ return [
68
+ merged,
69
+ { ...DEFAULT_STATIONERY_PALETTE, ...palette },
70
+ { ...DEFAULT_STATIONERY_FONTS, ...fonts },
71
+ ]
72
+ }
73
+
74
+ const capsStyle = (fonts) => ({
75
+ fontFamily: fonts.mono,
76
+ fontWeight: 500,
77
+ letterSpacing: '0.1em',
78
+ textTransform: 'uppercase',
79
+ })
80
+
81
+ /**
82
+ * BusinessCardFront — the mark, centred, on paper.
83
+ *
84
+ * @param {ReactNode} mark - The brand mark; sized to 70% of the card height
85
+ * @param {Object} palette - Partial override of DEFAULT_STATIONERY_PALETTE
86
+ * @param {string} className - Extra classes on the card
87
+ */
88
+ export function BusinessCardFront({ mark, palette, className = '' }) {
89
+ const [, pal] = resolve(null, palette)
90
+ return (
91
+ <div
92
+ className={`w-full h-full flex items-center justify-center p-8 [&_svg]:w-auto [&_svg]:h-full ${className}`.trim()}
93
+ style={{ background: pal.paper, color: pal.ink }}
94
+ >
95
+ <span className="max-h-[70%] flex items-center">{mark}</span>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ /**
101
+ * BusinessCardBack — role and name over the contact block, inked.
102
+ *
103
+ * @param {Object} info - Partial override of DEFAULT_BRAND_INFO — reads identity.role, identity.founder, contact.email, contact.phone, studio.city, studio.country
104
+ * @param {Object} palette - Partial override of DEFAULT_STATIONERY_PALETTE
105
+ * @param {Object} fonts - Partial override of DEFAULT_STATIONERY_FONTS
106
+ * @param {string} className - Extra classes on the card
107
+ */
108
+ export function BusinessCardBack({ info, palette, fonts, className = '' }) {
109
+ const [brand, pal, font] = resolve(info, palette, fonts)
110
+ return (
111
+ <div
112
+ className={`w-full h-full flex flex-col justify-between p-8 border border-fg-08 ${className}`.trim()}
113
+ style={{ background: pal.ink, color: pal.paper }}
114
+ >
115
+ <div>
116
+ <p style={{ ...capsStyle(font), fontSize: 9, opacity: 0.6 }}>{brand.identity.role}</p>
117
+ <p style={{ fontFamily: font.display, fontSize: 18, fontWeight: 500, marginTop: 4 }}>
118
+ {brand.identity.founder}
119
+ </p>
120
+ </div>
121
+ <div style={{ fontFamily: font.mono, fontSize: 9, lineHeight: 1.6, letterSpacing: '0.05em' }}>
122
+ <p>{brand.contact.email}</p>
123
+ <p>{brand.contact.phone}</p>
124
+ <p>{brand.studio.city}, {brand.studio.country}</p>
125
+ </div>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Envelope — DL, mark top-left, return address bottom-right, fold line.
132
+ *
133
+ * @param {ReactNode} mark - The brand mark, in an 8-unit-tall box
134
+ * @param {Object} info - Partial override of DEFAULT_BRAND_INFO — reads identity.name and the studio block
135
+ * @param {Object} palette - Partial override of DEFAULT_STATIONERY_PALETTE
136
+ * @param {Object} fonts - Partial override of DEFAULT_STATIONERY_FONTS
137
+ * @param {string} className - Extra classes on the envelope
138
+ */
139
+ export function Envelope({ mark, info, palette, fonts, className = '' }) {
140
+ const [brand, pal, font] = resolve(info, palette, fonts)
141
+ return (
142
+ <div
143
+ className={`w-full h-full relative p-8 [&_svg]:w-auto [&_svg]:h-full ${className}`.trim()}
144
+ style={{ background: pal.paper, color: pal.ink }}
145
+ >
146
+ <div
147
+ className="absolute inset-x-0 top-1/2 border-t border-dashed"
148
+ style={{ borderColor: 'rgba(0,0,0,0.08)' }}
149
+ />
150
+ <div className="absolute top-8 left-8 h-8">{mark}</div>
151
+ <div
152
+ className="absolute bottom-8 right-8 text-right"
153
+ style={{ fontFamily: font.text, fontSize: 11, lineHeight: 1.5, color: pal.ink, opacity: 0.8 }}
154
+ >
155
+ <p>{brand.identity.name}</p>
156
+ <p>{brand.studio.street}</p>
157
+ <p>{brand.studio.postcode}</p>
158
+ <p>{brand.studio.country}</p>
159
+ </div>
160
+ </div>
161
+ )
162
+ }
163
+
164
+ /**
165
+ * Letterhead — A4: wordmark, body, contact rule at the foot.
166
+ *
167
+ * @param {ReactNode} mark - The brand mark (a wordmark in the forks), in a 10-unit-tall box
168
+ * @param {Object} info - Partial override of DEFAULT_BRAND_INFO — reads identity.founder, contact.web, contact.phone, studio.city, studio.country
169
+ * @param {Object} palette - Partial override of DEFAULT_STATIONERY_PALETTE
170
+ * @param {Object} fonts - Partial override of DEFAULT_STATIONERY_FONTS
171
+ * @param {ReactNode} children - The letter body; omitted, nothing renders between the mark and the signoff
172
+ * @param {ReactNode} signoff - Line above the signature (default: 'Warmly,')
173
+ * @param {string} className - Extra classes on the sheet
174
+ */
175
+ export function Letterhead({ mark, info, palette, fonts, children, signoff = 'Warmly,', className = '' }) {
176
+ const [brand, pal, font] = resolve(info, palette, fonts)
177
+ return (
178
+ <div
179
+ className={`w-full h-full flex flex-col p-12 [&_svg]:w-auto [&_svg]:h-auto ${className}`.trim()}
180
+ style={{ background: pal.paper, color: pal.ink }}
181
+ >
182
+ <div className="h-10 mb-12 [&_svg]:h-full [&_svg]:w-auto">{mark}</div>
183
+ <div
184
+ style={{
185
+ fontFamily: font.text,
186
+ fontSize: 10,
187
+ lineHeight: 1.7,
188
+ color: pal.ink,
189
+ fontWeight: 300,
190
+ letterSpacing: '0.02em',
191
+ }}
192
+ >
193
+ {children}
194
+ <p>{signoff}</p>
195
+ <p style={{ fontFamily: font.display, fontSize: 12, fontWeight: 500, marginTop: 16 }}>
196
+ {brand.identity.founder}
197
+ </p>
198
+ </div>
199
+ <div
200
+ className="mt-auto pt-8 border-t"
201
+ style={{
202
+ borderColor: 'rgba(0,0,0,0.08)',
203
+ fontFamily: font.mono,
204
+ fontSize: 8,
205
+ letterSpacing: '0.06em',
206
+ color: pal.ink,
207
+ opacity: 0.6,
208
+ }}
209
+ >
210
+ <p>{brand.contact.web} · {brand.studio.city}, {brand.studio.country} · {brand.contact.phone}</p>
211
+ </div>
212
+ </div>
213
+ )
214
+ }
215
+
216
+ /**
217
+ * LetterheadCorrespondence — the richer sheet: mark + address block, a
218
+ * date/ref/to grid, the body, a signed signoff, and a legal footer.
219
+ *
220
+ * The forks called this `LetterheadB`. Renamed on the way in — a B suffix says
221
+ * nothing about which sheet to reach for, and this is the correspondence
222
+ * variant. `LetterheadB` is exported as an alias so a fork can migrate without
223
+ * touching its call sites.
224
+ *
225
+ * @param {ReactNode} mark - The brand mark for the header, 48px tall
226
+ * @param {ReactNode} signature - Mark or scan above the signed name, 22px tall in the forks
227
+ * @param {Object} info - Partial override of DEFAULT_BRAND_INFO — reads the studio, contact, identity and legal blocks
228
+ * @param {Object} palette - Partial override of DEFAULT_STATIONERY_PALETTE
229
+ * @param {Object} fonts - Partial override of DEFAULT_STATIONERY_FONTS
230
+ * @param {Array<{label: string, value: ReactNode}>} fields - The date/ref/to grid; each row is a caps label and its value
231
+ * @param {ReactNode} children - The letter body
232
+ * @param {ReactNode} signoff - Line above the signature (default: 'With thanks,')
233
+ * @param {ReactNode} page - Footer page marker (default: 'Page 01 / 01')
234
+ * @param {string} className - Extra classes on the sheet
235
+ */
236
+ export function LetterheadCorrespondence({
237
+ mark,
238
+ signature,
239
+ info,
240
+ palette,
241
+ fonts,
242
+ fields = [],
243
+ children,
244
+ signoff = 'With thanks,',
245
+ page = 'Page 01 / 01',
246
+ className = '',
247
+ }) {
248
+ const [brand, pal, font] = resolve(info, palette, fonts)
249
+ const inkAt = (alpha) => `rgba(19,19,22,${alpha})`
250
+
251
+ return (
252
+ <div
253
+ className={`w-full h-full flex flex-col p-7 [&_svg]:w-auto [&_svg]:h-auto ${className}`.trim()}
254
+ style={{ background: pal.paper, color: pal.ink }}
255
+ >
256
+ <div className="flex justify-between items-start">
257
+ <span className="[&_svg]:h-12 [&_svg]:w-auto">{mark}</span>
258
+ <div
259
+ style={{
260
+ fontFamily: font.mono,
261
+ fontSize: 6.5,
262
+ lineHeight: 1.7,
263
+ color: inkAt(0.6),
264
+ letterSpacing: '0.08em',
265
+ textTransform: 'uppercase',
266
+ textAlign: 'right',
267
+ }}
268
+ >
269
+ {brand.studio.street}<br />
270
+ {brand.studio.locShort}<br />
271
+ {brand.contact.email}<br />
272
+ {brand.contact.phone}
273
+ </div>
274
+ </div>
275
+
276
+ {fields.length > 0 && (
277
+ <div
278
+ className="grid mt-6"
279
+ style={{
280
+ gridTemplateColumns: 'auto 1fr',
281
+ columnGap: 16,
282
+ rowGap: 3,
283
+ fontFamily: font.mono,
284
+ fontSize: 6.5,
285
+ letterSpacing: '0.04em',
286
+ color: inkAt(0.85),
287
+ }}
288
+ >
289
+ {fields.map((field) => (
290
+ <div key={field.label} className="contents">
291
+ <span style={{ color: inkAt(0.45), textTransform: 'uppercase', letterSpacing: '0.18em', fontSize: 6 }}>
292
+ {field.label}
293
+ </span>
294
+ <span>{field.value}</span>
295
+ </div>
296
+ ))}
297
+ </div>
298
+ )}
299
+
300
+ <div
301
+ className="mt-5 flex-1"
302
+ style={{ fontFamily: font.text, fontSize: 7.5, lineHeight: 1.6, color: inkAt(0.88), letterSpacing: '0.005em' }}
303
+ >
304
+ {children}
305
+ <p style={{ margin: '0 0 7px 0' }}>{signoff}</p>
306
+ <div style={{ marginTop: 12 }}>
307
+ {signature && (
308
+ <span className="block [&_svg]:h-[22px] [&_svg]:w-auto" style={{ margin: '6px 0 4px' }}>
309
+ {signature}
310
+ </span>
311
+ )}
312
+ <div style={{ fontFamily: font.display, fontSize: 8, fontWeight: 600, letterSpacing: '0.02em' }}>
313
+ {brand.identity.founder}
314
+ </div>
315
+ <div
316
+ style={{
317
+ fontFamily: font.mono,
318
+ fontSize: 6,
319
+ letterSpacing: '0.18em',
320
+ textTransform: 'uppercase',
321
+ color: inkAt(0.55),
322
+ marginTop: 2,
323
+ }}
324
+ >
325
+ {brand.identity.role}
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <div
331
+ className="mt-auto pt-2 flex justify-between"
332
+ style={{
333
+ fontFamily: font.mono,
334
+ fontSize: 5.5,
335
+ letterSpacing: '0.18em',
336
+ color: inkAt(0.45),
337
+ textTransform: 'uppercase',
338
+ borderTop: '0.5px solid rgba(19,19,22,0.18)',
339
+ }}
340
+ >
341
+ <span>{brand.legal.entity} · Kt. {brand.legal.kt}</span>
342
+ <span>{brand.contact.web}</span>
343
+ <span>{page}</span>
344
+ </div>
345
+ </div>
346
+ )
347
+ }
348
+
349
+ /** Deprecated alias — the forks' name for LetterheadCorrespondence. */
350
+ export const LetterheadB = LetterheadCorrespondence
351
+
352
+ /**
353
+ * EmailSignature — mark, rule, name and contact line.
354
+ *
355
+ * @param {ReactNode} mark - The brand mark, in a 16-unit square
356
+ * @param {Object} info - Partial override of DEFAULT_BRAND_INFO — reads identity.founder, identity.name, contact.email, contact.web
357
+ * @param {Object} palette - Partial override of DEFAULT_STATIONERY_PALETTE
358
+ * @param {Object} fonts - Partial override of DEFAULT_STATIONERY_FONTS
359
+ * @param {ReactNode} role - The line under the name; unset, it composes identity.role and identity.name
360
+ * @param {string} className - Extra classes on the block
361
+ */
362
+ export function EmailSignature({ mark, info, palette, fonts, role, className = '' }) {
363
+ const [brand, pal, font] = resolve(info, palette, fonts)
364
+ return (
365
+ <div
366
+ className={`flex items-center gap-6 p-6 rounded-sm ${className}`.trim()}
367
+ style={{ background: pal.paper, color: pal.ink }}
368
+ >
369
+ <div className="h-16 w-16 flex items-center justify-center [&_svg]:w-auto [&_svg]:h-full">{mark}</div>
370
+ <div className="border-l h-16" style={{ borderColor: 'rgba(0,0,0,0.12)' }} />
371
+ <div style={{ color: pal.ink }}>
372
+ <p style={{ fontFamily: font.display, fontSize: 16, fontWeight: 500, lineHeight: 1.2 }}>
373
+ {brand.identity.founder}
374
+ </p>
375
+ <p style={{ ...capsStyle(font), fontSize: 9, opacity: 0.6, marginTop: 2 }}>
376
+ {role ?? `${brand.identity.role} · ${brand.identity.name}`}
377
+ </p>
378
+ <p style={{ fontFamily: font.mono, fontSize: 10, marginTop: 8, lineHeight: 1.5, letterSpacing: '0.04em' }}>
379
+ <span style={{ opacity: 0.6 }}>{brand.contact.email}</span>
380
+ <span style={{ margin: '0 8px', opacity: 0.3 }}>·</span>
381
+ <span style={{ opacity: 0.6 }}>{brand.contact.web}</span>
382
+ </p>
383
+ </div>
384
+ </div>
385
+ )
386
+ }
package/src/Swatch.jsx ADDED
@@ -0,0 +1,60 @@
1
+ import { ColorSwatch } from '@kolkrabbi/kol-component'
2
+
3
+ /**
4
+ * Swatch — the DOCUMENTED swatch: chip + meta row (name, hex), with an
5
+ * optional canonical-anchor dot.
6
+ *
7
+ * `ColorSwatch` (kol-component) is the chip alone — a fixed-size pressable
8
+ * atom for paint bars and inspectors. A colour PAGE needs the chip plus its
9
+ * label and value, which is the form both brand apps had built locally
10
+ * (`brand-book-mocks-two-consumers`, 2026-09-03: 23 lines, byte-identical in
11
+ * kol-client-olina and kol-website). This composes the atom rather than
12
+ * redrawing it, per the ticket.
13
+ *
14
+ * ⚠ The forks were rendering `.kol-swatch` / `.kol-swatch-chip` /
15
+ * `.kol-swatch-meta` — classes that exist in NO stylesheet in the estate
16
+ * (checked kol-theme, both apps, the showcase; only `.kol-swatch-none-marker`
17
+ * is real). The chip was a bare div with a background and no dimensions, so it
18
+ * collapsed to zero height and the swatch has been showing its meta row over
19
+ * nothing. The ticket reported the CSS as already shipping in
20
+ * `kol-components-molecules.css`; it does not. So there is no verbatim
21
+ * rendering to carry through here — the chip gets a real box from
22
+ * `ColorSwatch`, and the dead hooks are dropped.
23
+ *
24
+ * <Swatch hex="#131316" name="Ink" anchor />
25
+ *
26
+ * The hex is uppercased — that is value formatting, not a text-transform on
27
+ * copy; `name` renders exactly as authored.
28
+ *
29
+ * @param {string} hex - The colour, e.g. '#131316' (required — it is both the paint and the printed value)
30
+ * @param {ReactNode} name - Label above the value; omitted, only the hex renders
31
+ * @param {boolean} anchor - Mark this stop as the palette's canonical anchor — a difference-blended dot centred on the chip (default: false)
32
+ * @param {number|'fill'|'stretch'} size - Forwarded to ColorSwatch; 'fill' (default) squares the chip to the column
33
+ * @param {string} className - Extra classes on the wrapper
34
+ */
35
+ export default function Swatch({ hex, name, anchor = false, size = 'fill', className = '' }) {
36
+ return (
37
+ <div className={`flex flex-col gap-2 ${className}`.trim()}>
38
+ <div className="relative">
39
+ <ColorSwatch hex={hex} size={size} />
40
+ {anchor && (
41
+ <span
42
+ aria-label="Canonical anchor"
43
+ className="absolute inset-0 flex items-center justify-center pointer-events-none"
44
+ >
45
+ {/* difference blend so the dot reads on any stop, light or dark —
46
+ the forks' trick, kept verbatim */}
47
+ <span
48
+ className="block rounded-full"
49
+ style={{ width: 10, height: 10, background: 'white', mixBlendMode: 'difference' }}
50
+ />
51
+ </span>
52
+ )}
53
+ </div>
54
+ <div className="kol-helper-10 flex flex-col">
55
+ {name && <span className="text-meta">{name}</span>}
56
+ <span className="text-strong font-semibold">{hex.toUpperCase()}</span>
57
+ </div>
58
+ </div>
59
+ )
60
+ }
package/src/index.js CHANGED
@@ -22,6 +22,20 @@ export {
22
22
  } from './comboLayouts.jsx'
23
23
  export { generatePalette, fgOn, hexToHsl, hslToHex, GENERATION_MODES } from './comboMath.js'
24
24
 
25
+ // The brand-book set the two brand apps were both maintaining locally
26
+ // (brand-book-mocks-two-consumers, 2026-09-03).
27
+ export { default as AssetCard } from './AssetCard.jsx'
28
+ export { default as Swatch } from './Swatch.jsx'
29
+ export {
30
+ PostPhoto, PostType, PostProduct, PostEditorial, StoryPhoto, StoryType, ProfileAvatar,
31
+ DEFAULT_MOCK_PALETTE, DEFAULT_MOCK_FONTS,
32
+ } from './SocialMocks.jsx'
33
+ export {
34
+ BusinessCardFront, BusinessCardBack, Envelope, Letterhead,
35
+ LetterheadCorrespondence, LetterheadB, EmailSignature,
36
+ DEFAULT_BRAND_INFO, DEFAULT_STATIONERY_PALETTE, DEFAULT_STATIONERY_FONTS,
37
+ } from './StationeryMocks.jsx'
38
+
25
39
  // This barrel exports THIS package only. The convenience re-exports were
26
40
  // dropped in 0.3.0: `export … from '@kolkrabbi/kol-foundry'` made every import
27
41
  // of this package pull foundry + opentype.js + framer-motion, so a consumer