@tenphi/renderer 0.25.2 → 0.25.4

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.
@@ -10,6 +10,9 @@ export const MainContentRoot = defineComponent("MainContent", {
10
10
  styles: {
11
11
  "@property": TASTY_SPACING_PROPERTIES,
12
12
  padding: "0 0 5rem",
13
+ // A focus target for the skip link and page swaps, not a control. Clicking
14
+ // its text focuses it, so keyboard scrolling would otherwise show a ring.
15
+ outline: "none",
13
16
  // Layout spacing is a default; generated Markdown owns prose spacing.
14
17
  ContentSpacing: {
15
18
  $: ":where(Panel > Container) > * + *",
@@ -22,7 +22,7 @@ export const PaginationRoot = defineComponent("Pagination", {
22
22
  gap: "$gap",
23
23
  inlineSize: "100%",
24
24
  padding: "($gap * 2)",
25
- border: "#clear",
25
+ border: true,
26
26
  color: "#text-soft",
27
27
  fill: "#surface-2",
28
28
  shadow: "none",
@@ -158,7 +158,9 @@ export const SidebarRoot = defineComponent("Sidebar", {
158
158
  $: "ul > li + li",
159
159
  blockMargin: {
160
160
  "": null,
161
- "@own(top-level)": "0 start",
161
+ // States add no specificity, so leave sections to GroupSpacing rather
162
+ // than depending on the order of the two rules.
163
+ "@own(top-level & !section)": "0 start",
162
164
  },
163
165
  },
164
166
  GroupSpacing: {
@@ -252,6 +254,9 @@ export const SidebarRoot = defineComponent("Sidebar", {
252
254
  '@own((:hover | :focus-visible) & ![aria-current="page"])':
253
255
  "#surface-2-hover",
254
256
  },
257
+ // Lift the focus ring above the next item's fill.
258
+ position: { "": null, "@own(:focus-visible)": "relative" },
259
+ zIndex: { "": null, "@own(:focus-visible)": "1" },
255
260
  },
256
261
  SummaryMarker: {
257
262
  $: "cookbook-sidebar summary::marker, summary::-webkit-details-marker",
@@ -1,21 +1,29 @@
1
1
  // Kept dependency-free for the page shell's synchronous, pre-paint script.
2
+ // Reads the documented storage contract in appearance-preferences.ts.
2
3
  (() => {
3
4
  const root = globalThis.document.documentElement;
5
+ let appearance = {};
4
6
  try {
5
- const scheme = globalThis.localStorage.getItem("cookbook-theme");
6
- if (scheme === "light" || scheme === "dark") root.dataset.theme = scheme;
7
- else delete root.dataset.theme;
8
- } catch {
9
- // Without storage, the document's system appearance rules remain active.
10
- }
11
- try {
12
- const appearance = JSON.parse(
13
- globalThis.localStorage.getItem("cookbook-appearance") ?? "{}",
14
- );
15
- if (appearance?.contrast === "normal" || appearance?.contrast === "more")
16
- root.dataset.contrast = appearance.contrast;
17
- else delete root.dataset.contrast;
7
+ appearance =
8
+ JSON.parse(
9
+ globalThis.localStorage.getItem("cookbook-appearance") ?? "{}",
10
+ ) ?? {};
18
11
  } catch {
19
12
  // Malformed or unavailable storage must not prevent the first paint.
20
13
  }
14
+ let theme = appearance.theme;
15
+ if (theme === undefined) {
16
+ try {
17
+ // A scheme saved before the appearance object held it.
18
+ theme = globalThis.localStorage.getItem("cookbook-theme");
19
+ } catch {
20
+ // Without storage, the document's system appearance rules remain active.
21
+ }
22
+ }
23
+ if (theme === "light" || theme === "dark") root.dataset.theme = theme;
24
+ else delete root.dataset.theme;
25
+ const contrast = appearance.contrast;
26
+ if (contrast === "normal" || contrast === "more")
27
+ root.dataset.contrast = contrast;
28
+ else delete root.dataset.contrast;
21
29
  })();
@@ -7,71 +7,104 @@ function storage(values: Record<string, string> = {}) {
7
7
  setItem: (key: string, value: string) => {
8
8
  values[key] = value;
9
9
  },
10
+ removeItem: (key: string) => {
11
+ delete values[key];
12
+ },
10
13
  };
11
14
  }
12
15
 
13
16
  describe("appearance preferences", () => {
14
- it("restores existing independent scheme and contrast choices", () => {
17
+ it("restores independent theme and contrast choices from one object", () => {
15
18
  const saved = storage({
16
- "cookbook-theme": "dark",
17
- "cookbook-appearance": JSON.stringify({ contrast: "more" }),
19
+ "cookbook-appearance": JSON.stringify({
20
+ theme: "dark",
21
+ contrast: "more",
22
+ }),
18
23
  });
19
24
  expect(loadPreferences(saved)).toEqual({
20
- scheme: "dark",
25
+ theme: "dark",
21
26
  contrast: "more",
22
27
  });
23
- storePreferences({ scheme: "light", contrast: "more" }, saved);
28
+ storePreferences({ theme: "light", contrast: "more" }, saved);
24
29
  expect(loadPreferences(saved)).toEqual({
25
- scheme: "light",
30
+ theme: "light",
26
31
  contrast: "more",
27
32
  });
28
- storePreferences({ scheme: "light", contrast: "normal" }, saved);
29
- expect(loadPreferences(saved)).toEqual({
30
- scheme: "light",
33
+ storePreferences({ theme: "light", contrast: "normal" }, saved);
34
+ expect(JSON.parse(saved.getItem("cookbook-appearance")!)).toEqual({
35
+ theme: "light",
31
36
  contrast: "normal",
32
37
  });
33
38
  });
34
39
 
35
- it("persists Auto using Cookbook storage without losing other preferences", () => {
40
+ it("persists system choices without losing other fields", () => {
36
41
  const saved = storage({
37
42
  "cookbook-appearance": JSON.stringify({ contrast: "more", other: true }),
38
43
  });
39
- storePreferences({ scheme: "auto", contrast: "system" }, saved);
40
- expect(saved.getItem("cookbook-theme")).toBe("");
44
+ storePreferences({ theme: "system", contrast: "system" }, saved);
41
45
  expect(JSON.parse(saved.getItem("cookbook-appearance")!)).toEqual({
46
+ theme: "system",
42
47
  contrast: "system",
43
48
  other: true,
44
49
  });
45
50
  expect(loadPreferences(saved)).toEqual({
46
- scheme: "auto",
51
+ theme: "system",
47
52
  contrast: "system",
48
53
  });
49
54
  });
50
55
 
56
+ it("reads a scheme saved under the legacy key and removes it on save", () => {
57
+ const saved = storage({
58
+ "cookbook-theme": "dark",
59
+ "cookbook-appearance": JSON.stringify({ contrast: "more" }),
60
+ });
61
+ expect(loadPreferences(saved)).toEqual({
62
+ theme: "dark",
63
+ contrast: "more",
64
+ });
65
+ storePreferences(loadPreferences(saved), saved);
66
+ expect(saved.getItem("cookbook-theme")).toBeNull();
67
+ expect(JSON.parse(saved.getItem("cookbook-appearance")!)).toEqual({
68
+ theme: "dark",
69
+ contrast: "more",
70
+ });
71
+ });
72
+
73
+ it("prefers the object's theme over the legacy key", () => {
74
+ const saved = storage({
75
+ "cookbook-theme": "dark",
76
+ "cookbook-appearance": JSON.stringify({ theme: "system" }),
77
+ });
78
+ expect(loadPreferences(saved).theme).toBe("system");
79
+ });
80
+
51
81
  it.each(["{broken", "null", "[]", '"more"', '{"contrast":"invalid"}'])(
52
- "recovers from malformed contrast data: %s",
82
+ "recovers from malformed appearance data: %s",
53
83
  (value) => {
54
- const saved = storage({
55
- "cookbook-theme": "light",
56
- "cookbook-appearance": value,
57
- });
84
+ const saved = storage({ "cookbook-appearance": value });
58
85
  expect(loadPreferences(saved)).toEqual({
59
- scheme: "light",
86
+ theme: "system",
60
87
  contrast: "system",
61
88
  });
62
- storePreferences({ scheme: "dark", contrast: "more" }, saved);
89
+ storePreferences({ theme: "dark", contrast: "more" }, saved);
63
90
  expect(loadPreferences(saved)).toEqual({
64
- scheme: "dark",
91
+ theme: "dark",
65
92
  contrast: "more",
66
93
  });
67
94
  },
68
95
  );
69
96
 
70
97
  it("uses system defaults for unknown values or unavailable storage", () => {
71
- expect(loadPreferences(storage({ "cookbook-theme": "invalid" }))).toEqual({
72
- scheme: "auto",
73
- contrast: "system",
74
- });
98
+ expect(
99
+ loadPreferences(
100
+ storage({
101
+ "cookbook-appearance": JSON.stringify({
102
+ theme: "invalid",
103
+ contrast: "invalid",
104
+ }),
105
+ }),
106
+ ),
107
+ ).toEqual({ theme: "system", contrast: "system" });
75
108
  const unavailable = {
76
109
  getItem: () => {
77
110
  throw new Error("Storage unavailable");
@@ -79,13 +112,16 @@ describe("appearance preferences", () => {
79
112
  setItem: () => {
80
113
  throw new Error("Storage unavailable");
81
114
  },
115
+ removeItem: () => {
116
+ throw new Error("Storage unavailable");
117
+ },
82
118
  };
83
119
  expect(loadPreferences(unavailable)).toEqual({
84
- scheme: "auto",
120
+ theme: "system",
85
121
  contrast: "system",
86
122
  });
87
123
  expect(() =>
88
- storePreferences({ scheme: "dark", contrast: "more" }, unavailable),
124
+ storePreferences({ theme: "dark", contrast: "more" }, unavailable),
89
125
  ).not.toThrow();
90
126
  });
91
127
  });
@@ -1,19 +1,25 @@
1
- type Scheme = "light" | "dark" | "auto";
1
+ // `cookbook-appearance` is a documented contract that other apps on the same
2
+ // origin read and write (docs/theme-and-components.md, "Reader appearance").
3
+ // Keep its key and value format compatible; appearance-bootstrap.js reads it too.
4
+ export const APPEARANCE_KEY = "cookbook-appearance";
5
+ // The color scheme used to live in its own key. It is still read when the
6
+ // object has no `theme`, and removed on the next save.
7
+ export const LEGACY_THEME_KEY = "cookbook-theme";
8
+
9
+ type Theme = "light" | "dark" | "system";
2
10
  type Contrast = "normal" | "more" | "system";
3
- type Preferences = { scheme: Scheme; contrast: Contrast };
4
- type PreferenceStorage = Pick<Storage, "getItem" | "setItem">;
11
+ type Preferences = { theme: Theme; contrast: Contrast };
12
+ type PreferenceStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
5
13
 
6
- export const parseScheme = (value: unknown): Scheme =>
7
- value === "light" || value === "dark" ? value : "auto";
14
+ export const parseTheme = (value: unknown): Theme =>
15
+ value === "light" || value === "dark" ? value : "system";
8
16
 
9
17
  export const parseContrast = (value: unknown): Contrast =>
10
18
  value === "normal" || value === "more" ? value : "system";
11
19
 
12
20
  function storedAppearance(storage: PreferenceStorage): Record<string, unknown> {
13
21
  try {
14
- const value: unknown = JSON.parse(
15
- storage.getItem("cookbook-appearance") ?? "{}",
16
- );
22
+ const value: unknown = JSON.parse(storage.getItem(APPEARANCE_KEY) ?? "{}");
17
23
  return value && typeof value === "object" && !Array.isArray(value)
18
24
  ? (value as Record<string, unknown>)
19
25
  : {};
@@ -23,11 +29,14 @@ function storedAppearance(storage: PreferenceStorage): Record<string, unknown> {
23
29
  }
24
30
 
25
31
  export function loadPreferences(storage?: PreferenceStorage): Preferences {
26
- const preferences: Preferences = { scheme: "auto", contrast: "system" };
32
+ const preferences: Preferences = { theme: "system", contrast: "system" };
27
33
  try {
28
34
  storage ??= localStorage;
29
- preferences.scheme = parseScheme(storage.getItem("cookbook-theme"));
30
- preferences.contrast = parseContrast(storedAppearance(storage).contrast);
35
+ const stored = storedAppearance(storage);
36
+ preferences.theme = parseTheme(
37
+ "theme" in stored ? stored.theme : storage.getItem(LEGACY_THEME_KEY),
38
+ );
39
+ preferences.contrast = parseContrast(stored.contrast);
31
40
  } catch {
32
41
  // System defaults work even when browser storage is unavailable.
33
42
  }
@@ -41,16 +50,14 @@ export function storePreferences(
41
50
  try {
42
51
  storage ??= localStorage;
43
52
  storage.setItem(
44
- "cookbook-theme",
45
- value.scheme === "auto" ? "" : value.scheme,
46
- );
47
- storage.setItem(
48
- "cookbook-appearance",
53
+ APPEARANCE_KEY,
49
54
  JSON.stringify({
50
55
  ...storedAppearance(storage),
56
+ theme: value.theme,
51
57
  contrast: value.contrast,
52
58
  }),
53
59
  );
60
+ storage.removeItem(LEGACY_THEME_KEY);
54
61
  } catch {
55
62
  // The current page still updates when preferences cannot be persisted.
56
63
  }
@@ -11,7 +11,11 @@ const id = `appearance-${crypto.randomUUID()}`;
11
11
  const schemes = [
12
12
  { value: "light", label: Astro.locals.t("themeSelect.light"), icon: "sun" },
13
13
  { value: "dark", label: Astro.locals.t("themeSelect.dark"), icon: "moon" },
14
- { value: "auto", label: Astro.locals.t("themeSelect.auto"), icon: "laptop" },
14
+ {
15
+ value: "system",
16
+ label: Astro.locals.t("themeSelect.auto"),
17
+ icon: "laptop",
18
+ },
15
19
  ] as const;
16
20
  const contrasts = [
17
21
  { value: "normal", label: ui.normalContrast, icon: "normal" },
@@ -46,7 +50,7 @@ const contrasts = [
46
50
  name={`${id}-scheme`}
47
51
  value={value}
48
52
  data-appearance-scheme
49
- checked={value === "auto"}
53
+ checked={value === "system"}
50
54
  />
51
55
  <Icon name={icon} />
52
56
  <span>{label}</span>
@@ -83,9 +87,11 @@ const contrasts = [
83
87
 
84
88
  <script>
85
89
  import {
90
+ APPEARANCE_KEY,
91
+ LEGACY_THEME_KEY,
86
92
  loadPreferences,
87
93
  storePreferences,
88
- parseScheme,
94
+ parseTheme,
89
95
  parseContrast,
90
96
  } from "../components/appearance-preferences.js";
91
97
  import { initializePopoverMotion } from "../client/popover-motion.js";
@@ -95,11 +101,11 @@ const contrasts = [
95
101
 
96
102
  function applyPreferences(): void {
97
103
  document.documentElement.dataset.theme =
98
- current.scheme === "auto"
104
+ current.theme === "system"
99
105
  ? systemScheme.matches
100
106
  ? "light"
101
107
  : "dark"
102
- : current.scheme;
108
+ : current.theme;
103
109
  if (current.contrast === "system") {
104
110
  delete document.documentElement.dataset.contrast;
105
111
  } else {
@@ -108,7 +114,7 @@ const contrasts = [
108
114
  document
109
115
  .querySelectorAll<HTMLInputElement>("[data-appearance-scheme]")
110
116
  .forEach((input) => {
111
- input.checked = input.value === current.scheme;
117
+ input.checked = input.value === current.theme;
112
118
  });
113
119
  document
114
120
  .querySelectorAll<HTMLInputElement>("[data-appearance-contrast]")
@@ -118,12 +124,13 @@ const contrasts = [
118
124
  }
119
125
 
120
126
  systemScheme.addEventListener("change", () => {
121
- if (current.scheme === "auto") applyPreferences();
127
+ if (current.theme === "system") applyPreferences();
122
128
  });
123
129
  window.addEventListener("storage", (event) => {
124
130
  if (
125
131
  !event.key ||
126
- ["cookbook-theme", "cookbook-appearance"].includes(event.key)
132
+ event.key === APPEARANCE_KEY ||
133
+ event.key === LEGACY_THEME_KEY
127
134
  ) {
128
135
  current = loadPreferences();
129
136
  applyPreferences();
@@ -139,7 +146,7 @@ const contrasts = [
139
146
  const input = event.target;
140
147
  if (!(input instanceof HTMLInputElement)) return;
141
148
  if (input.hasAttribute("data-appearance-scheme")) {
142
- current.scheme = parseScheme(input.value);
149
+ current.theme = parseTheme(input.value);
143
150
  } else if (input.hasAttribute("data-appearance-contrast")) {
144
151
  current.contrast = parseContrast(input.value);
145
152
  } else return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/renderer",
3
- "version": "0.25.2",
3
+ "version": "0.25.4",
4
4
  "description": "Astro renderer and Tasty theme for Cookbook",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -61,7 +61,7 @@
61
61
  "@fontsource-variable/jetbrains-mono": "^5.3.0",
62
62
  "@fontsource-variable/onest": "^5.3.0",
63
63
  "@pagefind/default-ui": "1.5.2",
64
- "@tenphi/docs": "0.25.2",
64
+ "@tenphi/docs": "0.25.4",
65
65
  "@tenphi/eslint-plugin-tasty": "1.6.0",
66
66
  "@tenphi/glaze": "2.0.1",
67
67
  "@tenphi/tasty": "3.9.10",