Tutorial: 2026-07-09

2026-07-09

Newest entry on top.

Android target - Providers + API keys (BYOK image generation)

Third overflow control; the image-gen milestone. Read the shared provider registry (targets/web/shared): most providers are hosted-proxy (need the desktop backend) so can't run on mobile; only the browser-direct BYOK ones work (like the online web build) — OpenAI, fal, Stability, Gemini (+ text-only), plus copy-prompt tiers (midjourney/novelai).

  • lib/imageProviders.js: ported the browser-direct adapters (openai / fal / stability / gemini server.js) — direct fetch → {images} (data: / https:).
  • lib/keys.js: expo-secure-store per-provider keys (web no-op).
  • lib/storage.js saveImageSrc: data: (base64 write) / https: (downloadAsync) / file: (copy) → Gallery.
  • theme.js context also carries the selected provider (persisted).
  • OverflowMenu: Image-provider picker + secure key field (+ Get-a-key link) at the top, above Appearance.
  • GenerateScreen.generate(): provider+key set → generate one image per rolled prompt, save each to the Gallery, live status; else prompts-only. App bumps galleryKey via onGenerated.

Started with OpenAI (owner's pick). Can't verify real output from here (needs the owner's key + phone network); UI + bundle verified via web render. Follow-ups: Leonardo (submit-poll transport), per-provider model/size "gear", and a batch-concurrency cap for large prompt counts.

Android target - theming: System/Dark/Light + accent picker (Appearance)

First of the three overflow controls the owner picked. Read the web theming source (tokens.css dark+light palettes, theme/presets.js + theme/themes/*.json, ThemePicker.jsx) and built the mobile counterpart:

  • lib/theme.js: ThemeProvider + useTheme() → the token object T for the resolved base (System via useColorScheme / Dark / Light) + accent; 9 accent presets mirrored; persists to the doc dir (expo-file-system legacy, web-safe no-op).
  • Refactored all screens/components from import { T } to const { T } = useTheme() + makeStyles(T) (memoized) — required because StyleSheet.create caches at module load, so theme-dependent styles must be built per-render. Memoized rows (ResultRow / LineRow / palette SubTab) call the hook themselves so FlashList perf holds (context is stable unless the theme changes).
  • OverflowMenu: added the Appearance section (mode segmented + accent swatches) above the links — the web ThemePicker folded into the overflow.

Verified via web export + Playwright: switching to Dark + Violet recolored the whole app (tabs, editor, tools, generate, FAB). System mode follows the browser/OS scheme (light in the container, so the render looked light by default — on the phone it follows the phone). Next: Language picker, then Providers + API keys (image-gen milestone).

Android target - header rebuilt to match the web (read the source first)

Read the real web header before touching it: App.jsx <header className="topbar">, title-bar.css, view-switch.css, topbar-responsive.css (the phone collapse behaviour), and LinksMenu.jsx. Then fixed the mobile header:

  • Brand = the real app logo (assets/icon.png, 32px); the wordmark is hidden on phone, as the web does.
  • View-switch active tab = accent-strong; switch bg = input tone (matches view-switch.css).
  • The ⋯ was a dead button → components/OverflowMenu.js: a dropdown under the bar with the project links + "get it" group + legal pages + version, opened via Linking — the web's compact LinksMenu. Added the Feather link icons to lib/icons.js.

Deploy origin for the legal links = prompt.fairyfox.io (found referenced in targets/web/public/legal/*.html). Providers / NSFW / Theme overflow rows are deferred until those features land on mobile.

Android target - proper Generate pass (read the real web source, not the screenshot)

Owner (rightly) called out that I'd guessed from the page image: the Insert button wrongly opened the blocks menu, the preview didn't cycle, and the palette lacked the web's sorted categories. Read the actual web components (PromptComposer, DplInsertBar, LivePreview, home/BlockPalette, promptEngine.getBlocks, home/blockCategories, dplInserts) and rebuilt to match:

  • Insert ▾ = the DPL SYNTAX menu (dplInserts catalog): category→constructs with live 1s examples; inserts a snippet. lib/dplInserts.js + components/InsertMenu.js.
  • eye = live preview re-rolling every 1s; click toggles.
  • Building blocks = a faithful getBlocks port over the metroLoader (lib/blockCatalog.js, reusing engine/nameOrder.js computeButtonNames/compareNames + engine/gatedLists.js isGatedBlock) + foldersOf (lib/blockCategories.js); components/BlockPalette.js renders search + Blocks/Lists groups + All + folder sub-tabs + hints + group-pills + chips.
  • Added a DPL completion strip (getDplCompletions) — tap-complete {…}/{#…} tokens as you type.
  • lib/engine.js centralizes run + expandOnce (clean example expansion, autoAddFx/artists off).

Landmine: computeButtonNames/compareNames live in engine/nameOrder.js (the web re-exports them via engine/listManifest.js) — import from nameOrder.js directly. Verified via expo export --platform web

  • Playwright; no page errors. LAN URL is stable across Metro restarts (exp://<lan-ip>:8081), so a restart to pick up new modules needs no new QR.

Android target - device-test fixes (owner testing round)

Owner tested on the phone and flagged: the status bar covering the tabs, the DPL editor rendering garbled, tabs/buttons mis-tapping ("buttons click other buttons"), and keyword/artist settings that were asked removed and aren't even on the web. Read the REAL web composer (components/PromptComposer.jsx) for true parity, then fixed:

  • App.js: react-native-safe-area-context SafeAreaProvider/SafeAreaView (edges top) - react-native's SafeAreaView is a no-op on Android, which is why the status bar overlapped the tabs.
  • GenerateScreen.js: dropped the highlight-overlay editor (garbled on-device) for a plain monospace input; removed the invented settings; mirrored the web's editable Prompts count in the field bar; gear opens a minimal prompt-settings sheet; auto-fix/keyword tools rendered disabled (no text provider on mobile); removed hitSlop that overlapped adjacent tap targets.

ngrok tunnel is down today (@expo/ngrok "Cannot read properties of undefined (reading 'body')", points to status.ngrok.com) - fell back to LAN (expo start, exp://<lan-ip>:8081; phone must share the PC's Wi-Fi). Also: restart Metro after adding a native-bundled dep (safe-area-context) so its module map picks it up (Metro was started before the install).

Android target - composer/nav fidelity pass + web⇄mobile parity rule

Owner: keep the web UI in parity with the mobile UI by default. Recorded as a standing rule in notes/systems/mobile.md (new) - the analog of the CLI's engine+GUI parity rule.

Rendered the REAL web phone Home (expo-exported web + Playwright at 390px) and matched it natively:

  • App.js: one-row top bar - brand mark + enclosed pill view-switch (active green-filled) + overflow.
  • GenerateScreen.js: the web PromptComposer natively - Insert ▾ dropdown, code-editor prompt box (gutter 1 + + {…} syntax highlight over a transparent input, ✓/eye/gear), PROMPTS count, tool toolbar + round green generate button, bottom-left green building-blocks FAB.
  • lib/icons.js (new): react-native-svg line-icons matched to the web set (Expo Go bundles svg - no rebuild).

Verify loop: edit on Windows → expo export --platform web --clear → stage dist-web/ into the cloud container → serve + Playwright screenshot at 390px → compare to the web reference. Generate/Gallery/Manage all match; no page errors. dist-web/ + run logs gitignored.

Landmine: each Windows-MCP PowerShell call is a SEPARATE session, so Start-Job / -NoNewWindow children die with it. Launch long-lived processes (expo export, expo start --tunnel) via Start-Process <npx.cmd> ... -RedirectStandardOutput <log> (detached, survives) and poll the log. The exp:// tunnel URL isn't in the redirected log (expo's interactive UI only) - read it from the ngrok inspector at http://127.0.0.1:4040/api/tunnels.

Android target - reworked the UI to match the web app (owner correction)

Correction. I'd assumed web = desktop-only and invented a fresh mobile design (blue theme, bottom tab bar, a top-right tab-name tag). Owner: the web app ALREADY has a complete responsive phone layout - stick close to it. Reworked to mirror it.

Read the web design first: notes/plans/responsive.md (nav pattern, drawers, master/detail), styles/foundation/tokens.css (the real palette), components/Home.jsx (composer + block-palette + results).

Changes:

  • lib/theme.js: tokens mirrored from the web (mint #34e2a0 accent + dark ink, #1c1c1f canvas, #232328 panels, real text/border colors, 14px radius). Re-themed every screen off it.
  • App.js: top-bar nav (brand + scrollable view switch), removed the bottom tab bar + the top-right tag.
  • Generate: composer + a building-blocks bottom sheet (palette grouped from the engine catalog) + the virtualized results list - matching Home.jsx.
  • Single/Manage: re-themed; Manage keeps master/detail + back.

Verified web render (screenshotted at 390px: mint theme, top-bar view-switch, blocks drawer works) + Android Hermes bundle (697 modules, 3.25 MB .hbc). The web:build for a live reference errored (a prerender/build issue in this env) so I matched from tokens + component structure; a live-reference pass can refine later.

Next. Image generation (targets/web/shared providers + SecureStore) to fill the Gallery; Manage overlay into runtime generation. Later: fonts (Maven Pro / Space Grotesk) + the secondary-controls overflow menu.

Android target - full 4-tab app built to the max-load performance bar

Scope correction. Earlier I wrongly scoped mobile to just Generate (mirroring the web online edition where Gallery/Single/Manage are disabled). Owner corrected: the phone HAS storage, so all four tabs apply, backed by the filesystem. Rebuilt as the real 4-tab app.

Read the perf bar first (owner-directed). notes/reference/performance.md + decisions/architecture.md: supported max simultaneous load = 100k-image gallery + 1000 prompts (~10k images) + 100k-line Manage file. Built each surface to it with RN-native tools:

  • Generate: virtualized FlashList results (1000 rows), memoized ResultRow, stable seed:index identity.
  • Gallery: recycling FlashList + expo-image (disk cache, downsampled, recyclingKey), uniform cells (100k).
  • Manage: windowed editor - UNCONTROLLED per-line inputs commit into a ref so typing never re-renders the 100k-row FlashList; responsive filter; stable per-line ids for add/delete.
  • Single: expo-image detail. Storage: lib/storage.js on expo-file-system (legacy API), web-safe no-op.

Shell: custom bottom tab bar, panes kept mounted (absoluteFill + display:none) so state persists on switch - the web SPA's pattern.

Verified: react-native-web render of all tabs (screenshotted at 412px; Generate/Gallery/Manage clean, tab nav works, web-safe storage guard shows "off in web preview"); Android Hermes bundle 696 modules -> 3.24 MB .hbc. Metro landmine: a non---clear export served a STALE App.js; always --clear after screen edits.

Next. Image generation (reuse targets/web/shared/ provider adapters + expo-secure-store keys) to fill the Gallery; wire the Manage user overlay into runtime generation.

Android target - runs on a real phone (Expo Go), SDK 54 pin, clean alias, emulator notes

On-device. The PoC now runs on the owner's Moto G Power (Android 16) via Expo Go. Two blockers solved:

  • Expo Go SDK mismatch. create-expo-app gave SDK 57; the Play Store Expo Go is pinned to SDK 54 (Expo policy, May 2026) and refused ("requires a newer Expo Go"). Downgraded the mobile package to SDK 54 (expo 54.0.35, RN 0.81.5, React 19.1.0) via expo install --fix. Confirmed running on the phone.
  • Connection. expo start --tunnel (@expo/ngrok) so the phone connects over any network (couldn't add a firewall rule for LAN without admin).

Engine linking - settled on a Metro alias (no copy, no symlink). Relative ../../engine never resolved (repo root is an ESM type:module package Metro won't cross); a junction worked on SDK 57 but not SDK 54; an interim copy worked but was inelegant (owner pushed back). Final: Metro resolver alias (extraNodeModules: engine -> real repo path) + watchFolders + nodeModulesPaths. Single-source, verified. Removed scripts/link-engine.mjs; setup is just catalog. Logged engine-as-workspace-package as the proper follow-up.

Screenshots. Owner's phone Developer Options is carrier-blocked (no adb). Fixed the local emulator boot (hw.gpu.enabled=no + 2GB -> swiftshader_indirect + 4GB, ~47s) but headless screencap fails (hasReadColorBufferDma) and hardware-GPU crashes - a machine graphics-stack issue. Settled on the react-native-web render (identical RN tree, real engine) as the screenshot tool; owner can send manual phone screenshots for real-hardware checks.

Next. Port the tab UI to RN screens; then the engine-as-package refactor.

Android target - Expo scaffold + Hermes bundling proven

Scaffolded targets/mobile via create-expo-app --template blank (Expo SDK 57, RN 0.86, React 19; New Architecture + Hermes are the defaults). Wrote a PoC App.js that runs the REAL engine via metroLoader (generate a prompt + reroll). Pruned template cruft (its CLAUDE.md/AGENTS.md/LICENSE/.claude).

Metro monorepo fight (resolved). Metro would not resolve the engine across the monorepo boundary (../../engine/...): even with canonical watchFolders + nodeModulesPaths and cleared caches it reported the (existing) engine files as non-existent - the repo root being an ESM (type:module) package appears to confuse Metro's resolver. Fix: link the engine IN-project via a Windows junction (targets/mobile/engine -> ../../engine, scripts/link-engine.mjs / npm run link:engine), install the engine's runtime deps (lodash, compromise) locally, and use the DEFAULT Metro config. All resolution now stays inside the project root. (Separate earlier blocker: babel-preset-expo was not hoisted top-level; installed it directly.)

Result. npx expo export --platform android -> Android Bundled 623 modules -> 2.7MB Hermes bytecode (.hbc). The engine that parity-proved under Node now COMPILES for Android under Hermes/Metro. Compilation proven; not yet run on an emulator.

Committed: targets/mobile scaffold (App.js, app.json newArchEnabled, babel/metro config, package.json with link:engine/catalog/setup scripts, scripts/link-engine.mjs, package-lock.json). node_modules, the engine junction, and dist/ are gitignored.

Next. Boot an Android emulator (SDK at C:\Users\juneh\AppData\Local\Android\Sdk) to see the PoC screen generate a prompt, then port the tab UI.

Android target - decision + Metro loader foundation (parity-proven)

Request. Owner wants an Android target (no iOS/Windows Phone). Asked whether Facebook's "React on the native layer" (React Native) still exists and is wise.

Decision. Rejected WebView wrappers (Tauri mobile / Capacitor) as fragile/heavy here, and a native rewrite (Flutter/Kotlin) because it would abandon the isomorphic JS engine (the crown jewel). Chose React Native via Expo (New Architecture: Hermes + Fabric) - native feel, light runtime, single JS stack; the engine + fetch-based providers reuse unchanged, only the tab UI gets a one-time port. Mobile drops NSFW + local providers (which conveniently removes the Node-dependent adapters). Android SDK to be referenced via a git-ignored symlink under assets/references/ (owner's idea) with an ANDROID_HOME fallback - wired at scaffolding.

Content rating. Play is hostile to BOTH adult apps and kids apps. Settled on: Play build = all-ages ("Everyone" IARC rating), NOT enrolled in Designed-for-Families/kids (avoids the heavy kids scrutiny), and no NSFW at all. NSFW stays GitHub-APK-only. Base corpus is already purged of slurs/minor-sexual/ extreme by contentSafety.js, so "SFW" starts from an already-clean base.

Built (de-risk foundation, no Expo install yet):

  • engine/core/metroLoader.js - third isomorphic loader (static catalog, synchronous).
  • scripts/build-metro-catalog.mjs - codegen for metroCatalogData.js; tiers full|sfw; NSFW excluded by name token; nsfw group refs + nsfw preset stripped from sfw; all-ages scrub hook.
  • scripts/metro-parity-check.mjs - parity gate. npm metro:catalog / metro:parity; catalog gitignored.

Verified. npm run metro:parity -> 89 blocks + 88 lists identical to nodeLoader (built-in), 150 seeded generations byte-identical. SFW catalog carries no adult content/refs (only the doc header + anti-nsfw negative prompts remain as the literal token). Lint + smoke green.

Next. Scaffold targets/mobile/ (Expo, New Arch) consuming the shared engine + Metro catalog; SecureStore for keys; two build flavors (play=sfw, full); then port the tabs to RN screens.

Maintenance sweep — branch cleanup, ship dev→main, docs reconcile, new runbook

Request. Owner asked for a full maintenance sweep: close all feature branches (local + GitHub), ship devmain, get both branches in sync, and fix any doc/notes/README drift — "full sweep type thing." Mid-run they added: turn it into a reusable maintenance procedure and report it to the fairyfox hub for adoption.

Git / branch cleanup.

  • Audited first (read-only): all six local feature/* branches were fully merged into dev; the two remote feature branches (gallery-composer-a11y-seo, user-overlay) were merged too; one Dependabot PR (#43) had unmerged content.
  • Surfaced PR #43 and asked — owner chose to adopt it. Merged it into dev (vitest + @vitest/coverage-v8 4.1.9→4.1.10, a dev-dep patch; test-tooling only → no VERSION bump); its remote branch auto-deleted.
  • Deleted the 6 merged local branches and the 2 merged remote feature branches. End state: only main + dev on both the local checkout and GitHub, zero open PRs.

Docs reconcile. notes/status.md had drifted: Version: said 2.46.0 (actual 2.51.1), shipped branches were still labeled "pending review," the structure para called the CLI "planned" (it shipped in 2.50.0, ComfyUI in 2.51.0), and the build-health/open-issues tables were stale (18 lint warnings, 128/60 tests). Corrected the version line + a dated "two branches only" note, marked CLI/ComfyUI as shipped targets, retired the now-resolved lint-warning issues, and refreshed the health table with freshly-run numbers (lint 0 problems; smoke ok; unit 319; web 419). README verified accurate.

New procedure (for reuse + hub proposal). Wrote ../../reference/maintenance-sweep.md — the repeatable full-sweep runbook (audit → triage → close branches → ship → reconcile docs → verify), composing the existing git-workflow / repo-hygiene / versioning standards with hard safety rules. Wrote the fairyfox proposal report ../../fairyfox-reports/2026-07-09-propose-maintenance-sweep-standard.md recommending the hub adopt the procedure cross-project (proposal-only — hub not touched, per the stay-inside-this-repo guardrails; the owner carries it upstream).

Verify + ship. npm test green (check:docs 364 links · lint 0 · smoke · unit 319 · web 419); npm run check:tidy clean. Then shipped devmain via PR (no VERSION bump, so release.yml no-ops — the v2.51.1 tag already exists) and back-merged so dev contains main.

DPL editor dark-mode chrome fix — white gutter + washed-out active line (2.51.1)

Symptom (owner-reported, verified visually at prompt.fairyfox.io in dark mode): the DPL prompt box's line-number column rendered as a bright-white slab, and the active line was washed out by a milky light-blue band.

Diagnosis. Inspected the live DOM's computed styles: .cm-guttersrgb(245,245,245), .cm-activeLineGutterrgb(226,242,255), .cm-activeLinergba(204,238,255,0.267). Those are CodeMirror 6's built-in light gutter baseTheme values. CM's gutter baseTheme ships both &light .cm-gutters {#f5f5f5} and a &dark variant and switches on whether the EditorView theme declares { dark: true }. Neither DplEditor.jsx nor CodeEditor.jsx did, so CM stayed light and injected the light rules — at the SAME specificity as the existing .dpl-editor .cm-gutters CSS (both one class + .cm-*), so CM's later-injected <style> won on source order. The plain-CSS override (added back in 2.49.6) never actually applied; that's why the deployed 2.51.0 still looked bad even though the source "had a fix".

Fix. Moved the chrome colors into a CodeMirror theme extension (targets/web/frontend/lib/editorChrome.js) — a theme gets StyleModule priority over the baseTheme, so it wins reliably. Values use the app's CSS variables (--faint, --muted, --fg, --accent-soft) so it stays light/dark adaptive without hard-pinning a dark flag. Wired into both editors. Removed the now-dead CSS rules and left a comment explaining the mechanism.

Verify. Ran the dev server, screenshotted the editor in dark mode (transparent gutter, dim numbers, subtle neutral active-line lift) and confirmed light mode adapts (subtle dark lift, transparent gutter). Added tests/e2e/editor-chrome.spec.js (dark scheme; asserts non-light gutter + neutral active line) — passes against the production build (4.8s).

Note for release: committed to dev as 2.51.1 (PATCH). Not yet released to main/deployed — the live site keeps showing the white gutter until a dev → main release goes out.

Landmine recorded: see notes/reference/esm-patterns.md / fix-patterns — CodeMirror chrome (gutter, active line, selection, tooltips) must be themed via EditorView.theme, NOT plain CSS, because CM's baseTheme is same-specificity and injected late. (The autocomplete-contrast fix hit the same class of bug from the tooltip side.)