@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.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/package.json +65 -6
- package/src/components/BackLink.astro +24 -0
- package/src/components/CodeRecipes.astro +67 -0
- package/src/components/CtaBand.astro +121 -0
- package/src/components/DataTable.astro +114 -0
- package/src/components/DocAccordion.astro +15 -0
- package/src/components/DocAccordionItem.astro +83 -0
- package/src/components/Header.astro +148 -0
- package/src/components/Icon.astro +29 -0
- package/src/components/ModeTabs.astro +124 -0
- package/src/components/PageHeader.astro +63 -0
- package/src/components/ProjectSwitcher.astro +95 -0
- package/src/components/ProofStats.astro +207 -0
- package/src/components/QuickStart.astro +156 -0
- package/src/components/ScreenshotGrid.astro +164 -0
- package/src/components/Section.astro +103 -0
- package/src/components/ShowMore.astro +82 -0
- package/src/components/SiteFooter.astro +149 -0
- package/src/components/SiteShell.astro +155 -0
- package/src/components/SizeTable.astro +118 -0
- package/src/components/ThemeScript.astro +31 -0
- package/src/components/ThemeToggle.astro +163 -0
- package/src/components/ValueGrid.astro +116 -0
- package/src/components/VisuallyHidden.astro +23 -0
- package/src/components/icons/Collapse.astro +11 -0
- package/src/components/icons/Expand.astro +11 -0
- package/src/components/icons/Next.astro +11 -0
- package/src/components/icons/Pause.astro +11 -0
- package/src/components/icons/Play.astro +11 -0
- package/src/components/icons/Prev.astro +11 -0
- package/src/components/icons/Replay.astro +11 -0
- package/src/env.d.ts +15 -0
- package/src/lib/entries.ts +72 -0
- package/src/lib/icons.ts +15 -0
- package/src/scripts/show-more.ts +46 -0
- package/src/starlight/Footer.astro +16 -0
- package/src/starlight/SiteTitle.astro +34 -0
- package/src/starlight/SocialIcons.astro +18 -0
- package/src/starlight/ThemeSelect.astro +42 -0
- package/src/starlight/index.ts +103 -0
- package/src/styles/chrome.css +8 -0
- package/src/styles/document.css +72 -0
- package/src/styles/footer.css +124 -0
- package/src/styles/landing.css +55 -0
- package/src/styles/mermaid.css +75 -0
- package/src/styles/starlight.css +162 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The single rxova.org header, rendered by `SiteShell` and the website landing; callers pass
|
|
4
|
+
* the menu fully resolved. Not for the Starlight docs sites, which use `sharedStarlightConfig`.
|
|
5
|
+
*/
|
|
6
|
+
import ThemeToggle from './ThemeToggle.astro'
|
|
7
|
+
|
|
8
|
+
export interface HeaderItem {
|
|
9
|
+
label: string
|
|
10
|
+
/** Fully resolved by the caller — absolute or root-relative as its base needs. */
|
|
11
|
+
href: string
|
|
12
|
+
/** Marks the item for the surface the reader is on. */
|
|
13
|
+
current?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
/** Where the brand mark links — the site root, resolved by the caller. */
|
|
18
|
+
homeHref: string
|
|
19
|
+
/** Logo `src`, resolved by the caller for its base. */
|
|
20
|
+
logoSrc: string
|
|
21
|
+
/** The menu, in the one fixed order every surface shows it. */
|
|
22
|
+
items: readonly HeaderItem[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { homeHref, logoSrc, items } = Astro.props
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
{/* Outside <main> so it spans the viewport; sticky, not fixed, so nothing needs a top margin. */}
|
|
29
|
+
<header class="site">
|
|
30
|
+
<div class="site__inner">
|
|
31
|
+
<a class="brand" href={homeHref}>
|
|
32
|
+
<img src={logoSrc} alt="" width="28" height="28" />
|
|
33
|
+
<span>Rxova</span>
|
|
34
|
+
</a>
|
|
35
|
+
<nav aria-label="Sections">
|
|
36
|
+
{items.map((item) => (
|
|
37
|
+
<a href={item.href} aria-current={item.current ? 'page' : undefined}>
|
|
38
|
+
{item.label}
|
|
39
|
+
</a>
|
|
40
|
+
))}
|
|
41
|
+
</nav>
|
|
42
|
+
{/* In the header, not floating over it — see ThemeToggle's `floating`. */}
|
|
43
|
+
<ThemeToggle floating={false} />
|
|
44
|
+
</div>
|
|
45
|
+
</header>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
/* Sticky, translucent, full-bleed. */
|
|
49
|
+
.site {
|
|
50
|
+
position: sticky;
|
|
51
|
+
top: 0;
|
|
52
|
+
z-index: 10;
|
|
53
|
+
border-bottom: 1px solid var(--rx-rule);
|
|
54
|
+
background: color-mix(in srgb, var(--rx-bg) 88%, transparent);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* The blur keeps text legible under the bar; where unsupported, the 88% mix above stays
|
|
58
|
+
nearly opaque. */
|
|
59
|
+
@supports (backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px)) {
|
|
60
|
+
.site {
|
|
61
|
+
background: color-mix(in srgb, var(--rx-bg) 72%, transparent);
|
|
62
|
+
-webkit-backdrop-filter: blur(10px);
|
|
63
|
+
backdrop-filter: blur(10px);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.site__inner {
|
|
68
|
+
max-width: var(--rx-max-wide);
|
|
69
|
+
margin: 0 auto;
|
|
70
|
+
padding: 0.75rem 1.5rem;
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 1rem;
|
|
74
|
+
/* No wrapping: a two-line header would eat a phone's viewport, and the row
|
|
75
|
+
is sized to fit at the narrowest widths we support. */
|
|
76
|
+
min-height: 3.5rem;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.brand {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 0.55rem;
|
|
83
|
+
color: var(--rx-fg);
|
|
84
|
+
text-decoration: none;
|
|
85
|
+
font-weight: 640;
|
|
86
|
+
letter-spacing: -0.01em;
|
|
87
|
+
}
|
|
88
|
+
.brand img {
|
|
89
|
+
display: block;
|
|
90
|
+
border-radius: 6px;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* The nav takes the space between the brand and the toggle, and pushes itself
|
|
94
|
+
to the right of it. */
|
|
95
|
+
nav {
|
|
96
|
+
margin-left: auto;
|
|
97
|
+
display: flex;
|
|
98
|
+
gap: 1.4rem;
|
|
99
|
+
}
|
|
100
|
+
nav a {
|
|
101
|
+
color: var(--rx-muted);
|
|
102
|
+
text-decoration: none;
|
|
103
|
+
font-size: 0.95rem;
|
|
104
|
+
white-space: nowrap;
|
|
105
|
+
}
|
|
106
|
+
nav a:hover {
|
|
107
|
+
color: var(--rx-fg);
|
|
108
|
+
}
|
|
109
|
+
/* The current section is marked for everyone, not only for assistive tech —
|
|
110
|
+
aria-current on its own is invisible. */
|
|
111
|
+
nav a[aria-current='page'] {
|
|
112
|
+
color: var(--rx-fg);
|
|
113
|
+
text-decoration: underline;
|
|
114
|
+
text-underline-offset: 5px;
|
|
115
|
+
}
|
|
116
|
+
nav a:focus-visible {
|
|
117
|
+
outline: 2px solid var(--rx-fg);
|
|
118
|
+
outline-offset: 3px;
|
|
119
|
+
border-radius: 2px;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Brand mark, the links and the toggle stay on one row at the narrowest phones
|
|
123
|
+
by dropping the wordmark — the logo carries the identity in the space left. */
|
|
124
|
+
@media (max-width: 34rem) {
|
|
125
|
+
.site__inner {
|
|
126
|
+
gap: 0.75rem;
|
|
127
|
+
padding-inline: 1rem;
|
|
128
|
+
min-height: 3.25rem;
|
|
129
|
+
}
|
|
130
|
+
nav {
|
|
131
|
+
gap: 0.9rem;
|
|
132
|
+
}
|
|
133
|
+
nav a {
|
|
134
|
+
font-size: 0.875rem;
|
|
135
|
+
}
|
|
136
|
+
.brand span {
|
|
137
|
+
display: none;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
</style>
|
|
141
|
+
|
|
142
|
+
<!-- Global because `html` sits outside Astro's scoping. Without it a jump to an
|
|
143
|
+
in-page anchor would land under the sticky header. -->
|
|
144
|
+
<style is:global>
|
|
145
|
+
html {
|
|
146
|
+
scroll-padding-top: 4.5rem;
|
|
147
|
+
}
|
|
148
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from 'astro/types'
|
|
3
|
+
|
|
4
|
+
/** The frame every glyph in `icons/` draws in: a 16px grid in the text colour. Decorative: label the button. */
|
|
5
|
+
type Props = HTMLAttributes<'svg'> & {
|
|
6
|
+
/** Width and height, in pixels. */
|
|
7
|
+
size?: number
|
|
8
|
+
/** Fills the shape instead of stroking it. */
|
|
9
|
+
solid?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { size = 16, solid = false, class: className, ...rest } = Astro.props
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<svg
|
|
16
|
+
class:list={['icon', className]}
|
|
17
|
+
viewBox="0 0 16 16"
|
|
18
|
+
width={size}
|
|
19
|
+
height={size}
|
|
20
|
+
fill={solid ? 'currentColor' : 'none'}
|
|
21
|
+
stroke={solid ? 'none' : 'currentColor'}
|
|
22
|
+
stroke-width="1.6"
|
|
23
|
+
stroke-linecap="round"
|
|
24
|
+
stroke-linejoin="round"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
{...rest}
|
|
27
|
+
>
|
|
28
|
+
<slot />
|
|
29
|
+
</svg>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A numbered progression of modes, each a prose column beside a code pane. Panes are named slots
|
|
4
|
+
* (one per `mode.slot`), so a page writes fenced blocks and gets the docs' Shiki highlighting.
|
|
5
|
+
*/
|
|
6
|
+
export interface Mode {
|
|
7
|
+
name: string
|
|
8
|
+
/** The named slot holding this mode's code. */
|
|
9
|
+
slot: string
|
|
10
|
+
summary: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
modes: readonly Mode[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const { modes } = Astro.props
|
|
18
|
+
const panes = await Promise.all(
|
|
19
|
+
modes.map(async (mode) => ({
|
|
20
|
+
...mode,
|
|
21
|
+
html: Astro.slots.has(mode.slot) ? await Astro.slots.render(mode.slot) : '',
|
|
22
|
+
})),
|
|
23
|
+
)
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
<ol class="rx-modes">
|
|
27
|
+
{panes.map((mode, index) => (
|
|
28
|
+
<li class="rx-modes__item">
|
|
29
|
+
<div class="rx-modes__prose">
|
|
30
|
+
<div class="rx-modes__head">
|
|
31
|
+
<span class="rx-modes__step" aria-hidden="true">
|
|
32
|
+
{index + 1}
|
|
33
|
+
</span>
|
|
34
|
+
<h3 class="rx-modes__name">{mode.name}</h3>
|
|
35
|
+
</div>
|
|
36
|
+
<p class="rx-modes__summary">{mode.summary}</p>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="rx-modes__code" set:html={mode.html} />
|
|
39
|
+
</li>
|
|
40
|
+
))}
|
|
41
|
+
</ol>
|
|
42
|
+
|
|
43
|
+
<style>
|
|
44
|
+
.rx-modes {
|
|
45
|
+
display: grid;
|
|
46
|
+
gap: 1.25rem;
|
|
47
|
+
margin: 0;
|
|
48
|
+
padding: 0;
|
|
49
|
+
list-style: none;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Full-width rows, not columns: snippets are wider than a third of the page and would truncate. */
|
|
53
|
+
.rx-modes__item {
|
|
54
|
+
display: grid;
|
|
55
|
+
gap: 1.25rem;
|
|
56
|
+
min-width: 0;
|
|
57
|
+
padding: 1.5rem;
|
|
58
|
+
background: var(--rx-card);
|
|
59
|
+
border: 1px solid var(--rx-rule);
|
|
60
|
+
border-radius: var(--rx-radius);
|
|
61
|
+
grid-template-columns: 1fr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@media (min-width: 50rem) {
|
|
65
|
+
.rx-modes__item {
|
|
66
|
+
grid-template-columns: 17rem 1fr;
|
|
67
|
+
align-items: start;
|
|
68
|
+
gap: 2rem;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.rx-modes__head {
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 0.7rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.rx-modes__step {
|
|
79
|
+
display: inline-flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: center;
|
|
82
|
+
width: 1.6rem;
|
|
83
|
+
height: 1.6rem;
|
|
84
|
+
flex: none;
|
|
85
|
+
color: var(--rx-primary);
|
|
86
|
+
background: var(--rx-tag-bg);
|
|
87
|
+
border-radius: var(--rx-radius-pill);
|
|
88
|
+
font-family: var(--rx-font-mono);
|
|
89
|
+
font-size: 0.8rem;
|
|
90
|
+
font-weight: 500;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.rx-modes__name {
|
|
94
|
+
margin: 0;
|
|
95
|
+
color: var(--rx-fg);
|
|
96
|
+
font-size: 1.05rem;
|
|
97
|
+
font-weight: 620;
|
|
98
|
+
letter-spacing: -0.01em;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.rx-modes__summary {
|
|
102
|
+
margin: 0.7rem 0 0;
|
|
103
|
+
color: var(--rx-muted);
|
|
104
|
+
font-size: 0.92rem;
|
|
105
|
+
line-height: 1.6;
|
|
106
|
+
text-wrap: pretty;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* min-width: 0 so a long line scrolls inside the pane instead of widening the card past the page. */
|
|
110
|
+
.rx-modes__code {
|
|
111
|
+
min-width: 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.rx-modes__code :global(pre) {
|
|
115
|
+
margin: 0;
|
|
116
|
+
padding: 0.95rem 1.1rem;
|
|
117
|
+
background: var(--rx-bg);
|
|
118
|
+
border: 1px solid var(--rx-rule);
|
|
119
|
+
border-radius: var(--rx-radius-sm);
|
|
120
|
+
overflow-x: auto;
|
|
121
|
+
font-size: 0.8rem;
|
|
122
|
+
line-height: 1.6;
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A page's title and lede, with an optional back link. The default slot is the lede.
|
|
4
|
+
* `detail` keeps the repo pages' heavier title, which still inherits the body's line height.
|
|
5
|
+
*/
|
|
6
|
+
import BackLink from './BackLink.astro'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
title: string
|
|
10
|
+
back?: { href: string; label: string }
|
|
11
|
+
variant?: 'index' | 'detail'
|
|
12
|
+
/** The lede's measure: `narrow` is 46ch, `wide` 56ch. */
|
|
13
|
+
measure?: 'narrow' | 'wide'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { title, back, variant = 'index', measure = 'wide' } = Astro.props
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<header class:list={['head', variant]}>
|
|
20
|
+
{back && (
|
|
21
|
+
<BackLink class="back" href={back.href}>
|
|
22
|
+
{back.label}
|
|
23
|
+
</BackLink>
|
|
24
|
+
)}
|
|
25
|
+
<h1>{title}</h1>
|
|
26
|
+
<p class:list={['lede', measure]}>
|
|
27
|
+
<slot />
|
|
28
|
+
</p>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.head {
|
|
33
|
+
margin-bottom: clamp(2rem, 6vh, 3rem);
|
|
34
|
+
}
|
|
35
|
+
.back {
|
|
36
|
+
margin-bottom: 1.25rem;
|
|
37
|
+
}
|
|
38
|
+
h1 {
|
|
39
|
+
font-size: clamp(1.8rem, 5vw, 2.4rem);
|
|
40
|
+
letter-spacing: -0.02em;
|
|
41
|
+
margin-bottom: 0.6rem;
|
|
42
|
+
}
|
|
43
|
+
/* One title across /about, /blog and /updates, so switching pages does not move it. */
|
|
44
|
+
.index h1 {
|
|
45
|
+
line-height: 1.15;
|
|
46
|
+
font-weight: 650;
|
|
47
|
+
}
|
|
48
|
+
.detail h1 {
|
|
49
|
+
font-weight: 660;
|
|
50
|
+
}
|
|
51
|
+
.lede {
|
|
52
|
+
color: var(--rx-muted);
|
|
53
|
+
}
|
|
54
|
+
.narrow {
|
|
55
|
+
max-width: 46ch;
|
|
56
|
+
}
|
|
57
|
+
.wide {
|
|
58
|
+
max-width: 56ch;
|
|
59
|
+
}
|
|
60
|
+
.lede :global(a) {
|
|
61
|
+
color: var(--rx-fg);
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Cross-project navigation as a zero-JS `<details>`. Every href is absolute: a relative one would
|
|
4
|
+
* resolve under the current site's base path.
|
|
5
|
+
*/
|
|
6
|
+
import { PROJECTS, RXOVA_ORIGIN, type ProjectId } from '@rxova/brand'
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** Which project this site is, so it can be marked as current. */
|
|
10
|
+
current?: ProjectId
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { current } = Astro.props
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<details class="rx-switcher">
|
|
17
|
+
<summary class="rx-switcher__trigger" aria-label="Switch project">
|
|
18
|
+
Projects
|
|
19
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="12" height="12">
|
|
20
|
+
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.75"></path>
|
|
21
|
+
</svg>
|
|
22
|
+
</summary>
|
|
23
|
+
<ul class="rx-switcher__menu">
|
|
24
|
+
<li>
|
|
25
|
+
<a href={RXOVA_ORIGIN}>rxova.org</a>
|
|
26
|
+
</li>
|
|
27
|
+
{PROJECTS.map((project) => (
|
|
28
|
+
<li>
|
|
29
|
+
<a
|
|
30
|
+
href={`${RXOVA_ORIGIN}${project.mount}`}
|
|
31
|
+
class={project.id === current ? 'rx-switcher__current' : undefined}
|
|
32
|
+
aria-current={project.id === current ? 'page' : undefined}
|
|
33
|
+
>
|
|
34
|
+
{project.label}
|
|
35
|
+
</a>
|
|
36
|
+
</li>
|
|
37
|
+
))}
|
|
38
|
+
</ul>
|
|
39
|
+
</details>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.rx-switcher__trigger {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: 0.3rem;
|
|
46
|
+
padding: 0.25rem 0.5rem;
|
|
47
|
+
border-radius: var(--rx-radius-sm);
|
|
48
|
+
color: var(--sl-color-gray-2);
|
|
49
|
+
font-size: var(--sl-text-sm);
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
list-style: none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.rx-switcher__trigger::-webkit-details-marker {
|
|
55
|
+
display: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.rx-switcher__trigger:hover {
|
|
59
|
+
color: var(--sl-color-white);
|
|
60
|
+
background: var(--sl-color-gray-6);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.rx-switcher[open] > .rx-switcher__trigger svg {
|
|
64
|
+
transform: rotate(180deg);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.rx-switcher__menu {
|
|
68
|
+
position: absolute;
|
|
69
|
+
z-index: var(--sl-z-index-navbar, 20);
|
|
70
|
+
inset-inline-end: 0;
|
|
71
|
+
min-width: 12rem;
|
|
72
|
+
margin: 0.4rem 0 0;
|
|
73
|
+
padding: 0.35rem;
|
|
74
|
+
border: 1px solid var(--sl-color-hairline);
|
|
75
|
+
border-radius: var(--rx-radius);
|
|
76
|
+
background: var(--sl-color-bg-nav);
|
|
77
|
+
box-shadow: var(--sl-shadow-md);
|
|
78
|
+
list-style: none;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.rx-switcher__menu a {
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
padding: 0.4rem 0.6rem;
|
|
85
|
+
border-radius: var(--rx-radius-sm);
|
|
86
|
+
color: var(--sl-color-gray-2);
|
|
87
|
+
font-size: var(--sl-text-sm);
|
|
88
|
+
text-decoration: none;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.rx-switcher__menu a:hover {
|
|
92
|
+
color: var(--sl-color-white);
|
|
93
|
+
background: var(--sl-color-gray-6);
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The proof band: a gradient-framed panel of figures that count up the first time it scrolls into view.
|
|
4
|
+
* Figures are server-rendered at their final value, so with JS off or motion reduced it is a static panel.
|
|
5
|
+
*/
|
|
6
|
+
export interface Stat {
|
|
7
|
+
value: string
|
|
8
|
+
label: string
|
|
9
|
+
note: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
stats: readonly Stat[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { stats } = Astro.props
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<div class="rx-stats">
|
|
20
|
+
<div class="rx-stats__glow" aria-hidden="true"></div>
|
|
21
|
+
<div class="rx-stats__frame">
|
|
22
|
+
<ul class="rx-stats__grid">
|
|
23
|
+
{stats.map((stat) => (
|
|
24
|
+
<li class="rx-stats__item">
|
|
25
|
+
<p class="rx-stats__value">{stat.value}</p>
|
|
26
|
+
<p class="rx-stats__label">{stat.label}</p>
|
|
27
|
+
<p class="rx-stats__note">{stat.note}</p>
|
|
28
|
+
</li>
|
|
29
|
+
))}
|
|
30
|
+
</ul>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<script>
|
|
35
|
+
const DURATION = 1100
|
|
36
|
+
|
|
37
|
+
const animate = (panel: HTMLElement) => {
|
|
38
|
+
// Values are display strings ("7.58 kB", "95%"); the leading number animates and the rest stays.
|
|
39
|
+
const figures = [...panel.querySelectorAll<HTMLElement>('.rx-stats__value')].flatMap((el) => {
|
|
40
|
+
const match = /^(\d+(?:\.\d+)?)(.*)$/s.exec(el.textContent ?? '')
|
|
41
|
+
if (!match) return []
|
|
42
|
+
const [, number = '', suffix = ''] = match
|
|
43
|
+
const final = el.textContent ?? ''
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
el,
|
|
47
|
+
target: Number(number),
|
|
48
|
+
decimals: (number.split('.')[1] ?? '').length,
|
|
49
|
+
suffix,
|
|
50
|
+
final,
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const frame = (start: number) => {
|
|
56
|
+
const t = Math.min(1, (performance.now() - start) / DURATION)
|
|
57
|
+
// easeOutExpo: quick to nearly-there, then settles.
|
|
58
|
+
const eased = t === 1 ? 1 : 1 - Math.pow(2, -10 * t)
|
|
59
|
+
for (const f of figures) {
|
|
60
|
+
f.el.textContent = t === 1 ? f.final : (f.target * eased).toFixed(f.decimals) + f.suffix
|
|
61
|
+
}
|
|
62
|
+
if (t < 1) requestAnimationFrame(() => frame(start))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const observer = new IntersectionObserver(
|
|
66
|
+
(entries) => {
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
if (!entry.isIntersecting) continue
|
|
69
|
+
observer.disconnect()
|
|
70
|
+
for (const f of figures) f.el.textContent = '0' + f.suffix
|
|
71
|
+
requestAnimationFrame(() => frame(performance.now()))
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{ threshold: 0.3 },
|
|
75
|
+
)
|
|
76
|
+
observer.observe(panel)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
80
|
+
for (const panel of document.querySelectorAll<HTMLElement>('.rx-stats')) animate(panel)
|
|
81
|
+
}
|
|
82
|
+
</script>
|
|
83
|
+
|
|
84
|
+
<style>
|
|
85
|
+
.rx-stats {
|
|
86
|
+
position: relative;
|
|
87
|
+
isolation: isolate;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* A blurred copy of the frame's gradient behind it; its own element, since the frame paints a background. */
|
|
91
|
+
.rx-stats__glow {
|
|
92
|
+
position: absolute;
|
|
93
|
+
z-index: -1;
|
|
94
|
+
inset: -1.25rem -0.5rem;
|
|
95
|
+
border-radius: calc(var(--rx-radius) + 1rem);
|
|
96
|
+
background: var(--rx-gradient);
|
|
97
|
+
background-size: 220% 100%;
|
|
98
|
+
filter: blur(38px);
|
|
99
|
+
opacity: 0.2;
|
|
100
|
+
animation: rx-stats-sweep 14s linear infinite;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* 1px of padding over a gradient is the border: border-image does not follow border-radius. */
|
|
104
|
+
.rx-stats__frame {
|
|
105
|
+
padding: 1px;
|
|
106
|
+
border-radius: var(--rx-radius);
|
|
107
|
+
background: var(--rx-gradient);
|
|
108
|
+
background-size: 220% 100%;
|
|
109
|
+
animation: rx-stats-sweep 14s linear infinite;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@keyframes rx-stats-sweep {
|
|
113
|
+
to {
|
|
114
|
+
background-position: 220% 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.rx-stats__grid {
|
|
119
|
+
display: grid;
|
|
120
|
+
margin: 0;
|
|
121
|
+
padding: 0;
|
|
122
|
+
list-style: none;
|
|
123
|
+
background: var(--rx-card);
|
|
124
|
+
border-radius: calc(var(--rx-radius) - 1px);
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
grid-template-columns: 1fr;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@media (min-width: 38rem) {
|
|
130
|
+
.rx-stats__grid {
|
|
131
|
+
grid-template-columns: repeat(2, 1fr);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@media (min-width: 58rem) {
|
|
136
|
+
.rx-stats__grid {
|
|
137
|
+
grid-template-columns: repeat(3, 1fr);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Dividers by outline, not grid gap: with an odd tile count a gap leaves a hole that reads as a broken tile. */
|
|
142
|
+
.rx-stats__item {
|
|
143
|
+
position: relative;
|
|
144
|
+
padding: 1.7rem 1.5rem;
|
|
145
|
+
box-shadow: 0 0 0 1px var(--rx-rule);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* A gradient wash that fades up from the bottom of the hovered tile. */
|
|
149
|
+
.rx-stats__item::after {
|
|
150
|
+
content: '';
|
|
151
|
+
position: absolute;
|
|
152
|
+
inset: 0;
|
|
153
|
+
background: var(--rx-gradient);
|
|
154
|
+
opacity: 0;
|
|
155
|
+
transition: opacity 0.3s ease;
|
|
156
|
+
pointer-events: none;
|
|
157
|
+
mask-image: linear-gradient(to top, #000, transparent 72%);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.rx-stats__item:hover::after {
|
|
161
|
+
opacity: 0.1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.rx-stats__value,
|
|
165
|
+
.rx-stats__label,
|
|
166
|
+
.rx-stats__note {
|
|
167
|
+
position: relative;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.rx-stats__value {
|
|
171
|
+
margin: 0;
|
|
172
|
+
color: var(--rx-fg);
|
|
173
|
+
font-size: clamp(2rem, 1.5rem + 1.9vw, 2.9rem);
|
|
174
|
+
font-weight: 640;
|
|
175
|
+
line-height: 1;
|
|
176
|
+
letter-spacing: -0.03em;
|
|
177
|
+
/* Tabular figures so a counting number cannot jitter its own width. */
|
|
178
|
+
font-variant-numeric: tabular-nums;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.rx-stats__label {
|
|
182
|
+
margin: 0.7rem 0 0;
|
|
183
|
+
color: var(--rx-fg);
|
|
184
|
+
font-size: 0.95rem;
|
|
185
|
+
font-weight: 600;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.rx-stats__note {
|
|
189
|
+
margin: 0.35rem 0 0;
|
|
190
|
+
/* --rx-muted, not --rx-faint: faint on the card is ~3.4:1 and fails AA here. */
|
|
191
|
+
color: var(--rx-muted);
|
|
192
|
+
font-size: 0.82rem;
|
|
193
|
+
line-height: 1.5;
|
|
194
|
+
text-wrap: pretty;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@media (prefers-reduced-motion: reduce) {
|
|
198
|
+
.rx-stats__glow,
|
|
199
|
+
.rx-stats__frame {
|
|
200
|
+
animation: none;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.rx-stats__item::after {
|
|
204
|
+
transition: none;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
</style>
|