@timmo001/docs-kit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,7 +21,7 @@ The `blume` export needs the `blume` version in `peerDependencies`.
21
21
 
22
22
  `docsConfig` returns a full Blume config from the site details. Pass anything
23
23
  else Blume accepts alongside them; it overrides the default it replaces, and
24
- `navigation` and `seo` merge one level deep.
24
+ `markdown`, `navigation` and `seo` merge one level deep.
25
25
 
26
26
  ```ts
27
27
  import { docsConfig } from "@timmo001/docs-kit/blume";
@@ -46,6 +46,8 @@ The defaults:
46
46
  with the title as the logo text.
47
47
  - Content in `src/content/docs`, last-modified dates from git and external links
48
48
  opening in a new tab.
49
+ - GitHub's high-contrast code themes, since the default ones miss WCAG AA on
50
+ comments and keywords. Set `markdown.code.theme` to change them.
49
51
  - Cloudflare deployment to `https://<site>`.
50
52
  - `llms.txt`, the MCP endpoint at `/mcp`, WebMCP and agent readability on. Ask
51
53
  AI and page feedback off.
@@ -98,12 +100,35 @@ Glazing, Survey, Slate, Bridge or Snag:
98
100
  @import "@timmo001/docs-kit/blume/themes/bridge.css";
99
101
  ```
100
102
 
101
- It sets Blume's colours, radius and fonts from the theme in light and dark, so
102
- leave `theme.accent` out of `blume.config.ts`. The fonts are stacks, so load
103
- the theme's font files in `theme.css` too, for example with
103
+ It sets Blume's colors, radius and fonts from the theme in light, dark and
104
+ Darker, so leave `theme.accent` out of `blume.config.ts`. The fonts are stacks,
105
+ so load the theme's font files in `theme.css` too, for example with
104
106
  `@import "@fontsource-variable/inter";`. Use Base until the app has a theme of
105
107
  its own.
106
108
 
109
+ `HeaderSearch` adds a mode picker next to Blume's light and dark toggle, with
110
+ Light, Dark and Darker. To let readers pick the theme as well, import every
111
+ theme after the site's default and pass the themes to `HeaderSearch` from a
112
+ wrapper component:
113
+
114
+ ```css
115
+ @import "@timmo001/docs-kit/blume/themes/base.css";
116
+ @import "@timmo001/docs-kit/blume/themes/all.css";
117
+ ```
118
+
119
+ ```astro
120
+ ---
121
+ import HeaderSearch from "@timmo001/docs-kit/blume/header-search.astro";
122
+
123
+ const themes = [
124
+ { id: "base", name: "Base" },
125
+ { id: "bridge", name: "Bridge" },
126
+ ];
127
+ ---
128
+
129
+ <HeaderSearch {...Astro.props} themes={themes} defaultTheme="base" />
130
+ ```
131
+
107
132
  Set these in `theme.css` to change the banner:
108
133
 
109
134
  ```css
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timmo001/docs-kit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Shared branding, layout and config for timmo001 documentation sites",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  "./blume": "./src/blume/index.ts",
10
10
  "./blume/header-search.astro": "./src/blume/header-search.astro",
11
11
  "./blume/home-banner.astro": "./src/blume/home-banner.astro",
12
+ "./blume/theme-picker.astro": "./src/blume/theme-picker.astro",
12
13
  "./blume/theme.css": "./src/blume/theme.css",
13
14
  "./blume/themes/*.css": "./src/blume/themes/*.css",
14
15
  "./package.json": "./package.json"
@@ -38,6 +39,8 @@
38
39
  "format:check": "prettier --check ."
39
40
  },
40
41
  "dependencies": {
42
+ "@timmo001/groundwork-tokens": "0.3.0",
43
+ "@timmo001/groundwork-ui": "0.3.0",
41
44
  "sharp": "0.35.5"
42
45
  },
43
46
  "peerDependencies": {
@@ -17,12 +17,14 @@ export interface DocsConfigOptions extends Omit<
17
17
  /**
18
18
  * Build a Blume config with the shared defaults: light and dark logos from
19
19
  * `public/`, Cloudflare deployment, agent surfaces on, Ask AI and feedback off,
20
- * and generated share cards. Any option given here wins over the default it
21
- * replaces; `navigation` and `seo` merge one level deep.
20
+ * high-contrast code themes and generated share cards. Any option given here
21
+ * wins over the default it replaces; `markdown`, `navigation` and `seo` merge
22
+ * one level deep.
22
23
  */
23
24
  export const docsConfig = ({
24
25
  site,
25
26
  github,
27
+ markdown,
26
28
  navigation,
27
29
  seo,
28
30
  ...config
@@ -38,9 +40,6 @@ export const docsConfig = ({
38
40
  content: {
39
41
  root: "src/content/docs",
40
42
  },
41
- markdown: {
42
- externalLinks: true,
43
- },
44
43
  ai: {
45
44
  assistant: {
46
45
  enabled: false,
@@ -71,6 +70,20 @@ export const docsConfig = ({
71
70
  dir: "docs",
72
71
  ...github,
73
72
  },
73
+ // The high-contrast GitHub themes, since the default ones miss WCAG AA on
74
+ // comments and keywords
75
+ markdown: {
76
+ externalLinks: true,
77
+ ...markdown,
78
+ code: {
79
+ ...markdown?.code,
80
+ theme: {
81
+ dark: "github-dark-high-contrast",
82
+ light: "github-light-high-contrast",
83
+ ...markdown?.code?.theme,
84
+ },
85
+ },
86
+ },
74
87
  navigation: {
75
88
  repo: true,
76
89
  ...navigation,
@@ -2,13 +2,21 @@
2
2
  import Search from "blume/components/layout/Search.astro";
3
3
  import { SOCIAL_ICONS } from "blume/components/layout/footer-socials.ts";
4
4
  import data from "blume:data";
5
+ import ThemePicker from "./theme-picker.astro";
6
+
7
+ /**
8
+ * `themes` and `defaultTheme` go to the theme picker; everything else Blume
9
+ * passes goes to its search.
10
+ */
11
+ const { themes, defaultTheme, ...searchProps } = Astro.props;
5
12
 
6
13
  const repoUrl = data.config.repoUrl;
7
14
 
8
15
  const github = SOCIAL_ICONS.github;
9
16
  ---
10
17
 
11
- <Search {...Astro.props} />
18
+ <Search {...searchProps} />
19
+ <ThemePicker themes={themes} defaultTheme={defaultTheme} />
12
20
  {
13
21
  repoUrl && (
14
22
  <a
@@ -32,7 +32,7 @@ const description = page.description ?? data.config.description;
32
32
  <style>
33
33
  /* Set --home-banner-logo-size, --home-banner-height,
34
34
  --home-banner-background or --home-banner-background-dark in the site's
35
- theme.css to resize or recolour the banner. */
35
+ theme.css to resize or recolor the banner. */
36
36
  .home-banner {
37
37
  position: relative;
38
38
  overflow: hidden;
@@ -0,0 +1,226 @@
1
+ ---
2
+ /**
3
+ * Picks the color mode, Darker included, and optionally the Groundwork
4
+ * theme. Blume's own toggle still switches light and dark; this keeps in step
5
+ * with it. Theme choices need `@timmo001/docs-kit/blume/themes/all.css`.
6
+ * The controls are Groundwork's `gw-select` and `gw-segmented`, styled by the
7
+ * Groundwork theme tokens imported here.
8
+ */
9
+ import "@timmo001/groundwork-tokens/themes.css";
10
+
11
+ interface Props {
12
+ /** Themes to offer, in order. Leave out to offer the mode only. */
13
+ themes?: readonly { id: string; name: string }[];
14
+ /** The theme the site's own `theme.css` imports, shown when none is picked. */
15
+ defaultTheme?: string;
16
+ }
17
+
18
+ const { themes = [], defaultTheme = "base" } = Astro.props;
19
+
20
+ // MDI's sunny, waning crescent and night icons
21
+ const modes = [
22
+ {
23
+ value: "light",
24
+ label: "Light",
25
+ icon: "M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,2L14.39,5.42C13.65,5.15 12.84,5 12,5C11.16,5 10.35,5.15 9.61,5.42L12,2M3.34,7L7.5,6.65C6.9,7.16 6.36,7.78 5.94,8.5C5.5,9.24 5.25,10 5.11,10.79L3.34,7M3.36,17L5.12,13.23C5.26,14 5.53,14.78 5.95,15.5C6.37,16.24 6.91,16.86 7.5,17.37L3.36,17M20.65,7L18.88,10.79C18.74,10 18.47,9.23 18.05,8.5C17.63,7.78 17.1,7.15 16.5,6.64L20.65,7M20.64,17L16.5,17.36C17.09,16.85 17.62,16.22 18.04,15.5C18.46,14.77 18.73,14 18.87,13.21L20.64,17M12,22L9.59,18.56C10.33,18.83 11.14,19 12,19C12.82,19 13.63,18.83 14.37,18.56L12,22Z",
26
+ },
27
+ {
28
+ value: "dark",
29
+ label: "Dark",
30
+ icon: "M2 12A10 10 0 0 0 15 21.54A10 10 0 0 1 15 2.46A10 10 0 0 0 2 12Z",
31
+ },
32
+ {
33
+ value: "darker",
34
+ label: "Darker",
35
+ icon: "M17.75,4.09L15.22,6.03L16.13,9.09L13.5,7.28L10.87,9.09L11.78,6.03L9.25,4.09L12.44,4L13.5,1L14.56,4L17.75,4.09M21.25,11L19.61,12.25L20.2,14.23L18.5,13.06L16.8,14.23L17.39,12.25L15.75,11L17.81,10.95L18.5,9L19.19,10.95L21.25,11M18.97,15.95C19.8,15.87 20.69,17.05 20.16,17.8C19.84,18.25 19.5,18.67 19.08,19.07C15.17,23 8.84,23 4.94,19.07C1.03,15.17 1.03,8.83 4.94,4.93C5.34,4.53 5.76,4.17 6.21,3.85C6.96,3.32 8.14,4.21 8.06,5.04C7.79,7.9 8.75,10.87 10.95,13.06C13.14,15.26 16.1,16.22 18.97,15.95M17.33,17.97C14.5,17.81 11.7,16.64 9.53,14.5C7.36,12.31 6.2,9.5 6.04,6.68C3.23,9.82 3.34,14.64 6.35,17.66C9.37,20.67 14.19,20.78 17.33,17.97Z",
36
+ },
37
+ ];
38
+ ---
39
+
40
+ <script is:inline>
41
+ (() => {
42
+ const root = document.documentElement;
43
+
44
+ const read = (key) => {
45
+ try {
46
+ return localStorage.getItem(key);
47
+ } catch {
48
+ return null;
49
+ }
50
+ };
51
+
52
+ const apply = () => {
53
+ const theme = read("gw-docs-theme");
54
+
55
+ if (theme) root.dataset.gwDocsTheme = theme;
56
+ else delete root.dataset.gwDocsTheme;
57
+
58
+ if (read("gw-darker") === "1" && root.dataset.theme === "dark") {
59
+ root.setAttribute("data-gw-darker", "");
60
+ } else {
61
+ root.removeAttribute("data-gw-darker");
62
+ }
63
+ };
64
+
65
+ apply();
66
+ document.addEventListener("astro:after-swap", apply);
67
+ })();
68
+ </script>
69
+
70
+ <div
71
+ class="gw-theme-picker"
72
+ data-gw-theme-picker
73
+ data-default-theme={defaultTheme}
74
+ data-gw-theme={defaultTheme}
75
+ >
76
+ {
77
+ themes.length > 0 && (
78
+ <gw-select
79
+ data-gw-theme-select
80
+ label="Theme"
81
+ inline-label
82
+ search="never"
83
+ options={JSON.stringify(themes.map((theme) => ({ value: theme.id, label: theme.name })))}
84
+ />
85
+ )
86
+ }
87
+ <gw-segmented
88
+ data-gw-mode-select
89
+ label="Color mode"
90
+ labels="hide"
91
+ options={JSON.stringify(modes)}></gw-segmented>
92
+ </div>
93
+
94
+ <script>
95
+ import { GwSegmented } from "@timmo001/groundwork-ui/gw-segmented.ts";
96
+ import { GwSelect } from "@timmo001/groundwork-ui/gw-select.ts";
97
+
98
+ const root = document.documentElement;
99
+
100
+ const save = (key: string, value: string | null) => {
101
+ try {
102
+ if (value === null) localStorage.removeItem(key);
103
+ else localStorage.setItem(key, value);
104
+ } catch {
105
+ // Storage can be blocked; the choice then lasts for the page.
106
+ }
107
+ };
108
+
109
+ const currentMode = () =>
110
+ root.dataset.theme !== "dark"
111
+ ? "light"
112
+ : root.hasAttribute("data-gw-darker")
113
+ ? "darker"
114
+ : "dark";
115
+
116
+ const sync = () => {
117
+ for (const picker of document.querySelectorAll("div[data-gw-theme-picker]")) {
118
+ if (!(picker instanceof HTMLElement)) continue;
119
+
120
+ const theme = root.dataset.gwDocsTheme ?? picker.dataset.defaultTheme ?? "";
121
+ const mode = currentMode();
122
+
123
+ // The controls are Groundwork components, so the picker carries the
124
+ // page's theme and mode for them to follow
125
+ picker.dataset.gwTheme = theme;
126
+ picker.dataset.gwMode = mode;
127
+
128
+ for (const control of picker.querySelectorAll("gw-select, gw-segmented")) {
129
+ if (control instanceof GwSegmented) control.value = mode;
130
+ else if (control instanceof GwSelect) control.value = theme;
131
+ }
132
+ }
133
+ };
134
+
135
+ document.addEventListener("change", (event) => {
136
+ const target = event.target;
137
+
138
+ if (!(target instanceof GwSelect || target instanceof GwSegmented)) return;
139
+
140
+ // Theme and mode change the whole page, so they crossfade where the
141
+ // browser has View Transitions and motion isn't reduced.
142
+ const transition = (change: () => void) => {
143
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
144
+
145
+ if (document.startViewTransition === undefined || reduced) change();
146
+ else document.startViewTransition(change).ready.catch(() => undefined);
147
+ };
148
+
149
+ if (target.matches("[data-gw-mode-select]")) {
150
+ const dark = target.value !== "light";
151
+ const value = target.value;
152
+
153
+ transition(() => {
154
+ root.dataset.theme = dark ? "dark" : "light";
155
+ root.toggleAttribute("data-gw-darker", value === "darker");
156
+ });
157
+
158
+ save("blume-theme", dark ? "dark" : "light");
159
+ save("gw-darker", value === "darker" ? "1" : null);
160
+ }
161
+
162
+ if (target.matches("[data-gw-theme-select]")) {
163
+ const value = target.value;
164
+
165
+ transition(() => {
166
+ root.dataset.gwDocsTheme = value;
167
+ });
168
+
169
+ save("gw-docs-theme", value);
170
+ }
171
+ });
172
+
173
+ // Blume's toggle flips data-theme; going light ends Darker.
174
+ new MutationObserver(() => {
175
+ if (root.dataset.theme !== "dark" && root.hasAttribute("data-gw-darker")) {
176
+ root.removeAttribute("data-gw-darker");
177
+ save("gw-darker", null);
178
+ }
179
+
180
+ sync();
181
+ }).observe(root, {
182
+ attributes: true,
183
+ attributeFilter: ["data-theme", "data-gw-darker", "data-gw-docs-theme"],
184
+ });
185
+
186
+ sync();
187
+
188
+ document.addEventListener("astro:after-swap", sync);
189
+ </script>
190
+
191
+ <style>
192
+ /* Gaps leave room for the focus ring, which sits outside each control */
193
+ .gw-theme-picker {
194
+ display: inline-flex;
195
+ flex-shrink: 0;
196
+ align-items: center;
197
+ gap: 0.625rem;
198
+ margin-inline: 0.25rem;
199
+ }
200
+
201
+ /* Keep the room the controls take once they upgrade, so the header
202
+ doesn't move */
203
+ .gw-theme-picker:has(:not(:defined)) {
204
+ min-inline-size: 16.75rem;
205
+ min-block-size: 2.5rem;
206
+ }
207
+
208
+ /* The picker's mode toggle replaces Blume's own light and dark button */
209
+ :global([data-blume-header]:has(.gw-theme-picker) [data-blume-theme-toggle]) {
210
+ display: none;
211
+ }
212
+
213
+ /* The components draw on Groundwork's surface, so the header's own
214
+ background shows through instead */
215
+ .gw-theme-picker {
216
+ background: transparent;
217
+ color: inherit;
218
+ }
219
+
220
+ /* Phones keep the header to one row, so the select shows only its value */
221
+ @media (max-width: 40rem) {
222
+ .gw-theme-picker :global(.gw-select-inline-label) {
223
+ display: none;
224
+ }
225
+ }
226
+ </style>
@@ -12,7 +12,7 @@
12
12
  }
13
13
 
14
14
  /* Keep the "On this page" rail beside the content instead of at the far right
15
- edge: centre the content and rail, with equal space either side. The content
15
+ edge: center the content and rail, with equal space either side. The content
16
16
  column is the content width plus main's 2.5rem padding either side.
17
17
  home-banner.astro works out its full-width bleed from these columns. */
18
18
  @media (min-width: 80rem) {