@rxova/brand 0.6.0 → 0.7.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 +1 -1
- package/src/components/Header.astro +174 -0
- package/src/components/SiteShell.astro +19 -135
package/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The rxova.org header — the one every plain-Astro surface renders.
|
|
4
|
+
*
|
|
5
|
+
* There used to be two copies of this markup and its styles: one inside
|
|
6
|
+
* `SiteShell` (for /blog and /updates) and a hand-rolled twin in the website
|
|
7
|
+
* repo (for the landing at `/`). They drifted — the landing missed the sticky
|
|
8
|
+
* update the shell got — which is exactly the failure mode of a header that
|
|
9
|
+
* lives in more than one place. This is the single copy; `SiteShell` renders it,
|
|
10
|
+
* and the website imports it over npm and renders it too.
|
|
11
|
+
*
|
|
12
|
+
* It takes the menu as data rather than owning it. The two surfaces resolve their
|
|
13
|
+
* URLs differently — the shell links absolutely through `siteUrl` because /blog
|
|
14
|
+
* and /updates are mounted at their own base paths, while the landing sits at the
|
|
15
|
+
* root and links relatively — and the landing's menu is derived from what is
|
|
16
|
+
* actually deployed. So each caller passes a fully-resolved list; this owns how
|
|
17
|
+
* the header *looks and behaves*, which is the part that was drifting.
|
|
18
|
+
*
|
|
19
|
+
* Not for the docs sites at /packages/*: those are Starlight and take their
|
|
20
|
+
* chrome from `sharedStarlightConfig`, not from an Astro component.
|
|
21
|
+
*/
|
|
22
|
+
import ThemeToggle from './ThemeToggle.astro'
|
|
23
|
+
|
|
24
|
+
export interface HeaderItem {
|
|
25
|
+
label: string
|
|
26
|
+
/** Fully resolved by the caller — absolute or root-relative as its base needs. */
|
|
27
|
+
href: string
|
|
28
|
+
/** Marks the item for the surface the reader is on. */
|
|
29
|
+
current?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
/** Where the brand mark links — the site root, resolved by the caller. */
|
|
34
|
+
homeHref: string
|
|
35
|
+
/** Logo `src`, resolved by the caller for its base. */
|
|
36
|
+
logoSrc: string
|
|
37
|
+
/** The menu, in the one fixed order every surface shows it. */
|
|
38
|
+
items: readonly HeaderItem[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { homeHref, logoSrc, items } = Astro.props
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
/* Outside <main> in every shell so it can span the viewport while its contents
|
|
46
|
+
stay on the page's measure. Sticky rather than fixed: it still takes part in
|
|
47
|
+
layout, so nothing below needs a compensating top margin. */
|
|
48
|
+
}
|
|
49
|
+
<header class="site">
|
|
50
|
+
<div class="site__inner">
|
|
51
|
+
<a class="brand" href={homeHref}>
|
|
52
|
+
<img src={logoSrc} alt="" width="28" height="28" />
|
|
53
|
+
<span>rxova</span>
|
|
54
|
+
</a>
|
|
55
|
+
<nav aria-label="Sections">
|
|
56
|
+
{
|
|
57
|
+
items.map((item) => (
|
|
58
|
+
<a href={item.href} aria-current={item.current ? 'page' : undefined}>
|
|
59
|
+
{item.label}
|
|
60
|
+
</a>
|
|
61
|
+
))
|
|
62
|
+
}
|
|
63
|
+
</nav>
|
|
64
|
+
{/* In the header, not floating over it — see ThemeToggle's `floating`. */}
|
|
65
|
+
<ThemeToggle floating={false} />
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
/* Sticky, translucent, full-bleed. It was a plain in-flow row with the theme
|
|
71
|
+
button floating over its right end, which on a phone put the button on top
|
|
72
|
+
of the nav links and left the header behind as soon as you scrolled — a long
|
|
73
|
+
post gave you no way back without scrolling to the top. */
|
|
74
|
+
.site {
|
|
75
|
+
position: sticky;
|
|
76
|
+
top: 0;
|
|
77
|
+
z-index: 10;
|
|
78
|
+
border-bottom: 1px solid var(--rule);
|
|
79
|
+
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* The blur is what keeps text legible as it passes under the bar. Where it is
|
|
83
|
+
unsupported the background falls back to nearly opaque, which is why the mix
|
|
84
|
+
above is 88% and not 60%. */
|
|
85
|
+
@supports (backdrop-filter: blur(8px)) or (-webkit-backdrop-filter: blur(8px)) {
|
|
86
|
+
.site {
|
|
87
|
+
background: color-mix(in srgb, var(--bg) 72%, transparent);
|
|
88
|
+
-webkit-backdrop-filter: blur(10px);
|
|
89
|
+
backdrop-filter: blur(10px);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.site__inner {
|
|
94
|
+
max-width: var(--max);
|
|
95
|
+
margin: 0 auto;
|
|
96
|
+
padding: 0.75rem 1.5rem;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
gap: 1rem;
|
|
100
|
+
/* No wrapping: a two-line header would eat a phone's viewport, and the row
|
|
101
|
+
is sized to fit at the narrowest widths we support. */
|
|
102
|
+
min-height: 3.5rem;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.brand {
|
|
106
|
+
display: inline-flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 0.55rem;
|
|
109
|
+
color: var(--fg);
|
|
110
|
+
text-decoration: none;
|
|
111
|
+
font-weight: 640;
|
|
112
|
+
letter-spacing: -0.01em;
|
|
113
|
+
}
|
|
114
|
+
.brand img {
|
|
115
|
+
display: block;
|
|
116
|
+
border-radius: 6px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* The nav takes the space between the brand and the toggle, and pushes itself
|
|
120
|
+
to the right of it. */
|
|
121
|
+
nav {
|
|
122
|
+
margin-left: auto;
|
|
123
|
+
display: flex;
|
|
124
|
+
gap: 1.4rem;
|
|
125
|
+
}
|
|
126
|
+
nav a {
|
|
127
|
+
color: var(--muted);
|
|
128
|
+
text-decoration: none;
|
|
129
|
+
font-size: 0.95rem;
|
|
130
|
+
white-space: nowrap;
|
|
131
|
+
}
|
|
132
|
+
nav a:hover {
|
|
133
|
+
color: var(--fg);
|
|
134
|
+
}
|
|
135
|
+
/* The current section is marked for everyone, not only for assistive tech —
|
|
136
|
+
aria-current on its own is invisible. */
|
|
137
|
+
nav a[aria-current='page'] {
|
|
138
|
+
color: var(--fg);
|
|
139
|
+
text-decoration: underline;
|
|
140
|
+
text-underline-offset: 5px;
|
|
141
|
+
}
|
|
142
|
+
nav a:focus-visible {
|
|
143
|
+
outline: 2px solid var(--accent);
|
|
144
|
+
outline-offset: 3px;
|
|
145
|
+
border-radius: 2px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Brand mark, the links and the toggle stay on one row at the narrowest phones
|
|
149
|
+
by dropping the wordmark — the logo carries the identity in the space left. */
|
|
150
|
+
@media (max-width: 34rem) {
|
|
151
|
+
.site__inner {
|
|
152
|
+
gap: 0.75rem;
|
|
153
|
+
padding-inline: 1rem;
|
|
154
|
+
min-height: 3.25rem;
|
|
155
|
+
}
|
|
156
|
+
nav {
|
|
157
|
+
gap: 0.9rem;
|
|
158
|
+
}
|
|
159
|
+
nav a {
|
|
160
|
+
font-size: 0.875rem;
|
|
161
|
+
}
|
|
162
|
+
.brand span {
|
|
163
|
+
display: none;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
</style>
|
|
167
|
+
|
|
168
|
+
<!-- Global because `html` sits outside Astro's scoping. Without it a jump to an
|
|
169
|
+
in-page anchor would land under the sticky header. -->
|
|
170
|
+
<style is:global>
|
|
171
|
+
html {
|
|
172
|
+
scroll-padding-top: 4.5rem;
|
|
173
|
+
}
|
|
174
|
+
</style>
|
|
@@ -14,9 +14,9 @@ import '../astro.css'
|
|
|
14
14
|
|
|
15
15
|
import { RXOVA_ORIGIN, siteUrl } from '../sites.ts'
|
|
16
16
|
|
|
17
|
+
import Header, { type HeaderItem } from './Header.astro'
|
|
17
18
|
import SiteFooter from './SiteFooter.astro'
|
|
18
19
|
import ThemeScript from './ThemeScript.astro'
|
|
19
|
-
import ThemeToggle from './ThemeToggle.astro'
|
|
20
20
|
|
|
21
21
|
interface Props {
|
|
22
22
|
title: string
|
|
@@ -51,6 +51,21 @@ const SECTIONS = [
|
|
|
51
51
|
* against the mount and point at `/blog/updates`.
|
|
52
52
|
*/
|
|
53
53
|
const inside = (p = '') => `${base}/${p}`.replace(/\/{2,}/g, '/')
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The menu, fully resolved for this surface, in the one fixed order every surface
|
|
57
|
+
* shows it. The current section links inside its own base; every other link is
|
|
58
|
+
* absolute so it does not resolve against this mount. `Header` owns how it looks;
|
|
59
|
+
* this owns where it points.
|
|
60
|
+
*/
|
|
61
|
+
const items: HeaderItem[] = [
|
|
62
|
+
{ label: 'Projects', href: siteUrl('/') },
|
|
63
|
+
...SECTIONS.map((section) =>
|
|
64
|
+
section.path === base
|
|
65
|
+
? { label: section.label, href: inside(), current: true }
|
|
66
|
+
: { label: section.label, href: siteUrl(section.path) },
|
|
67
|
+
),
|
|
68
|
+
]
|
|
54
69
|
---
|
|
55
70
|
|
|
56
71
|
<!doctype html>
|
|
@@ -72,40 +87,7 @@ const inside = (p = '') => `${base}/${p}`.replace(/\/{2,}/g, '/')
|
|
|
72
87
|
<ThemeScript />
|
|
73
88
|
</head>
|
|
74
89
|
<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>
|
|
90
|
+
<Header homeHref={siteUrl('/')} logoSrc={siteUrl('/rxova-logo-256.png')} items={items} />
|
|
109
91
|
|
|
110
92
|
<main>
|
|
111
93
|
<slot />
|
|
@@ -116,100 +98,8 @@ const inside = (p = '') => `${base}/${p}`.replace(/\/{2,}/g, '/')
|
|
|
116
98
|
</div>
|
|
117
99
|
|
|
118
100
|
<style>
|
|
119
|
-
/*
|
|
120
|
-
|
|
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
|
-
|
|
101
|
+
/* The header is `Header` (its own component, shared with the landing); the
|
|
102
|
+
only chrome styled here is the page body and the footer's measure. */
|
|
213
103
|
main {
|
|
214
104
|
max-width: var(--max);
|
|
215
105
|
margin: 0 auto;
|
|
@@ -230,12 +120,6 @@ const inside = (p = '') => `${base}/${p}`.replace(/\/{2,}/g, '/')
|
|
|
230
120
|
Astro's scoping attributes. Kept here so both /blog and /updates read
|
|
231
121
|
the same. -->
|
|
232
122
|
<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
123
|
.prose {
|
|
240
124
|
color: var(--muted);
|
|
241
125
|
line-height: 1.7;
|