@ciderpress/ui 1.0.0-rc.12 → 1.0.0-rc.14

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 (52) hide show
  1. package/dist/head/css/themes/honeycrisp.css +1 -1
  2. package/dist/node.mjs +17 -13
  3. package/dist/plugins/mermaid/MermaidRenderer.tsx +2 -2
  4. package/dist/theme/components/home/hero-banner.tsx +225 -0
  5. package/dist/theme/components/home/hero-demo.css +492 -61
  6. package/dist/theme/components/home/hero-demo.test.tsx +159 -0
  7. package/dist/theme/components/home/hero-demo.tsx +335 -32
  8. package/dist/theme/components/home/hero.css +142 -9
  9. package/dist/theme/components/home/hero.test.tsx +105 -0
  10. package/dist/theme/components/home/hero.tsx +201 -4
  11. package/dist/theme/components/home/home-visual.css +98 -0
  12. package/dist/theme/components/home/home-visual.test.tsx +27 -0
  13. package/dist/theme/components/home/home-visual.tsx +20 -13
  14. package/dist/theme/components/home/layout.tsx +125 -1
  15. package/dist/theme/components/home/split.css +2 -2
  16. package/dist/theme/components/home/tabs.css +1 -1
  17. package/dist/theme/components/home/trust-strip.css +12 -0
  18. package/dist/theme/components/home/trust-strip.test.tsx +33 -0
  19. package/dist/theme/components/home/trust-strip.tsx +76 -15
  20. package/dist/theme/components/nav/layout.tsx +10 -14
  21. package/dist/theme/components/nav/nav-logo.tsx +2 -3
  22. package/dist/theme/components/sidebar/sidebar-filter.test.ts +49 -9
  23. package/dist/theme/components/sidebar/sidebar-filter.ts +24 -4
  24. package/dist/theme/index.tsx +8 -1
  25. package/dist/theme/lib/rich-text-parse.ts +0 -2
  26. package/dist/theme/lib/theme-favicon.ts +0 -3
  27. package/dist/theme/styles/themes/honeycrisp.css +20 -20
  28. package/package.json +20 -19
  29. package/src/theme/components/home/hero-banner.tsx +225 -0
  30. package/src/theme/components/home/hero-demo.css +492 -61
  31. package/src/theme/components/home/hero-demo.test.tsx +159 -0
  32. package/src/theme/components/home/hero-demo.tsx +335 -32
  33. package/src/theme/components/home/hero.css +142 -9
  34. package/src/theme/components/home/hero.test.tsx +105 -0
  35. package/src/theme/components/home/hero.tsx +201 -4
  36. package/src/theme/components/home/home-visual.css +98 -0
  37. package/src/theme/components/home/home-visual.test.tsx +27 -0
  38. package/src/theme/components/home/home-visual.tsx +20 -13
  39. package/src/theme/components/home/layout.tsx +125 -1
  40. package/src/theme/components/home/split.css +2 -2
  41. package/src/theme/components/home/tabs.css +1 -1
  42. package/src/theme/components/home/trust-strip.css +12 -0
  43. package/src/theme/components/home/trust-strip.test.tsx +33 -0
  44. package/src/theme/components/home/trust-strip.tsx +76 -15
  45. package/src/theme/components/nav/layout.tsx +10 -14
  46. package/src/theme/components/nav/nav-logo.tsx +2 -3
  47. package/src/theme/components/sidebar/sidebar-filter.test.ts +49 -9
  48. package/src/theme/components/sidebar/sidebar-filter.ts +24 -4
  49. package/src/theme/index.tsx +8 -1
  50. package/src/theme/lib/rich-text-parse.ts +0 -2
  51. package/src/theme/lib/theme-favicon.ts +0 -3
  52. package/src/theme/styles/themes/honeycrisp.css +20 -20
@@ -1,10 +1,98 @@
1
1
  .cp-hero {
2
2
  position: relative;
3
+ overflow: hidden;
3
4
  padding: 96px 0 72px;
4
5
  text-align: center;
5
6
  }
6
7
 
8
+ .cp-hero__background {
9
+ position: absolute;
10
+ z-index: 0;
11
+ inset: 0;
12
+ pointer-events: none;
13
+ }
14
+
15
+ .cp-hero__background > * {
16
+ position: absolute;
17
+ inset: 0;
18
+ display: block;
19
+ width: 100%;
20
+ height: 100%;
21
+ object-fit: cover;
22
+ }
23
+
24
+ .cp-hero--has-background {
25
+ background-color: var(--cp-c-home-bg);
26
+ background-image: var(--cp-hero-background-image);
27
+ background-position: var(--cp-hero-background-position, center);
28
+ background-size: var(--cp-hero-background-size, cover);
29
+ background-repeat: var(--cp-hero-background-repeat, no-repeat);
30
+ }
31
+
32
+ .cp-hero--has-background .cp-hero__inner {
33
+ position: relative;
34
+ z-index: 2;
35
+ }
36
+
37
+ .cp-hero--has-background::before {
38
+ content: '';
39
+ position: absolute;
40
+ inset: 0;
41
+ z-index: 1;
42
+ backdrop-filter: brightness(0.5);
43
+ }
44
+
45
+ :where([data-cp-variant='light']) .cp-hero--has-background::before {
46
+ backdrop-filter: brightness(0.8);
47
+ }
48
+
49
+ .cp-hero--background-dark,
50
+ .cp-hero--background-variants {
51
+ --cp-hero-text-1: #f5f5f5;
52
+ --cp-hero-text-2: #d4d4d4;
53
+ --cp-hero-border: rgb(255 255 255 / 18%);
54
+ --cp-hero-surface: rgb(10 10 10 / 72%);
55
+ --cp-hero-soft: rgb(255 255 255 / 10%);
56
+
57
+ background-color: #080506;
58
+ }
59
+
60
+ :where([data-cp-variant='light']) .cp-hero--background-dark::before,
61
+ .cp-hero--background-dark::before {
62
+ backdrop-filter: brightness(0.5);
63
+ }
64
+
65
+ .cp-hero--background-light,
66
+ :where([data-cp-variant='light']) .cp-hero--background-variants {
67
+ --cp-hero-text-1: #1a1a1a;
68
+ --cp-hero-text-2: #4a4a4a;
69
+ --cp-hero-border: rgb(0 0 0 / 18%);
70
+ --cp-hero-surface: rgb(255 255 255 / 76%);
71
+ --cp-hero-soft: rgb(0 0 0 / 8%);
72
+ }
73
+
74
+ .cp-hero--background-light::before,
75
+ :where([data-cp-variant='light']) .cp-hero--background-variants::before {
76
+ backdrop-filter: none;
77
+ }
78
+
79
+ .cp-hero--has-background .cp-hero__title code,
80
+ .cp-hero--has-background .cp-hero__tagline code {
81
+ color: var(--cp-hero-text-1);
82
+ background: var(--cp-hero-soft);
83
+ border-color: var(--cp-hero-border);
84
+ }
85
+
86
+ :where([data-cp-variant='light']) .cp-hero--background-variants {
87
+ background-image: var(--cp-hero-background-light-image);
88
+ background-position: var(--cp-hero-background-light-position, center);
89
+ background-size: var(--cp-hero-background-light-size, cover);
90
+ background-repeat: var(--cp-hero-background-light-repeat, no-repeat);
91
+ }
92
+
7
93
  .cp-hero__inner {
94
+ position: relative;
95
+ z-index: 2;
8
96
  max-width: 1100px;
9
97
  margin: 0 auto;
10
98
  padding: 0 32px;
@@ -15,12 +103,12 @@
15
103
  align-items: center;
16
104
  gap: 8px;
17
105
  padding: 6px 12px;
18
- border: 1px solid var(--cp-c-border);
106
+ border: 1px solid var(--cp-hero-border, var(--cp-c-border));
19
107
  border-radius: var(--cp-radius-pill);
20
- background: var(--cp-c-bg-elv);
108
+ background: var(--cp-hero-surface, var(--cp-c-bg-elv));
21
109
  font-family: var(--cp-font-family-mono);
22
110
  font-size: 11px;
23
- color: var(--cp-c-text-2);
111
+ color: var(--cp-hero-text-2, var(--cp-c-text-2));
24
112
  text-transform: uppercase;
25
113
  letter-spacing: 0.06em;
26
114
  margin-bottom: 28px;
@@ -32,7 +120,7 @@
32
120
  letter-spacing: -0.04em;
33
121
  line-height: 1;
34
122
  margin: 0 0 24px;
35
- color: var(--cp-c-text-1);
123
+ color: var(--cp-hero-text-1, var(--cp-c-text-1));
36
124
  }
37
125
 
38
126
  .cp-hero__title .cp-hero__grad {
@@ -44,7 +132,7 @@
44
132
  margin: 0 auto 40px;
45
133
  font-size: 18px;
46
134
  line-height: 1.6;
47
- color: var(--cp-c-text-2);
135
+ color: var(--cp-hero-text-2, var(--cp-c-text-2));
48
136
  }
49
137
 
50
138
  .cp-hero__cta {
@@ -63,9 +151,9 @@
63
151
  font-size: 14px;
64
152
  font-weight: 500;
65
153
  border-radius: var(--cp-radius-pill);
66
- border: 1px solid var(--cp-c-border);
154
+ border: 1px solid var(--cp-hero-border, var(--cp-c-border));
67
155
  background: transparent;
68
- color: var(--cp-c-text-1);
156
+ color: var(--cp-hero-text-1, var(--cp-c-text-1));
69
157
  cursor: pointer;
70
158
  transition: all 0.15s ease;
71
159
  white-space: nowrap;
@@ -73,8 +161,8 @@
73
161
  }
74
162
 
75
163
  .cp-hero__btn:hover {
76
- background: var(--cp-c-bg-soft);
77
- border-color: var(--cp-c-border);
164
+ background: var(--cp-hero-soft, var(--cp-c-bg-soft));
165
+ border-color: var(--cp-hero-border, var(--cp-c-border));
78
166
  }
79
167
 
80
168
  .cp-hero__btn--primary {
@@ -94,3 +182,48 @@
94
182
  margin: 0 auto;
95
183
  text-align: left;
96
184
  }
185
+
186
+ @media (max-width: 880px) {
187
+ .cp-hero--has-background {
188
+ background-image: var(--cp-hero-background-tablet-image, var(--cp-hero-background-image));
189
+ }
190
+
191
+ :where([data-cp-variant='light']) .cp-hero--background-variants {
192
+ background-image: var(
193
+ --cp-hero-background-light-tablet-image,
194
+ var(--cp-hero-background-light-image)
195
+ );
196
+ }
197
+ }
198
+
199
+ @media (max-width: 640px) {
200
+ .cp-hero {
201
+ padding: 72px 0 48px;
202
+ }
203
+
204
+ .cp-hero--has-background {
205
+ background-image: var(
206
+ --cp-hero-background-mobile-image,
207
+ var(--cp-hero-background-tablet-image, var(--cp-hero-background-image))
208
+ );
209
+ }
210
+
211
+ :where([data-cp-variant='light']) .cp-hero--background-variants {
212
+ background-image: var(
213
+ --cp-hero-background-light-mobile-image,
214
+ var(--cp-hero-background-light-tablet-image, var(--cp-hero-background-light-image))
215
+ );
216
+ }
217
+
218
+ .cp-hero__inner {
219
+ padding: 0 20px;
220
+ }
221
+
222
+ .cp-hero__title .cp-hero__grad {
223
+ display: block;
224
+ }
225
+
226
+ .cp-hero__tagline {
227
+ font-size: 16px;
228
+ }
229
+ }
@@ -0,0 +1,105 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, it, expect, vi } from 'vitest'
3
+
4
+ import { Hero } from './hero.tsx'
5
+
6
+ // `RouteLink` pulls in Rspress's router, which resolves the `virtual-routes`
7
+ // module only inside a real build. Stand in a plain anchor so link output is
8
+ // 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
+ withBase: (v: string) => v,
15
+ }
16
+ })
17
+
18
+ describe('Hero — background', () => {
19
+ it('should render React content in the background layer', () => {
20
+ const out = renderToStaticMarkup(
21
+ <Hero title="Docs" backgroundContent={<canvas data-testid="field" />} />
22
+ )
23
+ expect(out).toContain('<div class="cp-hero__background" aria-hidden="true"><canvas')
24
+ })
25
+
26
+ it('should add the modifier class when a background is configured', () => {
27
+ const out = renderToStaticMarkup(<Hero title="Docs" background={{ src: '/hero.jpg' }} />)
28
+ expect(out).toContain('cp-hero cp-hero--has-background')
29
+ })
30
+
31
+ it('should paint the image src into the CSS custom property', () => {
32
+ const out = renderToStaticMarkup(<Hero title="Docs" background={{ src: '/hero.jpg' }} />)
33
+ expect(out).toContain('--cp-hero-background-image:url(&quot;/hero.jpg&quot;)')
34
+ })
35
+
36
+ it('should use CSS defaults when sizing fields are omitted', () => {
37
+ const out = renderToStaticMarkup(<Hero title="Docs" background={{ src: '/hero.jpg' }} />)
38
+ expect(out).toContain('--cp-hero-background-position:center')
39
+ expect(out).toContain('--cp-hero-background-size:cover')
40
+ expect(out).toContain('--cp-hero-background-repeat:no-repeat')
41
+ })
42
+
43
+ it('should respect custom sizing fields', () => {
44
+ const out = renderToStaticMarkup(
45
+ <Hero title="Docs" background={{ src: '/hero.jpg', position: '50% 25%', size: 'contain' }} />
46
+ )
47
+ expect(out).toContain('--cp-hero-background-position:50% 25%')
48
+ expect(out).toContain('--cp-hero-background-size:contain')
49
+ })
50
+
51
+ it('should add the configured background mode class', () => {
52
+ const out = renderToStaticMarkup(
53
+ <Hero title="Docs" background={{ src: '/hero.jpg', mode: 'dark' }} />
54
+ )
55
+ expect(out).toContain('cp-hero--background-dark')
56
+ })
57
+
58
+ it('should paint responsive sources into CSS custom properties', () => {
59
+ const out = renderToStaticMarkup(
60
+ <Hero
61
+ title="Docs"
62
+ background={{
63
+ src: '/hero.jpg',
64
+ sources: { tablet: '/hero-tablet.jpg', mobile: '/hero-mobile.jpg' },
65
+ }}
66
+ />
67
+ )
68
+ expect(out).toContain('--cp-hero-background-tablet-image:url(&quot;/hero-tablet.jpg&quot;)')
69
+ expect(out).toContain('--cp-hero-background-mobile-image:url(&quot;/hero-mobile.jpg&quot;)')
70
+ })
71
+
72
+ it('should paint dark and light variant sources into CSS custom properties', () => {
73
+ const out = renderToStaticMarkup(
74
+ <Hero
75
+ title="Docs"
76
+ background={{
77
+ dark: { src: '/hero-dark.jpg', sources: { mobile: '/hero-dark-mobile.jpg' } },
78
+ light: { src: '/hero-light.jpg', sources: { mobile: '/hero-light-mobile.jpg' } },
79
+ }}
80
+ />
81
+ )
82
+ expect(out).toContain('cp-hero--background-variants')
83
+ expect(out).toContain('--cp-hero-background-image:url(&quot;/hero-dark.jpg&quot;)')
84
+ expect(out).toContain('--cp-hero-background-light-image:url(&quot;/hero-light.jpg&quot;)')
85
+ expect(out).toContain(
86
+ '--cp-hero-background-light-mobile-image:url(&quot;/hero-light-mobile.jpg&quot;)'
87
+ )
88
+ })
89
+
90
+ it('should not add the modifier when background is absent', () => {
91
+ const out = renderToStaticMarkup(<Hero title="Docs" />)
92
+ expect(out).not.toContain('cp-hero--has-background')
93
+ expect(out).not.toContain('--cp-hero-background-image')
94
+ })
95
+
96
+ it('should not render an empty demo wrapper when the demo is suppressed', () => {
97
+ const out = renderToStaticMarkup(<Hero title="Docs" demo={null} />)
98
+ expect(out).not.toContain('cp-hero__demo')
99
+ })
100
+
101
+ it('should not paint a background when src is an empty string', () => {
102
+ const out = renderToStaticMarkup(<Hero title="Docs" background={{ src: '' }} />)
103
+ expect(out).not.toContain('cp-hero--has-background')
104
+ })
105
+ })
@@ -3,6 +3,7 @@ import type React from 'react'
3
3
 
4
4
  import { RouteLink } from '../../lib/route-link.tsx'
5
5
  import { safeUrl } from '../../lib/safe-url.ts'
6
+ import { withMountBase } from '../../lib/with-mount-base.ts'
6
7
 
7
8
  import './hero.css'
8
9
 
@@ -21,6 +22,55 @@ export interface HeroAction {
21
22
  readonly theme?: 'brand' | 'alt'
22
23
  }
23
24
 
25
+ /** Background image source and responsive rendering controls. */
26
+ export interface HeroBackgroundSource {
27
+ /**
28
+ * Image URL or path. Runs through {@link withMountBase} so paths on a
29
+ * mounted `base` still resolve.
30
+ */
31
+ readonly src: string
32
+ /**
33
+ * Optional image overrides for the 880px tablet and 640px mobile
34
+ * breakpoints.
35
+ */
36
+ readonly sources?: {
37
+ readonly tablet?: string
38
+ readonly mobile?: string
39
+ }
40
+ /**
41
+ * CSS `background-position` value (e.g. `'center'`). Default `'center'`.
42
+ */
43
+ readonly position?: string
44
+ /**
45
+ * CSS `background-size` value. Default `'cover'`.
46
+ */
47
+ readonly size?: string
48
+ /**
49
+ * CSS `background-repeat` value. Default `'no-repeat'`.
50
+ */
51
+ readonly repeat?: string
52
+ }
53
+
54
+ /** A single hero background image with fixed foreground contrast. */
55
+ export interface HeroBackgroundImage extends HeroBackgroundSource {
56
+ /**
57
+ * Color mode used for the hero foreground and scrim. When omitted, the
58
+ * active site variant is used.
59
+ */
60
+ readonly mode?: 'dark' | 'light'
61
+ }
62
+
63
+ /** Hero background sources selected from the active site variant. */
64
+ export interface HeroBackgroundVariants {
65
+ /** Artwork displayed in dark mode. */
66
+ readonly dark: HeroBackgroundSource
67
+ /** Artwork displayed in light mode. */
68
+ readonly light: HeroBackgroundSource
69
+ }
70
+
71
+ /** Background image painted behind the hero copy. */
72
+ export type HeroBackground = HeroBackgroundImage | HeroBackgroundVariants
73
+
24
74
  export interface HeroProps {
25
75
  /**
26
76
  * Optional eyebrow chip (e.g. "★ 2.1k stars · v0.4.2").
@@ -43,22 +93,39 @@ export interface HeroProps {
43
93
  * Optional visual block beneath the CTAs (terminal demo, screenshot, etc).
44
94
  */
45
95
  readonly demo?: React.ReactNode
96
+ /**
97
+ * Optional background image painted behind the hero copy.
98
+ */
99
+ readonly background?: HeroBackground
100
+ /**
101
+ * Optional React content painted behind the hero copy. Intended for
102
+ * procedural canvas, WebGL, or inline SVG backgrounds.
103
+ */
104
+ readonly backgroundContent?: React.ReactNode
46
105
  }
47
106
 
48
107
  /**
49
108
  * Hero — landing-page hero with eyebrow chip, gradient-friendly headline,
50
- * tagline, CTAs, and an optional demo slot. The radial accent glow + dotted
51
- * grid backdrop are baked in via CSS and adapt to the active theme.
109
+ * tagline, CTAs, an optional demo slot, and an optional background image.
110
+ * The radial accent glow + dotted grid backdrop are baked in via CSS and
111
+ * adapt to the active theme.
52
112
  *
53
113
  * @param props - Hero configuration.
54
114
  * @returns React element.
55
115
  */
56
116
  export function Hero(props: HeroProps): React.ReactElement {
57
- const { eyebrow, title, tagline, actions, demo } = props
117
+ const { eyebrow, title, tagline, actions, demo, background, backgroundContent } = props
58
118
  const list = actions ?? []
59
119
 
60
120
  return (
61
- <section className="cp-hero">
121
+ <section className={heroClassName(background)} style={heroBackgroundStyle(background)}>
122
+ {match(backgroundContent)
123
+ .with(undefined, () => null)
124
+ .otherwise((content) => (
125
+ <div className="cp-hero__background" aria-hidden="true">
126
+ {content}
127
+ </div>
128
+ ))}
62
129
  <div className="cp-hero__inner">
63
130
  {match(eyebrow)
64
131
  .with(undefined, () => null)
@@ -77,6 +144,7 @@ export function Hero(props: HeroProps): React.ReactElement {
77
144
  <div className="cp-hero__cta">{list.map(renderAction)}</div>
78
145
  ))}
79
146
  {match(demo)
147
+ .with(null, () => null)
80
148
  .with(undefined, () => null)
81
149
  .otherwise((d) => (
82
150
  <div className="cp-hero__demo">{d}</div>
@@ -86,6 +154,135 @@ export function Hero(props: HeroProps): React.ReactElement {
86
154
  )
87
155
  }
88
156
 
157
+ /**
158
+ * Compute the `<section>` className. The modifier class carries the
159
+ * background-image CSS variables' consumers; the base class alone when no
160
+ * background is configured.
161
+ *
162
+ * @private
163
+ * @param background - Hero background config (may be `undefined`)
164
+ * @returns ClassName string
165
+ */
166
+ function heroClassName(background: HeroBackground | undefined): string {
167
+ if (background === undefined) {
168
+ return 'cp-hero'
169
+ }
170
+ if (isVariantBackground(background)) {
171
+ return 'cp-hero cp-hero--has-background cp-hero--background-variants'
172
+ }
173
+ if (background.src.length === 0) {
174
+ return 'cp-hero'
175
+ }
176
+ if (background.mode === undefined) {
177
+ return 'cp-hero cp-hero--has-background'
178
+ }
179
+ return `cp-hero cp-hero--has-background cp-hero--background-${background.mode}`
180
+ }
181
+
182
+ /**
183
+ * Build the inline CSS custom properties for the hero background. Returns
184
+ * `undefined` when no background is configured, so the `<section>` carries
185
+ * no `style` attr at all in the default case.
186
+ *
187
+ * @private
188
+ * @param background - Hero background config (may be `undefined`)
189
+ * @returns CSS properties keyed as custom properties, or `undefined`
190
+ */
191
+ function heroBackgroundStyle(
192
+ background: HeroBackground | undefined
193
+ ): React.CSSProperties | undefined {
194
+ if (background === undefined) {
195
+ return undefined
196
+ }
197
+ if (isVariantBackground(background)) {
198
+ const dark = heroBackgroundSourceStyle(background.dark)
199
+ const light = heroBackgroundSourceStyle(background.light)
200
+ return {
201
+ '--cp-hero-background-image': dark.image,
202
+ '--cp-hero-background-position': dark.position,
203
+ '--cp-hero-background-size': dark.size,
204
+ '--cp-hero-background-repeat': dark.repeat,
205
+ '--cp-hero-background-tablet-image': dark.tabletImage,
206
+ '--cp-hero-background-mobile-image': dark.mobileImage,
207
+ '--cp-hero-background-light-image': light.image,
208
+ '--cp-hero-background-light-position': light.position,
209
+ '--cp-hero-background-light-size': light.size,
210
+ '--cp-hero-background-light-repeat': light.repeat,
211
+ '--cp-hero-background-light-tablet-image': light.tabletImage,
212
+ '--cp-hero-background-light-mobile-image': light.mobileImage,
213
+ } as React.CSSProperties
214
+ }
215
+ if (background.src.length === 0) {
216
+ return undefined
217
+ }
218
+ const style = heroBackgroundSourceStyle(background)
219
+ return {
220
+ '--cp-hero-background-image': style.image,
221
+ '--cp-hero-background-position': style.position,
222
+ '--cp-hero-background-size': style.size,
223
+ '--cp-hero-background-repeat': style.repeat,
224
+ '--cp-hero-background-tablet-image': style.tabletImage,
225
+ '--cp-hero-background-mobile-image': style.mobileImage,
226
+ } as React.CSSProperties
227
+ }
228
+
229
+ interface HeroBackgroundStyleValues {
230
+ readonly image: string
231
+ readonly position: string
232
+ readonly size: string
233
+ readonly repeat: string
234
+ readonly tabletImage: string | undefined
235
+ readonly mobileImage: string | undefined
236
+ }
237
+
238
+ /**
239
+ * Build normalized CSS values for one background source.
240
+ *
241
+ * @private
242
+ * @param source - Background source and rendering controls.
243
+ * @returns Normalized CSS values.
244
+ */
245
+ function heroBackgroundSourceStyle(source: HeroBackgroundSource): HeroBackgroundStyleValues {
246
+ const sources = source.sources
247
+ return {
248
+ image: `url("${withMountBase(source.src)}")`,
249
+ position: source.position ?? 'center',
250
+ size: source.size ?? 'cover',
251
+ repeat: source.repeat ?? 'no-repeat',
252
+ tabletImage: match(sources)
253
+ .with(undefined, () => undefined)
254
+ .otherwise((responsive) => heroSourceImage(responsive.tablet)),
255
+ mobileImage: match(sources)
256
+ .with(undefined, () => undefined)
257
+ .otherwise((responsive) => heroSourceImage(responsive.mobile)),
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Test whether a background provides site-variant sources.
263
+ *
264
+ * @private
265
+ * @param background - Hero background configuration.
266
+ * @returns Whether the background contains dark and light sources.
267
+ */
268
+ function isVariantBackground(background: HeroBackground): background is HeroBackgroundVariants {
269
+ return 'dark' in background
270
+ }
271
+
272
+ /**
273
+ * Convert an optional responsive source into a base-aware CSS image value.
274
+ *
275
+ * @private
276
+ * @param src - Optional image URL or path.
277
+ * @returns CSS `url()` value, or `undefined` when no override is configured.
278
+ */
279
+ function heroSourceImage(src: string | undefined): string | undefined {
280
+ if (src === undefined) {
281
+ return undefined
282
+ }
283
+ return `url("${withMountBase(src)}")`
284
+ }
285
+
89
286
  /**
90
287
  * Render a single hero CTA action.
91
288
  *
@@ -0,0 +1,98 @@
1
+ .cp-home-terminal {
2
+ width: 100%;
3
+ min-width: 0;
4
+ overflow: hidden;
5
+ background: var(--cp-c-term-bg);
6
+ color: var(--cp-c-term-text);
7
+ }
8
+
9
+ .cp-home-terminal--framed {
10
+ width: min(920px, 100%);
11
+ margin: 0 auto;
12
+ border: 1px solid var(--cp-c-term-border);
13
+ border-radius: var(--cp-radius-lg);
14
+ }
15
+
16
+ .cp-home-terminal__bar {
17
+ display: grid;
18
+ grid-template-columns: 1fr auto 1fr;
19
+ align-items: center;
20
+ min-height: var(--cp-size-titlebar);
21
+ padding: var(--cp-s-8) var(--cp-s-12);
22
+ border-bottom: 1px solid var(--cp-c-term-border);
23
+ background: var(--cp-c-term-titlebar-bg);
24
+ }
25
+
26
+ .cp-home-terminal__dots {
27
+ display: flex;
28
+ gap: var(--cp-s-6);
29
+ justify-self: start;
30
+ }
31
+
32
+ .cp-home-terminal__dot {
33
+ width: var(--cp-size-window-dot);
34
+ height: var(--cp-size-window-dot);
35
+ border-radius: 50%;
36
+ }
37
+
38
+ .cp-home-terminal__dot--close {
39
+ background: var(--cp-c-window-dot-close);
40
+ }
41
+
42
+ .cp-home-terminal__dot--minimize {
43
+ background: var(--cp-c-window-dot-minimize);
44
+ }
45
+
46
+ .cp-home-terminal__dot--maximize {
47
+ background: var(--cp-c-window-dot-maximize);
48
+ }
49
+
50
+ .cp-home-terminal__title {
51
+ overflow: hidden;
52
+ color: var(--cp-c-term-title);
53
+ font-family: var(--rp-font-family-mono);
54
+ font-size: var(--cp-fs-window-title);
55
+ text-overflow: ellipsis;
56
+ white-space: nowrap;
57
+ }
58
+
59
+ .cp-home-terminal__body {
60
+ min-height: 220px;
61
+ margin: 0;
62
+ overflow-x: auto;
63
+ padding: var(--cp-s-20) var(--cp-s-24);
64
+ color: var(--cp-c-term-text);
65
+ font-family: var(--rp-font-family-mono);
66
+ font-size: clamp(11px, 1.2vw, var(--cp-fs-term-body));
67
+ line-height: var(--cp-lh-relaxed);
68
+ overflow-wrap: anywhere;
69
+ white-space: pre-wrap;
70
+ }
71
+
72
+ .cp-home-terminal__prompt {
73
+ color: var(--cp-c-term-prompt-prefix);
74
+ }
75
+
76
+ .cp-home-terminal__ok {
77
+ color: var(--cp-c-term-green);
78
+ }
79
+
80
+ .cp-home-terminal__info {
81
+ color: var(--cp-c-term-cyan);
82
+ }
83
+
84
+ .cp-home-terminal__cmt {
85
+ color: var(--cp-c-term-muted);
86
+ }
87
+
88
+ .cp-home-terminal__err {
89
+ color: var(--cp-c-term-red);
90
+ }
91
+
92
+ @media (max-width: 640px) {
93
+ .cp-home-terminal__body {
94
+ min-height: 190px;
95
+ padding: var(--cp-s-16);
96
+ font-size: 11px;
97
+ }
98
+ }
@@ -0,0 +1,27 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { HomeVisualView } from './home-visual.tsx'
5
+
6
+ vi.mock('@rspress/core/theme', () => ({ CodeBlockRuntime: (): null => null }))
7
+ vi.mock('@rspress/core/runtime', () => ({ withBase: (value: string): string => value }))
8
+
9
+ describe('home visual', () => {
10
+ it('should render terminal visuals with dedicated compact chrome', () => {
11
+ expect.assertions(3)
12
+ const output = renderToStaticMarkup(
13
+ <HomeVisualView
14
+ context="tabs"
15
+ visual={{
16
+ type: 'terminal',
17
+ windowTitle: 'ciderpress dev',
18
+ command: 'ciderpress dev',
19
+ lines: [{ kind: 'ok', text: 'ready' }],
20
+ }}
21
+ />
22
+ )
23
+ expect(output).toContain('role="group" aria-label="Terminal output"')
24
+ expect(output).toContain('ciderpress dev')
25
+ expect(output).toContain('ready')
26
+ })
27
+ })