diff --git a/docs/LOGSCAN_AND_TRIGGERS_TODO.md b/docs/LOGSCAN_AND_TRIGGERS_TODO.md
new file mode 100644
index 0000000..cda7398
--- /dev/null
+++ b/docs/LOGSCAN_AND_TRIGGERS_TODO.md
@@ -0,0 +1,385 @@
+# Log Scanner + Event Triggers — Design Handoff
+
+Two related features. They can ship independently, but were designed together
+because they share the event_log seam.
+
+- **A. Log scanner** — tail container logs, match against rules, emit event_log
+ entries. Producer of events.
+- **B. Event triggers** — turn event_log entries into webhook / notification
+ dispatches. Consumer of events. Generalizes the existing
+ `RegisterPersistentLogger` pattern.
+
+Either half is useful alone:
+- A without B = errors get surfaced in the events UI, no external delivery.
+- B without A = manual + reconciler + deploy events can drive notifications.
+
+Recommended ship order: B first (smaller, self-contained generalization), then
+A (more moving parts, depends on container-lifecycle hooks).
+
+---
+
+## A. Log scanner — BACKEND LANDED
+
+Status:
+
+- **Schema + store CRUD** — `internal/store/log_scan_rules.go` +
+ `log_scan_rules` table added to the `observabilityTables` block.
+ Includes the `EffectiveLogScanRules(workloadID)` helper that
+ resolves global rules minus per-workload overrides plus workload-
+ only additions in one Go-side pass.
+- **Stream-selectable docker reads** — `internal/docker/container.go`
+ `ContainerLogsOpts` accepts a `ContainerLogOptions{ShowStdout,
+ ShowStderr, Follow, Tail}` so the scanner can subscribe to one
+ stream when a rule scopes itself to stdout or stderr. The legacy
+ `ContainerLogs` is preserved as a thin wrapper for back-compat.
+- **Engine** — `internal/logscanner/engine.go`: per-rule cooldown
+ (keyed on container+rule), per-container token bucket (default 10
+ events / 60s, override-able), regex match per line, hits returned
+ for the manager to persist. Pure logic, fully unit-tested.
+- **Tail goroutine** — `internal/logscanner/tail.go`: per-container
+ loop reading docker's multiplexed log frames (with TTY fallback),
+ strips the prepended RFC3339 timestamp, runs every line through the
+ engine + snapshot. Exits on container stop or context cancel.
+- **Manager** — `internal/logscanner/manager.go`: 5s polling diff
+ against `ListContainers(state=running)`, atomic.Pointer[Snapshot]
+ hot-reload, structural HitEmitter that writes event_log rows AND
+ publishes `EventLog` on the bus (so event-trigger dispatchers can
+ pick them up immediately).
+- **API** — `internal/api/log_scan_rules.go`: full CRUD,
+ `/test` endpoint accepting `{"sample_line": "..."}` and returning
+ matched/captures, plus
+ `GET /api/workloads/{id}/effective-rules` for the workload detail
+ page's future Log Rules tab. Admin-gated mutations.
+- **Wired in main.go** before the API server is constructed so the
+ reload callback is plugged via `apiServer.SetLogScanReloader`.
+- **Loop-prevention** — Same boundary as feature B: scanner publishes
+ EventLog events, dispatcher consumes them, neither writes to
+ event_log on the consume side.
+- **Tests** — `internal/logscanner/{engine,rules}_test.go` cover
+ cooldown isolation, token bucket refill, stream filtering,
+ override-replaces-global, disabled-override-suppresses-global,
+ compile-error reporting. `internal/store/log_scan_rules_test.go`
+ covers validation + cascade delete.
+
+**Frontend still pending** — `/log-scan-rules` pages, regex test box
+component, Log Rules tab on `/apps/[id]`, i18n keys. Not touched this
+turn.
+
+### Where it plugs in
+
+[internal/docker/container.go:362](../internal/docker/container.go#L362) already
+exposes `ContainerLogs(ctx, id, follow=true, tail)`. The existing SSE handler at
+[internal/api/workloads.go:43](../internal/api/workloads.go#L43)
+(`streamWorkloadContainerLogs`) is per-viewer and dies on browser disconnect —
+**do not hook the scanner there**. The scanner is a separate long-lived
+subsystem owned by the server process.
+
+Minor required change to `ContainerLogs`: expose `ShowStdout` / `ShowStderr` as
+caller-controlled. Currently hardcoded to `true`/`true`. Single existing caller
+passes "both" → no friction. Add an options struct or two booleans.
+
+### New package: `internal/logscanner/`
+
+```
+internal/logscanner/
+ manager.go — Manager: map[containerID]*tail, lifecycle hooks
+ tail.go — per-container goroutine; reads logs, fans to engine
+ engine.go — rule evaluation + cooldown + rate limit
+ rules.go — Rule struct, regex compile cache, effective-set resolver
+```
+
+**Manager lifecycle.** Subscribes to container start/stop signals. Options for
+the signal source:
+1. Add a `ContainerStarted` / `ContainerStopped` event type to the bus and
+ publish from the reconciler + deployer. Cleanest, but adds two event types.
+2. Manager polls `docker.ListContainers` every N seconds and diffs. Lazier,
+ robust to missed signals, slightly higher idle CPU. Probably fine.
+
+Pick (1) if you want zero-latency start, (2) if you want fewer moving parts.
+Defaulting to **(2) with 5s poll** — Docker container starts already take
+seconds; sub-second matching is not a requirement.
+
+**Tail goroutine.** On container start: open `ContainerLogs(follow=true,
+tail="0")` with stdout/stderr filters per rules in scope. Read line-by-line via
+`bufio.Scanner`. For each line: run through engine. On container stop or ctx
+cancel: drain and exit.
+
+**Engine.** Holds compiled regexes per rule. For each line:
+- Walk effective ruleset for this workload (see schema below).
+- For each matching rule: check cooldown (`map[ruleID]time.Time`, mutex
+ guarded). If cooled down, insert event_log row + publish + update timestamp.
+- Per-container token bucket (default: 10 events/min/container) to prevent
+ catastrophic event_log floods if a regex is too greedy.
+
+### Schema
+
+Single table, global + override pattern. No separate "overrides" table.
+
+```sql
+CREATE TABLE log_scan_rules (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ workload_id TEXT, -- NULL = global rule
+ overrides_id INTEGER, -- if set, this row overrides a global rule for one workload
+ name TEXT NOT NULL,
+ pattern TEXT NOT NULL, -- regex, compiled at load
+ severity TEXT NOT NULL, -- info|warn|error
+ streams TEXT NOT NULL DEFAULT 'all', -- all|stdout|stderr
+ cooldown_seconds INTEGER NOT NULL DEFAULT 60,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY (workload_id) REFERENCES workloads(id) ON DELETE CASCADE,
+ FOREIGN KEY (overrides_id) REFERENCES log_scan_rules(id) ON DELETE CASCADE
+);
+CREATE INDEX idx_log_scan_rules_workload ON log_scan_rules(workload_id);
+CREATE INDEX idx_log_scan_rules_overrides ON log_scan_rules(overrides_id);
+```
+
+**Effective ruleset for workload X:**
+1. All rows where `workload_id IS NULL AND overrides_id IS NULL` (pure globals),
+ *minus* any global that has a row with `workload_id = X AND overrides_id = global.id`.
+2. Plus all rows where `workload_id = X AND overrides_id IS NULL` (workload-only additions).
+3. Plus all override rows where `workload_id = X AND overrides_id IS NOT NULL`
+ (substitute for the global; their fields win, including `enabled=false` to
+ disable the global for this workload).
+
+A pure SQL implementation is doable with a `LEFT JOIN ... WHERE override.id IS
+NULL` for step 1 plus a `UNION ALL` for steps 2 and 3. Or compute in Go after
+two simpler queries — fine since rule counts will be small.
+
+### Output
+
+Scanner calls `store.InsertEvent` with:
+- `Source = "logscan"`
+- `Severity` from the matched rule
+- `Message` = raw matched line (truncated to ~500 chars)
+- `Metadata` JSON = `{"workload_id": ..., "container_id": ..., "rule_id": ..., "rule_name": ..., "captures": {...}}`
+
+Then `bus.Publish(EventLog, payload)`. This reuses exactly the path
+[internal/events/bus.go:158](../internal/events/bus.go#L158)
+(`RegisterPersistentLogger`) already established. SSE clients see it live, and
+the dispatcher from feature B picks it up.
+
+### Hot-reload
+
+When a rule is created/updated/deleted via the API, the manager must rebuild
+the effective ruleset for affected containers. Cheapest path: a single
+`*atomic.Pointer[ruleSnapshot]` shared across tails, replaced wholesale on any
+rule change. Each tail dereferences the snapshot per line — no locking on the
+hot path.
+
+---
+
+## B. Event triggers — BACKEND LANDED
+
+Status:
+
+- **Schema + store CRUD** — `internal/store/event_triggers.go` + table
+ creation in `internal/store/store.go` `observabilityTables`. Model:
+ `EventTrigger` in `internal/store/models.go`.
+- **Dispatcher** — `internal/events/dispatcher.go`
+ `RegisterEventTriggerDispatcher(bus, triggerSource, notifier)`.
+ Filter eval is AND-composed across severity (CSV), source (CSV), and
+ optional message regex. Compiled regexes are memoized.
+- **Webhook delivery** — extended `notify.Notifier` with
+ `SendPayload(url, secret, eventType, payload)` which reuses the
+ existing HMAC + headers infra (`X-Hub-Signature-256`, etc.). New
+ `TierEventTrigger` tier is recorded for telemetry / audit.
+- **Loop-prevention** — dispatcher does **not** call `InsertEvent`.
+ Delivery outcomes go through the notifier's existing logging only.
+- **API** — `internal/api/event_triggers.go` with admin-gated mutations:
+
+```http
+GET /api/event-triggers
+POST /api/event-triggers
+GET /api/event-triggers/{id}
+PATCH /api/event-triggers/{id}
+DELETE /api/event-triggers/{id}
+POST /api/event-triggers/{id}/test — synthetic event_log → notifier.SendSyncForTest
+```
+
+- **Wired in main.go** next to `RegisterPersistentLogger`.
+- **Tests** — `internal/events/dispatcher_test.go`: 10 cases covering
+ filter eval, regex caching, dispatcher fan-out, unsupported
+ action_type, trigger-source errors. CSV filter helper has dedicated
+ table-driven coverage.
+
+**Frontend still pending** — `/event-triggers` list + detail + new
+pages, the Send-test UX, i18n keys. Not touched this turn.
+
+### Where it plugs in
+
+Mirrors the `RegisterPersistentLogger` shape at
+[internal/events/bus.go:158](../internal/events/bus.go#L158):
+
+```go
+func RegisterEventTriggerDispatcher(b *Bus, triggers TriggerSource, notifier Notifier) func() {
+ sub := b.Subscribe(func(evt Event) bool { return evt.Type == EventLog })
+ go func() {
+ for evt := range sub {
+ payload, ok := evt.Payload.(EventLogPayload)
+ if !ok { continue }
+ for _, t := range triggers.Enabled() {
+ if t.matches(payload) {
+ notifier.Send(t.ActionTarget, buildBody(t, payload))
+ }
+ }
+ }
+ }()
+ return func() { b.Unsubscribe(sub) }
+}
+```
+
+Reuses the existing notifier at
+[internal/notify/notifier.go](../internal/notify/notifier.go) — including the
+signed-delivery and `webhook_deliveries` audit trail.
+
+### Schema
+
+```sql
+CREATE TABLE event_triggers (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ name TEXT NOT NULL,
+ filter_severity TEXT, -- nullable; comma-list like 'warn,error'
+ filter_source TEXT, -- nullable; comma-list like 'logscan,deploy'
+ filter_message_regex TEXT, -- nullable; matched against message
+ action_type TEXT NOT NULL, -- 'webhook' | 'notification_channel'
+ action_target TEXT NOT NULL, -- URL or channel ID
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL
+);
+```
+
+Filters AND together. Empty filters match all.
+
+### Loop-prevention
+
+**Critical constraint: the dispatcher must not write to event_log.** All
+delivery successes / failures land in `webhook_deliveries` (existing table) so
+the audit trail is preserved without risking trigger recursion. Keeps the
+boundary crisp:
+
+- `event_log` = system observing itself
+- `webhook_deliveries` = system talking to the outside
+
+If a user-visible "trigger fired" entry is desired in the events UI, add a
+*read-only join* from `webhook_deliveries` into the events page rather than
+writing event_log rows.
+
+---
+
+## What to defer
+
+| Item | Why | Add when |
+|---|---|---|
+| Multi-line stack trace coalescing | Real rabbit hole (which lines belong together?). | Real user pain. |
+| Capture-group templating in messages (`{{.captures.code}}`) | v1 stores captures in metadata, displays raw line. | Once real rules exist and patterns emerge. |
+| Backfilling history search | This is Loki/Grafana scope-creep. | Never (push to Loki instead if it comes up). |
+| Per-rule alert routing | v1 fans out by `(severity, source)` filter on trigger side. | When users want one rule → one channel. |
+| YAML config-as-code | Tinyforge is UI-driven everywhere else. | Probably never. |
+| Retry / backoff on trigger delivery failure | Notifier already handles delivery; whether *triggers* retry is a separate question. | If trigger reliability becomes an SLO. |
+
+---
+
+## UI footprint
+
+All boolean inputs use `ToggleSwitch` per project CLAUDE.md. All destructive
+actions use `ConfirmDialog` per memory note (no inline Yes/No strips).
+
+### New pages
+
+- **`/log-scan-rules`** — list with severity / workload filter, "+ New rule" button.
+ - Detail page: name, pattern (regex with live test box that takes a sample log line), severity, streams, cooldown, enabled toggle, scope picker (global / workload).
+- **`/event-triggers`** — list, "+ New trigger" button.
+ - Detail page: name, filters (severity multiselect, source multiselect, optional message regex), action type, action target, enabled toggle.
+
+### Augmentations
+
+- **Workload detail page** (`/apps/[id]`): new "Log Rules" tab/panel listing
+ effective rules for this workload. Each global shows an "Override for this
+ workload" button. Each override / workload-only shows edit + delete.
+- **Events page** (`/events`): entries with `source=logscan` get a small icon
+ + tooltip showing rule name. Click → jumps to rule detail.
+- **Settings sidebar**: links to `/log-scan-rules` and `/event-triggers` under
+ a new "Observability" group.
+
+### i18n keys to add
+
+Roughly 40–60 keys across `en.json` + `ru.json`. Namespace: `logscan.*` and
+`triggers.*`.
+
+---
+
+## API surface
+
+```
+GET /api/log-scan-rules — list (filter: ?workload_id=, ?global=true)
+POST /api/log-scan-rules — create
+GET /api/log-scan-rules/{id} — detail
+PATCH /api/log-scan-rules/{id} — update
+DELETE /api/log-scan-rules/{id} — delete
+POST /api/log-scan-rules/{id}/test — body: {sample_line}; returns matched: bool, captures
+GET /api/workloads/{id}/effective-rules — computed effective ruleset for a workload
+
+GET /api/event-triggers — list
+POST /api/event-triggers — create
+GET /api/event-triggers/{id} — detail
+PATCH /api/event-triggers/{id} — update
+DELETE /api/event-triggers/{id} — delete
+POST /api/event-triggers/{id}/test — dispatches a synthetic event to verify the action target
+```
+
+`POST .../test` endpoints are worth shipping in v1 — they make the rule /
+trigger editing UX dramatically nicer and avoid "did I get the regex right?"
+deploy-and-pray cycles.
+
+---
+
+## File pointers (when work starts)
+
+**Backend, new:**
+- `internal/logscanner/{manager,tail,engine,rules}.go`
+- `internal/api/log_scan_rules.go`
+- `internal/api/event_triggers.go`
+- `internal/store/log_scan_rules.go`
+- `internal/store/event_triggers.go`
+- `internal/events/dispatcher.go` (or extend `bus.go` with `RegisterEventTriggerDispatcher`)
+
+**Backend, modified:**
+- [internal/docker/container.go:362](../internal/docker/container.go#L362) — expose stream selection on `ContainerLogs`
+- [internal/api/router.go](../internal/api/router.go) — register new routes
+- [cmd/server/main.go](../cmd/server/main.go) — wire `RegisterEventTriggerDispatcher` next to `RegisterPersistentLogger`, start `logscanner.Manager`
+- migrations: `internal/store/migrations/00XX_log_scan_rules.sql`, `00XX_event_triggers.sql`
+
+**Frontend, new:**
+- `web/src/routes/log-scan-rules/+page.svelte`, `[id]/+page.svelte`, `new/+page.svelte`
+- `web/src/routes/event-triggers/+page.svelte`, `[id]/+page.svelte`, `new/+page.svelte`
+- `web/src/lib/components/LogRulePanel.svelte` (workload detail tab)
+- `web/src/lib/components/RegexTestBox.svelte` (reusable)
+
+**Frontend, modified:**
+- `web/src/routes/apps/[id]/+page.svelte` — add Log Rules tab
+- `web/src/routes/events/+page.svelte` — logscan source icon + rule tooltip
+- `web/src/routes/+layout.svelte` — Observability nav group
+- `web/src/lib/i18n/{en,ru}.json` — new key namespaces
+- `web/src/lib/api.ts`, `web/src/lib/types.ts` — typed clients
+
+---
+
+## Open questions to revisit before coding
+
+1. **Container start/stop signal source** — bus events (low latency, two new
+ event types) vs polling (simpler, ~5s latency). Tentative: polling.
+2. **Trigger delivery retry** — does the dispatcher retry on webhook failure,
+ or is one shot enough since `webhook_deliveries` records failures? Tentative:
+ one shot v1; revisit if reliability complaints surface.
+3. **Where does the "logscan source icon" link go on the events page** — rule
+ detail page, or the workload's effective-rules tab? Latter is probably more
+ useful since it shows context.
+
+---
+
+## Memory pointer
+
+Add a memory after this lands describing the event_log = observe-self,
+webhook_deliveries = talk-to-outside boundary — it's the kind of invariant
+that's easy to violate accidentally when adding new event types later.
diff --git a/docs/WORKLOAD_REFACTOR_TODO.md b/docs/WORKLOAD_REFACTOR_TODO.md
new file mode 100644
index 0000000..23c7674
--- /dev/null
+++ b/docs/WORKLOAD_REFACTOR_TODO.md
@@ -0,0 +1,334 @@
+# Workload-First Refactor — Remaining Work
+
+Handoff for resuming the refactor. The plugin architecture (Source × Trigger),
+`/api/workloads` surface, `/apps` UI, env/volume/webhook/logs/chain panels,
+multi-face proxy routes, blue-green image deploys, schema-driven wizard, and
+test coverage on triggers / image helpers / webhook parser / store upserts are
+**already landed and live**. What follows is what's still pending, in priority
+order.
+
+## Status at a glance
+
+| Item | Priority | Status |
+| ---- | -------- | ------ |
+| Static source inline port | 1 | **PENDING** — only remaining blocker for hard cutover |
+| Hard legacy cutover | 1 | **PENDING** — gated by static port (volume scopes blocker is resolved) |
+| Generalized volume scopes | 2 | DONE |
+| Kind-aware editors (compose / image / static) | 2 | DONE |
+| Vendor-specific webhook parsing | 2 | DONE |
+| Chain-panel CSS | 3 | DONE |
+| Log Rules panel on `/apps/[id]` | adjacent | DONE — uses `getEffectiveLogScanRules` + per-workload override action |
+| i18n for `/apps/*` page strings | 3 | **PARTIAL** — Log Rules panel + Observability surfaces i18n'd; `apps.*` namespace still pending |
+| Docs / codemap entries for `internal/workload/plugin/` | 3 | **PENDING** |
+| API-handler / dispatcher / compose-source / static-backend tests | 4 | **PENDING** |
+| Triggers as first-class reusable entities (post-cutover) | 5 | **PENDING** |
+
+Cross-references to the adjacent Observability work (Event Triggers + Log
+Scanner backend + drop-counter stats panel) live in
+[docs/LOGSCAN_AND_TRIGGERS_TODO.md](LOGSCAN_AND_TRIGGERS_TODO.md).
+
+## Priority 1 — Architecture unlock
+
+### Static source inline port — ~2150 LOC across 8 files
+
+The current `internal/workload/plugin/source/static/` delegates to
+`staticsite.Manager` via a phantom-row adapter
+(`cmd/server/static_backend.go`) that keeps a synthetic row in the legacy
+`static_sites` table per workload. This works but blocks the hard cutover —
+you can't drop `static_sites` until the adapter is gone.
+
+To port inline, the deploy pipeline body has to move into
+`internal/workload/plugin/source/static/`:
+
+| Source file | Lines | What to keep / port |
+| --- | --- | --- |
+| `internal/staticsite/manager.go` | 834 | Deploy / Stop / status pipeline. State should move to `containers` rows + `workload_env` instead of `static_sites`. |
+| `internal/staticsite/gitea_content.go` | 360 | Keep as helper — Gitea content download/listing. |
+| `internal/staticsite/github_provider.go` | 276 | Keep as helper. |
+| `internal/staticsite/gitlab_provider.go` | 254 | Keep as helper. |
+| `internal/staticsite/healthcheck.go` | 111 | Convert to plugin Reconcile body. |
+| `internal/staticsite/markdown.go` | 83 | Keep as helper. |
+| `internal/staticsite/provider.go` | 171 | Keep — provider abstraction. |
+| `internal/staticsite/deno/` | (sub-pkg) | Keep — Dockerfile + router.ts codegen. |
+
+Estimated as its own dedicated turn (or two). Strategy: keep the provider
+abstraction + helpers exported; rewrite only `Manager.Deploy` body into a new
+`source/static/deploy.go` that operates against `plugin.Workload` directly and
+writes container rows + workload_env rather than the `static_sites` table.
+
+### Hard legacy cutover
+
+Sole remaining blocker is the static source inline port above. The
+generalized-volume-scopes blocker is resolved (legacy `ResolvePath`
+stays in place for legacy callers and dies with the cutover). When the
+static port lands:
+
+- Delete `/api/projects`, `/api/stacks`, `/api/sites`, `/api/stages` handlers.
+- Drop tables: `projects`, `stages`, `stacks`, `stack_revisions`,
+ `stack_deploys`, `static_sites`, `static_site_secrets`, `deploys`,
+ `poll_states`.
+- Delete `internal/stack/`, `internal/staticsite/` packages.
+- Delete frontend `/projects`, `/sites`, `/stacks` routes.
+- Delete legacy `volume.ResolvePath` + `internal/api/volume_browser.go`
+ callers (the only remaining users).
+
+## Priority 2 — Behavior gaps
+
+### ~~Generalized volume scopes~~ — DONE
+
+Landed: `internal/volume.ResolveWorkloadPath` (workload-keyed; sits next to the
+legacy `ResolvePath` so legacy code paths keep working) plus the wired-through
+`computeMounts` in `internal/workload/plugin/source/image/image.go`. All
+`VolumeScope` values are now honored at deploy time:
+
+- `absolute` — host bind, validated against `settings.AllowedVolumePaths`.
+- `ephemeral` — tmpfs.
+- `instance` — per-tag dir under `/-/instance-/`.
+- `stage`, `project` — both collapse to `/-/`.
+- `project_named` — Docker named volume prefixed `tf--`.
+- `named` — Docker named volume by raw name.
+
+Test coverage: `internal/volume/resolver_test.go` (table-driven, portable
+Linux/Windows). The legacy `ResolvePath` stays in place for legacy deployer +
+volume-browser callers and dies with the hard cutover.
+
+### ~~Kind-aware editors on `/apps/new` and `/apps/[id]` edit~~ — DONE
+
+All three Source plugins now have hand-rolled forms on both pages, with
+an "Advanced JSON" toggle preserved as the power-user escape hatch.
+Submit logic marshals form fields back into the same JSON shape the
+backend already expects — no API or store changes required.
+
+**Principle:** the plugin contract makes new Source / Trigger kinds cheap
+on the backend, but the UI is not cheap by default — every kind needs a
+paired hand-rolled form to be daily-driver usable. The shared JSON
+editor is the fallback for power users and brand-new plugins, not the
+end state. New Source / Trigger merge requests should treat "ship the
+kind-aware form" as part of done, not a follow-up.
+
+**Landed:**
+
+- `compose`: YAML textarea + project_name input on both `/apps/new`
+ and `/apps/[id]`.
+- `image`: form fields for image / port / healthcheck / default_tag /
+ registry_name / cpu_limit / memory_limit / max_instances on both
+ pages. Registry name is a select populated from `/api/registries`
+ (with text-input fallback when the list is empty). env + volumes
+ stay in their detail-page panels and round-trip through the form
+ via `imageFormBody` so manual edits aren't clobbered.
+- `static`: provider select (gitea / github / gitlab), base URL,
+ repo_owner / repo_name (both required), branch (default "main"),
+ folder_path, access_token (password input, for private repos),
+ mode radio (static / deno), render_markdown checkbox. The
+ storage_enabled / storage_limit_mb fields aren't surfaced as
+ form controls yet, but they round-trip through `staticFormBody`
+ so values set via the raw JSON editor survive form edits.
+
+**Still pending forms:** none — all three Source plugins now have
+hand-rolled forms on both `/apps/new` and `/apps/[id]`.
+
+The raw JSON editor stays available behind the "Advanced JSON" toggle
+(shipped with compose) so the plugin's full sample is still reachable
+for power users and for any new plugin kind without a hand-rolled form.
+
+Effort: per-kind form roughly half a turn each; can land incrementally.
+Touches `web/src/routes/apps/new/+page.svelte` and the edit block in
+`web/src/routes/apps/[id]/+page.svelte`. The Svelte side keeps
+serializing into the same `source_config` JSON shape the backend
+already expects — no API or store change required.
+
+### ~~Vendor-specific webhook parsing for `/api/webhook/workloads/{secret}`~~ — DONE
+
+Landed: `internal/webhook/vendor_parsers.go` plus rewrites in
+`internal/webhook/handler.go` `buildInboundEvent`. The dispatch order is now:
+
+1. Empty body → manual event.
+2. Vendor-specific parsers, short-circuit on a recognized `X-*-Event`
+ header — Gitea package, GitHub `package` / `registry_package`, GitHub
+ push, Gitea push, GitLab `Push Hook` / `Tag Push Hook`.
+3. Generic simple-body fallback: top-level `image` or top-level `ref` —
+ what the legacy CI integrations already send.
+
+Vendor parsers can populate fields the generic parser cannot: image
+digest, `GitEvent.Vendor`, registry host. When a vendor parser claims a
+request (header matches) it is authoritative — a malformed Gitea
+package payload surfaces as an error rather than silently falling
+through to the generic parser. Test coverage:
+`internal/webhook/vendor_parsers_test.go` covers each vendor branch +
+the routed-via-`buildInboundEvent` integration cases.
+
+Open follow-ups deferred to future turns:
+
+- GitLab Container Registry events use a custom envelope outside the
+ webhook event surface — handle if a user reports needing it.
+- Docker Hub webhook (push event) uses `{"push_data": {"tag": ...}, "repository": {...}}` — add when there's a user request.
+
+## Priority 3 — Polish
+
+### ~~Chain-panel CSS~~ — DONE
+
+Landed: rules for `.chain-row`, `.chain-card` (with hover/transform on
+anchors), `.chain-self` (brand-tinted highlight), `.chain-name`,
+`.chain-label` (70px fixed-width mono column), `.chain-children-list`
+(flex-wrap), plus a sub-600px stack to keep the panel usable on narrow
+screens. Appended at the end of the `