Skip to content

web: port the LUI web backend to pure OCaml/Melange - #63

Merged
tiensonqin merged 35 commits into
mainfrom
devin/lui-web-backend
Sep 27, 2026
Merged

tiensonqin merged 35 commits into
mainfrom
devin/lui-web-backend

Conversation

@tiensonqin

Copy link
Copy Markdown
Collaborator

Summary

Replaces the deleted LG web backend (platform/web/lg/lui/backend/web.cljc, ~6400 lines) with a pure OCaml/Melange backend in platform/web/melange/ (~9400 lines), so lui compiles the whole UI stack to JavaScript without any ClojureScript. The port preserves the LG-produced DOM contract exactly — same tags, class names, attributes, and child order — so platform/web/src/lui.css and the web e2e suites needed almost no changes.

Module layering (see platform/web/melange/PORTING.md, dependency order bottom→top): core/ (types, util, retained store mirror) → nodes/ (element factories incl. modal/tooltip/split DOM) → popup/ (position, overlay, menu incl. submenu corridor) → widgets/ (incl. lui_web_split) → focus/ (tree/horizontal/toolbar/tabs roving) → render/props (apply_property/remove_property) → events/ → shell/ (apply_dom_batch, simulator, extension plumbing, Lui_web entry). Modules may only call layers below them.

Web demo restored as examples/components/web/ — a thin Melange entry that mounts the existing shared OCaml gallery (examples/gallery, already used by the iOS/macOS demos) into #app, with the gallery's navigation shell (web_main.ml). On WebOS the gallery additionally mounts the NativeExtension and Tweaks pages via web simulator extensions (map/camera).

Hot reload: the Vite plugin's LG root-swap (Lg_runtime__Runtime_reference) no longer exists in pure OCaml, so platform/web/vite.config.mjs now watches examples/**/*.ml(i), waits for the dune -w emit to settle with all entry modules present (the emit tree is replaced wholesale mid-build), invalidates the module graph, and sends full-reload. tooling/dev_web.mjs runs opam with --switch=default so melange-webapi resolves.

Runtime fixes uncovered by e2e:

  • Lui_runtime.event_is_value_echo for ToggleChanged only consulted Checked, which suppressed collapse (ToggleChanged(_, false)) on nodes that bind Expanded (tree rows) or Selected (accordion) instead — both fallbacks added.
  • accordion gained ?selected_signal (matching menu_item's precedent) so the gallery can bind a reactive signal instead of a one-shot sample.
  • Store enabled_node: absent Enabled means enabled (only Some false disables).
  • Modal surfaces (Dialog/Sheet) get a heading + lui-modal-description slot inside the title div; the class name avoids the stylesheet contract's banned lui-{dialog,sheet}-* layout suffixes.
  • MenuTrigger is now treated as a menu row (Store.menu_item_row) so it gets data-submenu-trigger/aria-expanded, hover-open wiring, menuitem role, and inclusion in roving/keyboard item lists; nested dropdowns default data-anchor="right".
  • Picker dropdown initial focus uses a microtask instead of setTimeout(0) so it lands before any later-queued task.

Test results (all green): platform/web unit/contract suites 44/44, overlay.e2e.mjs 38/38, simulator.e2e.mjs 15/15, hot-reload.e2e.mjs 1/1, firefox.e2e.mjs 1/1. dune build clean (warnings-as-errors honored).

Link to Devin session: https://app.devin.ai/sessions/5d6b198dceb54bb2b8fa02c8a412f45c
Open in Devin Desktop: https://app.devin.ai/desktop/session/5d6b198dceb54bb2b8fa02c8a412f45c?variant=devin
Requested by: @tiensonqin

…e stubs

Porting foundation for the LG web.cljc backend to OCaml/Melange:
- core/lui_web_types.ml: renderer, retained mirror, simulator, extension types
- core/lui_web_util.ml: shared DOM helpers and DOM-type coercions
- core/lui_web_store.ml: retained mirror + batch validation port
- Feature-module stubs (nodes/render/widgets/focus/popup/events/shell) so
  parallel porting compiles; PORTING.md documents ownership and conventions.
- dune: include_subdirs unqualified + lui/melange.dom deps (needed for the
  feature-folder layout requested for this port)
Ports web.cljc 3137-3345 and 5439-5597 to OCaml/Melange:
clamp-popup-axis, resolved-popup-side, position-anchored!,
position-tooltip!, begin-popup-open!/close!, prefers-reduced-motion?,
transition-event-from?, after-transition!,
finish-popup-close-after-transition!, align-select-item-with-trigger!,
position-dropdown!, point-in-triangle?, submenu-corridor?.
Picker lookup helpers are replicated locally since position sits below
menu in the module layering.
- shell/lui_web_apply.ml: apply_dom_op/apply_dom_batch covering
  CreateNode/CreateExtension/DropNode/SetProp/RemoveProp/
  SetExtensionProp/RemoveExtensionProp/InsertChild/RemoveChild/MoveChild,
  portal routing (dropdown/modal/tooltip/context-menu/toast),
  bottom-tabs page+trigger handling, visible-child-index hiding popup
  kinds, focus save/restore across moves, post-batch roving refresh.
- shell/lui_web_extensions.ml: extension adapter lookup, platform node
  creation, prop apply/remove, cleanup (split out so apply can use it
  without a lui_web cycle; lui_web re-exports for API parity).
- Stub arity/return-type fixes so callers compile clean under
  warnings-as-errors.
…elange

Ported from web.cljc:
- 866-1055 picker/combobox plumbing (picker-dropdown ... activate-menu-item!)
- 1261-1322 attach-picker-press-event! / attach-picker-trigger-events!
- 1694-1710 dropdown-group-contains-event?
- 2047-2210 attach-dropdown-events! (pointer/click/resize/key handlers, typeahead)
- 2806-3131 context-menu (direct-context-menu ... attach-context-menu-events!)
- 5597-5794 set-dropdown-open! / mount-dropdown! (submenu hover wiring)
- 5843-5860 update-picker-expanded! / 5894-5907 remove-dropdown-after-exit!

Popup transition helpers (begin_popup_open/close, after_transition,
finish_popup_close_after_transition) implemented locally since
Lui_web_position does not expose them yet.
…ass refresh, accordion open/close, alignment and select-display helpers
Port web.cljc split (split-base-fraction … attach-split-events) and
widgets (update-icon-name … select-display-text) ranges to OCaml:
fraction reconciliation with resize-origin animation, divider drag and
keyboard adjust, image/media-surface registries, avatar/image crop math,
stepper/timeline/bottom-tabs/progress updaters.
…n/lui-web-backend

# Conflicts:
#	platform/web/melange/popup/lui_web_overlay.ml
Replaces the LG-compiled demo entry with OCaml: web_main.ml ports the
web entry from web_main.cljc (simulator-map/camera/native-card/gallery-accent
adapters, simulator toolbar outside #app, gallery shell nav by root
section), and web_bootstrap.ml mounts it on #app.

The gallery itself reuses examples/gallery's gallery_app library
(model/view/extension_schemas) instead of re-porting gallery.cljc;
gallery_app gains the melange mode so melange.emit can link it.
index.html keeps the same shell and e2e hooks, with an import map over
the lui-components-web emit node_modules (no lg.* entries).
… on tree rows

- store: absent Enabled means enabled (only Some(false) disables)
- nodes/props: Dialog|Sheet get heading+description slots in title div
- runtime: ToggleChanged echo check consults Expanded on tree rows,
  restoring ArrowLeft collapse in trees
- web demo: backtrace on mount error; web gallery shows native
  extension + tweaks sections via simulator extensions
- simulator: JS float formatting for --lui-device-scale; e2e waits
  300ms for accent transition
…n class

- vite plugin: watch examples/**/*.ml(i), wait for dune emit to settle,
  invalidate module graph, send full-reload (replaces LG runtime
  root-swap which no longer exists in pure OCaml)
- dev_web.mjs: opam exec --switch=default so melange-webapi resolves
- hot-reload.e2e.mjs: rewritten for the Melange pipeline (source edit →
  reload instead of LG HMR; CSS/JS HMR semantics unchanged)
- nodes/css: dialog|sheet description slot renamed to
  .lui-modal-description (tailwind contract bans
  .lui-{dialog,sheet}-{header,content,footer,description})
The e2e composition tests assert on .lui-combobox-query-value to read the
model-owned query; the OCaml gallery port dropped the class the original
cljc gallery set on that text element.
…rols

Port fidelity fixes matching the original gallery.cljc:
- button_group/toggle_group/breadcrumb/pagination used
  ~accessibility_identifier (renders id=) where the original used
  :accessibility-label (renders aria-label=). Switch to ~label so the
  roving-group containers expose their accessible names.
- The vertical Insert toolbar's checkbox/select/input were wired to
  model.disabled (initially false); the original passed a constant-true
  toolbar-disabled-source, so the controls are always-disabled showcase
  cases. Use ~disabled:true.
…task

MenuTrigger nodes render as .lui-menu-item but every menu-row check only
matched MenuItem, so submenu triggers never got data-submenu-trigger/
aria-expanded, press/highlight events, menuitem roles, or inclusion in
keyboard-nav item lists — submenu mouseenter left aria-expanded null.

Add Store.menu_item_row covering MenuItem | MenuTrigger and use it in
menu item collection, role refresh, context-menu focus items, submenu
trigger detection, dropdown anchor resolution, and submenu cleanup.
Route MenuTrigger visible text to its label span so the icon child is
not wiped.

Nested dropdowns hanging off a menu row inside another menu default to
data-anchor=right when no explicit anchor is set, matching the original
gallery's explicit right-anchored submenus.

mount_picker_dropdown deferred its item-focus/active-descendant work
with setTimeout(0) because item DOMs are inserted by later ops in the
same batch; the timer task could lose to a subsequent scripted
assertion. Use a microtask instead — still after the batch unwinds but
before any later-queued task.
…pages

- accordion: add ?selected_signal so open state round-trips reactively;
  gallery binds ~selected:(reactive open_) instead of a one-shot sample
- lui_runtime: event_is_value_echo falls back to Selected for ToggleChanged
  when Checked/Expanded are absent (accordion, drawer, toggle-button)
- gallery: BottomTabs panel titles use styled text instead of role=heading
- combine empty_state title uses styled text instead of role=heading
- gallery: wrap platform tweak demo in a Tweaks section so its page has a title
- e2e: nav count 66 -> 69 in overlay and firefox suites (kept in sync)
Dune replaces the whole emit tree on rebuild; the changed module settles
before sibling entries reappear, so the previous version could trigger a
reload into a transiently-incomplete tree.
@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tiensonqin
tiensonqin merged commit 9487731 into main Sep 27, 2026
4 checks passed
@tiensonqin
tiensonqin deleted the devin/lui-web-backend branch September 27, 2026 09:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant