@ciderpress/ui 1.0.0-rc.1 → 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 (162) 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/index.d.ts +1 -1
  10. package/dist/index.mjs +4 -3
  11. package/dist/node.d.ts +1 -1
  12. package/dist/node.mjs +732 -55
  13. package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
  14. package/dist/theme/components/footer/site-footer.tsx +68 -9
  15. package/dist/theme/components/home/cta.css +15 -0
  16. package/dist/theme/components/home/cta.tsx +10 -2
  17. package/dist/theme/components/home/feature-card.css +1 -1
  18. package/dist/theme/components/home/feature-card.tsx +8 -10
  19. package/dist/theme/components/home/feature.tsx +54 -31
  20. package/dist/theme/components/home/hero-demo.css +38 -0
  21. package/dist/theme/components/home/home-visual.tsx +226 -0
  22. package/dist/theme/components/home/layout.tsx +410 -108
  23. package/dist/theme/components/home/split.css +46 -19
  24. package/dist/theme/components/home/split.tsx +28 -8
  25. package/dist/theme/components/home/tabs.css +299 -0
  26. package/dist/theme/components/home/tabs.tsx +351 -0
  27. package/dist/theme/components/home/trust-strip.css +41 -0
  28. package/dist/theme/components/home/trust-strip.tsx +168 -11
  29. package/dist/theme/components/home/workspaces.tsx +176 -38
  30. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  31. package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
  32. package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  33. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  34. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  35. package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  36. package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  37. package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
  38. package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  39. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  40. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  41. package/dist/theme/components/nav/header-icon.css +24 -0
  42. package/dist/theme/components/nav/header-icon.tsx +119 -0
  43. package/dist/theme/components/nav/header-logo.css +16 -0
  44. package/dist/theme/components/nav/header-logo.tsx +200 -0
  45. package/dist/theme/components/nav/layout.tsx +117 -16
  46. package/dist/theme/components/nav/nav-logo.tsx +16 -19
  47. package/dist/theme/components/openapi/index.ts +0 -2
  48. package/dist/theme/components/openapi/openapi.css +0 -14
  49. package/dist/theme/components/shared/card-icon.tsx +37 -0
  50. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  51. package/dist/theme/components/shared/icon.tsx +120 -35
  52. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  53. package/dist/theme/components/shared/section-card.tsx +37 -6
  54. package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
  55. package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
  56. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  57. package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
  58. package/dist/theme/components/theme-provider.tsx +27 -11
  59. package/dist/theme/components/workspaces/card.css +4 -0
  60. package/dist/theme/components/workspaces/card.tsx +10 -12
  61. package/dist/theme/components/workspaces/grid.tsx +18 -6
  62. package/dist/theme/hooks/use-ciderpress.ts +65 -5
  63. package/dist/theme/hooks/use-nav-items.ts +82 -11
  64. package/dist/theme/index.tsx +10 -3
  65. package/dist/theme/lib/read-social-links.ts +4 -3
  66. package/dist/theme/lib/rich-text-parse.ts +740 -0
  67. package/dist/theme/lib/rich-text.test.tsx +342 -0
  68. package/dist/theme/lib/rich-text.tsx +86 -0
  69. package/dist/theme/lib/theme-favicon.ts +1 -1
  70. package/dist/theme/lib/with-mount-base.ts +31 -0
  71. package/dist/theme/styles/overrides/fonts.css +7 -4
  72. package/dist/theme/styles/overrides/footnotes.css +54 -0
  73. package/dist/theme/styles/overrides/home.css +11 -2
  74. package/dist/theme/styles/overrides/lists.css +83 -0
  75. package/dist/theme/styles/overrides/rail.css +27 -10
  76. package/dist/theme/styles/overrides/rspress.css +10 -5
  77. package/dist/theme/styles/overrides/section-card.css +9 -0
  78. package/dist/theme/styles/overrides/sidebar.css +22 -0
  79. package/dist/theme/styles/overrides/tokens.css +41 -23
  80. package/dist/theme/styles/rich-text.css +106 -0
  81. package/dist/theme/styles/themes/amber.css +32 -2
  82. package/dist/theme/styles/themes/arcade.css +16 -1
  83. package/dist/theme/styles/themes/grannysmith.css +32 -2
  84. package/dist/theme/styles/themes/honeycrisp.css +37 -7
  85. package/dist/theme/styles/themes/midnight.css +16 -1
  86. package/dist/theme/styles/themes/mulled.css +54 -9
  87. package/package.json +18 -16
  88. package/src/theme/components/footer/site-footer.tsx +68 -9
  89. package/src/theme/components/home/cta.css +15 -0
  90. package/src/theme/components/home/cta.tsx +10 -2
  91. package/src/theme/components/home/feature-card.css +1 -1
  92. package/src/theme/components/home/feature-card.tsx +8 -10
  93. package/src/theme/components/home/feature.tsx +54 -31
  94. package/src/theme/components/home/hero-demo.css +38 -0
  95. package/src/theme/components/home/home-visual.tsx +226 -0
  96. package/src/theme/components/home/layout.tsx +410 -108
  97. package/src/theme/components/home/split.css +46 -19
  98. package/src/theme/components/home/split.tsx +28 -8
  99. package/src/theme/components/home/tabs.css +299 -0
  100. package/src/theme/components/home/tabs.tsx +351 -0
  101. package/src/theme/components/home/trust-strip.css +41 -0
  102. package/src/theme/components/home/trust-strip.tsx +168 -11
  103. package/src/theme/components/home/workspaces.tsx +176 -38
  104. package/src/theme/components/nav/branch-tag.tsx +7 -7
  105. package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
  106. package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  107. package/src/theme/components/nav/ciderpress-header.css +2 -1
  108. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  109. package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  110. package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  111. package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
  112. package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  113. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  114. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  115. package/src/theme/components/nav/header-icon.css +24 -0
  116. package/src/theme/components/nav/header-icon.tsx +119 -0
  117. package/src/theme/components/nav/header-logo.css +16 -0
  118. package/src/theme/components/nav/header-logo.tsx +200 -0
  119. package/src/theme/components/nav/layout.tsx +117 -16
  120. package/src/theme/components/nav/nav-logo.tsx +16 -19
  121. package/src/theme/components/openapi/index.ts +0 -2
  122. package/src/theme/components/openapi/openapi.css +0 -14
  123. package/src/theme/components/shared/card-icon.tsx +37 -0
  124. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  125. package/src/theme/components/shared/icon.tsx +120 -35
  126. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  127. package/src/theme/components/shared/section-card.tsx +37 -6
  128. package/src/theme/components/sidebar/sidebar-badge.css +50 -0
  129. package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
  130. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  131. package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
  132. package/src/theme/components/theme-provider.tsx +27 -11
  133. package/src/theme/components/workspaces/card.css +4 -0
  134. package/src/theme/components/workspaces/card.tsx +10 -12
  135. package/src/theme/components/workspaces/grid.tsx +18 -6
  136. package/src/theme/hooks/use-ciderpress.ts +65 -5
  137. package/src/theme/hooks/use-nav-items.ts +82 -11
  138. package/src/theme/index.tsx +10 -3
  139. package/src/theme/lib/read-social-links.ts +4 -3
  140. package/src/theme/lib/rich-text-parse.ts +740 -0
  141. package/src/theme/lib/rich-text.test.tsx +342 -0
  142. package/src/theme/lib/rich-text.tsx +86 -0
  143. package/src/theme/lib/theme-favicon.ts +1 -1
  144. package/src/theme/lib/with-mount-base.ts +31 -0
  145. package/src/theme/styles/overrides/fonts.css +7 -4
  146. package/src/theme/styles/overrides/footnotes.css +54 -0
  147. package/src/theme/styles/overrides/home.css +11 -2
  148. package/src/theme/styles/overrides/lists.css +83 -0
  149. package/src/theme/styles/overrides/rail.css +27 -10
  150. package/src/theme/styles/overrides/rspress.css +10 -5
  151. package/src/theme/styles/overrides/section-card.css +9 -0
  152. package/src/theme/styles/overrides/sidebar.css +22 -0
  153. package/src/theme/styles/overrides/tokens.css +41 -23
  154. package/src/theme/styles/rich-text.css +106 -0
  155. package/src/theme/styles/themes/amber.css +32 -2
  156. package/src/theme/styles/themes/arcade.css +16 -1
  157. package/src/theme/styles/themes/grannysmith.css +32 -2
  158. package/src/theme/styles/themes/honeycrisp.css +37 -7
  159. package/src/theme/styles/themes/midnight.css +16 -1
  160. package/src/theme/styles/themes/mulled.css +54 -9
  161. package/dist/theme/components/openapi/copy-markdown-button.tsx +0 -41
  162. package/src/theme/components/openapi/copy-markdown-button.tsx +0 -41
@@ -0,0 +1,351 @@
1
+ import type { HomeVisual } from '@ciderpress/config'
2
+ import { match, P } from 'massaman/match'
3
+ import type React from 'react'
4
+ import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
5
+
6
+ import { renderRichText, toPlainText } from '../../lib/rich-text.tsx'
7
+ import { RouteLink } from '../../lib/route-link.tsx'
8
+ import { safeUrl } from '../../lib/safe-url.ts'
9
+ import { CardIcon } from '../shared/card-icon'
10
+ import type { CardIconInput } from '../shared/resolve-card-icon'
11
+ import { resolveCardIcon } from '../shared/resolve-card-icon'
12
+ import { HomeVisualView } from './home-visual'
13
+
14
+ import './tabs.css'
15
+
16
+ /**
17
+ * CTA rendered at the bottom of a tab panel's copy. Mirrors
18
+ * `SplitAction` — the `ButtonConfig` from config is mapped into this
19
+ * shape by the home layout.
20
+ */
21
+ export interface TabsAction {
22
+ /**
23
+ * Visible label.
24
+ */
25
+ readonly text: string
26
+ /**
27
+ * Destination URL.
28
+ */
29
+ readonly link: string
30
+ /**
31
+ * Visual style — `brand` is the filled primary, `alt` is the outline.
32
+ */
33
+ readonly theme?: 'brand' | 'alt'
34
+ }
35
+
36
+ /**
37
+ * One tab in the strip plus the copy and visual its panel shows while
38
+ * selected. Frontmatter-shaped: icons arrive pre-serialized from the
39
+ * sync engine.
40
+ */
41
+ export interface HomeTabEntry {
42
+ /**
43
+ * Tab label rendered in the strip.
44
+ */
45
+ readonly label: string
46
+ /**
47
+ * Icon rendered before the label.
48
+ */
49
+ readonly icon?: CardIconInput
50
+ /**
51
+ * Panel headline. Falls back to {@link HomeTabEntry.label}.
52
+ */
53
+ readonly title?: string
54
+ /**
55
+ * Panel body copy.
56
+ */
57
+ readonly body?: string
58
+ /**
59
+ * Checkmark bullets rendered under the body.
60
+ */
61
+ readonly bullets?: readonly string[]
62
+ /**
63
+ * CTA rendered at the bottom of the panel copy.
64
+ */
65
+ readonly cta?: TabsAction
66
+ /**
67
+ * Visual rendered in the panel while the tab is selected.
68
+ */
69
+ readonly visual?: HomeVisual
70
+ }
71
+
72
+ /**
73
+ * Props for {@link HomeTabs} — the section heading trio, the tab
74
+ * entries, and how the strip is laid out against the panel.
75
+ */
76
+ export interface HomeTabsProps {
77
+ /**
78
+ * Eyebrow label rendered above the section title.
79
+ */
80
+ readonly eyebrow?: string
81
+ /**
82
+ * Section title rendered above the strip.
83
+ */
84
+ readonly title?: string
85
+ /**
86
+ * Supporting sentence rendered under the section title.
87
+ */
88
+ readonly body?: string
89
+ /**
90
+ * Tabs rendered in the strip, in order.
91
+ */
92
+ readonly items: readonly HomeTabEntry[]
93
+ /**
94
+ * Strip placement. `vertical` (default) stacks the tabs beside the
95
+ * panel; `horizontal` runs them in a row above it.
96
+ */
97
+ readonly orientation?: 'vertical' | 'horizontal'
98
+ /**
99
+ * Flip a vertical band's columns — panel left, strip right. Ignored
100
+ * when horizontal.
101
+ */
102
+ readonly reverse?: boolean
103
+ }
104
+
105
+ /**
106
+ * HomeTabs — a strip of selectable tabs driving one panel. Clicking a tab
107
+ * swaps the panel's copy and visual; the first tab is selected on load.
108
+ *
109
+ * Built on react-aria-components `Tabs`, so roving focus, arrow-key
110
+ * navigation, and the tab/tabpanel ARIA wiring come from the library
111
+ * rather than being hand-rolled.
112
+ *
113
+ * @param props - Heading trio, tab entries, orientation, and column order.
114
+ * @returns React element with the tab band, or null when there are no tabs.
115
+ */
116
+ export function HomeTabs(props: HomeTabsProps): React.ReactElement | null {
117
+ const { eyebrow, title, body, items, orientation, reverse } = props
118
+ // Frontmatter is unvalidated user content on hand-authored home pages —
119
+ // treat anything that is not a populated array as "no band".
120
+ const list = match(items)
121
+ .with(
122
+ P.when((i): i is readonly HomeTabEntry[] => Array.isArray(i) && i.length > 0),
123
+ (i) => i
124
+ )
125
+ .otherwise(() => null)
126
+ if (list === null) {
127
+ return null
128
+ }
129
+
130
+ const axis = match(orientation)
131
+ .with('horizontal', () => 'horizontal' as const)
132
+ .otherwise(() => 'vertical' as const)
133
+ const sectionClass = match(reverse ?? false)
134
+ .with(true, () => `cp-tabs cp-tabs--${axis} cp-tabs--reverse`)
135
+ .otherwise(() => `cp-tabs cp-tabs--${axis}`)
136
+ // aria-label takes a bare string — strip any markup the title carries.
137
+ const listLabel = match(title)
138
+ .with(P.string, (t) => toPlainText(t))
139
+ .otherwise(() => 'Highlights')
140
+
141
+ return (
142
+ <section className={sectionClass}>
143
+ {renderHead({ eyebrow, title, body })}
144
+ <Tabs className="cp-tabs__inner" orientation={axis}>
145
+ <TabList className="cp-tabs__list" aria-label={listLabel}>
146
+ {list.map((item, index) => (
147
+ <Tab key={`${item.label}-${index}`} id={String(index)} className="cp-tabs__tab">
148
+ {renderTabIcon(item.icon)}
149
+ {/* Plain text, not rich: the tab is already an interactive
150
+ `role="tab"`, and a markdown link in a label would nest an
151
+ anchor inside it — invalid DOM that breaks keyboard
152
+ traversal and screen-reader announcement. */}
153
+ <span className="cp-tabs__tab-label">{toPlainText(item.label)}</span>
154
+ </Tab>
155
+ ))}
156
+ </TabList>
157
+ {list.map((item, index) => (
158
+ <TabPanel
159
+ key={`${item.label}-${index}`}
160
+ id={String(index)}
161
+ className={panelClass(item.visual)}
162
+ >
163
+ {renderPanelCopy(item, axis)}
164
+ {renderPanelVisual(item.visual)}
165
+ {match(axis)
166
+ .with('vertical', () => renderPanelCta(item.cta))
167
+ .otherwise(() => null)}
168
+ </TabPanel>
169
+ ))}
170
+ </Tabs>
171
+ </section>
172
+ )
173
+ }
174
+
175
+ /**
176
+ * Heading trio rendered above the strip.
177
+ *
178
+ * @private
179
+ */
180
+ interface TabsHead {
181
+ readonly eyebrow?: string
182
+ readonly title?: string
183
+ readonly body?: string
184
+ }
185
+
186
+ /**
187
+ * Render the section heading, or nothing when every field is absent.
188
+ * Reuses the shared feature-section head so the typography matches the
189
+ * other home blocks.
190
+ *
191
+ * @private
192
+ * @param head - Heading trio from the block
193
+ * @returns Heading element, or null
194
+ */
195
+ function renderHead(head: TabsHead): React.ReactElement | null {
196
+ const { eyebrow, title, body } = head
197
+ const hasContent = [eyebrow, title, body].some((value) => typeof value === 'string')
198
+ if (!hasContent) {
199
+ return null
200
+ }
201
+ return (
202
+ <div className="cp-feature-section-head">
203
+ {match(eyebrow)
204
+ .with(P.string, (e) => (
205
+ <div className="cp-feature-section-head__eyebrow">{renderRichText(e)}</div>
206
+ ))
207
+ .otherwise(() => null)}
208
+ {match(title)
209
+ .with(P.string, (t) => (
210
+ <h2 className="cp-feature-section-head__title">{renderRichText(t)}</h2>
211
+ ))
212
+ .otherwise(() => null)}
213
+ {match(body)
214
+ .with(P.string, (s) => <p className="cp-feature-section-head__sub">{renderRichText(s)}</p>)
215
+ .otherwise(() => null)}
216
+ </div>
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Render a tab's leading icon. Keeps the shared `cp-card__icon` chip so
222
+ * the colour variants come from the card stylesheet; tabs.css only
223
+ * shrinks it to fit the strip.
224
+ *
225
+ * @private
226
+ * @param icon - Serialized icon from frontmatter
227
+ * @returns Icon element, or null when the tab has no icon
228
+ */
229
+ function renderTabIcon(icon: CardIconInput | undefined): React.ReactElement | null {
230
+ const resolved = resolveCardIcon(icon)
231
+ return match(resolved)
232
+ .with(P.nonNullable, (r) => <CardIcon resolved={r} />)
233
+ .otherwise(() => null)
234
+ }
235
+
236
+ /**
237
+ * Class for a panel, collapsing it to a single column when the selected
238
+ * tab carries no visual — otherwise a horizontal band would leave half
239
+ * the row empty.
240
+ *
241
+ * @private
242
+ * @param visual - The selected tab's optional visual
243
+ * @returns Panel class name
244
+ */
245
+ function panelClass(visual: HomeVisual | undefined): string {
246
+ return match(visual)
247
+ .with(P.nullish, () => 'cp-tabs__panel cp-tabs__panel--full')
248
+ .otherwise(() => 'cp-tabs__panel')
249
+ }
250
+
251
+ /**
252
+ * Which way the tab strip runs relative to its panel.
253
+ *
254
+ * @private
255
+ */
256
+ type TabsAxis = 'horizontal' | 'vertical'
257
+
258
+ /**
259
+ * Render a panel's copy column: headline, body, bullets, and, on the
260
+ * horizontal axis, the CTA. The headline falls back to the tab label so
261
+ * a minimal entry still reads as a titled panel.
262
+ *
263
+ * The CTA's home depends on the axis. Horizontal lays copy and visual out
264
+ * as side-by-side columns, so the CTA closes the copy column. Vertical
265
+ * stacks them, where that same placement would wedge the button between
266
+ * the body text and the visual, so the panel renders it last instead.
267
+ *
268
+ * @private
269
+ * @param item - The selected tab entry
270
+ * @param axis - Orientation the band is rendering on
271
+ * @returns Copy column element
272
+ */
273
+ function renderPanelCopy(item: HomeTabEntry, axis: TabsAxis): React.ReactElement {
274
+ const heading = match(item.title)
275
+ .with(P.string, (t) => t)
276
+ .otherwise(() => item.label)
277
+ const bullets = item.bullets ?? []
278
+
279
+ return (
280
+ <div className="cp-tabs__copy">
281
+ <h3 className="cp-tabs__title">{renderRichText(heading)}</h3>
282
+ {match(item.body)
283
+ .with(P.string, (b) => <p className="cp-tabs__body">{renderRichText(b)}</p>)
284
+ .otherwise(() => null)}
285
+ {match(bullets.length === 0)
286
+ .with(true, () => null)
287
+ .otherwise(() => (
288
+ <ul className="cp-tabs__bullets">
289
+ {bullets.map((bullet) => (
290
+ <li key={bullet}>
291
+ <span className="cp-tabs__check">✓</span>
292
+ <span>{renderRichText(bullet)}</span>
293
+ </li>
294
+ ))}
295
+ </ul>
296
+ ))}
297
+ {match(axis)
298
+ .with('horizontal', () => renderPanelCta(item.cta))
299
+ .otherwise(() => null)}
300
+ </div>
301
+ )
302
+ }
303
+
304
+ /**
305
+ * Render a panel CTA, dropping links that fail URL validation.
306
+ *
307
+ * @private
308
+ * @param cta - Optional CTA action
309
+ * @returns Link element, or null
310
+ */
311
+ function renderPanelCta(cta: TabsAction | undefined): React.ReactElement | null {
312
+ return match(cta)
313
+ .with(P.nonNullable, (a) => {
314
+ const href = safeUrl(a.link)
315
+ if (href === null) {
316
+ return null
317
+ }
318
+ const className = match(a.theme ?? 'brand')
319
+ .with('brand', () => 'cp-tabs__btn cp-tabs__btn--primary')
320
+ .otherwise(() => 'cp-tabs__btn')
321
+ // Wrapped rather than returned bare: the CTA is a direct child of
322
+ // the panel so it lands under the visual, and an unwrapped link
323
+ // would stretch as a flex item and break out of the grid columns.
324
+ return (
325
+ <div className="cp-tabs__actions">
326
+ <RouteLink className={className} href={href}>
327
+ {a.text}
328
+ </RouteLink>
329
+ </div>
330
+ )
331
+ })
332
+ .otherwise(() => null)
333
+ }
334
+
335
+ /**
336
+ * Render a panel's visual frame. Omitted entirely when the entry carries
337
+ * no visual, so a copy-only tab does not paint an empty frame.
338
+ *
339
+ * @private
340
+ * @param visual - Optional visual for the selected tab
341
+ * @returns Framed visual element, or null
342
+ */
343
+ function renderPanelVisual(visual: HomeVisual | undefined): React.ReactElement | null {
344
+ return match(visual)
345
+ .with(P.nonNullable, (v) => (
346
+ <div className="cp-tabs__visual">
347
+ <HomeVisualView visual={v} context="tabs" />
348
+ </div>
349
+ ))
350
+ .otherwise(() => null)
351
+ }
@@ -31,3 +31,44 @@
31
31
  .cp-trust__sep {
32
32
  color: var(--cp-c-text-3);
33
33
  }
34
+
35
+ .cp-trust__logo {
36
+ display: inline-block;
37
+ width: auto;
38
+ vertical-align: middle;
39
+ }
40
+
41
+ /*
42
+ * Mono variant: paint a solid block of the current text color, clipped
43
+ * to the asset's alpha channel. One file then tracks every theme and
44
+ * both variants, which is the escape hatch for marks whose baked
45
+ * palette only reads against one background.
46
+ */
47
+ .cp-trust__logo--mono {
48
+ background-color: currentcolor;
49
+ color: var(--cp-c-text-2);
50
+ mask-position: center;
51
+ mask-repeat: no-repeat;
52
+ mask-size: contain;
53
+ -webkit-mask-position: center;
54
+ -webkit-mask-repeat: no-repeat;
55
+ -webkit-mask-size: contain;
56
+ transition: color 0.15s ease;
57
+ }
58
+
59
+ /* Lays out at the asset's intrinsic ratio to give the mask a width. */
60
+ .cp-trust__logo--mono img {
61
+ display: block;
62
+ height: 100%;
63
+ width: auto;
64
+ visibility: hidden;
65
+ }
66
+
67
+ .cp-trust__link {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ }
71
+
72
+ .cp-trust__link:hover .cp-trust__logo--mono {
73
+ color: var(--cp-c-text-1);
74
+ }
@@ -1,25 +1,68 @@
1
- import { match } from 'massaman/match'
1
+ import { match, P } from 'massaman/match'
2
2
  import type React from 'react'
3
3
 
4
+ import { renderRichText } from '../../lib/rich-text.tsx'
5
+ import { safeUrl } from '../../lib/safe-url.ts'
6
+ import { withMountBase } from '../../lib/with-mount-base.ts'
7
+
4
8
  import './trust-strip.css'
5
9
 
10
+ /**
11
+ * Default rendered height, in pixels, for a proof logo.
12
+ */
13
+ const DEFAULT_LOGO_HEIGHT = 20
14
+
15
+ /**
16
+ * A logo entry in the trust strip. Drawn as a mask filled with the
17
+ * current text color, so one asset covers every theme and both variants.
18
+ */
19
+ export interface TrustLogo {
20
+ /**
21
+ * Public URL of the logo asset.
22
+ */
23
+ readonly src: string
24
+ /**
25
+ * Accessible name for the logo.
26
+ */
27
+ readonly alt: string
28
+ /**
29
+ * Optional link target. When set, the logo becomes a link.
30
+ */
31
+ readonly href?: string
32
+ /**
33
+ * Rendered height in pixels. Defaults to {@link DEFAULT_LOGO_HEIGHT}.
34
+ */
35
+ readonly height?: number
36
+ /**
37
+ * Draw the logo as a silhouette in the current text color instead of
38
+ * its own colors. Defaults to `false`.
39
+ */
40
+ readonly mono?: boolean
41
+ }
42
+
43
+ /**
44
+ * One trust strip entry — a plain name or a {@link TrustLogo}.
45
+ */
46
+ export type TrustItem = string | TrustLogo
47
+
6
48
  export interface TrustStripProps {
7
49
  /**
8
50
  * Lead text shown before the names. Defaults to "used by".
9
51
  */
10
52
  readonly lead?: string
11
53
  /**
12
- * Names to render, separated by mono dots.
54
+ * Entries to render, separated by mono dots.
13
55
  */
14
- readonly names: readonly string[]
56
+ readonly names: readonly TrustItem[]
15
57
  }
16
58
 
17
59
  /**
18
- * TrustStrip — a single-row "used by …" band with mono dots between names.
19
- * Renders only when at least one name is supplied.
60
+ * TrustStrip — a single-row "used by …" band with mono dots between
61
+ * entries. Entries are either text or logos. Renders only when at least
62
+ * one entry is supplied.
20
63
  *
21
64
  * @param props - Trust strip configuration.
22
- * @returns React element, or null when no names are provided.
65
+ * @returns React element, or null when no entries are provided.
23
66
  */
24
67
  export function TrustStrip(props: TrustStripProps): React.ReactElement | null {
25
68
  const { lead = 'used by', names } = props
@@ -28,12 +71,16 @@ export function TrustStrip(props: TrustStripProps): React.ReactElement | null {
28
71
  .otherwise(() => (
29
72
  <section className="cp-trust">
30
73
  <div className="cp-trust__row">
31
- <span className="cp-trust__lead">{lead}</span>
32
- {names.map((name, i) => (
33
- <span key={`${name}:${i}`} className="cp-trust__item">
34
- <span className="cp-trust__name">{name}</span>
74
+ <span className="cp-trust__lead">{renderRichText(lead)}</span>
75
+ {names.map((item, i) => (
76
+ <span key={`${itemKey(item)}:${i}`} className="cp-trust__item">
77
+ {renderItem(item)}
35
78
  {match(i < names.length - 1)
36
- .with(true, () => <span className="cp-trust__sep">·</span>)
79
+ .with(true, () => (
80
+ <span aria-hidden="true" className="cp-trust__sep">
81
+ ·
82
+ </span>
83
+ ))
37
84
  .otherwise(() => null)}
38
85
  </span>
39
86
  ))}
@@ -41,3 +88,113 @@ export function TrustStrip(props: TrustStripProps): React.ReactElement | null {
41
88
  </section>
42
89
  ))
43
90
  }
91
+
92
+ /**
93
+ * Stable key fragment for an entry.
94
+ *
95
+ * @private
96
+ * @param item - Entry to derive a key from
97
+ * @returns Key fragment
98
+ */
99
+ function itemKey(item: TrustItem): string {
100
+ return match(item)
101
+ .with(P.string, (name) => name)
102
+ .otherwise((logo) => logo.src)
103
+ }
104
+
105
+ /**
106
+ * Render a single entry as either text or a themed logo.
107
+ *
108
+ * @private
109
+ * @param item - Entry to render
110
+ * @returns React node for the entry
111
+ */
112
+ function renderItem(item: TrustItem): React.ReactNode {
113
+ return match(item)
114
+ .with(P.string, (name) => <span className="cp-trust__name">{renderRichText(name)}</span>)
115
+ .otherwise((logo) => renderLogo(logo))
116
+ }
117
+
118
+ /**
119
+ * Render a logo in its own colors, or as a silhouette when `mono` is set.
120
+ *
121
+ * A destination that fails {@link safeUrl} degrades to the bare mark
122
+ * rather than a dead anchor, matching how `<Card />` handles the same
123
+ * case — the logo stays visible without becoming a script-execution sink.
124
+ *
125
+ * @private
126
+ * @param logo - Logo entry to render
127
+ * @returns React node for the logo
128
+ */
129
+ function renderLogo(logo: TrustLogo): React.ReactNode {
130
+ const mark = match(logo.mono === true)
131
+ .with(true, () => renderMonoMark(logo))
132
+ .otherwise(() => renderColorMark(logo))
133
+
134
+ return match(logo.href)
135
+ .with(P.string, (raw) =>
136
+ match(safeUrl(raw))
137
+ .with(P.string, (href) => (
138
+ <a className="cp-trust__link" href={href} rel="noreferrer" target="_blank">
139
+ {mark}
140
+ </a>
141
+ ))
142
+ .otherwise(() => mark)
143
+ )
144
+ .otherwise(() => mark)
145
+ }
146
+
147
+ /**
148
+ * Render a logo as an image, preserving the asset's own colors.
149
+ *
150
+ * @private
151
+ * @param logo - Logo entry to render
152
+ * @returns React node for the logo
153
+ */
154
+ function renderColorMark(logo: TrustLogo): React.ReactNode {
155
+ const { src, alt, height = DEFAULT_LOGO_HEIGHT } = logo
156
+ return (
157
+ <img
158
+ alt={alt}
159
+ className="cp-trust__logo"
160
+ src={withMountBase(src)}
161
+ style={{ height: `${height}px` }}
162
+ />
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Render a logo as a mask filled with the current text color.
168
+ *
169
+ * The mask reads only the asset's alpha channel, so one file works on
170
+ * every theme and in both light and dark without a second variant.
171
+ *
172
+ * @private
173
+ * @param logo - Logo entry to render
174
+ * @returns React node for the logo
175
+ */
176
+ function renderMonoMark(logo: TrustLogo): React.ReactNode {
177
+ const { src, alt, height = DEFAULT_LOGO_HEIGHT } = logo
178
+ // `encodeURI` so a `"` or `)` in the filename cannot terminate the
179
+ // `url()` token — the CSSOM would reject the whole declaration, and a
180
+ // mask that fails to load leaves the fill unclipped, painting a solid
181
+ // text-coloured rectangle where the logo should be.
182
+ const mask = `url("${encodeURI(withMountBase(src))}")`
183
+ // The inner image is hidden but still laid out, so the masked wrapper
184
+ // inherits the asset's intrinsic aspect ratio. A mask alone has no
185
+ // intrinsic size, so without it every logo would collapse to zero width.
186
+ return (
187
+ <span
188
+ aria-label={alt}
189
+ className="cp-trust__logo cp-trust__logo--mono"
190
+ role="img"
191
+ style={{
192
+ height: `${height}px`,
193
+ maskImage: mask,
194
+ WebkitMaskImage: mask,
195
+ }}
196
+ >
197
+ <img alt="" aria-hidden="true" src={withMountBase(src)} />
198
+ </span>
199
+ )
200
+ }