@broberg/app-shell 0.2.2 → 0.3.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
@@ -21,6 +21,7 @@ const notifications = createFetchNotificationSource({ url: "/api/notifications"
21
21
  notifications={notifications}
22
22
  user={{ name, email, picture }} // picture = Broberg ID's OIDC `picture`
23
23
  userMenu={{ accountHref: "https://id.broberg.ai/account", onSignOut }}
24
+ layout="inset" // default; "topbar" = the 0.2.x full-width bar
24
25
  mobile="drawer" // or "rail"
25
26
  collapse="icon" // desktop fold: "offcanvas" (default) or an icon rail
26
27
  brandMark={<LogoIcon />} // top of the icon rail
@@ -49,6 +50,29 @@ the user menu reads and writes theme, palette, surfaces and backdrop through it.
49
50
  | `PageHeader`, `PageTabs` | Eyebrow/title/description/actions; tabs as routes (`?tab=` or one path per tab). |
50
51
  | `Avatar` | `picture` when given, otherwise initials. |
51
52
 
53
+ ## 0.3.0 — inset layout by default, links that leave open a new tab, a language row (F092.8, .10, .11)
54
+
55
+ **Breaking (visual):** `layout` now defaults to **`"inset"`**, shadcn
56
+ dashboard-01's layout: no full-width top bar. The brand heads the sidebar (and
57
+ folds away with it), and the content's top row is
58
+
59
+ `[sidebar button] | [title] ……… [actions] [bell] [user menu]`
60
+
61
+ The 0.2.x bar is `layout="topbar"`. The bell and user menu keep their testids
62
+ (`topbar-notifications`, `topbar-user-menu`) in both layouts — the names are
63
+ historical — and in inset the sidebar's brand link is `brand-home`.
64
+
65
+ - **External links open in a new tab** (D-d28547): every link the shell renders
66
+ goes through one place, so an http(s) link to another origin — the account
67
+ page at Broberg ID, a sidebar item, a menu item, a notification — gets
68
+ `target="_blank" rel="noopener noreferrer"` and never reaches your router.
69
+ Internal links (`/account` included) go through `onNavigate` as before.
70
+ `isExternal(href)` is exported.
71
+ - **Language:** `userMenu.language = { value, options: [{ id, label }], onChange }`
72
+ adds a «Sprog»/«Language» row with the same segmented control as Theme and
73
+ Palette. The app owns the language; the shell shows and reports it. Arrow keys
74
+ move between the options in every segmented row.
75
+
52
76
  ## 0.2.0 — one sidebar button, sub-items, an icon rail, the logo goes home (F092.4–.7)
53
77
 
54
78
  **Breaking:** the three sidebar buttons are now ONE. `sidebar-collapse` («<» in
@@ -117,6 +141,13 @@ followed; `javascript:`, `data:` and anything else is refused, and the click onl
117
141
  marks the row seen. `safeNavigate(target)` is exported if you route links yourself.
118
142
  0.1.0 followed any value — upgrade.
119
143
 
144
+ ### The bell panel closes itself (0.2.3)
145
+
146
+ «Markér alle læst» closes the panel when it worked and leaves it open with an
147
+ error when it did not (`notifications-mark-all-error`) — a panel that closed on a
148
+ failure would hide that nothing was marked. A click anywhere outside the bell and
149
+ its panel closes it, and Escape closes it and puts focus back on the bell.
150
+
120
151
  ## `lang` is required
121
152
 
122
153
  Every component that shows text takes `lang: "da" | "en"` with no default — the
package/css/app-shell.css CHANGED
@@ -70,7 +70,7 @@
70
70
  .bas-sidebar__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 10px 4px 12px; min-height: 44px; }
71
71
  .bas-sidebar__brand { min-width: 0; }
72
72
  /* drawer mode: on desktop the brand already sits in the top bar — show it in the sidebar only on a phone */
73
- .bas-root[data-mobile="drawer"] .bas-sidebar__brand { display: none; }
73
+ .bas-root[data-layout="topbar"][data-mobile="drawer"] .bas-sidebar__brand { display: none; }
74
74
  .bas-sidebar__mark { display: none; }
75
75
  .bas-home { display: inline-flex; align-items: center; gap: 8px; color: inherit; text-decoration: none; border-radius: var(--bas-radius); }
76
76
  .bas-home:hover { opacity: .85; }
@@ -152,6 +152,16 @@
152
152
  border-bottom: 1px solid var(--border);
153
153
  }
154
154
  .bas-trigger { width: 28px; height: 28px; margin-left: -4px; }
155
+
156
+ /* ── layout="inset" (F092.8, shadcn dashboard-01): no full-width bar ── */
157
+ .bas-root[data-layout="inset"] { --bas-top: 0px; }
158
+ .bas-root[data-layout="inset"] .bas-contenthead {
159
+ position: sticky; top: 0; z-index: 30; height: 56px; gap: 8px;
160
+ background: color-mix(in srgb, var(--surface-header, var(--bg)) 85%, transparent);
161
+ backdrop-filter: blur(8px);
162
+ }
163
+ .bas-root[data-layout="inset"] .bas-sidebar__head { min-height: 56px; padding: 8px 12px; }
164
+ .bas-contenthead__actions { display: flex; align-items: center; gap: 8px; min-width: 0; }
155
165
  .bas-contenthead__sep { flex: none; width: 1px; height: 16px; margin: 0 8px; background: var(--border); }
156
166
  .bas-contenthead__title { margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; font-weight: 500; color: var(--fg); }
157
167
 
@@ -171,7 +181,7 @@
171
181
  transform: translateX(0); visibility: visible; transition: transform var(--bas-dur) var(--bas-ease);
172
182
  box-shadow: 0 24px 56px rgba(0, 0, 0, .22);
173
183
  }
174
- .bas-root[data-mobile="drawer"] .bas-sidebar__brand { display: block; }
184
+ .bas-root[data-layout="topbar"][data-mobile="drawer"] .bas-sidebar__brand { display: block; }
175
185
  .bas-backdrop.is-open { display: block; position: fixed; inset: 0; z-index: 55; background: rgba(0, 0, 0, .36); }
176
186
 
177
187
  /* rail: always the 60px icon rail on a phone, never a drawer (styling via .is-railed) */
@@ -202,7 +212,7 @@
202
212
  background: var(--bg-card); color: var(--fg);
203
213
  }
204
214
  .bas-panel--drop {
205
- top: calc(var(--bas-top) - 4px); right: 16px; width: 392px; max-width: 92vw; max-height: 560px;
215
+ top: calc(var(--bas-bar, 56px) - 4px); right: 16px; width: 392px; max-width: 92vw; max-height: 560px;
206
216
  border: 1px solid var(--border); border-radius: calc(var(--bas-radius) + 4px);
207
217
  box-shadow: 0 24px 56px rgba(0, 0, 0, .18);
208
218
  }
package/dist/index.cjs CHANGED
@@ -18,6 +18,18 @@ function safeNavigate(target) {
18
18
  return null;
19
19
  }
20
20
  }
21
+ function isExternal(href, origin = typeof location !== "undefined" ? location.origin : void 0) {
22
+ if (typeof href !== "string") return false;
23
+ const h = href.trim();
24
+ if (!origin) return /^(https?:)?\/\//i.test(h);
25
+ let u;
26
+ try {
27
+ u = new URL(h, origin);
28
+ } catch {
29
+ return false;
30
+ }
31
+ return (u.protocol === "http:" || u.protocol === "https:") && u.origin !== origin;
32
+ }
21
33
  function activeNavLabel(groups, footer, currentPath) {
22
34
  let best;
23
35
  const visit = (it) => {
@@ -167,6 +179,8 @@ var TEXT = {
167
179
  openMenu: "\xC5bn menu",
168
180
  closeMenu: "Luk menu",
169
181
  home: "Forside",
182
+ language: "Sprog",
183
+ couldNotMarkAll: "Kunne ikke markere alle som l\xE6st. Pr\xF8v igen.",
170
184
  collapse: "Skjul sidemenu",
171
185
  expand: "Vis sidemenu",
172
186
  notifications: "Notifikationer",
@@ -193,6 +207,8 @@ var TEXT = {
193
207
  openMenu: "Open menu",
194
208
  closeMenu: "Close menu",
195
209
  home: "Home",
210
+ language: "Language",
211
+ couldNotMarkAll: "Could not mark all as read. Try again.",
196
212
  collapse: "Hide sidebar",
197
213
  expand: "Show sidebar",
198
214
  notifications: "Notifications",
@@ -224,6 +240,7 @@ exports.createFetchNotificationSource = createFetchNotificationSource;
224
240
  exports.createMemoryNotificationSource = createMemoryNotificationSource;
225
241
  exports.initials = initials;
226
242
  exports.isActivePath = isActivePath;
243
+ exports.isExternal = isExternal;
227
244
  exports.readFlag = readFlag;
228
245
  exports.readSet = readSet;
229
246
  exports.relativeTime = relativeTime;
@@ -1 +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 /**\n * Sub-items, ONE level (F092.5). An item with children is a toggle that opens\n * and closes them, not a link; its own `href` is used only to decide whether\n * it is on the current path.\n */\n children?: NavItem<Icon>[];\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/**\n * A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.\n * `javascript:` there runs code in the app, so only a same-site path or an\n * http(s) URL is followed; anything else returns null and the row just closes.\n */\nexport function safeNavigate(target: string | null | undefined): string | null {\n if (typeof target !== \"string\") return null;\n const t = target.trim();\n if (t.startsWith(\"/\") && !t.startsWith(\"//\") && !t.startsWith(\"/\\\\\")) return t;\n try {\n const u = new URL(t);\n return u.protocol === \"https:\" || u.protocol === \"http:\" ? u.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The label of the nav item the user is on, for the content header (F092.4).\n * Sub-items count, and the most specific match wins: on \"/models/genesis\" a\n * \"Genesis\" child beats its \"Models\" parent.\n */\nexport function activeNavLabel<Icon>(\n groups: NavGroup<Icon>[],\n footer: NavItem<Icon>[] | undefined,\n currentPath: string,\n): string | undefined {\n let best: { label: string; len: number } | undefined;\n const visit = (it: NavItem<Icon>) => {\n if (it.href && isActivePath(it.href, currentPath)) {\n const len = it.href.length;\n if (!best || len > best.len) best = { label: it.label, len };\n }\n it.children?.forEach(visit);\n };\n for (const g of groups) g.items.forEach(visit);\n footer?.forEach(visit);\n return best?.label;\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 closeMenu: \"Luk menu\",\n home: \"Forside\",\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 closeMenu: \"Close menu\",\n home: \"Home\",\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"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AAuCO,SAAS,YAAA,CAAa,MAAc,WAAA,EAA8B;AACvE,EAAA,MAAM,IAAA,GAAA,CAAQ,WAAA,CAAY,KAAA,CAAM,MAAM,CAAA,CAAE,CAAC,CAAA,IAAK,GAAA,EAAK,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAA,IAAK,GAAA;AAC1E,EAAA,MAAM,MAAA,GAAA,CAAU,IAAA,CAAK,KAAA,CAAM,MAAM,CAAA,CAAE,CAAC,CAAA,IAAK,GAAA,EAAK,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAA,IAAK,GAAA;AACrE,EAAA,IAAI,MAAA,KAAW,GAAA,EAAK,OAAO,IAAA,KAAS,GAAA;AACpC,EAAA,OAAO,IAAA,KAAS,MAAA,IAAU,IAAA,CAAK,UAAA,CAAW,SAAS,GAAG,CAAA;AACxD;AAOO,SAAS,aAAa,MAAA,EAAkD;AAC7E,EAAA,IAAI,OAAO,MAAA,KAAW,QAAA,EAAU,OAAO,IAAA;AACvC,EAAA,MAAM,CAAA,GAAI,OAAO,IAAA,EAAK;AACtB,EAAA,IAAI,CAAA,CAAE,UAAA,CAAW,GAAG,CAAA,IAAK,CAAC,CAAA,CAAE,UAAA,CAAW,IAAI,CAAA,IAAK,CAAC,CAAA,CAAE,UAAA,CAAW,KAAK,GAAG,OAAO,CAAA;AAC7E,EAAA,IAAI;AACF,IAAA,MAAM,CAAA,GAAI,IAAI,GAAA,CAAI,CAAC,CAAA;AACnB,IAAA,OAAO,EAAE,QAAA,KAAa,QAAA,IAAY,EAAE,QAAA,KAAa,OAAA,GAAU,EAAE,IAAA,GAAO,IAAA;AAAA,EACtE,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,IAAA;AAAA,EACT;AACF;AAOO,SAAS,UAAA,CACd,MACA,MAAA,GAA6B,OAAO,aAAa,WAAA,GAAc,QAAA,CAAS,SAAS,MAAA,EACxE;AACT,EAAA,IAAI,OAAO,IAAA,KAAS,QAAA,EAAU,OAAO,KAAA;AACrC,EAAA,MAAM,CAAA,GAAI,KAAK,IAAA,EAAK;AACpB,EAAA,IAAI,CAAC,MAAA,EAAQ,OAAO,kBAAA,CAAmB,KAAK,CAAC,CAAA;AAC7C,EAAA,IAAI,CAAA;AACJ,EAAA,IAAI;AACF,IAAA,CAAA,GAAI,IAAI,GAAA,CAAI,CAAA,EAAG,MAAM,CAAA;AAAA,EACvB,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,KAAA;AAAA,EACT;AACA,EAAA,OAAA,CAAQ,EAAE,QAAA,KAAa,OAAA,IAAW,EAAE,QAAA,KAAa,QAAA,KAAa,EAAE,MAAA,KAAW,MAAA;AAC7E;AAOO,SAAS,cAAA,CACd,MAAA,EACA,MAAA,EACA,WAAA,EACoB;AACpB,EAAA,IAAI,IAAA;AACJ,EAAA,MAAM,KAAA,GAAQ,CAAC,EAAA,KAAsB;AACnC,IAAA,IAAI,GAAG,IAAA,IAAQ,YAAA,CAAa,EAAA,CAAG,IAAA,EAAM,WAAW,CAAA,EAAG;AACjD,MAAA,MAAM,GAAA,GAAM,GAAG,IAAA,CAAK,MAAA;AACpB,MAAA,IAAI,CAAC,IAAA,IAAQ,GAAA,GAAM,IAAA,CAAK,GAAA,SAAY,EAAE,KAAA,EAAO,EAAA,CAAG,KAAA,EAAO,GAAA,EAAI;AAAA,IAC7D;AACA,IAAA,EAAA,CAAG,QAAA,EAAU,QAAQ,KAAK,CAAA;AAAA,EAC5B,CAAA;AACA,EAAA,KAAA,MAAW,CAAA,IAAK,MAAA,EAAQ,CAAA,CAAE,KAAA,CAAM,QAAQ,KAAK,CAAA;AAC7C,EAAA,MAAA,EAAQ,QAAQ,KAAK,CAAA;AACrB,EAAA,OAAO,IAAA,EAAM,KAAA;AACf;AAGO,SAAS,WAAW,CAAA,EAAsC;AAC/D,EAAA,IAAI,CAAC,CAAA,IAAK,CAAA,IAAK,CAAA,EAAG,OAAO,IAAA;AACzB,EAAA,OAAO,CAAA,GAAI,EAAA,GAAK,KAAA,GAAQ,MAAA,CAAO,CAAC,CAAA;AAClC;AAKO,SAAS,QAAA,CAAS,KAAa,QAAA,EAA4B;AAChE,EAAA,IAAI;AACF,IAAA,MAAM,IAAI,OAAO,YAAA,KAAiB,cAAc,IAAA,GAAO,YAAA,CAAa,QAAQ,GAAG,CAAA;AAC/E,IAAA,OAAO,CAAA,KAAM,IAAA,GAAO,QAAA,GAAW,CAAA,KAAM,GAAA;AAAA,EACvC,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,QAAA;AAAA,EACT;AACF;AACO,SAAS,SAAA,CAAU,KAAa,KAAA,EAAsB;AAC3D,EAAA,IAAI;AACF,IAAA,IAAI,OAAO,iBAAiB,WAAA,EAAa,YAAA,CAAa,QAAQ,GAAA,EAAK,KAAA,GAAQ,MAAM,GAAG,CAAA;AAAA,EACtF,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AACO,SAAS,QAAQ,GAAA,EAA0B;AAChD,EAAA,IAAI;AACF,IAAA,MAAM,MAAM,OAAO,YAAA,KAAiB,cAAc,IAAA,GAAO,YAAA,CAAa,QAAQ,GAAG,CAAA;AACjF,IAAA,MAAM,IAAI,GAAA,GAAO,IAAA,CAAK,KAAA,CAAM,GAAG,IAAgB,EAAC;AAChD,IAAA,OAAO,IAAI,GAAA,CAAI,KAAA,CAAM,OAAA,CAAQ,CAAC,CAAA,GAAI,CAAA,CAAE,MAAA,CAAO,CAAC,MAAmB,OAAO,CAAA,KAAM,QAAQ,CAAA,GAAI,EAAE,CAAA;AAAA,EAC5F,CAAA,CAAA,MAAQ;AACN,IAAA,2BAAW,GAAA,EAAI;AAAA,EACjB;AACF;AACO,SAAS,QAAA,CAAS,KAAa,KAAA,EAA0B;AAC9D,EAAA,IAAI;AACF,IAAA,IAAI,OAAO,YAAA,KAAiB,WAAA,EAAa,YAAA,CAAa,OAAA,CAAQ,GAAA,EAAK,IAAA,CAAK,SAAA,CAAU,CAAC,GAAG,KAAK,CAAC,CAAC,CAAA;AAAA,EAC/F,CAAA,CAAA,MAAQ;AAAA,EAER;AACF;AAYO,SAAS,QAAA,CAAS,MAA0B,KAAA,EAAwB;AACzE,EAAA,MAAM,MAAA,GAAA,CAAU,IAAA,IAAQ,EAAA,EAAI,IAAA,EAAK,IAAA,CAAM,KAAA,IAAS,EAAA,EAAI,KAAA,CAAM,GAAG,CAAA,CAAE,CAAC,CAAA,IAAK,EAAA;AACrE,EAAA,MAAM,QAAQ,MAAA,CAAO,KAAA,CAAM,UAAU,CAAA,CAAE,OAAO,OAAO,CAAA;AACrD,EAAA,IAAI,KAAA,CAAM,MAAA,KAAW,CAAA,EAAG,OAAO,GAAA;AAC/B,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,CAAC,CAAA,CAAG,CAAC,CAAA,IAAK,EAAA;AAC9B,EAAA,MAAM,IAAA,GAAO,KAAA,CAAM,MAAA,GAAS,CAAA,GAAI,KAAA,CAAM,KAAA,CAAM,MAAA,GAAS,CAAC,CAAA,CAAG,CAAC,CAAA,IAAK,EAAA,GAAK,EAAA;AACpE,EAAA,OAAA,CAAQ,KAAA,GAAQ,IAAA,EAAM,WAAA,EAAY,IAAK,GAAA;AACzC;AA4CO,SAAS,8BAA8B,IAAA,EAMvB;AACrB,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,OAAA,CAAQ,QAAQ,EAAE,CAAA;AACxC,EAAA,MAAM,OAAA,GAAU,CAAC,KAAA,EAAe,IAAA,KAAA,CAC7B,KAAK,KAAA,IAAS,KAAA,EAAO,KAAA,EAAO,EAAE,aAAa,IAAA,CAAK,WAAA,IAAe,SAAA,EAAW,GAAG,MAAM,CAAA;AACtF,EAAA,MAAM,MAAM,YAAmE;AAC7E,IAAA,MAAM,GAAA,GAAM,MAAM,OAAA,CAAQ,IAAA,EAAM,EAAE,SAAS,EAAE,MAAA,EAAQ,kBAAA,EAAmB,EAAG,CAAA;AAC3E,IAAA,IAAI,CAAC,IAAI,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,CAAA,KAAA,EAAQ,GAAA,CAAI,MAAM,CAAA,CAAE,CAAA;AACjD,IAAA,MAAM,OAAQ,MAAM,GAAA,CAAI,MAAK,CAAE,KAAA,CAAM,MAAM,IAAI,CAAA;AAC/C,IAAA,IAAI,CAAC,IAAA,IAAQ,CAAC,KAAA,CAAM,OAAA,CAAQ,IAAA,CAAK,KAAK,CAAA,EAAG,MAAM,IAAI,KAAA,CAAM,6BAA6B,CAAA;AACtF,IAAA,MAAM,QAAQ,IAAA,CAAK,KAAA;AACnB,IAAA,MAAM,MAAA,GAAS,OAAO,IAAA,CAAK,MAAA,KAAW,WAAW,IAAA,CAAK,MAAA,GAAS,KAAA,CAAM,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,MAAA,IAAU,IAAI,CAAA,CAAE,MAAA;AACrG,IAAA,OAAO,EAAE,OAAO,MAAA,EAAO;AAAA,EACzB,CAAA;AACA,EAAA,MAAM,IAAA,GAAO,OAAO,IAAA,EAAc,IAAA,KAAsC;AACtE,IAAA,MAAM,MAAM,MAAM,OAAA,CAAQ,GAAG,IAAI,CAAA,EAAG,IAAI,CAAA,CAAA,EAAI;AAAA,MAC1C,MAAA,EAAQ,MAAA;AAAA,MACR,SAAS,IAAA,KAAS,MAAA,GAAY,MAAA,GAAY,EAAE,gBAAgB,kBAAA,EAAmB;AAAA,MAC/E,MAAM,IAAA,KAAS,MAAA,GAAY,MAAA,GAAY,IAAA,CAAK,UAAU,IAAI;AAAA,KAC3D,CAAA;AACD,IAAA,IAAI,CAAC,IAAI,EAAA,EAAI,MAAM,IAAI,KAAA,CAAM,CAAA,KAAA,EAAQ,GAAA,CAAI,MAAM,CAAA,CAAE,CAAA;AACjD,IAAA,MAAM,CAAA,GAAK,MAAM,GAAA,CAAI,IAAA,GAAO,KAAA,CAAM,OAAO,EAAC,CAAE,CAAA;AAC5C,IAAA,OAAO,MAAM,OAAA,CAAQ,CAAA,CAAE,UAAU,CAAA,GAAK,CAAA,CAAE,aAA0B,EAAC;AAAA,EACrE,CAAA;AACA,EAAA,OAAO;AAAA,IACL,QAAA,EAAU,YAAA,CAAa,MAAM,GAAA,EAAI,EAAG,KAAA;AAAA,IACpC,WAAA,EAAa,YAAA,CAAa,MAAM,GAAA,EAAI,EAAG,MAAA;AAAA,IACvC,UAAU,CAAC,GAAA,KAAQ,KAAK,OAAA,EAAS,EAAE,KAAK,CAAA;AAAA,IACxC,WAAA,EAAa,MAAM,IAAA,CAAK,WAAW,CAAA;AAAA,IACnC,UAAU,QAAA,EAAU;AAClB,MAAA,IAAI,OAAO,MAAA,KAAW,WAAA,EAAa,OAAO,MAAM;AAAA,MAAC,CAAA;AACjD,MAAA,MAAM,YAAY,MAAM;AACtB,QAAA,IAAI,QAAA,CAAS,eAAA,KAAoB,SAAA,EAAW,QAAA,EAAS;AAAA,MACvD,CAAA;AACA,MAAA,MAAA,CAAO,gBAAA,CAAiB,SAAS,QAAQ,CAAA;AACzC,MAAA,QAAA,CAAS,gBAAA,CAAiB,oBAAoB,SAAS,CAAA;AACvD,MAAA,MAAM,EAAA,GAAK,KAAK,MAAA,IAAU,GAAA;AAC1B,MAAA,MAAM,QAAQ,EAAA,GAAK,CAAA,GAAI,WAAA,CAAY,QAAA,EAAU,EAAE,CAAA,GAAI,IAAA;AACnD,MAAA,OAAO,MAAM;AACX,QAAA,MAAA,CAAO,mBAAA,CAAoB,SAAS,QAAQ,CAAA;AAC5C,QAAA,QAAA,CAAS,mBAAA,CAAoB,oBAAoB,SAAS,CAAA;AAC1D,QAAA,IAAI,KAAA,gBAAqB,KAAK,CAAA;AAAA,MAChC,CAAA;AAAA,IACF;AAAA,GACF;AACF;AAGO,SAAS,8BAAA,CAA+B,OAAA,GAA6B,EAAC,EAG3E;AACA,EAAA,IAAI,IAAA,GAAO,QAAQ,GAAA,CAAI,CAAC,OAAO,EAAE,GAAG,GAAE,CAAE,CAAA;AACxC,EAAA,MAAM,SAAA,uBAAgB,GAAA,EAAgB;AACtC,EAAA,MAAM,OAAO,MAAM,SAAA,CAAU,QAAQ,CAAC,CAAA,KAAM,GAAG,CAAA;AAC/C,EAAA,MAAM,IAAA,GAAO,CAAC,IAAA,KAAoD;AAChE,IAAA,MAAM,GAAA,GAAM,KAAK,GAAA,EAAI;AACrB,IAAA,MAAM,UAAoB,EAAC;AAC3B,IAAA,IAAA,GAAO,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,KAAM;AACrB,MAAA,IAAI,CAAA,CAAE,MAAA,IAAU,IAAA,IAAQ,IAAA,CAAK,CAAC,CAAA,EAAG;AAC/B,QAAA,OAAA,CAAQ,IAAA,CAAK,EAAE,EAAE,CAAA;AACjB,QAAA,OAAO,EAAE,GAAG,CAAA,EAAG,MAAA,EAAQ,GAAA,EAAI;AAAA,MAC7B;AACA,MAAA,OAAO,CAAA;AAAA,IACT,CAAC,CAAA;AACD,IAAA,IAAI,OAAA,CAAQ,QAAQ,IAAA,EAAK;AACzB,IAAA,OAAO,OAAA;AAAA,EACT,CAAA;AACA,EAAA,OAAO;AAAA,IACL,QAAA,EAAU,YAAY,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,MAAO,EAAE,GAAG,CAAA,EAAE,CAAE,CAAA;AAAA,IAChD,WAAA,EAAa,YAAY,IAAA,CAAK,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,MAAA,IAAU,IAAI,CAAA,CAAE,MAAA;AAAA,IAC9D,QAAA,EAAU,OAAO,GAAA,KAAQ,IAAA,CAAK,CAAC,MAAM,GAAA,CAAI,QAAA,CAAS,CAAA,CAAE,EAAE,CAAC,CAAA;AAAA,IACvD,WAAA,EAAa,YAAY,IAAA,CAAK,MAAM,IAAI,CAAA;AAAA,IACxC,UAAU,QAAA,EAAU;AAClB,MAAA,SAAA,CAAU,IAAI,QAAQ,CAAA;AACtB,MAAA,OAAO,MAAM,SAAA,CAAU,MAAA,CAAO,QAAQ,CAAA;AAAA,IACxC,CAAA;AAAA,IACA,KAAK,CAAA,EAAG;AACN,MAAA,IAAA,GAAO,CAAC,CAAA,EAAG,GAAG,IAAI,CAAA;AAClB,MAAA,IAAA,EAAK;AAAA,IACP,CAAA;AAAA,IACA,IAAA,EAAM,MAAM,IAAA,CAAK,GAAA,CAAI,CAAC,CAAA,MAAO,EAAE,GAAG,CAAA,EAAE,CAAE;AAAA,GACxC;AACF;AAGO,SAAS,aAAa,EAAA,EAAqB,IAAA,EAAmB,GAAA,GAAM,IAAA,CAAK,KAAI,EAAW;AAC7F,EAAA,MAAM,KAAK,OAAO,EAAA,KAAO,WAAW,EAAA,GAAK,IAAA,CAAK,MAAM,EAAE,CAAA;AACtD,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,EAAE,GAAG,OAAO,EAAA;AACjC,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAA,CAAO,GAAA,GAAM,MAAM,GAAM,CAAA;AACxC,EAAA,IAAI,CAAA,GAAI,CAAA,EAAG,OAAO,IAAA,KAAS,OAAO,IAAA,GAAO,KAAA;AACzC,EAAA,IAAI,CAAA,GAAI,EAAA,EAAI,OAAO,CAAA,EAAG,CAAC,CAAA,IAAA,CAAA;AACvB,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,CAAA,GAAI,EAAE,CAAA;AAC3B,EAAA,IAAI,CAAA,GAAI,IAAI,OAAO,IAAA,KAAS,OAAO,CAAA,EAAG,CAAC,CAAA,EAAA,CAAA,GAAO,CAAA,EAAG,CAAC,CAAA,EAAA,CAAA;AAClD,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,KAAA,CAAM,CAAA,GAAI,EAAE,CAAA;AAC3B,EAAA,IAAI,CAAA,KAAM,CAAA,EAAG,OAAO,IAAA,KAAS,OAAO,UAAA,GAAU,WAAA;AAC9C,EAAA,OAAO,GAAG,CAAC,CAAA,EAAA,CAAA;AACb;AAIO,IAAM,IAAA,GAAO;AAAA,EAClB,EAAA,EAAI;AAAA,IACF,QAAA,EAAU,aAAA;AAAA,IACV,SAAA,EAAW,UAAA;AAAA,IACX,IAAA,EAAM,SAAA;AAAA,IACN,QAAA,EAAU,OAAA;AAAA,IACV,eAAA,EAAiB,oDAAA;AAAA,IACjB,QAAA,EAAU,gBAAA;AAAA,IACV,MAAA,EAAQ,cAAA;AAAA,IACR,aAAA,EAAe,gBAAA;AAAA,IACf,WAAA,EAAa,wBAAA;AAAA,IACb,eAAA,EAAiB,6BAAA;AAAA,IACjB,YAAA,EAAc,kCAAA;AAAA,IACd,MAAA,EAAQ,UAAA;AAAA,IACR,KAAA,EAAO,KAAA;AAAA,IACP,OAAA,EAAS,OAAA;AAAA,IACT,OAAA,EAAS,QAAA;AAAA,IACT,KAAA,EAAO,MAAA;AAAA,IACP,KAAA,EAAO,KAAA;AAAA,IACP,IAAA,EAAM,SAAA;AAAA,IACN,MAAA,EAAQ,QAAA;AAAA,IACR,OAAA,EAAS,OAAA;AAAA,IACT,QAAA,EAAU,QAAA;AAAA,IACV,IAAA,EAAM,OAAA;AAAA,IACN,OAAA,EAAS,KAAA;AAAA,IACT,QAAA,EAAU,UAAA;AAAA,IACV,OAAA,EAAS,UAAA;AAAA,IACT,KAAA,EAAO;AAAA,GACT;AAAA,EACA,EAAA,EAAI;AAAA,IACF,QAAA,EAAU,WAAA;AAAA,IACV,SAAA,EAAW,YAAA;AAAA,IACX,IAAA,EAAM,MAAA;AAAA,IACN,QAAA,EAAU,UAAA;AAAA,IACV,eAAA,EAAiB,wCAAA;AAAA,IACjB,QAAA,EAAU,cAAA;AAAA,IACV,MAAA,EAAQ,cAAA;AAAA,IACR,aAAA,EAAe,eAAA;AAAA,IACf,WAAA,EAAa,eAAA;AAAA,IACb,eAAA,EAAiB,uBAAA;AAAA,IACjB,YAAA,EAAc,+BAAA;AAAA,IACd,MAAA,EAAQ,SAAA;AAAA,IACR,KAAA,EAAO,OAAA;AAAA,IACP,OAAA,EAAS,SAAA;AAAA,IACT,OAAA,EAAS,UAAA;AAAA,IACT,KAAA,EAAO,OAAA;AAAA,IACP,KAAA,EAAO,OAAA;AAAA,IACP,IAAA,EAAM,MAAA;AAAA,IACN,MAAA,EAAQ,QAAA;AAAA,IACR,OAAA,EAAS,SAAA;AAAA,IACT,QAAA,EAAU,UAAA;AAAA,IACV,IAAA,EAAM,MAAA;AAAA,IACN,OAAA,EAAS,SAAA;AAAA,IACT,QAAA,EAAU,UAAA;AAAA,IACV,OAAA,EAAS,SAAA;AAAA,IACT,KAAA,EAAO;AAAA;AAEX","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 /**\n * Sub-items, ONE level (F092.5). An item with children is a toggle that opens\n * and closes them, not a link; its own `href` is used only to decide whether\n * it is on the current path.\n */\n children?: NavItem<Icon>[];\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/**\n * A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.\n * `javascript:` there runs code in the app, so only a same-site path or an\n * http(s) URL is followed; anything else returns null and the row just closes.\n */\nexport function safeNavigate(target: string | null | undefined): string | null {\n if (typeof target !== \"string\") return null;\n const t = target.trim();\n if (t.startsWith(\"/\") && !t.startsWith(\"//\") && !t.startsWith(\"/\\\\\")) return t;\n try {\n const u = new URL(t);\n return u.protocol === \"https:\" || u.protocol === \"http:\" ? u.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * F092.10 — is this link leaving the app? An http(s) link to another origin.\n * Paths, \"#…\", mailto:/tel: and the app's own origin are not. Owner decision\n * D-d28547: every external link opens in a new tab.\n */\nexport function isExternal(\n href: string | null | undefined,\n origin: string | undefined = typeof location !== \"undefined\" ? location.origin : undefined,\n): boolean {\n if (typeof href !== \"string\") return false;\n const h = href.trim();\n if (!origin) return /^(https?:)?\\/\\//i.test(h); // no page to compare with: absolute = external\n let u: URL;\n try {\n u = new URL(h, origin);\n } catch {\n return false;\n }\n return (u.protocol === \"http:\" || u.protocol === \"https:\") && u.origin !== origin;\n}\n\n/**\n * The label of the nav item the user is on, for the content header (F092.4).\n * Sub-items count, and the most specific match wins: on \"/models/genesis\" a\n * \"Genesis\" child beats its \"Models\" parent.\n */\nexport function activeNavLabel<Icon>(\n groups: NavGroup<Icon>[],\n footer: NavItem<Icon>[] | undefined,\n currentPath: string,\n): string | undefined {\n let best: { label: string; len: number } | undefined;\n const visit = (it: NavItem<Icon>) => {\n if (it.href && isActivePath(it.href, currentPath)) {\n const len = it.href.length;\n if (!best || len > best.len) best = { label: it.label, len };\n }\n it.children?.forEach(visit);\n };\n for (const g of groups) g.items.forEach(visit);\n footer?.forEach(visit);\n return best?.label;\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 closeMenu: \"Luk menu\",\n home: \"Forside\",\n language: \"Sprog\",\n couldNotMarkAll: \"Kunne ikke markere alle som læst. Prøv igen.\",\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 closeMenu: \"Close menu\",\n home: \"Home\",\n language: \"Language\",\n couldNotMarkAll: \"Could not mark all as read. Try again.\",\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"]}
package/dist/index.d.cts CHANGED
@@ -31,6 +31,12 @@ declare function isActivePath(href: string, currentPath: string): boolean;
31
31
  * http(s) URL is followed; anything else returns null and the row just closes.
32
32
  */
33
33
  declare function safeNavigate(target: string | null | undefined): string | null;
34
+ /**
35
+ * F092.10 — is this link leaving the app? An http(s) link to another origin.
36
+ * Paths, "#…", mailto:/tel: and the app's own origin are not. Owner decision
37
+ * D-d28547: every external link opens in a new tab.
38
+ */
39
+ declare function isExternal(href: string | null | undefined, origin?: string | undefined): boolean;
34
40
  /**
35
41
  * The label of the nav item the user is on, for the content header (F092.4).
36
42
  * Sub-items count, and the most specific match wins: on "/models/genesis" a
@@ -99,6 +105,8 @@ declare const TEXT: {
99
105
  readonly openMenu: "Åbn menu";
100
106
  readonly closeMenu: "Luk menu";
101
107
  readonly home: "Forside";
108
+ readonly language: "Sprog";
109
+ readonly couldNotMarkAll: "Kunne ikke markere alle som læst. Prøv igen.";
102
110
  readonly collapse: "Skjul sidemenu";
103
111
  readonly expand: "Vis sidemenu";
104
112
  readonly notifications: "Notifikationer";
@@ -125,6 +133,8 @@ declare const TEXT: {
125
133
  readonly openMenu: "Open menu";
126
134
  readonly closeMenu: "Close menu";
127
135
  readonly home: "Home";
136
+ readonly language: "Language";
137
+ readonly couldNotMarkAll: "Could not mark all as read. Try again.";
128
138
  readonly collapse: "Hide sidebar";
129
139
  readonly expand: "Show sidebar";
130
140
  readonly notifications: "Notifications";
@@ -150,4 +160,4 @@ declare const TEXT: {
150
160
  };
151
161
  type Lang = keyof typeof TEXT;
152
162
 
153
- export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
163
+ export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
package/dist/index.d.ts CHANGED
@@ -31,6 +31,12 @@ declare function isActivePath(href: string, currentPath: string): boolean;
31
31
  * http(s) URL is followed; anything else returns null and the row just closes.
32
32
  */
33
33
  declare function safeNavigate(target: string | null | undefined): string | null;
34
+ /**
35
+ * F092.10 — is this link leaving the app? An http(s) link to another origin.
36
+ * Paths, "#…", mailto:/tel: and the app's own origin are not. Owner decision
37
+ * D-d28547: every external link opens in a new tab.
38
+ */
39
+ declare function isExternal(href: string | null | undefined, origin?: string | undefined): boolean;
34
40
  /**
35
41
  * The label of the nav item the user is on, for the content header (F092.4).
36
42
  * Sub-items count, and the most specific match wins: on "/models/genesis" a
@@ -99,6 +105,8 @@ declare const TEXT: {
99
105
  readonly openMenu: "Åbn menu";
100
106
  readonly closeMenu: "Luk menu";
101
107
  readonly home: "Forside";
108
+ readonly language: "Sprog";
109
+ readonly couldNotMarkAll: "Kunne ikke markere alle som læst. Prøv igen.";
102
110
  readonly collapse: "Skjul sidemenu";
103
111
  readonly expand: "Vis sidemenu";
104
112
  readonly notifications: "Notifikationer";
@@ -125,6 +133,8 @@ declare const TEXT: {
125
133
  readonly openMenu: "Open menu";
126
134
  readonly closeMenu: "Close menu";
127
135
  readonly home: "Home";
136
+ readonly language: "Language";
137
+ readonly couldNotMarkAll: "Could not mark all as read. Try again.";
128
138
  readonly collapse: "Hide sidebar";
129
139
  readonly expand: "Show sidebar";
130
140
  readonly notifications: "Notifications";
@@ -150,4 +160,4 @@ declare const TEXT: {
150
160
  };
151
161
  type Lang = keyof typeof TEXT;
152
162
 
153
- export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
163
+ export { type Lang, type NavGroup, type NavItem, type NotificationSource, TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
package/dist/index.js CHANGED
@@ -16,6 +16,18 @@ function safeNavigate(target) {
16
16
  return null;
17
17
  }
18
18
  }
19
+ function isExternal(href, origin = typeof location !== "undefined" ? location.origin : void 0) {
20
+ if (typeof href !== "string") return false;
21
+ const h = href.trim();
22
+ if (!origin) return /^(https?:)?\/\//i.test(h);
23
+ let u;
24
+ try {
25
+ u = new URL(h, origin);
26
+ } catch {
27
+ return false;
28
+ }
29
+ return (u.protocol === "http:" || u.protocol === "https:") && u.origin !== origin;
30
+ }
19
31
  function activeNavLabel(groups, footer, currentPath) {
20
32
  let best;
21
33
  const visit = (it) => {
@@ -165,6 +177,8 @@ var TEXT = {
165
177
  openMenu: "\xC5bn menu",
166
178
  closeMenu: "Luk menu",
167
179
  home: "Forside",
180
+ language: "Sprog",
181
+ couldNotMarkAll: "Kunne ikke markere alle som l\xE6st. Pr\xF8v igen.",
168
182
  collapse: "Skjul sidemenu",
169
183
  expand: "Vis sidemenu",
170
184
  notifications: "Notifikationer",
@@ -191,6 +205,8 @@ var TEXT = {
191
205
  openMenu: "Open menu",
192
206
  closeMenu: "Close menu",
193
207
  home: "Home",
208
+ language: "Language",
209
+ couldNotMarkAll: "Could not mark all as read. Try again.",
194
210
  collapse: "Hide sidebar",
195
211
  expand: "Show sidebar",
196
212
  notifications: "Notifications",
@@ -215,6 +231,6 @@ var TEXT = {
215
231
  }
216
232
  };
217
233
 
218
- export { TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
234
+ export { TEXT, activeNavLabel, badgeLabel, createFetchNotificationSource, createMemoryNotificationSource, initials, isActivePath, isExternal, readFlag, readSet, relativeTime, safeNavigate, writeFlag, writeSet };
219
235
  //# sourceMappingURL=index.js.map
220
236
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","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 /**\n * Sub-items, ONE level (F092.5). An item with children is a toggle that opens\n * and closes them, not a link; its own `href` is used only to decide whether\n * it is on the current path.\n */\n children?: NavItem<Icon>[];\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/**\n * A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.\n * `javascript:` there runs code in the app, so only a same-site path or an\n * http(s) URL is followed; anything else returns null and the row just closes.\n */\nexport function safeNavigate(target: string | null | undefined): string | null {\n if (typeof target !== \"string\") return null;\n const t = target.trim();\n if (t.startsWith(\"/\") && !t.startsWith(\"//\") && !t.startsWith(\"/\\\\\")) return t;\n try {\n const u = new URL(t);\n return u.protocol === \"https:\" || u.protocol === \"http:\" ? u.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * The label of the nav item the user is on, for the content header (F092.4).\n * Sub-items count, and the most specific match wins: on \"/models/genesis\" a\n * \"Genesis\" child beats its \"Models\" parent.\n */\nexport function activeNavLabel<Icon>(\n groups: NavGroup<Icon>[],\n footer: NavItem<Icon>[] | undefined,\n currentPath: string,\n): string | undefined {\n let best: { label: string; len: number } | undefined;\n const visit = (it: NavItem<Icon>) => {\n if (it.href && isActivePath(it.href, currentPath)) {\n const len = it.href.length;\n if (!best || len > best.len) best = { label: it.label, len };\n }\n it.children?.forEach(visit);\n };\n for (const g of groups) g.items.forEach(visit);\n footer?.forEach(visit);\n return best?.label;\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 closeMenu: \"Luk menu\",\n home: \"Forside\",\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 closeMenu: \"Close menu\",\n home: \"Home\",\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"]}
1
+ {"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","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 /**\n * Sub-items, ONE level (F092.5). An item with children is a toggle that opens\n * and closes them, not a link; its own `href` is used only to decide whether\n * it is on the current path.\n */\n children?: NavItem<Icon>[];\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/**\n * A notification's `navigate` is SERVER DATA, and it ends in `location.assign`.\n * `javascript:` there runs code in the app, so only a same-site path or an\n * http(s) URL is followed; anything else returns null and the row just closes.\n */\nexport function safeNavigate(target: string | null | undefined): string | null {\n if (typeof target !== \"string\") return null;\n const t = target.trim();\n if (t.startsWith(\"/\") && !t.startsWith(\"//\") && !t.startsWith(\"/\\\\\")) return t;\n try {\n const u = new URL(t);\n return u.protocol === \"https:\" || u.protocol === \"http:\" ? u.href : null;\n } catch {\n return null;\n }\n}\n\n/**\n * F092.10 — is this link leaving the app? An http(s) link to another origin.\n * Paths, \"#…\", mailto:/tel: and the app's own origin are not. Owner decision\n * D-d28547: every external link opens in a new tab.\n */\nexport function isExternal(\n href: string | null | undefined,\n origin: string | undefined = typeof location !== \"undefined\" ? location.origin : undefined,\n): boolean {\n if (typeof href !== \"string\") return false;\n const h = href.trim();\n if (!origin) return /^(https?:)?\\/\\//i.test(h); // no page to compare with: absolute = external\n let u: URL;\n try {\n u = new URL(h, origin);\n } catch {\n return false;\n }\n return (u.protocol === \"http:\" || u.protocol === \"https:\") && u.origin !== origin;\n}\n\n/**\n * The label of the nav item the user is on, for the content header (F092.4).\n * Sub-items count, and the most specific match wins: on \"/models/genesis\" a\n * \"Genesis\" child beats its \"Models\" parent.\n */\nexport function activeNavLabel<Icon>(\n groups: NavGroup<Icon>[],\n footer: NavItem<Icon>[] | undefined,\n currentPath: string,\n): string | undefined {\n let best: { label: string; len: number } | undefined;\n const visit = (it: NavItem<Icon>) => {\n if (it.href && isActivePath(it.href, currentPath)) {\n const len = it.href.length;\n if (!best || len > best.len) best = { label: it.label, len };\n }\n it.children?.forEach(visit);\n };\n for (const g of groups) g.items.forEach(visit);\n footer?.forEach(visit);\n return best?.label;\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 closeMenu: \"Luk menu\",\n home: \"Forside\",\n language: \"Sprog\",\n couldNotMarkAll: \"Kunne ikke markere alle som læst. Prøv igen.\",\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 closeMenu: \"Close menu\",\n home: \"Home\",\n language: \"Language\",\n couldNotMarkAll: \"Could not mark all as read. Try again.\",\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"]}