@rxova/brand 0.4.0 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rxova/brand",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Design tokens, Starlight theme and shared site chrome for rxova.org",
6
6
  "keywords": [
package/src/astro.css CHANGED
@@ -13,6 +13,10 @@
13
13
  */
14
14
 
15
15
  @import './tokens.css';
16
+ /* The shared site footer, which /blog, /updates and the landing all render. It
17
+ is the one piece of chrome common to these surfaces and the Starlight ones,
18
+ so its rules sit in their own file and both entry points pull them in. */
19
+ @import './footer.css';
16
20
 
17
21
  :root {
18
22
  --bg: var(--rx-bg);
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  /**
3
- * The four-column rxova footer, shared by all four surfaces.
3
+ * The rxova footer, shared by every surface.
4
4
  *
5
5
  * Before this, the landing had a single dot-separated link row and the three
6
6
  * docs sites had a bare copyright line (journey went further and hid its footer
@@ -8,6 +8,14 @@
8
8
  * make one origin feel like one site — so it carries the full project list on
9
9
  * every page.
10
10
  *
11
+ * Shape: a brand block that says what rxova is, the link columns, then a bottom
12
+ * bar with the copyright and the legal links. The legal links are down there
13
+ * rather than in a column because that is where a reader looks for them, and it
14
+ * keeps the columns to things someone might actually want to click.
15
+ *
16
+ * Styles are in `../footer.css`, imported by both `astro.css` and
17
+ * `starlight.css` — see that file for why they are not scoped here.
18
+ *
11
19
  * All cross-project hrefs are absolute; see sites.ts for why.
12
20
  */
13
21
  import { PROJECTS, RXOVA_ORIGIN, getProject, siteUrl, type ProjectId } from '../sites.ts'
@@ -25,72 +33,102 @@ const year = new Date().getFullYear()
25
33
  ---
26
34
 
27
35
  <footer class="rx-footer">
28
- <div class="rx-footer__columns">
29
- {
30
- docs && docs.length > 0 && (
31
- <div>
32
- <h2 class="rx-footer__title">Docs</h2>
33
- <ul class="rx-footer__list">
34
- {docs.map((link) => (
36
+ <div class="rx-footer__top">
37
+ <div class="rx-footer__brand">
38
+ <a class="rx-footer__mark" href={RXOVA_ORIGIN}>
39
+ <img src={siteUrl('/rxova-logo-256.png')} alt="" width="28" height="28" loading="lazy" />
40
+ <span>rxova</span>
41
+ </a>
42
+ <p class="rx-footer__blurb">
43
+ Open-source React libraries, documented in one place. MIT licensed, built in the open.
44
+ </p>
45
+ </div>
46
+
47
+ <div class="rx-footer__columns">
48
+ {
49
+ docs && docs.length > 0 && (
50
+ <div>
51
+ <h2 class="rx-footer__title">Docs</h2>
52
+ <ul class="rx-footer__list">
53
+ {docs.map((link) => (
54
+ <li>
55
+ <a href={link.href}>{link.label}</a>
56
+ </li>
57
+ ))}
58
+ </ul>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ <div>
64
+ <h2 class="rx-footer__title">Projects</h2>
65
+ <ul class="rx-footer__list">
66
+ {
67
+ PROJECTS.map((p) => (
35
68
  <li>
36
- <a href={link.href}>{link.label}</a>
69
+ <a href={`${RXOVA_ORIGIN}${p.mount}`}>{p.label}</a>
37
70
  </li>
38
- ))}
39
- </ul>
40
- </div>
41
- )
42
- }
71
+ ))
72
+ }
73
+ </ul>
74
+ </div>
43
75
 
44
- <div>
45
- <h2 class="rx-footer__title">Projects</h2>
46
- <ul class="rx-footer__list">
47
- <li><a href={RXOVA_ORIGIN}>rxova.org</a></li>
48
- {
49
- PROJECTS.map((p) => (
50
- <li>
51
- <a href={`${RXOVA_ORIGIN}${p.mount}`}>{p.label}</a>
52
- </li>
53
- ))
54
- }
55
- </ul>
56
- </div>
76
+ {
77
+ self && (
78
+ <div>
79
+ <h2 class="rx-footer__title">Community</h2>
80
+ <ul class="rx-footer__list">
81
+ <li>
82
+ <a href={self.repo}>GitHub</a>
83
+ </li>
84
+ <li>
85
+ <a href={`${self.repo}/issues`}>Issues</a>
86
+ </li>
87
+ <li>
88
+ <a href={`${self.repo}/discussions`}>Discussions</a>
89
+ </li>
90
+ <li>
91
+ <a href={self.npm}>npm</a>
92
+ </li>
93
+ <li>
94
+ <a href={`${self.repo}/blob/main/CONTRIBUTING.md`}>Contributing</a>
95
+ </li>
96
+ </ul>
97
+ </div>
98
+ )
99
+ }
57
100
 
58
- {
59
- self && (
60
- <div>
61
- <h2 class="rx-footer__title">Community</h2>
62
- <ul class="rx-footer__list">
63
- <li>
64
- <a href={self.repo}>GitHub</a>
65
- </li>
66
- <li>
67
- <a href={`${self.repo}/issues`}>Issues</a>
68
- </li>
69
- <li>
70
- <a href={`${self.repo}/discussions`}>Discussions</a>
71
- </li>
72
- <li>
73
- <a href={self.npm}>npm</a>
74
- </li>
75
- <li>
76
- <a href={`${self.repo}/blob/main/CONTRIBUTING.md`}>Contributing</a>
77
- </li>
78
- </ul>
79
- </div>
80
- )
81
- }
101
+ {
102
+ /* Blog and Updates are here on every surface, docs sites included: they
103
+ were reachable only from the umbrella landing, which meant a reader
104
+ who arrived on a docs page from a search result never learned the
105
+ rest of the site existed. */
106
+ }
107
+ <div>
108
+ <h2 class="rx-footer__title">Site</h2>
109
+ <ul class="rx-footer__list">
110
+ <li><a href={RXOVA_ORIGIN}>Home</a></li>
111
+ <li><a href={siteUrl('/blog')}>Blog</a></li>
112
+ <li><a href={siteUrl('/updates')}>Updates</a></li>
113
+ </ul>
114
+ </div>
82
115
 
83
- <div>
84
- <h2 class="rx-footer__title">rxova</h2>
85
- <ul class="rx-footer__list">
86
- <li><a href={RXOVA_ORIGIN}>Home</a></li>
87
- <li><a href="https://github.com/rxova">GitHub org</a></li>
88
- <li><a href={siteUrl('/privacy')}>Privacy</a></li>
89
- <li><a href={siteUrl('/terms')}>Terms</a></li>
90
- <li><a href="mailto:rxova@proton.me">rxova@proton.me</a></li>
91
- </ul>
116
+ <div>
117
+ <h2 class="rx-footer__title">Connect</h2>
118
+ <ul class="rx-footer__list">
119
+ <li><a href="https://github.com/rxova">GitHub</a></li>
120
+ <li><a href="https://www.npmjs.com/org/rxova">npm</a></li>
121
+ <li><a href="mailto:rxova@proton.me">rxova@proton.me</a></li>
122
+ </ul>
123
+ </div>
92
124
  </div>
93
125
  </div>
94
126
 
95
- <p class="rx-footer__legal">© {year} rxova · MIT licensed</p>
127
+ <div class="rx-footer__legal">
128
+ <p>© {year} rxova · MIT licensed</p>
129
+ <div class="rx-footer__legal-links">
130
+ <a href={siteUrl('/privacy')}>Privacy</a>
131
+ <a href={siteUrl('/terms')}>Terms</a>
132
+ </div>
133
+ </div>
96
134
  </footer>
@@ -0,0 +1,321 @@
1
+ ---
2
+ /**
3
+ * The shell for rxova.org's non-Starlight surfaces — /blog and /updates.
4
+ *
5
+ * It lived in both packages as a 244-line file that differed on four lines, which
6
+ * is exactly how the menu came to render in a different order on each: a fix has to
7
+ * be made twice, and once was enough to forget.
8
+ *
9
+ * Not for the docs sites — those are Starlight and take their chrome from
10
+ * `sharedStarlightConfig`. This is for the surfaces that are plain Astro.
11
+ */
12
+ import '../fonts.css'
13
+ import '../astro.css'
14
+
15
+ import { RXOVA_ORIGIN, siteUrl } from '../sites.ts'
16
+
17
+ import SiteFooter from './SiteFooter.astro'
18
+ import ThemeScript from './ThemeScript.astro'
19
+ import ThemeToggle from './ThemeToggle.astro'
20
+
21
+ interface Props {
22
+ title: string
23
+ description: string
24
+ /** Canonical path, e.g. `/blog/why-rxova-has-a-blog`. */
25
+ path: string
26
+ ogType?: 'website' | 'article'
27
+ }
28
+
29
+ const { title, description, path, ogType = 'website' } = Astro.props
30
+ const canonical = `${RXOVA_ORIGIN}${path}`
31
+
32
+ /**
33
+ * The menu, in one fixed order for every surface.
34
+ *
35
+ * Which item is current is *derived* from the base the surface was built for, not
36
+ * declared per package — declaring it is what let the two drift into different
37
+ * orders. `BASE_URL` is substituted by the consuming app's build, so this is
38
+ * `/blog` inside @rxova/blog and `/updates` inside @rxova/updates.
39
+ */
40
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '')
41
+
42
+ const SECTIONS = [
43
+ { label: 'Blog', path: '/blog' },
44
+ { label: 'Updates', path: '/updates' },
45
+ ]
46
+
47
+ /**
48
+ * A URL inside this surface.
49
+ *
50
+ * Anything *leaving* it goes through `siteUrl`: a bare `/updates` would resolve
51
+ * against the mount and point at `/blog/updates`.
52
+ */
53
+ const inside = (p = '') => `${base}/${p}`.replace(/\/{2,}/g, '/')
54
+ ---
55
+
56
+ <!doctype html>
57
+ <html lang="en">
58
+ <head>
59
+ <meta charset="utf-8" />
60
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
61
+ <link rel="icon" type="image/png" href={siteUrl('/rxova-logo-256.png')} />
62
+ <link rel="apple-touch-icon" href={siteUrl('/rxova-logo-256.png')} />
63
+ <link rel="canonical" href={canonical} />
64
+ <title>{title}</title>
65
+ <meta name="description" content={description} />
66
+ <meta property="og:title" content={title} />
67
+ <meta property="og:description" content={description} />
68
+ <meta property="og:type" content={ogType} />
69
+ <meta property="og:url" content={canonical} />
70
+ <meta property="og:image" content={siteUrl('/og/rxova.png')} />
71
+ <meta name="twitter:card" content="summary_large_image" />
72
+ <ThemeScript />
73
+ </head>
74
+ <body>
75
+ {
76
+ /* Outside <main> so it can span the viewport while its contents stay on
77
+ the same measure as the page. Sticky rather than fixed: it still takes
78
+ part in layout, so nothing needs a compensating top margin. */
79
+ }
80
+ <header class="site">
81
+ <div class="site__inner">
82
+ <a class="brand" href={siteUrl('/')}>
83
+ <img src={siteUrl('/rxova-logo-256.png')} alt="" width="28" height="28" />
84
+ <span>rxova</span>
85
+ </a>
86
+ {
87
+ /* Fixed order on every surface. It used to render the current item
88
+ second, so Blog and Updates swapped places as you moved between them —
89
+ the menu shifting under the cursor you just clicked with. */
90
+ }
91
+ <nav aria-label="Sections">
92
+ <a href={siteUrl('/')}>Projects</a>
93
+ {
94
+ SECTIONS.map((section) =>
95
+ section.path === base ? (
96
+ <a href={inside()} aria-current="page">
97
+ {section.label}
98
+ </a>
99
+ ) : (
100
+ <a href={siteUrl(section.path)}>{section.label}</a>
101
+ ),
102
+ )
103
+ }
104
+ </nav>
105
+ {/* In the header, not floating over it — see ThemeToggle's `floating`. */}
106
+ <ThemeToggle floating={false} />
107
+ </div>
108
+ </header>
109
+
110
+ <main>
111
+ <slot />
112
+ </main>
113
+
114
+ <div class="foot">
115
+ <SiteFooter />
116
+ </div>
117
+
118
+ <style>
119
+ /* Sticky, translucent, full-bleed. It was a plain in-flow row with the
120
+ theme button floating over its right end, which on a phone put the
121
+ button on top of the nav links and left the header behind as soon as
122
+ you scrolled — a long post gave you no way back without scrolling to
123
+ the top. */
124
+ .site {
125
+ position: sticky;
126
+ top: 0;
127
+ z-index: 10;
128
+ border-bottom: 1px solid var(--rule);
129
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
130
+ }
131
+
132
+ /* The blur is what keeps text legible as it passes under the bar. Where
133
+ it is unsupported the background falls back to nearly opaque, which is
134
+ why the mix above is 88% and not 60%. */
135
+ @supports (backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px)) {
136
+ .site {
137
+ background: color-mix(in srgb, var(--bg) 72%, transparent);
138
+ -webkit-backdrop-filter: blur(10px);
139
+ backdrop-filter: blur(10px);
140
+ }
141
+ }
142
+
143
+ .site__inner {
144
+ max-width: var(--max);
145
+ margin: 0 auto;
146
+ padding: 0.75rem 1.5rem;
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 1rem;
150
+ /* No wrapping: a two-line header would eat a phone's viewport, and the
151
+ row is sized to fit at the narrowest widths we support. */
152
+ min-height: 3.5rem;
153
+ }
154
+
155
+ /* The nav takes the space between the brand and the toggle, and pushes
156
+ itself to the right of it. */
157
+ .site nav {
158
+ margin-left: auto;
159
+ }
160
+ .brand {
161
+ display: inline-flex;
162
+ align-items: center;
163
+ gap: 0.55rem;
164
+ color: var(--fg);
165
+ text-decoration: none;
166
+ font-weight: 640;
167
+ letter-spacing: -0.01em;
168
+ }
169
+ .brand img {
170
+ display: block;
171
+ border-radius: 6px;
172
+ }
173
+ .site nav {
174
+ display: flex;
175
+ gap: 1.4rem;
176
+ }
177
+ .site nav a {
178
+ color: var(--muted);
179
+ text-decoration: none;
180
+ font-size: 0.95rem;
181
+ white-space: nowrap;
182
+ }
183
+ .site nav a:hover {
184
+ color: var(--fg);
185
+ }
186
+ .site nav a[aria-current='page'] {
187
+ color: var(--fg);
188
+ text-decoration: underline;
189
+ text-underline-offset: 5px;
190
+ }
191
+
192
+ /* Brand mark, three links and the toggle at 390px: tight, but it fits on
193
+ one row, which is worth more here than the extra breathing room. */
194
+ @media (max-width: 34rem) {
195
+ .site__inner {
196
+ gap: 0.75rem;
197
+ padding-inline: 1rem;
198
+ min-height: 3.25rem;
199
+ }
200
+ .site nav {
201
+ gap: 0.9rem;
202
+ }
203
+ .site nav a {
204
+ font-size: 0.875rem;
205
+ }
206
+ .brand span {
207
+ /* The logo carries the identity in the space left; the word does not
208
+ fit beside three links on the narrowest phones. */
209
+ display: none;
210
+ }
211
+ }
212
+
213
+ main {
214
+ max-width: var(--max);
215
+ margin: 0 auto;
216
+ padding: clamp(2rem, 6vh, 3.5rem) 1.5rem 4rem;
217
+ }
218
+
219
+ /* The footer's own rules are unscoped (footer.css, shared with the docs
220
+ sites); this only puts it on the page's measure. */
221
+ .foot {
222
+ max-width: var(--max);
223
+ margin: 0 auto;
224
+ padding: 0 1.5rem;
225
+ }
226
+ </style>
227
+
228
+ <!-- Prose styling for rendered markdown. Global rather than scoped: the
229
+ markup comes from <Content /> at build time, so it carries none of
230
+ Astro's scoping attributes. Kept here so both /blog and /updates read
231
+ the same. -->
232
+ <style is:global>
233
+ /* Global because `html` sits outside Astro's scoping. Without it a jump
234
+ to a heading anchor lands under the sticky header. */
235
+ html {
236
+ scroll-padding-top: 4.5rem;
237
+ }
238
+
239
+ .prose {
240
+ color: var(--muted);
241
+ line-height: 1.7;
242
+ }
243
+ .prose > * + * {
244
+ margin-top: 1.1rem;
245
+ }
246
+ .prose h2 {
247
+ color: var(--fg);
248
+ font-size: 1.25rem;
249
+ font-weight: 640;
250
+ letter-spacing: -0.01em;
251
+ margin-top: 2.5rem;
252
+ }
253
+ .prose h3 {
254
+ color: var(--fg);
255
+ font-size: 1.05rem;
256
+ font-weight: 620;
257
+ margin-top: 2rem;
258
+ }
259
+ .prose a {
260
+ color: var(--fg);
261
+ text-decoration: underline;
262
+ text-underline-offset: 2px;
263
+ }
264
+ .prose strong {
265
+ color: var(--fg);
266
+ font-weight: 620;
267
+ }
268
+ .prose ul,
269
+ .prose ol {
270
+ margin-left: 1.2rem;
271
+ }
272
+ .prose li + li {
273
+ margin-top: 0.35rem;
274
+ }
275
+ .prose code {
276
+ font-family: var(--mono);
277
+ font-size: 0.88em;
278
+ background: var(--tag-bg);
279
+ padding: 0.05rem 0.35rem;
280
+ border-radius: 4px;
281
+ }
282
+ .prose pre {
283
+ background: var(--card);
284
+ border: 1px solid var(--rule);
285
+ border-radius: 10px;
286
+ padding: 1rem 1.1rem;
287
+ /* Long lines scroll inside the block; the page itself never does. */
288
+ overflow-x: auto;
289
+ }
290
+ .prose pre code {
291
+ background: none;
292
+ padding: 0;
293
+ }
294
+ .prose blockquote {
295
+ border-left: 2px solid var(--rule);
296
+ padding-left: 1rem;
297
+ color: var(--faint);
298
+ }
299
+ .prose img {
300
+ max-width: 100%;
301
+ height: auto;
302
+ border-radius: 10px;
303
+ }
304
+ .prose table {
305
+ width: 100%;
306
+ border-collapse: collapse;
307
+ font-size: 0.95rem;
308
+ }
309
+ .prose th,
310
+ .prose td {
311
+ border-bottom: 1px solid var(--rule);
312
+ padding: 0.5rem 0.6rem;
313
+ text-align: left;
314
+ }
315
+ .prose th {
316
+ color: var(--fg);
317
+ font-weight: 620;
318
+ }
319
+ </style>
320
+ </body>
321
+ </html>
@@ -16,9 +16,29 @@
16
16
  * Pairs with ThemeScript.astro in <head>, which is what actually avoids the flash;
17
17
  * this only handles clicks and keeps the icon honest.
18
18
  */
19
+
20
+ interface Props {
21
+ /**
22
+ * Pin the button to the top-right corner of the viewport.
23
+ *
24
+ * The default, because that is where it has always been and the landing lays
25
+ * out around it. `SiteShell` passes `false`: it puts the button in its own
26
+ * sticky header, and a fixed button over a fixed header is two things
27
+ * competing for the same corner — on a phone the floating one landed on top
28
+ * of the nav.
29
+ */
30
+ floating?: boolean
31
+ }
32
+
33
+ const { floating = true } = Astro.props
19
34
  ---
20
35
 
21
- <button id="theme-toggle" class="theme-toggle" type="button" aria-label="Toggle theme">
36
+ <button
37
+ id="theme-toggle"
38
+ class:list={['theme-toggle', { 'theme-toggle--floating': floating }]}
39
+ type="button"
40
+ aria-label="Toggle theme"
41
+ >
22
42
  <span class="icon" aria-hidden="true"
23
43
  ><svg
24
44
  xmlns="http://www.w3.org/2000/svg"
@@ -35,37 +55,67 @@
35
55
  </button>
36
56
 
37
57
  <style>
58
+ /* Quiet by default: no border, no fill, the same muted ink as the nav links
59
+ beside it. A bordered card-coloured box was the heaviest thing in a header
60
+ that is otherwise three grey words — it read as a primary control on a page
61
+ where changing the theme is the least important thing you can do. */
38
62
  .theme-toggle {
39
- position: fixed;
40
- top: 1rem;
41
- right: 1rem;
42
- width: 2.25rem;
43
- height: 2.25rem;
63
+ flex: none;
64
+ width: 2rem;
65
+ height: 2rem;
44
66
  display: grid;
45
67
  place-items: center;
46
- border: 1px solid var(--rule);
68
+ border: 1px solid transparent;
47
69
  border-radius: 8px;
48
- background: var(--card);
49
- color: var(--fg);
70
+ background: none;
71
+ color: var(--muted);
50
72
  cursor: pointer;
51
- z-index: 3;
52
73
  transition:
74
+ color 0.15s ease,
53
75
  border-color 0.15s ease,
54
76
  background-color 0.2s ease;
55
77
  }
56
78
 
79
+ /* Floating, it has no header to belong to and sits over arbitrary page
80
+ content, so it keeps the border and the fill — without them there is
81
+ nothing to say it is a button. */
82
+ .theme-toggle--floating {
83
+ position: fixed;
84
+ top: 1rem;
85
+ right: 1rem;
86
+ /* Above SiteShell's sticky header (z-index 10). Nothing pairs the two
87
+ today, but the whole point of a fixed button is that it stays reachable,
88
+ and the header would otherwise paint straight over it. */
89
+ z-index: 20;
90
+ width: 2.25rem;
91
+ height: 2.25rem;
92
+ border-color: var(--rule);
93
+ background: var(--card);
94
+ color: var(--fg);
95
+ }
96
+
57
97
  .theme-toggle .icon {
58
98
  display: grid;
59
99
  place-items: center;
60
100
  }
61
101
 
62
- .theme-toggle svg {
63
- width: 1.15rem;
64
- height: 1.15rem;
102
+ /* `:global` because the icon is swapped by innerHTML in the script below, and
103
+ what that writes carries none of Astro's scoping attributes. Scoped, these
104
+ rules applied only to the SVG the server rendered — the first `render()`
105
+ replaced it with one at Lucide's own 24px and stroke-width 2, which is why
106
+ the button looked so much heavier than the nav beside it. */
107
+ .theme-toggle :global(svg) {
108
+ width: 1.05rem;
109
+ height: 1.05rem;
65
110
  display: block;
111
+ stroke-width: 1.6;
66
112
  }
67
113
 
68
114
  .theme-toggle:hover {
115
+ color: var(--fg);
116
+ }
117
+
118
+ .theme-toggle--floating:hover {
69
119
  border-color: var(--faint);
70
120
  }
71
121
 
package/src/footer.css ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Styles for `components/SiteFooter.astro`.
3
+ *
4
+ * Its own file because the footer is the one piece of chrome both kinds of
5
+ * surface render: the three Starlight docs sites and the plain Astro ones
6
+ * (/blog, /updates, the landing). It used to live inside starlight.css, which
7
+ * is why /blog and /updates shipped a dot-separated link row instead — the
8
+ * shared footer would have rendered unstyled there.
9
+ *
10
+ * Imported by both `starlight.css` and `astro.css`; nothing needs to import it
11
+ * directly. Written against `--rx-*` tokens only, so it does not depend on the
12
+ * short aliases astro.css defines.
13
+ */
14
+
15
+ @import './tokens.css';
16
+
17
+ .rx-footer {
18
+ margin-top: 4rem;
19
+ padding: 3rem 0 2rem;
20
+ border-top: 1px solid var(--rx-rule);
21
+ color: var(--rx-muted);
22
+ font-size: 0.875rem;
23
+ }
24
+
25
+ /* The brand block and the link columns, side by side where there is room.
26
+ `auto-fit` rather than a media query because what matters is how wide the
27
+ footer's *container* is, not the viewport: on /blog the page measure is 44rem
28
+ on any screen, and a viewport query would have put the brand block beside the
29
+ columns and squeezed them to two cramped tracks on a desktop. */
30
+ .rx-footer__top {
31
+ display: grid;
32
+ grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
33
+ gap: 2.5rem 3rem;
34
+ }
35
+
36
+ .rx-footer__brand {
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 0.75rem;
40
+ align-items: flex-start;
41
+ }
42
+
43
+ .rx-footer__mark {
44
+ display: inline-flex;
45
+ align-items: center;
46
+ gap: 0.55rem;
47
+ color: var(--rx-fg);
48
+ font-size: 1rem;
49
+ font-weight: 640;
50
+ letter-spacing: -0.01em;
51
+ text-decoration: none;
52
+ }
53
+
54
+ .rx-footer__mark img {
55
+ display: block;
56
+ border-radius: 6px;
57
+ }
58
+
59
+ .rx-footer__blurb {
60
+ margin: 0;
61
+ max-width: 30ch;
62
+ color: var(--rx-faint);
63
+ line-height: 1.6;
64
+ }
65
+
66
+ /* 8rem, not the 11rem this used to be: it is the widest track that still fits
67
+ two columns on a 360px phone, and one column of five-word links down the
68
+ whole width of a phone is a lot of scrolling for a footer. */
69
+ .rx-footer__columns {
70
+ display: grid;
71
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr));
72
+ gap: 2rem 1.5rem;
73
+ }
74
+
75
+ .rx-footer__title {
76
+ margin-bottom: 0.75rem;
77
+ color: var(--rx-fg);
78
+ font-size: 0.75rem;
79
+ font-weight: 700;
80
+ letter-spacing: 0.06em;
81
+ text-transform: uppercase;
82
+ }
83
+
84
+ .rx-footer__list {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 0.45rem;
88
+ margin: 0;
89
+ padding: 0;
90
+ list-style: none;
91
+ }
92
+
93
+ .rx-footer__list a {
94
+ color: var(--rx-muted);
95
+ text-decoration: none;
96
+ }
97
+
98
+ .rx-footer__list a:hover {
99
+ color: var(--rx-fg);
100
+ text-decoration: underline;
101
+ text-underline-offset: 2px;
102
+ }
103
+
104
+ /* The bottom bar: copyright on the left, the legal links on the right, and one
105
+ column on a phone. */
106
+ .rx-footer__legal {
107
+ display: flex;
108
+ align-items: center;
109
+ justify-content: space-between;
110
+ flex-wrap: wrap;
111
+ gap: 0.5rem 1.25rem;
112
+ margin-top: 2.5rem;
113
+ padding-top: 1.25rem;
114
+ border-top: 1px solid var(--rx-rule);
115
+ color: var(--rx-faint);
116
+ font-size: 0.8125rem;
117
+ }
118
+
119
+ .rx-footer__legal p {
120
+ margin: 0;
121
+ }
122
+
123
+ .rx-footer__legal-links {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 1.25rem;
127
+ }
128
+
129
+ .rx-footer__legal a {
130
+ color: var(--rx-faint);
131
+ text-decoration: none;
132
+ }
133
+
134
+ .rx-footer__legal a:hover {
135
+ color: var(--rx-fg);
136
+ text-decoration: underline;
137
+ text-underline-offset: 2px;
138
+ }
package/src/sites.ts CHANGED
@@ -102,7 +102,7 @@ export const REPOS = [
102
102
 
103
103
  export type RepoId = (typeof REPOS)[number]['id']
104
104
 
105
- /** The ids only, for schema validation — see packages/schemas. */
105
+ /** The ids only, for schema validation — see packages/website-schemas. */
106
106
  export const REPO_IDS: readonly RepoId[] = REPOS.map((r) => r.id)
107
107
 
108
108
  export function getRepo(id: RepoId): (typeof REPOS)[number] {
package/src/starlight.css CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  @import './tokens.css';
19
+ @import './footer.css';
19
20
 
20
21
  :root {
21
22
  /* Accent. */
@@ -131,53 +132,6 @@ header.header {
131
132
 
132
133
  /* --- Footer -------------------------------------------------------------- */
133
134
 
134
- .rx-footer {
135
- margin-top: 4rem;
136
- padding: 2.5rem 0 2rem;
137
- border-top: 1px solid var(--rx-rule);
138
- color: var(--rx-muted);
139
- font-size: 0.875rem;
140
- }
141
-
142
- .rx-footer__columns {
143
- display: grid;
144
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
145
- gap: 2rem;
146
- }
147
-
148
- .rx-footer__title {
149
- margin-bottom: 0.75rem;
150
- color: var(--rx-fg);
151
- font-size: 0.75rem;
152
- font-weight: 700;
153
- letter-spacing: 0.06em;
154
- text-transform: uppercase;
155
- }
156
-
157
- .rx-footer__list {
158
- display: flex;
159
- flex-direction: column;
160
- gap: 0.45rem;
161
- margin: 0;
162
- padding: 0;
163
- list-style: none;
164
- }
165
-
166
- .rx-footer__list a {
167
- color: var(--rx-muted);
168
- text-decoration: none;
169
- }
170
-
171
- .rx-footer__list a:hover {
172
- color: var(--rx-fg);
173
- text-decoration: underline;
174
- text-underline-offset: 2px;
175
- }
176
-
177
- .rx-footer__legal {
178
- margin-top: 2.5rem;
179
- padding-top: 1.25rem;
180
- border-top: 1px solid var(--rx-rule);
181
- color: var(--rx-faint);
182
- font-size: 0.8125rem;
183
- }
135
+ /* Its styles are in footer.css, imported at the top of this file: /blog and
136
+ /updates render the same footer and load astro.css, not this one, so the
137
+ rules cannot live here. */