@abreen/tada 1.6.0 → 1.6.2

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/build/bundle.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  getDistDir,
10
10
  toPosix,
11
11
  } from './utils/paths';
12
- import { deriveTheme } from './utils/derive-theme';
12
+ import { deriveTheme, deriveLinkHue } from './utils/derive-theme';
13
13
  import type { PluginBuilder } from 'bun';
14
14
  import type { SiteVariables } from './types';
15
15
  import timezones from '../src/timezone/timezones.json' with { type: 'json' };
@@ -27,6 +27,12 @@ function renderThemeScss(siteVariables: SiteVariables): string {
27
27
  const iconColorHoverDark = `hsl(${tintHue}deg ${(6 * tintAmount) / 100}% 60%)`;
28
28
  const iconColorTranslucentDark = `hsl(${tintHue}deg ${(85 * tintAmount) / 100}% 90%)`;
29
29
 
30
+ const linkHue = deriveLinkHue(tintHue);
31
+ const linkColor = `hsl(${linkHue}deg 44.4% 49.4%)`;
32
+ const linkColorHover = `hsl(${linkHue}deg 34% 60%)`;
33
+ const linkColorDark = `hsl(${linkHue}deg 50% 72%)`;
34
+ const linkColorHoverDark = `hsl(${linkHue}deg 40% 80%)`;
35
+
30
36
  const rendered = _.template(template)({
31
37
  ...theme,
32
38
  tintHue,
@@ -36,6 +42,10 @@ function renderThemeScss(siteVariables: SiteVariables): string {
36
42
  iconColorDark,
37
43
  iconColorHoverDark,
38
44
  iconColorTranslucentDark,
45
+ linkColor,
46
+ linkColorHover,
47
+ linkColorDark,
48
+ linkColorHoverDark,
39
49
  });
40
50
 
41
51
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tada-'));
@@ -1,5 +1,5 @@
1
1
  import type MarkdownIt from 'markdown-it';
2
- import type Token from 'markdown-it/lib/token.mjs';
2
+ import Token from 'markdown-it/lib/token.mjs';
3
3
  import { makeLogger } from './log';
4
4
  import type { SiteVariables } from './types';
5
5
 
@@ -9,38 +9,197 @@ export default function externalLinks(
9
9
  md: MarkdownIt,
10
10
  siteVariables: SiteVariables,
11
11
  ): void {
12
- function addClass(token: Token): void {
13
- if (token.type === 'link_open') {
14
- const href = token.attrGet('href');
15
- if (!href) {
16
- return;
12
+ function isExternal(href: string): boolean {
13
+ if (!href.match(/^https?:\/\/.*$/)) {
14
+ return false;
15
+ }
16
+ const url = new URL(href);
17
+ return !siteVariables.internalDomains?.includes(url.host);
18
+ }
19
+
20
+ function findMatchingClose(children: Token[], openIdx: number): number {
21
+ let depth = 1;
22
+ for (let i = openIdx + 1; i < children.length; i++) {
23
+ if (children[i].type === 'link_open') {
24
+ depth++;
25
+ } else if (children[i].type === 'link_close') {
26
+ depth--;
27
+ if (depth === 0) {
28
+ return i;
29
+ }
17
30
  }
31
+ }
32
+ return -1;
33
+ }
34
+
35
+ function makeSpanOpen(): Token {
36
+ const t = new Token('html_inline', '', 0);
37
+ t.content = '<span class="external-link-tail">';
38
+ return t;
39
+ }
18
40
 
19
- if (href.match(/^https?:\/\/.*$/)) {
20
- const url = new URL(href);
21
- if (!siteVariables.internalDomains?.includes(url.host)) {
22
- const classAttr = token.attrGet('class');
23
- let newClassAttr;
41
+ function makeSpanClose(): Token {
42
+ const t = new Token('html_inline', '', 0);
43
+ t.content = '</span>';
44
+ return t;
45
+ }
46
+
47
+ // Wrap the trailing word of an external link in
48
+ // <span class="external-link-tail"> so the icon (added via that span's
49
+ // ::after) stays glued to the last word and doesn't wrap on its own.
50
+ // The span is inserted inside any inline-formatting elements (em,
51
+ // strong, code, ...) that are still open at the split point, so the
52
+ // resulting markup stays nested.
53
+ function wrapTail(linkChildren: Token[]): Token[] | null {
54
+ if (linkChildren.length === 0) {
55
+ return null;
56
+ }
24
57
 
25
- if (classAttr) {
26
- newClassAttr = classAttr + ' external';
27
- } else {
28
- newClassAttr = 'external';
29
- }
58
+ // Find the rightmost split char (space or hyphen) in a text token,
59
+ // skipping any candidate that would produce an empty tail (e.g. a
60
+ // trailing hyphen with no content after it).
61
+ let splitTokenIdx = -1;
62
+ let splitCharIdx = -1;
63
+ for (let i = linkChildren.length - 1; i >= 0; i--) {
64
+ const tok = linkChildren[i];
65
+ if (tok.type !== 'text') {
66
+ continue;
67
+ }
68
+ const text = tok.content;
69
+ const hasLaterTokens = i < linkChildren.length - 1;
70
+ let foundIdx = -1;
71
+ for (let j = text.length - 1; j >= 0; j--) {
72
+ const c = text[j];
73
+ if (c !== ' ' && c !== '-') {
74
+ continue;
75
+ }
76
+ if (j < text.length - 1 || hasLaterTokens) {
77
+ foundIdx = j;
78
+ break;
79
+ }
80
+ }
81
+ if (foundIdx !== -1) {
82
+ splitTokenIdx = i;
83
+ splitCharIdx = foundIdx;
84
+ break;
85
+ }
86
+ }
30
87
 
31
- log.debug`${href} -> "${newClassAttr}"`;
32
- token.attrSet('class', newClassAttr);
88
+ if (splitTokenIdx === -1) {
89
+ // No space anywhere in the link's text. Wrap the whole content.
90
+ return [makeSpanOpen(), ...linkChildren, makeSpanClose()];
91
+ }
33
92
 
34
- log.debug`${href} -> target="_blank"`;
35
- token.attrSet('target', '_blank');
93
+ // Find where to put the span close: walk forward from the split
94
+ // point tracking nesting depth (locally opened elements after the
95
+ // split). The span must close before any -1 token that would close
96
+ // an element that was already open at the split point.
97
+ let spanCloseIdx = linkChildren.length;
98
+ let depth = 0;
99
+ for (let i = splitTokenIdx + 1; i < linkChildren.length; i++) {
100
+ const tok = linkChildren[i];
101
+ if (tok.nesting === 1) {
102
+ depth++;
103
+ } else if (tok.nesting === -1) {
104
+ if (depth > 0) {
105
+ depth--;
106
+ } else {
107
+ spanCloseIdx = i;
108
+ break;
36
109
  }
37
110
  }
38
111
  }
39
112
 
40
- token.children?.map(addClass);
113
+ const splitToken = linkChildren[splitTokenIdx];
114
+ const before = splitToken.content.slice(0, splitCharIdx + 1);
115
+ const after = splitToken.content.slice(splitCharIdx + 1);
116
+
117
+ const result: Token[] = [];
118
+
119
+ // Tokens before the split, unchanged.
120
+ for (let i = 0; i < splitTokenIdx; i++) {
121
+ result.push(linkChildren[i]);
122
+ }
123
+
124
+ // The "before" part of the split text token.
125
+ if (before.length > 0) {
126
+ const beforeToken = new Token('text', '', 0);
127
+ beforeToken.content = before;
128
+ result.push(beforeToken);
129
+ }
130
+
131
+ result.push(makeSpanOpen());
132
+
133
+ // The "after" part of the split text token.
134
+ if (after.length > 0) {
135
+ const afterToken = new Token('text', '', 0);
136
+ afterToken.content = after;
137
+ result.push(afterToken);
138
+ }
139
+
140
+ // Tokens between the split and the span close.
141
+ for (let i = splitTokenIdx + 1; i < spanCloseIdx; i++) {
142
+ result.push(linkChildren[i]);
143
+ }
144
+
145
+ result.push(makeSpanClose());
146
+
147
+ // Tokens from the span close position to the end.
148
+ for (let i = spanCloseIdx; i < linkChildren.length; i++) {
149
+ result.push(linkChildren[i]);
150
+ }
151
+
152
+ return result;
153
+ }
154
+
155
+ function processChildren(children: Token[]): Token[] {
156
+ const result: Token[] = [];
157
+ let i = 0;
158
+ while (i < children.length) {
159
+ const token = children[i];
160
+
161
+ if (token.type !== 'link_open') {
162
+ result.push(token);
163
+ i++;
164
+ continue;
165
+ }
166
+
167
+ const href = token.attrGet('href');
168
+ if (!href || !isExternal(href)) {
169
+ result.push(token);
170
+ i++;
171
+ continue;
172
+ }
173
+
174
+ const classAttr = token.attrGet('class');
175
+ const newClassAttr = classAttr ? `${classAttr} external` : 'external';
176
+ token.attrSet('class', newClassAttr);
177
+ token.attrSet('target', '_blank');
178
+ log.debug`${href} -> "${newClassAttr}"`;
179
+
180
+ const closeIdx = findMatchingClose(children, i);
181
+ if (closeIdx === -1) {
182
+ result.push(token);
183
+ i++;
184
+ continue;
185
+ }
186
+
187
+ const linkContent = children.slice(i + 1, closeIdx);
188
+ const wrapped = wrapTail(linkContent);
189
+
190
+ result.push(token);
191
+ result.push(...(wrapped ?? linkContent));
192
+ result.push(children[closeIdx]);
193
+ i = closeIdx + 1;
194
+ }
195
+ return result;
41
196
  }
42
197
 
43
198
  md.core.ruler.push('external_links', state => {
44
- state.tokens.map(addClass);
199
+ state.tokens.forEach(token => {
200
+ if (token.children) {
201
+ token.children = processChildren(token.children);
202
+ }
203
+ });
45
204
  });
46
205
  }
@@ -14,6 +14,9 @@ const LIGHT_TEXT_L_MAX = 0.5;
14
14
  const DARK_TEXT_L_MIN = 0.7;
15
15
  const DARK_TEXT_L_MAX = 0.82;
16
16
 
17
+ const LINK_ANCHOR_HUE = 212;
18
+ const LINK_TINT_BLEND = 0.05;
19
+
17
20
  function clamp(v: number, min: number, max: number): number {
18
21
  return Math.min(Math.max(v, min), max);
19
22
  }
@@ -75,6 +78,11 @@ export function deriveTheme(cssColor: string): DerivedTheme {
75
78
  };
76
79
  }
77
80
 
81
+ export function deriveLinkHue(tintHue: number): number {
82
+ const diff = ((tintHue - LINK_ANCHOR_HUE + 540) % 360) - 180;
83
+ return (LINK_ANCHOR_HUE + diff * LINK_TINT_BLEND + 360) % 360;
84
+ }
85
+
78
86
  export function getTextOnColor(cssColor: string): '#fff' | '#000' {
79
87
  const parsed = parse(cssColor);
80
88
  if (!parsed) {
@@ -46,12 +46,17 @@ const log = makeLogger(import.meta.url);
46
46
 
47
47
  const REQUIRED_FRONT_MATTER_FIELDS = ['title'];
48
48
 
49
+ function isWatchMode(assetFiles: string[]): boolean {
50
+ return assetFiles.some(f => f.includes('watch-reload-client'));
51
+ }
52
+
49
53
  interface TemplateParametersInput {
50
54
  pageVariables: Record<string, unknown>;
51
55
  siteVariables: SiteVariables;
52
56
  content: string | null;
53
57
  applyBasePath: (subPath: string) => string;
54
58
  subPath: string;
59
+ isWatchMode: boolean;
55
60
  }
56
61
 
57
62
  function resolveAuthor(
@@ -101,6 +106,7 @@ function createTemplateParameters({
101
106
  content,
102
107
  applyBasePath,
103
108
  subPath,
109
+ isWatchMode,
104
110
  }: TemplateParametersInput): Record<string, unknown> {
105
111
  return {
106
112
  vars: siteVariables.vars || {},
@@ -111,6 +117,7 @@ function createTemplateParameters({
111
117
  page: pageVariables,
112
118
  content,
113
119
  applyBasePath,
120
+ isWatchMode,
114
121
  };
115
122
  }
116
123
 
@@ -188,12 +195,14 @@ export function renderPlainTextPageAsset({
188
195
  const applyBasePath = createApplyBasePath(siteVariables);
189
196
 
190
197
  log.info`Rendering page ${B`${subPath + ext}`}`;
198
+ const watchMode = isWatchMode(assetFiles);
191
199
  const { content, pageVariables, tocItems, alertIds } = renderPlainTextContent(
192
200
  filePath,
193
201
  subPath,
194
202
  siteVariables,
195
203
  applyBasePath,
196
204
  validInternalTargets,
205
+ watchMode,
197
206
  {
198
207
  validateInternalLinks: extensionIsMarkdown(ext.toLowerCase()),
199
208
  traceCache,
@@ -223,6 +232,7 @@ export function renderPlainTextPageAsset({
223
232
  content,
224
233
  applyBasePath,
225
234
  subPath,
235
+ isWatchMode: watchMode,
226
236
  });
227
237
 
228
238
  const templateHtml = render(
@@ -286,6 +296,7 @@ export function renderCodePageAsset({
286
296
  content,
287
297
  applyBasePath,
288
298
  subPath,
299
+ isWatchMode: isWatchMode(assetFiles),
289
300
  });
290
301
 
291
302
  const html = injectAssetTags(
@@ -339,6 +350,7 @@ function renderPlainTextContent(
339
350
  siteVariables: SiteVariables,
340
351
  applyBasePath: (subPath: string) => string,
341
352
  validInternalTargets: Set<string>,
353
+ isWatchMode: boolean,
342
354
  {
343
355
  validateInternalLinks = true,
344
356
  traceCache,
@@ -396,6 +408,7 @@ function renderPlainTextContent(
396
408
  content: null,
397
409
  applyBasePath,
398
410
  subPath,
411
+ isWatchMode,
399
412
  });
400
413
  const pageVariablesProcessed: Record<string, unknown> = Object.entries(
401
414
  pageVariables,
@@ -446,6 +459,7 @@ function renderPlainTextContent(
446
459
  content: strippedContent,
447
460
  applyBasePath,
448
461
  subPath,
462
+ isWatchMode,
449
463
  });
450
464
 
451
465
  if (traceCache && contentDir && distDir) {
@@ -649,6 +663,7 @@ export function renderLiterateJavaPageAsset({
649
663
  content: contentHtml,
650
664
  applyBasePath,
651
665
  subPath,
666
+ isWatchMode: isWatchMode(assetFiles),
652
667
  });
653
668
 
654
669
  const templateHtml = render('literate.html', templateParameters) as string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
package/src/_alerts.scss CHANGED
@@ -39,10 +39,15 @@
39
39
  }
40
40
 
41
41
  a {
42
- text-decoration-color: var(--fg-color);
42
+ @include link-underline;
43
+ color: var(--fg-color);
43
44
 
44
45
  &:hover {
45
- text-decoration-color: inherit;
46
+ color: var(--fg2-color);
47
+ }
48
+
49
+ &:active {
50
+ color: var(--link-active-color);
46
51
  }
47
52
  }
48
53
 
@@ -60,6 +65,7 @@
60
65
  }
61
66
 
62
67
  .alert .title::before {
68
+ content: '';
63
69
  left: 8px;
64
70
  top: 8px;
65
71
  position: absolute;
@@ -68,12 +74,11 @@
68
74
  }
69
75
 
70
76
  .alert.warning .title::before {
71
- content: var(--icon-warning);
77
+ @include contained-mask(var(--icon-warning));
72
78
  }
73
79
 
74
80
  i.warning {
75
- background: var(--icon-warning);
76
- background-size: contain;
81
+ @include contained-mask(var(--icon-warning));
77
82
  display: inline-block;
78
83
  height: 1em;
79
84
  width: 1em;
@@ -81,7 +86,7 @@ i.warning {
81
86
  }
82
87
 
83
88
  .alert.note .title::before {
84
- content: var(--icon-note);
89
+ @include contained-mask(var(--icon-note));
85
90
  }
86
91
 
87
92
  .alert .title {
package/src/_base.scss CHANGED
@@ -66,22 +66,18 @@ html {
66
66
  }
67
67
 
68
68
  a {
69
- color: var(--fg-color);
70
- text-decoration-color: var(--fg2-color);
71
- text-decoration-thickness: 1.5px;
69
+ color: var(--link-color);
70
+ text-decoration-line: none;
72
71
 
73
72
  @media (hover: hover) {
74
73
  &:hover {
75
- color: var(--fg2-color);
74
+ color: var(--link-color-hover);
75
+ @include link-underline;
76
76
  }
77
77
  }
78
- }
79
78
 
80
- a:active {
81
- color: var(--link-active-color);
82
-
83
- @media (hover: hover) {
84
- text-decoration-color: var(--link-active-color);
79
+ &:active {
80
+ color: var(--link-active-color);
85
81
  }
86
82
  }
87
83
 
@@ -146,6 +142,7 @@ details .content {
146
142
  details summary {
147
143
  &:hover {
148
144
  color: var(--fg2-color);
145
+ @include link-underline;
149
146
  }
150
147
 
151
148
  &:active {
package/src/_layout.scss CHANGED
@@ -156,10 +156,31 @@ body.literate main table {
156
156
  .text {
157
157
  color: var(--fg2-color);
158
158
  text-decoration: underline;
159
- text-decoration-color: var(--fg2-color);
160
159
  text-decoration-thickness: 1px;
161
160
  text-underline-offset: 3px;
162
161
  }
162
+
163
+ &:hover .text {
164
+ color: var(--link-color-hover);
165
+ }
166
+
167
+ &:active .text {
168
+ color: var(--link-active-color);
169
+ }
170
+
171
+ &:active .avatar {
172
+ border-color: var(--link-active-color);
173
+ }
174
+
175
+ &:focus-visible {
176
+ outline: none;
177
+ }
178
+
179
+ &:focus-visible .text,
180
+ &:focus-visible .avatar {
181
+ outline: 2px solid var(--theme-color);
182
+ outline-offset: 2px;
183
+ }
163
184
  }
164
185
 
165
186
  .avatar {
@@ -167,6 +188,7 @@ body.literate main table {
167
188
  height: 24px;
168
189
  vertical-align: middle;
169
190
  margin-left: 4px;
191
+ border: 1px solid transparent;
170
192
  }
171
193
  }
172
194
  }
package/src/_mixins.scss CHANGED
@@ -2,3 +2,44 @@
2
2
  -webkit-user-select: $val;
3
3
  user-select: $val;
4
4
  }
5
+
6
+ @mixin link-underline {
7
+ text-decoration-line: underline;
8
+ text-decoration-color: currentColor;
9
+ text-decoration-thickness: 1px;
10
+ text-underline-offset: 0.11rem;
11
+ }
12
+
13
+ @mixin subdued-link {
14
+ color: var(--fg-color);
15
+ text-decoration-line: none;
16
+
17
+ @media (hover: hover) {
18
+ &:hover {
19
+ color: var(--fg2-color);
20
+ @include link-underline;
21
+ }
22
+ }
23
+
24
+ &:active {
25
+ color: var(--link-active-color);
26
+ }
27
+ }
28
+
29
+ @mixin contained-icon {
30
+ background-size: contain;
31
+ background-repeat: no-repeat;
32
+ background-position: center center;
33
+ }
34
+
35
+ @mixin contained-mask($url) {
36
+ -webkit-mask-image: $url;
37
+ mask-image: $url;
38
+ -webkit-mask-size: contain;
39
+ mask-size: contain;
40
+ -webkit-mask-repeat: no-repeat;
41
+ mask-repeat: no-repeat;
42
+ -webkit-mask-position: center center;
43
+ mask-position: center center;
44
+ background-color: currentColor;
45
+ }
@@ -1,40 +1,33 @@
1
- .heading-anchor {
1
+ @use '../mixins' as *;
2
+
3
+ a.heading-anchor {
2
4
  position: relative;
3
5
  display: inline-block;
4
- color: var(--fg-color);
5
- text-decoration: none;
6
+ @include subdued-link;
6
7
  }
7
8
 
8
- .heading-anchor::after {
9
- content: var(--icon-anchor);
9
+ a.heading-anchor::after {
10
+ content: '';
10
11
  display: inline-block;
11
- height: 0.75em;
12
- width: 0.75em;
12
+ height: 1em;
13
+ width: 1em;
13
14
  margin-left: 4px;
15
+ vertical-align: middle;
14
16
  opacity: 0;
17
+ @include contained-mask(var(--icon-anchor));
15
18
  }
16
19
 
17
- .heading-anchor:focus::after,
18
- .heading-anchor:focus-visible::after {
20
+ a.heading-anchor:focus-visible::after {
19
21
  opacity: 1;
20
22
  }
21
23
 
22
24
  @media (hover: hover) {
23
- h1:hover .heading-anchor::after,
24
- h2:hover .heading-anchor::after,
25
- h3:hover .heading-anchor::after,
26
- h4:hover .heading-anchor::after,
27
- h5:hover .heading-anchor::after,
28
- h6:hover .heading-anchor::after {
25
+ h1:hover a.heading-anchor::after,
26
+ h2:hover a.heading-anchor::after,
27
+ h3:hover a.heading-anchor::after,
28
+ h4:hover a.heading-anchor::after,
29
+ h5:hover a.heading-anchor::after,
30
+ h6:hover a.heading-anchor::after {
29
31
  opacity: 1;
30
32
  }
31
-
32
- h1:hover .heading-anchor,
33
- h2:hover .heading-anchor,
34
- h3:hover .heading-anchor,
35
- h4:hover .heading-anchor,
36
- h5:hover .heading-anchor,
37
- h6:hover .heading-anchor {
38
- color: var(--fg-color);
39
- }
40
33
  }
@@ -1,3 +1,5 @@
1
+ @use '../mixins' as *;
2
+
1
3
  :root {
2
4
  --header-collapsed-height: 45px;
3
5
  }
@@ -31,6 +33,12 @@ header details summary {
31
33
  @media (hover: hover) {
32
34
  &:hover {
33
35
  color: inherit;
36
+ text-decoration-line: none;
37
+ background: var(--bg2-color);
38
+ }
39
+
40
+ &:hover .site-title {
41
+ @include link-underline;
34
42
  }
35
43
  }
36
44
 
@@ -26,20 +26,7 @@ header nav ul li {
26
26
  header nav a {
27
27
  border: none;
28
28
  display: inline;
29
- color: var(--fg-color);
30
- text-decoration: none;
31
- }
32
-
33
- @media (hover: hover) {
34
- header nav a {
35
- &:hover {
36
- color: var(--fg2-color);
37
- }
38
-
39
- &:active {
40
- color: var(--link-active-color);
41
- }
42
- }
29
+ @include subdued-link;
43
30
  }
44
31
 
45
32
  header nav a.external {
@@ -60,10 +47,6 @@ header nav div > p {
60
47
  }
61
48
 
62
49
  @media (hover: hover) {
63
- header details summary:hover {
64
- background: var(--bg2-color);
65
- }
66
-
67
50
  header:has(details[open]):hover {
68
51
  background: var(--bg-color-translucent);
69
52
  }
package/src/index.ts CHANGED
@@ -59,6 +59,18 @@ document.addEventListener('DOMContentLoaded', async () => {
59
59
  // Mount per-page components
60
60
  await mountPerPageComponents(window);
61
61
 
62
+ // On reload with manual scrollRestoration, browsers skip the initial
63
+ // fragment scroll. Do it ourselves once per-page components have
64
+ // finished mutating the DOM. On a fresh cold load the browser already
65
+ // scrolled, so this is a harmless re-align.
66
+ if (window.location.hash) {
67
+ const id = window.location.hash.slice(1);
68
+ const el = window.document.getElementById(id);
69
+ if (el) {
70
+ el.scrollIntoView();
71
+ }
72
+ }
73
+
62
74
  for (const [name, reason] of Object.entries(failed)) {
63
75
  console.error(`Failed to mount ${name} component:`, reason);
64
76
  }
@@ -5,6 +5,11 @@ let currentAbortController: AbortController | null = null;
5
5
  let historyIndex = 0;
6
6
  let currentPath = '';
7
7
 
8
+ // In-memory scroll positions keyed by navIndex. Populated on scroll and
9
+ // consulted by handlePopState so returning to a previous entry via
10
+ // back/forward restores the user's scroll, not just scroll 0.
11
+ const scrollByIndex = new Map<number, number>();
12
+
8
13
  type Direction = 'forward' | 'back';
9
14
 
10
15
  function findAnchor(event: MouseEvent): HTMLAnchorElement | null {
@@ -145,27 +150,35 @@ async function performNavigation(
145
150
  teardownPerPageComponents();
146
151
  swapContent(newDoc);
147
152
  updateHead(newDoc);
153
+ const parsed = new URL(url);
154
+ currentPath = parsed.pathname + parsed.search;
155
+
148
156
  if (isPush) {
149
157
  historyIndex++;
150
- window.history.pushState({ navIndex: historyIndex }, '', url);
158
+ // Push without the hash; we add it back below via location.replace
159
+ // so the browser performs a real fragment navigation that updates
160
+ // :target and fires hashchange. pushState alone would not do
161
+ // either of those things.
162
+ const urlWithoutHash = parsed.origin + parsed.pathname + parsed.search;
163
+ window.history.pushState({ navIndex: historyIndex }, '', urlWithoutHash);
151
164
  }
152
- const parsed = new URL(url);
153
- currentPath = parsed.pathname + parsed.search;
154
165
 
155
166
  if (typeof scrollTarget === 'string') {
156
- // Hash target
157
- const el = document.getElementById(scrollTarget);
158
- if (el) {
159
- el.scrollIntoView();
160
- } else {
161
- window.scrollTo({ top: 0 });
162
- }
167
+ // Trigger a real fragment navigation. location.replace overwrites
168
+ // the current entry's URL in place and runs fragment navigation
169
+ // (updating :target, firing hashchange, scrolling to the element).
170
+ // It wipes the entry's state object, so reapply it via
171
+ // replaceState.
172
+ const urlWithHash = parsed.pathname + parsed.search + '#' + scrollTarget;
173
+ window.location.replace(urlWithHash);
174
+ window.history.replaceState({ navIndex: historyIndex }, '', urlWithHash);
163
175
  } else if (typeof scrollTarget === 'number') {
164
176
  // Restore scroll position (popstate)
165
177
  window.scrollTo({ top: scrollTarget });
166
178
  } else {
167
179
  window.scrollTo({ top: 0 });
168
180
  }
181
+ scrollByIndex.set(historyIndex, window.scrollY);
169
182
  };
170
183
 
171
184
  const doc = document as Document & {
@@ -261,6 +274,16 @@ async function performNavigation(
261
274
  export default function mountNavigate(window: Window): () => void {
262
275
  window.history.scrollRestoration = 'manual';
263
276
  currentPath = window.location.pathname + window.location.search;
277
+ scrollByIndex.set(historyIndex, window.scrollY);
278
+
279
+ // Track scroll position on every scroll event. We keep the latest
280
+ // scrollY per navIndex so that back/forward navigation can restore
281
+ // where the user was, not just scroll 0. Map writes are cheap; no
282
+ // need to debounce.
283
+ const saveScroll = () => {
284
+ scrollByIndex.set(historyIndex, window.scrollY);
285
+ };
286
+ window.addEventListener('scroll', saveScroll, { passive: true });
264
287
 
265
288
  function handleClick(event: MouseEvent) {
266
289
  const anchor = findAnchor(event);
@@ -278,7 +301,8 @@ export default function mountNavigate(window: Window): () => void {
278
301
  return;
279
302
  }
280
303
 
281
- // Same-page hash links: smooth scroll instead of letting the browser jump
304
+ // Same-page hash links: handle locally so we can clear search and
305
+ // close the header without a full SPA fetch.
282
306
  const url = new URL(anchor.href);
283
307
  if (
284
308
  url.pathname === window.location.pathname &&
@@ -293,11 +317,11 @@ export default function mountNavigate(window: Window): () => void {
293
317
  details.open = false;
294
318
  }
295
319
  if (url.hash) {
296
- window.history.pushState(null, '', url.hash);
297
- const el = window.document.getElementById(url.hash.slice(1));
298
- if (el) {
299
- el.scrollIntoView({ behavior: 'smooth' });
300
- }
320
+ // location.hash assignment performs a real fragment navigation:
321
+ // updates :target, fires hashchange (which the TOC listens for),
322
+ // creates a new history entry, and scrolls to the element.
323
+ // pushState would do none of those things.
324
+ window.location.hash = url.hash.slice(1);
301
325
  }
302
326
  return;
303
327
  }
@@ -314,15 +338,8 @@ export default function mountNavigate(window: Window): () => void {
314
338
  details.open = false;
315
339
  }
316
340
 
317
- // Save current scroll position and history index
318
- window.history.replaceState(
319
- {
320
- ...window.history.state,
321
- scrollY: window.scrollY,
322
- navIndex: historyIndex,
323
- },
324
- '',
325
- );
341
+ // Save current scroll position for the entry we're leaving
342
+ scrollByIndex.set(historyIndex, window.scrollY);
326
343
 
327
344
  const hash = url.hash ? url.hash.slice(1) : null;
328
345
 
@@ -337,23 +354,23 @@ export default function mountNavigate(window: Window): () => void {
337
354
  if (hash) {
338
355
  const el = window.document.getElementById(hash);
339
356
  if (el) {
340
- el.scrollIntoView({ behavior: 'smooth' });
357
+ el.scrollIntoView();
341
358
  }
342
359
  } else {
343
- window.scrollTo({ top: 0, behavior: 'smooth' });
360
+ window.scrollTo({ top: 0 });
344
361
  }
345
362
  return;
346
363
  }
347
364
  currentPath = newPath;
348
365
 
349
- const scrollY = event.state?.scrollY ?? null;
350
366
  const targetIndex = event.state?.navIndex ?? 0;
351
367
  const direction: Direction =
352
368
  targetIndex >= historyIndex ? 'forward' : 'back';
353
369
  historyIndex = targetIndex;
370
+ const savedY = scrollByIndex.get(targetIndex);
354
371
  performNavigation(
355
372
  window.location.href,
356
- typeof scrollY === 'number' ? scrollY : null,
373
+ typeof savedY === 'number' ? savedY : null,
357
374
  direction,
358
375
  false,
359
376
  );
@@ -367,5 +384,6 @@ export default function mountNavigate(window: Window): () => void {
367
384
  return () => {
368
385
  window.document.removeEventListener('click', handleClick, true);
369
386
  window.removeEventListener('popstate', handlePopState);
387
+ window.removeEventListener('scroll', saveScroll);
370
388
  };
371
389
  }
@@ -65,7 +65,7 @@
65
65
  width: 100%;
66
66
  }
67
67
 
68
- .heading-anchor::after {
68
+ a.heading-anchor::after {
69
69
  display: none;
70
70
  }
71
71
 
package/src/style.scss CHANGED
@@ -55,42 +55,23 @@ a.disabled {
55
55
  @media (hover: hover) {
56
56
  &:hover {
57
57
  color: var(--fg-color-translucent);
58
+ text-decoration-line: none;
58
59
  }
59
60
  }
60
61
  }
61
62
 
62
- a.external::after {
63
- content: '';
64
- background: var(--icon-external-link);
65
- background-size: contain;
66
- background-repeat: no-repeat;
67
- background-position: center center;
68
- margin-left: 4px;
69
- padding-right: 18px;
70
- }
71
-
72
- @media (hover: hover) {
73
- a.external:hover::after {
74
- background: var(--icon-external-link-hover);
75
- background-size: contain;
76
- background-repeat: no-repeat;
77
- background-position: center center;
78
- }
79
- }
80
-
81
- a.external:active::after {
82
- background: var(--icon-external-link-active) !important;
83
- background-size: contain !important;
84
- background-repeat: no-repeat !important;
85
- background-position: center center !important;
63
+ .external-link-tail {
64
+ white-space: nowrap;
86
65
  }
87
66
 
88
- a.external.disabled::after,
89
- a.external.disabled:active::after {
90
- background: var(--icon-external-translucent) !important;
91
- background-size: contain !important;
92
- background-repeat: no-repeat !important;
93
- background-position: center center !important;
67
+ .external-link-tail::after,
68
+ a.external:not(:has(.external-link-tail))::after {
69
+ content: '';
70
+ display: inline-block;
71
+ width: 1em;
72
+ height: 1em;
73
+ vertical-align: middle;
74
+ @include contained-mask(var(--icon-external-link));
94
75
  }
95
76
 
96
77
  .footnotes {
@@ -379,21 +360,6 @@ dl {
379
360
  dt {
380
361
  font-weight: 600;
381
362
  color: var(--theme-color-text);
382
-
383
- a {
384
- color: var(--theme-color-text);
385
- text-decoration-color: var(--theme-color-text);
386
-
387
- @media (hover: hover) {
388
- &:hover {
389
- text-decoration-color: var(--fg2-color);
390
- }
391
- }
392
-
393
- &:active {
394
- text-decoration-color: var(--link-active-color);
395
- }
396
- }
397
363
  }
398
364
 
399
365
  dd > *:first-child {
@@ -407,12 +373,17 @@ dfn {
407
373
  font-style: italic;
408
374
  }
409
375
 
410
- .breadcrumb {
376
+ a.breadcrumb {
411
377
  @include user-select(none);
378
+ @include subdued-link;
412
379
 
413
380
  &::after {
414
- content: var(--icon-breadcrumb);
381
+ content: '';
382
+ display: inline-block;
383
+ width: 1em;
384
+ height: 1em;
415
385
  vertical-align: middle;
386
+ @include contained-mask(var(--icon-breadcrumb));
416
387
  }
417
388
  }
418
389
 
package/src/toc/index.ts CHANGED
@@ -37,7 +37,7 @@ function scrollIfNeeded(element: HTMLElement, doc: Document) {
37
37
  elementRect.height / 2;
38
38
  const desiredScrollTop = elementCenter - container.clientHeight / 2;
39
39
 
40
- container.scrollTo({ top: desiredScrollTop, behavior: 'smooth' });
40
+ container.scrollTo({ top: desiredScrollTop });
41
41
  }
42
42
 
43
43
  export function getHighlightIndexes(items: (Heading | Alert | Dinkus)[]) {
@@ -127,51 +127,22 @@ body.code .toc ol a {
127
127
  position: relative;
128
128
  }
129
129
 
130
- // Preload hover icon variants so the browser decodes them before first hover
131
- .toc ol li.alert-item.warning::after {
132
- content: var(--icon-warning-sm-hover);
133
- position: absolute;
134
- width: 0;
135
- height: 0;
136
- overflow: hidden;
137
- }
138
-
139
- .toc ol li.alert-item.note::after {
140
- content: var(--icon-note-sm-hover);
141
- position: absolute;
142
- width: 0;
143
- height: 0;
144
- overflow: hidden;
145
- }
146
-
147
130
  .toc ol li.alert-item.warning a::before,
148
131
  .toc ol li.alert-item.note a::before {
132
+ content: '';
149
133
  display: inline-block;
134
+ width: 20px;
150
135
  height: 20px;
151
136
  vertical-align: bottom;
152
137
  margin-right: 4px;
153
138
  }
154
139
 
155
140
  .toc ol li.alert-item.warning a::before {
156
- content: var(--icon-warning-sm);
157
- }
158
-
159
- @media (hover: hover) {
160
- .toc ol li.alert-item.warning:hover a::before,
161
- .toc ol li.alert-item.warning a:hover::before {
162
- content: var(--icon-warning-sm-hover);
163
- }
141
+ @include contained-mask(var(--icon-warning));
164
142
  }
165
143
 
166
144
  .toc ol li.alert-item.note a::before {
167
- content: var(--icon-note-sm);
168
- }
169
-
170
- @media (hover: hover) {
171
- .toc ol li.alert-item.note:hover a::before,
172
- .toc ol li.alert-item.note a:hover::before {
173
- content: var(--icon-note-sm-hover);
174
- }
145
+ @include contained-mask(var(--icon-note));
175
146
  }
176
147
 
177
148
  .toc ol li.label {
package/src/top/index.ts CHANGED
@@ -30,11 +30,15 @@ export default (window: Window) => {
30
30
  e.preventDefault();
31
31
  const cleanUrl = window.location.pathname + window.location.search;
32
32
  if (window.location.hash) {
33
- window.history.pushState(null, '', cleanUrl);
33
+ // Clear the hash via fragment navigation so :target clears and
34
+ // hashchange fires. location.hash = '' may leave a trailing '#';
35
+ // strip it with replaceState.
36
+ window.location.hash = '';
37
+ window.history.replaceState(null, '', cleanUrl);
34
38
  } else {
35
39
  window.history.replaceState(null, '', cleanUrl);
36
40
  }
37
- window.scrollTo({ top: 0, behavior: 'smooth' });
41
+ window.scrollTo({ top: 0 });
38
42
  const toc = window.document.querySelector('nav.toc') as HTMLElement | null;
39
43
  if (toc) {
40
44
  toc.scrollTop = 0;
@@ -21,6 +21,9 @@
21
21
  // Secondary foreground color (lighter)
22
22
  --fg2-color: hsl(var(--tint-hue) calc(6% * var(--tint-amount)) 45%);
23
23
 
24
+ --link-color: <%= linkColor %>;
25
+ --link-color-hover: <%= linkColorHover %>;
26
+
24
27
  // Main background color (the color of the page background)
25
28
  --bg-color: hsl(var(--tint-hue) calc(5% * var(--tint-amount)) 97%);
26
29
  --bg-color-translucent: hsl(
@@ -56,20 +59,14 @@
56
59
  --mono-border-radius: 0.375em;
57
60
 
58
61
  // SVG icons
59
- --icon-external-link: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><rect x='3' y='3' width='18' height='18' stroke='<%= iconColor %>' stroke-width='2' fill='none' rx='2'/><path d='M9 15 L15 9' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round' fill='none'/><path d='M9 9 H15 V15' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round' fill='none'/></svg>");
60
- --icon-external-link-hover: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><rect x='3' y='3' width='18' height='18' stroke='<%= iconColorHover %>' stroke-width='2' fill='none' rx='2'/><path d='M9 15 L15 9' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linecap='round' fill='none'/><path d='M9 9 H15 V15' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linecap='round' fill='none'/></svg>");
61
- --icon-external-link-active: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><rect x='3' y='3' width='18' height='18' stroke='black' stroke-width='2' fill='none' rx='2'/><path d='M9 15 L15 9' stroke='black' stroke-width='2' stroke-linecap='round' fill='none'/><path d='M9 9 H15 V15' stroke='black' stroke-width='2' stroke-linecap='round' fill='none'/></svg>");
62
- --icon-external-translucent: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><g opacity='0.3' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round' fill='none'><rect x='3' y='3' width='18' height='18' rx='2'/><path d='M9 15 L15 9' stroke-linecap='round'/><path d='M9 9 H15 V15' stroke-linecap='round'/></g></svg>");
63
- --icon-warning: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='<%= iconColor %>' stroke-width='2' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/></svg>");
64
- --icon-warning-sm: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='<%= iconColor %>' stroke-width='2' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/></svg>");
65
- --icon-warning-sm-hover: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linecap='round'/></svg>");
66
- --icon-note: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='<%= iconColor %>' stroke-width='2'/><line x1='12' y1='10' x2='12' y2='16' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/></svg>");
67
- --icon-note-sm: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='<%= iconColor %>' stroke-width='2'/><line x1='12' y1='10' x2='12' y2='16' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/></svg>");
68
- --icon-note-sm-hover: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='<%= iconColorHover %>' stroke-width='2'/><line x1='12' y1='10' x2='12' y2='16' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='<%= iconColorHover %>' stroke-width='2' stroke-linecap='round'/></svg>");
62
+ // Mask icons (color-agnostic, painted via background-color: currentColor)
63
+ --icon-external-link: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path d='M6 18 L20 4 M10 4 H20 V14' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
64
+ --icon-anchor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 28'><path d='M11 4 L8 20 M17 4 L14 20 M5 9 H21 M3 15 H19' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
65
+ --icon-warning: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='black' stroke-width='1.5' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='black' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='black' stroke-width='2' stroke-linecap='round'/></svg>");
66
+ --icon-note: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='black' stroke-width='1.5'/><line x1='12' y1='10' x2='12' y2='16' stroke='black' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='black' stroke-width='2' stroke-linecap='round'/></svg>");
69
67
  --icon-search: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><circle cx='11' cy='11' r='7' stroke='<%= iconColor %>' stroke-width='2' fill='none'/><line x1='16.5' y1='16.5' x2='22' y2='22' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round'/></svg>");
70
- --icon-anchor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='<%= iconColor %>' fill-rule='evenodd' clip-rule='evenodd' d='M10.975 14.51a1.05 1.05 0 0 0 0-1.485 2.95 2.95 0 0 1 0-4.172l3.536-3.535a2.95 2.95 0 1 1 4.172 4.172l-1.093 1.092a1.05 1.05 0 0 0 1.485 1.485l1.093-1.092a5.05 5.05 0 0 0-7.142-7.142L9.49 7.368a5.05 5.05 0 0 0 0 7.142c.41.41 1.075.41 1.485 0zm2.05-5.02a1.05 1.05 0 0 0 0 1.485 2.95 2.95 0 0 1 0 4.172l-3.5 3.5a2.95 2.95 0 1 1-4.171-4.172l1.025-1.025a1.05 1.05 0 0 0-1.485-1.485L3.87 12.99a5.05 5.05 0 0 0 7.142 7.142l3.5-3.5a5.05 5.05 0 0 0 0-7.142 1.05 1.05 0 0 0-1.485 0z'/></svg>");
71
68
  --icon-chevron: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><polyline points='9 18 15 12 9 6' stroke='<%= iconColor %>' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
72
- --icon-breadcrumb: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path d='M3 7 H17 V22' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/><polyline points='11 16 17 22 23 16' stroke='<%= iconColor %>' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
69
+ --icon-breadcrumb: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path d='M3 7 H17 V22' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/><polyline points='11 16 17 22 23 16' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
73
70
 
74
71
  color-scheme: light dark;
75
72
  }
@@ -85,6 +82,8 @@
85
82
  var(--tint-hue) calc(85% * var(--tint-amount)) 90% / 33%
86
83
  );
87
84
  --fg2-color: hsl(var(--tint-hue) calc(6% * var(--tint-amount)) 60%);
85
+ --link-color: <%= linkColorDark %>;
86
+ --link-color-hover: <%= linkColorHoverDark %>;
88
87
  --bg-color: hsl(var(--tint-hue) calc(3% * var(--tint-amount)) 5%);
89
88
  --bg-color-translucent: hsl(
90
89
  var(--tint-hue) calc(15% * var(--tint-amount)) 10% / 66%
@@ -103,19 +102,7 @@
103
102
  --note-bg-color: hsl(209 90% 25%);
104
103
 
105
104
  // SVG icons (dark overrides)
106
- --icon-external-link: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><rect x='3' y='3' width='18' height='18' stroke='<%= iconColorDark %>' stroke-width='2' fill='none' rx='2'/><path d='M9 15 L15 9' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round' fill='none'/><path d='M9 9 H15 V15' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round' fill='none'/></svg>");
107
- --icon-external-link-hover: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><rect x='3' y='3' width='18' height='18' stroke='<%= iconColorHoverDark %>' stroke-width='2' fill='none' rx='2'/><path d='M9 15 L15 9' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linecap='round' fill='none'/><path d='M9 9 H15 V15' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linecap='round' fill='none'/></svg>");
108
- --icon-external-link-active: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><rect x='3' y='3' width='18' height='18' stroke='white' stroke-width='2' fill='none' rx='2'/><path d='M9 15 L15 9' stroke='white' stroke-width='2' stroke-linecap='round' fill='none'/><path d='M9 9 H15 V15' stroke='white' stroke-width='2' stroke-linecap='round' fill='none'/></svg>");
109
- --icon-external-translucent: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24'><g opacity='0.3' stroke='<%= iconColorTranslucentDark %>' stroke-width='2' stroke-linecap='round' fill='none'><rect x='3' y='3' width='18' height='18' rx='2'/><path d='M9 15 L15 9' stroke-linecap='round'/><path d='M9 9 H15 V15' stroke-linecap='round'/></g></svg>");
110
- --icon-warning: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
111
- --icon-warning-sm: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
112
- --icon-warning-sm-hover: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><polygon points='12,2 22,20 2,20' fill='none' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linejoin='round'/><line x1='12' y1='9' x2='12' y2='13' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='16' x2='12' y2='16' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
113
- --icon-note: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='<%= iconColorDark %>' stroke-width='2'/><line x1='12' y1='10' x2='12' y2='16' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
114
- --icon-note-sm: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='<%= iconColorDark %>' stroke-width='2'/><line x1='12' y1='10' x2='12' y2='16' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
115
- --icon-note-sm-hover: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='none' stroke='<%= iconColorHoverDark %>' stroke-width='2'/><line x1='12' y1='10' x2='12' y2='16' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linecap='round'/><line x1='12' y1='7' x2='12' y2='7' stroke='<%= iconColorHoverDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
116
105
  --icon-search: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><circle cx='11' cy='11' r='7' stroke='<%= iconColorDark %>' stroke-width='2' fill='none'/><line x1='16.5' y1='16.5' x2='22' y2='22' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round'/></svg>");
117
- --icon-anchor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='<%= iconColorDark %>' fill-rule='evenodd' clip-rule='evenodd' d='M10.975 14.51a1.05 1.05 0 0 0 0-1.485 2.95 2.95 0 0 1 0-4.172l3.536-3.535a2.95 2.95 0 1 1 4.172 4.172l-1.093 1.092a1.05 1.05 0 0 0 1.485 1.485l1.093-1.092a5.05 5.05 0 0 0-7.142-7.142L9.49 7.368a5.05 5.05 0 0 0 0 7.142c.41.41 1.075.41 1.485 0zm2.05-5.02a1.05 1.05 0 0 0 0 1.485 2.95 2.95 0 0 1 0 4.172l-3.5 3.5a2.95 2.95 0 1 1-4.171-4.172l1.025-1.025a1.05 1.05 0 0 0-1.485-1.485L3.87 12.99a5.05 5.05 0 0 0 7.142 7.142l3.5-3.5a5.05 5.05 0 0 0 0-7.142 1.05 1.05 0 0 0-1.485 0z'/></svg>");
118
106
  --icon-chevron: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><polyline points='9 18 15 12 9 6' stroke='<%= iconColorDark %>' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
119
- --icon-breadcrumb: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24'><path d='M3 7 H17 V22' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/><polyline points='11 16 17 22 23 16' stroke='<%= iconColorDark %>' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' fill='none'/></svg>");
120
107
  }
121
108
  }
@@ -33,6 +33,7 @@
33
33
  <meta property="og:author" content="<%= page.author.name %>" />
34
34
  <% } %>
35
35
  <title><%= page.title %><%= site.titlePostfix %></title>
36
+ <% if (!isWatchMode) { %>
36
37
  <script type="speculationrules">
37
38
  {
38
39
  "prerender": [{
@@ -43,6 +44,7 @@
43
44
  }]
44
45
  }
45
46
  </script>
47
+ <% } %>
46
48
  <script>document.head.appendChild(document.createElement('style')).textContent='.time-zone-label[hidden],select.time-zone[hidden]{display:revert}'</script>
47
49
  <noscript><style>
48
50
  .question-a-body:not([data-revealed]) > * { color: unset; background-color: unset; }