feat: a VS Code custom editor for .apollon files, and the theming fixes it exposed#806
Conversation
`fitView` reserved the overlay insets a host's chrome declares, but nothing reserved the notch, Dynamic Island, home indicator or the mobile soft keyboard. On a phone the camera framed nodes underneath them. The overlay grid already pads itself by the safe area, so it is measured independently of the chrome insets (which come from each control's own box) and the two stack without double-counting. `respectInsets: false` and a per-side `padding` override both still opt out of chrome; neither can opt out of the safe area, which is a hardware constraint rather than breathing room. Consolidates the `--safe-area-inset-*` declaration — previously duplicated in the library and the webapp, with a unitless `0` fallback that invalidated the `calc()` expressions it fed — into a single block in the shared tokens. Also: the zoom cluster honours prefers-reduced-transparency and prefers-contrast, floating chrome uses a cheaper backdrop blur on touch devices, and the iOS shell no longer strands the viewport after the keyboard closes.
…ghost
Every embed that themes the editor's own mount node — `dataTheme`, a `theme`
override, or a host stylesheet like the VS Code webview's — hit three separate
bugs that a document-root theme (the webapp) hides completely.
An unregistered custom property substitutes its `var()` at the element that
declares it and then inherits the *computed* result. The `--home-*` aliases the
shared @tumaet/ui form primitives paint from were declared only at `:root`, so
they resolved against the light document and merely inherited down: a dark embed
drew light input borders and light placeholder ink. They are now re-declared on
`.apollon-editor`, exactly as the chrome ramp already was.
Text fields inherited nothing and fell through to the UA's `input { color:
fieldtext }` — a system color keyed to `color-scheme`, i.e. the OS appearance
rather than the editor's theme. Tailwind's Preflight resets this, but the
Tailwind-free bundle the embeds ship does not, so the primitives reset `color`
themselves.
Popups portal to `<body>` and copy resolved token values, but never recomputed
them, so a theme switch left an open menu — and every later one anchored to the
same element — painting the old palette. They now subscribe to theme changes
through one shared observer rather than one per popup, and the palette drag
ghost carries the theme it was grabbed under out to `<body>` with it.
…files The extension previously opened a diagram in a bespoke webview backed by two extra pnpm workspaces (`editor/`, `menu/`) and mutated the user's global `files.associations` on every activation. It is now a single `CustomTextEditorProvider` over one webview workspace. VS Code owns the document, so save, auto-save, undo, dirty state, hot exit, diffs and branch switches all work by default rather than being reimplemented. Writes go through `jsonc-parser` as a minimal range edit, so a hand-annotated `.apollon` file keeps its comments, sibling keys, indentation and line endings, and a text editor open on the same document keeps its cursor. Also contributes an `apollon` language with a grammar and icon, a diagram tree view of the workspace, an empty-file diagram-type picker instead of a parse error, and optional export-on-save. The webview maps the host's `--vscode-*` colors onto the editor's public theme tokens, verified against Light/Dark Modern and both high-contrast themes. The extension has never been published, so nothing migrates. CI never typechecked, tested or built this workspace. It does now.
The stylesheet had grown to exactly the 35 kB ceiling, so the next CSS change of any size would fail the check regardless of its merit — the two theming fixes in this branch add 56 B gzipped. Raised to 36 kB, which the current 35.06 kB clears with room to still catch a real regression.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The custom-editor rewrite is headed in the right direction, but I found a data-loss window in the debounce lifecycle, stale Dockerfile workspace paths that are breaking Docker CI, and a trust guard gap on manual export. Codacy also has a few CSS style annotations to clear, and the required UI screenshots still need to be attached.
|
|
||
| function DiagramPicker() { | ||
| return ( | ||
| <div className="apollon-vscode-notice"> |
There was a problem hiding this comment.
@FelixTJDietrich [medium] This PR changes visible VS Code editor states and scoped-theme UI behavior, but there are no real screenshots or screencasts attached. The Apollon review rules require captures for UI changes, so please add the Extension Development Host screenshots for the empty picker, the editor across VS Code themes, and a themed popover/input before merge.
🤖 Prompt for AI agents
For PR #806, visible UI changes lack the required review screenshots. Capture and attach real screenshots or screencasts from a running VS Code Extension Development Host covering the empty-file picker, editor theme variants, and a scoped dark popover/input; do not use generated or mock screenshots.
There was a problem hiding this comment.
@FelixTJDietrich Still open: I do not see screenshots in the PR body or comments yet, and the PR body still says they are not attached.
There was a problem hiding this comment.
Agreed, and I have not attached them — I cannot capture a running Extension Development Host from here, and I would rather leave the checklist box unticked than fill it with anything generated. Called out explicitly in the PR body under Screenshots / screencasts: the empty-file picker, the editor across the four VS Code themes, and a scoped-dark popover with an input still need real captures before merge.
There was a problem hiding this comment.
@FelixTJDietrich Still open then. I agree generated screenshots would be worse, but the review requirement is real captures before merge, so I’m leaving this thread unresolved.
Both Dockerfiles copied `vscode-extension/{menu,editor}/package.json` so pnpm
could read them as workspace manifests. Those two workspaces are gone, replaced
by a single `webview`, and the `COPY` failed to resolve. `.dockerignore` still
dropped the same two source trees.
Verified by building both images locally.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich One Docker issue is fixed and I resolved that thread, but the debounce/external-edit data-loss paths, manual export trust guard, missing screenshots, and Codacy CSS items are still open in the existing threads.
…aries Three lifecycle holes, all found in review, all now covered by tests that fail without their fix. The extension had no coverage of the provider at all, so `tests/vscodeStub.ts` stands in for the `vscode` module: events fire synchronously and `applyEdit` really rewrites the document, which lets the tests assert on the text the document ends up holding rather than on which API was called. Closing a tab inside the 300ms debounce cleared the timer and dropped the edit with it. VS Code never saw a dirty document, so there was no save prompt and the change was simply gone. The pending model is now written on disposal — unless the document is already closed, because closing without saving is a choice and resurrecting it would undo that. An external write to a *transiently invalid* document (mid-typing in a split JSON editor) returned before cancelling the pending canvas write, so the debounce fired 300ms later and overwrote what had just been typed. Every external change now supersedes the pending write, before validity is considered. The manual export command bypassed the workspace-trust guard that auto-export applied, even though the manifest promises image exports stay off until the workspace is trusted. The guard now sits on the shared write path. Also clears the Codacy stylelint findings: direct `padding` fallbacks take a unitless `0` (the typed `0px` stays where the value feeds `calc()`), plus two blank-line nits.
Clears the last Codacy stylelint finding (`comment-empty-line-before`). Verified by running Codacy's three rules locally over every stylesheet this branch touches: nothing else it reports sits on a line this PR adds.
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The code issues I had flagged are addressed and CI is green now. I'm keeping changes requested only because the required real screenshots or screencasts for the VS Code UI/scoped-theme changes are still missing.
…tax-tree popover titles `nodeTypeLabel` strips a diagram-type prefix so a popover reads "Marking", not "Reachability Graph Marking". Two copies of the prefix table existed: a dead one in `nodeUtils.ts` that listed every diagram, and the live one in `i18n/labels.ts` that was missing `reachabilityGraph` and `syntaxTree`. Delete the dead copy and complete the live one. knip was already red on the dead export. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`tokens.css` scopes the `--home-*` aliases to `.apollon-editor`; a popup portaled to `<body>` leaves that subtree and reads them off the bridge instead. Nothing tied the two lists together, so an alias added to one and not the other went unnoticed until an embed rendered light input borders on a dark surface. Assert the bridge is a superset of what `tokens.css` scopes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The webview rasterized its own PNGs through `<img>` → `<canvas>` → `toBlob`, the exact pipeline the library abandoned in #667: past the browser's canvas-area cap (~16 MP on WebKit, ~268 MP on Chrome) `toBlob` yields `null` with no exception, so a large diagram exported as a 0-byte file. Route the export through `@tumaet/apollon/export`, which renders into resvg's own wasm buffer, as the webapp already does. The renderer and its wasm binary load on first export, so the canvas keeps its startup path. Instantiating the module needs `wasm-unsafe-eval` and fetching it needs `connect-src`; the nonce does not propagate to `import()`-ed chunks, so `script-src` gains `cspSource`. Verified against the CSP the host emits: without `wasm-unsafe-eval` the export fails to compile, with it a real PNG comes back. `@tumaet/apollon/export` also exports `svgToPdf`, and a bundler keeps every export of a dynamically imported module. Narrowing the namespace through `pngRenderer.ts` sheds jsPDF, svg2pdf and html2canvas — about 860 kB the webview could never reach. Also stop the VSIX from swallowing a nested `node_modules`: the `node_modules/**` pattern was anchored to the root and matched neither subdirectory. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ApollonEditorProvider` was 481 lines holding three unrelated jobs. Two of them move out: - `DocumentSync` — the document↔canvas engine, previously five interlocking mutable variables inside a closure. It is the code where a bug loses a user's work, and it now has a name and a lifecycle. - `DiagramExporter` — render request/response correlation, the workspace-trust guard, the file write and cancellation. What is left is the webview's lifecycle and the messages it answers, at 253 lines. `protocol.ts` and `diagramTypes.ts` move to `src/shared/`, which the webview imports too. The boundary was real but unenforced: nothing stopped a `vscode` import from breaking the webview bundle. An eslint rule stops it now. Naming: the `config` message carried one field and promised a bag, so it is `autoExportChanged`. The customEditor viewType `apollon.diagram` differed from the tree view `apollon.diagrams` by one character; it is `apollon.diagramEditor`. Tests cover what the branch newly relies on. Auto-export writes files to a user's disk on every save and nothing asserted a file was written — now the round trip, both encodings, the failure path, the trust refusal, the scaffold and its non-empty guard are all pinned. Each was checked against the unfixed code. The manifest gains the Marketplace icon it had no `icon` field for, per-value descriptions for `apollon.autoExport`, and `scope: resource` since export writes are per-folder. `Other` leaves `categories`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The code feedback I checked is addressed, and the targeted VS Code extension tests pass locally. I’m keeping changes requested because the required real screenshots or screencasts for the VS Code UI and scoped-theme changes are still missing, and the GitHub checks for this SHA are still pending.
|
Correction to this PR's description, found while debugging the release run it triggered (#808). Under Implementation notes I wrote that the extension "has never been published ( That is wrong. I trusted the changelog instead of querying the Marketplace. The conclusion mostly survives, but not for the reason I gave:
Deleting the code that wrote it was still right; claiming there was nobody to migrate was not. #808 corrects Two related things #808 fixes, both surfaced by merging this PR:
|
Summary
The VS Code extension is rewritten as a
CustomTextEditorProviderover.apollonfiles. Rather than a bespoke webview that owned the document, VS Code now owns it — so save, auto-save, undo, dirty state, hot exit, diffs, split editors and branch switches all work because they are the editor's, not because we reimplemented them.Embedding the library in a real host immediately exposed three bugs in it. All three only appear when the theme is scoped to the editor's own mount node —
dataTheme, athemeoverride, or a host stylesheet — and are invisible to the webapp, which themes the document root. They are fixed here because the extension is unusable without them. Separately,fitViewnever reserved the device safe area, so on a phone the camera framed nodes under the notch and the home indicator.feat(vscode-extension)fix(library)× 3 — safe area, scoped theme, popover titlesfix(vscode-extension)× 2 — lifecycle holes, PNG exportrefactor(vscode-extension)— split the providerbuild,build(webapp),test(library),styleRelease note
Apollon diagrams now open in a real VS Code editor, with saving, undo and diffs working the way they do for any other file — and embedded editors correctly follow a dark or custom theme in their popups, inputs and drag previews.
Implementation notes
Why the extension shrank by two workspaces. It previously ran two extra pnpm workspaces (
editor/,menu/) and mutated the user's globalfiles.associationson every activation. Both are gone: one webview workspace, and no writes to user settings. The extension has never been published (CHANGELOG.md: "First Marketplace release pending"), so there is no installed base holding a stalefiles.associationsentry and nothing to migrate. That is the only reason it was safe to simply delete that code rather than clean up after it.Writes are range edits, not rewrites. The document is edited through
jsonc-parser'smodify/applyEditsat the model's own path, so a hand-annotated.apollonfile keeps its comments, trailing commas, sibling keys, indentation and line endings, and a text editor open on the same document keeps its cursor and a sane undo granularity.detectFormattingreads the document's own indent and EOL rather than imposing ours. Tests pin the wiring, not just the detection — deletingformattingOptionsfrommodelEditsturns three of them red.An empty file is a state, not a failure.
readDocumentreturnsempty | model | invalid. An empty.apollonoffers a diagram-type picker; an unparseable one explains itself instead of throwing.parseModelchecks only thetypediscriminator — structural validation belongs to the library, which rejects a bad model on mount with a better message than this extension could reconstruct from a JSON shape.The three library theming bugs. Each has a one-line root cause worth stating precisely, because each is easy to "fix" somewhere that doesn't work:
var()at the element that declares it, then inherits the computed result. The--home-*aliases the shared@tumaet/uiprimitives paint from were declared only at:root, so under a scoped mount they had already frozen to the light document. Re-declared on.apollon-editor, exactly as the chrome ramp already was.input { color: fieldtext }is a declaration on the element, which beats inheritance, andfieldtextfollowscolor-scheme— the OS appearance, not the editor's theme. Tailwind's Preflight resets this; the Tailwind-free bundle the embeds ship does not, so the primitives now resetcolorthemselves.For (3) the popups now share one
MutationObserverbehinduseSyncExternalStoreinstead of one per popup (an editor keeps dozens of tooltips mounted). It deliberately watchesclassbut notstyleon<body>, because a palette drag writesbody.style.overflowon every grab and would otherwise repaint every popup. The drag ghost, which portals to<body>, carries both thedata-themeattribute and the resolved values — the attribute alone is not enough for a mount themed by inline properties or a host stylesheet, which is precisely the VS Code case.Nothing tied the bridge to
tokens.css, so an alias added to one and not the other was invisible until an embed rendered light input borders on a dark surface. A test now asserts the bridge is a superset of whattokens.cssscopes to.apollon-editor.PNG export produced 0-byte files for large diagrams. The webview rasterized its own PNGs through
<img>→<canvas>→toBlob— the exact pipeline the library abandoned in #667. Past the browser's canvas-area cap (~16 MP on WebKit, ~268 MP on Chrome)toBlobreturnsnullwith no exception, so the export silently wrote nothing. It now goes through@tumaet/apollon/export, which renders into resvg's own wasm buffer, as the webapp already does. This was a duplicate of library code, and the known-broken variant of it.The renderer and its wasm binary load on first export, so the canvas keeps its startup path. Instantiating the module needs
wasm-unsafe-evaland fetching it needsconnect-src; a nonce does not propagate toimport()-ed chunks, soscript-srcalso carriescspSource. This was verified against the CSP the host actually emits, rendered fromwebviewHtml.tsrather than retyped: withwasm-unsafe-evala real PNG comes back, and without it the module fails to compile — so the check can fail.860 kB of dead weight left the VSIX.
@tumaet/apollon/exportalso exportssvgToPdf, and a bundler keeps every export of a dynamically imported module, since nothing constrains what the namespace object is read for. So the PNG import dragged jsPDF, svg2pdf, html2canvas and dompurify into a code path the webview can never take.pngRenderer.tsnarrows the namespace to one binding and they shake out; an eslint rule stops a future import from silently re-inflating it. Separately,.vscodeignore'snode_modules/**was anchored to the root and matched no nested tree.The provider was doing three jobs.
ApollonEditorProviderwas 481 lines. Two subsystems moved out, leaving 253:DocumentSync— the document↔canvas engine, previously five interlocking mutable variables in a closure. It is the code where a bug loses a user's work, and it now has a name, a lifecycle and a doc comment that fits it.DiagramExporter— render request/response correlation, the workspace-trust guard, the file write, cancellation.protocol.tsanddiagramTypes.tsmoved tosrc/shared/, which the webview imports too. That boundary was real but unenforced: nothing stopped avscodeimport from breaking the webview bundle. An eslint rule enforces it now, and both rules were checked by writing the violating import and watching them fire.Naming, while the files were open: the
configmessage carried one field and promised a bag →autoExportChanged. The customEditor viewTypeapollon.diagramdiffered from the tree view idapollon.diagramsby one character →apollon.diagramEditor.What review caught, and what it means. Three defects in the first push, all now covered by tests that fail without their fix:
The extension had no coverage of the provider at all — only of the pure document functions — which is precisely why these survived.
tests/vscodeStub.tsnow stands in for thevscodemodule: events fire synchronously andapplyEditreally rewrites the document, so tests assert on the text the document ends up holding rather than on which API happened to be called. Auto-export writes files to a user's disk on every save and nothing asserted a file was ever written; the round trip, both payload encodings, the failure path, the trust refusal, the scaffold and its non-empty guard are now pinned too. 44 extension tests, up from 31.Every one was falsified: eleven targeted mutations of the source, each turning red exactly the tests that guard the behaviour it broke, and green again on revert.
CI gap closed.
pr-health-checks.ymllintedvscode-extensionbut never typechecked, tested or built it, so its unit tests were decorative. Avscode-extension-checksjob now runstypecheck,testandbuild:allagainst a built library, and it is wired into thePR Health Gate.Safe area. The overlay grid's padding is the safe area, and chrome insets are measured from each control's own box, which begins inside that padding — so the two stack without double-counting.
respectInsets: falseand a per-sidepaddingoverride both still opt out of chrome; neither can opt out of the safe area, which is a hardware constraint rather than breathing room.Trade-off — the CSS size budget.
standalone/webappcapped gzipped CSS at 35 kB andmainhad grown to exactly 35,001 B, so the next CSS change of any size would have failed the check on its own. This PR adds 56 B. Raised to 36 kB rather than shaving bytes out of a correctness fix; the current 35.06 kB still leaves the budget able to catch a real regression.Marketplace metadata. The manifest had no
icon, which the Marketplace requires as a PNG of at least 128×128 (media/held only the monochrome activity-bar glyph, which is recoloured by the theme and would have listed as a black square).media/icon.pngis the project's own brand asset downscaled to 256×256.apollon.autoExportgainsenumDescriptionsandscope: resource, since export writes are per-folder.Otherleavescategories.Deliberately not in this PR
An audit for duplication turned up four more things. None are this branch's doing, and folding them in would make it harder to review, not better:
standalone/webapp'sdiagramTypesidentity map re-declares all 13 diagram types purely to hand back string constants identical to the library's runtimeUMLDiagramType. Unlike the extension — where importing the runtime value would drag the browser editor into the Node host bundle, which is whysrc/shared/diagramTypes.tslegitimately declares its own — the webapp already ships the editor, so this is free to delete. Its siblingshortLabelsByTypeandtilesByTypeare typedPartial<Record<…>>, so a 14th diagram type would silently render with no label rather than failing the build."4.0.0"is the repo-wide convention (the v3→v4 converter, the webapp store, every fixture) and the extension follows it; the outlier islibrary/lib/apollon-editor.tsx, whosemodelgetter emits"4.1.0".UMLModel["version"]is typed`4.${number}.${number}`, so nothing catches the disagreement. The library should export the constant.DocumentSync.write()rollswrittenback ifapplyEditis refused. If an unrelated external edit lands inside that await, the rollback restores text the document no longer holds. This behaviour is carried over verbatim from the pre-refactor closure, not introduced here. RefusedapplyEditis rare and the window is a microtask; still worth a ticket.knipruns in no workflow, so the config inknip.jsononly gates whoever runs it locally. (It is green here, for the first time — the deadnodeTypeLabelexport it was flagging turned out to be a stale duplicate of the i18n label table, missing two diagram prefixes the live one also lacked. That is thefix(library)popover-titles commit.)vsceis deliberately not a workspace dependency — the release workflow installs it into a temp prefix with--ignore-scripts, keeping its native credential store and Azure SDK out of every contributor'spnpm install. Recorded inREADME.dev.mdso it doesn't get "fixed".Steps for testing
The extension
pnpm install && pnpm run build:libpnpm --filter apollon-vscode run build:all, then F5 to launch an Extension Development Host.diagram.apollonand open it — you should get a diagram-type picker, not an error. Pick one, then Cmd/Ctrl+Z: you are back to an empty file.// commentand a sibling key to the JSON via Reopen with Text Editor, go back to the canvas, move a node, save. The comment, the sibling key and your indentation survive.apollon.autoExporttopngand save a large diagram. You get a real PNG next to the file, not a 0-byte one.The library fixes (these are the ones with regression coverage)
pnpm --filter @tumaet/apollon run test—portalTheme,portalThemeReactivity,draggableGhostTheme,nodeTypeLabel,fitView.pnpm --filter @tumaet/webapp run test:e2e -- scoped-theme-form-controls— asserts the renderedborder-colorandcolorof an input inside a body-portaled popover under an inline dark theme.pnpm --filter apollon-vscode run test— the provider lifecycle, including the data-loss path above.Every new test was falsified against the unfixed code before being kept: reverting the
.apollon-editor { --home-* }block turns the border assertion red (it reads0.84luminance — a light border on a dark surface), and removingtext-inheritturns the ink assertion red (rgb(0, 0, 0)).Two caveats worth stating, because both are cases where a test passed for the wrong reason until it was checked:
vscodestub modelledapplyEditas refusing a closed document. That made the "don't resurrect a closed document" test pass whether or not the guard existed. Real VS Code applies aWorkspaceEditto files that aren't open — that is how cross-file refactors work — so the stub was corrected and the test now actually fails without the guard.data-theme="dark", which also setscolor-scheme: darkand therefore madefieldtextwhite on its own — the fix was unfalsifiable. Rewritten to an inline theme; it then still passed, because the webapp loads Tailwind Preflight, which already resetsinput { color }. The bug is only observable in the Preflight-freecomponents.cssthe embeds ship, which the test now loads directly.Screenshots / screencasts
Checklist
pnpm changeset, how) — or this PR doesn't touch a Changesets-tracked package (@tumaet/apollon,@tumaet/webapp,@tumaet/server)feat/fix/…) matches the kind of change — it groups the release notepnpm lint && pnpm format:check && pnpm build && pnpm testlocally — greenFive changesets ship with this PR: four
@tumaet/apollonpatches (safe area, scoped-theme form controls, drag ghost, popover titles), of which the form-controls one also bumps@tumaet/ui; plus one@tumaet/webapppatch (iOS keyboard).apollon-vscodeis on the Changesets ignore list and versions through its own release workflow.Note for the reviewer: the e2e suite is flaky under parallel load with retries disabled —
mainfails 1–2 different tests per run in that mode, as does this branch, on a different test each time. CI runs with retries, where this branch is 211 passed / 1 flaky. The palette-drag ghost tests were A/B'd specifically (this branch'sDraggableGhostvsmain's, 18 repeats each across 4 workers): both 18/18.