mithril-lynx 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  Mithril.js rendered through [Lynx](https://lynxjs.org)'s Element PAPI — the core runtime layer of a Mithril-based alternative to [`@lynx-js/react`](https://lynxjs.org/react/).
4
4
 
5
+ ## Getting started
6
+
7
+ ```bash
8
+ npm create mithril-lynx@latest
9
+ ```
10
+
11
+ Scaffolds a new app via [`create-mithril-lynx`](https://github.com/carlos-sweb/create-mithril-lynx) — pick **Hello World** (a tap-to-animate logo demo, the Mithril analog of Lynx's own React hello-world), **Blank** (a single line of text, nothing else), or **Basic Activity** (two screens wired with `mithril-lynx/navigation`, confirmed working end to end on a real device), in TypeScript or JavaScript. That's the fastest way to a running app; the rest of this README documents the framework itself for once you're inside one.
12
+
5
13
  ## What this package is
6
14
 
7
15
  `src/lynx-mithril-shim.js` is a contract-complete, line-by-line port of `mithril/render/render.js@2.3.8`: the exact same diff algorithm (`createNode`/`updateNodes`/`updateNode`/keyed-diff-with-LIS/etc.) as upstream Mithril, with every DOM call it makes redirected onto Lynx's Element PAPI (`__CreateView`, `__AppendElement`, `__SetAttribute`, `__SetInlineStyles`, `__AddEventListener`, ...) instead of the browser DOM. See `CONTRACT.md` for the exhaustive, reverse-engineered spec of exactly which DOM surface Mithril's renderer touches — that document is the reference this shim is built and validated against.
@@ -35,7 +43,7 @@ engine.addEventListener("__RenderPage", () => {
35
43
  });
36
44
  ```
37
45
 
38
- Subsequent UI updates flow through `shim.redraw()` (called automatically by event handlers bound via Mithril's own `on*` attrs, since the shim ports Mithril's `EventDict`/redraw machinery verbatim) — **never plain `m.redraw()`**, which is a no-op in this shim-based architecture.
46
+ Subsequent UI updates flow through `shim.redraw()` — **never plain `m.redraw()`**, which is a no-op in this shim-based architecture, and **never automatically after an `on*` handler either**: every event the shim hands to a handler is normalized with `redraw: false` on purpose (confirmed on real hardware 2026-09-10 — an `ontap` handler that only mutates local main-thread state, with no round trip through a data-channel/renderer-mode push, silently doesn't repaint until `shim.redraw()` is called from inside the handler itself; no error is thrown, since Mithril's own `EventDict.handleEvent` just skips the auto-redraw when `ev.redraw === false`). This is deliberate — a real DOM redraws cheaply enough that auto-redraw-per-event is a reasonable default there; a full Lynx PAPI diff pass on every touch event is not something to default to. `mithril-lynx/navigation`'s `push`/`pop`/`replace` already do this internally (see its source), which is why call sites using only that module never need to think about it.
39
47
 
40
48
  ## Usage — data-channel mode
41
49
 
@@ -206,11 +214,74 @@ Two tiers, matching the real complexity spread in Lynx's own `list` examples:
206
214
 
207
215
  **Deliberately out of scope for v1** (documented, not silently missing): deferred list items (ReactLynx's `defer`/`isReady` promise dance), `componentAtIndexes` batching, and independent per-item redraw after the initial bind — a bound cell's content is recomputed fresh from `renderItem(index)` only when native calls `componentAtIndex` for it (scroll-driven reuse), not automatically when app state changes.
208
216
 
217
+ ## Navigation
218
+
219
+ `mithril-lynx/navigation`'s `createNavigator({ initial, initialAttrs? })` is a stack-based, in-memory screen navigator — deliberately **not** built on `m.route` (see "Known permanent gaps" below: Lynx pages have no URL/History API for `m.route` to hook into). Android's own Activity navigation doesn't need URLs either — it's a plain back-stack — so this is that idea directly, not a URL-shaped abstraction forced onto an environment with no URLs.
220
+
221
+ ```js
222
+ import { createNavigator } from "mithril-lynx/navigation";
223
+ import m from "mithril";
224
+
225
+ const Home = {
226
+ view: (vnode) => m("view", { ontap: () => vnode.attrs.nav.push(Details, { id: 42 }) }, [
227
+ m("text", null, "Go to details"),
228
+ ]),
229
+ };
230
+ const Details = {
231
+ view: (vnode) => m("view", { ontap: () => vnode.attrs.nav.pop() }, [
232
+ m("text", null, "Details for #" + vnode.attrs.id + " — tap to go back"),
233
+ ]),
234
+ };
235
+
236
+ const nav = createNavigator({ initial: Home });
237
+ shim.renderToPage(page, m(nav.Navigator)); // main-thread-owned mode
238
+ ```
239
+
240
+ Every screen the navigator renders receives its own `attrs` plus a `nav` prop (`push`/`pop`/`replace`/`canGoBack`/`depth`), so screens don't need to import the navigator instance separately to navigate onward. Only the top of the stack is ever mounted — previous screens are torn down, not kept alive offscreen (matching how most single-activity/single-page navigators behave); a popped screen that needs to remember its own state should keep that state somewhere the app already owns (a module-level store, `background.js`'s data store, etc.), not rely on its own component instance surviving the pop.
241
+
242
+ Built on `shim.redraw()` alone, so it works unmodified in all three rendering modes (main-thread-owned, data-channel, renderer) — `nav.push()`/`pop()`/`replace()` just trigger whichever redraw mechanism that mode already uses.
243
+
244
+ **Deliberately out of scope for v1**: screen transition animations (left entirely to the app's own CSS/styling on whatever wraps `nav.Navigator`), and hardware back-button integration (no documented Lynx PAPI hook for it was found — wire a screen's own back-affordance to `nav.pop()` instead, as in the example above).
245
+
246
+ ## Custom fonts
247
+
248
+ Use a plain CSS `@font-face` rule — not `lynx.addFont()`. That JS API (background-thread-only) is for loading a font dynamically *after* mount, matching [`lynx-family/lynx-examples`](https://github.com/lynx-family/lynx-examples)'s own `examples/text/src/custom_font`, which calls it from `componentDidMount` and re-renders via `setState` once its callback fires. For a font known at build time (the common case), `examples/text/src/font_face` is the pattern to copy — a declarative `@font-face`, no JS:
249
+
250
+ ```css
251
+ @font-face {
252
+ font-family: "Ubuntu Mono";
253
+ src: url("./assets/fonts/ubuntu-mono-400.ttf");
254
+ }
255
+
256
+ @font-face {
257
+ font-family: "Ubuntu Mono";
258
+ font-weight: 700;
259
+ src: url("./assets/fonts/ubuntu-mono-700.ttf");
260
+ }
261
+
262
+ :root {
263
+ font-family: "Ubuntu Mono"; /* needs enableCSSInheritance, see below */
264
+ }
265
+ ```
266
+
267
+ Two gotchas, both confirmed on real hardware 2026-09-10:
268
+
269
+ - **The font file must be `.ttf`, not `.woff2`.** A `.woff2` `@font-face` compiles fine — a valid `url('data:font/woff2;base64,...')` lands in the bundle, no build error, no runtime error — but the native text renderer silently never applies it, even with a maximally-distinctive test font (swapping the default sans-serif for a cursive/marker-style face produced zero visual change). Re-pointing the exact same rule at a `.ttf` of the same font worked immediately, no other change needed. Fontsource-distributed packages only ship woff/woff2; get a `.ttf` from the font's original source instead (e.g. [`google/fonts`](https://github.com/google/fonts) for anything Google-Fonts-hosted).
270
+ - **`font-family` set on `:root` (or any ancestor) does not cascade to descendants by default.** `@lynx-js/config-rsbuild-plugin`'s `pluginLynxConfig()` has an `enableCSSInheritance` option that's off unless set explicitly; without it, only the exact element the property is set on gets it — confirmed by setting `font-family: serif` on `:root` and seeing zero change anywhere in the tree. Turn it on (`pluginLynxConfig({ enableCSSInheritance: true })` in `lynx.config.ts`) to use a single `:root` declaration instead of repeating `font-family` on every class.
271
+
209
272
  ## Known permanent gaps
210
273
 
211
274
  - `m.trust` / innerHTML vnodes — no Lynx PAPI equivalent to raw innerHTML injection.
212
275
  - `m.route` — Lynx pages aren't URL-addressable the way DOM `history` is.
213
276
 
277
+ ## Known gap, not permanent
278
+
279
+ - `m.request` — throws (`XMLHttpRequest is not defined`) rather than silently misbehaving: it's hard-wired to a real `XMLHttpRequest`, which doesn't exist in Lynx's JS runtime (neither the main-thread Lepus/QuickJS engine nor the background JS thread). Unlike `m.trust`/`m.route` above, this isn't structural — Lynx does have its own networking primitives — it just hasn't been wrapped in a `$window`-shaped compat layer yet. Use Lynx's own networking API directly (wrapped in a `Promise`, if desired) until this exists.
280
+
281
+ ## Rest of the public `m` API — what's actually used
282
+
283
+ Beyond hyperscript (`m(...)`) itself, only `m.fragment` and `m.censor` are used as shipped from the real `mithril` package — both are pure data/diff logic with no DOM dependency, so they work unmodified. `m.render`, `m.mount`, and `m.redraw` are never called from the real package at all: `mithril-lynx` has its own equivalents (`shim.renderToPage()`/`shim.render()`/`shim.redraw()`, this README's own "Usage" sections) that target the Lynx Element PAPI instead of the DOM — calling the *real* `m.mount()`/`m.redraw()` does nothing here, since they're wired to `m.render()`'s own DOM-only render path, which this project's apps never invoke.
284
+
214
285
  ## Compat with the plain-JS ecosystem
215
286
 
216
287
  Mithril was never hooks-based, so — unlike React — there's no special rules-of-hooks compatibility story to build: `m.redraw()` after any state mutation already works with any plain-JS state library (a simple pub/sub store, streams, whatever). Nothing in this package needs to shim a specific state-management library for that reason; if something in the ecosystem doesn't work, it's not because of a hooks-equivalence gap.
@@ -0,0 +1,35 @@
1
+ // Ambient declaration for the ESM navigation.js (the file itself is not
2
+ // type-checked; this describes its runtime export shape for TS consumers).
3
+
4
+ import type { Component, ComponentTypes } from "mithril";
5
+
6
+ export interface Nav {
7
+ /** Pushes a new screen onto the stack and redraws. */
8
+ push(component: ComponentTypes<any, any>, attrs?: Record<string, unknown>): void;
9
+ /** Replaces the current top screen without growing the stack, and redraws. */
10
+ replace(component: ComponentTypes<any, any>, attrs?: Record<string, unknown>): void;
11
+ /** Pops the top screen and redraws. Returns false (a no-op) at the root screen. */
12
+ pop(): boolean;
13
+ /** True if pop() would actually pop something (stack depth > 1). */
14
+ canGoBack(): boolean;
15
+ /** Current stack depth (1 at the root screen). */
16
+ depth(): number;
17
+ }
18
+
19
+ export interface Navigator extends Nav {
20
+ /** Mithril component that always renders whichever screen is on top of the stack. */
21
+ Navigator: Component;
22
+ }
23
+
24
+ export interface CreateNavigatorOptions {
25
+ /** The root screen, mounted first. */
26
+ initial: ComponentTypes<any, any>;
27
+ initialAttrs?: Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * Creates a stack-based, in-memory navigator (no m.route, no URLs — see
32
+ * navigation.js's header comment for why). Every screen the navigator
33
+ * renders receives its own attrs plus a `nav` prop shaped like {@link Nav}.
34
+ */
35
+ export function createNavigator(options: CreateNavigatorOptions): Navigator;
package/navigation.js ADDED
@@ -0,0 +1,76 @@
1
+ // navigation.js
2
+ //
3
+ // Stack-based, in-memory screen navigation (project plan follow-up, "basic
4
+ // Activity" template support) — deliberately NOT built on m.route. Real
5
+ // `m.route` is hard-wired to the browser's URL/History API (see README.md's
6
+ // "Known permanent gaps"), which has no Lynx equivalent: a Lynx page has no
7
+ // address bar, no back/forward, nothing URL-addressable. Android's own
8
+ // Activity navigation doesn't need URLs either — it's a plain back-stack —
9
+ // so this module ports that idea directly instead of forcing a URL-shaped
10
+ // abstraction onto an environment that has no URLs.
11
+ //
12
+ // Only the top of the stack is ever mounted (previous screens are torn
13
+ // down, not kept alive offscreen) — matching how most single-activity /
14
+ // single-page navigators actually behave, and avoiding the cost of keeping
15
+ // arbitrarily many past screens' DOM trees around. A popped screen that
16
+ // needs to remember its own state should keep that state somewhere the app
17
+ // already owns (a module-level store, background.js's data store, etc.),
18
+ // not rely on the screen's own component instance surviving the pop.
19
+
20
+ import shim from "./src/lynx-mithril-shim.js";
21
+ import m from "mithril";
22
+
23
+ /**
24
+ * Creates a navigator: a stack of {component, attrs} screens, plus a
25
+ * `Navigator` Mithril component that always renders whichever screen is on
26
+ * top. Every screen receives its own `attrs` PLUS a `nav` prop (this
27
+ * navigator's push/pop/replace/canGoBack), so screens don't need to import
28
+ * the navigator instance separately to navigate onward.
29
+ */
30
+ export function createNavigator(options) {
31
+ const { initial, initialAttrs } = options;
32
+ if (initial == null) throw new Error("mithril-lynx/navigation: createNavigator() requires an `initial` screen");
33
+
34
+ const stack = [{ component: initial, attrs: initialAttrs }];
35
+
36
+ function top() {
37
+ return stack[stack.length - 1];
38
+ }
39
+
40
+ function push(component, attrs) {
41
+ stack.push({ component, attrs });
42
+ shim.redraw();
43
+ }
44
+
45
+ function replace(component, attrs) {
46
+ stack[stack.length - 1] = { component, attrs };
47
+ shim.redraw();
48
+ }
49
+
50
+ /** Returns false (a no-op) at the root screen, true otherwise. */
51
+ function pop() {
52
+ if (stack.length <= 1) return false;
53
+ stack.pop();
54
+ shim.redraw();
55
+ return true;
56
+ }
57
+
58
+ function canGoBack() {
59
+ return stack.length > 1;
60
+ }
61
+
62
+ function depth() {
63
+ return stack.length;
64
+ }
65
+
66
+ const nav = { push, pop, replace, canGoBack, depth };
67
+
68
+ const Navigator = {
69
+ view() {
70
+ const { component, attrs } = top();
71
+ return m(component, Object.assign({}, attrs, { nav }));
72
+ },
73
+ };
74
+
75
+ return Object.assign({ Navigator }, nav);
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mithril-lynx",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Mithril.js rendered through Lynx's Element PAPI — a contract-complete port of mithril/render/render.js@2.3.8 to the Lynx main thread, packaged as a reusable framework.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,6 +49,10 @@
49
49
  "types": "./list.d.ts",
50
50
  "default": "./list.js"
51
51
  },
52
+ "./navigation": {
53
+ "types": "./navigation.d.ts",
54
+ "default": "./navigation.js"
55
+ },
52
56
  "./testing": {
53
57
  "types": "./testing.d.ts",
54
58
  "default": "./testing.js"
@@ -70,6 +74,8 @@
70
74
  "gesture.d.ts",
71
75
  "list.js",
72
76
  "list.d.ts",
77
+ "navigation.js",
78
+ "navigation.d.ts",
73
79
  "testing.js",
74
80
  "testing.d.ts",
75
81
  "CONTRACT.md"