Der Hintergrund-Takt aus dem letzten Commit funktionierte in der Praxis nicht:
im Netzwerk-Mitschnitt stapelten sich Anfragen und wurden abgebrochen
("cancelled"). Die Ursache ist nicht Langsamkeit, sondern ein Rate Limit —
Etherpads `importExportRateLimiting` ist serienmäßig an und lässt 10 Abrufe je
90 s und IP zu; der Takt wollte 36. Jenseits davon antwortet die Gegenseite
nicht mit 429, sondern hält die Verbindung ohne Antwort offen. Nachgemessen:
12:48:12 Abbruch nach 25 s (0 Bytes)
Abbruch nach 25 s (0 Bytes)
12:50:23 HTTP 200 nach 0,436 s <- nach ~40 s Pause
Zwei Minuten totgestellt, dann sofort in 0,4 s da. Gegen eine Drosselung kann
ein Takt nicht gewinnen, er erzeugt sie. Also ein Knopf. Damit entfallen
Stabilitätstakt, Sichtbarkeits-Wächter, Anti-Stapel-Riegel und Wiederanlauf.
Der Knopf greift zudem gut mit "Was ist neu?" (D28) zusammen: drücken, und was
seither in Produktion ging, leuchtet auf.
Verhindern UND melden, beides:
- Mindestabstand 10 s zwischen zwei Abrufen. Das ergibt höchstens 9 je 90 s und
bleibt damit beweisbar unter Etherpads Budget — die Drosselung wird gar nicht
erst ausgelöst. Nach einem Abbruch sind wir schon drüber: dann das ganze
Fenster (90 s) abwarten statt weiter dagegen zu rennen.
- Der Knopf bleibt in der Sperre **klickbar** (nur `aria-disabled`, nicht
`disabled`): Ein Klick meldet den Grund und nennt die Grenze samt Restzeit.
Die Meldung landet in `#warn`, das eine Live-Region ist, wird also angesagt.
Ein `disabled` erklärt nichts.
- Eigener Warnungstyp `sourceTimeout` für den Abbruch: `sourceLoad` zeigt auf
CORS und schickte hier auf die falsche Fährte.
- `setPadBusy()` dreht das Symbol während des Abrufs — das können bis zu 20 s
sein, und ohne Zeichen wirkt der Knopf kaputt.
Zwei Fehler des Vorgänger-Commits fielen dabei auf:
- `padSource` und der Takt standen HINTER dem `await` des ersten Abrufs. Ein
einziger Fehlschlag — bei dieser Gegenseite der Normalfall — ließ das Dokument
tot liegen, ohne Knopf und ohne Wiederversuch, bis zum Neuladen der Seite.
Genau das war die vom Nutzer gemeldete Fehlermeldung.
- Kein Erfolg räumte die Warnung des ersten Versuchs weg; sie hätte gestanden,
während der Text längst da war.
Dazu: `deleteDoc()` beendet die Pad-Quelle, sonst legte ein späterer Abruf das
gelöschte Dokument wieder an und aktivierte es auch gleich.
Nebenbefunde, beide nachgemessen und in D31 festgehalten:
- Das *Tippen* im Etherpad-Editor war der letzte offene Punkt. Der Nutzer hat
` - [ ] Layout` eingerückt eingetippt; der Export gibt es zeichengenau
zurück (vier echte Leerzeichen, kein Tab, kein Listen-Markup).
- Einbetten verweigert der Server nicht: kein `X-Frame-Options`, keine CSP mit
`frame-ancestors`, und im Versuch baute das Pad in einem fremdstämmigen Rahmen
seine Editorframes auf. Preis ist `SameSite=Lax` am Autoren-Cookie (jedes
Laden ein neuer Autor; nur serverseitig zu beheben) und der Verlust beider
Richtungen von D25, falls das Textpanel ersetzt wird.
86 Tests (85 -> 86); Schreibschutz zeigt den Text jetzt in Tinte statt grau —
hier wird gelesen, der Plantext ist der Hauptinhalt.
255 lines
12 KiB
Markdown
255 lines
12 KiB
Markdown
<p>
|
||
<img src="docs/brand/logo.svg" width="72" alt="Werkbaum logo">
|
||
</p>
|
||
|
||
# Werkbaum
|
||
|
||
**English** · [Deutsch](README.de.md)
|
||
|
||
**▶ Try it live: <https://werkbaum.javagil.de>** (stable) · <https://mhoennig.github.io/werkbaum/> (latest build 🚧)
|
||
|
||
A textual, Markdown-like notation for work breakdown structures with
|
||
and/or decomposition — plus a live editor that renders it as a diagram.
|
||
|
||
```
|
||
[~] Werkbaum (XL) https://wiki.example.de/relaunch
|
||
- [~] Document store
|
||
| [x] Text file with copy+paste in the frontend (S)
|
||
- [x] Parser
|
||
- [x] Text input field in the frontend
|
||
| [ ] Backend
|
||
- [~] Display/rendering (XL)
|
||
- [/] H (S) @anna
|
||
- [ ] CMS integration (M)
|
||
| [ ] WordPress
|
||
| [?] Headless CMS
|
||
```
|
||
|
||
`-` = mandatory sub-package (all of, side by side in the diagram) ·
|
||
`|` = alternative (any of, stacked) · `[…]` = status ·
|
||
`(M)` = T-shirt effort · `@name` = responsibility · `%%` = comment.
|
||
|
||
## Usage
|
||
|
||

|
||
|
||
Open the [hosted editor](https://werkbaum.javagil.de) — edit text on
|
||
the left, the diagram is built live on the right. Toggles: transposed (narrow)
|
||
layout, show discarded elements.
|
||
|
||
### Jumping between diagram and text
|
||
|
||
Diagram and editor are linked in both directions:
|
||
|
||
- **Alt+click a node** (keyboard: **Alt+Enter**, touch: **long press**) selects
|
||
its line in the text editor — collapsed editor panel opens first.
|
||
- **Moving the cursor in the text** highlights the matching node in the diagram.
|
||
|
||
A plain click keeps its old meaning: a node carrying a URL still opens it.
|
||
|
||
### Loading a diagram from a URL
|
||
|
||
The editor can pull its notation text from an external text file via the
|
||
`sourceUrl` query parameter — handy for sharing a plan or keeping the source in
|
||
Git or a wiki:
|
||
|
||
```
|
||
https://mhoennig.github.io/werkbaum/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-0.werkbaum
|
||
```
|
||
|
||
Ready-made examples in [`docs/examples/`](docs/examples/) — **open them one
|
||
after another**, each becomes its own document, so afterwards you can switch
|
||
between all of them in the editor title bar:
|
||
|
||
| Example | Shows |
|
||
|---|---|
|
||
| **▶ [0 · Online shop relaunch](https://mhoennig.github.io/werkbaum/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-0.werkbaum)** | a software project with all eight states |
|
||
| **▶ [1 · New kitchen](https://mhoennig.github.io/werkbaum/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-1.werkbaum)** | a non-software plan, lots of alternatives — good for the cheapest-path toggle |
|
||
| **▶ [2 · Community conference](https://mhoennig.github.io/werkbaum/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-2.werkbaum)** | a wide plan with many people; compare horizontal vs. compact |
|
||
| **▶ [3 · Three workstreams](https://mhoennig.github.io/werkbaum/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-3.werkbaum)** | several roots = several trees side by side |
|
||
| **▶ [Werkbaum itself](https://mhoennig.github.io/werkbaum/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-werkbaum.werkbaum)** | what exists today and where it could go |
|
||
|
||
(The links point at the latest build 🚧; the stable instance supports
|
||
`sourceUrl` from its next production deploy on.)
|
||
|
||
The loaded text becomes its own document whose **name is the URL**, so your own
|
||
documents stay untouched; the same link updates that one document instead of
|
||
piling up copies. The URL is the source of truth — it is re-fetched on every
|
||
load, so local edits to it do not survive a reload.
|
||
|
||
Notation files use the extension **`.werkbaum`** (UTF-8; see `docs/SPEC.md` §12
|
||
and D24) — a convention, not a contract: `sourceUrl` reads any text file over
|
||
http(s) regardless of extension or content type.
|
||
|
||
**Caveat — CORS:** the browser only fetches a foreign host if it sends
|
||
`Access-Control-Allow-Origin`. `raw.githubusercontent.com` and GitLab raw links
|
||
do; an arbitrary web server often does not. If loading fails the previous
|
||
content stays and a warning explains why. Only `http`/`https` are allowed.
|
||
|
||
### Working on one plan together (Etherpad)
|
||
|
||
For real-time collaboration Werkbaum needs no backend of its own — it borrows an
|
||
**Etherpad**. Pass the pad address as it appears in your browser (no export path,
|
||
Werkbaum appends that itself):
|
||
|
||
```
|
||
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/my-plan
|
||
```
|
||
|
||
Everyone edits the notation text **in the pad**; a reload button next to the pad
|
||
button fetches the current state. Merging simultaneous edits is Etherpad's job —
|
||
that is the whole point.
|
||
|
||
Deliberately **no background polling**: Etherpad rate-limits the export (10
|
||
fetches per 90 s per IP by default), so a timer does not win against it — it
|
||
*causes* the throttling. Measured: after exceeding the budget the server holds
|
||
the connection open with no reply for about two minutes, then answers in 0.4 s.
|
||
The button pairs well with "what's new": press it, and whatever went into
|
||
production since your last look lights up.
|
||
|
||
Because the pad is the writing surface, the text area here is **read-only**; a
|
||
button in the editor title bar opens the pad in a new tab. Without that
|
||
protection, anything you typed would vanish on the next fetch.
|
||
|
||
Verified against Etherpad: the plain-text export sends
|
||
`Access-Control-Allow-Origin: *`, and the notation comes back **byte-identical** —
|
||
leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage
|
||
model, and `-` is not turned into a bullet list.
|
||
|
||
**Be aware:** your plan text then lives on third-party infrastructure, and a pad
|
||
is readable by anyone who knows its address. See `docs/DECISIONS.md` D31.
|
||
|
||
### Running it locally
|
||
|
||
The editor source now lives as ES modules under `frontend/src/`, bundled by
|
||
[Vite](https://vitejs.dev/) (see `docs/DECISIONS.md` D19). Because browsers block
|
||
ES-module imports over `file://`, opening `frontend/index.html` directly no
|
||
longer works — use one of:
|
||
|
||
```bash
|
||
cd frontend
|
||
npm install # once
|
||
npm run dev # dev server at http://localhost:8137
|
||
npm test # Vitest unit tests
|
||
npm run build # -> frontend/dist/index.html (single self-contained file)
|
||
```
|
||
|
||
The built `dist/index.html` inlines all JS, CSS and the favicon, so **that** file
|
||
does open standalone via `file://` and is what gets deployed.
|
||
|
||
### Build hint & your own production install
|
||
|
||
Non-production builds carry a small hint next to the title (symbol + tooltip) so
|
||
it's clear this is **not** the stable instance:
|
||
|
||
- **Dev server** (`npm run dev`) → 🔧 "Preview – local development build"
|
||
- **Default build** (`npm run build`, incl. the GitHub Pages deploy) → 🚧
|
||
"Latest build – may still be buggy"
|
||
|
||
For **your own production install** the hint is switched off:
|
||
|
||
```bash
|
||
cd frontend
|
||
npm ci # or: npm install
|
||
npm run build:prod # -> frontend/dist/index.html WITHOUT the hint
|
||
```
|
||
|
||
`build:prod` runs Vite in mode `prod`; `frontend/.env.prod` sets
|
||
`VITE_BUILD_BADGE=none`, so the badge code is tree-shaken away entirely (it isn't
|
||
even present in the output source). Drop the resulting `dist/index.html` onto your
|
||
web space/server (standalone, `file://`-capable). Details: `app.js`
|
||
(`mountBuildBadge`), `docs/DECISIONS.md` D16.
|
||
|
||
Two things otherwise handled only by the Pages workflow, to fix up yourself when
|
||
self-hosting: the footer **MIT-License** link is relative to `../LICENSE` (place
|
||
that file one level above `index.html`, or adjust the link), and the **version
|
||
number** stays the source placeholder `1.0` (the workflow otherwise replaces it
|
||
from `VERSION` + commit count).
|
||
|
||
**Easier: `scripts/deploy-prod.sh`.** The script runs exactly that prod build
|
||
**and** both fix-ups (drop `LICENSE` alongside + straighten the link, footer
|
||
version + commit link just like the Pages workflow) and mirrors the result to a
|
||
server via rsync/SSH:
|
||
|
||
```bash
|
||
scripts/deploy-prod.sh mih00@mih00.hostsharing.net:~/doms/werkbaum.javagil.de/htdocs-ssl
|
||
```
|
||
|
||
The target is either this argument or — with no argument — the `DEPLOY_TARGET`
|
||
variable from the **git-ignored** file `.env` (template:
|
||
`.env.example`, copy it once and fill in the path). An argument
|
||
takes precedence.
|
||
|
||
Without `-y` it first shows a `--dry-run` preview and asks for confirmation.
|
||
`rsync --delete` ensures **nothing old** is left at the target — so the target
|
||
directory is treated as exclusive to Werkbaum (an in-flight Let's Encrypt
|
||
challenge under `.well-known/` is spared via `--filter=protect`, and web-friendly
|
||
755/644 permissions are enforced). (Hostsharing: a **directly-served** domain is
|
||
served from `…/htdocs-ssl/`; as a **subdomain** under another domain the web
|
||
directory would be `…/subs-ssl/<name>/`.)
|
||
|
||
## Project documents
|
||
|
||
- `frontend/` — editor · `backend/` — Kotlin/Spring (scaffold to follow, see backend/README.md)
|
||
- `docs/SPEC.md` — normative language definition
|
||
- `docs/DECISIONS.md` — design decisions with rationale
|
||
- `docs/ROADMAP.md` — Mermaid plugin, Taiga integration, Tenzu
|
||
- `docs/TASKS.md` — open tasks (checkboxes)
|
||
- `docs/brand/BRAND.md` — logo, wordmark, usage rules
|
||
- `docs/design/` — design derivation of the brand
|
||
- `CLAUDE.md` — project context for Claude Code
|
||
|
||
> **Note:** The detailed project documentation under `docs/` is maintained in
|
||
> German, the project's source language (see `CLAUDE.md`). This README is
|
||
> available in [English](README.md) and [German](README.de.md).
|
||
|
||
## Deployment
|
||
|
||
The editor is published as a static page on **GitHub Pages** via GitHub
|
||
Actions (workflow: `.github/workflows/pages.yml`). Triggered on every push to
|
||
`main` and manually (`workflow_dispatch`).
|
||
|
||
The workflow sets up Node, runs `npm ci`, `npm test` (Vitest) and `npm run build`
|
||
(Vite), then publishes the bundled `frontend/dist/index.html` as `index.html` at
|
||
the root URL, plus `LICENSE` for the MIT link in the footer. The favicon is
|
||
already inlined into the build, so nothing else needs copying; only the runtime
|
||
`../LICENSE` link is straightened on the copy. A failing test blocks the deploy.
|
||
`backend/` and the remaining `docs/` are not published.
|
||
|
||
While assembling the site, the workflow also stamps the version into the footer:
|
||
**major.minor** comes from the `VERSION` file (bumped by an explicit "bump
|
||
commit"), and the **micro** part is the number of commits since that last bump —
|
||
so it grows with every commit and resets to `0` right after a bump
|
||
(`Werkbaum 1.0.0`, `1.0.1`, … then bump `VERSION` to `1.1` → `1.1.0`). Nothing is
|
||
written back to the repo. In the footer the name **Werkbaum** links to the
|
||
repository, while the **version number** links to that exact commit
|
||
(`…/commit/<sha>`). Opened locally, the editor shows the source placeholder
|
||
(`Werkbaum 1.0`).
|
||
|
||
**One-time setup:** In the repo settings under **Pages**, select **Source** =
|
||
"GitHub Actions". The repo must be **public** for this (GitHub Pages via Actions
|
||
is only available for private repos on a paid plan).
|
||
|
||
### Stable instance
|
||
|
||
`scripts/deploy-prod.sh` mirrors a badge-free production build to a server over
|
||
SSH (target in `.env`, template `.env.example`). Unlike Pages this is a
|
||
**deliberate** step, so it is also the moment a feature actually goes live.
|
||
|
||
The script therefore starts by running `scripts/promote-shipped.sh`, which turns
|
||
`[x]` (done) into `[^]` (in production) in `docs/examples/example-werkbaum.werkbaum`
|
||
— the shipped plan describing Werkbaum itself — and records that as its own
|
||
commit. The convention is: mark a finished feature `[x]` when it is merged and
|
||
let the deploy promote it. That keeps the plan honest on both instances and
|
||
keeps the "what's new" highlighting meaningful. Skip it with `--no-promote`; see
|
||
[docs/DECISIONS.md](docs/DECISIONS.md) D30.
|
||
|
||
The commit is **not** pushed automatically — run `git push` afterwards, or the
|
||
footer version link points at a commit GitHub does not know yet.
|
||
|
||
## License
|
||
|
||
MIT — see [LICENSE](LICENSE). © 2026 Michael Hönnig. The bundled IBM Plex fonts
|
||
are under the SIL Open Font License 1.1 (see [LICENSE](LICENSE) and
|
||
`frontend/src/fonts/OFL.txt`).
|