@rxova/brand 0.6.0 → 0.8.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.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Design tokens, Starlight theme and shared site chrome for rxova.org",
6
6
  "keywords": [
@@ -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>
@@ -20,16 +20,38 @@
20
20
  */
21
21
  import { PROJECTS, RXOVA_ORIGIN, getProject, siteUrl, type ProjectId } from '../sites.ts'
22
22
 
23
+ /** A standalone surface of rxova.org, for the "Site" column. */
24
+ export interface SiteLink {
25
+ label: string
26
+ /** Absolute — see sites.ts. */
27
+ href: string
28
+ }
29
+
23
30
  interface Props {
24
31
  /** Current project, if this is a docs site. Omitted on the landing. */
25
32
  project?: ProjectId
26
33
  /** Optional per-site "Docs" column. */
27
34
  docs?: { label: string; href: string }[]
35
+ /**
36
+ * The standalone surfaces to list under "Site", below Home.
37
+ *
38
+ * Defaults to Blog and Updates, which is what every surface showed when this
39
+ * column was hardcoded. It is a prop because only the umbrella repo knows
40
+ * which surfaces are actually *deployed* — `sources.json` gates the mount and
41
+ * the link together — and a hardcoded list advertises a 404 for a surface
42
+ * whose first build has not landed yet. Same reason `Header` takes `items`.
43
+ */
44
+ site?: readonly SiteLink[]
28
45
  }
29
46
 
30
- const { project, docs } = Astro.props
47
+ const { project, docs, site } = Astro.props
31
48
  const self = project ? getProject(project) : undefined
32
49
  const year = new Date().getFullYear()
50
+
51
+ const surfaces: readonly SiteLink[] = site ?? [
52
+ { label: 'Blog', href: siteUrl('/blog') },
53
+ { label: 'Updates', href: siteUrl('/updates') },
54
+ ]
33
55
  ---
34
56
 
35
57
  <footer class="rx-footer">
@@ -108,8 +130,13 @@ const year = new Date().getFullYear()
108
130
  <h2 class="rx-footer__title">Site</h2>
109
131
  <ul class="rx-footer__list">
110
132
  <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>
133
+ {
134
+ surfaces.map((s) => (
135
+ <li>
136
+ <a href={s.href}>{s.label}</a>
137
+ </li>
138
+ ))
139
+ }
113
140
  </ul>
114
141
  </div>
115
142
 
@@ -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
- /* 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
-
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;