@rxova/astro-ui 0.0.0 → 0.2.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/package.json +65 -6
  4. package/src/components/BackLink.astro +24 -0
  5. package/src/components/CodeRecipes.astro +67 -0
  6. package/src/components/CtaBand.astro +121 -0
  7. package/src/components/DataTable.astro +114 -0
  8. package/src/components/DocAccordion.astro +15 -0
  9. package/src/components/DocAccordionItem.astro +83 -0
  10. package/src/components/Header.astro +148 -0
  11. package/src/components/Icon.astro +29 -0
  12. package/src/components/ModeTabs.astro +124 -0
  13. package/src/components/PageHeader.astro +63 -0
  14. package/src/components/ProjectSwitcher.astro +95 -0
  15. package/src/components/ProofStats.astro +207 -0
  16. package/src/components/QuickStart.astro +156 -0
  17. package/src/components/ScreenshotGrid.astro +164 -0
  18. package/src/components/Section.astro +103 -0
  19. package/src/components/ShowMore.astro +82 -0
  20. package/src/components/SiteFooter.astro +149 -0
  21. package/src/components/SiteShell.astro +155 -0
  22. package/src/components/SizeTable.astro +118 -0
  23. package/src/components/ThemeScript.astro +31 -0
  24. package/src/components/ThemeToggle.astro +163 -0
  25. package/src/components/ValueGrid.astro +116 -0
  26. package/src/components/VisuallyHidden.astro +23 -0
  27. package/src/components/icons/Collapse.astro +11 -0
  28. package/src/components/icons/Expand.astro +11 -0
  29. package/src/components/icons/Next.astro +11 -0
  30. package/src/components/icons/Pause.astro +11 -0
  31. package/src/components/icons/Play.astro +11 -0
  32. package/src/components/icons/Prev.astro +11 -0
  33. package/src/components/icons/Replay.astro +11 -0
  34. package/src/env.d.ts +15 -0
  35. package/src/lib/entries.ts +72 -0
  36. package/src/lib/icons.ts +15 -0
  37. package/src/scripts/show-more.ts +46 -0
  38. package/src/starlight/Footer.astro +16 -0
  39. package/src/starlight/SiteTitle.astro +34 -0
  40. package/src/starlight/SocialIcons.astro +18 -0
  41. package/src/starlight/ThemeSelect.astro +42 -0
  42. package/src/starlight/index.ts +103 -0
  43. package/src/styles/chrome.css +8 -0
  44. package/src/styles/document.css +72 -0
  45. package/src/styles/footer.css +124 -0
  46. package/src/styles/landing.css +55 -0
  47. package/src/styles/mermaid.css +75 -0
  48. package/src/styles/starlight.css +162 -0
@@ -0,0 +1,156 @@
1
+ ---
2
+ /**
3
+ * An install command with a copy button beside a "Use" pane. The snippet arrives through the slot,
4
+ * so a page can write a fenced block and get the same Shiki highlighting as the docs.
5
+ */
6
+ interface Props {
7
+ command: string
8
+ }
9
+
10
+ const { command } = Astro.props
11
+ ---
12
+
13
+ <div class="rx-qs">
14
+ <div class="rx-qs__pane">
15
+ <p class="rx-qs__label">Install</p>
16
+ <div class="rx-qs__term">
17
+ {/* Focusable and labelled: a nowrap command over overflow-x scrolls, and a scrollable region needs a tab stop. */}
18
+ <code class="rx-qs__cmd" tabindex="0" role="region" aria-label="Install command">
19
+ <span class="rx-qs__prompt" aria-hidden="true">
20
+ $
21
+ </span>
22
+ {command}
23
+ </code>
24
+ <button class="rx-qs__copy" type="button" data-copy={command}>
25
+ <span class="rx-qs__copy-idle">Copy</span>
26
+ <span class="rx-qs__copy-done" aria-hidden="true">
27
+ Copied
28
+ </span>
29
+ </button>
30
+ </div>
31
+ </div>
32
+
33
+ <div class="rx-qs__pane">
34
+ <p class="rx-qs__label">Use</p>
35
+ <div class="rx-qs__code">
36
+ <slot />
37
+ </div>
38
+ </div>
39
+ </div>
40
+
41
+ <script>
42
+ for (const button of document.querySelectorAll<HTMLButtonElement>('.rx-qs__copy')) {
43
+ button.addEventListener('click', async () => {
44
+ try {
45
+ await navigator.clipboard.writeText(button.dataset.copy ?? '')
46
+ } catch {
47
+ // Clipboard access can be denied; saying nothing beats claiming a copy that did not happen.
48
+ return
49
+ }
50
+ button.dataset.state = 'done'
51
+ window.setTimeout(() => delete button.dataset.state, 1600)
52
+ })
53
+ }
54
+ </script>
55
+
56
+ <style>
57
+ .rx-qs {
58
+ display: grid;
59
+ gap: 1.25rem;
60
+ grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
61
+ }
62
+
63
+ .rx-qs__pane {
64
+ display: flex;
65
+ flex-direction: column;
66
+ min-width: 0;
67
+ }
68
+
69
+ .rx-qs__label {
70
+ margin: 0 0 0.6rem;
71
+ /* --rx-muted, not --rx-faint: faint on the card is ~3.4:1 and fails AA here. */
72
+ color: var(--rx-muted);
73
+ font-size: 0.72rem;
74
+ font-weight: 700;
75
+ letter-spacing: 0.08em;
76
+ text-transform: uppercase;
77
+ }
78
+
79
+ /* Not `flex: 1`: a one-line command stretched to the snippet's height floats in an empty box. */
80
+ .rx-qs__term {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 1rem;
84
+ padding: 1rem 1rem 1rem 1.15rem;
85
+ background: var(--rx-card);
86
+ border: 1px solid var(--rx-rule);
87
+ border-radius: var(--rx-radius);
88
+ }
89
+
90
+ .rx-qs__cmd {
91
+ flex: 1;
92
+ min-width: 0;
93
+ overflow-x: auto;
94
+ color: var(--rx-fg);
95
+ font-family: var(--rx-font-mono);
96
+ font-size: 0.88rem;
97
+ white-space: nowrap;
98
+ }
99
+
100
+ .rx-qs__prompt {
101
+ margin-right: 0.6rem;
102
+ color: var(--rx-primary);
103
+ user-select: none;
104
+ }
105
+
106
+ /* One label over the other, so the button keeps its size when the state flips. */
107
+ .rx-qs__copy {
108
+ display: grid;
109
+ flex: none;
110
+ padding: 0.4rem 0.85rem;
111
+ color: var(--rx-muted);
112
+ background: var(--rx-bg);
113
+ border: 1px solid var(--rx-rule-strong);
114
+ border-radius: var(--rx-radius-pill);
115
+ font: inherit;
116
+ font-size: 0.78rem;
117
+ font-weight: 500;
118
+ cursor: pointer;
119
+ }
120
+
121
+ .rx-qs__copy:hover {
122
+ color: var(--rx-fg);
123
+ border-color: var(--rx-primary);
124
+ }
125
+
126
+ .rx-qs__copy-idle,
127
+ .rx-qs__copy-done {
128
+ grid-area: 1 / 1;
129
+ }
130
+
131
+ .rx-qs__copy-done,
132
+ .rx-qs__copy[data-state='done'] .rx-qs__copy-idle {
133
+ visibility: hidden;
134
+ }
135
+
136
+ .rx-qs__copy[data-state='done'] .rx-qs__copy-done {
137
+ visibility: visible;
138
+ color: var(--rx-primary);
139
+ }
140
+
141
+ .rx-qs__code {
142
+ flex: 1;
143
+ }
144
+
145
+ /* The slotted block renders its own <pre>; frame it like the terminal so the panes read as a pair. */
146
+ .rx-qs__code :global(pre) {
147
+ height: 100%;
148
+ margin: 0;
149
+ padding: 1rem 1.15rem;
150
+ border: 1px solid var(--rx-rule);
151
+ border-radius: var(--rx-radius);
152
+ overflow-x: auto;
153
+ font-size: 0.84rem;
154
+ line-height: 1.6;
155
+ }
156
+ </style>
@@ -0,0 +1,164 @@
1
+ ---
2
+ /**
3
+ * Screenshots shown rather than described: each in a hairline frame through Astro's `<Image>`,
4
+ * a mono label and a line of detail under it, and an optional link beneath the row.
5
+ */
6
+ import { Image } from 'astro:assets'
7
+ import type { ImageMetadata } from 'astro'
8
+
9
+ export interface Shot {
10
+ src: ImageMetadata
11
+ alt: string
12
+ title: string
13
+ body: string
14
+ }
15
+
16
+ interface Props {
17
+ shots: readonly Shot[]
18
+ link?: { href: string; text: string; external?: boolean }
19
+ }
20
+
21
+ const { shots, link } = Astro.props
22
+ ---
23
+
24
+ <div class="rx-shots">
25
+ <ul class="rx-shots__grid">
26
+ {shots.map((shot, index) => (
27
+ <li class="rx-shots__item">
28
+ <figure class="rx-shots__figure">
29
+ <div class="rx-shots__frame">
30
+ <Image src={shot.src} alt={shot.alt} loading="lazy" />
31
+ </div>
32
+ <figcaption class="rx-shots__caption">
33
+ <span class="rx-shots__index" aria-hidden="true">
34
+ {String(index + 1).padStart(2, '0')}
35
+ </span>
36
+ <span class="rx-shots__title">{shot.title}</span>
37
+ <span class="rx-shots__body">{shot.body}</span>
38
+ </figcaption>
39
+ </figure>
40
+ </li>
41
+ ))}
42
+ </ul>
43
+
44
+ {link && (
45
+ <a class="rx-shots__link" href={link.href} rel={link.external ? 'noopener' : undefined}>
46
+ {link.text}
47
+ <svg
48
+ viewBox="0 0 16 16"
49
+ width="14"
50
+ height="14"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="1.6"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ aria-hidden="true"
57
+ >
58
+ {link.external ? <path d="M5 3h8v8M13 3 3 13" /> : <path d="M3 8h10M9 4l4 4-4 4" />}
59
+ </svg>
60
+ </a>
61
+ )}
62
+ </div>
63
+
64
+ <style>
65
+ .rx-shots__grid {
66
+ display: grid;
67
+ gap: 2rem 2.5rem;
68
+ margin: 0;
69
+ padding: 0;
70
+ list-style: none;
71
+ grid-template-columns: 1fr;
72
+ }
73
+
74
+ /* Viewport breakpoints, like ValueGrid: three across on a wide screen, two on a tablet, one on a phone. */
75
+ @media (min-width: 38rem) {
76
+ .rx-shots__grid {
77
+ grid-template-columns: repeat(2, 1fr);
78
+ }
79
+ }
80
+
81
+ @media (min-width: 60rem) {
82
+ .rx-shots__grid {
83
+ grid-template-columns: repeat(3, 1fr);
84
+ }
85
+ }
86
+
87
+ .rx-shots__item {
88
+ min-width: 0;
89
+ }
90
+
91
+ .rx-shots__figure {
92
+ margin: 0;
93
+ }
94
+
95
+ /* A hairline, not a card: the screenshot is the surface. The frame owns the radius. */
96
+ .rx-shots__frame {
97
+ overflow: hidden;
98
+ border: 1px solid var(--rx-rule);
99
+ border-radius: var(--rx-radius-sm);
100
+ background: var(--rx-card);
101
+ }
102
+
103
+ .rx-shots__frame :global(img) {
104
+ display: block;
105
+ width: 100%;
106
+ height: auto;
107
+ }
108
+
109
+ /* Index beside the label, detail hanging under the label; the indent binds the pair. */
110
+ .rx-shots__caption {
111
+ display: grid;
112
+ grid-template-columns: auto minmax(0, 1fr);
113
+ column-gap: 0.7rem;
114
+ row-gap: 0.3rem;
115
+ margin-top: 0.9rem;
116
+ align-content: start;
117
+ }
118
+
119
+ .rx-shots__index,
120
+ .rx-shots__title {
121
+ grid-row: 1;
122
+ font-family: var(--rx-font-mono);
123
+ font-size: 0.74rem;
124
+ letter-spacing: 0.06em;
125
+ text-transform: uppercase;
126
+ }
127
+
128
+ .rx-shots__index {
129
+ grid-column: 1;
130
+ color: var(--rx-faint);
131
+ }
132
+
133
+ .rx-shots__title {
134
+ grid-column: 2;
135
+ color: var(--rx-fg);
136
+ }
137
+
138
+ .rx-shots__body {
139
+ grid-column: 2;
140
+ grid-row: 2;
141
+ color: var(--rx-muted);
142
+ font-size: 0.9rem;
143
+ line-height: 1.5;
144
+ text-wrap: pretty;
145
+ }
146
+
147
+ .rx-shots__link {
148
+ display: inline-flex;
149
+ align-items: center;
150
+ gap: 0.45rem;
151
+ margin-top: 1.75rem;
152
+ color: var(--rx-fg);
153
+ font-size: 0.95rem;
154
+ font-weight: 500;
155
+ text-decoration: none;
156
+ border-bottom: 1px solid var(--rx-rule-strong);
157
+ padding-bottom: 0.15rem;
158
+ }
159
+
160
+ .rx-shots__link:hover {
161
+ color: var(--rx-primary);
162
+ border-color: currentColor;
163
+ }
164
+ </style>
@@ -0,0 +1,103 @@
1
+ ---
2
+ /**
3
+ * A landing-page section: eyebrow, heading, lede, then the slot. `not-content` opts the subtree out
4
+ * of Starlight's prose rules, which would otherwise give every stat and card title a paragraph's measure.
5
+ */
6
+ interface Props {
7
+ id?: string
8
+ eyebrow?: string
9
+ title: string
10
+ lede?: string
11
+ /** Draws the gradient hairline above the heading; the brand spends the gradient sparingly. */
12
+ accent?: boolean
13
+ }
14
+
15
+ const { id, eyebrow, title, lede, accent = false } = Astro.props
16
+ ---
17
+
18
+ <section class:list={['rx-sec', 'not-content', { 'rx-sec--accent': accent }]} id={id}>
19
+ <header class="rx-sec__head">
20
+ {eyebrow && <p class="rx-sec__eyebrow">{eyebrow}</p>}
21
+ <h2 class="rx-sec__title">{title}</h2>
22
+ {lede && <p class="rx-sec__lede">{lede}</p>}
23
+ </header>
24
+ <slot />
25
+ </section>
26
+
27
+ <style>
28
+ /* Unlayered, so it beats Starlight's `.sl-container > * + *` gap without a specificity fight. */
29
+ .rx-sec {
30
+ margin-block: 5.5rem 0;
31
+ }
32
+
33
+ /* rem, not ch: `ch` resolves against this 1rem block, not the ~2.35rem heading it wraps. */
34
+ .rx-sec__head {
35
+ max-width: 48rem;
36
+ margin-bottom: 2rem;
37
+ }
38
+
39
+ .rx-sec__eyebrow {
40
+ margin: 0 0 0.75rem;
41
+ /* --rx-muted, not --rx-faint: faint on the card is ~3.4:1 and fails AA here. */
42
+ color: var(--rx-muted);
43
+ font-size: 0.75rem;
44
+ font-weight: 700;
45
+ letter-spacing: 0.08em;
46
+ text-transform: uppercase;
47
+ }
48
+
49
+ .rx-sec__title {
50
+ margin: 0;
51
+ color: var(--rx-fg);
52
+ font-size: clamp(1.6rem, 1.15rem + 1.7vw, 2.35rem);
53
+ font-weight: 640;
54
+ line-height: 1.15;
55
+ letter-spacing: -0.02em;
56
+ text-wrap: balance;
57
+ }
58
+
59
+ .rx-sec__lede {
60
+ max-width: 62ch;
61
+ margin: 0.9rem 0 0;
62
+ color: var(--rx-muted);
63
+ font-size: 1.05rem;
64
+ line-height: 1.6;
65
+ text-wrap: pretty;
66
+ }
67
+
68
+ .rx-sec--accent .rx-sec__head {
69
+ padding-top: 1.75rem;
70
+ border-top: 2px solid transparent;
71
+ border-image: var(--rx-gradient) 1;
72
+ }
73
+
74
+ /* Direct children only: a descendant selector would outrank the slotted components' own scoped rules. */
75
+ .rx-sec > :global(pre) {
76
+ margin: 0;
77
+ padding: 1.1rem 1.25rem;
78
+ background: var(--rx-card);
79
+ border: 1px solid var(--rx-rule);
80
+ border-radius: var(--rx-radius);
81
+ overflow-x: auto;
82
+ font-size: 0.86rem;
83
+ line-height: 1.65;
84
+ }
85
+
86
+ .rx-sec > :global(p) {
87
+ margin: 1.5rem 0 0;
88
+ color: var(--rx-muted);
89
+ font-size: 0.98rem;
90
+ line-height: 1.62;
91
+ }
92
+
93
+ .rx-sec > :global(p a) {
94
+ color: var(--rx-primary);
95
+ font-weight: 500;
96
+ }
97
+
98
+ @media (min-width: 50rem) {
99
+ .rx-sec {
100
+ margin-block: 7rem 0;
101
+ }
102
+ }
103
+ </style>
@@ -0,0 +1,82 @@
1
+ ---
2
+ /**
3
+ * "Show more" controls for a batched list; they ship `hidden` until a script drives them.
4
+ * `enhanceShowMore` from `@rxova/astro-ui/scripts/show-more` batches a plain list.
5
+ */
6
+ import VisuallyHidden from './VisuallyHidden.astro'
7
+
8
+ interface Props {
9
+ /** What the list holds, for the button's accessible name: "Show more posts". */
10
+ noun: string
11
+ }
12
+
13
+ const { noun } = Astro.props
14
+ ---
15
+
16
+ <div class="more" data-reveal-controls hidden>
17
+ <button class="more-btn" type="button" data-reveal-more>
18
+ Show more<VisuallyHidden>{` ${noun}`}</VisuallyHidden>
19
+ </button>
20
+ <button class="show-all" type="button" data-reveal-all>
21
+ Show all
22
+ </button>
23
+ <p class="progress" data-reveal-progress></p>
24
+ </div>
25
+
26
+ <style>
27
+ .more {
28
+ display: flex;
29
+ align-items: baseline;
30
+ gap: 0.75rem;
31
+ flex-wrap: wrap;
32
+ margin-top: 2.5rem;
33
+ padding-top: 1.5rem;
34
+ border-top: 1px solid var(--rx-rule);
35
+ }
36
+ /* `display: flex` outranks the UA's `[hidden]` rule. */
37
+ .more[hidden] {
38
+ display: none;
39
+ }
40
+ .more-btn {
41
+ font: inherit;
42
+ font-size: 0.9rem;
43
+ color: var(--rx-fg);
44
+ background: var(--rx-tag-bg);
45
+ border: 1px solid var(--rx-rule);
46
+ border-radius: 999px;
47
+ padding: 0.35rem 1rem;
48
+ cursor: pointer;
49
+ }
50
+ .more-btn:hover {
51
+ border-color: var(--rx-fg);
52
+ }
53
+ .show-all {
54
+ font: inherit;
55
+ font-size: 0.85rem;
56
+ color: var(--rx-muted);
57
+ background: none;
58
+ border: 0;
59
+ padding: 0;
60
+ text-decoration: underline;
61
+ text-underline-offset: 3px;
62
+ cursor: pointer;
63
+ }
64
+ .show-all:hover {
65
+ color: var(--rx-fg);
66
+ }
67
+ .more-btn:focus-visible,
68
+ .show-all:focus-visible {
69
+ outline: 2px solid var(--rx-fg);
70
+ outline-offset: 2px;
71
+ }
72
+ .progress {
73
+ margin-left: auto;
74
+ color: var(--rx-faint);
75
+ font-size: 0.85rem;
76
+ }
77
+ @media print {
78
+ .more {
79
+ display: none;
80
+ }
81
+ }
82
+ </style>
@@ -0,0 +1,149 @@
1
+ ---
2
+ /**
3
+ * The rxova footer on every surface: brand block, link columns, then copyright and legal links.
4
+ * Styles live in `../styles/footer.css`; all cross-project hrefs are absolute.
5
+ */
6
+ import { PROJECTS, RXOVA_ORIGIN, getProject, siteUrl, type ProjectId } from '@rxova/brand'
7
+
8
+ /** A standalone surface of rxova.org, for the "Site" column. */
9
+ export interface SiteLink {
10
+ label: string
11
+ /** Absolute — see sites.ts in @rxova/brand. */
12
+ href: string
13
+ }
14
+
15
+ interface Props {
16
+ /** Current project, if this is a docs site. Omitted on the landing. */
17
+ project?: ProjectId
18
+ /** Optional per-site "Docs" column. */
19
+ docs?: { label: string; href: string }[]
20
+ /**
21
+ * Standalone surfaces listed under "Site", below Home. Only the umbrella repo knows which are
22
+ * deployed; the default (for docs sites) includes /about, a page of the landing build.
23
+ */
24
+ site?: readonly SiteLink[]
25
+ /**
26
+ * Projects whose docs are actually mounted; only the umbrella repo knows which are deployed.
27
+ * Defaults to every project, which is right for a docs site.
28
+ */
29
+ projects?: readonly SiteLink[]
30
+ }
31
+
32
+ const { project, docs, site, projects } = Astro.props
33
+ const self = project ? getProject(project) : undefined
34
+ const year = new Date().getFullYear()
35
+
36
+ const projectLinks: readonly SiteLink[] =
37
+ projects ?? PROJECTS.map((p) => ({ label: p.label, href: `${RXOVA_ORIGIN}${p.mount}` }))
38
+
39
+ const surfaces: readonly SiteLink[] = site ?? [
40
+ { label: 'Blog', href: siteUrl('/blog') },
41
+ { label: 'Updates', href: siteUrl('/updates') },
42
+ { label: 'About', href: siteUrl('/about') },
43
+ ]
44
+ ---
45
+
46
+ <footer class="rx-footer">
47
+ <div class="rx-footer__top">
48
+ <div class="rx-footer__brand">
49
+ <a class="rx-footer__mark" href={RXOVA_ORIGIN}>
50
+ <img src={siteUrl('/rxova-logo-256.png')} alt="" width="28" height="28" loading="lazy" />
51
+ <span>Rxova</span>
52
+ </a>
53
+ {/* Not "React libraries": most ship a framework-agnostic core; some are extensions/CLIs. */}
54
+ <p class="rx-footer__blurb">
55
+ Small TypeScript libraries and developer tools, documented in one place. MIT licensed, built
56
+ in the open.
57
+ </p>
58
+ </div>
59
+
60
+ <div class="rx-footer__columns">
61
+ {docs && docs.length > 0 && (
62
+ <div>
63
+ <h2 class="rx-footer__title">Docs</h2>
64
+ <ul class="rx-footer__list">
65
+ {docs.map((link) => (
66
+ <li>
67
+ <a href={link.href}>{link.label}</a>
68
+ </li>
69
+ ))}
70
+ </ul>
71
+ </div>
72
+ )}
73
+
74
+ {projectLinks.length > 0 && (
75
+ <div>
76
+ <h2 class="rx-footer__title">Projects</h2>
77
+ <ul class="rx-footer__list">
78
+ {projectLinks.map((link) => (
79
+ <li>
80
+ <a href={link.href}>{link.label}</a>
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ </div>
85
+ )}
86
+
87
+ {self && (
88
+ <div>
89
+ <h2 class="rx-footer__title">Community</h2>
90
+ <ul class="rx-footer__list">
91
+ <li>
92
+ <a href={self.repo}>GitHub</a>
93
+ </li>
94
+ <li>
95
+ <a href={`${self.repo}/issues`}>Issues</a>
96
+ </li>
97
+ <li>
98
+ <a href={`${self.repo}/discussions`}>Discussions</a>
99
+ </li>
100
+ <li>
101
+ <a href={self.npm}>npm</a>
102
+ </li>
103
+ <li>
104
+ <a href={`${self.repo}/blob/main/CONTRIBUTING.md`}>Contributing</a>
105
+ </li>
106
+ </ul>
107
+ </div>
108
+ )}
109
+
110
+ {/* Blog and Updates on every surface, so readers landing on a docs page find the rest. */}
111
+ <div>
112
+ <h2 class="rx-footer__title">Site</h2>
113
+ <ul class="rx-footer__list">
114
+ <li>
115
+ <a href={RXOVA_ORIGIN}>Home</a>
116
+ </li>
117
+ {surfaces.map((s) => (
118
+ <li>
119
+ <a href={s.href}>{s.label}</a>
120
+ </li>
121
+ ))}
122
+ </ul>
123
+ </div>
124
+
125
+ <div>
126
+ <h2 class="rx-footer__title">Connect</h2>
127
+ <ul class="rx-footer__list">
128
+ <li>
129
+ <a href="https://github.com/rxova">GitHub</a>
130
+ </li>
131
+ <li>
132
+ <a href="https://www.npmjs.com/org/rxova">npm</a>
133
+ </li>
134
+ <li>
135
+ <a href="mailto:rxova@proton.me">rxova@proton.me</a>
136
+ </li>
137
+ </ul>
138
+ </div>
139
+ </div>
140
+ </div>
141
+
142
+ <div class="rx-footer__legal">
143
+ <p>© {year} Rxova · MIT licensed</p>
144
+ <div class="rx-footer__legal-links">
145
+ <a href={siteUrl('/privacy')}>Privacy</a>
146
+ <a href={siteUrl('/terms')}>Terms</a>
147
+ </div>
148
+ </div>
149
+ </footer>