@ciderpress/ui 1.0.0-rc.10 → 1.0.0-rc.11

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.
Files changed (80) hide show
  1. package/dist/head/css/loader-apple.css +1 -1
  2. package/dist/head/css/loader-dots.css +1 -1
  3. package/dist/head/css/themes/amber.css +1 -1
  4. package/dist/head/css/themes/arcade.css +1 -1
  5. package/dist/head/css/themes/grannysmith.css +1 -1
  6. package/dist/head/css/themes/honeycrisp.css +1 -1
  7. package/dist/head/css/themes/midnight.css +1 -1
  8. package/dist/head/css/themes/mulled.css +1 -1
  9. package/dist/node.mjs +408 -15
  10. package/dist/theme/components/home/cta.css +15 -0
  11. package/dist/theme/components/home/cta.tsx +10 -2
  12. package/dist/theme/components/home/feature-card.css +1 -1
  13. package/dist/theme/components/home/feature-card.tsx +3 -2
  14. package/dist/theme/components/home/feature.tsx +34 -31
  15. package/dist/theme/components/home/hero-demo.css +11 -0
  16. package/dist/theme/components/home/home-visual.tsx +226 -0
  17. package/dist/theme/components/home/layout.tsx +350 -171
  18. package/dist/theme/components/home/split.css +46 -19
  19. package/dist/theme/components/home/split.tsx +28 -8
  20. package/dist/theme/components/home/tabs.css +299 -0
  21. package/dist/theme/components/home/tabs.tsx +351 -0
  22. package/dist/theme/components/home/trust-strip.css +41 -0
  23. package/dist/theme/components/home/trust-strip.tsx +168 -11
  24. package/dist/theme/components/home/workspaces.tsx +171 -69
  25. package/dist/theme/components/workspaces/card.css +4 -0
  26. package/dist/theme/components/workspaces/card.tsx +3 -3
  27. package/dist/theme/components/workspaces/grid.tsx +18 -6
  28. package/dist/theme/index.tsx +5 -0
  29. package/dist/theme/lib/rich-text-parse.ts +740 -0
  30. package/dist/theme/lib/rich-text.test.tsx +342 -0
  31. package/dist/theme/lib/rich-text.tsx +86 -0
  32. package/dist/theme/styles/overrides/fonts.css +7 -4
  33. package/dist/theme/styles/overrides/home.css +11 -2
  34. package/dist/theme/styles/overrides/rspress.css +10 -5
  35. package/dist/theme/styles/overrides/tokens.css +41 -23
  36. package/dist/theme/styles/rich-text.css +106 -0
  37. package/dist/theme/styles/themes/amber.css +32 -2
  38. package/dist/theme/styles/themes/arcade.css +16 -1
  39. package/dist/theme/styles/themes/grannysmith.css +32 -2
  40. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  41. package/dist/theme/styles/themes/midnight.css +16 -1
  42. package/dist/theme/styles/themes/mulled.css +54 -9
  43. package/package.json +3 -3
  44. package/src/theme/components/home/cta.css +15 -0
  45. package/src/theme/components/home/cta.tsx +10 -2
  46. package/src/theme/components/home/feature-card.css +1 -1
  47. package/src/theme/components/home/feature-card.tsx +3 -2
  48. package/src/theme/components/home/feature.tsx +34 -31
  49. package/src/theme/components/home/hero-demo.css +11 -0
  50. package/src/theme/components/home/home-visual.tsx +226 -0
  51. package/src/theme/components/home/layout.tsx +350 -171
  52. package/src/theme/components/home/split.css +46 -19
  53. package/src/theme/components/home/split.tsx +28 -8
  54. package/src/theme/components/home/tabs.css +299 -0
  55. package/src/theme/components/home/tabs.tsx +351 -0
  56. package/src/theme/components/home/trust-strip.css +41 -0
  57. package/src/theme/components/home/trust-strip.tsx +168 -11
  58. package/src/theme/components/home/workspaces.tsx +171 -69
  59. package/src/theme/components/workspaces/card.css +4 -0
  60. package/src/theme/components/workspaces/card.tsx +3 -3
  61. package/src/theme/components/workspaces/grid.tsx +18 -6
  62. package/src/theme/index.tsx +5 -0
  63. package/src/theme/lib/rich-text-parse.ts +740 -0
  64. package/src/theme/lib/rich-text.test.tsx +342 -0
  65. package/src/theme/lib/rich-text.tsx +86 -0
  66. package/src/theme/styles/overrides/fonts.css +7 -4
  67. package/src/theme/styles/overrides/home.css +11 -2
  68. package/src/theme/styles/overrides/rspress.css +10 -5
  69. package/src/theme/styles/overrides/tokens.css +41 -23
  70. package/src/theme/styles/rich-text.css +106 -0
  71. package/src/theme/styles/themes/amber.css +32 -2
  72. package/src/theme/styles/themes/arcade.css +16 -1
  73. package/src/theme/styles/themes/grannysmith.css +32 -2
  74. package/src/theme/styles/themes/honeycrisp.css +37 -7
  75. package/src/theme/styles/themes/midnight.css +16 -1
  76. package/src/theme/styles/themes/mulled.css +54 -9
  77. package/dist/theme/components/home/hero-demo-custom.tsx +0 -116
  78. package/dist/theme/components/home/split-visual-custom.tsx +0 -26
  79. package/src/theme/components/home/hero-demo-custom.tsx +0 -116
  80. package/src/theme/components/home/split-visual-custom.tsx +0 -26
@@ -0,0 +1,342 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, it, expect, vi } from 'vitest'
3
+
4
+ import { hasAccentMarker, isPlainText, renderRichText, toPlainText } from './rich-text.tsx'
5
+
6
+ // `RouteLink` pulls in Rspress's router, which resolves the
7
+ // `virtual-routes` module only inside a real build. Stand in a plain
8
+ // anchor so link output is assertable.
9
+ vi.mock('@rspress/core/runtime', async () => {
10
+ const react = await import('react')
11
+ return {
12
+ Link: (props: { readonly to: string; readonly children?: React.ReactNode }) =>
13
+ react.createElement('a', { href: props.to }, props.children),
14
+ }
15
+ })
16
+
17
+ describe('renderRichText()', () => {
18
+ it('should render plain copy unchanged', () => {
19
+ expect(html('Beautiful docs')).toBe('Beautiful docs')
20
+ })
21
+
22
+ it('should render emphasis markers as a branded strong', () => {
23
+ expect(html('Ship **fast**')).toContain('<strong class="cp-accent">fast</strong>')
24
+ })
25
+
26
+ it('should render html strong as plain bold, without the accent', () => {
27
+ const out = html('Ship <strong>fast</strong>')
28
+ expect(out).toContain('<strong>fast</strong>')
29
+ expect(out).not.toContain('cp-accent')
30
+ })
31
+
32
+ it('should render italic markers as em', () => {
33
+ expect(html('Ship *fast*')).toContain('<em>fast</em>')
34
+ })
35
+
36
+ it('should accent only the marked words', () => {
37
+ const out = html('Docs, **Zero Effort**')
38
+ expect(out).toContain('Docs, ')
39
+ expect(out).toContain('<strong class="cp-accent">Zero Effort</strong>')
40
+ })
41
+
42
+ it('should render highlight markers as a tinted mark', () => {
43
+ expect(html('Now with ==OpenAPI==')).toContain('<mark class="cp-mark">OpenAPI</mark>')
44
+ })
45
+
46
+ it('should keep highlight and accent distinct', () => {
47
+ const out = html('**accent** and ==highlight==')
48
+ expect(out).toContain('<strong class="cp-accent">accent</strong>')
49
+ expect(out).toContain('<mark class="cp-mark">highlight</mark>')
50
+ })
51
+
52
+ it('should render code spans literally', () => {
53
+ expect(html('Run `ciderpress dev`')).toContain('<code>ciderpress dev</code>')
54
+ })
55
+
56
+ it('should not parse markers inside a code span', () => {
57
+ expect(html('`**not accented**`')).toContain('<code>**not accented**</code>')
58
+ })
59
+
60
+ it('should render an external markdown link as an anchor', () => {
61
+ const out = html('See [the site](https://example.com)')
62
+ expect(out).toContain('href="https://example.com"')
63
+ expect(out).toContain('the site')
64
+ })
65
+
66
+ it('should render an internal markdown link', () => {
67
+ expect(html('See [the guide](/guides)')).toContain('href="/guides"')
68
+ })
69
+
70
+ it('should render a whitelisted anchor tag', () => {
71
+ expect(html('<a href="/guides">docs</a>')).toContain('href="/guides"')
72
+ })
73
+
74
+ it('should drop an anchor tag with an unsafe scheme but keep its text', () => {
75
+ const out = html('<a href="javascript:alert(1)">click</a>')
76
+ expect(out).not.toContain('javascript')
77
+ expect(out).toContain('click')
78
+ })
79
+
80
+ it('should drop a markdown link with an unsafe scheme but keep its text', () => {
81
+ const out = html('[click](javascript:alert(1))')
82
+ expect(out).not.toContain('javascript')
83
+ expect(out).not.toContain('<a')
84
+ expect(out).toContain('click')
85
+ })
86
+
87
+ it('should keep the class attribute on a whitelisted tag', () => {
88
+ expect(html('<span class="x">hi</span>')).toContain('<span class="x">hi</span>')
89
+ })
90
+
91
+ it('should drop event handler attributes', () => {
92
+ const out = html('<span onclick="alert(1)">hi</span>')
93
+ expect(out).not.toContain('onclick')
94
+ expect(out).toContain('hi')
95
+ })
96
+
97
+ it('should drop script tags and their contents', () => {
98
+ const out = html('safe<script>alert(1)</script>after')
99
+ expect(out).not.toContain('alert')
100
+ expect(out).toContain('safe')
101
+ expect(out).toContain('after')
102
+ })
103
+
104
+ it('should drop an unterminated script tag and everything after it', () => {
105
+ const out = html('safe<script>alert(1)')
106
+ expect(out).not.toContain('alert')
107
+ expect(out).toContain('safe')
108
+ })
109
+
110
+ it('should unwrap unknown tags but keep their text', () => {
111
+ const out = html('<div>kept</div>')
112
+ expect(out).not.toContain('<div>')
113
+ expect(out).toContain('kept')
114
+ })
115
+
116
+ it('should drop an img tag but keep surrounding copy', () => {
117
+ const out = html('<img src="x" onerror="alert(1)">shown')
118
+ expect(out).not.toContain('onerror')
119
+ expect(out).not.toContain('<img')
120
+ expect(out).toContain('shown')
121
+ })
122
+
123
+ it('should render br as a line break', () => {
124
+ expect(html('a<br>b')).toContain('<br/>')
125
+ })
126
+
127
+ it('should render a self-closed br', () => {
128
+ expect(html('a<br />b')).toContain('<br/>')
129
+ })
130
+
131
+ it('should render a self-closed whitelisted tag as empty', () => {
132
+ expect(html('a<span class="x" />b')).toContain('<span class="x"></span>')
133
+ })
134
+
135
+ it('should keep attributes on a tag with extra whitespace', () => {
136
+ expect(html('<span class="x" >hi</span>')).toContain('<span class="x">hi</span>')
137
+ })
138
+
139
+ it('should nest markup inside an accent', () => {
140
+ expect(html('**Zero `dev` Effort**')).toContain('<code>dev</code>')
141
+ })
142
+
143
+ it('should leave a stray closing tag out of the output', () => {
144
+ const out = html('text</span>')
145
+ expect(out).not.toContain('</span>')
146
+ expect(out).toContain('text')
147
+ })
148
+
149
+ it('should return null for empty copy', () => {
150
+ expect(renderRichText('')).toBeNull()
151
+ })
152
+ })
153
+
154
+ describe('toPlainText()', () => {
155
+ it('should strip accent markers', () => {
156
+ expect(toPlainText('Docs, **Zero Effort**')).toBe('Docs, Zero Effort')
157
+ })
158
+
159
+ it('should strip highlight markers', () => {
160
+ expect(toPlainText('Now with ==OpenAPI== support')).toBe('Now with OpenAPI support')
161
+ })
162
+
163
+ it('should strip emphasis and italic markers', () => {
164
+ expect(toPlainText('Ship **fast** and *typed*')).toBe('Ship fast and typed')
165
+ })
166
+
167
+ it('should keep code span contents', () => {
168
+ expect(toPlainText('Run `ciderpress dev`')).toBe('Run ciderpress dev')
169
+ })
170
+
171
+ it('should reduce a link to its label', () => {
172
+ expect(toPlainText('See [the guide](/guides)')).toBe('See the guide')
173
+ })
174
+
175
+ it('should strip html tags but keep their text', () => {
176
+ expect(toPlainText('Ship <strong>fast</strong>')).toBe('Ship fast')
177
+ })
178
+
179
+ it('should drop script contents', () => {
180
+ expect(toPlainText('safe<script>alert(1)</script>')).toBe('safe')
181
+ })
182
+
183
+ it('should return an empty string for empty copy', () => {
184
+ expect(toPlainText('')).toBe('')
185
+ })
186
+ })
187
+
188
+ describe('hasAccentMarker()', () => {
189
+ it('should detect an accent marker', () => {
190
+ expect(hasAccentMarker('Docs, **Zero Effort**')).toBe(true)
191
+ })
192
+
193
+ it('should return false without a marker', () => {
194
+ expect(hasAccentMarker('Docs, Zero Effort')).toBe(false)
195
+ })
196
+
197
+ it('should return false for a lone marker', () => {
198
+ expect(hasAccentMarker('a ** b')).toBe(false)
199
+ })
200
+
201
+ it('should not treat html strong as an explicit accent', () => {
202
+ expect(hasAccentMarker('Docs, <strong>Zero Effort</strong>')).toBe(false)
203
+ })
204
+
205
+ it('should not treat a highlight marker as an accent', () => {
206
+ expect(hasAccentMarker('Docs, ==Zero Effort==')).toBe(false)
207
+ })
208
+
209
+ it('should not treat a marker inside a code span as an accent', () => {
210
+ expect(hasAccentMarker('Write `**bold**` for the accent')).toBe(false)
211
+ })
212
+
213
+ it('should detect an accent nested inside a link', () => {
214
+ expect(hasAccentMarker('[**Docs**](/docs)')).toBe(true)
215
+ })
216
+ })
217
+
218
+ describe('isPlainText()', () => {
219
+ it('should return true for unmarked copy', () => {
220
+ expect(isPlainText('Beautiful Docs, Zero Effort')).toBe(true)
221
+ })
222
+
223
+ it('should return false for copy carrying a link', () => {
224
+ expect(isPlainText('See [the guide](/guides)')).toBe(false)
225
+ })
226
+
227
+ it('should return false for copy carrying a code span', () => {
228
+ expect(isPlainText('Run `ciderpress dev`')).toBe(false)
229
+ })
230
+
231
+ it('should return false for copy carrying html', () => {
232
+ expect(isPlainText('Ship <strong>fast</strong>')).toBe(false)
233
+ })
234
+ })
235
+
236
+ describe('tag nesting', () => {
237
+ // `indexOf('</' + tag)` matched `</span>` when closing `</s`, so the inner
238
+ // element vanished and the outer one ended early, letting trailing copy
239
+ // escape its styling. Every whitelisted tag that prefixes another hit it.
240
+ it('should not close a tag on a longer tag that shares its prefix', () => {
241
+ expect(html('<s>a <span>b</span> c</s>')).toBe('<s>a <span>b</span> c</s>')
242
+ })
243
+
244
+ it('should nest strong inside s', () => {
245
+ expect(html('<s>strike <strong>x</strong> tail</s>')).toBe(
246
+ '<s>strike <strong>x</strong> tail</s>'
247
+ )
248
+ })
249
+
250
+ it('should nest ins inside i', () => {
251
+ expect(html('<i>a <ins>b</ins> c</i>')).toBe('<i>a <ins>b</ins> c</i>')
252
+ })
253
+
254
+ it('should keep an attribute value containing a closing bracket', () => {
255
+ expect(html('<span title="a > b">hi</span>')).toBe('<span title="a &gt; b">hi</span>')
256
+ })
257
+
258
+ it('should still strip a script nested inside another tag', () => {
259
+ expect(html('<s>a<script>alert(1)</script>b</s>')).toBe('<s>ab</s>')
260
+ })
261
+ })
262
+
263
+ describe('escaping', () => {
264
+ // A docs framework has to be able to name a glob in its own copy.
265
+ it('should leave glob syntax alone', () => {
266
+ expect(html('Glob patterns like *.md and **/*.ts')).toBe('Glob patterns like *.md and **/*.ts')
267
+ })
268
+
269
+ it('should not italicise a spaced asterisk', () => {
270
+ expect(html('2 * 3 * 4')).toBe('2 * 3 * 4')
271
+ })
272
+
273
+ it('should render an escaped marker literally', () => {
274
+ expect(html('literal \\*not italic\\*')).toBe('literal *not italic*')
275
+ })
276
+
277
+ it('should strip markers out of plain text without eating the glob', () => {
278
+ expect(toPlainText('Glob **/*.ts')).toBe('Glob **/*.ts')
279
+ })
280
+
281
+ it('should still italicise a properly flanked marker', () => {
282
+ expect(html('*café*')).toBe('<em>café</em>')
283
+ })
284
+
285
+ it('should not let a stray asterisk swallow a later accent', () => {
286
+ expect(html('**Fast** builds * **typed** config')).toBe(
287
+ '<strong class="cp-accent">Fast</strong> builds * <strong class="cp-accent">typed</strong> config'
288
+ )
289
+ })
290
+ })
291
+
292
+ describe('links', () => {
293
+ it('should keep balanced parens in a destination', () => {
294
+ expect(html('[wiki](https://en.wikipedia.org/wiki/Foo_(bar))')).toContain(
295
+ 'href="https://en.wikipedia.org/wiki/Foo_(bar)"'
296
+ )
297
+ })
298
+
299
+ it('should not leave a stray bracket after a parenthesised destination', () => {
300
+ expect(html('[wiki](https://en.wikipedia.org/wiki/Foo_(bar))')).not.toContain('</a>)')
301
+ })
302
+
303
+ // An anchor with no accessible name announces as a bare "link".
304
+ it('should drop a link with an empty label', () => {
305
+ expect(html('[](/x)')).toBe('')
306
+ })
307
+
308
+ // Nested anchors are invalid DOM — React warns and browsers split the tree.
309
+ it('should not nest an anchor inside an anchor', () => {
310
+ expect(html('<a href="/a">see [b](/c)</a>')).toBe('<a href="/a">see b</a>')
311
+ })
312
+
313
+ it('should still reject a javascript destination', () => {
314
+ expect(html('<a href="javascript:alert(1)">x</a>')).toBe('x')
315
+ })
316
+ })
317
+
318
+ describe('long input', () => {
319
+ // Self-recursion added a frame per token, so long copy overflowed the
320
+ // stack with an unattributed RangeError that failed the whole build.
321
+ it('should parse many tokens without exhausting the stack', () => {
322
+ expect(() => renderRichText('*a* '.repeat(9000))).not.toThrow()
323
+ })
324
+
325
+ // Closing-tag lookup had the same shape: it recursed once per `</tag`
326
+ // occurrence in the remainder rather than per nesting level, so a run of
327
+ // prefix hits overflowed the stack on markup only one level deep.
328
+ it('should scan past many close-tag prefixes without exhausting the stack', () => {
329
+ expect(() => renderRichText(`<s>${'</should'.repeat(30000)}`)).not.toThrow()
330
+ })
331
+ })
332
+
333
+ /**
334
+ * Render copy to static markup for assertions.
335
+ *
336
+ * @private
337
+ * @param text - Raw copy to render
338
+ * @returns Rendered HTML string
339
+ */
340
+ function html(text: string): string {
341
+ return renderToStaticMarkup(<>{renderRichText(text)}</>)
342
+ }
@@ -0,0 +1,86 @@
1
+ import { match } from 'massaman/match'
2
+ import type React from 'react'
3
+ import { createElement } from 'react'
4
+
5
+ import type { InlineNode } from './rich-text-parse.ts'
6
+ import { parseRichText } from './rich-text-parse.ts'
7
+ import { RouteLink } from './route-link.tsx'
8
+
9
+ export { hasAccentMarker, isPlainText, toPlainText } from './rich-text-parse.ts'
10
+
11
+ /**
12
+ * Render config copy as React nodes, applying an inline-only markdown
13
+ * subset plus a whitelist of inline HTML.
14
+ *
15
+ * Supported: `**accent**`, `*italic*`, `` `code` ``, `[text](/href)`,
16
+ * `<br>`, and a whitelist of inline tags. Parsing and
17
+ * sanitizing happen in `rich-text-parse`; this renders the resulting
18
+ * nodes as React elements, so `dangerouslySetInnerHTML` is never
19
+ * involved and unknown markup cannot execute.
20
+ *
21
+ * @param text - Raw string from config or frontmatter
22
+ * @returns React nodes ready to render inline
23
+ *
24
+ * @example
25
+ * renderRichText('Beautiful Docs, **Zero Effort**')
26
+ * renderRichText('Runs on `ciderpress dev` — see [the guide](/guides).')
27
+ */
28
+ export function renderRichText(text: string): React.ReactNode {
29
+ const nodes = parseRichText(text)
30
+ if (nodes.length === 0) {
31
+ return null
32
+ }
33
+ return renderNodes(nodes)
34
+ }
35
+
36
+ interface RichTextProps {
37
+ /**
38
+ * Raw copy to render.
39
+ */
40
+ readonly text: string
41
+ }
42
+
43
+ /**
44
+ * Component form of {@link renderRichText}, for call sites that read
45
+ * better as an element than a function call.
46
+ *
47
+ * @param props - Raw copy to render
48
+ * @returns Rendered inline nodes
49
+ */
50
+ export function RichText(props: RichTextProps): React.ReactElement {
51
+ return <>{renderRichText(props.text)}</>
52
+ }
53
+
54
+ /**
55
+ * Render parsed nodes to React, keyed by position.
56
+ *
57
+ * @private
58
+ * @param nodes - Parsed inline nodes
59
+ * @returns React nodes
60
+ */
61
+ function renderNodes(nodes: readonly InlineNode[]): React.ReactNode {
62
+ return nodes.map((node, index) => <RichNode key={index} node={node} />)
63
+ }
64
+
65
+ interface RichNodeProps {
66
+ readonly node: InlineNode
67
+ }
68
+
69
+ /**
70
+ * Render one parsed node.
71
+ *
72
+ * @private
73
+ * @param props - The node to render
74
+ * @returns React element
75
+ */
76
+ function RichNode(props: RichNodeProps): React.ReactElement {
77
+ return match(props.node)
78
+ .with({ kind: 'text' }, (n) => <>{n.value}</>)
79
+ .with({ kind: 'code' }, (n) => <code>{n.value}</code>)
80
+ .with({ kind: 'break' }, () => <br />)
81
+ .with({ kind: 'link' }, (n) => <RouteLink href={n.href}>{renderNodes(n.children)}</RouteLink>)
82
+ .with({ kind: 'element' }, (n) =>
83
+ createElement(n.tag, { className: n.className, title: n.title }, renderNodes(n.children))
84
+ )
85
+ .exhaustive()
86
+ }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * Self-hosted Geist fonts (vercel/geist-font).
3
3
  *
4
- * `Geist` is the base sans-serif (used everywhere by default).
5
- * `Geist Pixel Square` is a decorative pixel font kept available via
6
- * `--cp-font-family-pixel` for the brand mark and any opt-in surfaces.
7
- * `Geist Mono` is used for code, eyebrow labels, status pills, etc.
4
+ * These `@font-face` rules only make the faces *available*. Which face a
5
+ * surface actually uses is decided by the active theme's `fonts.family`
6
+ * tokens — see `styles/overrides/tokens.css`. The built-in themes map
7
+ * `mono` to `Geist Mono` and `display` to `Geist Pixel Square`, and leave
8
+ * `sans` proportional (the Inter stack) so body, nav, sidebar and hero copy
9
+ * stay readable; `Geist` is shipped for custom themes that want a
10
+ * proportional face from this family instead.
8
11
  */
9
12
 
10
13
  @font-face {
@@ -158,10 +158,18 @@
158
158
  font-size: 11px;
159
159
  text-transform: uppercase;
160
160
  letter-spacing: 0.1em;
161
- color: var(--cp-c-brand-1);
161
+ /* Eyebrows are 11px, so the 4.5:1 bar applies. `--cp-c-brand-1` is a fill
162
+ colour and misses it in four theme/variant pairs (2.38:1 on mulled dark,
163
+ 4.10:1 on honeycrisp dark, ~3.1:1 on amber and grannysmith light), so
164
+ take the same shade pair `.cp-accent` uses — see styles/rich-text.css. */
165
+ color: var(--cp-c-brand-2, var(--cp-c-brand-1));
162
166
  margin-bottom: 12px;
163
167
  }
164
168
 
169
+ html[data-cp-variant='dark'] .cp-feature-section-head__eyebrow {
170
+ color: var(--cp-c-brand-light, var(--cp-c-brand-1));
171
+ }
172
+
165
173
  .cp-feature-section-head__title {
166
174
  font-size: clamp(28px, 4vw, 44px);
167
175
  font-weight: 600;
@@ -183,7 +191,8 @@
183
191
  through as hairlines between cells. Each cell paints its own panel bg. */
184
192
  .cp-feature-grid {
185
193
  display: grid;
186
- grid-template-columns: repeat(3, 1fr);
194
+ /* `--cp-feature-cols` is set inline from the block's `columns` key. */
195
+ grid-template-columns: repeat(var(--cp-feature-cols, 3), 1fr);
187
196
  gap: 1px;
188
197
  background: var(--cp-c-border);
189
198
  border: 1px solid var(--cp-c-border);
@@ -16,11 +16,16 @@
16
16
  */
17
17
 
18
18
  @layer ciderpress.overrides {
19
- html:root {
20
- /* Fonts — map Geist into Rspress's font stack */
21
- --rp-font-family-base: 'Geist', ui-sans-serif, system-ui, sans-serif;
22
- --rp-font-family-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', monospace;
23
- }
19
+ /* ── Fonts — deliberately NOT set here.
20
+
21
+ Rspress declares `--rp-font-family-base` / `--rp-font-family-mono` on
22
+ `:root` *unlayered*, and unlayered declarations beat every cascade
23
+ layer regardless of selector specificity — so a `html:root` rule in
24
+ this layer silently lost and Rspress's Inter/Menlo defaults stayed in
25
+ force. Both vars are now emitted from the active theme's own unlayered
26
+ block (`COMPAT_VAR_MAP` in `@ciderpress/theme`), which wins on
27
+ specificity, with a fallback declaration in
28
+ `styles/overrides/tokens.css`. ─────────────────────────────────── */
24
29
 
25
30
  /* ── LLMs UI — Ciderpress mounts its own `<LlmsContainer />` inside
26
31
  `<CiderpressDocsBar />`. The H1 placement is suppressed natively
@@ -35,30 +35,43 @@
35
35
  :root {
36
36
  /* ── Font families ──────────────────────────────────────── */
37
37
  /**
38
- * Default body / UI font — Geist Mono. Whole site reads as a
39
- * terminal-style mono surface.
38
+ * The active theme owns every font on the page. `--cp-ff-sans`,
39
+ * `--cp-ff-mono`, and `--cp-ff-display` are the canonical tokens
40
+ * (`fonts.family.{sans,mono,display}` in `@ciderpress/theme`); each theme
41
+ * emits all three from its own unlayered block, so the values below only
42
+ * apply when no theme CSS is present at all.
43
+ *
44
+ * Semantics:
45
+ * sans base UI + prose font — body, nav, sidebar, hero, cards, docs
46
+ * mono code, terminal chrome, eyebrow labels
47
+ * display decorative headline / brand-mark face (falls back to sans)
48
+ *
49
+ * These values mirror `SHARED_FONTS.family` in `@ciderpress/theme`, so a
50
+ * themeless page degrades to the house default rather than to a different
51
+ * design: `sans` is proportional (Inter), `mono` is Geist Mono, `display`
52
+ * is Geist Pixel Square. Putting the mono stack in `sans` turns every
53
+ * proportional surface — hero, body, nav, sidebar — monospace; the
54
+ * regression guard in `packages/theme/src/fonts.test.ts` exists for that.
40
55
  */
41
- --cp-font-family-base: 'Geist Mono', ui-monospace, 'SFMono-Regular', monospace;
42
- /**
43
- * Optional sans fallback — exposed for cases that explicitly want
44
- * proportional Geist (e.g. very long prose passages).
45
- */
46
- --cp-font-family-sans: 'Geist', ui-sans-serif, system-ui, sans-serif;
47
- /**
48
- * Decorative pixel-art font — opt-in for brand marks, headlines, etc.
49
- * Apply with `font-family: var(--cp-font-family-pixel)` on the target.
50
- */
51
- --cp-font-family-pixel: 'Geist Pixel Square', ui-sans-serif, system-ui, sans-serif;
56
+ --cp-ff-sans:
57
+ 'Inter var experimental', 'Inter var', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
58
+ Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
59
+ --cp-ff-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', monospace;
60
+ --cp-ff-display: 'Geist Pixel Square', ui-sans-serif, system-ui, sans-serif;
61
+
52
62
  /**
53
- * Monospace alias — same as base. Kept for callsites that explicitly
54
- * tag "this should be monospace" (code, shortcuts, eyebrow labels) so
55
- * the intent stays readable in the stylesheet.
63
+ * Compatibility aliases. Every one of these resolves through a canonical
64
+ * token above, so a rule written against the older name still follows the
65
+ * active theme. Themes re-declare them in their own block (see
66
+ * `COMPAT_VAR_MAP` in `@ciderpress/theme`) — the declarations here are the
67
+ * no-theme-CSS fallback.
56
68
  */
57
- --cp-font-family-mono: 'Geist Mono', ui-monospace, 'SFMono-Regular', monospace;
58
-
59
- /* Canonical aliases matching TOKEN_TO_CSS_VAR (fonts.family.sans/mono). */
60
- --cp-ff-sans: var(--cp-font-family-sans);
61
- --cp-ff-mono: var(--cp-font-family-mono);
69
+ --cp-font-family-base: var(--cp-ff-sans);
70
+ --cp-font-family-sans: var(--cp-ff-sans);
71
+ --cp-font-family-mono: var(--cp-ff-mono);
72
+ --cp-font-family-pixel: var(--cp-ff-display);
73
+ --rp-font-family-base: var(--cp-ff-sans);
74
+ --rp-font-family-mono: var(--cp-ff-mono);
62
75
 
63
76
  /* ── Font weights ───────────────────────────────────────── */
64
77
  --cp-fw-regular: 400;
@@ -272,13 +285,18 @@
272
285
  --cp-announce-h: 36px;
273
286
  }
274
287
 
275
- /* ── Typography defaults — match the mockup (14px / 1.5 / Geist) ── */
288
+ /* ── Typography defaults — match the mockup (14px / 1.5) ──────────
289
+ `body` is listed for completeness only: Rspress's own unlayered
290
+ `body { font-family: var(--rp-font-family-base) }` outranks anything
291
+ in a layer, so the document font is really carried by the theme's
292
+ `--rp-font-family-base` declaration. Both sides resolve to
293
+ `fonts.family.sans`, so the two agree. ──────────────────────────── */
276
294
  html body,
277
295
  html .rp-doc,
278
296
  html .rspress-doc,
279
297
  html .rp-home-hero,
280
298
  html .rp-nav {
281
- font-family: var(--cp-font-family-base);
299
+ font-family: var(--cp-ff-sans);
282
300
  }
283
301
 
284
302
  html body {