# Stack Research

**Domain:** TTRPG campaign management — additions for next milestone (PWA + multi-screen battle + extended WS + Obsidian browser + Level-Up)
**Researched:** 2026-04-27
**Confidence:** HIGH (versions verified against Context7 + official npm/changelog; all additions sit on top of locked NestJS 11 / React 19 / Prisma 7 / Socket.io 4 stack)

> Scope: This file recommends ONLY new libraries/patterns to add. The locked existing stack (NestJS 11.0.1, React 19.2.0, Prisma 7.2.0, PostgreSQL, Socket.io 4.8.3, Tailwind v4.1.18, Zustand 5, TanStack Query 5, axios, framer-motion, lucide-react, class-validator, JWT, @anthropic-ai/sdk) is documented in `.planning/codebase/STACK.md` and is NOT re-evaluated.

## Recommended Stack

### Core Additions

| Technology | Version | Purpose | Why Recommended |
|------------|---------|---------|-----------------|
| `vite-plugin-pwa` | ^1.2.0 | PWA shell, manifest injection, service worker generation, Workbox bundling | The de-facto Vite PWA plugin. v1.0.1 added explicit Vite 7 support (project is on Vite 7.2.4); v1.2.0 (Nov 2025) is current. Provides `useRegisterSW` hook from `virtual:pwa-register/react` for the existing React 19 client — slots in without touching App shell. Use `injectManifest` strategy so we can hand-author the service worker (push handler + custom REST cache rules) instead of being constrained by `generateSW`. |
| `workbox-precaching` `workbox-routing` `workbox-strategies` `workbox-expiration` `workbox-cacheable-response` | ^7.3.0 (whatever vite-plugin-pwa pulls in) | Caching primitives for the custom service worker | When using `injectManifest`, these are dev-deps (peer deps of vite-plugin-pwa). Provides `precacheAndRoute(self.__WB_MANIFEST)`, `NetworkFirst` (REST endpoints), `CacheFirst` (immutable assets, vault images), `StaleWhileRevalidate` (translation strings). |
| `@vite-pwa/assets-generator` | latest 0.x | Generate icon set + maskable icons + splash screens from one source SVG | Avoids hand-cropping ~20 icon variants. CLI step in build pipeline. Optional but high-leverage. |
| `web-push` | ^3.6.7 | Server-side Web Push delivery (VAPID-signed) from NestJS | Reference Node.js library by `web-push-libs`, used by every Web Push tutorial and most production deployments. Provides `generateVAPIDKeys()`, `setVapidDetails()`, `sendNotification()`. Stable API (3.6.x line is multi-year mature; the relative quietness on the changelog is a sign of maturity, not abandonment). Installed and called from a new `notifications` NestJS module. |
| `@dice-roller/rpg-dice-roller` | ^5.5.1 | Server-side dice notation parser and roller | The canonical TS-typed RPG dice library. Supports modifiers needed for PF2e: keep-highest/lowest, exploding (`!`), drop-lowest, rerolls (`r`, `ro`), success/failure pools, math expressions, and arbitrary number of dice. Persistent damage in PF2e is **not a special dice mechanic** — it's just `1d6` (or whatever) re-rolled at end of turn; the PF2e-specific layer (crit-doubling, fatal trait, persistent flagging, recovery DC) is application logic on top of the parser, not parser logic. Run dice on the **server** (auth'd, audit-loggable, anti-cheat). |
| `react-markdown` | ^9.x | React component for safe markdown → React tree | Fits React 19 (uses `unified`/`remark`/`rehype` toolchain, no `dangerouslySetInnerHTML` needed). Plugin-extensible via `remarkPlugins` and `rehypePlugins`. The `components` prop is exactly what we need to inject custom React components for Wikilinks (clickable to internal vault routes), images (resolved against the vault base), and code blocks (Shiki). |
| `remark-gfm` | ^4.x | GitHub-flavored markdown extensions (tables, strikethrough, task lists, autolinks) | Obsidian markdown is GFM-flavored. Drop-in remark plugin. |
| `@portaljs/remark-wiki-link` | ^1.2.0 | Parse Obsidian-style `[[note]]` and `[[note\|alias]]` wikilinks into AST nodes | Maintained, supports `obsidian-short` and `obsidian-absolute` path resolution modes (matches actual Obsidian behavior, where `[[Foo]]` resolves to the shortest matching file). The original `remark-wiki-link` is too generic; the `flowershow`/`@portaljs` fork specifically targets Obsidian semantics. AST node lets `react-markdown` `components` mapping render as a custom `<WikiLink>` React component routing into our vault browser. |
| `remark-obsidian-callout` (or inline custom plugin) | ^1.x | Render Obsidian `> [!note]` / `> [!warning]` callouts | Optional but Obsidian-flavored vaults heavily use callouts. Can also be hand-rolled as a small remark plugin (≤50 LOC) that detects the `[!type]` pattern in blockquotes — the user owns the vault content style, so a tight in-house plugin is acceptable. |
| `react-shiki` | ^0.9.x | Syntax highlighting for code blocks inside vault markdown | Recommended replacement for `react-syntax-highlighter` (latter is unmaintained, slow first-paint, has open vulnerabilities). Wraps Shiki — same TextMate grammars as VS Code — and exposes both a `<ShikiHighlighter>` component and a `useShikiHighlighter` hook. Offers fine-grained bundle control: pick only the languages we want (e.g., `typescript`, `bash`, `json`, `markdown`, `python`) to keep bundle size sane. |
| `webdav` (perry-mitchell client) | ^5.x | TS-typed WebDAV client for the NestJS server to read the user's self-hosted vault | TypeScript-native, supports Basic/Digest/Token auth, browser-and-Node compatible (we'll use Node in the server). Server-side fetch keeps vault credentials off the client and lets the existing JWT auth gate vault access. Maintained, last commits within current cycle. |

### Supporting Libraries

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `idb` (or `idb-keyval`) | ^8.x / ^6.x | Promise-wrapped IndexedDB for offline payload cache | When TanStack Query's in-memory cache isn't enough — i.e., persisting last-fetched character sheet, vault notes, equipment DB extracts so they survive a hard reload offline. `idb-keyval` if all we need is `get`/`set`; `idb` if we want indexed schemas. Recommendation: start with `idb-keyval`. |
| `@tanstack/query-sync-storage-persister` + `@tanstack/react-query-persist-client` | matching ^5.90.x | Persist React Query cache to localStorage/IndexedDB | The "right" way to make existing `useQuery` calls offline-readable without rewriting hooks. Wrap `QueryClientProvider` with `PersistQueryClientProvider`. Plug-in fit with the existing TanStack Query 5.90.19 install. |
| `unified` | ^11.x | Core processor that `react-markdown` already pulls in | Don't install separately unless authoring custom AST plugins; transitive via `react-markdown`. Listed for visibility. |
| `rehype-slug` + `rehype-autolink-headings` | ^6.x / ^7.x | Slug + anchor links on rendered headings | Vault notes get stable URL fragments. Optional. |
| `class-variance-authority` (cva) | ^0.7.x | Variant-driven Tailwind class composition | Optional. Only if new Battle/Display screens grow many size/state variants. Existing code uses `clsx` + `tailwind-merge` which already covers most cases — only add CVA if a component file starts looking like a chain of nested ternaries. |
| `usehooks-ts` (specifically `useBroadcastChannel`, `useMediaQuery`) | ^3.x | Tiny well-tested React hooks | Avoid hand-rolling `useBroadcastChannel` — `usehooks-ts` already wraps it with proper cleanup. Pick à la carte; don't import the whole library. |

### Development Tools

| Tool | Purpose | Notes |
|------|---------|-------|
| `web-push` CLI (`npx web-push generate-vapid-keys`) | One-shot VAPID key generation | Run once; store keys in `server/.env` as `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` (mailto). Don't regenerate without re-subscribing all users. |
| `@vite-pwa/assets-generator` CLI | Build-time icon and splash screen generation | Run as `pwa-assets-generator` script before production build. Source SVG checked into repo. |
| Workbox dev tools / Chrome DevTools "Application" tab | Debug service worker, cache contents, push subscriptions | Standard browser tooling; no install. Critical for verifying offline behavior of cached endpoints. |
| Lighthouse / PWA tab in Chrome | Validate manifest, install prompt, service worker quality | Run before each release of the milestone. Aim for "Installable" + offline-capable green checks. |

## Installation

```bash
# Client (cd client)
npm install vite-plugin-pwa workbox-precaching workbox-routing workbox-strategies workbox-expiration workbox-cacheable-response
npm install -D @vite-pwa/assets-generator

npm install react-markdown remark-gfm @portaljs/remark-wiki-link react-shiki shiki
npm install rehype-slug rehype-autolink-headings   # optional

npm install idb-keyval
npm install @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister

npm install usehooks-ts   # only the hooks we need

# Server (cd server)
npm install web-push @dice-roller/rpg-dice-roller webdav
npm install -D @types/web-push   # check if shipped types are sufficient first; @types/web-push exists but the package now ships its own .d.ts in 3.6.x

# One-time
cd server && npx web-push generate-vapid-keys   # capture into .env
```

## Alternatives Considered

| Recommended | Alternative | When to Use Alternative |
|-------------|-------------|-------------------------|
| `vite-plugin-pwa` | Hand-written service worker + manifest | If we ever need behavior the plugin actively fights (e.g., dev-only SW with HMR). Not the case here — `injectManifest` strategy already gives full control. |
| `vite-plugin-pwa` Workbox | Serwist (Workbox fork) | Active fork, cleaner ESM. Worth tracking but not yet the default for Vite — `vite-plugin-pwa` upstream still uses Workbox. Switch only if Workbox stalls. |
| `web-push` | Self-rolled VAPID + ECE encryption | Never. The encryption details (ECDH, HKDF, AES-128-GCM) are the kind of code you do not want to maintain yourself. |
| `web-push` | `@vv-01/web-push-service` (NestJS-specific wrapper) | If we later want OneSignal-like multi-tenant features. Overkill for self-hosted single-group app. |
| `@dice-roller/rpg-dice-roller` | `dice-notation` (`Morgul/rpgdice`) | Smaller, simpler. Use if rpg-dice-roller's modifier set turns out to be too feature-heavy. But for PF2e (with rerolls, exploding, keep-highest, math) the full feature set is justified. |
| `@dice-roller/rpg-dice-roller` | Hand-rolled regex parser | Tempting but a trap — every TTRPG team that did this regrets it within a year (operator precedence, `kh3` vs `kl3`, error messages). Use the library. |
| `react-markdown` | `markdown-to-jsx` | Smaller bundle (~10KB vs ~50KB), no plugin ecosystem. Use only if bundle size becomes critical and we don't need wikilinks/GFM/Shiki. We do need all three. |
| `react-markdown` | `marked` + custom React renderer | Faster parser. Loses the unified/remark plugin ecosystem (no wikilink plugin, no callouts). Not worth the speed gain. |
| `react-markdown` | `@uiw/react-md-editor` | Bundles an editor — we explicitly do NOT need editing in v1 of vault browser. Out of scope. |
| `@portaljs/remark-wiki-link` | `remark-wiki-link-plus` | Also viable. PortalJS variant has cleaner Obsidian path resolution semantics. Tied; pick one and stick to it. |
| `react-shiki` | Plain Shiki + custom integration | Use if we want SSR pre-rendered code blocks. We don't have SSR; client-side react-shiki is correct. |
| `react-shiki` | Prism / `prismjs` | Smaller bundle, but theme + accuracy are noticeably worse. PrismJS is also not actively keeping pace with newer language additions. |
| `react-shiki` | `highlight.js` | Zero-config but ~340KB uncompressed full bundle, less accurate, dated themes. |
| `webdav` (perry-mitchell) | Plain HTTP file server (`@nestjs/serve-static` pointing at vault dir) | **Strongly worth considering** — see "Stack Patterns by Variant" below. If the user is willing to mount the vault directory into the server's filesystem, a custom NestJS controller serving markdown over authenticated HTTP is simpler than WebDAV. WebDAV is the right answer ONLY if the vault lives on a separate machine the server has WebDAV access to. |
| `webdav` (perry-mitchell) | `tsdav` | Also TS-native, but adds CalDAV/CardDAV which we don't need. Smaller cognitive surface to use perry-mitchell's. |
| `webdav` (perry-mitchell) | git-based sync (clone the vault repo on server) | Works, but is a fight: Obsidian's git workflows are messy (large binary attachments, `.obsidian/` config noise), and "read-only" via git means scheduled pulls. Adds infra without paying for itself. |
| `idb-keyval` | LocalStorage | LocalStorage is sync, ~5MB cap, blocks main thread. IDB (via `idb-keyval`) is the right call for character sheet snapshots and vault notes. |
| BroadcastChannel for table-display sync | postMessage between `window.opener` and child window | postMessage works but loses messages if either window reloads, and requires the child to be opened from the parent. BroadcastChannel survives reloads and works between independently opened tabs as long as same-origin. |
| BroadcastChannel | A shared SharedWorker | Overkill. SharedWorker would be useful if we wanted a single shared WebSocket connection across tabs — we can defer that until we see actual traffic problems. |

## What NOT to Use

| Avoid | Why | Use Instead |
|-------|-----|-------------|
| `react-syntax-highlighter` | Unmaintained, slow first-paint when there are many code blocks, has flagged audit issues | `react-shiki` |
| `marked` (as React renderer) | No native React tree output, no remark plugin ecosystem, tempts you to `dangerouslySetInnerHTML` | `react-markdown` |
| `pwa-asset-generator` (the older CLI) | Older project, less Vite-aware | `@vite-pwa/assets-generator` |
| `firebase-admin` or FCM-tier push | Native push platform lock-in, account onboarding required, no advantage over standard Web Push for our self-hosted setup | `web-push` (standards-based VAPID) |
| Capacitor / Cordova / PWABuilder native wrappers | Adds an app store distribution path we explicitly don't want (PROJECT.md "Out of Scope: Native iOS/Android-App") | Stick with PWA install-to-home-screen |
| Background Sync API on iOS | iOS Safari does not support Background Sync as of 2026 — code that depends on it will silently no-op on iPhones | Treat write operations as online-only (already in scope: "Offline-Bearbeiten ist explizit raus"); use foreground sync with explicit user action on reconnect |
| Silent push / data-only push on iOS | iOS Safari does not support silent/background-wake push (only user-visible notifications) | Always send a visible notification payload; if you need to "trigger app data sync", do it foreground when the user taps the notification |
| Hand-written dice notation parser | Operator-precedence bugs, no error recovery, no modifier extensibility | `@dice-roller/rpg-dice-roller` |
| Storing dice rolls only in client | No audit trail, trivially cheatable, no cross-player visibility | Roll on server in a NestJS service; persist `Roll` records in Prisma; broadcast via Socket.io |
| `localStorage` event for cross-window sync | Fires only on OTHER tabs (not the originating one), doesn't survive private browsing edge cases | `BroadcastChannel` |
| LocalStorage for vault note caching | Sync, 5MB cap, will hit the cap with a real vault | IndexedDB via `idb-keyval` (or Workbox's runtime caching for HTTP responses) |
| Running dice rolls only on the client | Trivial to cheat with devtools, no replay log | Server authoritative, client merely formats |
| Allowing service worker to bypass auth on `/api/*` | Cached private data could leak across users sharing a device | Scope cache keys by user ID; on logout, call `caches.delete()` for user-bound caches |

## Stack Patterns by Variant

### Vault transport: WebDAV vs HTTP-mounted

**If the vault is on the same machine as the NestJS server (or NFS/SMB-mounted to it):**
- Build a small `vault` NestJS module that reads markdown directly from the filesystem with `fs/promises`
- Auth via existing JWT guard (gate behind ADMIN/GM/PLAYER roles per PROJECT.md role model)
- Endpoints: `GET /api/vault/tree`, `GET /api/vault/note?path=...`, `GET /api/vault/search?q=...`, `GET /api/vault/asset?path=...` (for embedded images)
- Cache-Control headers tuned for Workbox CacheFirst on assets, NetworkFirst on notes
- Why: simpler, lower latency, no extra protocol surface, full control over auth — best fit for the "self-hosted for own group" setup

**If the vault is on a separate machine the server can reach:**
- Use `webdav` (perry-mitchell) inside the same `vault` NestJS module
- Hide WebDAV credentials in `.env`; never expose them to the client
- Endpoints stay the same shape — module just swaps fs calls for WebDAV calls
- Why: WebDAV is the most common TTRPG-friendly self-hosted protocol (Nextcloud, Synology, raw sabre/dav)

**Recommendation:** Default to filesystem mount. Implement the `vault` module with a swappable `VaultProvider` interface (`FsVaultProvider`, `WebDAVVaultProvider`) so the choice is one DI binding, not a rewrite. This matches the existing module pattern in `server/src/modules/`.

### Multi-screen: Same-machine GM laptop driving table display

**Scenario A — Both screens served from same browser (likely):**
- GM opens main app on display 1
- "Open Tisch-Display" button → `window.open('/battle/:sessionId/display', 'tisch-display', '...')` on display 2
- Sync via `BroadcastChannel('battle-:sessionId')` — same-origin, both windows post and listen
- Display window subscribes to the same battle Socket.io namespace as the GM (read-only, GM events broadcast to both)
- Display window does NOT need its own auth dance — share JWT via the broadcast handshake

**Scenario B — Table display is a separate device (Raspberry Pi / kiosk PC):**
- Both devices connect to the same Socket.io namespace independently
- Add a `display` room/role on the gateway; emit only safe-for-public events to it (no GM private notes)
- Skip BroadcastChannel entirely — WS-only

**Recommendation:** Build for Scenario B (separate device, WS-only). It's the more capable pattern, and Scenario A is a degenerate case that just happens to work because BroadcastChannel is a no-op when only one window is listening. Don't make BroadcastChannel a load-bearing dependency.

### iOS PWA Push: degraded mode by default

- Document explicitly: iOS PWA push works ONLY when the app is installed to home screen (not in Safari tabs)
- Use **Declarative Web Push** payloads (Safari 18.4+, March 2026) where possible — no service worker needed, more reliable
- For Android/desktop Chrome where service-worker-driven push works fully, use the richer payload (custom actions, badges)
- Server should send the same logical message; client UX gracefully degrades

### Offline strategy by content type

| Content | Cache strategy | Reason |
|---------|---------------|--------|
| App shell (HTML/JS/CSS) | Precache (Workbox `precacheAndRoute`) | Hashed assets, immutable |
| `/api/characters/:id` | NetworkFirst, 5s timeout, 24h cache | Always want fresh, fall back to cached when offline |
| `/api/equipment/*` | StaleWhileRevalidate, 7-day cache | Mostly static after seed; show cached fast, refresh background |
| `/api/translations/*` | CacheFirst, 30-day cache | Translations are append-only |
| `/api/vault/note?path=...` | NetworkFirst, 5s timeout, 7-day cache | Vault rarely changes during a session |
| `/api/vault/asset?path=...` | CacheFirst, 30-day cache, max 200 entries | Images, large, immutable |
| Translation cache hits via `/api/translations` | StaleWhileRevalidate | Already cached server-side; client cache is bonus |

## Version Compatibility

| Package | Compatible With | Notes |
|---------|-----------------|-------|
| `vite-plugin-pwa@^1.2.0` | `vite@^7.0.0` (project: 7.2.4) | v1.0.1 was the cut-over to Vite 7. Don't pin below 1.0.1. |
| `react-markdown@^9.x` | `react@^18 \|\| ^19` (project: 19.2.0) | v9 is the major-version line that supports React 19. v8 requires React 18. |
| `react-shiki@^0.9.x` | React 18 + 19 | Verify peer dep at install; pin patch version after install. Pre-1.0 — read changelog before bumping. |
| `@portaljs/remark-wiki-link@^1.2.0` | `unified@^11`, `remark@^15` (transitive via react-markdown@9) | Aligned with current unified ecosystem. |
| `web-push@^3.6.7` | Node 16+ (project: Node 22 LTS) | Mature; release cadence intentionally slow. |
| `@dice-roller/rpg-dice-roller@^5.5.1` | Node 18+, browser ES2020+ | Pure TS, no native deps. |
| `webdav@^5.x` | Node 16+, modern browsers | v5 active dev; v4 is in maintenance. Use v5 for new code. |
| `idb-keyval@^6.x` | All browsers with IndexedDB (i.e. all evergreen) | No SSR concerns since we're SPA. |
| `@tanstack/react-query-persist-client` | Match `@tanstack/react-query` major (project: 5.90.19, so use `5.x`) | Same release train. |

## Module-fit notes (where each addition lands in existing structure)

- **`vite-plugin-pwa`** → `client/vite.config.ts` plugin entry; new `client/src/sw.ts` for `injectManifest` strategy; new `client/src/shared/hooks/use-pwa.ts` wrapping `useRegisterSW` from `virtual:pwa-register/react`
- **`web-push`** → new `server/src/modules/notifications/` module (controller + service + Prisma `PushSubscription` model). Mirrors the existing module pattern (auth, campaigns, characters)
- **`@dice-roller/rpg-dice-roller`** → new `server/src/modules/dice/` module with `DiceService.roll(notation, context)` and a `RollLog` Prisma model. Roll events emitted on existing Socket.io infrastructure via a new gateway or extended battle/character gateway — match the existing gateway pattern in `characters.gateway.ts` and `battle.gateway.ts`
- **`react-markdown` + plugins + `react-shiki`** → new `client/src/features/vault/` feature folder per the existing `features/*/components` convention. New `vault-renderer.tsx` component owns the `<Markdown>` component config; `wiki-link.tsx`, `vault-image.tsx`, `vault-code-block.tsx` are the custom React components passed via `components` prop
- **`webdav` (or fs-based)** → new `server/src/modules/vault/` module with `VaultProvider` interface and either `FsVaultProvider` or `WebDAVVaultProvider` injected via NestJS DI. Endpoints prefixed `/api/vault/*`
- **`idb-keyval` + React Query persist** → `client/src/app/` (where the `QueryClient` already lives, or wherever `App.tsx` mounts providers). Wrap `QueryClientProvider` with `PersistQueryClientProvider`
- **BroadcastChannel** → `client/src/features/battle/hooks/use-display-channel.ts`, used by the new display-mode route

## Sources

- `/vite-pwa/vite-plugin-pwa` (Context7, HIGH) — confirmed `injectManifest` strategy syntax, React `useRegisterSW` hook, Vite 7 support since v1.0.1
- `/web-push-libs/web-push` (Context7, HIGH) — confirmed `generateVAPIDKeys()`, `setVapidDetails()`, `sendNotification()` API surface; current API and 410/404/429 error patterns
- `/remarkjs/react-markdown` (Context7, HIGH) — confirmed `remarkPlugins`, `components` prop pattern for custom rendering; v9 line is current
- `/shikijs/shiki` (Context7, HIGH) — Shiki 4.0.2 current; ESM-bundled grammars
- `/avgvstvs96/react-shiki` (Context7, HIGH) — react-shiki recommended replacement for `react-syntax-highlighter`
- `/googlechrome/workbox` (Context7, HIGH) — caching strategies primitives
- npmjs.com release pages for `vite-plugin-pwa`, `web-push`, `@dice-roller/rpg-dice-roller`, `webdav`, `react-shiki` (Web search verified, MEDIUM-HIGH) — version numbers cross-checked
- [vite-plugin-pwa v1.2.0 release (npm)](https://www.npmjs.com/package/vite-plugin-pwa) — released Nov 27, 2025; v1.0.1 added Vite 7 support
- [vite-pwa-org docs (Netlify)](https://vite-pwa-org.netlify.app/) — `injectManifest`, `useRegisterSW` patterns
- [Vite 7 release notes](https://vite.dev/blog/announcing-vite7) — Vite 7 requires Node 20.19+ / 22.12+
- [web-push npm](https://www.npmjs.com/package/web-push) — v3.6.7 mature line; `web-push-libs` org
- [@dice-roller/rpg-dice-roller npm](https://www.npmjs.com/package/@dice-roller/rpg-dice-roller) — v5.5.1 current
- [react-shiki npm](https://www.npmjs.com/react-shiki) — v0.9.3 current (April 2026)
- [Shiki npm](https://www.npmjs.com/package/shiki) — v4.0.2 current
- [@portaljs/remark-wiki-link npm](https://www.npmjs.com/package/@portaljs/remark-wiki-link) — Obsidian-flavored path resolution
- [perry-mitchell/webdav-client GitHub](https://github.com/perry-mitchell/webdav-client) — TS-native WebDAV client v5
- [PWA iOS limitations 2026 (MagicBell)](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS push only when installed to home screen, no Background Sync, Declarative Web Push since Safari 18.4
- [BroadcastChannel API (MDN)](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) — same-origin cross-window messaging
- [Workbox strategies (Chrome for Developers)](https://developer.chrome.com/docs/workbox/modules/workbox-strategies) — NetworkFirst/CacheFirst/StaleWhileRevalidate
- [coddingtonbear/obsidian-local-rest-api](https://github.com/coddingtonbear/obsidian-local-rest-api) — *not adopted* but listed as a reference if vault is hosted inside Obsidian itself rather than a static directory

---
*Stack research for: Dimension47 next milestone (PWA + multi-screen battle + extended WS + Obsidian vault browser + Level-Up)*
*Researched: 2026-04-27*
