@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
package/src/env.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /// <reference types="astro/client" />
2
+
3
+ // Not referencing `@astrojs/starlight/locals`: it imports `virtual:starlight/*` modules that
4
+ // only exist in an Astro build, so plain `tsc` fails. Route data is narrowed structurally instead.
5
+
6
+ // Mirrors Starlight's `global.d.ts`: set by its `ThemeProvider.astro`, called by ThemeSelect.astro.
7
+ // Re-check against Starlight's `global.d.ts` on a major bump.
8
+ declare global {
9
+ interface StarlightThemeProvider {
10
+ updatePickers(theme?: string): void
11
+ }
12
+ var StarlightThemeProvider: StarlightThemeProvider
13
+ }
14
+
15
+ export {}
@@ -0,0 +1,72 @@
1
+ /** Ordering, bylines, dates, facets and excerpts shared by /blog and /updates. Pure, so tests need no Astro. */
2
+
3
+ /** Newest first, ties broken by `id` so the order never depends on filesystem order. Sorts a copy. */
4
+ export function newestFirst<T extends { id: string }>(entries: readonly T[], date: (e: T) => Date) {
5
+ return [...entries].sort(
6
+ (a, b) => date(b).valueOf() - date(a).valueOf() || a.id.localeCompare(b.id),
7
+ )
8
+ }
9
+
10
+ /** "Rxova" · "Rxova and Ada" · "Rxova, Ada, and Grace" */
11
+ export function byline(names: readonly string[]): string {
12
+ if (names.length <= 1) return names[0] ?? ''
13
+ if (names.length === 2) return `${names[0]} and ${names[1]}`
14
+ return `${names.slice(0, -1).join(', ')}, and ${names[names.length - 1]}`
15
+ }
16
+
17
+ /** The distinct values some entry carries, so a filter row never offers a chip that matches nothing. */
18
+ export function usedValues<T>(entries: readonly T[], pick: (e: T) => readonly string[]): string[] {
19
+ return [...new Set(entries.flatMap((e) => pick(e)))]
20
+ }
21
+
22
+ const DATE = new Intl.DateTimeFormat('en', {
23
+ year: 'numeric',
24
+ month: 'long',
25
+ day: 'numeric',
26
+ timeZone: 'UTC',
27
+ })
28
+
29
+ /** "July 27, 2026". UTC, so the rendered date matches the filename it came from. */
30
+ export function formatDate(d: Date): string {
31
+ return DATE.format(d)
32
+ }
33
+
34
+ /** `2026-07-27`, for `<time datetime>`. */
35
+ export function isoDate(d: Date): string {
36
+ return d.toISOString().slice(0, 10)
37
+ }
38
+
39
+ /** The next "show more" limit: one `step` further, clamped to `total` so the control never outlives the list. */
40
+ export function nextLimit(current: number, step: number, total: number): number {
41
+ return Math.min(current + Math.max(step, 0), total)
42
+ }
43
+
44
+ /**
45
+ * The opening prose of a markdown body, up to `max` characters, cut on a word boundary.
46
+ * Skips headings, images, quotes, code, tables and lists; reports whether anything was cut.
47
+ */
48
+ export function excerpt(body: string, max: number): { text: string; truncated: boolean } {
49
+ const prose = body
50
+ .split(/\n\s*\n/)
51
+ .map((p) => p.trim())
52
+ .filter((p) => p.length > 0 && !/^(#|!\[|>|```|\||-{3,}|\d+\.|[-*+]\s)/.test(p))
53
+ .join(' ')
54
+
55
+ const plain = prose
56
+ .replace(/!\[[^[\]]*\]\([^()]*\)/g, '')
57
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
58
+ .replace(/`([^`]+)`/g, '$1')
59
+ .replace(/(\*\*|__)(.*?)\1/g, '$2')
60
+ .replace(/(\*|_)(.*?)\1/g, '$2')
61
+ .replace(/\s+/g, ' ')
62
+ .trim()
63
+
64
+ if (plain.length <= max) return { text: plain, truncated: false }
65
+
66
+ const cut = plain.slice(0, max)
67
+ const lastSpace = cut.lastIndexOf(' ')
68
+ return {
69
+ text: (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).replace(/[,;:.—–-]+$/, ''),
70
+ truncated: true,
71
+ }
72
+ }
@@ -0,0 +1,15 @@
1
+ /** Lucide icons as inline markup, read from `lucide-static` at build time so nothing ships to the client. */
2
+
3
+ import { readFileSync } from 'node:fs'
4
+ import { createRequire } from 'node:module'
5
+
6
+ const require = createRequire(import.meta.url)
7
+
8
+ /** What sits inside a Lucide icon's root `<svg>`, so the caller's own element owns size and accessibility. */
9
+ export function lucideGlyph(name: string): string {
10
+ const svg = readFileSync(require.resolve(`lucide-static/icons/${name}.svg`), 'utf8')
11
+ return svg
12
+ .replace(/^[\s\S]*?<svg[^>]*>/, '')
13
+ .replace(/<\/svg>[\s\S]*$/, '')
14
+ .trim()
15
+ }
@@ -0,0 +1,46 @@
1
+ import { nextLimit } from '../lib/entries.ts'
2
+
3
+ /** Shows `list`'s children `data-reveal-step` at a time, driven by a ShowMore's `controls`. */
4
+ export function batchList(list: HTMLElement, controls: HTMLElement): void {
5
+ const items = Array.from(list.children) as HTMLElement[]
6
+ const moreBtn = controls.querySelector<HTMLElement>('[data-reveal-more]')
7
+ const allBtn = controls.querySelector<HTMLElement>('[data-reveal-all]')
8
+ const progress = controls.querySelector<HTMLElement>('[data-reveal-progress]')
9
+
10
+ const step = Number(list.dataset.revealStep) || items.length
11
+ let limit = step
12
+
13
+ function apply() {
14
+ items.forEach((el, i) => {
15
+ el.hidden = i >= limit
16
+ })
17
+ controls.hidden = limit >= items.length
18
+ if (progress) {
19
+ progress.textContent = limit < items.length ? `Showing ${limit} of ${items.length}` : ''
20
+ }
21
+ }
22
+
23
+ /** Raise the limit, then focus the first item that appeared. */
24
+ function reveal(next: number) {
25
+ const first = items[limit]
26
+ limit = next
27
+ apply()
28
+ if (!first) return
29
+ // The new batch starts where the button was, so the viewport is already right.
30
+ first.tabIndex = -1
31
+ first.focus({ preventScroll: true })
32
+ }
33
+
34
+ moreBtn?.addEventListener('click', () => reveal(nextLimit(limit, step, items.length)))
35
+ // Also the way out of find-in-page missing what is batched away.
36
+ allBtn?.addEventListener('click', () => reveal(items.length))
37
+
38
+ apply()
39
+ }
40
+
41
+ /** Batches the page's `[data-reveal-list]` behind its `[data-reveal-controls]`, when it has both. */
42
+ export function enhanceShowMore(root: ParentNode = document): void {
43
+ const list = root.querySelector<HTMLElement>('[data-reveal-list]')
44
+ const controls = root.querySelector<HTMLElement>('[data-reveal-controls]')
45
+ if (list && controls) batchList(list, controls)
46
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ /**
3
+ * Starlight `Footer` override: keeps Starlight's pagination, edit link and last-updated, and adds
4
+ * the shared rxova site footer beneath them.
5
+ */
6
+ import Default from '@astrojs/starlight/components/Footer.astro'
7
+ import SiteFooter from '../components/SiteFooter.astro'
8
+ import { projectFromBase } from '@rxova/brand'
9
+
10
+ const project = projectFromBase(import.meta.env.BASE_URL)
11
+ ---
12
+
13
+ <Default>
14
+ <slot />
15
+ </Default>
16
+ <SiteFooter project={project} />
@@ -0,0 +1,34 @@
1
+ ---
2
+ /**
3
+ * Starlight `SiteTitle` override: the mark links (absolutely) to rxova.org, the wordmark to this
4
+ * project's docs root via `siteTitleHref`. The image comes from @rxova/brand.
5
+ */
6
+ import mark from '@rxova/brand/assets/rxova-logo-256.png'
7
+ import { RXOVA_ORIGIN } from '@rxova/brand'
8
+
9
+ // Narrowed structurally, not via an `App.Locals` augmentation, which would collide with
10
+ // Starlight's own in the consumer site's build.
11
+ const { siteTitle, siteTitleHref } = (
12
+ Astro.locals as unknown as {
13
+ starlightRoute: { siteTitle: string; siteTitleHref: string }
14
+ }
15
+ ).starlightRoute
16
+ ---
17
+
18
+ <div class="rx-site-title">
19
+ <a href={RXOVA_ORIGIN} class="rx-site-title__mark" aria-label="Rxova home">
20
+ <img src={mark.src} alt="" width="28" height="28" />
21
+ </a>
22
+ <span class="rx-site-title__sep" aria-hidden="true"></span>
23
+ <a href={siteTitleHref} class="rx-site-title__project" translate="no">
24
+ {siteTitle}
25
+ </a>
26
+ </div>
27
+
28
+ <style>
29
+ /* Visual styling lives in ../styles/starlight.css so the Astro landing can
30
+ reuse the same classnames without importing this component. */
31
+ .rx-site-title__mark {
32
+ display: inline-flex;
33
+ }
34
+ </style>
@@ -0,0 +1,18 @@
1
+ ---
2
+ /**
3
+ * Starlight `SocialIcons` override: the stock icons plus the project switcher, which lands just
4
+ * before search and the theme toggle.
5
+ */
6
+ import Default from '@astrojs/starlight/components/SocialIcons.astro'
7
+ import ProjectSwitcher from '../components/ProjectSwitcher.astro'
8
+ import { projectFromBase } from '@rxova/brand'
9
+
10
+ // Overrides receive no props, so the current project is inferred from the base
11
+ // path the aggregator mounts this site at.
12
+ const current = projectFromBase(import.meta.env.BASE_URL)
13
+ ---
14
+
15
+ <ProjectSwitcher current={current} />
16
+ <Default>
17
+ <slot />
18
+ </Default>
@@ -0,0 +1,42 @@
1
+ ---
2
+ /**
3
+ * Wraps Starlight's theme picker and resyncs the theme on a bfcache restore, where Starlight's
4
+ * parse-time theme code does not run again and the page would keep a stale `data-theme`.
5
+ */
6
+ import Default from '@astrojs/starlight/components/ThemeSelect.astro'
7
+ ---
8
+
9
+ <Default>
10
+ <slot />
11
+ </Default>
12
+
13
+ <script>
14
+ type Theme = 'auto' | 'dark' | 'light'
15
+
16
+ /** Same key and coercion Starlight uses; unknown values mean "follow the OS". */
17
+ const STORAGE_KEY = 'starlight-theme'
18
+ const parseTheme = (theme: unknown): Theme =>
19
+ theme === 'auto' || theme === 'dark' || theme === 'light' ? theme : 'auto'
20
+ const preferredColorScheme = (): Theme =>
21
+ matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
22
+
23
+ // `pageshow` is the only notification of a bfcache restore; a normal load (`persisted: false`)
24
+ // is left to Starlight.
25
+ window.addEventListener('pageshow', (event) => {
26
+ if (!event.persisted) return
27
+
28
+ let stored: string | null
29
+ try {
30
+ stored = localStorage.getItem(STORAGE_KEY)
31
+ } catch {
32
+ // Storage can throw outright (Safari private mode, blocked cookies).
33
+ // Leaving the restored theme alone is the right fallback.
34
+ return
35
+ }
36
+
37
+ const theme = parseTheme(stored)
38
+ document.documentElement.dataset.theme = theme === 'auto' ? preferredColorScheme() : theme
39
+ // Keeps the picker from disagreeing with the page it controls.
40
+ StarlightThemeProvider.updatePickers(theme)
41
+ })
42
+ </script>
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared Starlight configuration for the rxova docs sites; each site adds only its sidebar and
3
+ * extras. Imported under Node (no CSS or components) and typed structurally, not as Starlight's.
4
+ */
5
+
6
+ import { RXOVA_ORIGIN, getProject, type ProjectId } from '@rxova/brand'
7
+
8
+ export interface SharedStarlightOptions {
9
+ /** Which project's docs this site is. */
10
+ project: ProjectId
11
+ /** Starlight sidebar config — the one thing every site defines itself. */
12
+ sidebar: unknown[]
13
+ /** Extra stylesheets, appended after the shared ones so they win. */
14
+ customCss?: string[]
15
+ /** Extra Starlight component overrides, merged over the shared ones. */
16
+ components?: Record<string, string>
17
+ /** Path under the repo root that holds the docs site, for the edit link. */
18
+ editLinkBase?: string
19
+ /** Build body-only docs for rxova-website's shell: Starlight's page UI stays, the footer goes. */
20
+ pageComponent?: boolean
21
+ }
22
+
23
+ export function sharedStarlightConfig({
24
+ project,
25
+ sidebar,
26
+ customCss = [],
27
+ components = {},
28
+ editLinkBase = 'apps/docs',
29
+ pageComponent = false,
30
+ }: SharedStarlightOptions) {
31
+ const self = getProject(project)
32
+
33
+ return {
34
+ title: self.label,
35
+ description: self.tagline,
36
+ // One origin, one tab icon. Each site must have this file in `public/`: Starlight resolves
37
+ // `favicon` against the site's own static directory.
38
+ favicon: '/favicon.svg',
39
+
40
+ // No `logo` on purpose: the SiteTitle override renders the mark from @rxova/brand's assets.
41
+
42
+ social: [
43
+ { icon: 'github' as const, label: 'GitHub', href: self.repo },
44
+ { icon: 'npm' as const, label: 'npm', href: self.npm },
45
+ ],
46
+
47
+ editLink: {
48
+ baseUrl: `${self.repo}/edit/main/${editLinkBase}/`,
49
+ },
50
+
51
+ // Order matters: fonts, then tokens+mapping, then per-site overrides.
52
+ customCss: ['@rxova/brand/fonts.css', '@rxova/astro-ui/styles/starlight.css', ...customCss],
53
+
54
+ components: {
55
+ // The rxova mark (linking back to the umbrella site) plus the project wordmark.
56
+ SiteTitle: '@rxova/astro-ui/starlight/SiteTitle.astro',
57
+ // Appends the cross-project switcher to the social icons. Without it the
58
+ // three docs sites are three islands under one domain.
59
+ SocialIcons: '@rxova/astro-ui/starlight/SocialIcons.astro',
60
+ // Starlight's default footer (pagination, edit link, last updated) plus
61
+ // the shared four-column site footer beneath it.
62
+ ...(!pageComponent ? { Footer: '@rxova/astro-ui/starlight/Footer.astro' } : {}),
63
+ // Starlight's own picker, plus a resync when a page is restored from the bfcache.
64
+ ThemeSelect: '@rxova/astro-ui/starlight/ThemeSelect.astro',
65
+ ...components,
66
+ },
67
+
68
+ head: [
69
+ {
70
+ tag: 'meta' as const,
71
+ attrs: { property: 'og:image', content: `${RXOVA_ORIGIN}/og/${project}.png` },
72
+ },
73
+ {
74
+ tag: 'meta' as const,
75
+ attrs: { name: 'twitter:card', content: 'summary_large_image' },
76
+ },
77
+ // SoftwareSourceCode JSON-LD built from PROJECTS, on every docs page: Starlight has no
78
+ // "site index only" hook.
79
+ {
80
+ tag: 'script' as const,
81
+ attrs: { type: 'application/ld+json' },
82
+ content: JSON.stringify({
83
+ '@context': 'https://schema.org',
84
+ '@type': 'SoftwareSourceCode',
85
+ name: self.label,
86
+ description: self.tagline,
87
+ url: `${RXOVA_ORIGIN}${self.mount}`,
88
+ codeRepository: self.repo,
89
+ programmingLanguage: 'TypeScript',
90
+ runtimePlatform: 'Node.js',
91
+ license: 'https://opensource.org/licenses/MIT',
92
+ author: { '@type': 'Person', name: 'Jonatan Kruszewski' },
93
+ }).replace(/</g, '\\u003c'),
94
+ },
95
+ ],
96
+
97
+ // Pagefind ships with Starlight and replaces the third-party search plugin
98
+ // journey was carrying.
99
+ pagefind: true,
100
+
101
+ sidebar,
102
+ }
103
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * What the shared header and footer need, and nothing document-level: the tokens and the footer.
3
+ * No reset, which is document.css: unlayered, it would flatten a Starlight page this is composed into.
4
+ */
5
+
6
+ @import '@rxova/brand/tokens.css';
7
+ /* The footer every surface renders, Starlight or not. */
8
+ @import './footer.css';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * For sites that own their document (the landing, /blog, /updates): chrome.css plus a reset and base
3
+ * element styles. Never inside a Starlight document, where the unlayered reset would flatten the page.
4
+ */
5
+
6
+ @import './chrome.css';
7
+
8
+ /* --- Reset ---------------------------------------------------------------- */
9
+
10
+ *,
11
+ *::before,
12
+ *::after {
13
+ box-sizing: border-box;
14
+ margin: 0;
15
+ padding: 0;
16
+ }
17
+
18
+ html {
19
+ -webkit-text-size-adjust: 100%;
20
+ scroll-behavior: smooth;
21
+ }
22
+
23
+ @media (prefers-reduced-motion: reduce) {
24
+ html {
25
+ scroll-behavior: auto;
26
+ }
27
+ }
28
+
29
+ body {
30
+ position: relative;
31
+ background: var(--rx-bg);
32
+ color: var(--rx-fg);
33
+ font-family: var(--rx-font-sans);
34
+ font-size: 1.0625rem;
35
+ line-height: 1.65;
36
+ -webkit-font-smoothing: antialiased;
37
+ text-rendering: optimizeLegibility;
38
+ transition:
39
+ background-color 0.2s ease,
40
+ color 0.2s ease;
41
+
42
+ /* At least a screen tall, with `main` taking the slack, so a short page keeps its footer at the
43
+ bottom. Flex rather than grid because the number of children varies. */
44
+ display: flex;
45
+ flex-direction: column;
46
+ min-height: 100dvh;
47
+ }
48
+
49
+ /* Flex items with `margin: 0 auto` shrink to their content; full width restores block behaviour.
50
+ `:where()` keeps both rules at zero specificity. */
51
+ :where(body > *) {
52
+ width: 100%;
53
+ }
54
+
55
+ :where(body > main) {
56
+ flex-grow: 1;
57
+ }
58
+
59
+ a {
60
+ color: inherit;
61
+ }
62
+
63
+ a:focus-visible {
64
+ outline: 2px solid var(--rx-fg);
65
+ outline-offset: 3px;
66
+ border-radius: 2px;
67
+ }
68
+
69
+ img {
70
+ max-width: 100%;
71
+ height: auto;
72
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Styles for `components/SiteFooter.astro`, shared by Starlight and plain Astro surfaces.
3
+ * Imported by `starlight.css` and `chrome.css`; uses `--rx-*` tokens only.
4
+ */
5
+
6
+ @import '@rxova/brand/tokens.css';
7
+
8
+ .rx-footer {
9
+ margin-top: 4rem;
10
+ padding: 3rem 0 2rem;
11
+ border-top: 1px solid var(--rx-rule);
12
+ color: var(--rx-muted);
13
+ font-size: 0.875rem;
14
+ }
15
+
16
+ /* Brand block and link columns side by side where the container (not viewport) has room.
17
+ `min(100%, 20rem)` lets the track floor collapse so a narrow footer never overflows the page. */
18
+ .rx-footer__top {
19
+ display: grid;
20
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
21
+ gap: 2.5rem 3rem;
22
+ }
23
+
24
+ .rx-footer__brand {
25
+ display: flex;
26
+ flex-direction: column;
27
+ gap: 0.75rem;
28
+ align-items: flex-start;
29
+ }
30
+
31
+ .rx-footer__mark {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: 0.55rem;
35
+ color: var(--rx-fg);
36
+ font-size: 1rem;
37
+ font-weight: 640;
38
+ letter-spacing: -0.01em;
39
+ text-decoration: none;
40
+ }
41
+
42
+ .rx-footer__mark img {
43
+ display: block;
44
+ border-radius: 6px;
45
+ }
46
+
47
+ .rx-footer__blurb {
48
+ margin: 0;
49
+ max-width: 30ch;
50
+ color: var(--rx-faint);
51
+ line-height: 1.6;
52
+ }
53
+
54
+ /* 8rem: the widest track that still fits two columns on a 360px phone. */
55
+ .rx-footer__columns {
56
+ display: grid;
57
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
58
+ gap: 2rem 1.5rem;
59
+ }
60
+
61
+ .rx-footer__title {
62
+ margin-bottom: 0.75rem;
63
+ color: var(--rx-fg);
64
+ font-size: 0.75rem;
65
+ font-weight: 700;
66
+ letter-spacing: 0.06em;
67
+ text-transform: uppercase;
68
+ }
69
+
70
+ .rx-footer__list {
71
+ display: flex;
72
+ flex-direction: column;
73
+ gap: 0.45rem;
74
+ margin: 0;
75
+ padding: 0;
76
+ list-style: none;
77
+ }
78
+
79
+ .rx-footer__list a {
80
+ color: var(--rx-muted);
81
+ text-decoration: none;
82
+ }
83
+
84
+ .rx-footer__list a:hover {
85
+ color: var(--rx-fg);
86
+ text-decoration: underline;
87
+ text-underline-offset: 2px;
88
+ }
89
+
90
+ /* The bottom bar: copyright on the left, the legal links on the right, and one
91
+ column on a phone. */
92
+ .rx-footer__legal {
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: space-between;
96
+ flex-wrap: wrap;
97
+ gap: 0.5rem 1.25rem;
98
+ margin-top: 2.5rem;
99
+ padding-top: 1.25rem;
100
+ border-top: 1px solid var(--rx-rule);
101
+ color: var(--rx-faint);
102
+ font-size: 0.8125rem;
103
+ }
104
+
105
+ .rx-footer__legal p {
106
+ margin: 0;
107
+ }
108
+
109
+ .rx-footer__legal-links {
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 1.25rem;
113
+ }
114
+
115
+ .rx-footer__legal a {
116
+ color: var(--rx-faint);
117
+ text-decoration: none;
118
+ }
119
+
120
+ .rx-footer__legal a:hover {
121
+ color: var(--rx-fg);
122
+ text-decoration: underline;
123
+ text-underline-offset: 2px;
124
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The rules a Starlight splash landing needs around the landing components. Load it through
3
+ * `customCss`; every rule is gated on `[data-has-hero]`, which Starlight sets only on a page with a hero.
4
+ */
5
+
6
+ /* Starlight's 45rem prose measure is wrong for a stat band or a card grid; take the brand's wide bound. */
7
+ html[data-has-hero] .sl-container {
8
+ max-width: var(--rx-max-wide);
9
+ }
10
+
11
+ /* The sections carry their own rhythm, so Starlight's 1.5rem between top-level children compounds. */
12
+ html[data-has-hero] .sl-container > * + * {
13
+ margin-top: 0;
14
+ }
15
+
16
+ /* Starlight's clamp tops out at 10rem of padding, which pushes the first call to action below the fold. */
17
+ html[data-has-hero] .hero {
18
+ padding-block: clamp(2.5rem, 1rem + 6vmin, 5rem);
19
+ }
20
+
21
+ /* Without a hero image the second grid column is dead space holding the headline off to one side. */
22
+ html[data-has-hero] .hero:not(:has(img)) {
23
+ grid-template-columns: 1fr;
24
+ }
25
+
26
+ /* Starlight caps every hero child at 50ch: right for the tagline, far too wide for a display headline. */
27
+ html[data-has-hero] .hero h1 {
28
+ max-width: 22ch;
29
+ text-wrap: balance;
30
+ }
31
+
32
+ html[data-has-hero] .hero .tagline {
33
+ max-width: 56ch;
34
+ text-wrap: pretty;
35
+ }
36
+
37
+ /* The first section's 7rem margin is separating from the hero, which already ends in its own padding. */
38
+ html[data-has-hero] .sl-markdown-content > :first-child {
39
+ margin-top: 2rem;
40
+ }
41
+
42
+ /* A pill above the headline, for a version: the hero title is frontmatter HTML, so it is styled by class. */
43
+ html[data-has-hero] .hero .rx-tag {
44
+ display: inline-block;
45
+ margin-bottom: 0.9rem;
46
+ padding: 0.28rem 0.7rem;
47
+ color: var(--rx-primary);
48
+ background: var(--rx-tag-bg);
49
+ border-radius: var(--rx-radius-pill);
50
+ font-family: var(--rx-font-mono);
51
+ font-size: 0.78rem;
52
+ font-weight: 500;
53
+ letter-spacing: 0;
54
+ line-height: 1.4;
55
+ }