Why this plan exists. The owner asked, looking at the mobile provider code: "this feels dumb — why isn't providers handled on an engine level? I'm actually wondering how much functionality that should be in the engine is not, and duplicated to the targets." They were right, and the answer generalizes past providers:
As much as possible that is not specific to a target should live in the engine; anything else that's common to several targets should live in a shared layer. A target is a thin wrapper.
This is working-agreements.md §A3 stated as a campaign.
The root cause (worth understanding, because it will recur)
Every plugin pool in this repo (providers, blocks, lists) is drop-a-folder-in, so it needs a discovery mechanism. Each runtime discovers differently — and there is no intersection:
| Runtime | Target | Discovery |
|---|---|---|
| Vite | web | import.meta.glob |
| Node | CLI, backend | fs.readdirSync + dynamic import() |
| Metro | mobile | neither — a static module graph, no fs at runtime |
When a shared module reaches for one of these, the other targets cannot import it — and the path
of least resistance is to hand-port it. That's the whole story of the duplication: the provider
registry got a Vite glob, so the CLI re-ported it (fs scan) and mobile re-ported it again (892
hand-written lines). The parity checks (scripts/mobile-parity-check.mjs) were then invented to
detect the drift — treating the symptom.
The rule that falls out of this (now in ../decisions/architecture.md):
a module intended to be shared across targets may not touch import.meta or node:. Discovery
goes through a generated static index — plain import statements, the one construct all three
runtimes understand. Anything genuinely platform-specific is injected by the target, not forked.
Done
-
A —
targets/web/shared/→targets/shared/(2.52.0). The provider adapters were living inside the web target while the CLI and mobile reached into it. Now a first-class cross-target layer, siblings undertargets/. -
B — one provider registry for all three runtimes (2.53.0).
targets/shared/registry.generated.js(npm run registry;npm run check:registryinnpm testfails if stale).shared/index.jsis runtime-agnostic. Web-only online-gating moved to the web shim. CLI registry: 145 lines → a thin facade (its duplicateapplySharedSettingsdeleted). -
B2 — the transport is now injectable (
_shared/transport/config.js). This was the reason a native target couldn't reuse the providers at all:hosted-proxyhardcoded a relativefetch("/api/generate")— a browser has an origin, a native app does not →configureTransport({ apiBase }).local-directtunnels through/api/forwardpurely to dodge browser CORS; RN has no CORS and should call the user's server directly →configureTransport({ forward: false }).- RN's
fetchhas no timeout →configureTransport({ timeoutMs }). Defaults reproduce the web's exact behavior (web = no-op). 11 contract tests, proven by re-introducing both bugs.
-
B3 — provider
description/keyUrlmoved onto the manifests. Was a hand-keptPROVIDER_METAtable in the web + a second copy inside mobile's registry. Now declared once, next to the provider. -
C — mobile imports the shared providers (2.54.0). The 892-line hand-port is deleted; a 268-line adapter derives the three role lists from the same manifests the web uses and dispatches into the shared provider code. The settings-schema mismatch (async manifests vs a sync UI) was resolved by preloading every schema once at boot — not by making the UI async: on a phone the bundle already ships everything, so lazy code-splitting buys nothing.
cleanDplOutputmoved toshared/_shared/rewriteSystem.js;keyHintjoined the manifests. Contract-tested (17 tests) intargets/mobile/lib/__tests__/imageProviders.test.js. -
D — retired the drift checks the duplication made necessary (2.54.0).
checkProviders,checkRewriteSystems,checkLocalSettingsdeleted fromscripts/mobile-parity-check.mjs— they now compare a file to itself.checkSurfaceskept (it asserts the UI exposes every web feature, which code-sharing does not guarantee).
Historical: how C was originally scoped (kept — the obstacle it names is instructive)
The shared manifest exposes settings asynchronously (
loadSettings() → {defaults, fields, data}, code-split), while the mobile UI reads a synchronous flatprovider.settingsarray. So the swap is not a find-and-replace: mobile's provider-settings UI must become async…
That framing was wrong in its conclusion, and it's worth remembering why. The async-ness of
loadSettings() is a web concern — it exists so the browser can code-split the gear. A phone has already
downloaded the whole bundle, so the right move was to resolve the asynchrony once at boot (preload all
schemas + their option sources) and keep the UI synchronous. Making the mobile UI async would have imported
the web's constraint along with the web's code. Share the logic; don't inherit the other platform's
trade-offs.
-
E (partial) — engine-domain logic promoted out of mobile (2.55.0):
listOps.js(71 lines) →engine/listEditorOps.js. Sort / dedupe / AI-candidate parse of list content is engine domain — "what counts as a duplicate entry" is a property of the engine's lists, not of any one UI. Both Manage editors import it; the mobile copy andcheckListOpsare deleted.themeData.js(82 → 24 lines) → the accent themes moved totargets/shared/theme/themes/*.jsonwith a generated static index. Same root cause as the providers: the web discovered them with a Viteimport.meta.globthat Metro can't run, so mobile transcribed all nine by hand.checkAccentsdeleted. (gen-accents.mjsstill emits the same byte-identicalaccents.css— only its source path moved.)
-
E (cont.) —
blockCatalog→engine/blockCatalog.js(2.59.0). The building-block catalog (the token cloud + the DPL autocomplete) is engine domain — it describes the engine's own content pools, their folder categories, the{#any}/{keyword}wildcards and the NSFW gate — and is a pure function of a loader, which is exactly why it could be shared: each target passes its own (runtimeLoaderin the browser,metroLoaderon the phone). The web'spromptEngine.jswent 411 → 219 lines and mobile's 218-line hand-port became 33 lines. That copy had no drift check at all — the worst case, since nothing would have noticed the phone falling behind. Replaced with a real test (tests/unit/blockCatalog.test.js, 9 tests) that pins the catalog's rules AND asserts the phone never invents content the engine doesn't have. -
E (done) —
dplInserts→engine/dplInsertCatalog.js(2.60.0). The last hand-port, and the one the plan called "entangled": the web localizes the menu's labels through react-intl descriptors while mobile inlines English, so a naïve "share the module" would have dragged react-intl into React Native. The split that resolved it is the general answer to that whole class of problem:- The grammar moved — ids, DPL
syntax, editortemplate,example, the${…}template conventions andmaterializeTemplate. It describes whatengine/core/dpl/dpl.jscompiles, so it was never the web's to own. - The label layer stayed — the web keeps its react-intl messages, mobile keeps its English table.
Each target attaches its own via
buildInsertMenu({category, item}). Presentation is platform-specific; grammar is not. - Label keys are derived from the catalog ids (
camelId:one-of-nothing→oneOfNothing…), so a construct added to the grammar with no string in a target failstests/unit/dplInsertCatalog.test.jsinstead of renderingundefinedon a phone. That test is whatcheckDplInsertsbecame: the copies can't drift (there are none), but a label layer can fall behind, and that's what's now asserted.
Mobile's file went 262 lines → a label table.
checkDplInsertsis deleted. - The grammar moved — ids, DPL
Next: E — nothing. The campaign's hand-ports are all gone.
Every mobile hand-port the audit found has been promoted:
| Was | Lines | Now | Its drift check |
|---|---|---|---|
lib/imageProviders.js |
892 | targets/shared/ registry |
checkProviders / checkRewriteSystems / checkLocalSettings — deleted |
lib/listOps.js |
71 | engine/listEditorOps.js |
checkListOps — deleted |
lib/themeData.js (accents) |
82 | targets/shared/theme/ |
checkAccents — deleted |
lib/blockCatalog.js |
218 | engine/blockCatalog.js |
(had none — the worst case) |
lib/dplInserts.js |
262 | engine/dplInsertCatalog.js |
checkDplInserts — deleted |
The "its drift check" column is the tell: every check deleted here was deleted because the thing it
guarded could no longer differ. What remains in scripts/mobile-parity-check.mjs is checkLocales
(one line of config, not a port), plus the three permanent gates that code-sharing does NOT satisfy —
checkSurfaces (the UI exposes every web feature), checkGating, and checkNoCaps.
Done: the drift checks that duplication made necessary
Six of seven are gone. checkProviders, checkRewriteSystems, checkLocalSettings (2.54.0),
checkListOps and checkAccents (2.55.0), and checkDplInserts (2.60.0) are deleted — mobile
derives all of them from the shared layer / engine now, so each was comparing a file to itself. Only
checkLocales remains, and it guards a config list, not a port.
checkSurfaces stays — permanently. It is not a drift check: it asserts the mobile UI exposes every
web feature (the FULL-parity mandate), which no amount of code-sharing guarantees. Same for the
capability-gating layer.
The measure of success: deleting a parity check because the thing it guarded can no longer differ, not because we stopped caring.