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

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 (106) 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 +335 -51
  5. package/dist/plugins/mermaid/MermaidRenderer.tsx +49 -6
  6. package/dist/theme/components/footer/site-footer.tsx +68 -9
  7. package/dist/theme/components/home/feature-card.tsx +5 -8
  8. package/dist/theme/components/home/feature.tsx +31 -11
  9. package/dist/theme/components/home/hero-demo-custom.tsx +116 -0
  10. package/dist/theme/components/home/hero-demo.css +27 -0
  11. package/dist/theme/components/home/layout.tsx +149 -26
  12. package/dist/theme/components/home/split-visual-custom.tsx +26 -0
  13. package/dist/theme/components/home/workspaces.tsx +39 -3
  14. package/dist/theme/components/nav/branch-tag.tsx +7 -7
  15. package/dist/theme/components/nav/ciderpress-docs-bar.css +13 -1
  16. package/dist/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  17. package/dist/theme/components/nav/ciderpress-header.css +2 -1
  18. package/dist/theme/components/nav/ciderpress-header.tsx +26 -3
  19. package/dist/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  20. package/dist/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  21. package/dist/theme/components/nav/ciderpress-nav-menu.css +88 -0
  22. package/dist/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  23. package/dist/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  24. package/dist/theme/components/nav/floating-branch-indicator.tsx +7 -7
  25. package/dist/theme/components/nav/header-icon.css +24 -0
  26. package/dist/theme/components/nav/header-icon.tsx +119 -0
  27. package/dist/theme/components/nav/header-logo.css +16 -0
  28. package/dist/theme/components/nav/header-logo.tsx +200 -0
  29. package/dist/theme/components/nav/layout.tsx +117 -16
  30. package/dist/theme/components/nav/nav-logo.tsx +16 -19
  31. package/dist/theme/components/openapi/index.ts +0 -2
  32. package/dist/theme/components/openapi/openapi.css +0 -14
  33. package/dist/theme/components/shared/card-icon.tsx +37 -0
  34. package/dist/theme/components/shared/ciderpress-logo.tsx +5 -1
  35. package/dist/theme/components/shared/icon.tsx +120 -35
  36. package/dist/theme/components/shared/resolve-card-icon.ts +53 -12
  37. package/dist/theme/components/shared/section-card.tsx +37 -6
  38. package/dist/theme/components/sidebar/sidebar-badge.css +50 -0
  39. package/dist/theme/components/sidebar/sidebar-badge.tsx +153 -0
  40. package/dist/theme/components/sidebar/sidebar-links.tsx +6 -2
  41. package/dist/theme/components/sidebar/sidebar-scope.tsx +24 -1
  42. package/dist/theme/components/theme-provider.tsx +27 -11
  43. package/dist/theme/components/workspaces/card.tsx +7 -9
  44. package/dist/theme/hooks/use-ciderpress.ts +65 -5
  45. package/dist/theme/hooks/use-nav-items.ts +82 -11
  46. package/dist/theme/index.tsx +5 -3
  47. package/dist/theme/lib/read-social-links.ts +4 -3
  48. package/dist/theme/lib/theme-favicon.ts +1 -1
  49. package/dist/theme/lib/with-mount-base.ts +31 -0
  50. package/dist/theme/styles/overrides/footnotes.css +54 -0
  51. package/dist/theme/styles/overrides/lists.css +83 -0
  52. package/dist/theme/styles/overrides/rail.css +27 -10
  53. package/dist/theme/styles/overrides/section-card.css +9 -0
  54. package/dist/theme/styles/overrides/sidebar.css +22 -0
  55. package/package.json +18 -16
  56. package/src/theme/components/footer/site-footer.tsx +68 -9
  57. package/src/theme/components/home/feature-card.tsx +5 -8
  58. package/src/theme/components/home/feature.tsx +31 -11
  59. package/src/theme/components/home/hero-demo-custom.tsx +116 -0
  60. package/src/theme/components/home/hero-demo.css +27 -0
  61. package/src/theme/components/home/layout.tsx +149 -26
  62. package/src/theme/components/home/split-visual-custom.tsx +26 -0
  63. package/src/theme/components/home/workspaces.tsx +39 -3
  64. package/src/theme/components/nav/branch-tag.tsx +7 -7
  65. package/src/theme/components/nav/ciderpress-docs-bar.css +13 -1
  66. package/src/theme/components/nav/ciderpress-docs-bar.tsx +33 -2
  67. package/src/theme/components/nav/ciderpress-header.css +2 -1
  68. package/src/theme/components/nav/ciderpress-header.tsx +26 -3
  69. package/src/theme/components/nav/ciderpress-nav-hamburger.css +50 -0
  70. package/src/theme/components/nav/ciderpress-nav-hamburger.tsx +95 -9
  71. package/src/theme/components/nav/ciderpress-nav-menu.css +88 -0
  72. package/src/theme/components/nav/ciderpress-nav-menu.tsx +397 -38
  73. package/src/theme/components/nav/ciderpress-nav-social-links.tsx +27 -22
  74. package/src/theme/components/nav/floating-branch-indicator.tsx +7 -7
  75. package/src/theme/components/nav/header-icon.css +24 -0
  76. package/src/theme/components/nav/header-icon.tsx +119 -0
  77. package/src/theme/components/nav/header-logo.css +16 -0
  78. package/src/theme/components/nav/header-logo.tsx +200 -0
  79. package/src/theme/components/nav/layout.tsx +117 -16
  80. package/src/theme/components/nav/nav-logo.tsx +16 -19
  81. package/src/theme/components/openapi/index.ts +0 -2
  82. package/src/theme/components/openapi/openapi.css +0 -14
  83. package/src/theme/components/shared/card-icon.tsx +37 -0
  84. package/src/theme/components/shared/ciderpress-logo.tsx +5 -1
  85. package/src/theme/components/shared/icon.tsx +120 -35
  86. package/src/theme/components/shared/resolve-card-icon.ts +53 -12
  87. package/src/theme/components/shared/section-card.tsx +37 -6
  88. package/src/theme/components/sidebar/sidebar-badge.css +50 -0
  89. package/src/theme/components/sidebar/sidebar-badge.tsx +153 -0
  90. package/src/theme/components/sidebar/sidebar-links.tsx +6 -2
  91. package/src/theme/components/sidebar/sidebar-scope.tsx +24 -1
  92. package/src/theme/components/theme-provider.tsx +27 -11
  93. package/src/theme/components/workspaces/card.tsx +7 -9
  94. package/src/theme/hooks/use-ciderpress.ts +65 -5
  95. package/src/theme/hooks/use-nav-items.ts +82 -11
  96. package/src/theme/index.tsx +5 -3
  97. package/src/theme/lib/read-social-links.ts +4 -3
  98. package/src/theme/lib/theme-favicon.ts +1 -1
  99. package/src/theme/lib/with-mount-base.ts +31 -0
  100. package/src/theme/styles/overrides/footnotes.css +54 -0
  101. package/src/theme/styles/overrides/lists.css +83 -0
  102. package/src/theme/styles/overrides/rail.css +27 -10
  103. package/src/theme/styles/overrides/section-card.css +9 -0
  104. package/src/theme/styles/overrides/sidebar.css +22 -0
  105. package/dist/theme/components/openapi/copy-markdown-button.tsx +0 -41
  106. package/src/theme/components/openapi/copy-markdown-button.tsx +0 -41
@@ -1,41 +1,126 @@
1
- import catppuccin from '@iconify-json/catppuccin/icons.json' with { type: 'json' }
2
- import devicon from '@iconify-json/devicon/icons.json' with { type: 'json' }
3
- import logos from '@iconify-json/logos/icons.json' with { type: 'json' }
4
- import materialIconTheme from '@iconify-json/material-icon-theme/icons.json' with { type: 'json' }
5
- import mdi from '@iconify-json/mdi/icons.json' with { type: 'json' }
6
- import pixelarticons from '@iconify-json/pixelarticons/icons.json' with { type: 'json' }
7
- import simpleIcons from '@iconify-json/simple-icons/icons.json' with { type: 'json' }
8
- import skillIcons from '@iconify-json/skill-icons/icons.json' with { type: 'json' }
9
- import vscodeIcons from '@iconify-json/vscode-icons/icons.json' with { type: 'json' }
10
- import { addCollection, Icon } from '@iconify/react'
11
-
12
- // Register all icon collections for offline Iconify resolution.
13
- // `addCollection` is called purely for its side effect of mutating
14
- // Iconify's internal registry. Holding the return values in a
15
- // throwaway `const` keeps the call list a single expression statement
16
- // rather than nine misleading named exports.
17
- // oxlint-disable-next-line no-unused-vars
18
- const _iconCollectionsLoaded = [
19
- addCollection(cast(pixelarticons)),
20
- addCollection(cast(devicon)),
21
- addCollection(cast(mdi)),
22
- addCollection(cast(simpleIcons)),
23
- addCollection(cast(skillIcons)),
24
- addCollection(cast(catppuccin)),
25
- addCollection(cast(logos)),
26
- addCollection(cast(vscodeIcons)),
27
- addCollection(cast(materialIconTheme)),
28
- ] as const
29
-
30
- export { Icon }
1
+ import { addCollection, Icon as IconifyIcon } from '@iconify/react'
2
+ import type { IconProps } from '@iconify/react'
3
+ import type React from 'react'
4
+ import { useEffect, useState } from 'react'
31
5
 
32
6
  /**
33
- * Cast an icon JSON import to the type expected by `addCollection`.
7
+ * Per-collection lazy loaders keyed by Iconify prefix.
8
+ *
9
+ * Each entry is a bare dynamic `import()` so the consuming site's Rsbuild
10
+ * build emits **one async chunk per collection** instead of folding all ten
11
+ * `icons.json` files into a single eager ~30MB chunk pulled on every route.
12
+ * Two consequences fall out of that:
13
+ *
14
+ * - **Deployability** — the largest collection (`logos`, ~8MB) stays well
15
+ * under per-file host caps (Cloudflare Pages rejects files >25MB), where
16
+ * the combined blob failed outright.
17
+ * - **Performance** — a page only downloads the collections it actually
18
+ * references, not the full set on first paint.
19
+ *
20
+ * The specifiers are string literals (not computed) so the bundler can
21
+ * statically resolve every chunk at build time.
22
+ *
23
+ * @private
24
+ */
25
+ const COLLECTION_LOADERS: Record<string, () => Promise<{ readonly default: unknown }>> = {
26
+ catppuccin: () => import('@iconify-json/catppuccin/icons.json'),
27
+ devicon: () => import('@iconify-json/devicon/icons.json'),
28
+ logos: () => import('@iconify-json/logos/icons.json'),
29
+ 'material-icon-theme': () => import('@iconify-json/material-icon-theme/icons.json'),
30
+ mdi: () => import('@iconify-json/mdi/icons.json'),
31
+ pixel: () => import('@iconify-json/pixel/icons.json'),
32
+ pixelarticons: () => import('@iconify-json/pixelarticons/icons.json'),
33
+ 'simple-icons': () => import('@iconify-json/simple-icons/icons.json'),
34
+ 'skill-icons': () => import('@iconify-json/skill-icons/icons.json'),
35
+ 'vscode-icons': () => import('@iconify-json/vscode-icons/icons.json'),
36
+ }
37
+
38
+ /**
39
+ * Cache of in-flight / settled collection registrations keyed by prefix.
40
+ * Guarantees each collection's chunk is fetched and merged into Iconify's
41
+ * registry exactly once, regardless of how many `<Icon>` instances on a
42
+ * page reference it.
43
+ *
44
+ * @private
45
+ */
46
+ const collectionCache = new Map<string, Promise<void>>()
47
+
48
+ /**
49
+ * Offline-registered Iconify icon.
50
+ *
51
+ * Renders `@iconify/react`'s `Icon` unchanged, but registers the icon's
52
+ * collection on demand: the first time a prefix is seen the matching
53
+ * `@iconify-json` chunk is dynamically imported and merged into Iconify's
54
+ * registry, then a re-render paints the resolved SVG. Because `IconifyIcon`
55
+ * reads the live registry on every render, an icon appears as soon as its
56
+ * collection chunk resolves.
57
+ *
58
+ * @param props - Standard `@iconify/react` icon props; `icon` is the
59
+ * `prefix:name` identifier (e.g. `devicon:typescript`)
60
+ * @returns The Iconify icon element
61
+ */
62
+ export function Icon(props: IconProps): React.ReactElement {
63
+ const prefix = resolvePrefix(props.icon)
64
+ const [, markRegistered] = useState(false)
65
+
66
+ useEffect(() => {
67
+ ensureCollection(prefix).then(() => markRegistered(true))
68
+ }, [prefix])
69
+
70
+ return <IconifyIcon {...props} />
71
+ }
72
+
73
+ /**
74
+ * Dynamically import and register the collection for a prefix, once.
75
+ *
76
+ * Returns the cached registration promise on repeat calls so the chunk is
77
+ * fetched a single time. Unknown prefixes (no bundled collection) resolve
78
+ * immediately — `IconifyIcon` falls back to its own resolution for those.
79
+ *
80
+ * @private
81
+ * @param prefix - Iconify collection prefix (e.g. `logos`)
82
+ * @returns Promise that settles once the collection is registered
83
+ */
84
+ function ensureCollection(prefix: string): Promise<void> {
85
+ const cached = collectionCache.get(prefix)
86
+ if (cached !== undefined) {
87
+ return cached
88
+ }
89
+ const loader = COLLECTION_LOADERS[prefix]
90
+ if (loader === undefined) {
91
+ return Promise.resolve()
92
+ }
93
+ const registration = loader().then(registerModule)
94
+ collectionCache.set(prefix, registration)
95
+ return registration
96
+ }
97
+
98
+ /**
99
+ * Merge a dynamically imported `icons.json` module into Iconify's registry.
100
+ *
101
+ * @private
102
+ * @param mod - Module namespace whose `default` export is the collection JSON
103
+ */
104
+ function registerModule(mod: { readonly default: unknown }): void {
105
+ addCollection(mod.default as Parameters<typeof addCollection>[0])
106
+ }
107
+
108
+ /**
109
+ * Extract the collection prefix from an Iconify identifier. Non-string icon
110
+ * inputs and identifiers without a `prefix:name` shape yield an empty string,
111
+ * which `ensureCollection` treats as "nothing to load".
34
112
  *
35
113
  * @private
36
- * @param v - Raw icon JSON import
37
- * @returns Value cast to the addCollection parameter type
114
+ * @param icon - The `icon` prop passed to `<Icon>`
115
+ * @returns The collection prefix, or `''` when none can be determined
38
116
  */
39
- function cast(v: unknown): Parameters<typeof addCollection>[0] {
40
- return v as Parameters<typeof addCollection>[0]
117
+ function resolvePrefix(icon: IconProps['icon']): string {
118
+ if (typeof icon !== 'string') {
119
+ return ''
120
+ }
121
+ const parts = icon.split(':')
122
+ if (parts.length < 2) {
123
+ return ''
124
+ }
125
+ return parts[0]
41
126
  }
@@ -1,28 +1,69 @@
1
+ import type { SerializedIcon } from '@ciderpress/config'
2
+ import { match } from 'massaman/match'
3
+
4
+ /**
5
+ * Render-ready image icon — `src` plus `alt`.
6
+ */
7
+ export interface ResolvedCardImageIcon {
8
+ readonly kind: 'image'
9
+ readonly src: string
10
+ readonly alt: string
11
+ }
12
+
1
13
  /**
2
- * Resolved icon with id and color.
14
+ * Render-ready Iconify icon — `id` plus a colour-rotation key.
3
15
  */
4
- export interface ResolvedCardIcon {
16
+ export interface ResolvedCardIconifyIcon {
17
+ readonly kind: 'iconify'
5
18
  readonly id: string
6
19
  readonly color: string
7
20
  }
8
21
 
9
22
  /**
10
- * Resolve a unified icon config into id + color.
23
+ * Discriminated union of resolved card-icon variants. Render sites
24
+ * switch on `kind` to decide between an Iconify `<Icon>` and an `<img>`.
25
+ */
26
+ export type ResolvedCardIcon = ResolvedCardIconifyIcon | ResolvedCardImageIcon
27
+
28
+ /**
29
+ * Card-icon input shape — the `SerializedIcon` shape emitted by the
30
+ * sync engine (workspaces, sections, features), re-exported here under
31
+ * a local alias to keep render-site call signatures readable. Always
32
+ * comes from `@ciderpress/config` — there is no separate source of
33
+ * truth.
34
+ */
35
+ export type CardIconInput = SerializedIcon | undefined
36
+
37
+ /**
38
+ * Resolve a serialized icon value into a discriminated icon ready for
39
+ * render.
11
40
  *
12
- * String icons get the default `'purple'` color. Object icons pass through.
13
- * Returns `undefined` for undefined input.
41
+ * - `string` → `{ kind: 'iconify', id, color: 'purple' }`
42
+ * - `{ id, color }` → `{ kind: 'iconify', id, color }`
43
+ * - `{ kind: 'image', src, alt }` → pass-through
44
+ * - `undefined` → `undefined`
14
45
  *
15
- * @param icon - Icon config (string, object, or undefined)
16
- * @returns Resolved icon or undefined
46
+ * @param icon - Serialized icon value or `undefined`
47
+ * @returns Discriminated resolved icon, or `undefined`
17
48
  */
18
- export function resolveCardIcon(
19
- icon: string | { readonly id: string; readonly color: string } | undefined
20
- ): ResolvedCardIcon | undefined {
49
+ export function resolveCardIcon(icon: CardIconInput): ResolvedCardIcon | undefined {
21
50
  if (icon === undefined) {
22
51
  return undefined
23
52
  }
24
53
  if (typeof icon === 'string') {
25
- return { id: icon, color: 'purple' }
54
+ return { kind: 'iconify', id: icon, color: 'purple' }
26
55
  }
27
- return icon
56
+ // Match on the discriminant value rather than `'kind' in icon` so a
57
+ // future variant that also carries a `kind` field (or a malformed
58
+ // runtime input) can't silently slip into the image branch.
59
+ return match(icon)
60
+ .with({ kind: 'image' }, (img) => ({
61
+ kind: 'image' as const,
62
+ src: img.src,
63
+ // Defence-in-depth: even if a downstream caller hand-builds the
64
+ // object and forgets `alt`, fall back to an empty string so React
65
+ // never sees `alt={undefined}` (which silences screen readers).
66
+ alt: img.alt ?? '',
67
+ }))
68
+ .otherwise((i) => ({ kind: 'iconify' as const, id: i.id, color: i.color }))
28
69
  }
@@ -1,15 +1,19 @@
1
+ import type { BadgeConfig } from '@ciderpress/config'
1
2
  import { match, P } from 'massaman/match'
2
3
  import type React from 'react'
3
4
 
5
+ import { useCiderpress } from '../../hooks/use-ciderpress'
6
+ import { BadgeChips } from '../sidebar/sidebar-badge'
4
7
  import { Card } from './card'
5
- import { Icon } from './icon'
8
+ import { CardIcon } from './card-icon'
9
+ import type { CardIconInput } from './resolve-card-icon'
6
10
  import { resolveCardIcon } from './resolve-card-icon'
7
11
 
8
12
  export interface SectionCardProps {
9
13
  readonly href: string
10
14
  readonly title: string
11
15
  readonly description?: string
12
- readonly icon?: string | { readonly id: string; readonly color: string }
16
+ readonly icon?: CardIconInput
13
17
  }
14
18
 
15
19
  /**
@@ -25,7 +29,13 @@ export function SectionCard({
25
29
  description,
26
30
  icon = 'pixelarticons:file',
27
31
  }: SectionCardProps): React.ReactElement {
28
- const resolved = resolveCardIcon(icon) ?? { id: 'pixelarticons:file', color: 'purple' }
32
+ const { pageBadges } = useCiderpress()
33
+ const badges = lookupBadges({ pageBadges, href })
34
+ const resolved = resolveCardIcon(icon) ?? {
35
+ kind: 'iconify' as const,
36
+ id: 'pixelarticons:file',
37
+ color: 'purple',
38
+ }
29
39
  const descEl = match(description)
30
40
  .with(P.nonNullable, (d) => <span className="cp-section-card__desc">{d}</span>)
31
41
  .otherwise(() => null)
@@ -33,12 +43,33 @@ export function SectionCard({
33
43
  return (
34
44
  <Card href={href} className="cp-section-card">
35
45
  <div className="cp-section-card__header">
36
- <span className={`cp-section-card__icon cp-section-card__icon--${resolved.color}`}>
37
- <Icon icon={resolved.id} />
38
- </span>
46
+ <CardIcon resolved={resolved} className="cp-section-card__icon" />
39
47
  <span className="cp-section-card__title">{title}</span>
48
+ {badges.length > 0 && (
49
+ <span className="cp-section-card__badges">
50
+ <BadgeChips badges={badges} />
51
+ </span>
52
+ )}
40
53
  </div>
41
54
  {descEl}
42
55
  </Card>
43
56
  )
44
57
  }
58
+
59
+ /**
60
+ * Look up a page's badges from the route→badges map by its href.
61
+ *
62
+ * @private
63
+ * @param params - The route→badges map (if present) and card destination.
64
+ * @returns The page's badges, or an empty array when none apply
65
+ */
66
+ function lookupBadges(params: {
67
+ readonly pageBadges: Record<string, readonly BadgeConfig[]> | undefined
68
+ readonly href: string
69
+ }): readonly BadgeConfig[] {
70
+ const { pageBadges, href } = params
71
+ if (pageBadges === undefined) {
72
+ return []
73
+ }
74
+ return pageBadges[href] ?? []
75
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Sidebar badge chips — small uppercase labels (ALPHA, WIP, …) rendered
3
+ * in a sidebar item's right slot via the `Tag` override.
4
+ */
5
+
6
+ @layer ciderpress.overrides {
7
+ .cp-sbadge {
8
+ display: inline-flex;
9
+ align-items: center;
10
+ padding: 1px 5px;
11
+ border-radius: var(--cp-radius-pill, 9999px);
12
+ font-family: var(--cp-font-family-mono, inherit);
13
+ font-size: 9px;
14
+ font-weight: 600;
15
+ line-height: 1.4;
16
+ letter-spacing: 0.04em;
17
+ text-transform: uppercase;
18
+ white-space: nowrap;
19
+ vertical-align: middle;
20
+ }
21
+
22
+ .cp-sbadge + .cp-sbadge {
23
+ margin-left: 4px;
24
+ }
25
+
26
+ .cp-sbadge--info {
27
+ background: var(--cp-c-badge-info-bg);
28
+ color: var(--cp-c-badge-info-fg);
29
+ }
30
+
31
+ .cp-sbadge--success {
32
+ background: var(--cp-c-badge-success-bg);
33
+ color: var(--cp-c-badge-success-fg);
34
+ }
35
+
36
+ .cp-sbadge--warning {
37
+ background: var(--cp-c-badge-warning-bg);
38
+ color: var(--cp-c-badge-warning-fg);
39
+ }
40
+
41
+ .cp-sbadge--danger {
42
+ background: var(--cp-c-badge-error-bg);
43
+ color: var(--cp-c-badge-error-fg);
44
+ }
45
+
46
+ .cp-sbadge--neutral {
47
+ background: var(--rp-c-bg-mute);
48
+ color: var(--rp-c-text-2);
49
+ }
50
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Sidebar badge rendering — overrides Rspress's `Tag` component.
3
+ *
4
+ * Rspress renders `<Tag tag={...} />` inside every sidebar item. Ciderpress
5
+ * encodes page badges (label + variant + tooltip) into that `tag` string via
6
+ * `@ciderpress/config`'s `encodeBadges`. This override decodes ciderpress
7
+ * badges and renders styled chips; any other tag falls through to Rspress's
8
+ * native `Tag` so existing tag behavior (images, keyword badges) is preserved.
9
+ */
10
+
11
+ import { decodeBadges } from '@ciderpress/config'
12
+ import type { BadgeConfig, BadgeVariant } from '@ciderpress/config'
13
+ import { Tag as RspressTag } from '@rspress/core/theme-original'
14
+ import { match, P } from 'massaman/match'
15
+ import type React from 'react'
16
+
17
+ /**
18
+ * Props mirror Rspress's `Tag` — a single (optional) tag string.
19
+ *
20
+ * @private
21
+ */
22
+ interface TagProps {
23
+ readonly tag?: string
24
+ }
25
+
26
+ /**
27
+ * Sidebar tag renderer. Renders ciderpress badge chips for encoded tags,
28
+ * delegating everything else to Rspress's native `Tag`.
29
+ *
30
+ * @param props - The sidebar item's tag string
31
+ * @returns Badge chips, a native tag, or `null` when there is no tag
32
+ */
33
+ export function Tag({ tag }: TagProps): React.ReactElement | null {
34
+ const badges = decodeTag(tag)
35
+ if (badges === null) {
36
+ return <RspressTag tag={tag} />
37
+ }
38
+ return <BadgeChips badges={badges} />
39
+ }
40
+
41
+ /**
42
+ * Props for {@link BadgeChips}.
43
+ */
44
+ export interface BadgeChipsProps {
45
+ readonly badges: readonly BadgeConfig[]
46
+ }
47
+
48
+ /**
49
+ * Render a list of badge chips (variant color, custom-color tint, hover
50
+ * tooltip). Used by the sidebar `Tag` override and the breadcrumb bar.
51
+ *
52
+ * @param props - The badges to render
53
+ * @returns Chip elements, or `null` when there are no badges
54
+ */
55
+ export function BadgeChips({ badges }: BadgeChipsProps): React.ReactElement | null {
56
+ if (badges.length === 0) {
57
+ return null
58
+ }
59
+ return (
60
+ <>
61
+ {badges.map((badge, index) => (
62
+ <SidebarBadge key={`${badge.text}-${String(index)}`} badge={badge} />
63
+ ))}
64
+ </>
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Decode a tag string into ciderpress badges, or `null` when the tag is
70
+ * absent or not ciderpress-encoded.
71
+ *
72
+ * @private
73
+ * @param tag - Raw tag string from the sidebar item
74
+ * @returns Decoded badges, or `null` to signal native fallback
75
+ */
76
+ function decodeTag(tag: string | undefined): readonly BadgeConfig[] | null {
77
+ if (tag === undefined) {
78
+ return null
79
+ }
80
+ return decodeBadges(tag)
81
+ }
82
+
83
+ /**
84
+ * A single sidebar badge chip.
85
+ *
86
+ * @private
87
+ */
88
+ interface SidebarBadgeProps {
89
+ readonly badge: BadgeConfig
90
+ }
91
+
92
+ /**
93
+ * Render one badge chip with variant color (or a custom color tint) and a
94
+ * hover tooltip.
95
+ *
96
+ * @private
97
+ * @param props - The badge to render
98
+ * @returns A styled badge chip element
99
+ */
100
+ function SidebarBadge({ badge }: SidebarBadgeProps): React.ReactElement {
101
+ const tooltip = resolveTooltip(badge)
102
+ return match(badge.color)
103
+ .with(P.string, (color) => (
104
+ <span className="cp-sbadge" style={{ color, backgroundColor: tint(color) }} title={tooltip}>
105
+ {badge.text}
106
+ </span>
107
+ ))
108
+ .otherwise(() => (
109
+ <span className={variantClass(badge.variant)} title={tooltip}>
110
+ {badge.text}
111
+ </span>
112
+ ))
113
+ }
114
+
115
+ /**
116
+ * Resolve the hover tooltip for a badge, defaulting to its label text.
117
+ *
118
+ * @private
119
+ * @param badge - The badge to resolve a tooltip for
120
+ * @returns The tooltip string
121
+ */
122
+ function resolveTooltip(badge: BadgeConfig): string {
123
+ return badge.tooltip ?? badge.text
124
+ }
125
+
126
+ /**
127
+ * Map a badge variant to its chip class, defaulting to `neutral`.
128
+ *
129
+ * @private
130
+ * @param variant - The badge variant, if any
131
+ * @returns The full class string for the chip
132
+ */
133
+ function variantClass(variant: BadgeVariant | undefined): string {
134
+ return match(variant)
135
+ .with('info', () => 'cp-sbadge cp-sbadge--info')
136
+ .with('success', () => 'cp-sbadge cp-sbadge--success')
137
+ .with('warning', () => 'cp-sbadge cp-sbadge--warning')
138
+ .with('danger', () => 'cp-sbadge cp-sbadge--danger')
139
+ .with('neutral', () => 'cp-sbadge cp-sbadge--neutral')
140
+ .with(undefined, () => 'cp-sbadge cp-sbadge--neutral')
141
+ .exhaustive()
142
+ }
143
+
144
+ /**
145
+ * Build a subtle background tint from a custom color.
146
+ *
147
+ * @private
148
+ * @param color - CSS color value
149
+ * @returns A `color-mix` background at low opacity
150
+ */
151
+ function tint(color: string): string {
152
+ return `color-mix(in srgb, ${color} 14%, transparent)`
153
+ }
@@ -1,3 +1,4 @@
1
+ import type { SerializedIcon } from '@ciderpress/config'
1
2
  import { Link } from '@rspress/core/runtime'
2
3
  import { match, P } from 'massaman/match'
3
4
  import type React from 'react'
@@ -10,7 +11,7 @@ import './sidebar-links.css'
10
11
  interface SidebarLinkItem {
11
12
  readonly text: string
12
13
  readonly link: string
13
- readonly icon?: string | { readonly id: string; readonly color: string }
14
+ readonly icon?: SerializedIcon
14
15
  readonly style?: 'brand' | 'alt' | 'ghost'
15
16
  readonly shape?: 'square' | 'rounded' | 'circle'
16
17
  }
@@ -51,7 +52,10 @@ export function SidebarLinks(props: SidebarLinksProps): React.ReactElement | nul
51
52
  function renderIcon(icon: SidebarLinkItem['icon']): React.ReactElement | null {
52
53
  return match(icon)
53
54
  .with(P.string, (id) => <Icon icon={id} className="cp-sidebar-link-icon" />)
54
- .with({ id: P.string, color: P.string }, (i) => (
55
+ .with({ kind: 'image' }, (img) => (
56
+ <img src={img.src} alt={img.alt} className="cp-sidebar-link-icon cp-sidebar-link-icon--img" />
57
+ ))
58
+ .with({ id: P.string }, (i) => (
55
59
  <Icon icon={i.id} className="cp-sidebar-link-icon" style={{ color: i.color }} />
56
60
  ))
57
61
  .otherwise(() => null)
@@ -14,7 +14,7 @@ import type { SidebarData } from '@rspress/core'
14
14
  import { useActiveMatcher, useLocation, useSidebar } from '@rspress/core/runtime'
15
15
  import { SidebarList } from '@rspress/core/theme-original'
16
16
  import type React from 'react'
17
- import { useLayoutEffect, useMemo, useState } from 'react'
17
+ import { useEffect, useLayoutEffect, useMemo, useState } from 'react'
18
18
 
19
19
  import { useCiderpress } from '../../hooks/use-ciderpress'
20
20
  import { resolveScopedSidebar } from './sidebar-filter'
@@ -59,9 +59,32 @@ export function Sidebar(): React.ReactElement {
59
59
  setSidebarData(initializeCollapsed(filteredData, activeMatcher))
60
60
  }, [activeMatcher, filteredData])
61
61
 
62
+ useEffect(() => {
63
+ decorateOverflowTitles()
64
+ }, [sidebarData])
65
+
62
66
  return <SidebarList sidebarData={sidebarData} setSidebarData={setSidebarData} />
63
67
  }
64
68
 
69
+ /**
70
+ * Add a `title` attribute to sidebar labels that overflow their column so
71
+ * the full text shows on hover; clear it when the label fits. Runs after
72
+ * render because overflow is a measured, layout-dependent property.
73
+ *
74
+ * @private
75
+ */
76
+ function decorateOverflowTitles(): void {
77
+ const labels = [...document.querySelectorAll<HTMLElement>('.rp-sidebar-item__left > .rp-doc')]
78
+ labels.reduce<null>((_, label) => {
79
+ if (label.scrollWidth > label.clientWidth) {
80
+ label.setAttribute('title', label.textContent ?? '')
81
+ } else {
82
+ label.removeAttribute('title')
83
+ }
84
+ return null
85
+ }, null)
86
+ }
87
+
65
88
  /**
66
89
  * Walk the sidebar tree and uncollapse groups that contain the active path.
67
90
  *
@@ -11,6 +11,8 @@ declare const __CIDERPRESS_DEFAULT_VARIANT__: string
11
11
  declare const __CIDERPRESS_THEME_COLORS__: string
12
12
  declare const __CIDERPRESS_THEME_DARK_COLORS__: string
13
13
  declare const __CIDERPRESS_THEME_REGISTRY__: string
14
+ declare const __CIDERPRESS_HAS_USER_FAVICON__: boolean
15
+ declare const __CIDERPRESS_LOADER_MIN_MS__: number
14
16
 
15
17
  interface RegistryEntry {
16
18
  readonly name: string
@@ -26,7 +28,7 @@ type ThemeColorKey = keyof ThemeColors
26
28
 
27
29
  /**
28
30
  * Parsed theme registry — built-in themes plus any user themes from
29
- * `config.themes`. Read from the build-time define so the client bundle
31
+ * `theme.themes`. Read from the build-time define so the client bundle
30
32
  * does not pull `@ciderpress/theme`'s factory + Zod into the runtime path.
31
33
  */
32
34
  const REGISTRY_ENTRIES: readonly RegistryEntry[] = parseRegistry(__CIDERPRESS_THEME_REGISTRY__)
@@ -123,9 +125,11 @@ const COLOR_VAR_MAP: Readonly<Record<ThemeColorKey, readonly string[]>> = Object
123
125
  const ALL_CSS_VARS: readonly string[] = Object.values(COLOR_VAR_MAP).flat()
124
126
 
125
127
  /**
126
- * Minimum time (ms) the loading overlay stays visible before fading out.
128
+ * Minimum time (ms) the loading overlay stays visible before fading
129
+ * out. Resolved at build time from `brand.loader.minDisplayMs` —
130
+ * defaults to 150ms in `packages/ui/src/config.ts`.
127
131
  */
128
- const LOADER_MIN_DISPLAY_MS = 150
132
+ const LOADER_MIN_DISPLAY_MS = __CIDERPRESS_LOADER_MIN_MS__
129
133
 
130
134
  /**
131
135
  * Duration (ms) of the CSS fade-out transition. Must match the
@@ -199,12 +203,14 @@ export function ThemeProvider(): React.ReactElement | null {
199
203
  applyColorOverrides(html, darkColors)
200
204
  }
201
205
 
202
- // Sync the document favicon with the active theme's brand colour.
203
- // Browsers cache static icon assets and ignore CSS, so the only way to
204
- // keep the tab mark in lockstep with the chosen theme is to swap
205
- // `<link rel="icon">` to a data-URI SVG carrying the resolved
206
- // `--cp-c-brand-1`.
207
- syncThemeFavicon(html)
206
+ // Sync the document favicon with the active theme's brand colour —
207
+ // only when the user has NOT set their own `favicon` in config. When
208
+ // they have, Rspress already points `<link rel="icon">` at the
209
+ // user's asset, and overwriting it with the themed apple data-URI
210
+ // would silently restore ciderpress branding.
211
+ if (!__CIDERPRESS_HAS_USER_FAVICON__) {
212
+ syncThemeFavicon(html)
213
+ }
208
214
 
209
215
  // Observe `.rp-dark` class changes so Rspress's built-in dark toggle
210
216
  // stays the single source of truth for variant flips. The new variant
@@ -253,8 +259,11 @@ export function ThemeProvider(): React.ReactElement | null {
253
259
  }
254
260
  // Re-sync the favicon — brand colour is constant across variants for
255
261
  // built-in themes, but surface overrides on the dark variant can
256
- // affect the chip background colour we bake into the SVG.
257
- syncThemeFavicon(html)
262
+ // affect the chip background colour we bake into the SVG. Skipped
263
+ // when the user supplied their own favicon (see initial sync above).
264
+ if (!__CIDERPRESS_HAS_USER_FAVICON__) {
265
+ syncThemeFavicon(html)
266
+ }
258
267
  })
259
268
  observer.observe(html, { attributes: true, attributeFilter: ['class'] })
260
269
 
@@ -628,6 +637,13 @@ function dismissLoader(html: HTMLElement): () => void {
628
637
  clearDotsInterval()
629
638
  }, LOADER_MIN_DISPLAY_MS + LOADER_FADE_MS)
630
639
 
640
+ // The forced-dismiss fallback (covering the case where React never
641
+ // hydrates) lives in the inline head script — see `buildHeadScriptBody`
642
+ // in `packages/ui/src/config.ts`. That timer always runs, so a
643
+ // duplicate React-side fallback here would only fire AFTER ThemeProvider
644
+ // has already mounted — at which point the normal fade path above has
645
+ // already won.
646
+
631
647
  return () => {
632
648
  clearTimeout(fadeTimer)
633
649
  clearTimeout(removeTimer)