diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 51e0bf1..4cd5827 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -88,6 +88,11 @@ jobs: cp frontend/dist/llms.md site/llms.md # Wegweiser der llms.txt-Konvention (D43-Nachtrag 2) cp frontend/dist/llms.txt site/llms.txt + # PWA-Hülle (D73): Manifest + Icons + Service Worker — public/-Assets + # neben der einen Datei + cp frontend/dist/manifest.webmanifest site/manifest.webmanifest + cp frontend/dist/icon-192.png frontend/dist/icon-512.png frontend/dist/icon-maskable-512.png site/ + cp frontend/dist/sw.js site/sw.js - name: Pages-Konfiguration uses: actions/configure-pages@v6 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5aef216..974bbd3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,9 @@ reverse. ## 2026-08-25 +- The installed app registers for `.werkbaum` files — a double-click in the file manager opens them straight into the editor +- A service worker starts the editor offline; navigations stay network-first, so the update notice keeps telling the truth +- Werkbaum installs as an app: a web app manifest with standalone display and icons rendered from the brand mark - PWA file handling recorded in the plan — an installed app would open `.werkbaum` files on double-click and save without any dialog - In Chromium browsers, saving writes back to the opened file, and the same file reopens into the same document - Open a local `.werkbaum` file and save the document back as a file, from the document menu diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 66ecfff..e8d57ba 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5389,3 +5389,90 @@ IndexedDB-Rundreise über einen Neustart: Stub-Handles überleben den Structured Clone nicht (`DataCloneError`, planmäßig geschluckt) — echte Handles sind gerade dafür klonbar; dieser eine Pfad ist Code-Review statt Messung. 480 Tests. + +## D73 — PWA: Manifest und ein bewusst dummer Offline-Worker, network-first +Werkbaum ist als App installierbar (`#bld.pwa`): Manifest mit Icons und +Standalone-Fenster, ein Service Worker für den Offline-Start, und die +installierte App registriert sich für `.werkbaum`-Dateien. Die tragende +Entscheidung ist die Rolle des Workers — und sie hat den gefürchteten Teil +des Features aufgelöst. + +**Der Worker ist ein Offline-Mantel, kein App-Verwalter.** Er fasst +ausschließlich die **Navigation zur App-Wurzel** an und beantwortet sie +**network-first**: Der Server bleibt die Quelle der Wahrheit, genau wie ohne +Worker; der Cache hält nur die zuletzt gesehene Fassung der einen +self-contained Datei (D19) für den Offline-Fall und wird bei jeder +erfolgreichen Navigation aufgefrischt. Alles andere — `?sourceUrl=`- und +Pad-Abrufe (D23/D31), `llms.md`/`llms.txt`, jeder `fetch()` — geht unangefasst +durch. + +**Damit blieb die geplante D45-Migration aus — und das ist ein Befund, keine +Abkürzung.** Der Plan-Knoten hieß „The reload notice moves into the worker", +in der Annahme, ein Worker entscheide, was ausgeliefert wird, und der +Vergleich „laufender Build gegen den, den der Server sendet" (D45) verliere +seine Grundlage. Das gilt für einen **cache-first** Worker — und genau +deshalb ist der verworfen: Er hätte den skipWaiting-/updatefound-Lebenszyklus +gebraucht (die Stelle, an der PWAs erfahrungsgemäß Fehler sammeln, und D45 +hat seine eigene Fehlergeschichte), eine zweite Update-Logik neben der +bestehenden, und jeden Nutzer bis dahin auf der zuerst installierten Fassung +festgenagelt. Network-first braucht nichts davon: Der Prüf-`fetch()` ist +keine Navigation und läuft ans echte Netz; „Jetzt laden" ist eine Navigation +und bekommt die frische Fassung. Beides gemessen, nicht angenommen (unten). +Der Knoten heißt jetzt „The reload notice stays truthful under the worker" — +die Arbeit war der Nachweis, nicht der Umzug. Der Preis von network-first ist +benannt: Der Start kostet online weiterhin einen Netz-Abruf (wie bisher auch) +statt sofort aus dem Cache zu kommen — für ein Produkt, das laufend deployt, +der richtige Tausch. + +**`sw.js` ändert sich praktisch nie.** Weil die App vom Server kommt und +nicht aus dem Worker, gibt es keine Versionsnummer, die dort gepflegt oder +von den Deploy-Skripten eingespritzt werden müsste — kein zweiter +`sed`-Stempel neben der Footer-Version (D16). + +**Aus „eine Datei" wird ehrlich „eine Datei plus App-Hülle".** Manifest, +Icons und Worker sind **nicht inlinebar** — der Browser holt sie per URL; +ein Worker braucht seine eigene Adresse. Sie liegen als `frontend/public/`- +Assets neben der Datei (der `llms.md`-Weg, D43) und werden von **beiden** +Deploy-Wegen mitkopiert (Pages-Workflow und `deploy-prod.sh` stellen die Site +je von Hand zusammen — dieselbe Doppelpflege wie bei den `sed`-Regeln, D16). +Die `file://`-Tauglichkeit der einen Datei bleibt: Ohne die Hülle fehlt nur +die Installierbarkeit, nicht die App. Die `.htaccess` bekommt den MIME-Typ +für `.webmanifest` — dieselbe Apache-Falle wie bei `.md` (D43-Nachtrag 2): +unbekannte Endung, kein Content-Type, Chromium verwirft das Manifest still. + +**Icons aus der Marke, eingecheckt.** Die Raster-Größen (192/512 plus eine +Maskable-Variante mit Schutzzone: Marke auf 60 % statt 78 % der Fläche) sind +einmalig per Inkscape aus `docs/brand/favicon.svg` gerendert und eingecheckt — +der Fonts-Präzedenzfall (D20): Assets im Repo, kein Werkzeug im Build. + +**Nicht im Dev-Server registriert.** Dort würde der Worker die HMR-Seite +cachen; der Zweig hängt an `!import.meta.env.DEV` und fällt im Dev als toter +Code weg. Auf `file://` und http ohne Secure Context gibt es keinen nutzbaren +`serviceWorker` — das Scheitern ist geschluckt, die App läuft ohne. + +**Dateihandling (`#bld.pwa.files`):** `file_handlers` im Manifest +(`.werkbaum`/`.txt`, dieselben Endungen wie `FILE_TYPES`, D72) plus ein +`launchQueue`-Empfänger, der das gereichte Handle an das vorhandene +`adoptFile()` gibt — dieselbe Datei landet damit im selben Dokument, und das +gemerkte Handle macht „Als Datei speichern" dialogfrei (D72-Nachtrag). +`launch_handler: focus-existing`, damit der Doppelklick ein offenes Fenster +wiederverwendet, statt Instanzen zu stapeln. Chromium only — Firefox +installiert auf dem Desktop nicht, Safari kennt `file_handlers` nicht; dort +ändert sich nichts. + +**Nachgemessen** am gebauten Stand (`vite preview`, dist auf localhost:8138): +Worker aktiv und `controller` gesetzt, Cache hält genau `./`. Dann ein Marker +in `dist/index.html` geschrieben: Der D45-artige `fetch(…, no-store)` **sieht +ihn sofort** (läuft also am Worker vorbei ans Netz), während die laufende +Seite ihn nicht hat; ein Reload **lädt ihn** (Navigation network-first) und +frischt den Cache mit auf. Server gestoppt, Reload: Die Seite kommt +vollständig aus dem Cache (18 Knoten gerendert, Marker enthalten). Manifest +parst mit `file_handlers` und `launch_handler`; `launchQueue` existiert und +der Consumer registriert sich fehlerfrei. Aufgeräumt per `unregister()` + +`caches.delete()`. 480 Tests unverändert grün. + +**Werkzeuggrenzen, wie bei D72 benannt:** Installieren, der OS-Doppelklick +auf eine `.werkbaum`-Datei und die persistente Schreibberechtigung der +installierten App sind Betriebssystem-Dialoge und bleiben ein Handtest auf +echter Hardware; gemessen ist alles bis an diese Kante (Manifest gültig, +Consumer registriert, `adoptFile()`-Weg seit D72 geprüft). diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 507a525..4e46077 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -99,11 +99,11 @@ - [^] #bld.tests: Unit tests (S) - [^] #bld.pages: Published on every push (S) - [^] #bld.update: Says when a new build is out (S) %% compares against the running one, see D45 - - [ ] #bld.pwa: Installable as a progressive web app (M) - - [ ] #bld.pwa.manifest: A manifest with name, icons and standalone display (XS) - - [ ] #bld.pwa.sw: A service worker that serves the app offline (S) %% one file to cache - - [ ] #bld.pwa.update: The reload notice moves into the worker (S) %% see D45 - - [ ] #bld.pwa.files: Double-click a .werkbaum file, save without any dialog (S) :#ed.files.inplace %% file_handlers + launchQueue + - [x] #bld.pwa: Installable as a progressive web app (M) + - [x] #bld.pwa.manifest: A manifest with name, icons and standalone display (XS) + - [x] #bld.pwa.sw: A service worker that serves the app offline (S) %% network-first, cache as fallback + - [x] #bld.pwa.update: The reload notice stays truthful under the worker (S) %% see D45/D73 + - [x] #bld.pwa.files: Double-click a .werkbaum file, save without any dialog (S) :#ed.files.inplace %% file_handlers + launchQueue - [-] #bld.ghpages: Deploy through a gh-pages branch (S) %% GitHub Actions instead - [-] #bld.gfonts: Fonts from Google (XS) %% self-hosted, no third-party request - [ ] #be: Backend (XL) @@ -667,31 +667,32 @@ #bld.pwa Installable from the browser and startable like an app, in its own window - and with its own icon. Everything it needs is already in one file, so what - is missing is mostly the declaration that says so. + and with its own icon. Everything it needs was already in one file — what + was missing was mostly the declaration that says so. #bld.pwa.manifest Name, icons, theme colour and a standalone display mode — the declaration a - browser reads before it offers to install anything. The brand mark exists - and needs the raster sizes an installer asks for. + browser reads before it offers to install anything. The raster icons are + rendered from the brand mark, with a padded maskable variant. #bld.pwa.sw - A worker answering from the cache lets the editor start without a network. - There is exactly one object to cache, because the build inlines fonts, - styles and modules; documents live in the browser anyway. Texts pulled from - a URL or a pad stay online by their nature. + A worker lets the editor start without a network. It is deliberately dumb: + navigations go network-first, and the cache only keeps the last version it + has seen of the one self-contained file for the offline case. Texts pulled from a URL + or a pad pass through untouched and stay online by their nature. #bld.pwa.update - With a worker in front of the page, the reload notice has to come from it: - it decides what gets served, so "the running build against the one the - server sends" is no longer the same comparison. + The feared migration turned out unnecessary: because the worker only + touches navigations and answers them network-first, the update check still + fetches the real server and a reload really loads the new build. The work + here was proving that, not moving code. #bld.pwa.files The installed app registers for the .werkbaum extension: double-clicking a file in the OS opens it here, with a ready file handle from the launch queue. Together with the persistent write permission an installed app can hold, editing becomes open, type, save — no dialog at all. The saving - mechanics already exist; what is missing is the manifest entry and the + mechanics existed already; this added the manifest entry and the launch-queue receiver. Chromium only. #bld.ghpages diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index ad3a41f..f2f82f7 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -554,6 +554,19 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Hash-Rückfall, der Marker-Pfad ist nur zu prüfen, wenn man einen echten `commit/` in `index.html` einspritzt (danach zurücknehmen!) und `window.fetch` überschreibt. +- **PWA (D73):** `public/sw.js` fasst NUR die Navigation zur App-Wurzel an + und beantwortet sie **network-first** — der Cache ist reiner + Offline-Rückfall. Wer daraus cache-first macht, bricht die Update-Prüfung + (D45): deren `fetch()` läuft nur deshalb ans echte Netz, und „Jetzt laden" + bekommt nur deshalb die frische Fassung. Keine Registrierung im Dev-Server + (`!import.meta.env.DEV` — sonst cacht der Worker die HMR-Seite). Manifest, + Icons und `sw.js` sind public/-Assets neben der einen Datei; **jedes neue + public/-Asset muss in BEIDE Deploy-Wege** (pages.yml und deploy-prod.sh + stellen die Site je von Hand zusammen) und ggf. in `scripts/prod.htaccess` + (Apache kennt `.webmanifest` nicht, D43-Falle). Der `launchQueue`-Empfänger + (Dateidoppelklick der installierten App) reicht Handles an `adoptFile()` + (D72) weiter — Installieren/Doppelklick sind nur auf echter Hardware + prüfbar. - **Neuigkeiten (D58):** `NEWS` kommt aus dem virtuellen Modul `virtual:werkbaum-news` (Vite-Plugin in `vite.config.js`), gefüllt zur **Bauzeit** aus `docs/CHANGELOG.md` (die Notizen) und der git-Historie des diff --git a/frontend/index.html b/frontend/index.html index c31c4e7..2dc86d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,6 +6,13 @@ Werkbaum – PSP & Lean Pathfinding Editor (Work Breakdown Structure) + + + diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..8feb6e6 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..efdad7f Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png new file mode 100644 index 0000000..3303e5e Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..3c7125c --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Werkbaum", + "short_name": "Werkbaum", + "description": "A textual notation for work breakdown structures with and/or decomposition, and a live diagram editor.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#243447", + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ], + "file_handlers": [ + { "action": "./", "accept": { "text/plain": [".werkbaum", ".txt"] } } + ], + "launch_handler": { "client_mode": "focus-existing" } +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..85b0e52 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,60 @@ +/* Werkbaum — Service Worker (D73). + * + * Bewusst ein dummer Offline-Mantel, kein App-Verwalter: Navigationen gehen + * NETWORK-FIRST (der Server ist die Quelle der Wahrheit, wie ohne Worker), + * der Cache hält nur die zuletzt gesehene Fassung der einen self-contained + * Datei (D19) für den Offline-Fall bereit. Drei Folgen, alle Absicht: + * + * - Die Update-Prüfung (D45) bleibt wahr: ihr fetch() ist keine Navigation + * und läuft unangefasst ans Netz; „Jetzt laden" ist eine Navigation und + * bekommt network-first die frische Fassung. Kein skipWaiting-Tanz, + * keine zweite Update-Logik. + * - ?sourceUrl=- und Pad-Abrufe (D23/D31) werden nie abgefangen — der + * Worker fasst ausschließlich die Navigation zur App-Wurzel an; llms.md, + * llms.txt und alles Fremde gehen unverändert durch. + * - Diese Datei ändert sich praktisch nie: Die App kommt vom Server, nicht + * aus dem Worker — es gibt keine Versionsnummer, die hier gepflegt werden + * müsste. + */ +const CACHE = 'werkbaum-shell'; + +self.addEventListener('install', e => { + /* Die Shell sofort vorhalten, damit Offline schon nach dem ersten Besuch + funktioniert (die erste Navigation lief noch ohne Worker). {cache: + 'reload'} umgeht den HTTP-Cache — vorgehalten wird, was der Server + JETZT sagt. Scheitert das (Installation offline), bleibt der Cache + leer und die nächste erfolgreiche Navigation füllt ihn. */ + e.waitUntil( + caches.open(CACHE) + .then(c => fetch('./', { cache: 'reload' }) + .then(r => { if (r.ok) return c.put('./', r); })) + .catch(() => {}) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', e => { + e.waitUntil(self.clients.claim()); +}); + +self.addEventListener('fetch', e => { + /* Nur die Navigation zur App selbst; alles andere geht unangefasst durch. */ + if (e.request.mode !== 'navigate') return; + const path = new URL(e.request.url).pathname; + const scopePath = new URL(self.registration.scope).pathname; + if (path !== scopePath && path !== scopePath + 'index.html') return; + + e.respondWith((async () => { + try { + const r = await fetch(e.request); + if (r.ok) { + const copy = r.clone(); + e.waitUntil(caches.open(CACHE).then(c => c.put('./', copy))); + } + return r; + } catch (_) { + const m = await caches.match('./'); + return m || Response.error(); + } + })()); +}); diff --git a/frontend/src/app.js b/frontend/src/app.js index e41c028..61e8689 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -4568,9 +4568,40 @@ try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_ applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */ initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */ if(hasFsAccess) idbLoadHandles(); /* gemerkte Datei-Handles zurückholen (D72, Stufe 2) */ + +/* PWA-Dateihandling (D73): Die installierte App registriert sich über das + Manifest (`file_handlers`) für .werkbaum/.txt; ein Doppelklick im + Dateimanager reicht die Datei als Handle über die launchQueue herein. + `adoptFile()` (D72) übernimmt — dieselbe Datei landet im selben Dokument, + und das Handle macht „Als Datei speichern" dialogfrei. Außerhalb einer + installierten Chromium-App gibt es die launchQueue nicht: dann ist der + Block ein No-op. */ +if('launchQueue' in window){ + window.launchQueue.setConsumer(async params => { + for(const h of (params && params.files) || []){ + try{ + const text = await (await h.getFile()).text(); + await adoptFile(h, h.name, text); + }catch(_){ /* nicht lesbar — still, wie beim Picker-Abbruch (D72) */ } + } + }); +} applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */ +/* ---------- PWA: Service Worker (D73) ---------- + Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der + Cache hält nur die zuletzt gesehene Fassung — die Update-Prüfung (D45) + bleibt dadurch unverändert wahr. NICHT im Dev-Server registrieren: dort + würde der Worker die HMR-Seite cachen; der Zweig fällt im Dev als toter + Code weg. Relativer Pfad, damit der Scope dem Auslieferungsort folgt + (Pages unter /werkbaum/, prod an der Wurzel). Auf file:// und http gibt es + keinen (nutzbaren) serviceWorker — das Scheitern ist geschluckt, die App + läuft ohne. */ +if(!import.meta.env.DEV && 'serviceWorker' in navigator){ + navigator.serviceWorker.register('sw.js').catch(() => {}); +} + /* ---------- Build-Hinweis (Vorschau/Dev + „latest build") ---------- Kennzeichnet einen nicht-produktiven Build mit einem kleinen Symbol samt Tooltip hinter dem Titel. Gesteuert per Vite-Env `VITE_BUILD_BADGE`; ohne diff --git a/scripts/deploy-prod.sh b/scripts/deploy-prod.sh index 14017c3..1475cef 100755 --- a/scripts/deploy-prod.sh +++ b/scripts/deploy-prod.sh @@ -138,6 +138,11 @@ cp frontend/dist/llms.md "$STAGE/llms.md" # llms.md, SPEC und Repo zeigt. Nur er liegt an der Adresse, die Agenten von # selbst probieren. cp frontend/dist/llms.txt "$STAGE/llms.txt" +# PWA-Hülle (D73): Manifest + Icons + Service Worker — public/-Assets neben +# der einen Datei +cp frontend/dist/manifest.webmanifest "$STAGE/manifest.webmanifest" +cp frontend/dist/icon-192.png frontend/dist/icon-512.png frontend/dist/icon-maskable-512.png "$STAGE/" +cp frontend/dist/sw.js "$STAGE/sw.js" # Ohne diese Zuordnung liefert Apache `.md` ohne Content-Type aus und der # Browser rät windows-1252 (D43-Nachtrag 2). Pages braucht sie nicht. cp scripts/prod.htaccess "$STAGE/.htaccess" diff --git a/scripts/prod.htaccess b/scripts/prod.htaccess index a31633d..d1a7436 100644 --- a/scripts/prod.htaccess +++ b/scripts/prod.htaccess @@ -20,4 +20,7 @@ AddType text/markdown;charset=utf-8 .md AddType text/plain;charset=utf-8 .txt AddType text/plain;charset=utf-8 .werkbaum + # PWA-Manifest (D73): ohne Zuordnung liefert Apache die unbekannte Endung + # ohne Content-Type aus, und Chromium verwirft das Manifest dann still. + AddType application/manifest+json .webmanifest