@wells-nelson/displays 0.0.0-stage → 0.0.1
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 +69 -2
- package/dist/AppHeader.svelte +158 -0
- package/dist/AppHeader.svelte.d.ts +26 -0
- package/dist/Badge.svelte +43 -0
- package/dist/Badge.svelte.d.ts +8 -0
- package/dist/Button.svelte +81 -0
- package/dist/Button.svelte.d.ts +12 -0
- package/dist/ConfirmDialog.svelte +48 -0
- package/dist/ConfirmDialog.svelte.d.ts +11 -0
- package/dist/FatalError.svelte +20 -0
- package/dist/FatalError.svelte.d.ts +9 -0
- package/dist/Icon.svelte +29 -0
- package/dist/Icon.svelte.d.ts +9 -0
- package/dist/IconButton.svelte +59 -0
- package/dist/IconButton.svelte.d.ts +13 -0
- package/dist/Leaderboards.svelte +74 -0
- package/dist/Leaderboards.svelte.d.ts +31 -0
- package/dist/Modal.svelte +106 -0
- package/dist/Modal.svelte.d.ts +12 -0
- package/dist/SortableTable.svelte +161 -0
- package/dist/SortableTable.svelte.d.ts +37 -0
- package/dist/Splash.svelte +17 -0
- package/dist/Splash.svelte.d.ts +6 -0
- package/dist/icons.d.ts +36 -0
- package/dist/icons.js +41 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/palettes/blue.css +32 -0
- package/dist/palettes/green.css +32 -0
- package/dist/theme.css +233 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.js +15 -0
- package/package.json +44 -4
package/README.md
CHANGED
|
@@ -1,3 +1,70 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @wells-nelson/displays
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Svelte 5 display components and the shared theme. Peer dependency: `svelte`.
|
|
4
|
+
|
|
5
|
+
## Theme
|
|
6
|
+
|
|
7
|
+
Import once in the root layout. The default palette is blue; import the green one (or set
|
|
8
|
+
`--accent*` yourself) after it.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import '@wells-nelson/displays/theme.css';
|
|
12
|
+
import '@wells-nelson/displays/palettes/green.css';
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Tokens: `--bg`, `--surface`, `--surface-2`, `--surface-hover`, `--text`, `--muted`, `--border`,
|
|
16
|
+
`--border-strong`, `--accent`, `--accent-strong`, `--accent-text`, `--accent-soft`, `--on-accent`,
|
|
17
|
+
`--danger`, `--danger-soft`, `--warning`, `--warning-soft`, `--radius`, `--radius-lg`, `--shadow`,
|
|
18
|
+
`--shadow-lg`, `--header-h`, `--header-offset`, `--content-w`. Helpers: `.page`, `.page-title`,
|
|
19
|
+
`.stack`, `.card`, `.row-card`, `.muted`, `.notice`, `.empty`, `.visually-hidden`, plus
|
|
20
|
+
drag-and-drop classes. Keep app-only tokens in the app's own stylesheet.
|
|
21
|
+
|
|
22
|
+
## Icons
|
|
23
|
+
|
|
24
|
+
The base set: alert, check, chevron, cloud, cloud-pending, gear, grip, mail, pencil, plus,
|
|
25
|
+
refresh, search, share, trash, undo, user, wifi-off, x. Register app icons at startup:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { registerIcons } from '@wells-nelson/displays';
|
|
29
|
+
registerIcons({ calendar: '<rect …/>', folder: '<path …/>' }); // 24x24, stroke-based
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Components
|
|
33
|
+
|
|
34
|
+
```svelte
|
|
35
|
+
<AppHeader
|
|
36
|
+
brandHref={resolve('/')}
|
|
37
|
+
brandLabel="Home — all projects"
|
|
38
|
+
profileHref={resolve('/profile')}
|
|
39
|
+
profileActive={routeId === '/profile'}
|
|
40
|
+
avatarUrl={cloud.user?.user_metadata?.avatar_url}
|
|
41
|
+
unsynced={cloud.hasAnyPending || cloud.sessionExpired}
|
|
42
|
+
hideOnScroll
|
|
43
|
+
width={isDashboard ? '64rem' : undefined}
|
|
44
|
+
>
|
|
45
|
+
{#snippet brand()}MA{/snippet}
|
|
46
|
+
{#snippet nav()}<IconButton icon="calendar" label="Schedule" href={…} active={…} />{/snippet}
|
|
47
|
+
</AppHeader>
|
|
48
|
+
|
|
49
|
+
<SortableTable
|
|
50
|
+
title="Members"
|
|
51
|
+
rows={stats}
|
|
52
|
+
rowKey={(s) => s.id}
|
|
53
|
+
rowLabel={(s) => s.name}
|
|
54
|
+
sortKey="assigned"
|
|
55
|
+
columns={[
|
|
56
|
+
{ key: 'assigned', title: 'Tasks', full: 'Tasks assigned', value: (s) => s.assigned },
|
|
57
|
+
{ key: 'donePct', title: 'Done %', value: (s) => s.donePct, display: (s) => `${s.donePct}%` }
|
|
58
|
+
]}
|
|
59
|
+
/>
|
|
60
|
+
|
|
61
|
+
<Leaderboards rowKey={(s) => s.id} rowLabel={(s) => s.name} boards={[{ title: 'Most done', rows: topDone, value: (s) => s.done }]} />
|
|
62
|
+
|
|
63
|
+
<Badge tone="warning">Pending</Badge>
|
|
64
|
+
<Splash text="MA" />
|
|
65
|
+
<FatalError error={app.fatal} />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`Button` (primary / secondary / danger / ghost, optional icon, `block`), `IconButton` (button or
|
|
69
|
+
link, required label, `active`), `Modal` (native `<dialog>`, sm / md) and `ConfirmDialog`
|
|
70
|
+
(danger variant, busy state) complete the set. See them all in the sandbox (`npm run sandbox`).
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Icon from './Icon.svelte';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fixed app header: brand on the left; dev-only tools, context nav and the profile avatar on
|
|
7
|
+
* the right. Keeps `--header-offset` on <html> up to date so sticky content can sit below it.
|
|
8
|
+
*/
|
|
9
|
+
let {
|
|
10
|
+
brand,
|
|
11
|
+
brandHref,
|
|
12
|
+
brandLabel,
|
|
13
|
+
nav,
|
|
14
|
+
dev,
|
|
15
|
+
profileHref,
|
|
16
|
+
profileLabel = 'Profile',
|
|
17
|
+
profileActive = false,
|
|
18
|
+
avatarUrl,
|
|
19
|
+
unsynced = false,
|
|
20
|
+
unsyncedLabel = 'Unsynced changes',
|
|
21
|
+
hideOnScroll = false,
|
|
22
|
+
width = 'var(--content-w)'
|
|
23
|
+
}: {
|
|
24
|
+
/** Brand mark, rendered inside the home link. */
|
|
25
|
+
brand: Snippet;
|
|
26
|
+
brandHref: string;
|
|
27
|
+
/** Accessible name of the home link, e.g. "Home — all projects". */
|
|
28
|
+
brandLabel: string;
|
|
29
|
+
/** Context navigation, usually `IconButton`s with `href`. */
|
|
30
|
+
nav?: Snippet;
|
|
31
|
+
/** Development-only tools; the app passes this only in dev builds. */
|
|
32
|
+
dev?: Snippet;
|
|
33
|
+
profileHref: string;
|
|
34
|
+
profileLabel?: string;
|
|
35
|
+
profileActive?: boolean;
|
|
36
|
+
avatarUrl?: string | null;
|
|
37
|
+
/** Shows a warning dot on the avatar. */
|
|
38
|
+
unsynced?: boolean;
|
|
39
|
+
unsyncedLabel?: string;
|
|
40
|
+
/** Slide the header away while scrolling down; it comes back when scrolling up. */
|
|
41
|
+
hideOnScroll?: boolean;
|
|
42
|
+
/** Max width of the header content (CSS length), e.g. a wider value on dashboard screens. */
|
|
43
|
+
width?: string;
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
let scrolledAway = $state(false);
|
|
47
|
+
let lastY = 0;
|
|
48
|
+
const hidden = $derived(hideOnScroll && scrolledAway);
|
|
49
|
+
|
|
50
|
+
function onScroll() {
|
|
51
|
+
if (!hideOnScroll) return;
|
|
52
|
+
const y = Math.max(0, window.scrollY);
|
|
53
|
+
if (Math.abs(y - lastY) < 4) return;
|
|
54
|
+
scrolledAway = y > lastY && y > 56;
|
|
55
|
+
lastY = y;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
$effect(() => {
|
|
59
|
+
document.documentElement.style.setProperty(
|
|
60
|
+
'--header-offset',
|
|
61
|
+
hidden ? '0px' : 'calc(var(--header-h) + env(safe-area-inset-top, 0px))'
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<svelte:window onscroll={onScroll} />
|
|
67
|
+
|
|
68
|
+
<header class="app-header" class:hidden>
|
|
69
|
+
<div class="inner" style:max-width={width}>
|
|
70
|
+
<a class="brand" href={brandHref} aria-label={brandLabel}>{@render brand()}</a>
|
|
71
|
+
<nav aria-label="Main">
|
|
72
|
+
{@render dev?.()}
|
|
73
|
+
{@render nav?.()}
|
|
74
|
+
<a class="profile" href={profileHref} aria-label={profileLabel} title={profileLabel} class:active={profileActive}>
|
|
75
|
+
{#if avatarUrl}
|
|
76
|
+
<img src={avatarUrl} alt="" referrerpolicy="no-referrer" />
|
|
77
|
+
{:else}
|
|
78
|
+
<Icon name="user" size={22} />
|
|
79
|
+
{/if}
|
|
80
|
+
{#if unsynced}
|
|
81
|
+
<span class="dot" title={unsyncedLabel}></span>
|
|
82
|
+
{/if}
|
|
83
|
+
</a>
|
|
84
|
+
</nav>
|
|
85
|
+
</div>
|
|
86
|
+
</header>
|
|
87
|
+
|
|
88
|
+
<style>
|
|
89
|
+
header {
|
|
90
|
+
position: fixed;
|
|
91
|
+
top: 0;
|
|
92
|
+
left: 0;
|
|
93
|
+
right: 0;
|
|
94
|
+
z-index: 50;
|
|
95
|
+
padding-top: env(safe-area-inset-top, 0px);
|
|
96
|
+
background: var(--surface);
|
|
97
|
+
border-bottom: 1px solid var(--border);
|
|
98
|
+
transition: transform 0.2s ease;
|
|
99
|
+
}
|
|
100
|
+
header.hidden {
|
|
101
|
+
transform: translateY(-100%);
|
|
102
|
+
}
|
|
103
|
+
.inner {
|
|
104
|
+
display: flex;
|
|
105
|
+
align-items: center;
|
|
106
|
+
justify-content: space-between;
|
|
107
|
+
height: var(--header-h);
|
|
108
|
+
margin: 0 auto;
|
|
109
|
+
padding: 0 0.25rem 0 1rem;
|
|
110
|
+
}
|
|
111
|
+
.brand {
|
|
112
|
+
font-weight: 800;
|
|
113
|
+
font-size: 1.35rem;
|
|
114
|
+
letter-spacing: 0.02em;
|
|
115
|
+
color: var(--accent-text);
|
|
116
|
+
text-decoration: none;
|
|
117
|
+
}
|
|
118
|
+
nav {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
}
|
|
122
|
+
.profile {
|
|
123
|
+
position: relative;
|
|
124
|
+
display: grid;
|
|
125
|
+
place-items: center;
|
|
126
|
+
width: 2.5rem;
|
|
127
|
+
height: 2.5rem;
|
|
128
|
+
border-radius: 50%;
|
|
129
|
+
color: inherit;
|
|
130
|
+
}
|
|
131
|
+
.profile:hover,
|
|
132
|
+
.profile.active {
|
|
133
|
+
background: var(--accent-soft);
|
|
134
|
+
}
|
|
135
|
+
.profile img {
|
|
136
|
+
width: 1.9rem;
|
|
137
|
+
height: 1.9rem;
|
|
138
|
+
border-radius: 50%;
|
|
139
|
+
}
|
|
140
|
+
.dot {
|
|
141
|
+
position: absolute;
|
|
142
|
+
top: 0.35rem;
|
|
143
|
+
right: 0.35rem;
|
|
144
|
+
width: 0.6rem;
|
|
145
|
+
height: 0.6rem;
|
|
146
|
+
border-radius: 50%;
|
|
147
|
+
background: var(--warning);
|
|
148
|
+
border: 2px solid var(--surface);
|
|
149
|
+
}
|
|
150
|
+
@media (max-width: 22rem) {
|
|
151
|
+
.inner {
|
|
152
|
+
padding-left: 0.6rem;
|
|
153
|
+
}
|
|
154
|
+
.brand {
|
|
155
|
+
font-size: 1.15rem;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Brand mark, rendered inside the home link. */
|
|
4
|
+
brand: Snippet;
|
|
5
|
+
brandHref: string;
|
|
6
|
+
/** Accessible name of the home link, e.g. "Home — all projects". */
|
|
7
|
+
brandLabel: string;
|
|
8
|
+
/** Context navigation, usually `IconButton`s with `href`. */
|
|
9
|
+
nav?: Snippet;
|
|
10
|
+
/** Development-only tools; the app passes this only in dev builds. */
|
|
11
|
+
dev?: Snippet;
|
|
12
|
+
profileHref: string;
|
|
13
|
+
profileLabel?: string;
|
|
14
|
+
profileActive?: boolean;
|
|
15
|
+
avatarUrl?: string | null;
|
|
16
|
+
/** Shows a warning dot on the avatar. */
|
|
17
|
+
unsynced?: boolean;
|
|
18
|
+
unsyncedLabel?: string;
|
|
19
|
+
/** Slide the header away while scrolling down; it comes back when scrolling up. */
|
|
20
|
+
hideOnScroll?: boolean;
|
|
21
|
+
/** Max width of the header content (CSS length), e.g. a wider value on dashboard screens. */
|
|
22
|
+
width?: string;
|
|
23
|
+
};
|
|
24
|
+
declare const AppHeader: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
25
|
+
type AppHeader = ReturnType<typeof AppHeader>;
|
|
26
|
+
export default AppHeader;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/** Small status pill, e.g. "Default", "Pending", "Editor". */
|
|
5
|
+
let {
|
|
6
|
+
tone = 'neutral',
|
|
7
|
+
children
|
|
8
|
+
}: {
|
|
9
|
+
tone?: 'neutral' | 'accent' | 'warning' | 'danger';
|
|
10
|
+
children: Snippet;
|
|
11
|
+
} = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<span class="badge {tone}">{@render children()}</span>
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
.badge {
|
|
18
|
+
display: inline-block;
|
|
19
|
+
flex: none;
|
|
20
|
+
font-size: 0.75rem;
|
|
21
|
+
font-weight: 700;
|
|
22
|
+
line-height: 1.4;
|
|
23
|
+
padding: 0.1rem 0.5rem;
|
|
24
|
+
border-radius: 1rem;
|
|
25
|
+
white-space: nowrap;
|
|
26
|
+
}
|
|
27
|
+
.neutral {
|
|
28
|
+
background: var(--surface-2);
|
|
29
|
+
color: var(--muted);
|
|
30
|
+
}
|
|
31
|
+
.accent {
|
|
32
|
+
background: var(--accent-soft);
|
|
33
|
+
color: var(--accent-text);
|
|
34
|
+
}
|
|
35
|
+
.warning {
|
|
36
|
+
background: var(--warning-soft);
|
|
37
|
+
color: var(--warning);
|
|
38
|
+
}
|
|
39
|
+
.danger {
|
|
40
|
+
background: var(--danger-soft);
|
|
41
|
+
color: var(--danger);
|
|
42
|
+
}
|
|
43
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
tone?: 'neutral' | 'accent' | 'warning' | 'danger';
|
|
4
|
+
children: Snippet;
|
|
5
|
+
};
|
|
6
|
+
declare const Badge: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type Badge = ReturnType<typeof Badge>;
|
|
8
|
+
export default Badge;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
4
|
+
import Icon from './Icon.svelte';
|
|
5
|
+
import type { IconName } from './icons.js';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
variant = 'secondary',
|
|
9
|
+
icon,
|
|
10
|
+
block = false,
|
|
11
|
+
children,
|
|
12
|
+
type = 'button',
|
|
13
|
+
...rest
|
|
14
|
+
}: HTMLButtonAttributes & {
|
|
15
|
+
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
16
|
+
icon?: IconName;
|
|
17
|
+
block?: boolean;
|
|
18
|
+
children?: Snippet;
|
|
19
|
+
} = $props();
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<button {type} class="btn {variant}" class:block {...rest}>
|
|
23
|
+
{#if icon}<Icon name={icon} size={18} />{/if}
|
|
24
|
+
{@render children?.()}
|
|
25
|
+
</button>
|
|
26
|
+
|
|
27
|
+
<style>
|
|
28
|
+
.btn {
|
|
29
|
+
display: inline-flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
justify-content: center;
|
|
32
|
+
gap: 0.4rem;
|
|
33
|
+
min-height: 2.5rem;
|
|
34
|
+
padding: 0 1rem;
|
|
35
|
+
border-radius: var(--radius);
|
|
36
|
+
border: 1px solid transparent;
|
|
37
|
+
font: inherit;
|
|
38
|
+
font-weight: 600;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
transition:
|
|
41
|
+
background-color 0.15s,
|
|
42
|
+
border-color 0.15s,
|
|
43
|
+
opacity 0.15s;
|
|
44
|
+
}
|
|
45
|
+
.btn:disabled {
|
|
46
|
+
opacity: 0.5;
|
|
47
|
+
cursor: not-allowed;
|
|
48
|
+
}
|
|
49
|
+
.block {
|
|
50
|
+
width: 100%;
|
|
51
|
+
}
|
|
52
|
+
.primary {
|
|
53
|
+
background: var(--accent);
|
|
54
|
+
color: var(--on-accent);
|
|
55
|
+
}
|
|
56
|
+
.primary:not(:disabled):hover {
|
|
57
|
+
background: var(--accent-strong);
|
|
58
|
+
}
|
|
59
|
+
.secondary {
|
|
60
|
+
background: var(--surface);
|
|
61
|
+
border-color: var(--border-strong);
|
|
62
|
+
color: var(--text);
|
|
63
|
+
}
|
|
64
|
+
.secondary:not(:disabled):hover {
|
|
65
|
+
background: var(--surface-hover);
|
|
66
|
+
}
|
|
67
|
+
.danger {
|
|
68
|
+
background: var(--danger);
|
|
69
|
+
color: #fff;
|
|
70
|
+
}
|
|
71
|
+
.danger:not(:disabled):hover {
|
|
72
|
+
filter: brightness(0.92);
|
|
73
|
+
}
|
|
74
|
+
.ghost {
|
|
75
|
+
background: transparent;
|
|
76
|
+
color: var(--accent-text);
|
|
77
|
+
}
|
|
78
|
+
.ghost:not(:disabled):hover {
|
|
79
|
+
background: var(--surface-hover);
|
|
80
|
+
}
|
|
81
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
|
+
import type { IconName } from './icons.js';
|
|
4
|
+
type $$ComponentProps = HTMLButtonAttributes & {
|
|
5
|
+
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
6
|
+
icon?: IconName;
|
|
7
|
+
block?: boolean;
|
|
8
|
+
children?: Snippet;
|
|
9
|
+
};
|
|
10
|
+
declare const Button: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type Button = ReturnType<typeof Button>;
|
|
12
|
+
export default Button;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from './Button.svelte';
|
|
3
|
+
import Modal from './Modal.svelte';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
open = $bindable(false),
|
|
7
|
+
title,
|
|
8
|
+
message,
|
|
9
|
+
confirmLabel = 'Confirm',
|
|
10
|
+
danger = false,
|
|
11
|
+
onconfirm
|
|
12
|
+
}: {
|
|
13
|
+
open?: boolean;
|
|
14
|
+
title: string;
|
|
15
|
+
message: string;
|
|
16
|
+
confirmLabel?: string;
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
onconfirm: () => void | Promise<void>;
|
|
19
|
+
} = $props();
|
|
20
|
+
|
|
21
|
+
let busy = $state(false);
|
|
22
|
+
|
|
23
|
+
async function confirm() {
|
|
24
|
+
busy = true;
|
|
25
|
+
try {
|
|
26
|
+
await onconfirm();
|
|
27
|
+
open = false;
|
|
28
|
+
} finally {
|
|
29
|
+
busy = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<Modal bind:open {title} size="sm">
|
|
35
|
+
<p class="message">{message}</p>
|
|
36
|
+
{#snippet footer()}
|
|
37
|
+
<Button onclick={() => (open = false)}>Cancel</Button>
|
|
38
|
+
<Button variant={danger ? 'danger' : 'primary'} onclick={confirm} disabled={busy}>{confirmLabel}</Button>
|
|
39
|
+
{/snippet}
|
|
40
|
+
</Modal>
|
|
41
|
+
|
|
42
|
+
<style>
|
|
43
|
+
.message {
|
|
44
|
+
margin: 0;
|
|
45
|
+
line-height: 1.5;
|
|
46
|
+
white-space: pre-line;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
open?: boolean;
|
|
3
|
+
title: string;
|
|
4
|
+
message: string;
|
|
5
|
+
confirmLabel?: string;
|
|
6
|
+
danger?: boolean;
|
|
7
|
+
onconfirm: () => void | Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
declare const ConfirmDialog: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
10
|
+
type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
|
|
11
|
+
export default ConfirmDialog;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/** Full-page error for when the app can't start, e.g. the local database couldn't be opened. */
|
|
3
|
+
let {
|
|
4
|
+
error,
|
|
5
|
+
title = 'Something went wrong',
|
|
6
|
+
message = "The local database couldn't be opened:",
|
|
7
|
+
hint = 'Private browsing modes sometimes block storage. Try a normal window.'
|
|
8
|
+
}: {
|
|
9
|
+
error: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
message?: string;
|
|
12
|
+
hint?: string;
|
|
13
|
+
} = $props();
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<main class="page">
|
|
17
|
+
<h1 class="page-title">{title}</h1>
|
|
18
|
+
<p>{message} {error}</p>
|
|
19
|
+
{#if hint}<p class="muted">{hint}</p>{/if}
|
|
20
|
+
</main>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
error: string;
|
|
3
|
+
title?: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const FatalError: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type FatalError = ReturnType<typeof FatalError>;
|
|
9
|
+
export default FatalError;
|
package/dist/Icon.svelte
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { iconMarkup, type IconName } from './icons.js';
|
|
3
|
+
|
|
4
|
+
let { name, size = 20, label }: { name: IconName; size?: number; label?: string } = $props();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<svg
|
|
8
|
+
class="icon"
|
|
9
|
+
width={size}
|
|
10
|
+
height={size}
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
fill="none"
|
|
13
|
+
stroke="currentColor"
|
|
14
|
+
stroke-width="1.9"
|
|
15
|
+
stroke-linecap="round"
|
|
16
|
+
stroke-linejoin="round"
|
|
17
|
+
role={label ? 'img' : undefined}
|
|
18
|
+
aria-label={label}
|
|
19
|
+
aria-hidden={label ? undefined : 'true'}
|
|
20
|
+
>
|
|
21
|
+
{@html iconMarkup(name)}
|
|
22
|
+
</svg>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.icon {
|
|
26
|
+
flex: none;
|
|
27
|
+
display: block;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type IconName } from './icons.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
name: IconName;
|
|
4
|
+
size?: number;
|
|
5
|
+
label?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const Icon: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type Icon = ReturnType<typeof Icon>;
|
|
9
|
+
export default Icon;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from './Icon.svelte';
|
|
3
|
+
import type { IconName } from './icons.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
icon,
|
|
7
|
+
label,
|
|
8
|
+
href,
|
|
9
|
+
onclick,
|
|
10
|
+
active = false,
|
|
11
|
+
size = 22,
|
|
12
|
+
disabled = false
|
|
13
|
+
}: {
|
|
14
|
+
icon: IconName;
|
|
15
|
+
label: string;
|
|
16
|
+
href?: string;
|
|
17
|
+
onclick?: (e: MouseEvent) => void;
|
|
18
|
+
active?: boolean;
|
|
19
|
+
size?: number;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
} = $props();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if href}
|
|
25
|
+
<a class="icon-btn" class:active {href} aria-label={label} title={label} aria-current={active ? 'page' : undefined}>
|
|
26
|
+
<Icon name={icon} {size} />
|
|
27
|
+
</a>
|
|
28
|
+
{:else}
|
|
29
|
+
<button type="button" class="icon-btn" class:active aria-label={label} title={label} {onclick} {disabled}>
|
|
30
|
+
<Icon name={icon} {size} />
|
|
31
|
+
</button>
|
|
32
|
+
{/if}
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.icon-btn {
|
|
36
|
+
display: inline-grid;
|
|
37
|
+
place-items: center;
|
|
38
|
+
width: 2.5rem;
|
|
39
|
+
height: 2.5rem;
|
|
40
|
+
flex: none;
|
|
41
|
+
border: none;
|
|
42
|
+
border-radius: 50%;
|
|
43
|
+
background: transparent;
|
|
44
|
+
color: inherit;
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
text-decoration: none;
|
|
47
|
+
}
|
|
48
|
+
.icon-btn:hover:not(:disabled) {
|
|
49
|
+
background: var(--surface-hover);
|
|
50
|
+
}
|
|
51
|
+
.icon-btn:disabled {
|
|
52
|
+
opacity: 0.4;
|
|
53
|
+
cursor: not-allowed;
|
|
54
|
+
}
|
|
55
|
+
.active {
|
|
56
|
+
color: var(--accent-text);
|
|
57
|
+
background: var(--accent-soft);
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IconName } from './icons.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
icon: IconName;
|
|
4
|
+
label: string;
|
|
5
|
+
href?: string;
|
|
6
|
+
onclick?: (e: MouseEvent) => void;
|
|
7
|
+
active?: boolean;
|
|
8
|
+
size?: number;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
};
|
|
11
|
+
declare const IconButton: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type IconButton = ReturnType<typeof IconButton>;
|
|
13
|
+
export default IconButton;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import type { Leaderboard } from './types.js';
|
|
3
|
+
|
|
4
|
+
/** A sideways-scrolling row of "top N" cards. Pass rows already sorted and trimmed. */
|
|
5
|
+
let {
|
|
6
|
+
boards,
|
|
7
|
+
rowKey,
|
|
8
|
+
rowLabel,
|
|
9
|
+
label = 'Leaderboards'
|
|
10
|
+
}: {
|
|
11
|
+
boards: Leaderboard<T>[];
|
|
12
|
+
rowKey: (row: T) => string;
|
|
13
|
+
rowLabel: (row: T) => string;
|
|
14
|
+
/** Accessible name of the region. */
|
|
15
|
+
label?: string;
|
|
16
|
+
} = $props();
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<div class="boards" role="region" aria-label={label}>
|
|
20
|
+
{#each boards as board (board.title)}
|
|
21
|
+
<section class="board card">
|
|
22
|
+
<h2>{board.title}</h2>
|
|
23
|
+
<ol>
|
|
24
|
+
{#each board.rows as row (rowKey(row))}
|
|
25
|
+
<li><span class="who">{rowLabel(row)}</span><span class="val">{board.value(row)}</span></li>
|
|
26
|
+
{/each}
|
|
27
|
+
</ol>
|
|
28
|
+
</section>
|
|
29
|
+
{/each}
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<style>
|
|
33
|
+
.boards {
|
|
34
|
+
display: flex;
|
|
35
|
+
gap: 0.75rem;
|
|
36
|
+
overflow-x: auto;
|
|
37
|
+
padding-bottom: 0.5rem;
|
|
38
|
+
margin-bottom: 0.75rem;
|
|
39
|
+
scroll-snap-type: x proximity;
|
|
40
|
+
}
|
|
41
|
+
.board {
|
|
42
|
+
flex: 0 0 min(15rem, 80%);
|
|
43
|
+
padding: 0.75rem 1rem;
|
|
44
|
+
scroll-snap-align: start;
|
|
45
|
+
}
|
|
46
|
+
h2 {
|
|
47
|
+
margin: 0 0 0.5rem;
|
|
48
|
+
font-size: 0.95rem;
|
|
49
|
+
}
|
|
50
|
+
ol {
|
|
51
|
+
margin: 0;
|
|
52
|
+
padding-left: 1.3rem;
|
|
53
|
+
font-size: 0.9rem;
|
|
54
|
+
}
|
|
55
|
+
li {
|
|
56
|
+
position: relative;
|
|
57
|
+
padding: 0.15rem 0;
|
|
58
|
+
}
|
|
59
|
+
li > span {
|
|
60
|
+
display: inline-flex;
|
|
61
|
+
}
|
|
62
|
+
.who {
|
|
63
|
+
max-width: calc(100% - 3.5rem);
|
|
64
|
+
overflow: hidden;
|
|
65
|
+
text-overflow: ellipsis;
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
}
|
|
68
|
+
.val {
|
|
69
|
+
position: absolute;
|
|
70
|
+
right: 0;
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
font-variant-numeric: tabular-nums;
|
|
73
|
+
}
|
|
74
|
+
</style>
|