stellar-drive 1.2.28 → 1.2.30
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 +146 -20
- package/dist/auth/deviceVerification.d.ts +39 -29
- package/dist/auth/deviceVerification.d.ts.map +1 -1
- package/dist/auth/deviceVerification.js +84 -63
- package/dist/auth/deviceVerification.js.map +1 -1
- package/dist/auth/resolveAuthState.d.ts.map +1 -1
- package/dist/auth/resolveAuthState.js +20 -0
- package/dist/auth/resolveAuthState.js.map +1 -1
- package/dist/auth/singleUser.d.ts.map +1 -1
- package/dist/auth/singleUser.js +32 -88
- package/dist/auth/singleUser.js.map +1 -1
- package/dist/bin/install-pwa.d.ts.map +1 -1
- package/dist/bin/install-pwa.js +3297 -966
- package/dist/bin/install-pwa.js.map +1 -1
- package/dist/demo.d.ts +7 -0
- package/dist/demo.d.ts.map +1 -1
- package/dist/demo.js +49 -1
- package/dist/demo.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +42 -0
- package/dist/engine.js.map +1 -1
- package/dist/entries/toast.d.ts +12 -0
- package/dist/entries/toast.d.ts.map +1 -0
- package/dist/entries/toast.js +11 -0
- package/dist/entries/toast.js.map +1 -0
- package/dist/kit/confirm.d.ts +1 -1
- package/dist/kit/confirm.d.ts.map +1 -1
- package/dist/kit/confirm.js +2 -2
- package/dist/kit/confirm.js.map +1 -1
- package/dist/kit/server.js +2 -2
- package/dist/kit/server.js.map +1 -1
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js +58 -1
- package/dist/realtime.js.map +1 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +1 -0
- package/dist/schema.js.map +1 -1
- package/dist/stores/toast.d.ts +40 -0
- package/dist/stores/toast.d.ts.map +1 -0
- package/dist/stores/toast.js +39 -0
- package/dist/stores/toast.js.map +1 -0
- package/package.json +17 -1
- package/src/components/GlobalToast.svelte +251 -0
- package/src/components/OfflineBanner.svelte +123 -0
- package/src/components/OfflineToast.svelte +168 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@fileoverview GlobalToast — toast notification queue renderer.
|
|
3
|
+
|
|
4
|
+
Self-contained: subscribes to the stellar-drive toast store internally and
|
|
5
|
+
renders toasts at the bottom of the viewport.
|
|
6
|
+
|
|
7
|
+
- Mount once in your root `+layout.svelte`. No props required.
|
|
8
|
+
- Automatically raises toasts above the demo banner in demo mode.
|
|
9
|
+
- Stacks up to three toasts with distinct bottom offsets.
|
|
10
|
+
- Toasts slide up on entry and fade out on dismiss (via Svelte out:fade).
|
|
11
|
+
- Four variants: info (blue), success (green), error (red), warning (purple).
|
|
12
|
+
- z-index 9100 — above DemoBanner (9000), below modals.
|
|
13
|
+
|
|
14
|
+
Example mount:
|
|
15
|
+
```svelte
|
|
16
|
+
import GlobalToast from 'stellar-drive/components/GlobalToast';
|
|
17
|
+
<GlobalToast />
|
|
18
|
+
```
|
|
19
|
+
-->
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import { fade } from 'svelte/transition';
|
|
22
|
+
import { toastStore, dismissToast } from 'stellar-drive/toast';
|
|
23
|
+
import { isDemoMode } from 'stellar-drive/demo';
|
|
24
|
+
import type { ToastVariant } from 'stellar-drive/toast';
|
|
25
|
+
|
|
26
|
+
// ==========================================================================
|
|
27
|
+
// COMPONENT STATE
|
|
28
|
+
// ==========================================================================
|
|
29
|
+
|
|
30
|
+
/** Whether demo mode is active — raises toasts above the demo banner. */
|
|
31
|
+
const inDemoMode = $derived(isDemoMode());
|
|
32
|
+
|
|
33
|
+
/** SVG icon paths for each variant. */
|
|
34
|
+
const ICONS: Record<ToastVariant, { paths: string[]; type: 'stroke' }> = {
|
|
35
|
+
info: {
|
|
36
|
+
type: 'stroke',
|
|
37
|
+
paths: [
|
|
38
|
+
'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z',
|
|
39
|
+
'M12 16L12 12',
|
|
40
|
+
'M12 8L12.01 8'
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
success: {
|
|
44
|
+
type: 'stroke',
|
|
45
|
+
paths: [
|
|
46
|
+
'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z',
|
|
47
|
+
'M9 12L11 14L15 10'
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
error: {
|
|
51
|
+
type: 'stroke',
|
|
52
|
+
paths: [
|
|
53
|
+
'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z',
|
|
54
|
+
'M15 9L9 15',
|
|
55
|
+
'M9 9L15 15'
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
warning: {
|
|
59
|
+
type: 'stroke',
|
|
60
|
+
paths: [
|
|
61
|
+
'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
|
|
62
|
+
'M12 9L12 13',
|
|
63
|
+
'M12 17L12.01 17'
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<!-- demo-mode class raises toasts above the demo banner -->
|
|
70
|
+
<div class="toast-stack" class:demo-mode={inDemoMode}>
|
|
71
|
+
{#each $toastStore as toast (toast.id)}
|
|
72
|
+
<div class="toast-item toast-{toast.variant}" out:fade={{ duration: 180 }}>
|
|
73
|
+
<div class="toast-content">
|
|
74
|
+
<svg class="toast-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
75
|
+
{#each ICONS[toast.variant].paths as d}
|
|
76
|
+
<path {d} />
|
|
77
|
+
{/each}
|
|
78
|
+
</svg>
|
|
79
|
+
<span class="toast-text">{toast.message}</span>
|
|
80
|
+
<button
|
|
81
|
+
class="toast-dismiss"
|
|
82
|
+
onclick={() => dismissToast(toast.id)}
|
|
83
|
+
aria-label="Dismiss"
|
|
84
|
+
>
|
|
85
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
86
|
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
87
|
+
</svg>
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
/* ==========================================================================
|
|
96
|
+
TOAST NOTIFICATIONS
|
|
97
|
+
========================================================================== */
|
|
98
|
+
|
|
99
|
+
.toast-item {
|
|
100
|
+
position: fixed;
|
|
101
|
+
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
|
102
|
+
left: 50%;
|
|
103
|
+
transform: translateX(-50%);
|
|
104
|
+
z-index: 9100; /* above DemoBanner (9000) */
|
|
105
|
+
max-width: 420px;
|
|
106
|
+
width: calc(100% - 32px);
|
|
107
|
+
animation: toastSlideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
108
|
+
pointer-events: auto;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* Stack multiple toasts */
|
|
112
|
+
.toast-item + .toast-item {
|
|
113
|
+
bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
|
|
114
|
+
}
|
|
115
|
+
.toast-item + .toast-item + .toast-item {
|
|
116
|
+
bottom: calc(8rem + env(safe-area-inset-bottom, 0px));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* Demo mode — raise above demo banner */
|
|
120
|
+
.toast-stack.demo-mode .toast-item {
|
|
121
|
+
bottom: calc(4rem + env(safe-area-inset-bottom, 0px));
|
|
122
|
+
}
|
|
123
|
+
.toast-stack.demo-mode .toast-item + .toast-item {
|
|
124
|
+
bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
|
|
125
|
+
}
|
|
126
|
+
.toast-stack.demo-mode .toast-item + .toast-item + .toast-item {
|
|
127
|
+
bottom: calc(11rem + env(safe-area-inset-bottom, 0px));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
@keyframes toastSlideUp {
|
|
131
|
+
from {
|
|
132
|
+
opacity: 0;
|
|
133
|
+
transform: translateX(-50%) translateY(20px) scale(0.94);
|
|
134
|
+
}
|
|
135
|
+
to {
|
|
136
|
+
opacity: 1;
|
|
137
|
+
transform: translateX(-50%) translateY(0) scale(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.toast-content {
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
gap: 10px;
|
|
145
|
+
padding: 12px 16px;
|
|
146
|
+
background: var(--color-glass, rgba(20, 18, 30, 0.85));
|
|
147
|
+
backdrop-filter: blur(24px);
|
|
148
|
+
-webkit-backdrop-filter: blur(24px);
|
|
149
|
+
border: 1px solid var(--color-glass-border, rgba(255, 255, 255, 0.08));
|
|
150
|
+
border-radius: 14px;
|
|
151
|
+
color: var(--color-text, #f0ede8);
|
|
152
|
+
font-size: 0.875rem;
|
|
153
|
+
line-height: 1.4;
|
|
154
|
+
box-shadow:
|
|
155
|
+
0 8px 32px rgba(0, 0, 0, 0.4),
|
|
156
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
|
157
|
+
transition:
|
|
158
|
+
border-color 0.3s,
|
|
159
|
+
box-shadow 0.3s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ── Info (blue) ── */
|
|
163
|
+
.toast-info .toast-content {
|
|
164
|
+
border-color: rgba(96, 165, 250, 0.3);
|
|
165
|
+
box-shadow:
|
|
166
|
+
0 8px 32px rgba(96, 165, 250, 0.12),
|
|
167
|
+
0 0 0 1px rgba(96, 165, 250, 0.06),
|
|
168
|
+
inset 0 1px 0 rgba(96, 165, 250, 0.08);
|
|
169
|
+
}
|
|
170
|
+
.toast-info .toast-icon { color: #60a5fa; }
|
|
171
|
+
|
|
172
|
+
/* ── Success (green) ── */
|
|
173
|
+
.toast-success .toast-content {
|
|
174
|
+
border-color: rgba(16, 185, 129, 0.3);
|
|
175
|
+
box-shadow:
|
|
176
|
+
0 8px 32px rgba(16, 185, 129, 0.12),
|
|
177
|
+
0 0 0 1px rgba(16, 185, 129, 0.06),
|
|
178
|
+
inset 0 1px 0 rgba(16, 185, 129, 0.08);
|
|
179
|
+
}
|
|
180
|
+
.toast-success .toast-icon { color: #34d399; }
|
|
181
|
+
|
|
182
|
+
/* ── Error (red) ── */
|
|
183
|
+
.toast-error .toast-content {
|
|
184
|
+
border-color: rgba(220, 50, 70, 0.3);
|
|
185
|
+
box-shadow:
|
|
186
|
+
0 8px 32px rgba(220, 50, 70, 0.12),
|
|
187
|
+
0 0 0 1px rgba(220, 50, 70, 0.06),
|
|
188
|
+
inset 0 1px 0 rgba(220, 50, 70, 0.08);
|
|
189
|
+
}
|
|
190
|
+
.toast-error .toast-icon { color: #e85d75; }
|
|
191
|
+
|
|
192
|
+
/* ── Warning (purple) ── */
|
|
193
|
+
.toast-warning .toast-content {
|
|
194
|
+
border-color: rgba(167, 139, 250, 0.3);
|
|
195
|
+
box-shadow:
|
|
196
|
+
0 8px 32px rgba(167, 139, 250, 0.12),
|
|
197
|
+
0 0 0 1px rgba(167, 139, 250, 0.06),
|
|
198
|
+
inset 0 1px 0 rgba(167, 139, 250, 0.08);
|
|
199
|
+
}
|
|
200
|
+
.toast-warning .toast-icon { color: #a78bfa; }
|
|
201
|
+
|
|
202
|
+
.toast-icon {
|
|
203
|
+
flex-shrink: 0;
|
|
204
|
+
opacity: 0.9;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.toast-text {
|
|
208
|
+
flex: 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.toast-dismiss {
|
|
212
|
+
flex-shrink: 0;
|
|
213
|
+
background: none;
|
|
214
|
+
border: none;
|
|
215
|
+
color: var(--color-text-muted, rgba(240, 237, 232, 0.5));
|
|
216
|
+
cursor: pointer;
|
|
217
|
+
padding: 4px;
|
|
218
|
+
border-radius: 6px;
|
|
219
|
+
transition:
|
|
220
|
+
color 0.15s,
|
|
221
|
+
background 0.15s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.toast-dismiss:hover {
|
|
225
|
+
color: var(--color-text, #f0ede8);
|
|
226
|
+
background: rgba(255, 255, 255, 0.06);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Push toasts above the mobile tab bar */
|
|
230
|
+
@media (max-width: 767px) {
|
|
231
|
+
.toast-item {
|
|
232
|
+
bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px));
|
|
233
|
+
}
|
|
234
|
+
.toast-item + .toast-item {
|
|
235
|
+
bottom: calc(9rem + env(safe-area-inset-bottom, 0px));
|
|
236
|
+
}
|
|
237
|
+
.toast-item + .toast-item + .toast-item {
|
|
238
|
+
bottom: calc(12.5rem + env(safe-area-inset-bottom, 0px));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.toast-stack.demo-mode .toast-item {
|
|
242
|
+
bottom: calc(7.5rem + env(safe-area-inset-bottom, 0px));
|
|
243
|
+
}
|
|
244
|
+
.toast-stack.demo-mode .toast-item + .toast-item {
|
|
245
|
+
bottom: calc(11rem + env(safe-area-inset-bottom, 0px));
|
|
246
|
+
}
|
|
247
|
+
.toast-stack.demo-mode .toast-item + .toast-item + .toast-item {
|
|
248
|
+
bottom: calc(14.5rem + env(safe-area-inset-bottom, 0px));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
</style>
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@fileoverview OfflineBanner — fixed-position notification bar for offline state.
|
|
3
|
+
|
|
4
|
+
Renders a persistent amber glass banner just below the top navigation when the
|
|
5
|
+
app has no network connectivity. Auto-dismisses reactively when connectivity
|
|
6
|
+
is restored — no explicit dismiss needed.
|
|
7
|
+
|
|
8
|
+
- Desktop: pill-shaped, centered, positioned below the 64px top nav
|
|
9
|
+
(accounts for safe-area-inset-top on notch/island devices).
|
|
10
|
+
- Mobile: full-width bar flush below the island-header, which ends at
|
|
11
|
+
calc(env(safe-area-inset-top, 47px) + 24px) from the viewport top.
|
|
12
|
+
- Only renders when `$isOnline` is `false`.
|
|
13
|
+
- No dismiss button — auto-hides on reconnect.
|
|
14
|
+
- Glass morphism styling with amber tint to distinguish from DemoBanner.
|
|
15
|
+
- z-index 9000 — matches DemoBanner tier.
|
|
16
|
+
- Never overlaps top nav on desktop or island-header on mobile.
|
|
17
|
+
-->
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { isOnline } from '../stores/network';
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#if !$isOnline}
|
|
23
|
+
<div class="offline-banner" role="status" aria-live="assertive">
|
|
24
|
+
<svg class="offline-banner-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
25
|
+
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
26
|
+
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
|
27
|
+
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
|
|
28
|
+
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
|
|
29
|
+
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
|
|
30
|
+
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
|
31
|
+
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
|
32
|
+
</svg>
|
|
33
|
+
<span class="offline-banner-text">You're offline — some features require a connection</span>
|
|
34
|
+
</div>
|
|
35
|
+
{/if}
|
|
36
|
+
|
|
37
|
+
<style>
|
|
38
|
+
/* ── Desktop: pill, centered, below top nav ──
|
|
39
|
+
Both apps have a 64px top nav plus env(safe-area-inset-top) padding.
|
|
40
|
+
0.75rem gap gives breathing room between nav bottom and banner top. */
|
|
41
|
+
.offline-banner {
|
|
42
|
+
position: fixed;
|
|
43
|
+
top: calc(64px + env(safe-area-inset-top, 0px) + 0.75rem);
|
|
44
|
+
left: 50%;
|
|
45
|
+
transform: translateX(-50%);
|
|
46
|
+
z-index: 9000;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 0.6rem;
|
|
50
|
+
padding: 0.5rem 1.1rem;
|
|
51
|
+
border-radius: 9999px;
|
|
52
|
+
background: rgba(180, 120, 0, 0.15);
|
|
53
|
+
backdrop-filter: blur(12px);
|
|
54
|
+
-webkit-backdrop-filter: blur(12px);
|
|
55
|
+
border: 1px solid rgba(255, 200, 80, 0.2);
|
|
56
|
+
color: #ffd97a;
|
|
57
|
+
font-size: 0.8125rem;
|
|
58
|
+
font-weight: 500;
|
|
59
|
+
letter-spacing: 0.01em;
|
|
60
|
+
white-space: nowrap;
|
|
61
|
+
max-width: calc(100vw - 2rem);
|
|
62
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
animation: offline-banner-slide-down 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.offline-banner-icon {
|
|
68
|
+
flex-shrink: 0;
|
|
69
|
+
opacity: 0.9;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.offline-banner-text {
|
|
73
|
+
opacity: 0.95;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ── Mobile: full-width bar flush below island-header ──
|
|
77
|
+
island-header bottom edge = safe-area-inset-top + 24px from viewport top.
|
|
78
|
+
Formula: -safe-area-inset-top (header top offset) + safe-area-inset-top*2 + 24px (header height)
|
|
79
|
+
= safe-area-inset-top + 24px.
|
|
80
|
+
Using env(safe-area-inset-top, 47px) matches the fallback the header itself uses
|
|
81
|
+
so the banner stays flush on devices that don't support env(). */
|
|
82
|
+
@media (max-width: 767px) {
|
|
83
|
+
.offline-banner {
|
|
84
|
+
top: calc(env(safe-area-inset-top, 47px) + 24px);
|
|
85
|
+
left: 0;
|
|
86
|
+
right: 0;
|
|
87
|
+
transform: none;
|
|
88
|
+
border-radius: 0;
|
|
89
|
+
border-left: none;
|
|
90
|
+
border-right: none;
|
|
91
|
+
border-top: none;
|
|
92
|
+
border-bottom: 1px solid rgba(255, 200, 80, 0.18);
|
|
93
|
+
padding: 0.45rem 1rem;
|
|
94
|
+
font-size: 0.78rem;
|
|
95
|
+
gap: 0.5rem;
|
|
96
|
+
max-width: none;
|
|
97
|
+
white-space: normal;
|
|
98
|
+
animation: offline-banner-drop 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@keyframes offline-banner-slide-down {
|
|
103
|
+
from {
|
|
104
|
+
opacity: 0;
|
|
105
|
+
transform: translateX(-50%) translateY(-0.75rem);
|
|
106
|
+
}
|
|
107
|
+
to {
|
|
108
|
+
opacity: 1;
|
|
109
|
+
transform: translateX(-50%) translateY(0);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@keyframes offline-banner-drop {
|
|
114
|
+
from {
|
|
115
|
+
opacity: 0;
|
|
116
|
+
transform: translateY(-100%);
|
|
117
|
+
}
|
|
118
|
+
to {
|
|
119
|
+
opacity: 1;
|
|
120
|
+
transform: translateY(0);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
</style>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@fileoverview OfflineToast — app-neutral chunk-load error recovery toast.
|
|
3
|
+
|
|
4
|
+
Mounts a single `unhandledrejection` listener and surfaces a friendly
|
|
5
|
+
"page not available offline" message whenever a dynamic import fails because
|
|
6
|
+
its JS chunk is not in the service-worker cache.
|
|
7
|
+
|
|
8
|
+
- Mount once in your root `+layout.svelte`. No props required.
|
|
9
|
+
- Auto-dismisses after 5 seconds. Dismiss button available immediately.
|
|
10
|
+
- Positioned top-center, above all navigation chrome (z-index 1500).
|
|
11
|
+
- Styled with the stellar-drive design tokens — works in any theme.
|
|
12
|
+
|
|
13
|
+
Example mount:
|
|
14
|
+
```svelte
|
|
15
|
+
import OfflineToast from 'stellar-drive/components/OfflineToast';
|
|
16
|
+
<OfflineToast />
|
|
17
|
+
```
|
|
18
|
+
-->
|
|
19
|
+
<script lang="ts">
|
|
20
|
+
// ==========================================================================
|
|
21
|
+
// COMPONENT STATE
|
|
22
|
+
// ==========================================================================
|
|
23
|
+
|
|
24
|
+
/** Whether the toast is currently visible. */
|
|
25
|
+
let visible = $state(false);
|
|
26
|
+
|
|
27
|
+
/** Text to display in the toast. */
|
|
28
|
+
let message = $state('');
|
|
29
|
+
|
|
30
|
+
/** Auto-dismiss timer reference. */
|
|
31
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
32
|
+
|
|
33
|
+
// ==========================================================================
|
|
34
|
+
// HELPERS
|
|
35
|
+
// ==========================================================================
|
|
36
|
+
|
|
37
|
+
function show(msg: string, durationMs = 5000) {
|
|
38
|
+
if (timer) clearTimeout(timer);
|
|
39
|
+
message = msg;
|
|
40
|
+
visible = true;
|
|
41
|
+
timer = setTimeout(dismiss, durationMs);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dismiss() {
|
|
45
|
+
visible = false;
|
|
46
|
+
if (timer) {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timer = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ==========================================================================
|
|
53
|
+
// CHUNK ERROR LISTENER
|
|
54
|
+
// ==========================================================================
|
|
55
|
+
|
|
56
|
+
$effect(() => {
|
|
57
|
+
function handleRejection(event: PromiseRejectionEvent) {
|
|
58
|
+
const error = event.reason;
|
|
59
|
+
const isChunkError =
|
|
60
|
+
error?.message?.includes('Failed to fetch dynamically imported module') ||
|
|
61
|
+
error?.message?.includes('error loading dynamically imported module') ||
|
|
62
|
+
error?.message?.includes('Importing a module script failed') ||
|
|
63
|
+
error?.name === 'ChunkLoadError' ||
|
|
64
|
+
(error?.message?.includes('Loading chunk') && error?.message?.includes('failed'));
|
|
65
|
+
|
|
66
|
+
if (isChunkError) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
show("This page isn't available offline. Please reconnect or go back.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
window.addEventListener('unhandledrejection', handleRejection);
|
|
73
|
+
return () => window.removeEventListener('unhandledrejection', handleRejection);
|
|
74
|
+
});
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
{#if visible}
|
|
78
|
+
<div class="offline-toast" role="alert" aria-live="polite">
|
|
79
|
+
<div class="offline-toast-icon" aria-hidden="true">
|
|
80
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
81
|
+
<circle cx="12" cy="12" r="10"/>
|
|
82
|
+
<line x1="12" y1="16" x2="12" y2="12"/>
|
|
83
|
+
<line x1="12" y1="8" x2="12.01" y2="8"/>
|
|
84
|
+
</svg>
|
|
85
|
+
</div>
|
|
86
|
+
<span class="offline-toast-message">{message}</span>
|
|
87
|
+
<button class="offline-toast-dismiss" onclick={dismiss} aria-label="Dismiss">
|
|
88
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
|
89
|
+
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
90
|
+
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
{/if}
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
.offline-toast {
|
|
98
|
+
position: fixed;
|
|
99
|
+
top: calc(env(safe-area-inset-top, 0px) + 1rem);
|
|
100
|
+
left: 50%;
|
|
101
|
+
transform: translateX(-50%);
|
|
102
|
+
z-index: 1500;
|
|
103
|
+
display: flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
gap: 0.625rem;
|
|
106
|
+
padding: 0.5rem 0.75rem 0.5rem 0.875rem;
|
|
107
|
+
background: rgba(0, 0, 0, 0.6);
|
|
108
|
+
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
109
|
+
border-radius: 9999px;
|
|
110
|
+
backdrop-filter: blur(12px);
|
|
111
|
+
-webkit-backdrop-filter: blur(12px);
|
|
112
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
|
113
|
+
color: #fff;
|
|
114
|
+
font-size: 0.8125rem;
|
|
115
|
+
font-weight: 500;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
max-width: calc(100vw - 2rem);
|
|
118
|
+
animation: offline-toast-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@keyframes offline-toast-in {
|
|
122
|
+
from {
|
|
123
|
+
opacity: 0;
|
|
124
|
+
transform: translateX(-50%) translateY(-0.75rem);
|
|
125
|
+
}
|
|
126
|
+
to {
|
|
127
|
+
opacity: 1;
|
|
128
|
+
transform: translateX(-50%) translateY(0);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.offline-toast-icon {
|
|
133
|
+
flex-shrink: 0;
|
|
134
|
+
color: rgba(255, 255, 255, 0.7);
|
|
135
|
+
display: flex;
|
|
136
|
+
align-items: center;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.offline-toast-message {
|
|
140
|
+
flex: 1;
|
|
141
|
+
opacity: 0.95;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.offline-toast-dismiss {
|
|
145
|
+
flex-shrink: 0;
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
width: 1.25rem;
|
|
150
|
+
height: 1.25rem;
|
|
151
|
+
padding: 0;
|
|
152
|
+
margin-left: 0.125rem;
|
|
153
|
+
background: rgba(255, 255, 255, 0.15);
|
|
154
|
+
border: none;
|
|
155
|
+
border-radius: 50%;
|
|
156
|
+
color: rgba(255, 255, 255, 0.8);
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
transition: background 0.15s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.offline-toast-dismiss:hover {
|
|
162
|
+
background: rgba(255, 255, 255, 0.28);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@media (prefers-reduced-motion: reduce) {
|
|
166
|
+
.offline-toast { animation: none; }
|
|
167
|
+
}
|
|
168
|
+
</style>
|