Direction for the next iteration. Strategic, not a release checklist — see
CHANGELOG.mdfor shipped work.
Make LWT reachable on a phone as a real app, starting with the FOSS audience (F-Droid) rather than the mass-market Play Store. The long-term prize is an app that does more in the browser and less on the server, so it can eventually run offline and sync — but each phase below is independently useful and we only pay for the hard parts when demand proves them worth it.
- F-Droid first, not Play Store first. The FOSS audience tolerates the rough edges (entering a server URL, self-hosting) that mass-market users won't, so it needs less of the expensive zero-config work.
- TWA is shelved. A Trusted Web Activity needs Chrome at runtime (fails on
de-Googled phones) and reads to F-Droid as a
NonFreeNetworkwrapper around a server we control. It was a shortcut for a different audience. - The mobile artifact is a configurable client, not a hardwired wrapper. A thin shell over the system WebView, pointed at a user-chosen server URL. This fits F-Droid's accepted "client for a self-hostable free service" pattern (Nextcloud-client style), and — unlike a TWA — it's reusable for Play Store later with a default instance baked in.
- License is Unlicense (public domain) → zero F-Droid licensing friction.
lwtstays the umbrella for the project and the server. The original name is kept for everything technical.- The mobile app carries its own brand: Lukaisu. Locked 2026-06
(
org.lukaisu.app,lukaisu.orgregistered), replacing the oldlanguage-readercodename. The consumer-facing client is where rebranding starts; the server stays LWT. App repo:../lukaisu/. - Name deadline — met. The Android
applicationId/ package name is permanent once published to F-Droid/Play, so it had to be locked before Phase 2's first release. Done:org.lukaisu.appis committed in the app's Capacitor config and is final. - Smooth the seam in client copy: the Lukaisu app connects to "LWT servers" — the server-URL config screen should make that relationship clear.
- App de-coupling (Phases 0, 1, 3, 4) lives in this repo (
lwt/). It is the app evolving — not separable. - The mobile client (Phase 2) has its own repo:
Lukaisu(../lukaisu/). Rationale: F-Droid builds reproducibly from a focused repo+tag (a thin Android project, not an Android subfolder buried in a 138k-LOC PHP monorepo); separate toolchain and release cadence; matches the workspace's one-repo-per-concern convention and the standard server/client split. - This roadmap stays in
lwt/— the published, version-controlled repo. The parent workspace folder is not a git repo, so a roadmap there would be an untracked, unshareable file.lukaisu/ROADMAP.mdcarries the build-focused detail; this file keeps the ecosystem strategy.
LWT renders HTML server-side (71 view templates). Until more logic moves to the
frontend against /api/v1, any mobile wrapper is just a window to a server.
So "more frontend, less server-rendered" is the enabling work — the thing
that makes a good client today and offline/local-first possible tomorrow.
The raw count overstates the remaining work, and converting a view does not
reduce it — a converted page keeps its .php file as a scaffold. Classified by
what each template actually does (audited 2026-08-07): roughly 18 are already
Alpine-rendered mount points, ~35 are static markup and form fields where
conversion buys nothing, and only ~17 still render database rows into HTML.
Most of those 17 are admin/config surfaces that are fine in an online WebView.
Track the mobile-critical flows, not the file count.
- Confirm and document the strategy above.
- API base URL decoupling: the frontend no longer assumes same-origin.
@shared/api/clientresolves an injectable server root (setApiServer, precedence runtime > localStoragelwt.apiServer> meta > same-origin), so the mobile client / self-host / future default instance can all point it at a chosen server. Unset = unchanged same-origin behavior. - Cross-origin auth plumbing (the seam's server + client halves):
- [x] Opt-in CORS on
/api/v1viaCORS_ALLOWED_ORIGINS(Corshelper + preflight handling inApiV1::handleRequest). - [x] Client sendsAuthorization: Bearer(setAuthToken/getAuthToken, persisted aslwt.apiToken); server already validated Bearer tokens. - [x] First-run auth UX (GET /connect,clientAuthcomponent): choose-server (probes/version) → log in or register against the auth API, storing the bearer token. Plus session longevity — proactive token refresh before the 30-day expiry, andlwt:auth-expired→/connecton a 401. Remaining for Phase 2: the Capacitor shell that hosts these screens and supplies a default server. - [~] Security hardening pass — continue the XSS phases. Gate for any
shared/public exposure; non-negotiable before a public instance.
Phase 8 shipped: four DOM sinks that built markup by string
concatenation (bulk-translate inputs, multi-word markers, the language
list, LibreTranslate errors) now set attributes and text through the DOM,
and the
json_encode-into-<script>convention is held by a codebase-wide invariant test instead of site-by-site vigilance. Ongoing — this bullet stays open until a full review, not just the XSS classes, has been done.
Reframe (post-audit): the mobile-critical pages are already client-rendered.
read_desktop.php renders the word grid + state 100% client-side via
textReader.renderTextContent() against /api/v1, with an offline prototype
(offline-text-reader.ts + IndexedDB) started. review_desktop.php is "all UI
rendered by Alpine.js." So Phase 1 is cutting the server-shell umbilical, not
converting pages.
Status (re-audited 2026-08-07): complete as scoped. Every mobile-critical surface is shell-free, and the legacy-fragment cleanup — the last open item in this phase — has shipped.
This does not close #266. That
issue asks for PHP to emit "only data for hydration", which is a wider bar than
Phase 1's. The data half is met (config blobs carry boot parameters —
['textId' => …, 'langId' => …] — and everything else is fetched from
/api/v1), but PHP still owns the markup half (audited 2026-08-08):
| PHP still owns | Where | Scale |
|---|---|---|
| DOM scaffold | x-data in views |
53 views |
| Which JS modules load | PageLayoutHelper::getRequiredModules() → <meta name="lwt-modules"> |
every page |
| i18n injection | PageLayoutHelper::buildI18nScript() |
every page |
| Icons | IconHelper::render() → server-rendered <i data-lucide> |
throughout |
A bundled client used to work around all four with a build-time transpiler rather than removing them; that client and its transpiler are gone (see Phase 2), so nothing routes around this today. #266 stays open against that markup half; it is not a Phase 1 deliverable, and with the mobile client now independent it needs a fresh justification before it is worth paying for.
-
(Phase 0 gate) Injectable API base URL. Done in Phase 0 — same seam.
@shared/api/clientresolves an injectable absolute server root and everything client-rendered already routes through it. (Listed here too because it's the gate the rest of Phase 1 depends on.) -
Reading — content + chrome now shell-free. The word grid + state render 100% client-side from
/api/v1/texts/{id}/words(TextTermApiHandler→word_store.ts→text_reader.ts); offline prototype atshared/offline/offline-text-reader.ts. The reader's chrome is now client-rendered too: book-context nav fromGET /texts/{id}/book-context(book_nav_renderer.ts) and the audio player fromGET /texts/{id}/audio(markup-onlyaudio_player.php+audioPlayerfetch), soread_desktop.phpcarries no per-text data andTextReadControllerdropped the server-side book/media plumbing. Verified live (book nav, audio reveal, no-regression plain read). Toolbar labels use__e()but resolve via the client i18nt(). Nothing outstanding — the global navbar was the last shared dependency and it shipped (next bullet). -
Global navbar — shell-free. Shipped in 3.2.0 (
d6e6a5203).PageLayoutHelper::buildNavbar()is gone; PHP now emits only the mount point<div id="navbar-root" data-current-page="…">(buildNavbarPlaceholder()), andmountNavbar()fetchesGET /api/v1/navbar(per-user language list, current language, theme state, admin/multi-user flags) and builds the markup inshared/components/navbar_renderer.ts. Labels resolve from the i18n bundle, so the payload stays locale-agnostic. Zero server-rendered navbar markup remains insrc/**/*.php. This was the last chrome dependency spanning every page. -
i18n → client (delivery mechanism). Shipped:
GET /api/v1/i18n[/{locale}](public;Translator::getAllTranslations()) returns the flat "namespace.key" => string bundle, merging English fallback — the same shape the page blob uses.shared/i18n/translator.tsgainedloadI18nFromApi()(fetch + localStorage cache) andhydrateI18nFromCache()(sync first-paint), so a shell-free client gets strings without a server-rendered page. The server-injected blob stays as the default for SSR pages (additive, no breakage). Client boot wired:bootI18n()(called frommain.ts) uses the blob when present, else hydrates from the localStorage cache and refreshes from the API, persisting the resolved locale (lwt.locale) for offline first-paint — so a bundled client now picks up strings on its own. Templates still calling__e()resolve client-side viat(). -
Review — verified shell-free. The review SPA (
review_desktop.php→review_api.ts) renders entirely from/api/v1/review/*(next-word/status/config/table-words/tomorrow-count all exist). Removed the orphanedstatus_change_result.php(HTML fragment, no route/no caller; superseded by the JSONstatus_change_config.php). The legacy non-SPA review-AJAX page turned out not to exist — an earlier revision of this bullet called it "a separate, non-mobile entry — out of scope", but/reviewis the module's only route. Its five views andreview_ajax.tshave been deleted: a "test" → "review" rename had moved the TypeScript to#term-review/#review-finished-areawhile the views still emitted#term-test/#test-finished-area, so the two halves could not have worked together even if something had wired them up. -
Text list / library — shell-free.
texts_grouped_app.tsclient-renders the list from/texts/by-language/{id}+/texts/statistics, and the destructive bulk archive/delete now go throughPUT /api/v1/texts/bulk-action(per-user scoped) instead of a same-origin form POST, so they work against a configurable API base. The remaining bulk actions (tag / review / reparse) intentionally stay on the form path — they need pickers/navigation and are desktop-admin, not mobile.__e()labels resolve via the i18n API once a page boots from it. -
Vocabulary mgmt — shell-free; legacy fragments deleted. Re-audit (corrected): the modern reader's word actions already go through
/api/v1/terms/*—word_store.ts/word_modal.tscallTermsApi.setStatus/createQuick/delete, and the unknown-word popup uses the API button family (createWellKnownButton/createIgnoreButtonwith aWordActionContext). The modern reader has no#frames-r, so the legacytarget="ro"→*_result.phpmechanism isn't even wired there. So the mobile-critical vocab flow needs no conversion — it's done. The remaining legacy/transitional code has since been removed. All nine dead-end*_result.phpfragments are gone (save_result,edit_result,edit_term_result,edit_multi_update_result,hover_save_result,all_wellknown_result,bulk_save_result, andBook/import_result), along with theword_popup_interface.tslink-builders that fed them. The one file still named*_result.phpisVocabulary/Views/upload_result.php, kept on purpose: it is a live results table rendered by Alpine fromGET /api/v1/terms/imported, not a server-rendered fragment. The term editor behind/word/edit,/word/edit-termand/words/{id}/editnow mounts the same API-driven component the reading view opens in a modal, and bulk save posts toPOST /api/v1/terms/bulk. -
Books — shell-free.
/booksand/book/{id}render fromGET /api/v1/books,GET /api/v1/books/{id}(which nests the chapters) viabookList/bookDetail; the views carry a config blob and nothing else, andBookControllerno longer touchesBookFacadeto render either page. The blocker was the endpoint registry, not the client:DELETE /books/{id}andPUT /books/{id}/progresswere rejected with 405 before dispatch even thoughBookApiHandlerimplemented both. A URL carrying an ID never matches aROUTESkey exactly, so lookup falls back to the first path segment — thebooks/chaptersandbooks/progresskeys were decorative, and the barebooksentry allowed only GET and POST.EndpointMethodReachabilityTestnow asserts real request shapes resolve, so the next drift fails a test instead of a feature. -
Form POSTs — the write half, for the surfaces above (#262). Those surfaces read from
/api/v1; several still wrote by posting a form to the page origin, which a client pointed at a different server cannot do. Converted: tags, user profile/password/preferences, standalone term creation, the text editor (POST /api/v1/texts,PUT /api/v1/texts/{id}— neither endpoint existed;TextsApi.create()had been calling a 404 since it was written), and the feed forms (POST/PUT /api/v1/feeds).Two mass-assignment holes closed on the way: `texts.TxLgID` and `news_feeds.NfLgID` are client-supplied references into `languages`, and a foreign key proves the row exists, not that the caller owns it. Both form handlers passed the submitted value straight to the facade; the API endpoints check ownership, and the form-POST routes that skipped it are retired. Multi-user installs only. Fourteen views still post, down from sixteen: admin (4), the imports (`import_epub_form`, `upload_form`, `bulk_translate_form`), the feed wizard's navigation steps (3), the archived-text and check forms, the Microsoft link confirmation, and `edit_form` for its Check button alone. Every one of those is a surface this phase deliberately leaves server-rendered (below), so what remains of #262 is scoped by that list rather than by view count.
Out of Phase 1 (leave server-rendered, fine in a WebView online): imports (file/web/youtube/whisper), admin/settings, language config, feeds.
Two exceptions inside that list, both because the work was a fix rather than a conversion:
- The feed create/edit forms went to
/api/v1even though feeds are out of scope, because tracing them turned up that finishing the RSS wizard had saved nothing since 2026-08-08 (1e2216bb6). Step 4 posted to/feeds/edit, and that commit made the route a 302 to the manager SPA when the duplicated feeds list was retired; a redirect discards the body. Present in 3.4.0 and 3.4.1. Verified both ways against a live BBC RSS feed: on the parent commit the walk ends withnews_feedsempty, on the fix it writes the row. The wizard's navigation steps (2 and 3) still post to/feeds/wizard— they drive a server-side session state machine that renders HTML previews of the fetched page, and moving that to the client is its own project. - The text editor's "Check" button still posts. It asks for a
server-rendered parsing report rather than saving, so it names its own target
with
formactionwhile the form itself carries no action.
Definition of done per surface: renders entirely from /api/v1 JSON, no
server-rendered partial carrying data, works against a configurable API base URL.
Keep the service worker (sw.ts) + manifest as the offline cache layer; expand
coverage as each surface goes shell-free. Track progress as "% of mobile-critical
flows that run without a server-rendered page."
Closed out of this repo, 2026-08. The client exists and shipped: Lukaisu
(../lukaisu/) is a Capacitor 8 app on the system WebView, signed, fully FOSS,
and live in its own F-Droid repo at https://fdroid.lukaisu.org/repo. Only
the main-catalog submission is still ahead of it. See lukaisu/ROADMAP.md.
It no longer builds on anything here. Lukaisu owns its reading frontend
outright under webapp/ (moved from lukaisu-server/src/frontend/ in its
Phase M, 2026-07) and has gone local-first: an on-device Dexie database and
TypeScript parsers run the read/save/review loop with no server at all,
connecting one being optional. So the bundled-client ("Model B") build this
phase used to describe — npm run build:app, vite.app.config.ts,
build/php-view-prerender.mjs, the four standalone pages under
src/frontend/app/ — had no consumer left, and has been deleted. It was
never in CI or the Docker image; recover it from git history if a bundled build
is ever wanted here again.
What that work bought this repo is kept and still earns its place, because it
serves any remote or third-party client, not just a bundled one: the injectable
API base URL, opt-in CORS, bearer-token auth, GET /connect, and the client
i18n bundle — all listed under Phase 0, all independently useful.
Consequence for the north star: phone reach is no longer LWT's to deliver; Lukaisu delivers it, from its own code. The keystone-constraint argument above was written when a bundled client depended on shrinking the server-rendered surface — it now has to stand on what self-hosters reading in a browser get out of it, which is a weaker case and a smaller budget. Re-argue #266 on those terms before spending on it.
Only once the client exists and demand is real. This is where the hosting commitment and zero-config UX get paid for.
- Guest / deferred-signup first-run ("read now, sign up later to sync")
using existing
Register+ClaimOrphanRows. - Stand up a hardened public instance — eyes open on the real cost: money is cheap (~$15/mo on the existing VPS), but backups, uptime, security patching, GDPR, and support are the actual burden.
- Optional: Play Store build of the same client with the public instance as default. Apply to the main F-Droid catalog.
The true offline F-Droid app, and the only thing that sheds per-user hosting. Do not start until offline demand or hosting cost justifies it.
- Spike conflict resolution FIRST (the Anki problem) — it looks easy and isn't. Decide last-writer-wins+tombstones vs CRDT before committing.
- Move reading + review data to client storage (SQLite-WASM / OPFS).
- Port the corresponding logic from PHP to TypeScript.
- Caveat: server-dependent parsers (MeCab/CJK, NLP lemmatizer, Whisper) stay server-side or need bundled WASM equivalents — plan per-language.
- Security before exposure — public instance multiplies the blast radius of any XSS.
- Sync is the underestimated monster — quarantined to Phase 4 on purpose.
- Two audiences pull opposite ways — FOSS/self-host vs zero-config mass-market. The configurable-server client is what lets one codebase serve both; protect that seam.