@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Joggr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.mjs ADDED
@@ -0,0 +1,75 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import node_path from "node:path";
4
+ function zpressPlugin() {
5
+ const componentsDir = node_path.resolve(import.meta.dirname, 'theme', 'components');
6
+ return {
7
+ name: 'zpress',
8
+ globalUIComponents: [
9
+ node_path.resolve(componentsDir, 'nav', 'branch-tag.tsx')
10
+ ]
11
+ };
12
+ }
13
+ function loadGenerated(contentDir, name, fallback) {
14
+ const p = node_path.resolve(contentDir, '.generated', name);
15
+ if (!existsSync(p)) {
16
+ process.stderr.write(`[zpress] Generated file not found: ${name} — run "zpress sync" first\n`);
17
+ return fallback;
18
+ }
19
+ return JSON.parse(readFileSync(p, 'utf8'));
20
+ }
21
+ function detectGitBranch() {
22
+ try {
23
+ return execSync('git rev-parse --abbrev-ref HEAD', {
24
+ encoding: 'utf8',
25
+ stdio: 'pipe'
26
+ }).trim();
27
+ } catch {
28
+ return '';
29
+ }
30
+ }
31
+ function createRspressConfig(options) {
32
+ const { config, paths } = options;
33
+ const sidebar = loadGenerated(paths.contentDir, 'sidebar.json', {});
34
+ const nav = loadGenerated(paths.contentDir, 'nav.json', []);
35
+ const workspaces = loadGenerated(paths.contentDir, 'workspaces.json', []);
36
+ const gitBranch = detectGitBranch();
37
+ return {
38
+ root: paths.contentDir,
39
+ outDir: paths.distDir,
40
+ llms: true,
41
+ title: config.title ?? 'zpress',
42
+ description: config.description ?? 'Documentation',
43
+ logo: '/logo.svg',
44
+ logoText: '',
45
+ themeDir: node_path.resolve(import.meta.dirname, 'theme'),
46
+ plugins: [
47
+ zpressPlugin()
48
+ ],
49
+ builderConfig: {
50
+ resolve: {
51
+ alias: {
52
+ '@zpress/ui/theme': node_path.resolve(import.meta.dirname, 'theme', 'index.tsx')
53
+ }
54
+ },
55
+ source: {
56
+ define: {
57
+ __ZPRESS_GIT_BRANCH__: JSON.stringify(gitBranch)
58
+ }
59
+ },
60
+ output: {
61
+ distPath: {
62
+ root: paths.distDir
63
+ }
64
+ }
65
+ },
66
+ themeConfig: {
67
+ sidebar,
68
+ nav,
69
+ darkMode: true,
70
+ search: true,
71
+ workspaces
72
+ }
73
+ };
74
+ }
75
+ export { createRspressConfig, zpressPlugin };
@@ -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
+ }