@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 +59 -0
- package/README.md +56 -13
- package/assets/css/app-shell.css +7 -5
- package/assets/css/tokens.css +11 -2
- package/components/AlertMessages.vue +39 -13
- package/components/AppNavigation.vue +19 -3
- package/components/Loading.vue +6 -1
- package/components/OfflineBanner.vue +25 -3
- package/composables/EventBus.ts +5 -1
- package/composables/theme.ts +25 -0
- package/composables/useAppHeight.ts +22 -4
- package/composables/useOfflineStatus.ts +3 -1
- package/composables/useTheme.ts +14 -12
- package/nuxt.config.ts +6 -0
- package/package.json +1 -1
- package/services/AuthService.ts +4 -0
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? }
|
|
35
|
-
`
|
|
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.
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
143
|
-
`--
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
package/assets/css/app-shell.css
CHANGED
|
@@ -27,10 +27,12 @@
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
+
.dpb-blink {
|
|
36
|
+
animation: dpb-blink 1s ease-in-out infinite;
|
|
37
|
+
}
|
|
36
38
|
}
|
package/assets/css/tokens.css
CHANGED
|
@@ -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: #
|
|
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: #
|
|
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="
|
|
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
|
|
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(
|
|
60
|
+
timers.delete(entry.id);
|
|
38
61
|
dismiss(entry.id);
|
|
39
|
-
}, message.durationMs
|
|
40
|
-
timers.
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
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>
|
package/components/Loading.vue
CHANGED
|
@@ -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
|
|
3
|
-
|
|
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-
|
|
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>
|
package/composables/EventBus.ts
CHANGED
|
@@ -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?:
|
|
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",
|
|
12
|
-
window.visualViewport?.addEventListener("resize",
|
|
25
|
+
window.addEventListener("resize", scheduleAppHeight);
|
|
26
|
+
window.visualViewport?.addEventListener("resize", scheduleAppHeight);
|
|
13
27
|
});
|
|
14
28
|
|
|
15
29
|
onUnmounted(() => {
|
|
16
|
-
window.removeEventListener("resize",
|
|
17
|
-
window.visualViewport?.removeEventListener("resize",
|
|
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(
|
|
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;
|
package/composables/useTheme.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
package/services/AuthService.ts
CHANGED
|
@@ -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;
|