@abreen/tada 1.16.0 → 1.16.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.
Files changed (55) hide show
  1. package/README.md +19 -0
  2. package/assets/material-symbols/celebration_24px.svg +1 -1
  3. package/assets/material-symbols/close_24px.svg +1 -1
  4. package/assets/material-symbols/contrast_20px.svg +1 -0
  5. package/assets/material-symbols/contrast_rtl_off_20px.svg +1 -0
  6. package/assets/material-symbols/info_20px.svg +1 -0
  7. package/assets/material-symbols/info_40px.svg +1 -0
  8. package/assets/material-symbols/menu_24px.svg +1 -1
  9. package/assets/material-symbols/open_in_new_20px.svg +1 -0
  10. package/assets/material-symbols/search_20px.svg +1 -0
  11. package/assets/material-symbols/smart_display_24px.svg +1 -0
  12. package/assets/material-symbols/south_east_20px.svg +1 -0
  13. package/assets/material-symbols/warning_20px.svg +1 -0
  14. package/assets/material-symbols/warning_40px.svg +1 -0
  15. package/bin/validators.ts +2 -0
  16. package/build/bundle.ts +23 -4
  17. package/build/custom-fonts.ts +300 -0
  18. package/build/generate-fonts.ts +43 -0
  19. package/build/material-symbols.ts +58 -20
  20. package/build/pipeline.ts +1 -1
  21. package/build/site-variables.ts +2 -0
  22. package/build/types.d.ts +29 -0
  23. package/build/utils/render.ts +36 -12
  24. package/build/watch/assets.ts +3 -3
  25. package/build/watch/build-full.ts +1 -1
  26. package/build/watch/build-incremental.ts +1 -1
  27. package/build/watch/validation.ts +18 -7
  28. package/package.json +1 -1
  29. package/schema/site.schema.json +77 -0
  30. package/src/_alerts.scss +17 -12
  31. package/src/_layout.scss +2 -2
  32. package/src/anchor/index.ts +2 -35
  33. package/src/anchor/style.scss +4 -28
  34. package/src/appearance-picker/index.ts +78 -19
  35. package/src/appearance-picker/style.scss +9 -11
  36. package/src/header/_base.scss +14 -6
  37. package/src/header/style.scss +8 -0
  38. package/src/material-symbols.scss +16 -3
  39. package/src/navigate/runtime.ts +13 -0
  40. package/src/question/style.scss +1 -2
  41. package/src/search/style.scss +5 -3
  42. package/src/style.scss +11 -5
  43. package/src/toc/style.scss +14 -10
  44. package/templates/_appearance-picker.html +4 -4
  45. package/templates/_theme.scss +14 -4
  46. package/templates/_top.html +42 -6
  47. package/templates/code.html +1 -1
  48. package/assets/material-symbols/contrast_24px.svg +0 -1
  49. package/assets/material-symbols/contrast_rtl_off_24px.svg +0 -1
  50. package/assets/material-symbols/info_24px.svg +0 -1
  51. package/assets/material-symbols/north_east_24px.svg +0 -1
  52. package/assets/material-symbols/search_24px.svg +0 -1
  53. package/assets/material-symbols/south_east_24px.svg +0 -1
  54. package/assets/material-symbols/tag_24px.svg +0 -1
  55. package/assets/material-symbols/warning_24px.svg +0 -1
package/build/types.d.ts CHANGED
@@ -3,6 +3,32 @@ import type { TadaProjectScan } from './source-model';
3
3
 
4
4
  export type PlainTextLanguage = 'text' | 'txt' | 'plain';
5
5
 
6
+ export interface SerifFontTuning {
7
+ scale?: number;
8
+ lineHeight?: number;
9
+ headingScale?: number;
10
+ headingWeight?: 400 | 700;
11
+ }
12
+
13
+ export interface SerifMonoFontTuning {
14
+ scale?: number;
15
+ lineHeight?: number;
16
+ }
17
+
18
+ export interface FontFamilyOverride<Tuning = unknown> {
19
+ regular: string;
20
+ italic?: string;
21
+ bold?: string;
22
+ boldItalic?: string;
23
+ features?: string[];
24
+ tuning?: Tuning;
25
+ }
26
+
27
+ export interface FontOverrides {
28
+ serif?: FontFamilyOverride<SerifFontTuning>;
29
+ serifMono?: FontFamilyOverride<SerifMonoFontTuning>;
30
+ }
31
+
6
32
  /** Site configuration loaded from site.dev.yaml/yml/json or site.prod.yaml/yml/json */
7
33
  export interface SiteVariables {
8
34
  base: string;
@@ -16,6 +42,9 @@ export interface SiteVariables {
16
42
  faviconFontWeight?: number;
17
43
  internalDomains?: string[];
18
44
  defaultTimeZone: string;
45
+ defaultFont?: 'sans' | 'serif';
46
+ defaultContrast?: 'standard' | 'high';
47
+ fontOverrides?: FontOverrides;
19
48
  features: FeatureConfig;
20
49
  extensionToShikiLanguage?: Record<
21
50
  string,
@@ -7,6 +7,8 @@ import { makeLogger } from '../log';
7
7
  import { B } from '../colors';
8
8
  import createTemplateGlobals from '../template-globals';
9
9
  import { getExtensionToShikiLanguage } from '../site-variables';
10
+ import { getDefaultFontPreloadFiles } from '../generate-fonts';
11
+ import { encodePublicAssetPath } from '../custom-fonts';
10
12
  import { config } from '../templates';
11
13
  import { render } from '../templates';
12
14
  import {
@@ -157,6 +159,7 @@ export function injectAssetTags(
157
159
  html: string,
158
160
  assetFiles: string[],
159
161
  distDir: string,
162
+ siteVariables: SiteVariables,
160
163
  ): string {
161
164
  const jsAssets = assetFiles.filter(f => f.endsWith('.js'));
162
165
  const cssAssets = assetFiles.filter(f => f.endsWith('.css'));
@@ -182,15 +185,19 @@ export function injectAssetTags(
182
185
  )
183
186
  .join('');
184
187
 
185
- const fontPreloadTags = [
186
- 'inter/InterVariable.woff2',
187
- 'google-sans-code/GoogleSansCodeVariable.woff2',
188
- ]
189
- .filter(f => fs.existsSync(path.join(distDir, f)))
190
- .map(
191
- f =>
192
- `<link rel="preload" href="${normalizeOutputPath('/' + f)}" as="font" type="font/woff2" crossorigin>`,
188
+ const fontPreloadTags = getDefaultFontPreloadFiles(siteVariables)
189
+ .filter(
190
+ preload =>
191
+ preload.source === 'public' ||
192
+ fs.existsSync(path.join(distDir, preload.filePath)),
193
193
  )
194
+ .map(preload => {
195
+ const urlPath =
196
+ preload.source === 'public'
197
+ ? encodePublicAssetPath(preload.filePath)
198
+ : preload.filePath;
199
+ return `<link rel="preload" href="${normalizeOutputPath('/' + urlPath)}" as="font" type="font/woff2" crossorigin>`;
200
+ })
194
201
  .join('');
195
202
 
196
203
  return html
@@ -210,12 +217,14 @@ export function preparePageTemplateHtml({
210
217
  templateHtml,
211
218
  assetFiles,
212
219
  distDir,
220
+ siteVariables,
213
221
  }: {
214
222
  templateHtml: string;
215
223
  assetFiles: string[];
216
224
  distDir: string;
225
+ siteVariables: SiteVariables;
217
226
  }): string {
218
- let html = injectAssetTags(templateHtml, assetFiles, distDir);
227
+ let html = injectAssetTags(templateHtml, assetFiles, distDir, siteVariables);
219
228
  if (templateHtml.includes('class="katex"')) {
220
229
  html = injectKatexStylesheet(html);
221
230
  }
@@ -289,7 +298,12 @@ export function renderPlainTextPageAsset({
289
298
  ) as string;
290
299
  const finalized = finalizeHtmlPage({
291
300
  filePath,
292
- html: preparePageTemplateHtml({ templateHtml, assetFiles, distDir }),
301
+ html: preparePageTemplateHtml({
302
+ templateHtml,
303
+ assetFiles,
304
+ distDir,
305
+ siteVariables,
306
+ }),
293
307
  siteVariables,
294
308
  sourceUrlPath,
295
309
  validInternalTargets,
@@ -362,7 +376,12 @@ export function renderCodePageAsset({
362
376
  const templateHtml = render('code.html', templateParameters) as string;
363
377
  const finalized = finalizeHtmlPage({
364
378
  filePath,
365
- html: preparePageTemplateHtml({ templateHtml, assetFiles, distDir }),
379
+ html: preparePageTemplateHtml({
380
+ templateHtml,
381
+ assetFiles,
382
+ distDir,
383
+ siteVariables,
384
+ }),
366
385
  siteVariables,
367
386
  sourceUrlPath,
368
387
  validInternalTargets,
@@ -700,7 +719,12 @@ export function renderLiterateJavaPageAsset({
700
719
  const templateHtml = render('literate.html', templateParameters) as string;
701
720
  const finalized = finalizeHtmlPage({
702
721
  filePath,
703
- html: preparePageTemplateHtml({ templateHtml, assetFiles, distDir }),
722
+ html: preparePageTemplateHtml({
723
+ templateHtml,
724
+ assetFiles,
725
+ distDir,
726
+ siteVariables,
727
+ }),
704
728
  siteVariables,
705
729
  sourceUrlPath,
706
730
  validInternalTargets,
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { bundle, getBundleNaming } from '../bundle';
4
- import { copyFonts } from '../generate-fonts';
4
+ import { copyFonts, DEFAULT_FONT_PRELOAD_FILES } from '../generate-fonts';
5
5
  import { copyKatexAssets } from '../generate-katex-assets';
6
6
  import { generateFavicons } from '../generate-favicon';
7
7
  import { generateWebAppManifest } from '../generate-web-app-manifest';
@@ -72,8 +72,8 @@ export function copyExistingBuildAssets(
72
72
  ): void {
73
73
  const relPaths = [
74
74
  ...assetFiles,
75
- 'inter/InterVariable.woff2',
76
- 'google-sans-code/GoogleSansCodeVariable.woff2',
75
+ ...DEFAULT_FONT_PRELOAD_FILES.sans,
76
+ ...DEFAULT_FONT_PRELOAD_FILES.serif,
77
77
  ];
78
78
 
79
79
  for (const relPath of relPaths) {
@@ -32,7 +32,7 @@ export async function buildFull({
32
32
  try {
33
33
  const siteVariables = getDevSiteVariables();
34
34
  const scan = scanProject(siteVariables);
35
- const configDiagnostics = validateConfig(scan);
35
+ const configDiagnostics = validateConfig(scan, siteVariables);
36
36
  if (configDiagnostics.length > 0) {
37
37
  return buildFailedWithDiagnostics(outputDir, configDiagnostics);
38
38
  }
@@ -34,7 +34,7 @@ export async function buildIncremental({
34
34
  const outputDir = makeTempBuildDir(distDir);
35
35
  try {
36
36
  const siteVariables = snapshot.siteVariables;
37
- const configDiagnostics = validateConfig(plan.scan);
37
+ const configDiagnostics = validateConfig(plan.scan, siteVariables);
38
38
  if (configDiagnostics.length > 0) {
39
39
  return buildFailedWithDiagnostics(outputDir, configDiagnostics);
40
40
  }
@@ -2,6 +2,8 @@ import { B } from '../colors';
2
2
  import { makeLogger } from '../log';
3
3
  import { config, getConfigFileName } from '../templates';
4
4
  import { validateConfigLinks } from '../validate-config-links';
5
+ import { validateCustomFontOverrides } from '../custom-fonts';
6
+ import type { SiteVariables } from '../types';
5
7
  import type { WatchDiagnostic } from './types';
6
8
  import { assertNoOutputPathConflicts, type TadaProjectScan } from './snapshot';
7
9
 
@@ -11,20 +13,29 @@ export function diagnosticsFromMessages(messages: string[]): WatchDiagnostic[] {
11
13
  return messages.map(message => ({ message }));
12
14
  }
13
15
 
14
- export function validateConfig(scan: TadaProjectScan): WatchDiagnostic[] {
16
+ export function validateConfig(
17
+ scan: TadaProjectScan,
18
+ siteVariables: SiteVariables,
19
+ ): WatchDiagnostic[] {
20
+ const diagnostics = diagnosticsFromMessages(
21
+ validateCustomFontOverrides({
22
+ fontOverrides: siteVariables.fontOverrides,
23
+ publicDir: scan.publicDir,
24
+ publicFiles: scan.publicFiles,
25
+ }),
26
+ );
15
27
  const conflicts = assertNoOutputPathConflicts(scan);
16
28
  if (conflicts.length === 0) {
17
- return [];
29
+ return diagnostics;
18
30
  }
19
31
  for (const relPath of conflicts) {
20
32
  log.error`content/${B`${relPath}`} conflicts with public/${B`${relPath}`}`;
21
33
  }
22
34
  const noun = conflicts.length === 1 ? 'file' : 'files';
23
- return [
24
- {
25
- message: `${conflicts.length} ${noun} in content/ and public/ have the same path`,
26
- },
27
- ];
35
+ diagnostics.push({
36
+ message: `${conflicts.length} ${noun} in content/ and public/ have the same path`,
37
+ });
38
+ return diagnostics;
28
39
  }
29
40
 
30
41
  export function validateProjectConfigLinks(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abreen/tada",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
4
4
  "type": "module",
5
5
  "description": "A static site generator",
6
6
  "license": "MIT",
@@ -48,6 +48,24 @@
48
48
  "default": 700
49
49
  },
50
50
  "defaultTimeZone": { "type": "string" },
51
+ "defaultFont": {
52
+ "type": "string",
53
+ "enum": ["sans", "serif"],
54
+ "default": "sans"
55
+ },
56
+ "defaultContrast": {
57
+ "type": "string",
58
+ "enum": ["standard", "high"],
59
+ "default": "standard"
60
+ },
61
+ "fontOverrides": {
62
+ "type": "object",
63
+ "properties": {
64
+ "serif": { "$ref": "#/$defs/serifFontOverride" },
65
+ "serifMono": { "$ref": "#/$defs/serifMonoFontOverride" }
66
+ },
67
+ "additionalProperties": false
68
+ },
51
69
  "vars": { "type": "object" },
52
70
  "extensionToShikiLanguage": {
53
71
  "type": "object",
@@ -55,5 +73,64 @@
55
73
  },
56
74
  "shikiLanguages": { "type": "array", "items": { "type": "string" } }
57
75
  },
76
+ "$defs": {
77
+ "serifFontOverride": {
78
+ "type": "object",
79
+ "required": ["regular"],
80
+ "properties": {
81
+ "regular": { "$ref": "#/$defs/publicWoff2Path" },
82
+ "italic": { "$ref": "#/$defs/publicWoff2Path" },
83
+ "bold": { "$ref": "#/$defs/publicWoff2Path" },
84
+ "boldItalic": { "$ref": "#/$defs/publicWoff2Path" },
85
+ "features": {
86
+ "type": "array",
87
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9]{4}$" },
88
+ "uniqueItems": true
89
+ },
90
+ "tuning": {
91
+ "type": "object",
92
+ "properties": {
93
+ "scale": { "$ref": "#/$defs/fontScale" },
94
+ "lineHeight": { "$ref": "#/$defs/fontLineHeight" },
95
+ "headingScale": { "$ref": "#/$defs/fontScale" },
96
+ "headingWeight": { "type": "integer", "enum": [400, 700] }
97
+ },
98
+ "additionalProperties": false
99
+ }
100
+ },
101
+ "additionalProperties": false
102
+ },
103
+ "serifMonoFontOverride": {
104
+ "type": "object",
105
+ "required": ["regular"],
106
+ "properties": {
107
+ "regular": { "$ref": "#/$defs/publicWoff2Path" },
108
+ "italic": { "$ref": "#/$defs/publicWoff2Path" },
109
+ "bold": { "$ref": "#/$defs/publicWoff2Path" },
110
+ "boldItalic": { "$ref": "#/$defs/publicWoff2Path" },
111
+ "features": {
112
+ "type": "array",
113
+ "items": { "type": "string", "pattern": "^[A-Za-z0-9]{4}$" },
114
+ "uniqueItems": true
115
+ },
116
+ "tuning": {
117
+ "type": "object",
118
+ "properties": {
119
+ "scale": { "$ref": "#/$defs/fontScale" },
120
+ "lineHeight": { "$ref": "#/$defs/fontLineHeight" }
121
+ },
122
+ "additionalProperties": false
123
+ }
124
+ },
125
+ "additionalProperties": false
126
+ },
127
+ "fontScale": { "type": "number", "minimum": 0.75, "maximum": 1.5 },
128
+ "fontLineHeight": { "type": "number", "minimum": 1, "maximum": 2.5 },
129
+ "publicWoff2Path": {
130
+ "type": "string",
131
+ "minLength": 6,
132
+ "pattern": "\\.woff2$"
133
+ }
134
+ },
58
135
  "additionalProperties": false
59
136
  }
package/src/_alerts.scss CHANGED
@@ -18,6 +18,8 @@
18
18
 
19
19
  .alert .title {
20
20
  padding: 10px 6px;
21
+ padding-top: 18px;
22
+ padding-left: 58px;
21
23
  }
22
24
 
23
25
  .alert .content {
@@ -67,32 +69,35 @@
67
69
 
68
70
  .alert .title::before {
69
71
  position: absolute;
70
- top: 8px;
71
- left: 8px;
72
- width: 32px;
73
- height: 32px;
72
+ top: 12px;
73
+ left: 10px;
74
+ width: var(--alert-icon-size);
75
+ height: var(--alert-icon-size);
74
76
  content: '';
77
+
78
+ @include contained-mask(var(--alert-icon));
75
79
  }
76
80
 
77
- .alert.warning .title::before {
78
- @include contained-mask(var(--icon-warning));
81
+ .alert.warning {
82
+ --alert-icon: var(--icon-warning);
83
+ --alert-icon-size: var(--icon-warning-size);
79
84
  }
80
85
 
81
86
  i.warning {
82
- @include contained-mask(var(--icon-warning));
87
+ @include contained-mask(var(--icon-warning-compact));
83
88
 
84
89
  display: inline-block;
85
- width: 1em;
86
- height: 1em;
90
+ width: var(--icon-warning-compact-size);
91
+ height: var(--icon-warning-compact-size);
87
92
  vertical-align: sub;
88
93
  }
89
94
 
90
- .alert.note .title::before {
91
- @include contained-mask(var(--icon-info));
95
+ .alert.note {
96
+ --alert-icon: var(--icon-info);
97
+ --alert-icon-size: var(--icon-info-size);
92
98
  }
93
99
 
94
100
  .alert .title {
95
- padding-left: 48px;
96
101
  margin: 0;
97
102
  font-weight: 700;
98
103
 
package/src/_layout.scss CHANGED
@@ -223,8 +223,8 @@ footer {
223
223
 
224
224
  &::after {
225
225
  display: inline-block;
226
- width: 1.5em;
227
- height: 1.5em;
226
+ width: var(--icon-tada-size);
227
+ height: var(--icon-tada-size);
228
228
  margin-left: 0.2em;
229
229
  vertical-align: middle;
230
230
  content: '';
@@ -2,45 +2,13 @@ function getElements(parent: HTMLElement): HTMLHeadingElement[] {
2
2
  return Array.from(parent.querySelectorAll('h1, h2, h3, h4, h5, h6'));
3
3
  }
4
4
 
5
- function createHeadingAnchorIcon(window: Window): HTMLSpanElement {
5
+ function createPresentIcon(window: Window): HTMLSpanElement {
6
6
  const icon = window.document.createElement('span');
7
- icon.className = 'material-symbol-icon material-symbol-icon-heading-anchor';
7
+ icon.className = 'material-symbol-icon material-symbol-icon-heading-present';
8
8
  icon.setAttribute('aria-hidden', 'true');
9
9
  return icon;
10
10
  }
11
11
 
12
- function createPresentIcon(window: Window): SVGSVGElement {
13
- const svg = window.document.createElementNS(
14
- 'http://www.w3.org/2000/svg',
15
- 'svg',
16
- );
17
- svg.setAttribute('aria-hidden', 'true');
18
- svg.setAttribute('viewBox', '0 0 24 24');
19
- svg.setAttribute('width', '16');
20
- svg.setAttribute('height', '16');
21
- svg.setAttribute('fill', 'none');
22
- svg.setAttribute('stroke', 'currentColor');
23
- svg.setAttribute('stroke-width', '1.5');
24
- svg.setAttribute('stroke-linecap', 'round');
25
- svg.setAttribute('stroke-linejoin', 'round');
26
-
27
- const paths = [
28
- 'M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z',
29
- 'M9 20h6',
30
- ];
31
-
32
- for (const d of paths) {
33
- const path = window.document.createElementNS(
34
- 'http://www.w3.org/2000/svg',
35
- 'path',
36
- );
37
- path.setAttribute('d', d);
38
- svg.appendChild(path);
39
- }
40
-
41
- return svg;
42
- }
43
-
44
12
  function getSlideIndex(slide: HTMLElement): number {
45
13
  const rawIndex = slide.getAttribute('data-slide-index');
46
14
  if (rawIndex != null) {
@@ -86,7 +54,6 @@ export default (window: Window) => {
86
54
  while (el.firstChild) {
87
55
  link.appendChild(el.firstChild);
88
56
  }
89
- link.appendChild(createHeadingAnchorIcon(window));
90
57
  el.appendChild(link);
91
58
 
92
59
  const handleClick = () => {
@@ -7,28 +7,12 @@ a.heading-anchor {
7
7
  @include subdued-link;
8
8
  }
9
9
 
10
- .heading-anchor .material-symbol-icon,
11
- .heading-present-button svg {
12
- display: inline-block;
13
- width: 1em;
14
- height: 1em;
15
- vertical-align: middle;
16
- }
17
-
18
- .heading-anchor .material-symbol-icon {
19
- margin-left: 4px;
20
- vertical-align: middle;
21
- opacity: 0;
22
- }
23
-
24
10
  .heading-present-button {
25
- display: inline-flex;
26
- align-items: center;
27
- justify-content: center;
28
- width: 1em;
29
- height: 1em;
11
+ display: inline-block;
12
+ width: var(--icon-heading-present-size);
13
+ height: var(--icon-heading-present-size);
30
14
  padding: 0;
31
- margin-left: 4px;
15
+ margin-left: 8px;
32
16
  font: inherit;
33
17
  line-height: 1;
34
18
  vertical-align: middle;
@@ -39,7 +23,6 @@ a.heading-anchor {
39
23
  opacity: 0;
40
24
  }
41
25
 
42
- a.heading-anchor:focus-visible .material-symbol-icon,
43
26
  .heading-present-button:focus {
44
27
  opacity: 1;
45
28
  }
@@ -65,12 +48,6 @@ body.is-presenting .heading-present-button {
65
48
  background: transparent;
66
49
  }
67
50
 
68
- h1:hover a.heading-anchor .material-symbol-icon,
69
- h2:hover a.heading-anchor .material-symbol-icon,
70
- h3:hover a.heading-anchor .material-symbol-icon,
71
- h4:hover a.heading-anchor .material-symbol-icon,
72
- h5:hover a.heading-anchor .material-symbol-icon,
73
- h6:hover a.heading-anchor .material-symbol-icon,
74
51
  h1:hover .heading-present-button,
75
52
  h2:hover .heading-present-button,
76
53
  h3:hover .heading-present-button,
@@ -88,7 +65,6 @@ body.is-presenting .heading-present-button {
88
65
  }
89
66
 
90
67
  @media print {
91
- .heading-anchor .material-symbol-icon,
92
68
  .heading-present-button {
93
69
  display: none;
94
70
  }
@@ -15,11 +15,14 @@ function getStorage(window: Window): Storage | null {
15
15
  function getStoredPreference<T extends string>(
16
16
  storage: Storage | null,
17
17
  key: string,
18
- storedValue: T,
18
+ storedValues: readonly T[],
19
19
  defaultValue: T,
20
20
  ): T {
21
21
  try {
22
- return storage?.getItem(key) === storedValue ? storedValue : defaultValue;
22
+ const storedValue = storage?.getItem(key);
23
+ return storedValues.includes(storedValue as T)
24
+ ? (storedValue as T)
25
+ : defaultValue;
23
26
  } catch {
24
27
  return defaultValue;
25
28
  }
@@ -29,41 +32,82 @@ function saveStoredPreference<T extends string>(
29
32
  storage: Storage | null,
30
33
  key: string,
31
34
  preference: T,
32
- storedValue: T,
35
+ defaultValue: T,
33
36
  ): void {
34
37
  try {
35
- if (preference === storedValue) {
36
- storage?.setItem(key, preference);
37
- } else {
38
+ if (preference === defaultValue) {
38
39
  storage?.removeItem(key);
40
+ } else {
41
+ storage?.setItem(key, preference);
39
42
  }
40
43
  } catch {
41
44
  // Storage can be unavailable in privacy-restricted contexts.
42
45
  }
43
46
  }
44
47
 
45
- export function getFontPreference(storage: Storage | null): FontPreference {
46
- return getStoredPreference(storage, FONT_STORAGE_KEY, 'serif', 'sans');
48
+ export function getFontPreference(
49
+ storage: Storage | null,
50
+ defaultValue: FontPreference = 'sans',
51
+ ): FontPreference {
52
+ return getStoredPreference(
53
+ storage,
54
+ FONT_STORAGE_KEY,
55
+ ['sans', 'serif'],
56
+ defaultValue,
57
+ );
47
58
  }
48
59
 
49
60
  export function getContrastPreference(
50
61
  storage: Storage | null,
62
+ defaultValue: ContrastPreference = 'standard',
51
63
  ): ContrastPreference {
52
- return getStoredPreference(storage, CONTRAST_STORAGE_KEY, 'high', 'standard');
64
+ return getStoredPreference(
65
+ storage,
66
+ CONTRAST_STORAGE_KEY,
67
+ ['standard', 'high'],
68
+ defaultValue,
69
+ );
53
70
  }
54
71
 
55
72
  export function saveFontPreference(
56
73
  storage: Storage | null,
57
74
  preference: FontPreference,
75
+ defaultValue: FontPreference = 'sans',
58
76
  ): void {
59
- saveStoredPreference(storage, FONT_STORAGE_KEY, preference, 'serif');
77
+ saveStoredPreference(storage, FONT_STORAGE_KEY, preference, defaultValue);
60
78
  }
61
79
 
62
80
  export function saveContrastPreference(
63
81
  storage: Storage | null,
64
82
  preference: ContrastPreference,
83
+ defaultValue: ContrastPreference = 'standard',
65
84
  ): void {
66
- saveStoredPreference(storage, CONTRAST_STORAGE_KEY, preference, 'high');
85
+ saveStoredPreference(storage, CONTRAST_STORAGE_KEY, preference, defaultValue);
86
+ }
87
+
88
+ function getDefaultFontPreference(document: Document): FontPreference {
89
+ return document.documentElement.dataset.defaultFontPreference === 'serif'
90
+ ? 'serif'
91
+ : 'sans';
92
+ }
93
+
94
+ function getDefaultContrastPreference(window: Window): ContrastPreference {
95
+ const configured =
96
+ window.document.documentElement.dataset.defaultContrastPreference === 'high'
97
+ ? 'high'
98
+ : 'standard';
99
+
100
+ if (configured === 'high' || typeof window.matchMedia !== 'function') {
101
+ return configured;
102
+ }
103
+
104
+ try {
105
+ return window.matchMedia('(prefers-contrast: more)').matches
106
+ ? 'high'
107
+ : configured;
108
+ } catch {
109
+ return configured;
110
+ }
67
111
  }
68
112
 
69
113
  export function applyFontPreference(
@@ -71,8 +115,10 @@ export function applyFontPreference(
71
115
  preference: FontPreference,
72
116
  ): void {
73
117
  if (preference === 'serif') {
74
- document.documentElement.dataset.fontPreference = preference;
75
- } else {
118
+ if (document.documentElement.dataset.fontPreference !== preference) {
119
+ document.documentElement.dataset.fontPreference = preference;
120
+ }
121
+ } else if (document.documentElement.dataset.fontPreference !== undefined) {
76
122
  delete document.documentElement.dataset.fontPreference;
77
123
  }
78
124
  }
@@ -82,8 +128,12 @@ export function applyContrastPreference(
82
128
  preference: ContrastPreference,
83
129
  ): void {
84
130
  if (preference === 'high') {
85
- document.documentElement.dataset.contrastPreference = preference;
86
- } else {
131
+ if (document.documentElement.dataset.contrastPreference !== preference) {
132
+ document.documentElement.dataset.contrastPreference = preference;
133
+ }
134
+ } else if (
135
+ document.documentElement.dataset.contrastPreference !== undefined
136
+ ) {
87
137
  delete document.documentElement.dataset.contrastPreference;
88
138
  }
89
139
  }
@@ -129,8 +179,13 @@ export default function mountAppearancePicker(window: Window): () => void {
129
179
  container.querySelectorAll<HTMLButtonElement>('button'),
130
180
  );
131
181
  const storage = getStorage(window);
132
- let fontPreference = getFontPreference(storage);
133
- let contrastPreference = getContrastPreference(storage);
182
+ const defaultFontPreference = getDefaultFontPreference(document);
183
+ const defaultContrastPreference = getDefaultContrastPreference(window);
184
+ let fontPreference = getFontPreference(storage, defaultFontPreference);
185
+ let contrastPreference = getContrastPreference(
186
+ storage,
187
+ defaultContrastPreference,
188
+ );
134
189
 
135
190
  applyFontPreference(document, fontPreference);
136
191
  applyContrastPreference(document, contrastPreference);
@@ -143,11 +198,15 @@ export default function mountAppearancePicker(window: Window): () => void {
143
198
 
144
199
  if (fontValue === 'sans' || fontValue === 'serif') {
145
200
  fontPreference = fontValue;
146
- saveFontPreference(storage, fontPreference);
201
+ saveFontPreference(storage, fontPreference, defaultFontPreference);
147
202
  applyFontPreference(document, fontPreference);
148
203
  } else if (contrastValue === 'standard' || contrastValue === 'high') {
149
204
  contrastPreference = contrastValue;
150
- saveContrastPreference(storage, contrastPreference);
205
+ saveContrastPreference(
206
+ storage,
207
+ contrastPreference,
208
+ defaultContrastPreference,
209
+ );
151
210
  applyContrastPreference(document, contrastPreference);
152
211
  } else {
153
212
  return;