@devopsplaybook.io/common-web 0.6.0 → 0.7.0-beta.14.f2476ab

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/CHANGELOG.md CHANGED
@@ -5,6 +5,65 @@ All notable changes to this project are documented here.
5
5
  This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-10-24
9
+
10
+ ### Added
11
+
12
+ - Pre-paint theme script: the layer injects a small inline `<head>` script that
13
+ applies `data-theme` from `localStorage` and `prefers-color-scheme` before
14
+ first paint, removing the flash of the light theme for users with a stored
15
+ dark preference. Applications with a strict CSP must allow this inline script
16
+ (nonce or `unsafe-inline`); without it the existing `prefers-color-scheme`
17
+ CSS path still applies.
18
+ - Per-status foreground tokens `--color-on-success`, `--color-on-error` and
19
+ `--color-on-warning` so status backgrounds and their text meet WCAG AA in
20
+ both themes, with an automated contrast test over the shipped tokens.
21
+ - `AppNavigation` accepts a `linkComponent` prop (default `"a"`) so Nuxt apps
22
+ can opt into SPA navigation with `resolveComponent("NuxtLink")` instead of
23
+ full page reloads.
24
+ - `OfflineBanner` accepts a `message` prop (default `"You are offline."`) and
25
+ keeps its `role="status"` live region mounted at all times, so the offline
26
+ announcement is not missed by screen readers; while online the region is
27
+ visually hidden.
28
+ - `AlertMessages` supports sticky alerts with `durationMs: 0`, deduplicates
29
+ identical `type` + `text` pairs, and caps the queue at five messages (oldest
30
+ dropped first).
31
+ - Respect `prefers-reduced-motion`: the loading spinner, `dpb-fade-in` and
32
+ `dpb-blink` animations are disabled under reduced motion.
33
+
34
+ ### Changed
35
+
36
+ - `useTheme().isDark` is now the effective theme: under the default `system`
37
+ preference it follows `prefers-color-scheme` (including live media changes),
38
+ and `toggleTheme()` toggles from the effective value instead of only an
39
+ explicit `dark` preference.
40
+ - Alert styles are prefixed: `.message*` selectors are renamed to
41
+ `.alert-message*` (`.alert-message`, `.alert-message-text`,
42
+ `.alert-message-{type}`) so they no longer collide with consumer styles.
43
+ This is the only consumer-facing class change.
44
+ - `useAppHeight()` coalesces resize updates with `requestAnimationFrame`, so
45
+ repeated resize events and multiple component instances write the CSS custom
46
+ property at most once per frame.
47
+ - `useOfflineStatus()` initialises `isOnline` from `navigator.onLine`, so a
48
+ page loaded offline no longer briefly renders as online.
49
+ - `AlertMessage.type` is narrowed to `"info" | "success" | "warning" | "error"`
50
+ instead of collapsing to `string`, and the mitt compatibility index
51
+ signatures are documented.
52
+ - `AuthService.removeToken()` documents that its optional argument is accepted
53
+ for API compatibility and ignored.
54
+
55
+ ### Fixed
56
+
57
+ - Dark-mode status colors failed WCAG AA against white text (warning 1.86:1,
58
+ success/info 3.30:1, error 4.23:1). Dark backgrounds now use `#171717` text
59
+ through the new `--color-on-*` tokens, and dark `--color-error` changed from
60
+ `#e53935` to `#ef5350` because the old value cannot reach 4.5:1 with either
61
+ white or `#171717` text. Light mode is unchanged and passes.
62
+ - The playground theme button showed "Switch to dark" on an already-dark page
63
+ under the `system` preference; `toggleTheme()` now toggles correctly.
64
+ - The offline banner no longer claims "showing cached data"; the layer provides
65
+ no cache and the default copy is now neutral.
66
+
8
67
  ## [0.6.0] - 2026-10-24
9
68
 
10
69
  ### Added
package/README.md CHANGED
@@ -25,23 +25,61 @@ required. The `playground/` directory demonstrates a consuming app.
25
25
 
26
26
  | Component | Purpose |
27
27
  | --- | --- |
28
- | `<Loading size="small" />` | Accessible loading indicator; `size` is `small` or `large`. |
28
+ | `<Loading size="small" />` | Accessible loading indicator; `size` is `small` or `large`. The animation is disabled under `prefers-reduced-motion: reduce`. |
29
29
  | `<AlertMessages />` | Accessible live region displaying messages emitted on `EventBus`. |
30
- | `<AppNavigation :links="links" />` | Navigation shell with a named `brand` slot and configurable links (`label`, `to`, optional `icon` and `active`). |
31
- | `<OfflineBanner />` | Announces offline status and accepts replacement content through its default slot. |
30
+ | `<AppNavigation :links="links" :link-component="NuxtLink" />` | Navigation shell with a named `brand` slot and configurable links (`label`, `to`, optional `icon` and `active`). |
31
+ | `<OfflineBanner message="You are offline." />` | Announces offline status and accepts replacement content through its default slot. |
32
32
 
33
33
  `AlertMessages` listens for `EventTypes.ALERT_MESSAGE` events. Its payload is
34
- `{ text, type?, durationMs? }`; the default display duration is five seconds.
35
- `handleError(error)` logs the error and emits a corresponding error alert.
34
+ `{ text, type?, durationMs? }` with `type` limited to `info`, `success`,
35
+ `warning` or `error`. The default display duration is five seconds;
36
+ `durationMs: 0` keeps the message until it is dismissed from the queue.
37
+ Identical `type` + `text` pairs are deduplicated and the queue keeps at most
38
+ five messages, dropping the oldest first. Alert styles use the prefixed
39
+ `.alert-message*` classes, so consumer stylesheets are not affected by the
40
+ layer's global styles. `handleError(error)` logs the error and emits a
41
+ corresponding error alert.
42
+
43
+ `AppNavigation` renders plain `<a href>` links by default, which keeps
44
+ non-Nuxt deep imports and raw-source usage working. Nuxt applications should
45
+ pass their link component to keep SPA state on navigation:
46
+
47
+ ```vue
48
+ <script setup lang="ts">
49
+ import { resolveComponent } from "vue";
50
+
51
+ const NuxtLink = resolveComponent("NuxtLink");
52
+ </script>
53
+
54
+ <template>
55
+ <AppNavigation :links="links" :link-component="NuxtLink" />
56
+ </template>
57
+ ```
58
+
59
+ `OfflineBanner` keeps its `role="status"` live region mounted at all times so
60
+ screen readers announce the transition; while online the region is visually
61
+ hidden. The default copy is the neutral `You are offline.`; replace it with
62
+ the `message` prop or the default slot.
36
63
 
37
64
  ## Composables and services
38
65
 
39
66
  - `useTheme()` persists `light`/`dark` preferences in `localStorage` and applies
40
- `data-theme` to the document root. Use `setTheme("system")` to return to the
41
- operating-system preference.
67
+ `data-theme` to the document root. `isDark` reflects the effective theme: the
68
+ default `system` preference follows `prefers-color-scheme`, including live
69
+ operating-system changes, and `toggleTheme()` toggles from that effective
70
+ value. Use `setTheme("system")` to return to the operating-system preference.
71
+ The layer also injects a small pre-paint `<head>` script that applies
72
+ `data-theme` from `localStorage` and `prefers-color-scheme` before first
73
+ paint, so a stored dark preference no longer flashes the light theme during
74
+ load. Applications with a strict CSP must allow this inline script (nonce or
75
+ `unsafe-inline`); without it, the `prefers-color-scheme` CSS path still
76
+ applies and the theme resolves after hydration.
42
77
  - `useAppHeight()` updates `--app-height` from `visualViewport` when available
43
- and cleans up resize listeners on unmount.
44
- - `useOfflineStatus()` returns a reactive `isOnline` ref.
78
+ and cleans up resize listeners on unmount. Resize updates are coalesced with
79
+ `requestAnimationFrame`, so repeated resize events and multiple component
80
+ instances write the custom property at most once per frame.
81
+ - `useOfflineStatus()` returns a reactive `isOnline` ref, initialised from
82
+ `navigator.onLine` so a page loaded offline renders offline immediately.
45
83
  - Services are browser-only and available through package deep imports, for
46
84
  example `@devopsplaybook.io/common-web/services/AuthService`. `AuthService`
47
85
  guards every `localStorage` access, so during SSR `getToken()` resolves
@@ -139,10 +177,15 @@ missing or malformed; `set()` normalises valid non-negative integer strings
139
177
  ## Tokens and app-shell styles
140
178
 
141
179
  The layer loads `assets/css/tokens.css` and `assets/css/app-shell.css`.
142
- Tokens include `--color-*`, `--space-*`, `--text-*`, `--radius-*`,
143
- `--shadow-*`, `--header-height`, and `--app-height`. Dark mode can be selected
144
- with `data-theme="dark"` or comes from `prefers-color-scheme` when no explicit
145
- light preference is set.
180
+ Tokens include `--color-*` (including the per-status foreground tokens
181
+ `--color-on-success`, `--color-on-error` and `--color-on-warning` used with
182
+ `--color-success`, `--color-error` and `--color-warning`),
183
+ `--space-*`, `--text-*`, `--radius-*`, `--shadow-*`, `--header-height`, and
184
+ `--app-height`. Dark mode can be selected with `data-theme="dark"` or comes
185
+ from `prefers-color-scheme` when no explicit light preference is set. Status
186
+ colors meet WCAG AA against their `--color-on-*` foreground tokens in both
187
+ themes. The `dpb-fade-in` and `dpb-blink` helper classes are disabled under
188
+ `prefers-reduced-motion: reduce`.
146
189
 
147
190
  Load app-specific CSS after the layer and redefine only the values you need:
148
191
 
@@ -27,10 +27,12 @@
27
27
  }
28
28
  }
29
29
 
30
- .dpb-fade-in {
31
- animation: dpb-fade 180ms ease-out;
32
- }
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ .dpb-fade-in {
32
+ animation: dpb-fade 180ms ease-out;
33
+ }
33
34
 
34
- .dpb-blink {
35
- animation: dpb-blink 1s ease-in-out infinite;
35
+ .dpb-blink {
36
+ animation: dpb-blink 1s ease-in-out infinite;
37
+ }
36
38
  }
@@ -13,6 +13,9 @@
13
13
  --color-success: #2e7d32;
14
14
  --color-error: #c62828;
15
15
  --color-warning: #8a5a00;
16
+ --color-on-success: #ffffff;
17
+ --color-on-error: #ffffff;
18
+ --color-on-warning: #ffffff;
16
19
 
17
20
  --space-xs: 0.25rem;
18
21
  --space-sm: 0.5rem;
@@ -53,8 +56,11 @@
53
56
  --color-surface-message-info: #1c3a1d;
54
57
  --color-border: #44445a;
55
58
  --color-success: #43a047;
56
- --color-error: #e53935;
59
+ --color-error: #ef5350;
57
60
  --color-warning: #f0b429;
61
+ --color-on-success: #171717;
62
+ --color-on-error: #171717;
63
+ --color-on-warning: #171717;
58
64
  --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
59
65
  --shadow-md: 0 4px 12px rgb(0 0 0 / 40%);
60
66
  --shadow-lg: 0 8px 24px rgb(0 0 0 / 50%);
@@ -72,8 +78,11 @@
72
78
  --color-surface-message-info: #1c3a1d;
73
79
  --color-border: #44445a;
74
80
  --color-success: #43a047;
75
- --color-error: #e53935;
81
+ --color-error: #ef5350;
76
82
  --color-warning: #f0b429;
83
+ --color-on-success: #171717;
84
+ --color-on-error: #171717;
85
+ --color-on-warning: #171717;
77
86
  --shadow-sm: 0 1px 2px rgb(0 0 0 / 30%);
78
87
  --shadow-md: 0 4px 12px rgb(0 0 0 / 40%);
79
88
  --shadow-lg: 0 8px 24px rgb(0 0 0 / 50%);
@@ -2,9 +2,13 @@
2
2
  <div role="status" aria-live="polite" class="alert-messages">
3
3
  <div v-for="message in messages" :key="message.id">
4
4
  <div
5
- :class="message.type ? `message message-${message.type}` : 'message'"
5
+ :class="
6
+ message.type
7
+ ? `alert-message alert-message-${message.type}`
8
+ : 'alert-message'
9
+ "
6
10
  >
7
- <span class="message-text">
11
+ <span class="alert-message-text">
8
12
  <b v-if="message.type">{{ message.type }}</b>
9
13
  {{ message.text }}
10
14
  </span>
@@ -21,35 +25,54 @@ interface DisplayMessage extends AlertMessage {
21
25
  id: number;
22
26
  }
23
27
 
28
+ const DEFAULT_DURATION_MS = 5000;
29
+ const MAX_MESSAGES = 5;
30
+
24
31
  const messages = ref<DisplayMessage[]>([]);
25
- const timers = new Set<ReturnType<typeof setTimeout>>();
32
+ const timers = new Map<number, ReturnType<typeof setTimeout>>();
26
33
  let nextId = 1;
27
34
 
28
35
  function dismiss(id: number): void {
36
+ const timer = timers.get(id);
37
+ if (timer) {
38
+ clearTimeout(timer);
39
+ timers.delete(id);
40
+ }
29
41
  const index = messages.value.findIndex((message) => message.id === id);
30
42
  if (index !== -1) messages.value.splice(index, 1);
31
43
  }
32
44
 
33
45
  function handleAlert(message: AlertMessage): void {
46
+ const duplicate = messages.value.some(
47
+ (existing) => existing.type === message.type && existing.text === message.text,
48
+ );
49
+ if (duplicate) return;
50
+
34
51
  const entry = { ...message, id: nextId++ };
35
52
  messages.value.push(entry);
53
+ while (messages.value.length > MAX_MESSAGES) {
54
+ const oldest = messages.value[0];
55
+ if (oldest) dismiss(oldest.id);
56
+ }
57
+
58
+ if (message.durationMs === 0) return;
36
59
  const timer = setTimeout(() => {
37
- timers.delete(timer);
60
+ timers.delete(entry.id);
38
61
  dismiss(entry.id);
39
- }, message.durationMs || 5000);
40
- timers.add(timer);
62
+ }, message.durationMs ?? DEFAULT_DURATION_MS);
63
+ timers.set(entry.id, timer);
41
64
  }
42
65
 
43
66
  onMounted(() => EventBus.on(EventTypes.ALERT_MESSAGE, handleAlert));
44
67
  onUnmounted(() => {
45
68
  EventBus.off(EventTypes.ALERT_MESSAGE, handleAlert);
46
- for (const timer of timers) clearTimeout(timer);
69
+ for (const timer of timers.values()) clearTimeout(timer);
47
70
  timers.clear();
48
71
  });
49
72
  </script>
50
73
 
51
74
  <style>
52
- .message {
75
+ .alert-message {
53
76
  display: flex;
54
77
  align-items: center;
55
78
  padding: var(--space-base, 1rem);
@@ -58,20 +81,23 @@ onUnmounted(() => {
58
81
  background-color: #546e7a;
59
82
  }
60
83
 
61
- .message-text {
84
+ .alert-message-text {
62
85
  flex: 1;
63
86
  }
64
87
 
65
- .message-info,
66
- .message-success {
88
+ .alert-message-info,
89
+ .alert-message-success {
67
90
  background-color: var(--color-success, #2e7d32);
91
+ color: var(--color-on-success, #fff);
68
92
  }
69
93
 
70
- .message-warning {
94
+ .alert-message-warning {
71
95
  background-color: var(--color-warning, #8a5a00);
96
+ color: var(--color-on-warning, #fff);
72
97
  }
73
98
 
74
- .message-error {
99
+ .alert-message-error {
75
100
  background-color: var(--color-error, #c62828);
101
+ color: var(--color-on-error, #fff);
76
102
  }
77
103
  </style>
@@ -5,10 +5,14 @@
5
5
  </div>
6
6
  <ul class="app-navigation-links">
7
7
  <li v-for="link in links" :key="link.to">
8
- <a :href="link.to" :aria-current="link.active ? 'page' : undefined">
8
+ <component
9
+ :is="linkComponent"
10
+ v-bind="linkProps(link)"
11
+ :aria-current="link.active ? 'page' : undefined"
12
+ >
9
13
  <span v-if="link.icon" :class="link.icon" aria-hidden="true" />
10
14
  {{ link.label }}
11
- </a>
15
+ </component>
12
16
  </li>
13
17
  </ul>
14
18
  <slot />
@@ -16,6 +20,8 @@
16
20
  </template>
17
21
 
18
22
  <script setup lang="ts">
23
+ import type { Component } from "vue";
24
+
19
25
  interface AppNavigationLink {
20
26
  label: string;
21
27
  to: string;
@@ -23,7 +29,17 @@ interface AppNavigationLink {
23
29
  active?: boolean;
24
30
  }
25
31
 
26
- defineProps<{ links: AppNavigationLink[] }>();
32
+ const props = withDefaults(
33
+ defineProps<{
34
+ links: AppNavigationLink[];
35
+ linkComponent?: string | Component;
36
+ }>(),
37
+ { linkComponent: "a" },
38
+ );
39
+
40
+ function linkProps(link: AppNavigationLink): { href: string } | { to: string } {
41
+ return props.linkComponent === "a" ? { href: link.to } : { to: link.to };
42
+ }
27
43
  </script>
28
44
 
29
45
  <style>
@@ -28,7 +28,6 @@ withDefaults(defineProps<{ size?: "small" | "large"; label?: string }>(), {
28
28
  var(--loading-gradient) 50% 50%,
29
29
  var(--loading-gradient) 100% 50%;
30
30
  background-size: 20% 100%;
31
- animation: dpb-loading 2s infinite linear;
32
31
  }
33
32
 
34
33
  .loading-indicator-small {
@@ -37,6 +36,12 @@ withDefaults(defineProps<{ size?: "small" | "large"; label?: string }>(), {
37
36
  margin-bottom: 0.5rem;
38
37
  }
39
38
 
39
+ @media (prefers-reduced-motion: no-preference) {
40
+ .loading-indicator {
41
+ animation: dpb-loading 2s infinite linear;
42
+ }
43
+ }
44
+
40
45
  @keyframes dpb-loading {
41
46
  0%,
42
47
  100% {
@@ -1,6 +1,12 @@
1
1
  <template>
2
- <div v-if="!isOnline" class="offline-banner" role="status" aria-live="polite">
3
- <slot>You are offline — showing cached data</slot>
2
+ <div
3
+ class="offline-banner"
4
+ :class="{ 'offline-banner-hidden': isOnline }"
5
+ role="status"
6
+ aria-live="polite"
7
+ aria-atomic="true"
8
+ >
9
+ <template v-if="!isOnline"><slot>{{ message }}</slot></template>
4
10
  </div>
5
11
  </template>
6
12
 
@@ -8,13 +14,29 @@
8
14
  import { useOfflineStatus } from "../composables/useOfflineStatus";
9
15
 
10
16
  const { isOnline } = useOfflineStatus();
17
+
18
+ withDefaults(defineProps<{ message?: string }>(), {
19
+ message: "You are offline.",
20
+ });
11
21
  </script>
12
22
 
13
23
  <style>
14
24
  .offline-banner {
15
25
  padding: var(--space-sm, 0.5rem) var(--space-base, 1rem);
16
- color: var(--color-text-inverse, #fff);
26
+ color: var(--color-on-warning, #fff);
17
27
  background: var(--color-warning, #8a5a00);
18
28
  text-align: center;
19
29
  }
30
+
31
+ .offline-banner-hidden {
32
+ position: absolute;
33
+ width: 1px;
34
+ height: 1px;
35
+ padding: 0;
36
+ margin: -1px;
37
+ overflow: hidden;
38
+ clip: rect(0, 0, 0, 0);
39
+ white-space: nowrap;
40
+ border-width: 0;
41
+ }
20
42
  </style>
@@ -1,11 +1,15 @@
1
1
  import mitt from "mitt";
2
2
 
3
+ export type AlertType = "info" | "success" | "warning" | "error";
4
+
3
5
  export interface AlertMessage {
4
- type?: "info" | "success" | "warning" | "error" | string;
6
+ type?: AlertType;
5
7
  text: string;
6
8
  durationMs?: number;
7
9
  }
8
10
 
11
+ // mitt's Emitter constraint requires string and symbol index signatures;
12
+ // the explicit ALERT_MESSAGE/AUTH_UPDATED keys keep their payload typing.
9
13
  interface AppEvents {
10
14
  [event: string]: unknown;
11
15
  [event: symbol]: unknown;
@@ -0,0 +1,25 @@
1
+ export const THEME_KEY = "UI_THEME";
2
+
3
+ // Runs synchronously in <head> before first paint; must stay dependency-free.
4
+ // An explicit stored preference always wins over the OS scheme; re-reading
5
+ // localStorage on media changes keeps setTheme() authoritative afterwards.
6
+ export const THEME_INIT_SCRIPT = `(function () {
7
+ var key = ${JSON.stringify(THEME_KEY)};
8
+ function apply() {
9
+ try {
10
+ var stored = localStorage.getItem(key);
11
+ var media = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
12
+ var dark = stored === "dark" || (stored !== "light" && !!(media && media.matches));
13
+ document.documentElement.setAttribute("data-theme", dark ? "dark" : "light");
14
+ } catch (error) {
15
+ /* localStorage unavailable (privacy mode) */
16
+ }
17
+ }
18
+ apply();
19
+ try {
20
+ var media = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)");
21
+ if (media && media.addEventListener) media.addEventListener("change", apply);
22
+ } catch (error) {
23
+ /* no prefers-color-scheme support */
24
+ }
25
+ })();`;
@@ -1,19 +1,37 @@
1
1
  import { onMounted, onUnmounted } from "vue";
2
2
 
3
+ let frame: number | null = null;
4
+
3
5
  export function useAppHeight(): void {
4
6
  function updateAppHeight(): void {
5
7
  const height = window.visualViewport?.height ?? window.innerHeight;
6
8
  document.documentElement.style.setProperty("--app-height", `${height}px`);
7
9
  }
8
10
 
11
+ function scheduleAppHeight(): void {
12
+ if (frame !== null) return;
13
+ if (typeof requestAnimationFrame === "function") {
14
+ frame = requestAnimationFrame(() => {
15
+ frame = null;
16
+ updateAppHeight();
17
+ });
18
+ } else {
19
+ updateAppHeight();
20
+ }
21
+ }
22
+
9
23
  onMounted(() => {
10
24
  updateAppHeight();
11
- window.addEventListener("resize", updateAppHeight);
12
- window.visualViewport?.addEventListener("resize", updateAppHeight);
25
+ window.addEventListener("resize", scheduleAppHeight);
26
+ window.visualViewport?.addEventListener("resize", scheduleAppHeight);
13
27
  });
14
28
 
15
29
  onUnmounted(() => {
16
- window.removeEventListener("resize", updateAppHeight);
17
- window.visualViewport?.removeEventListener("resize", updateAppHeight);
30
+ window.removeEventListener("resize", scheduleAppHeight);
31
+ window.visualViewport?.removeEventListener("resize", scheduleAppHeight);
32
+ if (frame !== null) {
33
+ cancelAnimationFrame(frame);
34
+ frame = null;
35
+ }
18
36
  });
19
37
  }
@@ -1,7 +1,9 @@
1
1
  import { onMounted, onUnmounted, ref } from "vue";
2
2
 
3
3
  export function useOfflineStatus() {
4
- const isOnline = ref(true);
4
+ const isOnline = ref(
5
+ typeof navigator === "undefined" ? true : navigator.onLine,
6
+ );
5
7
 
6
8
  function updateStatus(): void {
7
9
  isOnline.value = navigator.onLine;
@@ -1,25 +1,25 @@
1
1
  import { computed, onMounted, onUnmounted, ref } from "vue";
2
+ import { THEME_KEY } from "./theme";
2
3
 
3
4
  export type ThemePreference = "light" | "dark" | "system";
4
5
 
5
- const THEME_KEY = "UI_THEME";
6
-
7
6
  export function useTheme() {
8
7
  const preference = ref<ThemePreference>("system");
9
- const isDark = computed(() => preference.value === "dark");
8
+ const systemPrefersDark = ref(false);
9
+ const isDark = computed(
10
+ () =>
11
+ preference.value === "dark" ||
12
+ (preference.value === "system" && systemPrefersDark.value),
13
+ );
10
14
  let mediaQuery: MediaQueryList | undefined;
11
15
 
12
16
  function applyTheme(): void {
13
17
  if (typeof document === "undefined") return;
14
18
 
15
- const prefersDark = mediaQuery?.matches ?? false;
16
- const theme =
17
- preference.value === "system"
18
- ? prefersDark
19
- ? "dark"
20
- : "light"
21
- : preference.value;
22
- document.documentElement.setAttribute("data-theme", theme);
19
+ document.documentElement.setAttribute(
20
+ "data-theme",
21
+ isDark.value ? "dark" : "light",
22
+ );
23
23
  }
24
24
 
25
25
  function setTheme(theme: ThemePreference): void {
@@ -35,7 +35,8 @@ export function useTheme() {
35
35
  setTheme(isDark.value ? "light" : "dark");
36
36
  }
37
37
 
38
- function handleSystemThemeChange(): void {
38
+ function handleSystemThemeChange(event: MediaQueryListEvent): void {
39
+ systemPrefersDark.value = event.matches;
39
40
  if (preference.value === "system") applyTheme();
40
41
  }
41
42
 
@@ -44,6 +45,7 @@ export function useTheme() {
44
45
  preference.value =
45
46
  storedTheme === "light" || storedTheme === "dark" ? storedTheme : "system";
46
47
  mediaQuery = window.matchMedia?.("(prefers-color-scheme: dark)");
48
+ systemPrefersDark.value = mediaQuery?.matches ?? false;
47
49
  mediaQuery?.addEventListener?.("change", handleSystemThemeChange);
48
50
  applyTheme();
49
51
  });
package/nuxt.config.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { fileURLToPath } from "node:url";
2
+ import { THEME_INIT_SCRIPT } from "./composables/theme";
2
3
 
3
4
  export default defineNuxtConfig({
4
5
  css: [
5
6
  fileURLToPath(new URL("./assets/css/tokens.css", import.meta.url)),
6
7
  fileURLToPath(new URL("./assets/css/app-shell.css", import.meta.url)),
7
8
  ],
9
+ app: {
10
+ head: {
11
+ script: [{ key: "common-web-theme", innerHTML: THEME_INIT_SCRIPT }],
12
+ },
13
+ },
8
14
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0-beta.14.f2476ab",
4
4
  "description": "Shared Nuxt layer for DevOpsPlaybook web applications",
5
5
  "license": "ISC",
6
6
  "engines": {
@@ -55,6 +55,10 @@ export class AuthService {
55
55
  if (previous !== token) AuthService.emitAuthUpdated();
56
56
  }
57
57
 
58
+ /**
59
+ * Clears every stored token and emits `AUTH_UPDATED`. The optional argument
60
+ * is accepted for API compatibility with older callers and is ignored.
61
+ */
58
62
  static async removeToken(_token?: string): Promise<void> {
59
63
  const storage = AuthService.storage();
60
64
  if (!storage) return;