@broberg/app-shell 0.1.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 ADDED
@@ -0,0 +1,99 @@
1
+ # @broberg/app-shell
2
+
3
+ The fleet's app shell — the frame every app was copying: a collapsible sidebar,
4
+ a top bar with the **notification bell and the user menu as standard**, and a
5
+ page header with tabs. Framework-free core + Preact (Stack B). React (Stack A)
6
+ follows. Lifted in shape from cardmem and trail (F092).
7
+
8
+ ```ts
9
+ import "@broberg/theme/css/palettes.css";
10
+ import "@broberg/app-shell/css/app-shell.css";
11
+ import { AppShell, PageHeader, PageTabs, createFetchNotificationSource } from "@broberg/app-shell/preact";
12
+
13
+ const notifications = createFetchNotificationSource({ url: "/api/notifications" });
14
+
15
+ <AppShell
16
+ brand={<Logo />}
17
+ groups={[{ label: "Overview", items: [{ id: "home", label: "Home", href: "/", icon: <HomeIcon /> }] }]}
18
+ footer={[{ id: "settings", label: "Settings", href: "/settings" }]}
19
+ currentPath={path}
20
+ onNavigate={navigate} // SPA navigation; omit for normal links
21
+ notifications={notifications}
22
+ user={{ name, email, picture }} // picture = Broberg ID's OIDC `picture`
23
+ userMenu={{ accountHref: "https://id.broberg.ai/account", onSignOut }}
24
+ mobile="drawer" // or "rail"
25
+ lang="da"
26
+ >
27
+ <PageHeader eyebrow="Settings" title="Indstillinger" />
28
+ <PageTabs tabs={[{ id: "general", label: "Generelt" }, { id: "team", label: "Team" }]} currentUrl={url} />
29
+ …
30
+ </AppShell>
31
+ ```
32
+
33
+ Set up `@broberg/theme` first (`initTheme`, `initPalette`, `prePaintScript`) —
34
+ the user menu reads and writes theme, palette, surfaces and backdrop through it.
35
+
36
+ ## The parts
37
+
38
+ | Export | What it is |
39
+ |---|---|
40
+ | `AppShell` | Top bar + sidebar + `<main>`; remembers a collapsed sidebar (`storageKey`). |
41
+ | `Sidebar` | Groups (foldable, remembered) + footer, from data. Active item from `currentPath`. Badges, 99+. |
42
+ | `TopBar` | Brand left, `actions` slot, then **the bell and the user menu, always last, in that order**. |
43
+ | `NotificationBell` | Unread count, panel (dropdown on desktop, bottom sheet on a phone), mark one / all read. |
44
+ | `UserMenu` | Avatar, name + email, **Konto** → Broberg ID, your items, Theme · Palette · Surfaces · Backdrop, sign out. |
45
+ | `PageHeader`, `PageTabs` | Eyebrow/title/description/actions; tabs as routes (`?tab=` or one path per tab). |
46
+ | `Avatar` | `picture` when given, otherwise initials. |
47
+
48
+ ## Phone: `mobile="drawer"` or `mobile="rail"`
49
+
50
+ - **drawer** (cardmem): a hamburger in the top bar opens the sidebar over a backdrop; a tap on the backdrop or on a link closes it.
51
+ - **rail** (trail): the sidebar is a 60px icon rail; no hamburger. On desktop, collapsing gives the same rail instead of hiding the sidebar.
52
+
53
+ ## Notifications are standard — on @broberg/notifications
54
+
55
+ The bell is always in the top bar; with no source it shows an empty list, not
56
+ nothing. It does not invent a second notification contract: rows are
57
+ `NotificationRow` from **@broberg/notifications** (the fleet's shape, the same
58
+ field names @broberg/webpush sends), and the panel is driven by that package's
59
+ `createBellShell` — one count however many bells, Escape on the innermost layer,
60
+ and the ids "mark all read" cleared held for the visit. Put
61
+ `createNotifications` behind your endpoint and the server's one counting rule
62
+ (muted kinds excluded) is what the badge shows.
63
+
64
+ `createFetchNotificationSource({ url })` is the default source:
65
+
66
+ | call | request | response |
67
+ |---|---|---|
68
+ | rows + count | `GET {url}` | `{ items: NotificationRow[], unseen: number }` |
69
+ | mark seen | `POST {url}/seen` `{ ids }` | `{ clearedIds, count }` |
70
+ | mark all seen | `POST {url}/seen-all` | `{ clearedIds, count }` |
71
+
72
+ The server's `unseen` wins over counting rows. It re-counts on focus, on a
73
+ visible tab and every 60 s (`pollMs`). A failed load shows **«Kunne ikke hente
74
+ notifikationer»** — never «no notifications», because those two must not look
75
+ the same. `createMemoryNotificationSource(rows)` is for demos and tests; write
76
+ your own `NotificationSource` for SSE or anything else.
77
+
78
+ ## `lang` is required
79
+
80
+ Every component that shows text takes `lang: "da" | "en"` with no default — the
81
+ rule @broberg/notifications' bell shell set for a measured reason: a default
82
+ makes "nobody chose" look like a choice, and an app shipped Danish strings for
83
+ months in an English UI that way.
84
+
85
+ ## Avatar — no Gravatar
86
+
87
+ The avatar is Broberg ID's `picture`; without it, initials. There is no
88
+ Gravatar fallback (owner decision D-1dc849: no more US services than necessary —
89
+ Gravatar receives a hash of the user's email). A test fails if Gravatar enters
90
+ the source.
91
+
92
+ ## Styling
93
+
94
+ `css/app-shell.css` uses only `@broberg/theme` tokens (`--bg`, `--fg`,
95
+ `--border`, `--surface-header`, `--surface-panel`, `--surface-work` …), so every
96
+ palette × light/dark × flat/layered works with no extra CSS. A test fails on any
97
+ colour literal in it. Classes are prefixed `bas-` so they cannot collide with an
98
+ app's own during a migration. Every control has a `data-testid`.
99
+
@@ -0,0 +1,250 @@
1
+ /* @broberg/app-shell — css/app-shell.css (F092)
2
+ *
3
+ * Plain CSS, importable from node_modules:
4
+ * @import "@broberg/theme/css/palettes.css";
5
+ * @import "@broberg/app-shell/css/app-shell.css";
6
+ *
7
+ * Every colour is a token from @broberg/theme's palettes.css (--bg, --fg,
8
+ * --border, --surface-header …), so all palettes × light/dark × flat/layered
9
+ * work with no extra CSS. No colour is written here as a literal; the only
10
+ * literals are shadows and the backdrop dim, as rgba.
11
+ *
12
+ * Shape and behaviour follow cardmem's shell (apps/web/src/styles.css,
13
+ * .app-sidebar / .sidebar-item / .topbar-inner / .segmented), under a bas-
14
+ * prefix so it cannot collide with an app's own classes during a migration.
15
+ */
16
+
17
+ .bas-root {
18
+ --bas-top: 56px;
19
+ --bas-radius: var(--radius, 8px);
20
+ --bas-ease: cubic-bezier(.4, 0, .2, 1);
21
+ --bas-dur: 140ms;
22
+ min-height: 100dvh;
23
+ background: var(--surface-work, var(--bg));
24
+ color: var(--fg);
25
+ }
26
+ .bas-root *, .bas-root *::before, .bas-root *::after { box-sizing: border-box; }
27
+ .bas-root button { font: inherit; }
28
+
29
+ /* ── top bar ── */
30
+ .bas-topbar {
31
+ position: sticky; top: 0; z-index: 50;
32
+ background: color-mix(in srgb, var(--surface-header, var(--bg)) 85%, transparent);
33
+ backdrop-filter: blur(8px);
34
+ border-bottom: 1px solid var(--border);
35
+ }
36
+ .bas-topbar__inner { display: flex; align-items: center; gap: 12px; height: var(--bas-top); padding: 0 16px; }
37
+ .bas-topbar__brand, .bas-topbar__actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
38
+ .bas-spacer { flex: 1; }
39
+ .bas-iconbtn {
40
+ position: relative; display: inline-flex; align-items: center; justify-content: center;
41
+ width: 34px; height: 34px; border-radius: var(--bas-radius); border: 0;
42
+ background: transparent; color: var(--fg-muted); cursor: pointer;
43
+ transition: background var(--bas-dur) var(--bas-ease), color var(--bas-dur) var(--bas-ease);
44
+ }
45
+ .bas-iconbtn:hover { background: var(--hover); color: var(--fg); }
46
+ .bas-iconbtn:active { background: var(--active); }
47
+ .bas-hamburger { display: none; margin-left: -6px; }
48
+
49
+ /* ── layout row ── */
50
+ .bas-row { display: flex; align-items: flex-start; }
51
+ .bas-main { flex: 1; min-width: 0; position: relative; }
52
+
53
+ /* ── sidebar ── */
54
+ .bas-sidebar {
55
+ flex: 0 0 240px; width: 240px;
56
+ position: sticky; top: var(--bas-top); align-self: flex-start;
57
+ height: calc(100dvh - var(--bas-top));
58
+ display: flex; flex-direction: column; overflow: hidden;
59
+ background: var(--surface-panel, var(--bg));
60
+ border-right: 1px solid var(--border);
61
+ transition: width var(--bas-dur) var(--bas-ease), flex-basis var(--bas-dur) var(--bas-ease);
62
+ }
63
+ .bas-sidebar__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 10px 4px 12px; min-height: 44px; }
64
+ .bas-sidebar__brand { min-width: 0; }
65
+ /* drawer mode: on desktop the brand already sits in the top bar — show it in the sidebar only on a phone */
66
+ .bas-root[data-mobile="drawer"] .bas-sidebar__brand { display: none; }
67
+ .bas-collapse { width: 26px; height: 26px; border: 1px solid var(--border); background: var(--paper, var(--bg-card)); color: var(--fg-subtle); }
68
+ .bas-sidebar__nav { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 8px 8px; }
69
+ .bas-sidebar__foot { border-top: 1px solid var(--border); padding: 8px; }
70
+ .bas-group { margin-bottom: 6px; }
71
+ .bas-group__label {
72
+ display: flex; align-items: center; gap: 5px; width: 100%;
73
+ padding: 6px 4px 4px; border: 0; background: none; cursor: pointer; text-align: left;
74
+ font-family: var(--font-mono, ui-monospace, monospace); font-size: 10px; letter-spacing: .08em;
75
+ text-transform: uppercase; color: var(--fg-subtle);
76
+ }
77
+ .bas-group__label:hover { color: var(--fg-muted); }
78
+ .bas-chev { display: inline-flex; transition: transform var(--bas-dur) var(--bas-ease); }
79
+ .bas-chev.is-open { transform: rotate(90deg); }
80
+
81
+ .bas-item {
82
+ position: relative; display: flex; align-items: center; gap: 10px; width: 100%;
83
+ padding: 7px 10px; border-radius: 6px; text-decoration: none;
84
+ color: var(--fg-muted); font-size: 13px;
85
+ transition: background var(--bas-dur) var(--bas-ease), color var(--bas-dur) var(--bas-ease);
86
+ }
87
+ .bas-item:hover { background: var(--hover); color: var(--fg); }
88
+ .bas-item.is-active { background: var(--accent-soft); color: var(--fg); font-weight: 500; }
89
+ .bas-item__icon { display: inline-flex; flex: none; color: var(--fg-subtle); }
90
+ .bas-item.is-active .bas-item__icon { color: var(--accent); }
91
+ .bas-item__bar { position: absolute; left: -8px; top: 8px; bottom: 8px; width: 2px; border-radius: 1px; background: var(--accent); }
92
+ .bas-item__label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
93
+ .bas-item__count {
94
+ flex: none; min-width: 18px; padding: 1px 6px; border-radius: 999px; text-align: center;
95
+ font-family: var(--font-mono, ui-monospace, monospace); font-size: 10px; font-weight: 500;
96
+ background: var(--accent-soft); color: var(--fg);
97
+ }
98
+
99
+ /* Desktop collapse: drawer mode hides the sidebar; rail mode keeps a 60px icon rail. */
100
+ .bas-expand { display: none; }
101
+ @media (min-width: 768px) {
102
+ .bas-sidebar.is-mobile-drawer.is-collapsed { flex-basis: 0; width: 0; border-right: 0; }
103
+ .bas-expand {
104
+ display: inline-flex; position: sticky; top: calc(var(--bas-top) + 8px); align-self: flex-start;
105
+ margin: 8px 0 0 8px; width: 28px; height: 28px; border: 1px solid var(--border);
106
+ background: var(--paper, var(--bg-card)); color: var(--fg-subtle); z-index: 5;
107
+ }
108
+ }
109
+ .bas-sidebar.is-mobile-rail.is-collapsed { flex-basis: 60px; width: 60px; }
110
+ .bas-sidebar.is-mobile-rail.is-collapsed .bas-item__label,
111
+ .bas-sidebar.is-mobile-rail.is-collapsed .bas-group__label span,
112
+ .bas-sidebar.is-mobile-rail.is-collapsed .bas-sidebar__brand { display: none; }
113
+ .bas-sidebar.is-mobile-rail.is-collapsed .bas-item { justify-content: center; padding: 8px 0; }
114
+ .bas-sidebar.is-mobile-rail.is-collapsed .bas-item__count { position: absolute; top: 2px; right: 4px; padding: 0 4px; }
115
+ .bas-sidebar.is-mobile-rail.is-collapsed .bas-collapse { transform: rotate(180deg); }
116
+
117
+ .bas-backdrop { display: none; }
118
+
119
+ /* ── phone ── */
120
+ @media (max-width: 767px) {
121
+ .bas-root[data-mobile="drawer"] .bas-hamburger { display: inline-flex; }
122
+ .bas-root[data-mobile="drawer"] .bas-topbar__brand { display: none; }
123
+ .bas-sidebar.is-mobile-drawer {
124
+ position: fixed; top: 0; bottom: 0; left: 0; height: 100dvh; z-index: 60;
125
+ /* closed: hidden (no shadow bleeding in at the screen edge, out of the tab order) once the slide-out ends */
126
+ transform: translateX(-100%); visibility: hidden;
127
+ transition: transform var(--bas-dur) var(--bas-ease), visibility 0s linear var(--bas-dur);
128
+ }
129
+ .bas-sidebar.is-mobile-drawer.is-open {
130
+ transform: translateX(0); visibility: visible; transition: transform var(--bas-dur) var(--bas-ease);
131
+ box-shadow: 0 24px 56px rgba(0, 0, 0, .22);
132
+ }
133
+ .bas-root[data-mobile="drawer"] .bas-sidebar__brand { display: block; }
134
+ .bas-sidebar.is-mobile-drawer .bas-collapse { display: none; }
135
+ .bas-backdrop.is-open { display: block; position: fixed; inset: 0; z-index: 55; background: rgba(0, 0, 0, .36); }
136
+
137
+ /* rail: always the 60px icon rail on a phone, never a drawer */
138
+ .bas-sidebar.is-mobile-rail { flex-basis: 60px; width: 60px; }
139
+ .bas-sidebar.is-mobile-rail .bas-item__label,
140
+ .bas-sidebar.is-mobile-rail .bas-group__label span,
141
+ .bas-sidebar.is-mobile-rail .bas-sidebar__brand,
142
+ .bas-sidebar.is-mobile-rail .bas-collapse { display: none; }
143
+ .bas-sidebar.is-mobile-rail .bas-item { justify-content: center; padding: 8px 0; }
144
+ .bas-sidebar.is-mobile-rail .bas-item__count { position: absolute; top: 2px; right: 4px; padding: 0 4px; }
145
+ .bas-topbar__inner { padding: 0 12px; gap: 8px; }
146
+ .bas-userbtn__name { display: none; }
147
+ }
148
+ @media (prefers-reduced-motion: reduce) {
149
+ .bas-sidebar, .bas-sidebar.is-mobile-drawer, .bas-chev, .bas-item, .bas-iconbtn { transition: none; }
150
+ }
151
+
152
+ /* ── notification bell ── */
153
+ .bas-bell { position: relative; }
154
+ .bas-bell__count {
155
+ position: absolute; top: -2px; right: -2px; min-width: 17px; height: 17px; padding: 0 4px;
156
+ border-radius: 9px; display: flex; align-items: center; justify-content: center;
157
+ background: var(--clay-d); color: var(--paper, var(--bg-card)); border: 2px solid var(--bg);
158
+ font-size: 10.5px; font-weight: 700; font-variant-numeric: tabular-nums;
159
+ }
160
+ .bas-overlay { position: fixed; inset: 0; z-index: 60; background: transparent; }
161
+ .bas-overlay.is-dim { background: rgba(0, 0, 0, .34); }
162
+ .bas-panel {
163
+ position: fixed; z-index: 61; display: flex; flex-direction: column; overflow: hidden;
164
+ background: var(--bg-card); color: var(--fg);
165
+ }
166
+ .bas-panel--drop {
167
+ top: calc(var(--bas-top) - 4px); right: 16px; width: 392px; max-width: 92vw; max-height: 560px;
168
+ border: 1px solid var(--border); border-radius: calc(var(--bas-radius) + 4px);
169
+ box-shadow: 0 24px 56px rgba(0, 0, 0, .18);
170
+ }
171
+ .bas-panel--sheet { left: 0; right: 0; bottom: 0; max-height: 78vh; border-radius: 22px 22px 0 0; box-shadow: 0 -10px 30px rgba(0, 0, 0, .18); }
172
+ .bas-panel__head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); }
173
+ .bas-panel__head h3 { margin: 0; font-size: 15px; font-weight: 600; }
174
+ .bas-panel__list { flex: 1; overflow-y: auto; }
175
+ .bas-panel__note { margin: 0; padding: 28px 16px; text-align: center; font-size: 13px; color: var(--fg-subtle); }
176
+ .bas-panel__note.is-error { color: var(--danger); }
177
+ .bas-link { border: 0; background: transparent; color: var(--clay-d); font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 6px; border-radius: 6px; }
178
+ .bas-link:hover:not(:disabled) { background: var(--hover); }
179
+ .bas-link:disabled { color: var(--fg-faint); cursor: default; }
180
+ .bas-note {
181
+ display: flex; gap: 12px; width: 100%; padding: 12px 16px; align-items: flex-start; text-align: left;
182
+ border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--fg); cursor: pointer;
183
+ }
184
+ .bas-note:hover { background: var(--bg-sunk); }
185
+ .bas-note.is-read { opacity: .6; }
186
+ .bas-note__text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
187
+ .bas-note__title { font-size: 13.5px; font-weight: 600; line-height: 1.3; }
188
+ .bas-note__body { font-size: 12.5px; color: var(--fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
189
+ .bas-note__time { font-size: 11px; color: var(--fg-subtle); margin-top: 3px; }
190
+ .bas-note__dot { flex: none; align-self: center; width: 8px; height: 8px; border-radius: 50%; background: var(--clay); }
191
+
192
+ /* ── user menu ── */
193
+ .bas-usermenu { position: relative; flex: none; }
194
+ .bas-userbtn {
195
+ display: inline-flex; align-items: center; gap: 8px; padding: 4px 10px 4px 4px;
196
+ border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--fg); cursor: pointer;
197
+ transition: background var(--bas-dur) var(--bas-ease);
198
+ }
199
+ .bas-userbtn:hover, .bas-userbtn.is-open { background: var(--active); }
200
+ .bas-userbtn__name { font-size: 13px; font-weight: 500; }
201
+ .bas-avatar { border-radius: 50%; object-fit: cover; flex: none; }
202
+ .bas-avatar--initials {
203
+ display: inline-flex; align-items: center; justify-content: center; font-weight: 600;
204
+ background: var(--accent-soft); color: var(--fg);
205
+ }
206
+ .bas-menu {
207
+ position: absolute; right: 0; top: calc(100% + 8px); z-index: 60; width: 320px; max-width: 92vw;
208
+ background: var(--bg-card); color: var(--fg); border: 1px solid var(--border);
209
+ border-radius: calc(var(--bas-radius) + 4px); box-shadow: 0 24px 56px rgba(0, 0, 0, .18); overflow: hidden;
210
+ }
211
+ .bas-menu__who { display: flex; gap: 10px; align-items: center; padding: 14px; }
212
+ .bas-menu__whotext { min-width: 0; }
213
+ .bas-menu__name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
214
+ .bas-menu__email { font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; color: var(--fg-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
215
+ .bas-sep { height: 1px; background: var(--border); margin: 4px 0; }
216
+ .bas-mi {
217
+ display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 14px; text-align: left;
218
+ border: 0; background: transparent; color: var(--fg); font-size: 13px; text-decoration: none; cursor: pointer;
219
+ }
220
+ .bas-mi:hover:not(:disabled) { background: var(--hover); }
221
+ .bas-mi:active:not(:disabled) { background: var(--active); }
222
+ .bas-mi:disabled { color: var(--fg-faint); cursor: progress; }
223
+ .bas-mi.is-danger { color: var(--danger); }
224
+ .bas-prefs { padding: 6px 14px; }
225
+ .bas-pref { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 5px 0; font-size: 12.5px; color: var(--fg-muted); }
226
+ .bas-seg { display: inline-flex; flex-wrap: wrap; padding: 2px; border-radius: var(--bas-radius); background: var(--bg-sunk); border: 1px solid var(--border); }
227
+ .bas-seg > button {
228
+ font-family: var(--font-mono, ui-monospace, monospace); font-size: 11px; font-weight: 500; padding: 4px 8px;
229
+ border: 0; border-radius: calc(var(--bas-radius) - 2px); background: transparent; color: var(--fg-muted); cursor: pointer; white-space: nowrap;
230
+ }
231
+ .bas-seg > button[aria-pressed="true"] { background: var(--olive-btn); color: var(--paper, var(--bg-card)); }
232
+ .bas-seg > button:not([aria-pressed="true"]):hover { color: var(--fg); }
233
+
234
+ /* ── page header + tabs ── */
235
+ .bas-pagehead { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; padding: 24px 24px 12px; }
236
+ .bas-pagehead__eyebrow { font-family: var(--font-mono, ui-monospace, monospace); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--fg-subtle); margin-bottom: 4px; }
237
+ .bas-pagehead__title { margin: 0; font-size: 24px; font-weight: 600; letter-spacing: -.01em; }
238
+ .bas-pagehead__desc { margin: 6px 0 0; font-size: 13.5px; color: var(--fg-muted); }
239
+ .bas-pagehead__actions { display: flex; gap: 8px; flex: none; }
240
+ .bas-tabs { display: flex; gap: 4px; padding: 0 24px; border-bottom: 1px solid var(--border); overflow-x: auto; }
241
+ .bas-tab {
242
+ padding: 9px 12px; margin-bottom: -1px; border-bottom: 2px solid transparent; text-decoration: none;
243
+ font-size: 13px; color: var(--fg-muted); white-space: nowrap;
244
+ }
245
+ .bas-tab:hover { color: var(--fg); }
246
+ .bas-tab.is-active { color: var(--fg); border-bottom-color: var(--accent); font-weight: 500; }
247
+ @media (max-width: 767px) {
248
+ .bas-pagehead { padding: 16px 16px 8px; }
249
+ .bas-tabs { padding: 0 16px; }
250
+ }
package/dist/index.cjs ADDED
@@ -0,0 +1,204 @@
1
+ 'use strict';
2
+
3
+ // src/index.ts
4
+ function isActivePath(href, currentPath) {
5
+ const path = (currentPath.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
6
+ const target = (href.split(/[?#]/)[0] || "/").replace(/\/+$/, "") || "/";
7
+ if (target === "/") return path === "/";
8
+ return path === target || path.startsWith(target + "/");
9
+ }
10
+ function badgeLabel(n) {
11
+ if (!n || n <= 0) return null;
12
+ return n > 99 ? "99+" : String(n);
13
+ }
14
+ function readFlag(key, fallback) {
15
+ try {
16
+ const v = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
17
+ return v === null ? fallback : v === "1";
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
22
+ function writeFlag(key, value) {
23
+ try {
24
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, value ? "1" : "0");
25
+ } catch {
26
+ }
27
+ }
28
+ function readSet(key) {
29
+ try {
30
+ const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(key);
31
+ const v = raw ? JSON.parse(raw) : [];
32
+ return new Set(Array.isArray(v) ? v.filter((x) => typeof x === "string") : []);
33
+ } catch {
34
+ return /* @__PURE__ */ new Set();
35
+ }
36
+ }
37
+ function writeSet(key, value) {
38
+ try {
39
+ if (typeof localStorage !== "undefined") localStorage.setItem(key, JSON.stringify([...value]));
40
+ } catch {
41
+ }
42
+ }
43
+ function initials(name, email) {
44
+ const source = (name ?? "").trim() || (email ?? "").split("@")[0] || "";
45
+ const words = source.split(/[\s._-]+/).filter(Boolean);
46
+ if (words.length === 0) return "?";
47
+ const first = words[0][0] ?? "";
48
+ const last = words.length > 1 ? words[words.length - 1][0] ?? "" : "";
49
+ return (first + last).toUpperCase() || "?";
50
+ }
51
+ function createFetchNotificationSource(opts) {
52
+ const base = opts.url.replace(/\/+$/, "");
53
+ const doFetch = (input, init) => (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? "include", ...init });
54
+ const get = async () => {
55
+ const res = await doFetch(base, { headers: { accept: "application/json" } });
56
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
57
+ const body = await res.json().catch(() => null);
58
+ if (!body || !Array.isArray(body.items)) throw new Error("response has no items array");
59
+ const items = body.items;
60
+ const unseen = typeof body.unseen === "number" ? body.unseen : items.filter((n) => n.seenAt == null).length;
61
+ return { items, unseen };
62
+ };
63
+ const post = async (path, body) => {
64
+ const res = await doFetch(`${base}${path}`, {
65
+ method: "POST",
66
+ headers: body === void 0 ? void 0 : { "content-type": "application/json" },
67
+ body: body === void 0 ? void 0 : JSON.stringify(body)
68
+ });
69
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
70
+ const r = await res.json().catch(() => ({}));
71
+ return Array.isArray(r.clearedIds) ? r.clearedIds : [];
72
+ };
73
+ return {
74
+ loadRows: async () => (await get()).items,
75
+ countUnseen: async () => (await get()).unseen,
76
+ markSeen: (ids) => post("/seen", { ids }),
77
+ markAllSeen: () => post("/seen-all"),
78
+ subscribe(onChange) {
79
+ if (typeof window === "undefined") return () => {
80
+ };
81
+ const onVisible = () => {
82
+ if (document.visibilityState === "visible") onChange();
83
+ };
84
+ window.addEventListener("focus", onChange);
85
+ document.addEventListener("visibilitychange", onVisible);
86
+ const ms = opts.pollMs ?? 6e4;
87
+ const timer = ms > 0 ? setInterval(onChange, ms) : null;
88
+ return () => {
89
+ window.removeEventListener("focus", onChange);
90
+ document.removeEventListener("visibilitychange", onVisible);
91
+ if (timer) clearInterval(timer);
92
+ };
93
+ }
94
+ };
95
+ }
96
+ function createMemoryNotificationSource(initial = []) {
97
+ let rows = initial.map((n) => ({ ...n }));
98
+ const listeners = /* @__PURE__ */ new Set();
99
+ const emit = () => listeners.forEach((l) => l());
100
+ const mark = (pred) => {
101
+ const now = Date.now();
102
+ const cleared = [];
103
+ rows = rows.map((n) => {
104
+ if (n.seenAt == null && pred(n)) {
105
+ cleared.push(n.id);
106
+ return { ...n, seenAt: now };
107
+ }
108
+ return n;
109
+ });
110
+ if (cleared.length) emit();
111
+ return cleared;
112
+ };
113
+ return {
114
+ loadRows: async () => rows.map((n) => ({ ...n })),
115
+ countUnseen: async () => rows.filter((n) => n.seenAt == null).length,
116
+ markSeen: async (ids) => mark((n) => ids.includes(n.id)),
117
+ markAllSeen: async () => mark(() => true),
118
+ subscribe(onChange) {
119
+ listeners.add(onChange);
120
+ return () => listeners.delete(onChange);
121
+ },
122
+ push(n) {
123
+ rows = [n, ...rows];
124
+ emit();
125
+ },
126
+ rows: () => rows.map((n) => ({ ...n }))
127
+ };
128
+ }
129
+ function relativeTime(ts, lang, now = Date.now()) {
130
+ const ms = typeof ts === "number" ? ts : Date.parse(ts);
131
+ if (!Number.isFinite(ms)) return "";
132
+ const m = Math.floor((now - ms) / 6e4);
133
+ if (m < 1) return lang === "da" ? "nu" : "now";
134
+ if (m < 60) return `${m} min`;
135
+ const h = Math.floor(m / 60);
136
+ if (h < 24) return lang === "da" ? `${h} t` : `${h} h`;
137
+ const d = Math.floor(h / 24);
138
+ if (d === 1) return lang === "da" ? "i g\xE5r" : "yesterday";
139
+ return `${d} d`;
140
+ }
141
+ var TEXT = {
142
+ da: {
143
+ openMenu: "\xC5bn menu",
144
+ collapse: "Skjul sidemenu",
145
+ expand: "Vis sidemenu",
146
+ notifications: "Notifikationer",
147
+ markAllRead: "Mark\xE9r alle l\xE6st",
148
+ noNotifications: "Ingen notifikationer endnu.",
149
+ couldNotLoad: "Kunne ikke hente notifikationer.",
150
+ seeAll: "Vis alle",
151
+ close: "Luk",
152
+ account: "Konto",
153
+ signOut: "Log ud",
154
+ theme: "Tema",
155
+ light: "Lys",
156
+ dark: "M\xF8rk",
157
+ system: "System",
158
+ palette: "Palet",
159
+ surfaces: "Flader",
160
+ flat: "Flade",
161
+ layered: "Lag",
162
+ backdrop: "Baggrund",
163
+ neurons: "Neuroner",
164
+ plain: "Ren"
165
+ },
166
+ en: {
167
+ openMenu: "Open menu",
168
+ collapse: "Hide sidebar",
169
+ expand: "Show sidebar",
170
+ notifications: "Notifications",
171
+ markAllRead: "Mark all read",
172
+ noNotifications: "No notifications yet.",
173
+ couldNotLoad: "Could not load notifications.",
174
+ seeAll: "See all",
175
+ close: "Close",
176
+ account: "Account",
177
+ signOut: "Sign out",
178
+ theme: "Theme",
179
+ light: "Light",
180
+ dark: "Dark",
181
+ system: "System",
182
+ palette: "Palette",
183
+ surfaces: "Surfaces",
184
+ flat: "Flat",
185
+ layered: "Layered",
186
+ backdrop: "Backdrop",
187
+ neurons: "Neurons",
188
+ plain: "Plain"
189
+ }
190
+ };
191
+
192
+ exports.TEXT = TEXT;
193
+ exports.badgeLabel = badgeLabel;
194
+ exports.createFetchNotificationSource = createFetchNotificationSource;
195
+ exports.createMemoryNotificationSource = createMemoryNotificationSource;
196
+ exports.initials = initials;
197
+ exports.isActivePath = isActivePath;
198
+ exports.readFlag = readFlag;
199
+ exports.readSet = readSet;
200
+ exports.relativeTime = relativeTime;
201
+ exports.writeFlag = writeFlag;
202
+ exports.writeSet = writeSet;
203
+ //# sourceMappingURL=index.cjs.map
204
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/app-shell — the framework-free core of the fleet's app shell (F092).\n//\n// The shell every app in the fleet had been copying: sidebar + top bar +\n// notification bell + user menu. cardmem and trail each had one (\"ported from\n// trail\"), and helpdesk and the appkit starter would have made the third and\n// fourth. This file holds what does not depend on a framework: the navigation\n// data model, the notification contract and its sources, and the small rules\n// both the Preact and React parts must agree on.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template, not\n// an optional slot — so the contract and a default source live here, and a new\n// app gets a working notification centre by pointing one URL at its backend.\n\n// ── Navigation ─────────────────────────────────────────────────────────────\n\n/** One sidebar entry. `icon` is whatever your framework renders (an element). */\nexport interface NavItem<Icon = unknown> {\n id: string;\n label: string;\n href: string;\n icon?: Icon;\n /** A count shown on the item (99+ above 99). 0/undefined shows nothing. */\n badge?: number;\n}\nexport interface NavGroup<Icon = unknown> {\n label: string;\n items: NavItem<Icon>[];\n}\n\n/**\n * Is this item the current page? Exact match for \"/\", otherwise the path is the\n * item or below it (\"/board/123\" is on \"/board\", \"/boardroom\" is not).\n */\nexport function isActivePath(href: string, currentPath: string): boolean {\n const path = (currentPath.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n const target = (href.split(/[?#]/)[0] || \"/\").replace(/\\/+$/, \"\") || \"/\";\n if (target === \"/\") return path === \"/\";\n return path === target || path.startsWith(target + \"/\");\n}\n\n/** \"99+\" above 99, nothing for 0 or less. */\nexport function badgeLabel(n: number | undefined): string | null {\n if (!n || n <= 0) return null;\n return n > 99 ? \"99+\" : String(n);\n}\n\n// ── Persisted UI state ─────────────────────────────────────────────────────\n\n/** Read a boolean flag from localStorage; storage errors read as `fallback`. */\nexport function readFlag(key: string, fallback: boolean): boolean {\n try {\n const v = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n return v === null ? fallback : v === \"1\";\n } catch {\n return fallback;\n }\n}\nexport function writeFlag(key: string, value: boolean): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, value ? \"1\" : \"0\");\n } catch {\n /* private mode / quota: the UI still works, it just does not remember */\n }\n}\nexport function readSet(key: string): Set<string> {\n try {\n const raw = typeof localStorage === \"undefined\" ? null : localStorage.getItem(key);\n const v = raw ? (JSON.parse(raw) as unknown) : [];\n return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === \"string\") : []);\n } catch {\n return new Set();\n }\n}\nexport function writeSet(key: string, value: Set<string>): void {\n try {\n if (typeof localStorage !== \"undefined\") localStorage.setItem(key, JSON.stringify([...value]));\n } catch {\n /* see writeFlag */\n }\n}\n\n// ── Avatar ─────────────────────────────────────────────────────────────────\n\n/**\n * Two initials for the avatar fallback: first letter of the first and the last\n * word, upper-cased. One word gives one letter; nothing gives \"?\".\n *\n * There is deliberately no Gravatar here (owner decision D-1dc849): the avatar\n * is the Broberg ID `picture`, and without one, initials. Gravatar would send a\n * hash of the user's email to a US service on every render.\n */\nexport function initials(name: string | undefined, email?: string): string {\n const source = (name ?? \"\").trim() || (email ?? \"\").split(\"@\")[0] || \"\";\n const words = source.split(/[\\s._-]+/).filter(Boolean);\n if (words.length === 0) return \"?\";\n const first = words[0]![0] ?? \"\";\n const last = words.length > 1 ? words[words.length - 1]![0] ?? \"\" : \"\";\n return (first + last).toUpperCase() || \"?\";\n}\n\n// ── Notifications ──────────────────────────────────────────────────────────\n//\n// REUSE, NOT A SECOND CONTRACT. The fleet already agreed the notification shape\n// in @broberg/notifications (F074): `NotificationRow` — the same field names\n// @broberg/webpush sends, end to end — and the server core that owns the ONE\n// counting rule. The shell's bell renders that row and drives its panel with\n// that package's `createBellShell`, so a server built on `createNotifications`\n// and this bell speak the same language without an adapter.\n//\n// Christian, 4 Oct 2026: notifications are a STANDARD part of the template. So\n// a source, and a default one, ship here: point one URL at your backend.\n\nimport type { NotificationRow } from \"@broberg/notifications/types\";\nexport type { NotificationRow } from \"@broberg/notifications/types\";\n\n/** Where the bell gets its data. Swap it to point at any backend. */\nexport interface NotificationSource {\n /** The rows to show (newest first). */\n loadRows(): Promise<NotificationRow[]>;\n /** THE count — your server's, which may exclude muted kinds (F074's counting rule). */\n countUnseen(): Promise<number>;\n /** Returns the ids that actually transitioned to seen. */\n markSeen(ids: readonly string[]): Promise<string[]>;\n markAllSeen(): Promise<string[]>;\n /** Called with a callback that re-counts; return an unsubscribe. Optional. */\n subscribe?(onChange: () => void): () => void;\n}\n\n/**\n * The default source: a URL on your own backend, shaped after\n * @broberg/notifications' server core so `createNotifications` sits right\n * behind it.\n *\n * GET {url} → { items: NotificationRow[], unseen: number }\n * POST {url}/seen { ids: string[] } → { clearedIds: string[], count: number }\n * POST {url}/seen-all → { clearedIds: string[], count: number }\n *\n * It re-counts when the window regains focus or the tab becomes visible, and on\n * a timer (default 60 s). A failed GET THROWS — the bell shows «could not\n * load», never an empty list: \"could not fetch\" and \"nothing new\" must not look\n * the same.\n */\nexport function createFetchNotificationSource(opts: {\n url: string;\n fetch?: typeof fetch;\n /** Re-count interval in ms. 0 turns it off. Default 60 000. */\n pollMs?: number;\n credentials?: RequestCredentials;\n}): NotificationSource {\n const base = opts.url.replace(/\\/+$/, \"\");\n const doFetch = (input: string, init?: RequestInit) =>\n (opts.fetch ?? fetch)(input, { credentials: opts.credentials ?? \"include\", ...init });\n const get = async (): Promise<{ items: NotificationRow[]; unseen: number }> => {\n const res = await doFetch(base, { headers: { accept: \"application/json\" } });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const body = (await res.json().catch(() => null)) as { items?: unknown; unseen?: unknown } | null;\n if (!body || !Array.isArray(body.items)) throw new Error(\"response has no items array\");\n const items = body.items as NotificationRow[];\n const unseen = typeof body.unseen === \"number\" ? body.unseen : items.filter((n) => n.seenAt == null).length;\n return { items, unseen };\n };\n const post = async (path: string, body?: unknown): Promise<string[]> => {\n const res = await doFetch(`${base}${path}`, {\n method: \"POST\",\n headers: body === undefined ? undefined : { \"content-type\": \"application/json\" },\n body: body === undefined ? undefined : JSON.stringify(body),\n });\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const r = (await res.json().catch(() => ({}))) as { clearedIds?: unknown };\n return Array.isArray(r.clearedIds) ? (r.clearedIds as string[]) : [];\n };\n return {\n loadRows: async () => (await get()).items,\n countUnseen: async () => (await get()).unseen,\n markSeen: (ids) => post(\"/seen\", { ids }),\n markAllSeen: () => post(\"/seen-all\"),\n subscribe(onChange) {\n if (typeof window === \"undefined\") return () => {};\n const onVisible = () => {\n if (document.visibilityState === \"visible\") onChange();\n };\n window.addEventListener(\"focus\", onChange);\n document.addEventListener(\"visibilitychange\", onVisible);\n const ms = opts.pollMs ?? 60_000;\n const timer = ms > 0 ? setInterval(onChange, ms) : null;\n return () => {\n window.removeEventListener(\"focus\", onChange);\n document.removeEventListener(\"visibilitychange\", onVisible);\n if (timer) clearInterval(timer);\n };\n },\n };\n}\n\n/** An in-memory source for demos and tests. */\nexport function createMemoryNotificationSource(initial: NotificationRow[] = []): NotificationSource & {\n push(n: NotificationRow): void;\n rows(): NotificationRow[];\n} {\n let rows = initial.map((n) => ({ ...n }));\n const listeners = new Set<() => void>();\n const emit = () => listeners.forEach((l) => l());\n const mark = (pred: (n: NotificationRow) => boolean): string[] => {\n const now = Date.now();\n const cleared: string[] = [];\n rows = rows.map((n) => {\n if (n.seenAt == null && pred(n)) {\n cleared.push(n.id);\n return { ...n, seenAt: now };\n }\n return n;\n });\n if (cleared.length) emit();\n return cleared;\n };\n return {\n loadRows: async () => rows.map((n) => ({ ...n })),\n countUnseen: async () => rows.filter((n) => n.seenAt == null).length,\n markSeen: async (ids) => mark((n) => ids.includes(n.id)),\n markAllSeen: async () => mark(() => true),\n subscribe(onChange) {\n listeners.add(onChange);\n return () => listeners.delete(onChange);\n },\n push(n) {\n rows = [n, ...rows];\n emit();\n },\n rows: () => rows.map((n) => ({ ...n })),\n };\n}\n\n/** \"nu\", \"5 min\", \"3 t\", \"i går\", \"4 d\" (da) or the English equivalents. */\nexport function relativeTime(ts: string | number, lang: \"da\" | \"en\", now = Date.now()): string {\n const ms = typeof ts === \"number\" ? ts : Date.parse(ts);\n if (!Number.isFinite(ms)) return \"\";\n const m = Math.floor((now - ms) / 60_000);\n if (m < 1) return lang === \"da\" ? \"nu\" : \"now\";\n if (m < 60) return `${m} min`;\n const h = Math.floor(m / 60);\n if (h < 24) return lang === \"da\" ? `${h} t` : `${h} h`;\n const d = Math.floor(h / 24);\n if (d === 1) return lang === \"da\" ? \"i går\" : \"yesterday\";\n return `${d} d`;\n}\n\n// ── Text ───────────────────────────────────────────────────────────────────\n\nexport const TEXT = {\n da: {\n openMenu: \"Åbn menu\",\n collapse: \"Skjul sidemenu\",\n expand: \"Vis sidemenu\",\n notifications: \"Notifikationer\",\n markAllRead: \"Markér alle læst\",\n noNotifications: \"Ingen notifikationer endnu.\",\n couldNotLoad: \"Kunne ikke hente notifikationer.\",\n seeAll: \"Vis alle\",\n close: \"Luk\",\n account: \"Konto\",\n signOut: \"Log ud\",\n theme: \"Tema\",\n light: \"Lys\",\n dark: \"Mørk\",\n system: \"System\",\n palette: \"Palet\",\n surfaces: \"Flader\",\n flat: \"Flade\",\n layered: \"Lag\",\n backdrop: \"Baggrund\",\n neurons: \"Neuroner\",\n plain: \"Ren\",\n },\n en: {\n openMenu: \"Open menu\",\n collapse: \"Hide sidebar\",\n expand: \"Show sidebar\",\n notifications: \"Notifications\",\n markAllRead: \"Mark all read\",\n noNotifications: \"No notifications yet.\",\n couldNotLoad: \"Could not load notifications.\",\n seeAll: \"See all\",\n close: \"Close\",\n account: \"Account\",\n signOut: \"Sign out\",\n theme: \"Theme\",\n light: \"Light\",\n dark: \"Dark\",\n system: \"System\",\n palette: \"Palette\",\n surfaces: \"Surfaces\",\n flat: \"Flat\",\n layered: \"Layered\",\n backdrop: \"Backdrop\",\n neurons: \"Neurons\",\n plain: \"Plain\",\n },\n} as const;\nexport type Lang = keyof typeof TEXT;\n"]}