@ciderpress/ui 1.0.0-rc.3 → 1.0.0-rc.4

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 (65) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.mjs +4 -3
  3. package/dist/node.d.ts +1 -1
  4. package/dist/node.mjs +316 -50
  5. package/dist/theme/components/footer/site-footer.tsx +68 -9
  6. package/dist/theme/components/home/feature-card.tsx +5 -8
  7. package/dist/theme/components/home/feature.tsx +31 -11
  8. package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
  9. package/dist/theme/components/home/hero-demo.css +27 -0
  10. package/dist/theme/components/home/layout.tsx +149 -26
  11. package/dist/theme/components/home/split-visual-custom.tsx +26 -0
  12. package/dist/theme/components/home/workspaces.tsx +39 -3
  13. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  14. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  15. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  16. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
  17. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  18. package/dist/theme/components/nav/header-icon.css +24 -0
  19. package/dist/theme/components/nav/header-icon.tsx +119 -0
  20. package/dist/theme/components/nav/header-logo.css +16 -0
  21. package/dist/theme/components/nav/header-logo.tsx +200 -0
  22. package/dist/theme/components/nav/layout.tsx +45 -7
  23. package/dist/theme/components/nav/nav-logo.tsx +5 -1
  24. package/dist/theme/components/shared/card-icon.tsx +37 -0
  25. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  26. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  27. package/dist/theme/components/shared/section-card.tsx +9 -6
  28. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  29. package/dist/theme/components/theme-provider.tsx +27 -11
  30. package/dist/theme/components/workspaces/card.tsx +7 -9
  31. package/dist/theme/hooks/use-ciderpress.ts +60 -5
  32. package/dist/theme/lib/read-social-links.ts +4 -3
  33. package/dist/theme/lib/theme-favicon.ts +1 -1
  34. package/dist/theme/lib/with-mount-base.ts +31 -0
  35. package/package.json +13 -13
  36. package/src/theme/components/footer/site-footer.tsx +68 -9
  37. package/src/theme/components/home/feature-card.tsx +5 -8
  38. package/src/theme/components/home/feature.tsx +31 -11
  39. package/src/theme/components/home/hero-demo-custom.tsx +116 -0
  40. package/src/theme/components/home/hero-demo.css +27 -0
  41. package/src/theme/components/home/layout.tsx +149 -26
  42. package/src/theme/components/home/split-visual-custom.tsx +26 -0
  43. package/src/theme/components/home/workspaces.tsx +39 -3
  44. package/src/theme/components/nav/branch-tag.tsx +7 -7
  45. package/src/theme/components/nav/ciderpress-header.css +2 -1
  46. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  47. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +9 -7
  48. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  49. package/src/theme/components/nav/header-icon.css +24 -0
  50. package/src/theme/components/nav/header-icon.tsx +119 -0
  51. package/src/theme/components/nav/header-logo.css +16 -0
  52. package/src/theme/components/nav/header-logo.tsx +200 -0
  53. package/src/theme/components/nav/layout.tsx +45 -7
  54. package/src/theme/components/nav/nav-logo.tsx +5 -1
  55. package/src/theme/components/shared/card-icon.tsx +37 -0
  56. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  57. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  58. package/src/theme/components/shared/section-card.tsx +9 -6
  59. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  60. package/src/theme/components/theme-provider.tsx +27 -11
  61. package/src/theme/components/workspaces/card.tsx +7 -9
  62. package/src/theme/hooks/use-ciderpress.ts +60 -5
  63. package/src/theme/lib/read-social-links.ts +4 -3
  64. package/src/theme/lib/theme-favicon.ts +1 -1
  65. package/src/theme/lib/with-mount-base.ts +31 -0
@@ -1,4 +1,5 @@
1
- import type { HomeGridConfig } from '@ciderpress/config'
1
+ import type { HomeShowcaseConfig } from '@ciderpress/config'
2
+ import { useFrontmatter } from '@rspress/core/runtime'
2
3
  import { match, P } from 'massaman/match'
3
4
  import type React from 'react'
4
5
 
@@ -7,15 +8,29 @@ import type { WorkspaceGroupData } from '../../hooks/use-ciderpress'
7
8
  import { WorkspaceCard } from '../workspaces/card'
8
9
  import { WorkspaceGrid } from '../workspaces/grid'
9
10
 
11
+ interface FrontmatterWorkspacesHeading {
12
+ readonly label?: string
13
+ readonly title?: string
14
+ readonly subtitle?: string
15
+ }
16
+
10
17
  /**
11
18
  * Smart orchestrator that reads workspace data from themeConfig
12
19
  * and renders workspace groups with the correct card component per type.
13
20
  *
21
+ * Optionally renders a top-level heading above the workspace groups
22
+ * when `home.showcase.heading` is configured. The sync engine writes
23
+ * it into frontmatter as `workspacesHeading`.
24
+ *
14
25
  * @returns React element with workspace groups or null
15
26
  */
16
27
  export function HomeWorkspaces(): React.ReactElement | null {
17
28
  const { workspaces, home } = useCiderpress()
18
- const gridConfig = home && home.workspaces
29
+ const gridConfig = home && home.showcase
30
+ const { frontmatter } = useFrontmatter()
31
+ const heading = (frontmatter as Record<string, unknown>).workspacesHeading as
32
+ | FrontmatterWorkspacesHeading
33
+ | undefined
19
34
 
20
35
  return match(workspaces)
21
36
  .with(
@@ -23,6 +38,27 @@ export function HomeWorkspaces(): React.ReactElement | null {
23
38
  (groups) => (
24
39
  <div className="cp-workspace-section">
25
40
  <hr className="cp-divider" />
41
+ {match(heading)
42
+ .with(undefined, () => null)
43
+ .otherwise((h) => (
44
+ <div className="cp-feature-section-head">
45
+ {match(h.label)
46
+ .with(undefined, () => null)
47
+ .otherwise((e) => (
48
+ <div className="cp-feature-section-head__eyebrow">{e}</div>
49
+ ))}
50
+ {match(h.title)
51
+ .with(undefined, () => null)
52
+ .otherwise((t) => (
53
+ <h2 className="cp-feature-section-head__title">{t}</h2>
54
+ ))}
55
+ {match(h.subtitle)
56
+ .with(undefined, () => null)
57
+ .otherwise((s) => (
58
+ <p className="cp-feature-section-head__sub">{s}</p>
59
+ ))}
60
+ </div>
61
+ ))}
26
62
  {groups.map((group) => renderGroup(group, gridConfig))}
27
63
  </div>
28
64
  )
@@ -40,7 +76,7 @@ export function HomeWorkspaces(): React.ReactElement | null {
40
76
  */
41
77
  function renderGroup(
42
78
  group: WorkspaceGroupData,
43
- gridConfig: HomeGridConfig | undefined
79
+ gridConfig: HomeShowcaseConfig | undefined
44
80
  ): React.ReactElement {
45
81
  const titleLines = gridConfig && gridConfig.truncate && gridConfig.truncate.title
46
82
  const descLines = gridConfig && gridConfig.truncate && gridConfig.truncate.description
@@ -1,7 +1,7 @@
1
- import type { SiteConfig } from '@ciderpress/config'
2
1
  import { match, P } from 'massaman/match'
3
2
  import type React from 'react'
4
3
 
4
+ import type { CiderpressSiteBlock } from '../../hooks/use-ciderpress'
5
5
  import { useCiderpress } from '../../hooks/use-ciderpress'
6
6
 
7
7
  import './branch-tag.css'
@@ -12,7 +12,7 @@ declare const __CIDERPRESS_GIT_BRANCH__: string | undefined
12
12
  /**
13
13
  * Git branch tag — pill-shaped badge rendered via the `beforeNavMenu`
14
14
  * layout slot. Hidden when on default branches (`main` or `master`) or
15
- * when no `site.edit.repo` is configured. Uses the pixelarticons:git-branch
15
+ * when no `editLink.repo` is configured. Uses the pixelarticons:git-branch
16
16
  * icon.
17
17
  *
18
18
  * @returns React element, or `null` when on a default branch / unconfigured.
@@ -61,7 +61,7 @@ function resolveBranch(): string {
61
61
  }
62
62
 
63
63
  /**
64
- * Build a GitHub `/tree/<branch>` URL from the configured `site.edit.repo`.
64
+ * Build a GitHub `/tree/<branch>` URL from the configured `editLink.repo`.
65
65
  *
66
66
  * Accepts either a full URL (used as-is) or an `org/repo` slug
67
67
  * (prefixed with `https://github.com/`). Returns `null` when `repo` is
@@ -83,14 +83,14 @@ function buildBranchHref(params: {
83
83
  }
84
84
 
85
85
  /**
86
- * Pull the configured edit-repo slug off the site config, expressed
87
- * with explicit null checks rather than optional chaining.
86
+ * Pull the configured edit-repo slug off the serialised site block,
87
+ * expressed with explicit null checks rather than optional chaining.
88
88
  *
89
89
  * @private
90
- * @param site - The ciderpress site config (may be undefined).
90
+ * @param site - The runtime site block (may be undefined).
91
91
  * @returns The configured `edit.repo` value or `undefined`.
92
92
  */
93
- function resolveRepo(site: SiteConfig | undefined): string | undefined {
93
+ function resolveRepo(site: CiderpressSiteBlock | undefined): string | undefined {
94
94
  if (site === undefined) {
95
95
  return undefined
96
96
  }
@@ -76,7 +76,8 @@
76
76
  text-decoration: none;
77
77
  }
78
78
 
79
- .cp-header-logo > svg {
79
+ .cp-header-logo > svg,
80
+ .cp-header-logo > img {
80
81
  height: var(--cp-header-logo-height, 28px);
81
82
  width: auto;
82
83
  }
@@ -2,19 +2,29 @@ import { clsx } from 'clsx'
2
2
  import type React from 'react'
3
3
 
4
4
  import { RouteLink } from '../../lib/route-link.tsx'
5
- import { CiderpressLogo } from '../shared/ciderpress-logo'
6
5
  import { CiderpressNavHamburger } from './ciderpress-nav-hamburger'
7
6
  import { CiderpressNavMenu } from './ciderpress-nav-menu'
8
7
  import type { CiderpressNavMenuItem } from './ciderpress-nav-menu'
9
8
  import { CiderpressNavSearch } from './ciderpress-nav-search'
10
9
  import { CiderpressNavSocialLinks } from './ciderpress-nav-social-links'
11
10
  import type { CiderpressSocialLink } from './ciderpress-nav-social-links'
11
+ import { HeaderIcon } from './header-icon'
12
+ import { HeaderLogo } from './header-logo'
12
13
  import { NavDivider } from './nav-divider'
13
14
  import { TopbarCTA } from './topbar-cta'
14
15
  import { VariantToggle } from './variant-toggle'
15
16
 
16
17
  import './ciderpress-header.css'
17
18
 
19
+ /**
20
+ * Build-time flag: `true` when the active theme declares more than one
21
+ * variant AND `data-cp-variants` won't collapse the `<VariantToggle />`
22
+ * to `display: none`. Drives whether the trailing-cluster divider is
23
+ * rendered — when the toggle is hidden and neither socials nor CTA
24
+ * follow, the divider has nothing to separate and is omitted.
25
+ */
26
+ declare const __CIDERPRESS_HAS_VARIANT_TOGGLE__: boolean
27
+
18
28
  /**
19
29
  * Props for the ciderpress site header.
20
30
  */
@@ -57,6 +67,18 @@ export interface CiderpressHeaderProps {
57
67
  * @returns Sticky header element
58
68
  */
59
69
  export function CiderpressHeader(props: CiderpressHeaderProps): React.ReactElement {
70
+ // Dividers only render when they actually separate two visible
71
+ // clusters — orphan dividers (nothing on the right) are visually
72
+ // noisy. `<VariantToggle />` is hidden by CSS for single-variant
73
+ // themes; the build-time flag captures that decision so the React
74
+ // tree matches the painted output. The search/menu inner divider is
75
+ // unconditional — both clusters always render some chrome, and
76
+ // gating on `navItems.length` would flip after hydration (Rspress's
77
+ // nav data isn't populated at SSG) and produce a hydration warning.
78
+ const hasSocials = props.socialLinks.length > 0
79
+ const hasCta = props.topbarCta !== undefined
80
+ const hasTrailingCluster = __CIDERPRESS_HAS_VARIANT_TOGGLE__ || hasSocials || hasCta
81
+
60
82
  // Landing / home pages live inside a constrained max-width container;
61
83
  // doc pages stretch full-width (sidebar + article). The header mirrors
62
84
  // the content shell so it never floats outside the page rhythm.
@@ -78,7 +100,8 @@ export function CiderpressHeader(props: CiderpressHeaderProps): React.ReactEleme
78
100
  topbarCta={props.topbarCta}
79
101
  />
80
102
  <RouteLink href="/" className="cp-header-logo" aria-label="Home">
81
- <CiderpressLogo />
103
+ <HeaderIcon />
104
+ <HeaderLogo />
82
105
  </RouteLink>
83
106
 
84
107
  {/* Right cluster wrapper. Grows from a zero basis so its width
@@ -96,7 +119,7 @@ export function CiderpressHeader(props: CiderpressHeaderProps): React.ReactEleme
96
119
  <CiderpressNavMenu items={props.navItems} />
97
120
  </div>
98
121
 
99
- <NavDivider />
122
+ {hasTrailingCluster && <NavDivider />}
100
123
  <VariantToggle />
101
124
  <CiderpressNavSocialLinks links={props.socialLinks} />
102
125
  {props.topbarCta !== undefined && (
@@ -6,13 +6,15 @@ import { Icon } from '../shared/icon.tsx'
6
6
  import './ciderpress-nav-social-links.css'
7
7
 
8
8
  /**
9
- * Single social-link entry — matches the shape of `socialLinks` in
10
- * `ciderpress.config.ts`.
9
+ * Single social-link entry — matches the serialised `socials` shape
10
+ * written into `themeConfig.socialLinks` by `packages/ui/src/config.ts`.
11
+ *
12
+ * The Rspress `mode` / `content` discriminator was killed in the
13
+ * `rc.4` API overhaul; every link is now a plain anchor.
11
14
  */
12
15
  export interface CiderpressSocialLink {
13
16
  readonly icon: string
14
- readonly mode: string
15
- readonly content: string
17
+ readonly url: string
16
18
  readonly label?: string
17
19
  }
18
20
 
@@ -39,7 +41,7 @@ const ICON_MAP: Readonly<Record<string, string>> = Object.freeze({
39
41
  * Renders the configured social links as a cluster of icon buttons in
40
42
  * the topbar. Returns `null` when no links are configured.
41
43
  *
42
- * @param props - List of social links from `site.socialLinks`
44
+ * @param props - List of social links from `site.socials`
43
45
  * @returns Cluster of icon links, or `null`
44
46
  */
45
47
  export function CiderpressNavSocialLinks(
@@ -50,7 +52,7 @@ export function CiderpressNavSocialLinks(
50
52
  }
51
53
 
52
54
  const safeLinks = props.links
53
- .map((link) => ({ link, href: safeUrl(link.content) }))
55
+ .map((link) => ({ link, href: safeUrl(link.url) }))
54
56
  .filter((entry): entry is { readonly link: CiderpressSocialLink; readonly href: string } => {
55
57
  if (entry.href === null) {
56
58
  return false
@@ -66,7 +68,7 @@ export function CiderpressNavSocialLinks(
66
68
  <div className="cp-nav-social">
67
69
  {safeLinks.map(({ link, href }) => (
68
70
  <a
69
- key={link.content}
71
+ key={link.url}
70
72
  href={href}
71
73
  target="_blank"
72
74
  rel="noopener noreferrer"
@@ -1,7 +1,7 @@
1
- import type { SiteConfig } from '@ciderpress/config'
2
1
  import { match, P } from 'massaman/match'
3
2
  import type React from 'react'
4
3
 
4
+ import type { CiderpressSiteBlock } from '../../hooks/use-ciderpress'
5
5
  import { useCiderpress } from '../../hooks/use-ciderpress'
6
6
 
7
7
  import './floating-branch-indicator.css'
@@ -15,7 +15,7 @@ declare const __CIDERPRESS_GIT_BRANCH__: string | undefined
15
15
  * the branch name. Click anywhere on the pill opens the branch on GitHub.
16
16
  *
17
17
  * Hidden when on default branches (`main` / `master`), when the
18
- * build-time branch global is undefined, or when `site.edit.repo` is
18
+ * build-time branch global is undefined, or when `editLink.repo` is
19
19
  * not configured.
20
20
  *
21
21
  * @returns React element or null when hidden
@@ -69,7 +69,7 @@ function resolveBranch(): string {
69
69
  }
70
70
 
71
71
  /**
72
- * Build a GitHub `/tree/<branch>` URL from the configured `site.edit.repo`.
72
+ * Build a GitHub `/tree/<branch>` URL from the configured `editLink.repo`.
73
73
  *
74
74
  * Accepts either a full URL (used as-is) or an `org/repo` slug
75
75
  * (prefixed with `https://github.com/`). Returns `null` when `repo` is
@@ -91,14 +91,14 @@ function buildBranchHref(params: {
91
91
  }
92
92
 
93
93
  /**
94
- * Pull the configured edit-repo slug off the site config, expressed
95
- * with explicit null checks rather than optional chaining.
94
+ * Pull the configured edit-repo slug off the serialised site block,
95
+ * expressed with explicit null checks rather than optional chaining.
96
96
  *
97
97
  * @private
98
- * @param site - The ciderpress site config (may be undefined).
98
+ * @param site - The runtime site block (may be undefined).
99
99
  * @returns The configured `edit.repo` value or `undefined`.
100
100
  */
101
- function resolveRepo(site: SiteConfig | undefined): string | undefined {
101
+ function resolveRepo(site: CiderpressSiteBlock | undefined): string | undefined {
102
102
  if (site === undefined) {
103
103
  return undefined
104
104
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Small brand chip rendered immediately before `<HeaderLogo />` inside
3
+ * `cp-header-logo`. Sized to match `--cp-header-icon-size` (default
4
+ * 28px — same as the logo height token) so the two slots line up
5
+ * vertically by default. Themes can override either token to break
6
+ * the lockstep.
7
+ */
8
+
9
+ .cp-header-icon {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ flex-shrink: 0;
13
+ height: var(--cp-header-icon-size, var(--cp-header-logo-height, 28px));
14
+ width: var(--cp-header-icon-size, var(--cp-header-logo-height, 28px));
15
+ margin-right: var(--cp-header-icon-gap, 8px);
16
+ color: var(--cp-header-icon-color, currentColor);
17
+ }
18
+
19
+ .cp-header-icon > svg,
20
+ .cp-header-icon__img {
21
+ height: 100%;
22
+ width: 100%;
23
+ display: block;
24
+ }
@@ -0,0 +1,119 @@
1
+ /* oxlint-disable no-ternary -- raw-copied file; relaxed rules per packages/ui/CLAUDE.md */
2
+ import type { CiderpressConfig, IconConfig, IconImage } from '@ciderpress/config'
3
+ // oxlint-disable-next-line import/no-unresolved -- alias provided by createRspressConfig's resolve.alias
4
+ import userConfigModule from '@ciderpress/internal/user-config'
5
+ import { match } from 'massaman/match'
6
+ import type React from 'react'
7
+
8
+ import { Icon } from '../shared/icon'
9
+
10
+ import './header-icon.css'
11
+
12
+ /**
13
+ * Small brand chip rendered before `<HeaderLogo />` inside `cp-header-logo`.
14
+ *
15
+ * Reads `userConfig.brand.icon` from the bundled user config and routes:
16
+ * - Iconify id (`"devicon:react"`) → `<Icon>` component.
17
+ * - `{ id, color }` → `<Icon>` with inline color.
18
+ * - `{ src, alt }` → `<img>` with the user's asset.
19
+ * - missing → null (slot collapses, `<HeaderLogo />` stretches).
20
+ *
21
+ * Pairs with `<HeaderLogo />` for the canonical two-slot brand identity
22
+ * (icon chip + wordmark logo). Sites with only one of the two get the
23
+ * lone element rendered.
24
+ *
25
+ * @returns Branded icon chip ready to drop inside `cp-header-logo`, or null
26
+ */
27
+ export function HeaderIcon(): React.ReactElement | null {
28
+ const icon = readIconConfig(userConfigModule)
29
+ if (icon === undefined) {
30
+ return null
31
+ }
32
+ return match(resolveTopbar(icon))
33
+ .with({ kind: 'iconify' }, (r) => (
34
+ <span className="cp-header-icon">
35
+ <Icon icon={r.id} style={inlineColor(r.color)} />
36
+ </span>
37
+ ))
38
+ .with({ kind: 'image' }, (r) => (
39
+ <span className="cp-header-icon">
40
+ <img src={r.src} alt={r.alt} className="cp-header-icon__img" />
41
+ </span>
42
+ ))
43
+ .exhaustive()
44
+ }
45
+
46
+ export { HeaderIcon as default }
47
+
48
+ type TopbarIcon =
49
+ | { readonly kind: 'iconify'; readonly id: string; readonly color: string | undefined }
50
+ | { readonly kind: 'image'; readonly src: string; readonly alt: string }
51
+
52
+ /**
53
+ * Extract the `brand.icon` field from the bundled user config module.
54
+ *
55
+ * @private
56
+ * @param mod - Module imported from `@ciderpress/internal/user-config`
57
+ * @returns The `brand.icon` value or `undefined` when none is configured
58
+ */
59
+ function readIconConfig(mod: unknown): IconConfig | undefined {
60
+ if (mod === null || mod === undefined) {
61
+ return undefined
62
+ }
63
+ const asRecord = mod as Record<string, unknown>
64
+ const candidate =
65
+ asRecord.default !== null && asRecord.default !== undefined
66
+ ? (asRecord.default as Partial<CiderpressConfig>)
67
+ : (mod as Partial<CiderpressConfig>)
68
+ const brand = candidate.brand
69
+ if (brand === undefined) {
70
+ return undefined
71
+ }
72
+ return brand.icon
73
+ }
74
+
75
+ /**
76
+ * Discriminate `IconConfig` into a render-ready shape. Iconify strings get
77
+ * `color: undefined` (driven by ambient CSS); image objects supply alt
78
+ * defaulted to empty string per the underlying type.
79
+ *
80
+ * @private
81
+ * @param icon - Validated `IconConfig` value
82
+ * @returns Discriminated render shape
83
+ */
84
+ function resolveTopbar(icon: IconConfig): TopbarIcon {
85
+ if (typeof icon === 'string') {
86
+ return { kind: 'iconify', id: icon, color: undefined }
87
+ }
88
+ if (isImageIcon(icon)) {
89
+ return { kind: 'image', src: icon.src, alt: icon.alt ?? '' }
90
+ }
91
+ return { kind: 'iconify', id: icon.id, color: icon.color }
92
+ }
93
+
94
+ /**
95
+ * Build an inline-style object that sets the chip's `color` when the user
96
+ * supplied an explicit palette colour. Iconify renders fills via
97
+ * `currentColor` for monochrome icons, so this is enough to retint them.
98
+ *
99
+ * @private
100
+ * @param color - Optional palette colour token
101
+ * @returns Inline style object or undefined
102
+ */
103
+ function inlineColor(color: string | undefined): React.CSSProperties | undefined {
104
+ if (color === undefined) {
105
+ return undefined
106
+ }
107
+ return { color }
108
+ }
109
+
110
+ /**
111
+ * Type guard for the image-form `IconConfig` object.
112
+ *
113
+ * @private
114
+ * @param icon - Non-string icon config value
115
+ * @returns True when the object carries `src` (image form)
116
+ */
117
+ function isImageIcon(icon: Exclude<IconConfig, string>): icon is IconImage {
118
+ return 'src' in icon && typeof icon.src === 'string'
119
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Sizing for the brand mark rendered inside `cp-header-logo` by
3
+ * `<HeaderLogo />`.
4
+ *
5
+ * The parent `.cp-header-logo` already sets `--cp-header-logo-height`
6
+ * (default 28px) and `width: auto` for inline SVGs. Apply matching
7
+ * rules to image marks so a user `logo: '/logo.svg'` lines up exactly
8
+ * with the SVG wordmark height — no extra config needed.
9
+ */
10
+
11
+ .cp-header-logo__img,
12
+ .cp-header-logo__mark {
13
+ height: var(--cp-header-logo-height, 28px);
14
+ width: auto;
15
+ display: block;
16
+ }
@@ -0,0 +1,200 @@
1
+ /* oxlint-disable no-ternary -- raw-copied file; relaxed rules per packages/ui/CLAUDE.md */
2
+ import type { CiderpressConfig, LogoFn, LogoImage } from '@ciderpress/config'
3
+ // oxlint-disable-next-line import/no-unresolved -- alias provided by createRspressConfig's resolve.alias
4
+ import userConfigModule from '@ciderpress/internal/user-config'
5
+ import { BRAND_COLORS, DEFAULT_THEME_NAME } from '@ciderpress/theme'
6
+ import React, { useEffect, useState } from 'react'
7
+
8
+ import { withMountBase } from '../../lib/with-mount-base.ts'
9
+
10
+ import './header-logo.css'
11
+
12
+ interface ThemeContext {
13
+ readonly name: string
14
+ readonly variant: 'light' | 'dark'
15
+ readonly isDark: boolean
16
+ readonly colors: {
17
+ readonly brand: string
18
+ readonly brandHover: string
19
+ readonly brandSoft: string
20
+ readonly bg: string
21
+ readonly text: string
22
+ }
23
+ }
24
+
25
+ const COLOR_VARS = Object.freeze({
26
+ brand: '--rp-c-brand',
27
+ brandHover: '--rp-c-brand-dark',
28
+ brandSoft: '--rp-c-brand-tint',
29
+ bg: '--rp-c-bg',
30
+ text: '--rp-c-text-1',
31
+ })
32
+
33
+ const DEFAULT_BRAND = BRAND_COLORS[DEFAULT_THEME_NAME]
34
+ const FALLBACK_COLORS = Object.freeze({
35
+ brand: DEFAULT_BRAND.primary,
36
+ brandHover: DEFAULT_BRAND.hover,
37
+ brandSoft: DEFAULT_BRAND.soft,
38
+ bg: '#ffffff',
39
+ text: '#1f2937',
40
+ })
41
+
42
+ /**
43
+ * Brand mark rendered inside `<RouteLink className="cp-header-logo">`
44
+ * by `<CiderpressHeader />`.
45
+ *
46
+ * Reads `userConfig.brand.logo` from the bundled user config (same
47
+ * `@ciderpress/internal/user-config` alias `NavLogo` uses) and renders:
48
+ *
49
+ * - `string` → `<img src={...}>` pointed at the user's asset path.
50
+ * - `LogoFn` → call with the live theme context. `LogoImage` returns
51
+ * render as `<img>`; React node returns render as-is. Re-renders
52
+ * when `data-cp-theme` / `data-cp-variant` flips so theme-aware
53
+ * logos retint without a reload.
54
+ * - missing → the framework's themed `<CiderpressLogo />` wordmark.
55
+ *
56
+ * Replaces the prior approach where the header was hardcoded to
57
+ * `<CiderpressLogo />` and the user's logo was forwarded only to
58
+ * Rspress's nav slot — which renders at 1×1 on landing pages.
59
+ *
60
+ * @returns Branded logo element ready to drop inside `cp-header-logo`
61
+ */
62
+ export function HeaderLogo(): React.ReactElement | null {
63
+ const logoConfig = readLogoConfig(userConfigModule)
64
+ const [themeContext, setThemeContext] = useState<ThemeContext | null>(null)
65
+
66
+ useEffect(() => {
67
+ if (typeof logoConfig !== 'function') {
68
+ return
69
+ }
70
+ const html = globalThis.document.documentElement
71
+ setThemeContext(readThemeContext(html))
72
+
73
+ const observer = new MutationObserver(() => {
74
+ setThemeContext(readThemeContext(html))
75
+ })
76
+ observer.observe(html, {
77
+ attributes: true,
78
+ attributeFilter: ['data-cp-theme', 'data-cp-variant', 'class', 'style'],
79
+ })
80
+ return () => observer.disconnect()
81
+ }, [logoConfig])
82
+
83
+ if (logoConfig === undefined) {
84
+ // Default: the auto-generated `/logo.svg` written to the public dir
85
+ // by the banner module at sync time (derived from `config.title`).
86
+ // Sites that committed their own `public/logo.svg` already win here.
87
+ // The `<CiderpressLogo />` framework wordmark is opt-in via
88
+ // `logo: ({ theme }) => <CiderpressLogo />`.
89
+ return <img src={withMountBase('/logo.svg')} alt="" className="cp-header-logo__img" />
90
+ }
91
+
92
+ if (typeof logoConfig === 'string') {
93
+ return <img src={withMountBase(logoConfig)} alt="" className="cp-header-logo__img" />
94
+ }
95
+
96
+ if (themeContext === null) {
97
+ // Function-form logo: render nothing for a frame instead of flashing
98
+ // the ciderpress wordmark while themeContext resolves. The user's
99
+ // logo appears as soon as the layout effect runs.
100
+ return null
101
+ }
102
+
103
+ const result = logoConfig({ theme: themeContext })
104
+ if (isLogoImage(result)) {
105
+ return (
106
+ <img
107
+ src={withMountBase(result.src)}
108
+ alt={result.alt ?? ''}
109
+ width={result.width}
110
+ height={result.height}
111
+ className="cp-header-logo__img"
112
+ />
113
+ )
114
+ }
115
+ return <>{result as React.ReactNode}</>
116
+ }
117
+
118
+ export { HeaderLogo as default }
119
+
120
+ /**
121
+ * Extract the `logo` field from the bundled user config module.
122
+ *
123
+ * @private
124
+ * @param mod - Module imported from `@ciderpress/internal/user-config`
125
+ * @returns The `logo` value or `undefined` when none is configured
126
+ */
127
+ function readLogoConfig(mod: unknown): string | LogoFn | undefined {
128
+ if (mod === null || mod === undefined) {
129
+ return undefined
130
+ }
131
+ const asRecord = mod as Record<string, unknown>
132
+ const candidate =
133
+ asRecord.default !== null && asRecord.default !== undefined
134
+ ? (asRecord.default as Partial<CiderpressConfig>)
135
+ : (mod as Partial<CiderpressConfig>)
136
+ const brand = candidate.brand
137
+ if (brand === undefined) {
138
+ return undefined
139
+ }
140
+ const { logo } = brand
141
+ if (typeof logo === 'string') {
142
+ return logo
143
+ }
144
+ if (typeof logo === 'function') {
145
+ return logo
146
+ }
147
+ return undefined
148
+ }
149
+
150
+ /**
151
+ * Type guard distinguishing a `LogoImage` object from a React element.
152
+ *
153
+ * @private
154
+ * @param value - Return value from a `LogoFn`
155
+ * @returns True when `value` should be spread onto an `<img>` element
156
+ */
157
+ function isLogoImage(value: unknown): value is LogoImage {
158
+ if (value === null || typeof value !== 'object') {
159
+ return false
160
+ }
161
+ const obj = value as Record<string, unknown>
162
+ if ('$$typeof' in obj) {
163
+ return false
164
+ }
165
+ return typeof obj.src === 'string'
166
+ }
167
+
168
+ /**
169
+ * Build the live `ThemeContext` passed to a function-form `logo`.
170
+ *
171
+ * @private
172
+ * @param html - Document root element
173
+ * @returns Live theme context snapshot
174
+ */
175
+ function readThemeContext(html: HTMLElement): ThemeContext {
176
+ const variant: 'light' | 'dark' = html.dataset.cpVariant === 'light' ? 'light' : 'dark'
177
+ const name = typeof html.dataset.cpTheme === 'string' ? html.dataset.cpTheme : DEFAULT_THEME_NAME
178
+
179
+ const styles = globalThis.window.getComputedStyle(html)
180
+ function read(cssVar: string, fallback: string): string {
181
+ const raw = styles.getPropertyValue(cssVar).trim()
182
+ if (raw.length === 0) {
183
+ return fallback
184
+ }
185
+ return raw
186
+ }
187
+
188
+ return {
189
+ name,
190
+ variant,
191
+ isDark: variant === 'dark',
192
+ colors: {
193
+ brand: read(COLOR_VARS.brand, FALLBACK_COLORS.brand),
194
+ brandHover: read(COLOR_VARS.brandHover, FALLBACK_COLORS.brandHover),
195
+ brandSoft: read(COLOR_VARS.brandSoft, FALLBACK_COLORS.brandSoft),
196
+ bg: read(COLOR_VARS.bg, FALLBACK_COLORS.bg),
197
+ text: read(COLOR_VARS.text, FALLBACK_COLORS.text),
198
+ },
199
+ }
200
+ }