@abreen/tada 1.18.1 → 1.18.3

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/README.md CHANGED
@@ -109,7 +109,7 @@ Create a new Tada site in a new directory. Prompts for:
109
109
  - **Site title**: displayed in the header and `<title>` tag
110
110
  - **Symbol**: short text (1-5 uppercase characters) shown in the logo and favicon
111
111
  - **Theme color**: HSL color, e.g. `hsl(195 70% 40%)`
112
- - **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `20`)
112
+ - **Background tint hue**: hue (0-360) for background/foreground tinting (defaults to `33`)
113
113
  - **Background tint amount**: percentage (0-100) of tint to apply (defaults to `100`)
114
114
  - **Default time zone**: for `<time>` elements (defaults to your system zone)
115
115
  - **Production base URL**: e.g. `https://example.edu`
@@ -272,7 +272,7 @@ vars:
272
272
  | `logo` | *Optional*, image path relative to `public/`, replacing the header symbol |
273
273
  | `favicon` | *Optional*, `.ico` path relative to `public/`, replacing generated favicons and the web app manifest |
274
274
  | `themeColor` | Theme color for the site (e.g., `"tomato"`, `"#c04040"`, `"hsl(195 70% 40%)"`) |
275
- | `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `20`) |
275
+ | `tintHue` | *Optional*, hue (0-360) for background and foreground tinting (default `33`) |
276
276
  | `tintAmount` | *Optional*, percentage (0-100) of tint to apply to the neutral palette (default `100`); link saturation stays fixed |
277
277
  | `defaultFont` | *Optional*, initial font pairing: `sans` or `serif` (default `sans`) |
278
278
  | `defaultContrast` | *Optional*, initial neutral-palette contrast: `standard` or `high` (default `standard`) |
package/bin/tada.ts CHANGED
@@ -90,7 +90,7 @@ const INIT_QUESTIONS: Record<string, InitQuestion> = {
90
90
  },
91
91
  tintHue: {
92
92
  prompt: 'Background tint hue (0-360)',
93
- defaultValue: '20',
93
+ defaultValue: '33',
94
94
  validate: validateHue,
95
95
  },
96
96
  tintAmount: {
package/build/bundle.ts CHANGED
@@ -52,12 +52,12 @@ function renderThemeScss(siteVariables: SiteVariables): string {
52
52
  const templatePath = path.join(getPackageDir(), 'templates/_theme.scss');
53
53
  const template = fs.readFileSync(templatePath, 'utf-8');
54
54
  const theme = deriveTheme(siteVariables.themeColor);
55
- const tintHue = siteVariables.tintHue ?? 20;
55
+ const tintHue = siteVariables.tintHue ?? 33;
56
56
  const tintAmount = siteVariables.tintAmount ?? 100;
57
57
 
58
58
  const linkHue = formatCssNumber(deriveLinkHue(tintHue));
59
- const linkColor = `hsl(${linkHue}deg 44.4% 49.4%)`;
60
- const linkColorHover = `hsl(${linkHue}deg 34% 60%)`;
59
+ const linkColor = `hsl(${linkHue}deg 66% 38%)`;
60
+ const linkColorHover = `hsl(${linkHue}deg 56% 49%)`;
61
61
  const linkColorDark = `hsl(${linkHue}deg 50% 72%)`;
62
62
  const linkColorHoverDark = `hsl(${linkHue}deg 40% 80%)`;
63
63
  const traceLineActiveHue = formatCssNumber(deriveTraceLineActiveHue(tintHue));
@@ -18,7 +18,7 @@ The <dfn>standard in</dfn> (or `stdin`) is one of three streams available to a
18
18
  running program. The other two streams are the standard out and standard error.
19
19
 
20
20
  <figure>
21
- <svg viewBox="0 50 410 90" xmlns="http://www.w3.org/2000/svg" font-size="14" fill="none" style="margin:1rem auto;display:block;max-width:410px">
21
+ <svg viewBox="15 50 380 90" xmlns="http://www.w3.org/2000/svg" font-size="14" fill="none" style="margin:1rem auto;display:block;max-width:380px">
22
22
  <defs>
23
23
  <marker id="arrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
24
24
  <path d="M0,0 L8,4 L0,8 Z" stroke="currentColor" fill="currentColor"></path>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.18.1",
3
+ "version": "1.18.3",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "type": "integer",
39
39
  "minimum": 0,
40
40
  "maximum": 360,
41
- "default": 20
41
+ "default": 33
42
42
  },
43
43
  "tintAmount": {
44
44
  "type": "integer",
package/src/_content.scss CHANGED
@@ -387,7 +387,7 @@ dfn {
387
387
  }
388
388
 
389
389
  [aria-current='page'] {
390
- font-style: italic;
390
+ font-weight: 700;
391
391
  }
392
392
 
393
393
  .material-symbol-icon {
package/src/_layout.scss CHANGED
@@ -152,14 +152,16 @@ body.literate main table {
152
152
  .info {
153
153
  @include user-select(none);
154
154
 
155
- display: grid;
156
- grid-template-columns: 1fr auto;
155
+ // Flex instead of a `1fr auto` grid: WebKit keeps the auto column's width
156
+ // after the font changes, which wraps the author avatar onto its own line.
157
+ display: flex;
157
158
  align-items: baseline;
159
+ justify-content: space-between;
158
160
  font-size: var(--font-size-smaller);
159
161
  color: var(--fg2-color);
160
162
 
161
163
  @media (width < 450px) {
162
- grid-template-columns: 1fr;
164
+ flex-direction: column;
163
165
  }
164
166
 
165
167
  aside.author {
package/src/globals.ts CHANGED
@@ -11,6 +11,10 @@ export interface Globals {
11
11
  createResizeObserver: (
12
12
  callback: ResizeObserverCallback,
13
13
  ) => Pick<ResizeObserver, 'disconnect' | 'observe'>;
14
+ createIntersectionObserver: (
15
+ callback: IntersectionObserverCallback,
16
+ options?: IntersectionObserverInit,
17
+ ) => Pick<IntersectionObserver, 'disconnect' | 'observe'>;
14
18
  fetch: FetchFunction;
15
19
  importModule: ImportModuleFunction;
16
20
  isDocumentHidden: (document: Document) => boolean;
@@ -26,6 +30,9 @@ export const globals: Globals = {
26
30
  createResizeObserver(callback) {
27
31
  return new ResizeObserver(callback);
28
32
  },
33
+ createIntersectionObserver(callback, options) {
34
+ return new IntersectionObserver(callback, options);
35
+ },
29
36
  fetch(input: RequestInfo | URL, init?: RequestInit) {
30
37
  return fetch(input, init);
31
38
  },
@@ -5,6 +5,12 @@ and, where supported, animates its `<details>` content. The component's
5
5
  JavaScript adds dismissal behavior for outside clicks, focus leaving the menu,
6
6
  and the Escape key.
7
7
 
8
- This component doesn't provide any other functionality related to the header.
8
+ The component also rolls the header title between the site title and the page
9
+ title as the page heading scrolls under the header (see
10
+ [Header Title](../../spec/header-title.md)). The navigator calls
11
+ `swapHeaderTitle()` after each content swap so the header adopts the new page
12
+ title and shows the title for the new scroll position without rolling.
13
+
9
14
  If JavaScript is turned off in the web browser, the `<details>` element is still
10
- functional and its disclosure content opens immediately.
15
+ functional and its disclosure content opens immediately, and the header shows
16
+ only the site title.
@@ -5,32 +5,20 @@
5
5
  --header-menu-icon-size: 24px;
6
6
  }
7
7
 
8
+ // The summary and the controls share one grid row, so the site title gets
9
+ // exactly the width the currently rendered controls leave behind.
8
10
  header {
9
- --header-search-reservation: 0px;
10
- --header-to-top-reservation: 0px;
11
-
12
11
  position: fixed;
13
12
  top: 0;
14
13
  z-index: 1000;
15
14
  display: grid;
16
- grid-template-columns: 1fr;
15
+ grid-template-columns: minmax(0, 1fr) max-content;
17
16
  width: 100%;
17
+ container-type: inline-size;
18
18
  background: var(--bg-color-translucent);
19
19
  border-bottom: 1px solid var(--bg2-color);
20
20
  backdrop-filter: var(--backdrop-filter);
21
21
  view-transition-name: site-header;
22
-
23
- &:has(.search-controls) {
24
- --header-search-reservation: 200px;
25
-
26
- @media (width <= 400px) {
27
- --header-search-reservation: 0px;
28
- }
29
- }
30
-
31
- &:has(.to-top-container .is-visible) {
32
- --header-to-top-reservation: 120px;
33
- }
34
22
  }
35
23
 
36
24
  :root[data-contrast-preference='high'] header {
@@ -57,6 +45,7 @@ body:has(header details[open]) > .container::before {
57
45
  }
58
46
 
59
47
  header > details {
48
+ grid-area: 1 / 1;
60
49
  min-width: 0;
61
50
  }
62
51
 
@@ -65,9 +54,7 @@ header details summary {
65
54
  display: flex;
66
55
  align-items: center;
67
56
  height: var(--header-collapsed-height);
68
- padding: 0
69
- calc(var(--header-search-reservation) + var(--header-to-top-reservation)) 0
70
- 0;
57
+ padding: 0;
71
58
  cursor: pointer;
72
59
  list-style-type: none;
73
60
 
@@ -78,7 +65,8 @@ header details summary {
78
65
  background: var(--bg2-color);
79
66
  }
80
67
 
81
- &:hover .site-title {
68
+ &:hover .site-title,
69
+ &:hover .header-page-title {
82
70
  @include link-underline;
83
71
  }
84
72
  }
@@ -160,7 +148,9 @@ header details[open] summary .menu-icon-line-bottom {
160
148
 
161
149
  :root.js header > details::details-content {
162
150
  min-block-size: 0;
163
- overflow: clip;
151
+
152
+ // Clip only the height animation; the navigation spans the full header
153
+ overflow: visible clip;
164
154
  opacity: 0;
165
155
  transform: translateY(-4px);
166
156
  transition:
@@ -176,8 +166,12 @@ header details[open] summary .menu-icon-line-bottom {
176
166
  }
177
167
  }
178
168
 
169
+ // The details element sits in the first column; its navigation extends under
170
+ // the controls column to fill the header.
179
171
  header .content {
180
172
  position: relative;
173
+ box-sizing: border-box;
174
+ width: 100cqw;
181
175
  }
182
176
 
183
177
  header summary .logo {
@@ -201,36 +195,89 @@ header summary .logo-image {
201
195
  object-fit: contain;
202
196
  }
203
197
 
204
- header summary .site-title {
198
+ // The site title and page title sit on a reel, one header row each, behind a
199
+ // window one row tall. Scrolling past the page heading rolls the reel up to the
200
+ // page title; without JavaScript, only the site title is shown.
201
+ header summary .header-title {
205
202
  flex: 1 1 0;
203
+ align-self: stretch;
206
204
  min-width: 0;
207
- margin: 0 8px 0 0;
205
+ margin: 0 var(--gap) 0 0;
208
206
  overflow: hidden;
209
- text-overflow: ellipsis;
210
207
 
211
208
  // Values above 500 resolve to the supplied bold face when a custom family
212
209
  // provides only fixed 400 and 700 weights.
213
210
  font-weight: 600;
214
- white-space: nowrap;
215
211
  pointer-events: none;
216
212
  }
217
213
 
214
+ // Feather the window's top and bottom edges so a title fades as it rolls out
215
+ :root.js header summary .header-title {
216
+ -webkit-mask-image: linear-gradient(
217
+ transparent,
218
+ #000 25%,
219
+ #000 75%,
220
+ transparent
221
+ );
222
+ mask-image: linear-gradient(transparent, #000 25%, #000 75%, transparent);
223
+ }
224
+
225
+ header summary .header-title-reel {
226
+ display: block;
227
+ }
228
+
229
+ header summary .header-title.is-page-title .header-title-reel {
230
+ transform: translateY(calc(-1 * var(--header-collapsed-height)));
231
+ }
232
+
233
+ @media (prefers-reduced-motion: no-preference) {
234
+ header summary .header-title-reel {
235
+ transition: transform 300ms cubic-bezier(0.2, 0, 0, 1);
236
+ }
237
+ }
238
+
239
+ header summary .site-title,
240
+ header summary .header-page-title {
241
+ display: block;
242
+ height: var(--header-collapsed-height);
243
+ overflow: hidden;
244
+ text-overflow: ellipsis;
245
+ line-height: var(--header-collapsed-height);
246
+ white-space: nowrap;
247
+ }
248
+
249
+ // Code page titles keep their monospace font, without the inline code chip,
250
+ // like the file title heading
251
+ header summary .header-page-title :is(tt, code) {
252
+ padding: 0;
253
+ background: none;
254
+ }
255
+
256
+ :root:not(.js) header summary .header-page-title {
257
+ display: none;
258
+ }
259
+
218
260
  header > .header-controls {
219
- position: absolute;
220
- top: 0;
221
- right: 0;
222
- z-index: 1;
223
261
  display: flex;
262
+ grid-area: 1 / 2;
263
+ gap: var(--gap);
224
264
  align-items: center;
265
+ align-self: start;
225
266
  height: var(--header-collapsed-height);
226
267
  padding: 0 var(--gap) 0 0;
227
268
  }
228
269
 
270
+ // Continue the summary's hover background behind the controls
271
+ @media (hover: hover) {
272
+ header:has(> details > summary:hover) > .header-controls {
273
+ background: var(--bg2-color);
274
+ }
275
+ }
276
+
229
277
  header .to-top-container {
230
278
  display: none;
231
279
  align-items: center;
232
280
  height: var(--header-collapsed-height);
233
- padding: 0 var(--gap) 0 0;
234
281
  white-space: nowrap;
235
282
  pointer-events: none;
236
283
 
@@ -1,10 +1,150 @@
1
1
  import { getElement } from '../util';
2
+ import { globals } from '../globals';
3
+
4
+ export type HeaderTitleState = 'site' | 'page';
5
+
6
+ export interface HeadingGeometry {
7
+ /** Bottom edge of the page heading block (breadcrumbs, h1, and info) */
8
+ blockBottom: number;
9
+ /** Top edge of the page's h1 */
10
+ titleTop: number;
11
+ /** Bottom edge of the collapsed header */
12
+ headerBottom: number;
13
+ }
14
+
15
+ const HEADING_BLOCK_SELECTOR = '.title-and-info, .file-header';
16
+ const PAGE_TITLE_CLASS = 'is-page-title';
17
+
18
+ /**
19
+ * Show the page title once the whole heading block has scrolled under the
20
+ * header, and the site title once the h1 is entirely below the header again.
21
+ * In between, keep the current title so it does not flicker.
22
+ */
23
+ export function nextHeaderTitleState(
24
+ current: HeaderTitleState,
25
+ geometry: HeadingGeometry | null,
26
+ ): HeaderTitleState {
27
+ if (!geometry) {
28
+ return 'site';
29
+ }
30
+ if (geometry.blockBottom <= geometry.headerBottom) {
31
+ return 'page';
32
+ }
33
+ if (geometry.titleTop >= geometry.headerBottom) {
34
+ return 'site';
35
+ }
36
+ return current;
37
+ }
38
+
39
+ function getHeadingElements(document: Document) {
40
+ const block = document.querySelector(HEADING_BLOCK_SELECTOR);
41
+ const title = block?.querySelector('h1');
42
+ return block && title ? { block, title } : null;
43
+ }
44
+
45
+ interface TitleRoll {
46
+ swap(newDoc: Document): void;
47
+ disconnect(): void;
48
+ }
49
+
50
+ let titleRoll: TitleRoll | null = null;
51
+
52
+ function getTitleElements(summary: HTMLElement) {
53
+ const container = summary.querySelector<HTMLElement>('.header-title');
54
+ const reel = container?.querySelector<HTMLElement>('.header-title-reel');
55
+ const pageTitle = reel?.querySelector<HTMLElement>('.header-page-title');
56
+ return container && reel && pageTitle ? { container, reel, pageTitle } : null;
57
+ }
58
+
59
+ function mountTitleRoll(
60
+ window: Window,
61
+ summary: HTMLElement,
62
+ ): TitleRoll | null {
63
+ const elements = getTitleElements(summary);
64
+ if (!elements) {
65
+ return null;
66
+ }
67
+ const { container, reel, pageTitle } = elements;
68
+
69
+ let state: HeaderTitleState = 'site';
70
+
71
+ function measure(): HeadingGeometry | null {
72
+ const heading = getHeadingElements(window.document);
73
+ // A hidden heading (for example, while presenting slides) has no boxes
74
+ if (!heading || heading.block.getClientRects().length === 0) {
75
+ return null;
76
+ }
77
+ return {
78
+ blockBottom: heading.block.getBoundingClientRect().bottom,
79
+ titleTop: heading.title.getBoundingClientRect().top,
80
+ headerBottom: summary.getBoundingClientRect().bottom,
81
+ };
82
+ }
83
+
84
+ // Scrolling rolls the reel; other changes, like navigation, switch at once
85
+ function update(roll: boolean) {
86
+ const next = nextHeaderTitleState(state, measure());
87
+ if (next !== state) {
88
+ state = next;
89
+ container.classList.toggle(PAGE_TITLE_CLASS, state === 'page');
90
+ }
91
+ if (!roll && typeof reel.getAnimations === 'function') {
92
+ for (const animation of reel.getAnimations()) {
93
+ animation.finish();
94
+ }
95
+ }
96
+ }
97
+
98
+ const observer = globals.createIntersectionObserver(() => update(true), {
99
+ rootMargin: `${-summary.getBoundingClientRect().bottom}px 0px 0px 0px`,
100
+ threshold: [0, 1],
101
+ });
102
+
103
+ function observeHeading() {
104
+ observer.disconnect();
105
+ const heading = getHeadingElements(window.document);
106
+ if (heading) {
107
+ observer.observe(heading.block);
108
+ observer.observe(heading.title);
109
+ }
110
+ }
111
+
112
+ update(false);
113
+ observeHeading();
114
+
115
+ return {
116
+ swap(newDoc) {
117
+ const newPageTitle = newDoc.querySelector('header .header-page-title');
118
+ if (newPageTitle) {
119
+ pageTitle.replaceChildren(
120
+ ...window.document.importNode(newPageTitle, true).childNodes,
121
+ );
122
+ }
123
+ observeHeading();
124
+ update(false);
125
+ },
126
+ disconnect() {
127
+ observer.disconnect();
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Called by the navigator once a new page's content and scroll position are in
134
+ * place: adopt the new page title and show the title for the new position
135
+ * without rolling, so the page transition crossfades it.
136
+ */
137
+ export function swapHeaderTitle(newDoc: Document): void {
138
+ titleRoll?.swap(newDoc);
139
+ }
2
140
 
3
141
  export default (window: Window) => {
4
142
  const header: HTMLElement = getElement(window.document, 'header');
5
143
  const details = getElement(header, 'details') as HTMLDetailsElement;
6
144
  const summary = getElement(details, 'summary') as HTMLElement;
7
145
 
146
+ titleRoll = mountTitleRoll(window, summary);
147
+
8
148
  function close() {
9
149
  details.open = false;
10
150
  }
@@ -47,6 +187,8 @@ export default (window: Window) => {
47
187
  details.addEventListener('focusout', handleDetailsFocusOut);
48
188
 
49
189
  return () => {
190
+ titleRoll?.disconnect();
191
+ titleRoll = null;
50
192
  window.removeEventListener('keydown', handleWindowKeyDown);
51
193
  window.removeEventListener('click', handleWindowClick);
52
194
  details.removeEventListener('click', handleDetailsClick);
@@ -43,19 +43,45 @@ $movement-easing: cubic-bezier(0.2, 0, 0, 1);
43
43
  }
44
44
  }
45
45
 
46
- // Page heading and info: crossfade in place instead of sliding with the page
46
+ // Page heading parts: crossfade in place instead of sliding with the page
47
47
  // view-transition-name is set via inline style by the navigator
48
- // only when .title-and-info is visible in the viewport
49
-
48
+ // only when .title-and-info is visible in the viewport. All parts share one
49
+ // duration and easing so they move in step when the layout changes; the
50
+ // crossfades inherit the group timing.
51
+ ::view-transition-group(page-breadcrumbs),
52
+ ::view-transition-old(page-breadcrumbs),
53
+ ::view-transition-new(page-breadcrumbs),
54
+ ::view-transition-group(page-title),
50
55
  ::view-transition-old(page-title),
51
56
  ::view-transition-new(page-title),
57
+ ::view-transition-group(page-info),
52
58
  ::view-transition-old(page-info),
53
59
  ::view-transition-new(page-info) {
54
60
  animation-duration: $duration;
55
61
  }
56
62
 
57
- // Breadcrumbs use per-item named rules installed by the navigator. Shared
58
- // items stay opaque and preserve their position; removed/added suffixes fade.
63
+ ::view-transition-group(page-breadcrumbs),
64
+ ::view-transition-group(page-title),
65
+ ::view-transition-group(page-info) {
66
+ animation-timing-function: $movement-easing;
67
+ }
68
+
69
+ // Breadcrumbs: the whole trail is one group, so every item moves together when
70
+ // the layout changes. The default plus-lighter crossfade keeps unchanged items
71
+ // opaque while added or removed items fade. Snapshots keep their natural size
72
+ // so a change in trail width does not stretch the text.
73
+ ::view-transition-old(page-breadcrumbs),
74
+ ::view-transition-new(page-breadcrumbs) {
75
+ inline-size: auto;
76
+ }
77
+
78
+ // The header stays in place. Parts that change between pages, such as the
79
+ // title shown in it, crossfade in step with the page heading.
80
+ ::view-transition-group(site-header),
81
+ ::view-transition-old(site-header),
82
+ ::view-transition-new(site-header) {
83
+ animation-duration: $duration;
84
+ }
59
85
 
60
86
  // The site-wide banner does not change between pages. Keep it out of the root
61
87
  // slide and pin its named transition layer in place without a crossfade.
@@ -5,22 +5,13 @@ import {
5
5
  teardownPerPageComponents,
6
6
  } from './lifecycle';
7
7
  import { globals } from '../globals';
8
- import {
9
- cleanupBreadcrumbTransitionNames,
10
- getBreadcrumbItems,
11
- prepareBreadcrumbTransitionStyles,
12
- setBreadcrumbTransitionNames,
13
- sharedBreadcrumbCount,
14
- } from './breadcrumbs';
8
+ import { swapHeaderTitle } from '../header';
15
9
 
16
10
  export const NAVIGATION_EVENT = 'tada:navigation';
17
11
 
18
12
  let currentAbortController: AbortController | null = null;
19
13
  let historyIndex = 0;
20
14
  let currentPath = '';
21
- // Unlike the browser URL (which already changes on popstate), this tracks the
22
- // page whose content is currently displayed, for relative breadcrumb identity.
23
- let renderedPageUrl = '';
24
15
 
25
16
  const scrollByIndex = new Map<number, number>();
26
17
  const scrollByLocation = new Map<string, number>();
@@ -127,21 +118,29 @@ function dispatchNavigationEvent(
127
118
  );
128
119
  }
129
120
 
130
- function cleanupViewTransitionNames(document: Document): void {
131
- const newH1 = document.querySelector(
132
- '.title-and-info h1',
133
- ) as HTMLElement | null;
134
- const newInfo = document.querySelector(
135
- '.title-and-info .info',
136
- ) as HTMLElement | null;
137
-
138
- if (newH1) {
139
- newH1.style.viewTransitionName = '';
121
+ // Heading parts that get their own layer instead of sliding with the page. The
122
+ // breadcrumb trail is one group so every item, including the current one,
123
+ // moves together when the page layout changes.
124
+ const TITLE_TRANSITION_NAMES = [
125
+ ['nav.breadcrumbs', 'page-breadcrumbs'],
126
+ ['h1', 'page-title'],
127
+ ['.info', 'page-info'],
128
+ ] as const;
129
+
130
+ function setTitleTransitionNames(titleEl: Element, enabled: boolean): void {
131
+ for (const [selector, name] of TITLE_TRANSITION_NAMES) {
132
+ const element = titleEl.querySelector<HTMLElement>(selector);
133
+ if (element) {
134
+ element.style.viewTransitionName = enabled ? name : '';
135
+ }
140
136
  }
141
- if (newInfo) {
142
- newInfo.style.viewTransitionName = '';
137
+ }
138
+
139
+ function cleanupViewTransitionNames(document: Document): void {
140
+ const titleEl = document.querySelector('.title-and-info');
141
+ if (titleEl) {
142
+ setTitleTransitionNames(titleEl, false);
143
143
  }
144
- cleanupBreadcrumbTransitionNames(document);
145
144
  }
146
145
 
147
146
  function getLocationKey(window: Window): string {
@@ -177,7 +176,6 @@ export function initNavigation(window: Window): void {
177
176
  scrollByLocation.clear();
178
177
  window.history.scrollRestoration = 'manual';
179
178
  currentPath = window.location.pathname + window.location.search;
180
- renderedPageUrl = window.location.href;
181
179
  scrollByIndex.set(historyIndex, window.scrollY);
182
180
  scrollByLocation.set(getLocationKey(window), window.scrollY);
183
181
  }
@@ -311,7 +309,6 @@ export async function navigateToUrl(
311
309
  updateHead(document, newDoc);
312
310
  mountAppearancePickerForPage(window);
313
311
  currentPath = parsed.pathname + parsed.search;
314
- renderedPageUrl = parsed.href;
315
312
 
316
313
  if (pushHistory) {
317
314
  historyIndex++;
@@ -331,6 +328,9 @@ export async function navigateToUrl(
331
328
 
332
329
  scrollByIndex.set(historyIndex, window.scrollY);
333
330
  scrollByLocation.set(getLocationKey(window), window.scrollY);
331
+
332
+ // Measured at the destination scroll position; the transition crossfades it
333
+ swapHeaderTitle(newDoc);
334
334
  return true;
335
335
  };
336
336
 
@@ -343,18 +343,6 @@ export async function navigateToUrl(
343
343
  !prefersReducedMotion(window) &&
344
344
  typeof transitionDoc.startViewTransition === 'function'
345
345
  ) {
346
- const oldBreadcrumbItems = getBreadcrumbItems(document, renderedPageUrl);
347
- const newBreadcrumbItems = getBreadcrumbItems(newDoc, parsed.href);
348
- const sharedCount = sharedBreadcrumbCount(
349
- oldBreadcrumbItems,
350
- newBreadcrumbItems,
351
- );
352
- prepareBreadcrumbTransitionStyles(
353
- document,
354
- oldBreadcrumbItems.length,
355
- newBreadcrumbItems.length,
356
- sharedCount,
357
- );
358
346
  const titleEl = document.querySelector('.title-and-info');
359
347
  const headerHeight =
360
348
  document.querySelector('header')?.getBoundingClientRect().height ?? 0;
@@ -362,20 +350,7 @@ export async function navigateToUrl(
362
350
  titleEl != null && titleEl.getBoundingClientRect().top >= headerHeight;
363
351
 
364
352
  if (titleVisible) {
365
- const h1 = titleEl.querySelector('h1') as HTMLElement | null;
366
- const info = titleEl.querySelector('.info') as HTMLElement | null;
367
- if (h1) {
368
- h1.style.viewTransitionName = 'page-title';
369
- }
370
- if (info) {
371
- info.style.viewTransitionName = 'page-info';
372
- }
373
- setBreadcrumbTransitionNames(
374
- document,
375
- renderedPageUrl,
376
- sharedCount,
377
- 'leave',
378
- );
353
+ setTitleTransitionNames(titleEl, true);
379
354
  }
380
355
 
381
356
  document.documentElement.classList.add(
@@ -395,20 +370,7 @@ export async function navigateToUrl(
395
370
  newTitleEl.getBoundingClientRect().top >= newHeaderHeight;
396
371
 
397
372
  if (newTitleVisible && newTitleEl) {
398
- const h1 = newTitleEl.querySelector('h1') as HTMLElement | null;
399
- const info = newTitleEl.querySelector('.info') as HTMLElement | null;
400
- if (h1) {
401
- h1.style.viewTransitionName = 'page-title';
402
- }
403
- if (info) {
404
- info.style.viewTransitionName = 'page-info';
405
- }
406
- setBreadcrumbTransitionNames(
407
- document,
408
- renderedPageUrl,
409
- sharedCount,
410
- 'enter',
411
- );
373
+ setTitleTransitionNames(newTitleEl, true);
412
374
  }
413
375
  });
414
376
 
@@ -21,26 +21,28 @@
21
21
  --note-bg-color: hsl(209deg 95% 75%);
22
22
 
23
23
  // Main foreground color (the color of most text)
24
- --fg-color: hsl(var(--tint-hue) calc(8% * var(--tint-amount)) 8%);
24
+ --fg-color: hsl(var(--tint-hue) calc(30% * var(--tint-amount)) 8%);
25
25
  --fg-color-translucent: hsl(
26
26
  var(--tint-hue) calc(70% * var(--tint-amount)) 8% / 33%
27
27
  );
28
28
 
29
29
  // Secondary foreground color (lighter)
30
- --fg2-color: hsl(var(--tint-hue) calc(6% * var(--tint-amount)) 45%);
30
+ --fg2-color: hsl(var(--tint-hue) calc(8% * var(--tint-amount)) 45%);
31
31
  --link-color: <%= linkColor %>;
32
32
  --link-color-hover: <%= linkColorHover %>;
33
33
 
34
34
  // Main background color (the color of the page background)
35
- --bg-color: hsl(var(--tint-hue) calc(5% * var(--tint-amount)) 97%);
35
+ --bg-color: hsl(var(--tint-hue) calc(65% * var(--tint-amount)) 97%);
36
+
37
+ // Same saturation as --bg-color, so frosting doesn't add color to the page
36
38
  --bg-color-translucent: hsl(
37
- var(--tint-hue) calc(30% * var(--tint-amount)) 95% / 66%
39
+ var(--tint-hue) calc(65% * var(--tint-amount)) 95% / 66%
38
40
  );
39
41
 
40
42
  // Secondary background color (lighter)
41
- --bg2-color: hsl(var(--tint-hue) calc(12% * var(--tint-amount)) 92%);
43
+ --bg2-color: hsl(var(--tint-hue) calc(55% * var(--tint-amount)) 92%);
42
44
  --bg2-color-translucent: hsl(
43
- var(--tint-hue) calc(12% * var(--tint-amount)) 92% / 66%
45
+ var(--tint-hue) calc(55% * var(--tint-amount)) 92% / 66%
44
46
  );
45
47
  --bg-trace-line-active: <%= bgTraceLineActive %>;
46
48
  --box-shadow: 0 2px 9px
@@ -108,20 +110,20 @@
108
110
  --theme-color: <%= themeColorDark %>;
109
111
  --theme-color-text: <%= themeColorTextDark %>;
110
112
  --text-on-theme: <%= textOnThemeDark %>;
111
- --fg-color: hsl(var(--tint-hue) calc(10% * var(--tint-amount)) 92%);
113
+ --fg-color: hsl(var(--tint-hue) calc(40% * var(--tint-amount)) 92%);
112
114
  --fg-color-translucent: hsl(
113
115
  var(--tint-hue) calc(85% * var(--tint-amount)) 90% / 33%
114
116
  );
115
- --fg2-color: hsl(var(--tint-hue) calc(6% * var(--tint-amount)) 60%);
117
+ --fg2-color: hsl(var(--tint-hue) calc(10% * var(--tint-amount)) 60%);
116
118
  --link-color: <%= linkColorDark %>;
117
119
  --link-color-hover: <%= linkColorHoverDark %>;
118
- --bg-color: hsl(var(--tint-hue) calc(3% * var(--tint-amount)) 5%);
120
+ --bg-color: hsl(var(--tint-hue) calc(35% * var(--tint-amount)) 5%);
119
121
  --bg-color-translucent: hsl(
120
- var(--tint-hue) calc(15% * var(--tint-amount)) 10% / 66%
122
+ var(--tint-hue) calc(35% * var(--tint-amount)) 10% / 66%
121
123
  );
122
- --bg2-color: hsl(var(--tint-hue) calc(12% * var(--tint-amount)) 12%);
124
+ --bg2-color: hsl(var(--tint-hue) calc(40% * var(--tint-amount)) 12%);
123
125
  --bg2-color-translucent: hsl(
124
- var(--tint-hue) calc(12% * var(--tint-amount)) 12% / 66%
126
+ var(--tint-hue) calc(40% * var(--tint-amount)) 12% / 66%
125
127
  );
126
128
  --bg-trace-line-active: <%= bgTraceLineActiveDark %>;
127
129
  --box-shadow: 0 3px 8px
@@ -220,7 +220,12 @@
220
220
  <path class="menu-icon-line menu-icon-line-bottom" d="M3 18h18" />
221
221
  </svg>
222
222
  <% if (site.logo) { %><img class="logo-image" src="/<%- encodePublicAssetPath(site.logo) %>" alt="" aria-hidden="true" /><% } else if (site.symbol) { %><span class="logo" aria-hidden="true"><%- site.symbol %></span><% } %>
223
- <span class="site-title" aria-hidden="true"><%- site.title %></span>
223
+ <span class="header-title" aria-hidden="true">
224
+ <span class="header-title-reel">
225
+ <span class="site-title"><%- site.title %></span>
226
+ <span class="header-page-title"><%= page.titleHtml %></span>
227
+ </span>
228
+ </span>
224
229
  </summary>
225
230
  <div class="content">
226
231
  <%= render('_nav.html') %>
@@ -1,129 +0,0 @@
1
- interface BreadcrumbIdentity {
2
- label: string;
3
- url: string;
4
- }
5
-
6
- interface BreadcrumbItem extends BreadcrumbIdentity {
7
- element: HTMLElement;
8
- }
9
-
10
- export function breadcrumbPageKey(href: string, pageUrl: string): string {
11
- const url = new URL(href, pageUrl);
12
- // Fragments identify positions on the same page; query strings can identify
13
- // different content. URL serialization also resolves relative paths/spaces.
14
- return url.origin + url.pathname + url.search;
15
- }
16
-
17
- export function sharedBreadcrumbCount(
18
- oldItems: readonly BreadcrumbIdentity[],
19
- newItems: readonly BreadcrumbIdentity[],
20
- ): number {
21
- let count = 0;
22
- while (
23
- count < Math.min(oldItems.length, newItems.length) &&
24
- oldItems[count].url === newItems[count].url &&
25
- oldItems[count].label === newItems[count].label
26
- ) {
27
- count++;
28
- }
29
- return count;
30
- }
31
-
32
- export function getBreadcrumbItems(
33
- document: Document,
34
- pageUrl: string,
35
- ): BreadcrumbItem[] {
36
- return Array.from(
37
- document.querySelectorAll<HTMLElement>('nav.breadcrumbs ol > li'),
38
- ).flatMap(element => {
39
- const text = element.querySelector('a, [aria-current="page"]');
40
- if (!text) {
41
- return [];
42
- }
43
- const href = text.getAttribute('href') ?? pageUrl;
44
- return [
45
- {
46
- element,
47
- label: (text.textContent ?? '').trim().replace(/\s+/g, ' '),
48
- url: breadcrumbPageKey(href, pageUrl),
49
- },
50
- ];
51
- });
52
- }
53
-
54
- export function setBreadcrumbTransitionNames(
55
- document: Document,
56
- pageUrl: string,
57
- sharedCount: number,
58
- side: 'enter' | 'leave',
59
- ): void {
60
- const breadcrumb = document.querySelector<HTMLElement>('nav.breadcrumbs');
61
- if (!breadcrumb) {
62
- return;
63
- }
64
- getBreadcrumbItems(document, pageUrl).forEach((item, index) => {
65
- const kind = index < sharedCount ? 'shared' : side;
66
- item.element.style.viewTransitionName = `page-breadcrumb-${kind}-${index}`;
67
- });
68
- }
69
-
70
- export function prepareBreadcrumbTransitionStyles(
71
- document: Document,
72
- oldCount: number,
73
- newCount: number,
74
- sharedCount: number,
75
- ): void {
76
- if (!oldCount && !newCount) {
77
- return;
78
- }
79
- const style = document.createElement('style');
80
- style.setAttribute('data-breadcrumb-transition-styles', '');
81
- const rules: string[] = [];
82
- const duration = 'var(--page-transition-duration)';
83
- for (let index = 0; index < sharedCount; index++) {
84
- const name = `page-breadcrumb-shared-${index}`;
85
- rules.push(`
86
- ::view-transition-group(${name}) {
87
- animation-duration: ${duration};
88
- animation-timing-function: var(--page-transition-movement-easing);
89
- }
90
- ::view-transition-old(${name}), ::view-transition-new(${name}) {
91
- mix-blend-mode: normal;
92
- animation: none;
93
- }
94
- ::view-transition-old(${name}) { display: none; }
95
- ::view-transition-old(${name}):only-child {
96
- display: block;
97
- animation: page-fade-out ${duration} ease-in both;
98
- }
99
- ::view-transition-new(${name}):only-child {
100
- animation: page-fade-in ${duration} ease-out both;
101
- }
102
- `);
103
- }
104
- for (let index = sharedCount; index < oldCount; index++) {
105
- rules.push(`::view-transition-old(page-breadcrumb-leave-${index}) {
106
- mix-blend-mode: normal;
107
- animation: page-fade-out ${duration} ease-in both;
108
- }`);
109
- }
110
- for (let index = sharedCount; index < newCount; index++) {
111
- rules.push(`::view-transition-new(page-breadcrumb-enter-${index}) {
112
- mix-blend-mode: normal;
113
- animation: page-fade-in ${duration} ease-out both;
114
- }`);
115
- }
116
- // Exact names keep these rules compatible with the bundled CSS parser, which
117
- // does not yet accept view-transition-class selectors. Names use indices only.
118
- style.textContent = rules.join('\n');
119
- document.head.appendChild(style);
120
- }
121
-
122
- export function cleanupBreadcrumbTransitionNames(document: Document): void {
123
- for (const element of document.querySelectorAll<HTMLElement>(
124
- 'nav.breadcrumbs, nav.breadcrumbs li',
125
- )) {
126
- element.style.removeProperty('view-transition-name');
127
- }
128
- document.querySelector('[data-breadcrumb-transition-styles]')?.remove();
129
- }