Post hero imagePost hero image

Part 1 opened with a problem: Aardflex’s web app edited wallpaper XML in Monaco, and Monaco does not work on a phone. This part closes it. Monaco is gone from the web app, and the web app and the Android app now highlight Aardflex XML from the same grammar file.

The web app’s own spec had a cheaper idea: keep Monaco on the desktop and use Aardink only on phones. I dropped it. Two editors means two grammars, two themes, two completion providers, and two sets of bugs, all for one file format.

What Monaco was doing

All of it lived in src/editor/setup.ts, 487 lines:

  • A Monarch grammar with states for the root, tags, attribute values, comments, the XML declaration, and {module:property | transform} expressions.
  • An aardflex-dark theme: eight token rules on a #1E1E2E background, with element names in bold.
  • A completion provider fed by five tables: ELEMENTS, ATTRIBUTES, MODULE_TYPES, TEXT_STYLES, and TRANSFORMS.

EditorPane.svelte mounted it, with an applyingExternal flag. Monaco reports a setValue as a change straight away, so without the flag every file open came back into the document store as an edit.

One grammar, as data

The first plan had the web app build its own wasm executable with Gradle, with the Aardflex language written in Kotlin. I threw that out for two reasons. Aardink’s XmlTokenizer is a Kotlin object, so there was nothing to extend. And it would have put a JDK and Gradle into the CI of a Svelte app.

Aardink’s web package registers a language from JavaScript instead, and it takes a Monarch grammar as is. That made the grammar the thing to share, not the code.

The Android app had its own highlighter, XmlIncrementalTokenizer: Aardink’s XML tokens, patched afterwards for expressions and @colorRef theme references. 132 lines that knew things the web grammar did not, and the other way round.

So the Monarch object came out of setup.ts as JSON, with regexes as source strings, into aardflex/shared/grammar/aardflex-xml.grammar.json. It picked up Android’s @colorRef rule on the way. The catch-all rule after it had to stop at @, or it would swallow the reference first:

["@[a-zA-Z][a-zA-Z0-9_]*", "variable.colorref"],
["[^<{@]+", ""]

Both apps keep a copy. Android syncs it into its Java resources with a Gradle Sync task, so unit tests load it without an Android Context. The web app copies it with pnpm sync:grammar, and CI runs the same script with --check to fail on drift. The grammar stays inside the subset Aardink supports: no lookbehind, no @brackets, no nextEmbedded, and no $1 substitutions.

The safety net is a set of golden tokens. Fixture files sit next to the grammar, with the expected tokens for every line in aardflex-xml.tokens.json. Monaco generated the first set, so “identical to Monaco” was a test, not a claim. Three things check against it: an Android unit test, a browser test in the web app, and Aardink’s own DeclarativeGrammarTest.

On the web, registration is one call:

registerLanguage(
  {
    id: "aardflex-xml",
    extends: "xml",
    inheritCompletions: false,
    grammar: aardflexGrammar,
    comments: { blockComment: ["<!--", "-->"] },
  },
  aardflexProviders,
);

extends: "xml" brings XML’s diagnostics, hover, folding, closing tags, and formatting along for nothing. inheritCompletions: false arrived in rc3, because XML’s own completions are Android layout ones. Offering match_parent and LinearLayout in a wallpaper file helps nobody.

A flag that lasted two days

Step A1 landed on 29 September 2026. A build flag picked the editor, and a dynamic import kept the other one out of the bundle:

const editor = import.meta.env.VITE_EDITOR === "aardink" ? await import("./aardink") : await import("./setup");

The plan then had A2, Aardink as the default for one release, before A3 removed Monaco. A2 never happened. The flag was never set in .env or in CI, so the only person running the Aardink build was me. A soak where nobody uses the soaked build tests nothing. On 1 October Monaco, the flag, and setup.ts went in one commit, on Aardink 0.6.0-rc2.

The flag still earned its two days. Both builds had to pass type checks, tests, and the golden tokens, so by the time I deleted Monaco there was a green build showing the replacement matched it.

Two Svelte details bit. The first one the runbook missed: the editor handle has to be $state.raw. Aardink’s createEditor is async, because the first call loads the WebAssembly module. With a plain let, the effects that push file loads and settings into the editor ran once against undefined and never again.

The second is that the container must never move in the DOM. Aardink tears an editor down when its element leaves the page, so a Svelte {#key} block around it, or a phone layout that re-parents it, kills the editor. The phone layout that went in the same day keeps one EditorPane mounted and hides it when another tab is showing. Files switch with setValue.

The applyingExternal flag went with Monaco. Aardink reports a change after it happens, at most once a frame, and marks a setValue with change.isFlush. The pane can tell a file load from typing without a guard.

What it cost

The editor’s assets, measured while both builds still worked:

BuildFileRawGzip
Monacoeditor JS2,532 kB649 kB
Monacoeditor worker280 kBnot recorded
AardinkJS542 kB105 kB
Aardinkaardink-web.wasm4,462 kB1,426 kB
Aardinkskiko.wasm8,640 kB3,388 kB
Aardinkfont274 kBnot recorded

Aardink sends about 5 MB compressed, more than seven times Monaco’s JavaScript. Most of it is skiko.wasm, the Skia build Compose draws with. That is the price of running the same Compose editor as the Android app instead of a DOM editor.

It is a first-visit cost. Hashed assets are cached for a year as immutable, so the second load comes from the browser’s cache. While the module loads the pane shows a loading state, and if the load fails it falls back to a plain text area.

I reckon it is worth it. Monaco on a phone was not slow, it was unusable. A slow first load that works beats a fast one that does not.

The app lost one thing: a custom font. Aardink always draws with its bundled JetBrains Mono, which Monaco’s settings asked for first anyway. On rc1 bold element names and snippet placeholders were on that list too. rc2 added both.

The Android app too

Aardflex’s Android app was on Aardink 0.4.0 and went straight to 0.6.0-rc1 on 28 September 2026, skipping 0.5. It was the first app upgraded by following the migration guide, so it was the guide’s real test. The diff was four files, 31 lines in and 27 out. undoManager.canUndo became canUndo, and the derivedStateOf around it went, because canUndo is snapshot state now.

The guide also predicted the one thing that got worse. XmlIncrementalTokenizer wrapped Aardink’s XML tokenizer and added its own tokens. In 0.6 the built-in tokenizers only reuse their earlier result when they get that exact list back. The wrapper always handed back its own patched list, so every keystroke was a full rescan. Still correct, just slow on a big file. I made the full scan explicit rather than lean on that quirk.

The other change was in the app’s own code. It runs diagnostics on a background dispatcher, and in 0.6 the live document keeps changing on the main thread while they run. The fix was handing the language service a copy of the text.

The next day the wrapper went. The replacement reads the shared grammar and hands everything to Aardink’s DeclarativeTokenizer:

object AardflexXmlTokenizer : IncrementalTokenizer by DeclarativeTokenizer(AardflexXmlGrammar.grammar) {
  private val xmlToolbarChars =
    LanguageRegistry.withBuiltIns().byId("xml")?.tokenizer?.keyboardToolbarChars().orEmpty()

  override fun keyboardToolbarChars(): List<Char> = xmlToolbarChars + listOf('{', '}', '@')
}

132 lines out, 36 in, and highlighting is incremental again: an edit rescans only until the lines below are back in the state they were in. It merged on 3 October 2026, the day Aardink 0.6.0 shipped.

Changing how Aardflex XML highlights is now one edit in shared/grammar/, a regenerated tokens file, and a sync in each app.