@wells-nelson/displays 0.0.0-stage → 1.0.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.
package/README.md CHANGED
@@ -1,3 +1,70 @@
1
- # Temporary Holding Version
1
+ # @wells-nelson/displays
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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;
@@ -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>