@zpress/ui 0.1.0

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.mjs +75 -0
  3. package/dist/theme/components/home/feature-card.css +89 -0
  4. package/dist/theme/components/home/feature-card.tsx +75 -0
  5. package/dist/theme/components/home/feature.tsx +53 -0
  6. package/dist/theme/components/home/layout.tsx +36 -0
  7. package/dist/theme/components/home/workspaces.tsx +53 -0
  8. package/dist/theme/components/nav/branch-tag.css +54 -0
  9. package/dist/theme/components/nav/branch-tag.tsx +66 -0
  10. package/dist/theme/components/shared/card.tsx +27 -0
  11. package/dist/theme/components/shared/icon.tsx +27 -0
  12. package/dist/theme/components/shared/section-card.tsx +42 -0
  13. package/dist/theme/components/shared/section-grid.tsx +12 -0
  14. package/dist/theme/components/shared/tech-icon-table.tsx +68 -0
  15. package/dist/theme/components/shared/tech-tag.tsx +28 -0
  16. package/dist/theme/components/workspaces/card.css +141 -0
  17. package/dist/theme/components/workspaces/card.tsx +116 -0
  18. package/dist/theme/components/workspaces/grid.tsx +40 -0
  19. package/dist/theme/css.d.ts +1 -0
  20. package/dist/theme/fonts/GeistMono-Variable.woff2 +0 -0
  21. package/dist/theme/fonts/GeistPixel-Square.woff2 +0 -0
  22. package/dist/theme/fonts/GeistSans-Variable.woff2 +0 -0
  23. package/dist/theme/hooks/use-zpress.ts +57 -0
  24. package/dist/theme/icons/index.ts +2 -0
  25. package/dist/theme/icons/tech-map.ts +221 -0
  26. package/dist/theme/index.tsx +46 -0
  27. package/dist/theme/styles/overrides/details.css +61 -0
  28. package/dist/theme/styles/overrides/fonts.css +27 -0
  29. package/dist/theme/styles/overrides/home-card.css +125 -0
  30. package/dist/theme/styles/overrides/home.css +55 -0
  31. package/dist/theme/styles/overrides/rspress.css +108 -0
  32. package/dist/theme/styles/overrides/scrollbar.css +25 -0
  33. package/dist/theme/styles/overrides/section-card.css +115 -0
  34. package/dist/theme/styles/overrides/sidebar.css +9 -0
  35. package/dist/theme/styles/overrides/tokens.css +48 -0
  36. package/package.json +64 -0
  37. package/src/theme/components/home/feature-card.css +89 -0
  38. package/src/theme/components/home/feature-card.tsx +75 -0
  39. package/src/theme/components/home/feature.tsx +53 -0
  40. package/src/theme/components/home/layout.tsx +36 -0
  41. package/src/theme/components/home/workspaces.tsx +53 -0
  42. package/src/theme/components/nav/branch-tag.css +54 -0
  43. package/src/theme/components/nav/branch-tag.tsx +66 -0
  44. package/src/theme/components/shared/card.tsx +27 -0
  45. package/src/theme/components/shared/icon.tsx +27 -0
  46. package/src/theme/components/shared/section-card.tsx +42 -0
  47. package/src/theme/components/shared/section-grid.tsx +12 -0
  48. package/src/theme/components/shared/tech-icon-table.tsx +68 -0
  49. package/src/theme/components/shared/tech-tag.tsx +28 -0
  50. package/src/theme/components/workspaces/card.css +141 -0
  51. package/src/theme/components/workspaces/card.tsx +116 -0
  52. package/src/theme/components/workspaces/grid.tsx +40 -0
  53. package/src/theme/css.d.ts +1 -0
  54. package/src/theme/fonts/GeistMono-Variable.woff2 +0 -0
  55. package/src/theme/fonts/GeistPixel-Square.woff2 +0 -0
  56. package/src/theme/fonts/GeistSans-Variable.woff2 +0 -0
  57. package/src/theme/hooks/use-zpress.ts +57 -0
  58. package/src/theme/icons/index.ts +2 -0
  59. package/src/theme/icons/tech-map.ts +221 -0
  60. package/src/theme/index.tsx +46 -0
  61. package/src/theme/styles/overrides/details.css +61 -0
  62. package/src/theme/styles/overrides/fonts.css +27 -0
  63. package/src/theme/styles/overrides/home-card.css +125 -0
  64. package/src/theme/styles/overrides/home.css +55 -0
  65. package/src/theme/styles/overrides/rspress.css +108 -0
  66. package/src/theme/styles/overrides/scrollbar.css +25 -0
  67. package/src/theme/styles/overrides/section-card.css +115 -0
  68. package/src/theme/styles/overrides/sidebar.css +9 -0
  69. package/src/theme/styles/overrides/tokens.css +48 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Feature Card — layout and typography (base card styles from home-card.css).
3
+ */
4
+
5
+ /* ── Feature grid ───────────────────────────────────────────── */
6
+ .feature-grid {
7
+ --feature-grid-gap: 12px;
8
+ display: flex;
9
+ flex-wrap: wrap;
10
+ gap: var(--feature-grid-gap);
11
+ max-width: 1152px;
12
+ margin: 0 auto;
13
+ padding: 0 0 64px;
14
+ }
15
+
16
+ /* ── Grid item + span system ────────────────────────────────── */
17
+ .feature-card__item {
18
+ width: 100%;
19
+ position: relative;
20
+ }
21
+
22
+ .feature-card__item-wrapper {
23
+ position: relative;
24
+ height: 100%;
25
+ }
26
+
27
+ @media (min-width: 640px) {
28
+ .feature-card__item--span-2,
29
+ .feature-card__item--span-4,
30
+ .feature-card__item--span-6 {
31
+ width: calc(50% - var(--feature-grid-gap));
32
+ }
33
+ }
34
+
35
+ @media (min-width: 768px) {
36
+ .feature-card__item--span-2,
37
+ .feature-card__item--span-4 {
38
+ width: calc(50% - var(--feature-grid-gap));
39
+ }
40
+
41
+ .feature-card__item--span-3,
42
+ .feature-card__item--span-6 {
43
+ width: calc((100% - 2 * var(--feature-grid-gap)) / 3);
44
+ }
45
+ }
46
+
47
+ @media (min-width: 960px) {
48
+ .feature-card__item--span-3 {
49
+ width: calc((100% - 3 * var(--feature-grid-gap)) / 4);
50
+ }
51
+
52
+ .feature-card__item--span-4 {
53
+ width: calc((100% - 2 * var(--feature-grid-gap)) / 3);
54
+ }
55
+
56
+ .feature-card__item--span-6 {
57
+ width: calc(50% - var(--feature-grid-gap));
58
+ }
59
+ }
60
+
61
+ /* ── Feature card layout ───────────────────────────────────── */
62
+ .feature-card {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 10px;
66
+ height: 100%;
67
+ }
68
+
69
+ /* Row 1: icon + title */
70
+ .feature-header {
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 12px;
74
+ }
75
+
76
+ .feature-title {
77
+ font-family: 'Geist Pixel Square', var(--rp-font-family-base, sans-serif);
78
+ font-size: 18px;
79
+ font-weight: 700;
80
+ color: var(--rp-c-text-1, var(--zp-c-text-1, #fbfbfb));
81
+ line-height: 1.4;
82
+ }
83
+
84
+ /* Row 2: description */
85
+ .feature-desc {
86
+ font-size: 16px;
87
+ color: var(--rp-c-text-2, var(--zp-c-text-2, rgba(251, 251, 251, 0.72)));
88
+ line-height: 1.5;
89
+ }
@@ -0,0 +1,75 @@
1
+ import type React from 'react'
2
+ import { match, P } from 'ts-pattern'
3
+
4
+ import './feature-card.css'
5
+ import { Card } from '../shared/card'
6
+
7
+ /**
8
+ * Icon color variants matching the design system.
9
+ */
10
+ export type IconColor = 'purple' | 'blue' | 'green' | 'amber' | 'red' | 'slate' | 'cyan' | 'pink'
11
+
12
+ export interface FeatureCardProps {
13
+ readonly title: string
14
+ readonly description: string
15
+ readonly href?: string
16
+ readonly icon?: React.ReactNode
17
+ readonly iconColor?: IconColor
18
+ readonly span?: 2 | 3 | 4 | 6
19
+ }
20
+
21
+ /**
22
+ * Frontmatter-shaped data for consumers to map from.
23
+ */
24
+ export interface FeatureItem {
25
+ readonly title: string
26
+ readonly details: string
27
+ readonly link?: string
28
+ readonly icon?: string
29
+ readonly iconColor?: IconColor
30
+ readonly span?: 2 | 3 | 4 | 6
31
+ }
32
+
33
+ /**
34
+ * Feature card for landing pages — matches the workspace/section card design.
35
+ * Renders as `<a>` when `href` is provided, `<div>` otherwise.
36
+ */
37
+ export function FeatureCard({
38
+ title,
39
+ description,
40
+ href,
41
+ icon,
42
+ iconColor = 'purple',
43
+ span = 4,
44
+ }: FeatureCardProps): React.ReactElement {
45
+ const iconEl = match(icon)
46
+ .with(P.nonNullable, (ic) => (
47
+ <span className={`home-card-icon home-card-icon--${iconColor}`}>{ic}</span>
48
+ ))
49
+ .otherwise(() => null)
50
+
51
+ return (
52
+ <div className={`feature-card__item feature-card__item--span-${span}`}>
53
+ <div className="feature-card__item-wrapper">
54
+ <Card href={href} className="feature-card">
55
+ <div className="feature-header">
56
+ {iconEl}
57
+ <span className="feature-title">{title}</span>
58
+ </div>
59
+ <span className="feature-desc">{description}</span>
60
+ </Card>
61
+ </div>
62
+ </div>
63
+ )
64
+ }
65
+
66
+ interface FeatureGridProps {
67
+ readonly children: React.ReactNode
68
+ }
69
+
70
+ /**
71
+ * Flex-wrap layout container for feature cards.
72
+ */
73
+ export function FeatureGrid({ children }: FeatureGridProps): React.ReactElement {
74
+ return <div className="feature-grid">{children}</div>
75
+ }
@@ -0,0 +1,53 @@
1
+ import { useFrontmatter } from '@rspress/core/runtime'
2
+ import type React from 'react'
3
+ import { match, P } from 'ts-pattern'
4
+
5
+ import { Icon } from '../shared/icon'
6
+ import { FeatureCard, FeatureGrid } from './feature-card'
7
+ import type { FeatureItem } from './feature-card'
8
+
9
+ // ── Helpers ──────────────────────────────────────────────────
10
+
11
+ /**
12
+ * Render a single feature as a FeatureCard element.
13
+ * Accepts the array index from `.map()` to guarantee unique keys.
14
+ */
15
+ function renderFeature(feature: FeatureItem, index: number): React.ReactElement {
16
+ const iconEl = match(feature.icon)
17
+ .with(P.nonNullable, (iconId) => <Icon icon={iconId} />)
18
+ .otherwise(() => null)
19
+
20
+ return (
21
+ <FeatureCard
22
+ key={`${feature.title}-${index}`}
23
+ title={feature.title}
24
+ description={feature.details}
25
+ href={feature.link}
26
+ icon={iconEl}
27
+ iconColor={feature.iconColor}
28
+ />
29
+ )
30
+ }
31
+
32
+ // ── Component ────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Custom HomeFeature override for zpress.
36
+ * Uses useFrontmatter() hook to read features and renders with FeatureCard/FeatureGrid styling.
37
+ */
38
+ export function HomeFeature(): React.ReactElement | null {
39
+ const { frontmatter } = useFrontmatter()
40
+ // Rspress types frontmatter as its own FrontMatterMeta shape which does not
41
+ // include zpress-specific `features`. The double cast is necessary because
42
+ // no shared Zod schema exists for frontmatter validation at runtime.
43
+ const features = (frontmatter as Record<string, unknown>).features as
44
+ | readonly FeatureItem[]
45
+ | undefined
46
+
47
+ return match(features)
48
+ .with(
49
+ P.when((f): f is readonly FeatureItem[] => Array.isArray(f) && f.length > 0),
50
+ (items) => <FeatureGrid>{items.map(renderFeature)}</FeatureGrid>
51
+ )
52
+ .otherwise(() => null)
53
+ }
@@ -0,0 +1,36 @@
1
+ import { HomeLayout as OriginalHomeLayout } from '@rspress/core/theme-original'
2
+ import type React from 'react'
3
+
4
+ import { HomeWorkspaces } from './workspaces'
5
+
6
+ // ── Types ────────────────────────────────────────────────────
7
+
8
+ interface HomeLayoutProps {
9
+ readonly beforeHero?: React.ReactNode
10
+ readonly afterHero?: React.ReactNode
11
+ readonly beforeFeatures?: React.ReactNode
12
+ readonly afterFeatures?: React.ReactNode
13
+ readonly beforeHeroActions?: React.ReactNode
14
+ readonly afterHeroActions?: React.ReactNode
15
+ }
16
+
17
+ // ── Component ────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Custom HomeLayout override for zpress.
21
+ * Wraps the original Rspress HomeLayout and injects HomeWorkspaces
22
+ * into the afterFeatures slot.
23
+ */
24
+ export function HomeLayout(props: HomeLayoutProps): React.ReactElement {
25
+ return (
26
+ <OriginalHomeLayout
27
+ {...props}
28
+ afterFeatures={
29
+ <>
30
+ {props.afterFeatures}
31
+ <HomeWorkspaces />
32
+ </>
33
+ }
34
+ />
35
+ )
36
+ }
@@ -0,0 +1,53 @@
1
+ import type React from 'react'
2
+ import { match, P } from 'ts-pattern'
3
+
4
+ import { useZpress } from '../../hooks/use-zpress'
5
+ import type { WorkspaceGroupData } from '../../hooks/use-zpress'
6
+ import { WorkspaceCard } from '../workspaces/card'
7
+ import { WorkspaceGrid } from '../workspaces/grid'
8
+
9
+ /**
10
+ * Smart orchestrator that reads workspace data from themeConfig
11
+ * and renders workspace groups with the correct card component per type.
12
+ */
13
+ export function HomeWorkspaces(): React.ReactElement | null {
14
+ const { workspaces } = useZpress()
15
+
16
+ return match(workspaces)
17
+ .with(
18
+ P.when((w): w is readonly WorkspaceGroupData[] => Array.isArray(w) && w.length > 0),
19
+ (groups) => (
20
+ <div className="workspace-section">
21
+ <hr className="home-card-divider" />
22
+ {groups.map(renderGroup)}
23
+ </div>
24
+ )
25
+ )
26
+ .otherwise(() => null)
27
+ }
28
+
29
+ // ── Helpers ──────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Render a single workspace group.
33
+ * @private
34
+ */
35
+ function renderGroup(group: WorkspaceGroupData): React.ReactElement {
36
+ return (
37
+ <WorkspaceGrid key={group.heading} heading={group.heading} description={group.description}>
38
+ {group.cards.map((card, i) => (
39
+ <WorkspaceCard
40
+ key={`${card.text}-${i}`}
41
+ text={card.text}
42
+ href={card.href}
43
+ icon={card.icon}
44
+ iconColor={card.iconColor}
45
+ scope={card.scope}
46
+ description={card.description}
47
+ tags={card.tags}
48
+ badge={card.badge}
49
+ />
50
+ ))}
51
+ </WorkspaceGrid>
52
+ )
53
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Branch tag pill — git branch badge positioned in the nav bar.
3
+ */
4
+
5
+ .branch-tag {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 5px;
9
+ margin-left: 8px;
10
+ padding: 3px 10px;
11
+ border-radius: 20px;
12
+ background: var(--zp-c-bg-soft);
13
+ border: 1px solid var(--zp-c-divider);
14
+ color: var(--zp-c-text-3);
15
+ font-family: var(--zp-font-family-mono);
16
+ font-size: 11px;
17
+ font-weight: 600;
18
+ line-height: 1;
19
+ white-space: nowrap;
20
+ max-width: 180px;
21
+ text-decoration: none !important;
22
+ transition:
23
+ border-color 0.2s,
24
+ color 0.2s;
25
+ }
26
+
27
+ .branch-tag:hover {
28
+ border-color: var(--zp-c-brand-1);
29
+ color: var(--zp-c-brand-1);
30
+ text-decoration: none !important;
31
+ }
32
+
33
+ .branch-tag:focus-visible {
34
+ outline: 2px solid var(--rp-c-brand);
35
+ outline-offset: 2px;
36
+ }
37
+
38
+ .branch-tag svg {
39
+ width: 14px;
40
+ height: 14px;
41
+ flex-shrink: 0;
42
+ }
43
+
44
+ .branch-tag-text {
45
+ overflow: hidden;
46
+ text-overflow: ellipsis;
47
+ }
48
+
49
+ /* ── Responsive ──────────────────────────────────────────── */
50
+ @media (max-width: 768px) {
51
+ .branch-tag-text {
52
+ display: none;
53
+ }
54
+ }
@@ -0,0 +1,66 @@
1
+ import React, { useEffect, useRef } from 'react'
2
+
3
+ import './branch-tag.css'
4
+ import { Icon } from '../shared/icon.tsx'
5
+
6
+ declare const __ZPRESS_GIT_BRANCH__: string | undefined
7
+
8
+ /**
9
+ * Resolves the current git branch name from the build-time global,
10
+ * returning an empty string when undefined.
11
+ */
12
+ function resolveBranch(): string {
13
+ if (__ZPRESS_GIT_BRANCH__ !== undefined) {
14
+ return __ZPRESS_GIT_BRANCH__
15
+ }
16
+ return ''
17
+ }
18
+
19
+ /**
20
+ * Git branch tag — pill-shaped badge positioned in the nav bar.
21
+ * Hidden when on `main` (production). Uses the pixelarticons:git-branch icon.
22
+ */
23
+ export function BranchTag(): React.ReactElement | null {
24
+ const branch = resolveBranch()
25
+ const rootRef = useRef<HTMLAnchorElement>(null)
26
+
27
+ useEffect(() => {
28
+ if (!branch || branch === 'main' || !rootRef.current) {
29
+ return
30
+ }
31
+
32
+ const node = rootRef.current
33
+
34
+ // Relocate into the search container so it sits beside the search button.
35
+ // No Rspress API exists for injecting into the nav bar, so direct DOM
36
+ // manipulation is the only option for this placement.
37
+ const searchContainer = document.querySelector('.rspress-nav-search')
38
+ if (searchContainer) {
39
+ searchContainer.append(node)
40
+ }
41
+
42
+ return () => {
43
+ node.remove()
44
+ }
45
+ }, [branch])
46
+
47
+ if (!branch || branch === 'main') {
48
+ return null
49
+ }
50
+
51
+ return (
52
+ <a
53
+ className="branch-tag"
54
+ ref={rootRef}
55
+ href={`https://github.com/joggrdocs/zpress/tree/${branch}`}
56
+ target="_blank"
57
+ rel="noopener noreferrer"
58
+ title={`Branch: ${branch}`}
59
+ >
60
+ <Icon icon="pixelarticons:git-branch" width={14} height={14} />
61
+ <span className="branch-tag-text">{branch}</span>
62
+ </a>
63
+ )
64
+ }
65
+
66
+ export { BranchTag as default }
@@ -0,0 +1,27 @@
1
+ import type React from 'react'
2
+ import { match, P } from 'ts-pattern'
3
+
4
+ // ── Types ────────────────────────────────────────────────────
5
+
6
+ export interface CardProps {
7
+ readonly href?: string
8
+ readonly className?: string
9
+ readonly children: React.ReactNode
10
+ }
11
+
12
+ // ── Component ────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Shared base card handling link-vs-div rendering.
16
+ * Renders `<a>` with `home-card--clickable` when `href` is provided,
17
+ * plain `<div>` otherwise.
18
+ */
19
+ export function Card({ href, className, children }: CardProps): React.ReactElement {
20
+ return match(href)
21
+ .with(P.nonNullable, (h) => (
22
+ <a className={`home-card home-card--clickable ${className ?? ''}`} href={h}>
23
+ {children}
24
+ </a>
25
+ ))
26
+ .otherwise(() => <div className={`home-card ${className ?? ''}`}>{children}</div>)
27
+ }
@@ -0,0 +1,27 @@
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
+ function cast(v: unknown): Parameters<typeof addCollection>[0] {
14
+ return v as Parameters<typeof addCollection>[0]
15
+ }
16
+
17
+ export const pixelarticonsLoaded = addCollection(cast(pixelarticons))
18
+ export const deviconLoaded = addCollection(cast(devicon))
19
+ export const mdiLoaded = addCollection(cast(mdi))
20
+ export const simpleIconsLoaded = addCollection(cast(simpleIcons))
21
+ export const skillIconsLoaded = addCollection(cast(skillIcons))
22
+ export const catppuccinLoaded = addCollection(cast(catppuccin))
23
+ export const logosLoaded = addCollection(cast(logos))
24
+ export const vscodeIconsLoaded = addCollection(cast(vscodeIcons))
25
+ export const materialIconThemeLoaded = addCollection(cast(materialIconTheme))
26
+
27
+ export { Icon }
@@ -0,0 +1,42 @@
1
+ import type React from 'react'
2
+ import { match, P } from 'ts-pattern'
3
+
4
+ import type { IconColor } from '../home/feature-card'
5
+ import { Card } from './card'
6
+ import { Icon } from './icon'
7
+
8
+ export interface SectionCardProps {
9
+ readonly href: string
10
+ readonly title: string
11
+ readonly description?: string
12
+ readonly icon?: string
13
+ readonly iconColor?: IconColor
14
+ }
15
+
16
+ /**
17
+ * Section card — simple icon + title + description link card
18
+ * used on auto-generated section landing pages.
19
+ */
20
+ export function SectionCard({
21
+ href,
22
+ title,
23
+ description,
24
+ icon = 'pixelarticons:file',
25
+ iconColor = 'purple',
26
+ }: SectionCardProps): React.ReactElement {
27
+ const descEl = match(description)
28
+ .with(P.nonNullable, (d) => <span className="section-desc">{d}</span>)
29
+ .otherwise(() => null)
30
+
31
+ return (
32
+ <Card href={href} className="section-card">
33
+ <span className={`section-icon section-icon--${iconColor}`}>
34
+ <Icon icon={icon} />
35
+ </span>
36
+ <div className="section-info">
37
+ <span className="section-title">{title}</span>
38
+ {descEl}
39
+ </div>
40
+ </Card>
41
+ )
42
+ }
@@ -0,0 +1,12 @@
1
+ import type React from 'react'
2
+
3
+ export interface SectionGridProps {
4
+ readonly children: React.ReactNode
5
+ }
6
+
7
+ /**
8
+ * Grid container for section cards on landing pages.
9
+ */
10
+ export function SectionGrid({ children }: SectionGridProps): React.ReactElement {
11
+ return <div className="section-grid">{children}</div>
12
+ }
@@ -0,0 +1,68 @@
1
+ import type React from 'react'
2
+
3
+ import { Icon } from './icon'
4
+
5
+ export interface TechIconEntry {
6
+ readonly tag: string
7
+ readonly icon: string
8
+ readonly label: string
9
+ }
10
+
11
+ export interface TechIconTableProps {
12
+ readonly entries: readonly TechIconEntry[]
13
+ }
14
+
15
+ const tableStyle: React.CSSProperties = {
16
+ width: '100%',
17
+ }
18
+
19
+ const iconCellStyle: React.CSSProperties = {
20
+ width: 48,
21
+ textAlign: 'center' as const,
22
+ }
23
+
24
+ const iconWrapperStyle: React.CSSProperties = {
25
+ display: 'inline-flex',
26
+ alignItems: 'center',
27
+ justifyContent: 'center',
28
+ width: 36,
29
+ height: 36,
30
+ background: 'var(--zp-c-bg-icon)',
31
+ }
32
+
33
+ /**
34
+ * Renders a table of technology icons with tag, label, and identifier columns.
35
+ * Used in generated icon reference docs.
36
+ */
37
+ export function TechIconTable({ entries }: TechIconTableProps): React.ReactElement {
38
+ return (
39
+ <table style={tableStyle}>
40
+ <thead>
41
+ <tr>
42
+ <th style={iconCellStyle} />
43
+ <th>Tag</th>
44
+ <th>Label</th>
45
+ <th>Identifier</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ {entries.map((entry) => (
50
+ <tr key={entry.tag}>
51
+ <td style={iconCellStyle}>
52
+ <div style={iconWrapperStyle}>
53
+ <Icon icon={entry.icon} width={20} height={20} />
54
+ </div>
55
+ </td>
56
+ <td>
57
+ <code>{entry.tag}</code>
58
+ </td>
59
+ <td>{entry.label}</td>
60
+ <td>
61
+ <code>{entry.icon}</code>
62
+ </td>
63
+ </tr>
64
+ ))}
65
+ </tbody>
66
+ </table>
67
+ )
68
+ }
@@ -0,0 +1,28 @@
1
+ import type React from 'react'
2
+ import { match, P } from 'ts-pattern'
3
+
4
+ import { TECH_ICONS } from '../../icons/index.ts'
5
+ import { Icon } from './icon'
6
+
7
+ export interface TechTagProps {
8
+ readonly name: string
9
+ }
10
+
11
+ /**
12
+ * Technology tag — resolves a tech name to icon + label using the tech map.
13
+ * Falls back to raw name string if not in map.
14
+ */
15
+ export function TechTag({ name }: TechTagProps): React.ReactElement {
16
+ const entry = (TECH_ICONS as Record<string, { readonly icon: string; readonly label: string }>)[
17
+ name
18
+ ]
19
+
20
+ return match(entry)
21
+ .with(P.nonNullable, (e) => (
22
+ <span className="workspace-tag">
23
+ <Icon icon={e.icon} />
24
+ {` ${e.label}`}
25
+ </span>
26
+ ))
27
+ .otherwise(() => <span className="workspace-tag">{name}</span>)
28
+ }