@kolkrabbi/kol-styleguide 0.3.0 → 0.4.1
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 +2 -2
- package/src/AssetCard.jsx +46 -0
- package/src/AssetTable.jsx +158 -55
- package/src/SocialMocks.jsx +265 -0
- package/src/StationeryMocks.jsx +386 -0
- package/src/Swatch.jsx +73 -0
- package/src/index.js +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-styleguide",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
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.
|
|
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
|
+
}
|
package/src/AssetTable.jsx
CHANGED
|
@@ -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).
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* consumer
|
|
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:
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
* em-dash (DS Table
|
|
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
|
|
27
|
-
* name
|
|
28
|
-
* preview
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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={
|
|
102
|
-
className="inline-flex items-center
|
|
103
|
-
aria-label={
|
|
104
|
-
title=
|
|
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
|
-
<
|
|
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,73 @@
|
|
|
1
|
+
import { ColorSwatch } from '@kolkrabbi/kol-component'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Swatch — the DOCUMENTED swatch: a specimen bar over its meta row (name, hex),
|
|
5
|
+
* with an optional canonical-anchor dot.
|
|
6
|
+
*
|
|
7
|
+
* `ColorSwatch` (kol-component) is the chip alone — a fixed-size pressable atom
|
|
8
|
+
* for paint bars and inspectors. A colour PAGE needs the specimen plus its label
|
|
9
|
+
* and value, which is the form both brand apps had built locally
|
|
10
|
+
* (`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
|
+
* GEOMETRY IS CARRIED THROUGH. The forks wore `.kol-swatch` /
|
|
15
|
+
* `.kol-swatch-chip` / `.kol-swatch-meta`, and those rules are real — they ship
|
|
16
|
+
* in **`@kolkrabbi/kol-framework`** (`kol-framework.css`, the "Swatch / Ramp
|
|
17
|
+
* (color specimens)" block), not in kol-theme: a 6px-gap column, a **96px-tall**
|
|
18
|
+
* chip at radius 4, and a row-wrapped meta line on the baseline. They are
|
|
19
|
+
* stragglers — `.kol-mood-tile-*` left that same block for
|
|
20
|
+
* `kol-theme/kol-components-styleguide.css` on 2026-07-10 and these did not.
|
|
21
|
+
*
|
|
22
|
+
* So this component draws that geometry ITSELF rather than wearing the classes.
|
|
23
|
+
* kol-framework is not a peer of kol-styleguide and must not become one — a
|
|
24
|
+
* component whose look depends on another package's stylesheet being imported
|
|
25
|
+
* is the defect `styleguide-barrel-is-unimportable` was filed about. A
|
|
26
|
+
* consumer swapping its fork onto this gets the same render with no new
|
|
27
|
+
* install, and `height` is the seam if a page wants a different specimen depth.
|
|
28
|
+
*
|
|
29
|
+
* <Swatch hex="#131316" name="Ink" anchor />
|
|
30
|
+
*
|
|
31
|
+
* The hex is uppercased — that is value formatting, not a text-transform on
|
|
32
|
+
* copy; `name` renders exactly as authored.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} hex - The colour, e.g. '#131316' (required — it is both the paint and the printed value)
|
|
35
|
+
* @param {ReactNode} name - Label beside the value; omitted, only the hex renders
|
|
36
|
+
* @param {boolean} anchor - Mark this stop as the palette's canonical anchor — a difference-blended dot centred on the chip (default: false)
|
|
37
|
+
* @param {number|string} height - Specimen depth, the forks' `.kol-swatch-chip` height (default: 96)
|
|
38
|
+
* @param {string} className - Extra classes on the wrapper
|
|
39
|
+
*/
|
|
40
|
+
export default function Swatch({ hex, name, anchor = false, height = 96, className = '' }) {
|
|
41
|
+
return (
|
|
42
|
+
/* gap 6 — .kol-swatch */
|
|
43
|
+
<div className={`flex flex-col ${className}`.trim()} style={{ gap: 6 }}>
|
|
44
|
+
{/* .kol-swatch-chip: full column width, 96 tall, radius 4. ColorSwatch's
|
|
45
|
+
'stretch' size fills this box, so the atom owns the paint and the
|
|
46
|
+
frame while the specimen depth stays the page's decision. */}
|
|
47
|
+
<div className="relative w-full" style={{ height }}>
|
|
48
|
+
<ColorSwatch hex={hex} size="stretch" radius="sm" />
|
|
49
|
+
{anchor && (
|
|
50
|
+
<span
|
|
51
|
+
aria-label="Canonical anchor"
|
|
52
|
+
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
|
53
|
+
>
|
|
54
|
+
{/* difference blend so the dot reads on any stop, light or dark —
|
|
55
|
+
the forks' trick, kept verbatim */}
|
|
56
|
+
<span
|
|
57
|
+
className="block rounded-full"
|
|
58
|
+
style={{ width: 10, height: 10, background: 'white', mixBlendMode: 'difference' }}
|
|
59
|
+
/>
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{/* .kol-swatch-meta: row, space-between, baseline, wrapping */}
|
|
64
|
+
<div
|
|
65
|
+
className="kol-helper-10 flex flex-row flex-wrap items-baseline justify-between"
|
|
66
|
+
style={{ gap: '4px 12px' }}
|
|
67
|
+
>
|
|
68
|
+
{name && <span className="text-meta">{name}</span>}
|
|
69
|
+
<span className="text-strong font-semibold">{hex.toUpperCase()}</span>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
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
|