@joewinke/jatui 0.60.0 → 0.62.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.
@@ -32,6 +32,16 @@
32
32
  * focus) get the play/pause control only. The transport joins the
33
33
  * menu's roving order; a paused row stays visible (its play button is
34
34
  * the resume) and a stopped one hides.
35
+ * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
36
+ * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
37
+ * marks the track `downloadable: true`) also get a Download button
38
+ * next to the transport: the track url is fetched to a Blob (a plain
39
+ * cross-origin `<a download>` is ignored), saved under
40
+ * "Composer - Title.ext" so attribution travels with the file, and a
41
+ * CORS failure falls back to opening the url in a new tab. The busy
42
+ * spinner never blocks playback. Shared mode: a remote tab downloads
43
+ * the leader's published track url (`SharedAmbienceState.trackDownload`)
44
+ * — or hides the button when the leader published none.
35
45
  * - A playing playlist shows its current track's attribution (title,
36
46
  * composer, license) under the playlist item and its section row.
37
47
  * - With a menu or submenu open, the mouse wheel over that surface
@@ -233,8 +243,18 @@
233
243
  import { getMotionContext } from '../motion/level';
234
244
  import { isAmbienceLink, safeAmbienceHref } from '../ambience/groups';
235
245
  import { isPlaylistScape, type PlaylistState } from '../ambience/scapes/playlist';
246
+ import {
247
+ isTrackDownloadable,
248
+ trackDownloadFilename
249
+ } from '../ambience/scapes/playlist';
250
+ import IconButton from './IconButton.svelte';
251
+ import { safeSharedTrackDownload } from '../ambience/shared';
252
+ import type {
253
+ SharedAmbience,
254
+ SharedAmbienceState,
255
+ SharedTrackDownload
256
+ } from '../ambience/shared';
236
257
  import type { Ambience, AmbienceLink, Scape } from '../ambience/types';
237
- import type { SharedAmbience, SharedAmbienceState } from '../ambience/shared';
238
258
 
239
259
  let {
240
260
  engine,
@@ -252,6 +272,8 @@
252
272
  const menuId = $props.id();
253
273
  const hintId = `${menuId}-hint`;
254
274
  const subId = `${menuId}-sub`;
275
+ /** The download button's licence/source description line (jatui-0vxcu). */
276
+ const downloadHintId = `${menuId}-dl`;
255
277
 
256
278
  /** Long-press duration for the pointer path to the menu. */
257
279
  const LONG_PRESS_MS = 450;
@@ -309,6 +331,14 @@
309
331
  let sharedTitle = $state<string | null>(null);
310
332
  let sharedPosition = $state<number | null>(null);
311
333
  let sharedDuration = $state<number | null>(null);
334
+ /** The leader's published download info (jatui-0vxcu) — a remote tab's
335
+ * download button reads this; null hides the button (not published, or
336
+ * the licence forbids redistribution). */
337
+ let sharedDownload = $state<SharedTrackDownload | null>(null);
338
+ /** True while a download fetch is in flight — the button shows its busy
339
+ * spinner and refuses re-entry. The fetch runs beside playback; nothing
340
+ * here touches the engine. */
341
+ let downloadBusy = $state(false);
312
342
 
313
343
  // Wheel-volume accumulator over the open surfaces (jatui-xomny): carries
314
344
  // the pending partial notch between wheel events so trackpad pixel deltas
@@ -360,6 +390,46 @@
360
390
  );
361
391
  const npPercent = $derived(progressPercent(npPosition, npDuration));
362
392
 
393
+ // --- download button (jatui-0vxcu) ---------------------------------------
394
+ /** What the download button offers right now: the playing (or paused)
395
+ * playlist track's url + ready-made filename + licence, gated by the
396
+ * licence AND by `safeAmbienceHref` — the url has to be an absolute
397
+ * http(s) one, because it is fetched and (on CORS failure) opened in a
398
+ * new tab; a `javascript:`/`data:`/scheme-relative entry means no button.
399
+ * On a remote tab the leader's publication is re-validated the same way
400
+ * (the leader is another tab, not a trusted source) — null → no button.
401
+ * Generative scapes never have one. */
402
+ const currentDownload = $derived.by(() => {
403
+ if (!showNowPlaying) return null;
404
+ if (isRemote) return safeSharedTrackDownload(sharedDownload);
405
+ const t = currentIsPlaylist ? (playlist?.track ?? null) : null;
406
+ if (!t || !isTrackDownloadable(t)) return null;
407
+ const url = safeAmbienceHref(t.url);
408
+ if (!url) return null;
409
+ return {
410
+ url,
411
+ filename: trackDownloadFilename(t),
412
+ license: t.license,
413
+ sourceUrl: t.sourceUrl
414
+ } satisfies SharedTrackDownload;
415
+ });
416
+ /** The button's tooltip: licence + source link — CC-BY needs attribution. */
417
+ const downloadTooltip = $derived(
418
+ currentDownload
419
+ ? `Download ${currentDownload.filename} · ${currentDownload.license}${
420
+ currentDownload.sourceUrl ? ` · source: ${currentDownload.sourceUrl}` : ''
421
+ }`
422
+ : ''
423
+ );
424
+ /** The sr-only line the button's aria-describedby points at. */
425
+ const downloadHint = $derived(
426
+ currentDownload
427
+ ? `Download as ${currentDownload.filename}. Licence: ${currentDownload.license}.${
428
+ currentDownload.sourceUrl ? ` Source: ${currentDownload.sourceUrl}.` : ''
429
+ }`
430
+ : ''
431
+ );
432
+
363
433
  const BUTTON_SIZE = $derived(compact ? 'size-10' : 'size-11');
364
434
  const ICON_SIZE = $derived(compact ? 18 : 20);
365
435
 
@@ -399,6 +469,7 @@
399
469
  sharedTitle = s.trackTitle;
400
470
  sharedPosition = s.trackPosition;
401
471
  sharedDuration = s.trackDuration;
472
+ sharedDownload = s.trackDownload ?? null;
402
473
  });
403
474
  });
404
475
 
@@ -416,12 +487,25 @@
416
487
  return item.subscribe((s) => {
417
488
  playlist = s;
418
489
  track = s.status === 'playing' ? s.track : null;
490
+ // The download rides the same publication (jatui-0vxcu): only a track
491
+ // whose licence permits redistribution AND whose url survives
492
+ // safeAmbienceHref (absolute http/https) is offered to the other tabs.
493
+ const t = s.status === 'playing' ? s.track : null;
494
+ const dlUrl = t && isTrackDownloadable(t) ? safeAmbienceHref(t.url) : null;
419
495
  engine.setNowPlaying?.(
420
- s.status === 'playing' && s.track ? nowPlayingLine(s.track) : null,
496
+ s.status === 'playing' && t ? nowPlayingLine(t) : null,
421
497
  s.index,
422
498
  s.status === 'playing' ? s.position : null,
423
499
  s.status === 'playing' ? s.duration : null,
424
- s.track?.title ?? null
500
+ s.track?.title ?? null,
501
+ t && dlUrl
502
+ ? {
503
+ url: dlUrl,
504
+ filename: trackDownloadFilename(t),
505
+ license: t.license,
506
+ ...(t.sourceUrl ? { sourceUrl: t.sourceUrl } : {})
507
+ }
508
+ : null
425
509
  );
426
510
  });
427
511
  });
@@ -682,6 +766,47 @@
682
766
  if (isPlaylistScape(currentItem)) currentItem.skip();
683
767
  }
684
768
 
769
+ /**
770
+ * The download button (jatui-0vxcu). A cross-origin `<a download>` is
771
+ * ignored by browsers, so the file is fetched to a Blob and saved through
772
+ * an object URL — the attribution filename was built by the pure helper.
773
+ * A failed fetch (CORS) falls back to opening the url in a new tab. Both
774
+ * the fetch and the fallback go through `safeAmbienceHref` — a url that
775
+ * is not absolute http(s) (a hostile manifest, or a hostile shared-mode
776
+ * leader) never reaches `window.open`. The fetch runs beside the engine —
777
+ * playback is never blocked or touched.
778
+ */
779
+ async function onDownloadClick() {
780
+ const info = currentDownload;
781
+ const href = safeAmbienceHref(info?.url);
782
+ if (!info || !href || downloadBusy) return;
783
+ downloadBusy = true;
784
+ try {
785
+ const res = await fetch(href);
786
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
787
+ const blob = await res.blob();
788
+ const objectUrl = URL.createObjectURL(blob);
789
+ const a = document.createElement('a');
790
+ a.href = objectUrl;
791
+ a.download = info.filename;
792
+ document.body.appendChild(a);
793
+ a.click();
794
+ a.remove();
795
+ // The browser has the blob reference the moment it accepts the
796
+ // download; the revoke is belt-and-braces housekeeping.
797
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 10_000);
798
+ } catch {
799
+ // CORS refused or the network failed — the url still opens.
800
+ try {
801
+ window.open(href, '_blank', 'noopener,noreferrer');
802
+ } catch {
803
+ /* a blocked popup is not worth crashing over */
804
+ }
805
+ } finally {
806
+ downloadBusy = false;
807
+ }
808
+ }
809
+
685
810
  // --- wheel volume over the open surfaces (jatui-xomny) -------------------
686
811
  // While the menu (or one of its submenus) is open the wheel adjusts volume
687
812
  // instead of scrolling the page — EXCEPT over a scrollable list that can
@@ -1064,7 +1189,67 @@
1064
1189
  </svg>
1065
1190
  </button>
1066
1191
  {/if}
1192
+ {#if currentDownload}
1193
+ <!-- Download (jatui-0vxcu): only for tracks whose licence
1194
+ permits redistribution (or the leader's published url
1195
+ on a remote tab). aria-describedby points at the
1196
+ licence line below — the tooltip's own action also
1197
+ rewrites describedby while it shows, so the line stays
1198
+ in the row as readable content either way. -->
1199
+ <IconButton
1200
+ label="Download {npTitle}"
1201
+ variant="ghost"
1202
+ size="sm"
1203
+ shape="circle"
1204
+ tooltip={downloadTooltip}
1205
+ tooltipPlacement="top"
1206
+ aria-describedby={downloadHintId}
1207
+ tabindex={-1}
1208
+ data-ambience-nav
1209
+ disabled={downloadBusy}
1210
+ onclick={onDownloadClick}
1211
+ >
1212
+ {#if downloadBusy}
1213
+ <!-- busy: the fetch is in flight (playback unaffected) -->
1214
+ <svg
1215
+ xmlns="http://www.w3.org/2000/svg"
1216
+ viewBox="0 0 24 24"
1217
+ fill="none"
1218
+ stroke="currentColor"
1219
+ stroke-width="2"
1220
+ stroke-linecap="round"
1221
+ class="jatui-dl-spin size-4"
1222
+ aria-hidden="true"
1223
+ >
1224
+ <circle cx="12" cy="12" r="9" opacity="0.25" />
1225
+ <path d="M21 12a9 9 0 0 0-9-9" />
1226
+ </svg>
1227
+ {:else}
1228
+ <!-- download, lucide geometry -->
1229
+ <svg
1230
+ xmlns="http://www.w3.org/2000/svg"
1231
+ viewBox="0 0 24 24"
1232
+ fill="none"
1233
+ stroke="currentColor"
1234
+ stroke-width="2"
1235
+ stroke-linecap="round"
1236
+ stroke-linejoin="round"
1237
+ class="size-4"
1238
+ aria-hidden="true"
1239
+ >
1240
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
1241
+ <polyline points="7 10 12 15 17 10" />
1242
+ <line x1="12" y1="15" x2="12" y2="3" />
1243
+ </svg>
1244
+ {/if}
1245
+ </IconButton>
1246
+ {/if}
1067
1247
  </div>
1248
+ {#if currentDownload}
1249
+ <!-- The licence + source the download carries — CC-BY needs
1250
+ attribution, so the filename alone is not enough. -->
1251
+ <p id={downloadHintId} class="sr-only">{downloadHint}</p>
1252
+ {/if}
1068
1253
  </div>
1069
1254
  {/if}
1070
1255
 
@@ -1315,5 +1500,20 @@
1315
1500
  .now-progress-fill {
1316
1501
  transition: none;
1317
1502
  }
1503
+ .jatui-dl-spin {
1504
+ animation: none;
1505
+ }
1506
+ }
1507
+
1508
+ /* The download button's busy spinner — task feedback, not ambient motion,
1509
+ * but it still holds still under reduced motion. */
1510
+ .jatui-dl-spin {
1511
+ animation: jatui-dl-rotate 900ms linear infinite;
1512
+ }
1513
+
1514
+ @keyframes jatui-dl-rotate {
1515
+ to {
1516
+ transform: rotate(360deg);
1517
+ }
1318
1518
  }
1319
1519
  </style>
@@ -31,6 +31,16 @@
31
31
  * focus) get the play/pause control only. The transport joins the
32
32
  * menu's roving order; a paused row stays visible (its play button is
33
33
  * the resume) and a stopped one hides.
34
+ * - Playlists whose licence permits redistribution (jatui-0vxcu: CC0,
35
+ * public domain, CC-BY, CC-BY-SA — never NC/ND unless the manifest
36
+ * marks the track `downloadable: true`) also get a Download button
37
+ * next to the transport: the track url is fetched to a Blob (a plain
38
+ * cross-origin `<a download>` is ignored), saved under
39
+ * "Composer - Title.ext" so attribution travels with the file, and a
40
+ * CORS failure falls back to opening the url in a new tab. The busy
41
+ * spinner never blocks playback. Shared mode: a remote tab downloads
42
+ * the leader's published track url (`SharedAmbienceState.trackDownload`)
43
+ * — or hides the button when the leader published none.
34
44
  * - A playing playlist shows its current track's attribution (title,
35
45
  * composer, license) under the playlist item and its section row.
36
46
  * - With a menu or submenu open, the mouse wheel over that surface
@@ -141,8 +151,8 @@ export declare function wheelVolumeSteps(acc: {
141
151
  * slider itself would produce for the same value.
142
152
  */
143
153
  export declare function volumeAfterSteps(volume: number, steps: number, stepPct?: number): number;
144
- import type { Ambience } from '../ambience/types';
145
154
  import type { SharedAmbience } from '../ambience/shared';
155
+ import type { Ambience } from '../ambience/types';
146
156
  type $$ComponentProps = {
147
157
  /** The engine instance (from `createAmbience()`). The component never creates one, and never kills it — the host owns the engine's lifecycle. Section order/labels come from the engine's `groups` option. With the cross-tab shared player (jatui-lm1mf, `createAmbience({ shared: true })`) a plain `Ambience` is also accepted: the shared now-playing line is only read when the engine publishes one. */
148
158
  engine: Ambience | SharedAmbience;
@@ -0,0 +1,119 @@
1
+ <!--
2
+ StatusBanner: the overall status page headline (jatui-i3vxo). "All systems
3
+ operational" / degraded / outage, token colours, check / warning / x icon.
4
+ Token-only colours: the tint comes from the status tokens, never a hex.
5
+ -->
6
+ <script lang="ts" module>
7
+ export type BannerStatus = 'operational' | 'degraded' | 'outage';
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ import { tooltip } from '../../actions/tooltip';
12
+
13
+ interface Props {
14
+ /** 'operational' | 'degraded' | 'outage' (see aggregateStatus for a pure helper). */
15
+ status: BannerStatus;
16
+ /** Override the message. */
17
+ message?: string;
18
+ /** Extra detail behind an (i) info button. */
19
+ info?: string;
20
+ class?: string;
21
+ }
22
+
23
+ let { status, message, info, class: className = '' }: Props = $props();
24
+
25
+ const text = $derived(
26
+ message ?? (status === 'operational' ? 'All systems operational' : status === 'degraded' ? 'Some systems degraded' : 'Partial outage')
27
+ );
28
+ </script>
29
+
30
+ <div class="jatui-status-banner {className}" role="status" data-status={status}>
31
+ {#if status === 'operational'}
32
+ <svg viewBox="0 0 16 16" aria-hidden="true">
33
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
34
+ <path d="M5.2 8.2 7.1 10l3.7-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
35
+ </svg>
36
+ {:else if status === 'outage'}
37
+ <svg viewBox="0 0 16 16" aria-hidden="true">
38
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
39
+ <path d="M5.8 5.8l4.4 4.4M10.2 5.8 5.8 10.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
40
+ </svg>
41
+ {:else}
42
+ <svg viewBox="0 0 16 16" aria-hidden="true">
43
+ <path d="M8 2 14.5 13h-13Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
44
+ <path d="M8 6.4v3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
45
+ <circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
46
+ </svg>
47
+ {/if}
48
+ <span class="sb-text">{text}</span>
49
+ {#if info}
50
+ <button
51
+ type="button"
52
+ class="sb-info"
53
+ aria-label="More about this status"
54
+ use:tooltip={{ text: info, placement: 'top', delayMs: 0 }}
55
+ >
56
+ <svg viewBox="0 0 16 16" aria-hidden="true">
57
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
58
+ <path d="M8 7.2v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
59
+ <circle cx="8" cy="4.8" r="0.9" fill="currentColor" />
60
+ </svg>
61
+ </button>
62
+ {/if}
63
+ </div>
64
+
65
+ <style>
66
+ .jatui-status-banner {
67
+ --_st: var(--jatui-status-positive, var(--color-success));
68
+ display: flex;
69
+ align-items: center;
70
+ gap: var(--jatui-space-base, 8px);
71
+ padding: var(--jatui-space-base, 8px) var(--jatui-space-lg, 16px);
72
+ border-radius: var(--jatui-radius-box, 0.5rem);
73
+ background: color-mix(in oklch, var(--_st) 12%, var(--color-base-100));
74
+ border: 1px solid color-mix(in oklch, var(--_st) 30%, transparent);
75
+ color: var(--_st);
76
+ font-size: 0.9375rem;
77
+ font-weight: 600;
78
+ }
79
+ .jatui-status-banner[data-status='degraded'] {
80
+ --_st: var(--jatui-status-caution, var(--color-warning));
81
+ }
82
+ .jatui-status-banner[data-status='outage'] {
83
+ --_st: var(--jatui-status-negative, var(--color-error));
84
+ }
85
+ .jatui-status-banner svg {
86
+ width: 16px;
87
+ height: 16px;
88
+ flex: none;
89
+ }
90
+ .sb-text {
91
+ flex: 1;
92
+ min-width: 0;
93
+ }
94
+ .sb-info {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ width: 24px;
99
+ height: 24px;
100
+ padding: 0;
101
+ border: 0;
102
+ background: transparent;
103
+ border-radius: var(--jatui-radius-field, 0.25rem);
104
+ color: inherit;
105
+ opacity: 0.75;
106
+ cursor: help;
107
+ }
108
+ .sb-info svg {
109
+ width: 14px;
110
+ height: 14px;
111
+ }
112
+ .sb-info:hover {
113
+ opacity: 1;
114
+ }
115
+ .sb-info:focus-visible {
116
+ outline: 2px solid var(--color-primary);
117
+ outline-offset: 1px;
118
+ }
119
+ </style>
@@ -0,0 +1,13 @@
1
+ export type BannerStatus = 'operational' | 'degraded' | 'outage';
2
+ interface Props {
3
+ /** 'operational' | 'degraded' | 'outage' (see aggregateStatus for a pure helper). */
4
+ status: BannerStatus;
5
+ /** Override the message. */
6
+ message?: string;
7
+ /** Extra detail behind an (i) info button. */
8
+ info?: string;
9
+ class?: string;
10
+ }
11
+ declare const StatusBanner: import("svelte").Component<Props, {}, "">;
12
+ type StatusBanner = ReturnType<typeof StatusBanner>;
13
+ export default StatusBanner;
@@ -0,0 +1,176 @@
1
+ <!--
2
+ StatusCard: one measured component on a status page (jatui-i3vxo). Name +
3
+ info tooltip (what is measured), the status icon at the right, the
4
+ UptimeBar strip, and the status word (Normal / Degraded / Outage / …).
5
+ Data in: `{ date, status, downMinutes?, note? }[]`. Composes with
6
+ StatusBanner on top of a status page grid.
7
+ -->
8
+ <script lang="ts">
9
+ import { tooltip } from '../../actions/tooltip';
10
+ import { buildUptime, formatUptimePct, uptimeWord, type UptimeDayInput } from './uptime';
11
+ import UptimeBar from './UptimeBar.svelte';
12
+
13
+ interface Props {
14
+ /** Name of the measured thing (e.g. "API"). */
15
+ name: string;
16
+ /** What is measured, shown behind an (i) info button. */
17
+ description?: string;
18
+ data: readonly UptimeDayInput[];
19
+ /** Window length in days. Default 90. */
20
+ days?: number;
21
+ end?: string | Date;
22
+ /** Override the status word. */
23
+ statusWord?: string;
24
+ locale?: string;
25
+ class?: string;
26
+ }
27
+
28
+ let {
29
+ name,
30
+ description,
31
+ data,
32
+ days = 90,
33
+ end,
34
+ statusWord,
35
+ locale,
36
+ class: className = ''
37
+ }: Props = $props();
38
+
39
+ const layout = $derived(buildUptime({ data, days, end }));
40
+ const word = $derived(statusWord ?? uptimeWord(layout.worst));
41
+
42
+ const totalDown = $derived(layout.bars.reduce((s, b) => s + b.downMinutes, 0));
43
+ // Truncating formatter — never overclaims (see formatUptimePct).
44
+ const uptimeText = $derived(
45
+ layout.uptimePct == null
46
+ ? 'No data'
47
+ : `${formatUptimePct(layout.uptimePct, locale, totalDown)} uptime`
48
+ );
49
+
50
+ /** "operational" | "degraded" | "partial" | "outage" | "no-data" — for the tone classes. */
51
+ const heading = $derived(layout.worst);
52
+ </script>
53
+
54
+ <section class="jatui-status-card st-{heading} {className}" aria-label={name}>
55
+ <header class="sc-head">
56
+ <h3 class="sc-name">
57
+ <span>{name}</span>
58
+ {#if description}
59
+ <button
60
+ type="button"
61
+ class="sc-info"
62
+ aria-label={`About ${name}`}
63
+ use:tooltip={{ text: description, placement: 'top', delayMs: 0 }}
64
+ >
65
+ <svg viewBox="0 0 16 16" aria-hidden="true">
66
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
67
+ <path d="M8 7.2v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
68
+ <circle cx="8" cy="4.8" r="0.9" fill="currentColor" />
69
+ </svg>
70
+ </button>
71
+ {/if}
72
+ </h3>
73
+ <span class="sc-status">
74
+ {#if heading === 'operational'}
75
+ <svg viewBox="0 0 16 16" aria-hidden="true">
76
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
77
+ <path d="M5.2 8.2 7.1 10l3.7-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
78
+ </svg>
79
+ {:else if heading === 'outage'}
80
+ <svg viewBox="0 0 16 16" aria-hidden="true">
81
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
82
+ <path d="M5.8 5.8l4.4 4.4M10.2 5.8 5.8 10.2" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
83
+ </svg>
84
+ {:else if heading === 'no-data'}
85
+ <svg viewBox="0 0 16 16" aria-hidden="true">
86
+ <circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
87
+ <path d="M5.6 8h4.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
88
+ </svg>
89
+ {:else}
90
+ <svg viewBox="0 0 16 16" aria-hidden="true">
91
+ <path d="M8 2 14.5 13h-13Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
92
+ <path d="M8 6.4v3.2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
93
+ <circle cx="8" cy="11.3" r="0.9" fill="currentColor" />
94
+ </svg>
95
+ {/if}
96
+ <span class="sc-word">{word}</span>
97
+ </span>
98
+ </header>
99
+
100
+ <UptimeBar {data} {days} {end} {locale} label={name} />
101
+
102
+ <!-- Screen-reader text per bar group: the strip's aria-label already
103
+ carries the summary; this paragraph is the visible fallback for the
104
+ heading structure. -->
105
+ <p class="sr-only">{name}: {word}, {uptimeText}</p>
106
+ </section>
107
+
108
+ <style>
109
+ .jatui-status-card {
110
+ --_st: var(--jatui-status-positive, var(--color-success));
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: var(--jatui-space-base, 8px);
114
+ padding: var(--jatui-space-lg, 16px);
115
+ border: 1px solid var(--jatui-separator, color-mix(in oklch, var(--color-base-content) 12%, transparent));
116
+ border-radius: var(--jatui-radius-box, 0.5rem);
117
+ background: var(--color-base-100);
118
+ }
119
+ .st-operational { --_st: var(--jatui-status-positive, var(--color-success)); }
120
+ .st-degraded { --_st: var(--jatui-status-caution, var(--color-warning)); }
121
+ .st-partial { --_st: color-mix(in oklch, var(--color-warning) 55%, var(--color-error)); }
122
+ .st-outage { --_st: var(--jatui-status-negative, var(--color-error)); }
123
+ .st-no-data { --_st: var(--jatui-status-neutral, color-mix(in oklab, var(--color-base-content) 40%, transparent)); }
124
+
125
+ .sc-head {
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: space-between;
129
+ gap: var(--jatui-space-base, 8px);
130
+ }
131
+ .sc-name {
132
+ display: flex;
133
+ align-items: center;
134
+ gap: var(--jatui-space-sm, 4px);
135
+ margin: 0;
136
+ font-size: 0.9375rem;
137
+ font-weight: 600;
138
+ color: var(--color-base-content);
139
+ }
140
+ .sc-info {
141
+ display: inline-flex;
142
+ align-items: center;
143
+ justify-content: center;
144
+ width: 24px;
145
+ height: 24px;
146
+ padding: 0;
147
+ border: 0;
148
+ background: transparent;
149
+ border-radius: var(--jatui-radius-field, 0.25rem);
150
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
151
+ cursor: help;
152
+ }
153
+ .sc-info svg {
154
+ width: 14px;
155
+ height: 14px;
156
+ }
157
+ .sc-info:hover {
158
+ color: var(--color-base-content);
159
+ }
160
+ .sc-info:focus-visible {
161
+ outline: 2px solid var(--color-primary);
162
+ outline-offset: 1px;
163
+ }
164
+ .sc-status {
165
+ display: inline-flex;
166
+ align-items: center;
167
+ gap: var(--jatui-space-sm, 4px);
168
+ color: var(--_st);
169
+ font-size: 0.875rem;
170
+ font-weight: 500;
171
+ }
172
+ .sc-status svg {
173
+ width: 16px;
174
+ height: 16px;
175
+ }
176
+ </style>
@@ -0,0 +1,18 @@
1
+ import { type UptimeDayInput } from './uptime';
2
+ interface Props {
3
+ /** Name of the measured thing (e.g. "API"). */
4
+ name: string;
5
+ /** What is measured, shown behind an (i) info button. */
6
+ description?: string;
7
+ data: readonly UptimeDayInput[];
8
+ /** Window length in days. Default 90. */
9
+ days?: number;
10
+ end?: string | Date;
11
+ /** Override the status word. */
12
+ statusWord?: string;
13
+ locale?: string;
14
+ class?: string;
15
+ }
16
+ declare const StatusCard: import("svelte").Component<Props, {}, "">;
17
+ type StatusCard = ReturnType<typeof StatusCard>;
18
+ export default StatusCard;