gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit12595e4712595e47mission 035: theme re-vendored from layouts.worldapi.org 0222f67 (two corner radii: radiusSmall 5px, radiusLarge 10px); the tracker's 18 own radii -> radiusSmall/radiusLarge (--layout-radius is gone); check-theme 0; gates 379/0, 32/0, 53/0, 229/0, 26/0; real copy: every computed radius in {0, 5px, 10px, 50%}mre12595e47/plugins/web/client.hl

146.8 KB

  1. // The framework's client half — a file-class constructed ONCE in the browser by
  2. // the page's module script, with the seed the server rendered from. The
  3. // components are NOT preloaded: each mount carries the url its module is served
  4. // at and `hlLoad` fetches it when the mount happens. This file is the client
  5. // realm's entrypoint, and the page script sets that realm before constructing
  6. // it.
  7. //
  8. // Its whole job: CLAIM the DOM the server rendered for the mounted instances,
  9. // remember which elements read which member (the SITES), repaint a site when a
  10. // member is written, and turn a same-origin link into a navigation that fetches
  11. // the next route's tree and state and swaps the slot — the shell stays.
  12. import HlwView from './view.hl'
  13. import HlwRouter from './router.hl'
  14. seedText = '' // the server's seed, as JSON text (the one construction argument)
  15. seed = null // { page: { key module params state view }, shell: … | null }
  16. view = new HlwView
  17. router = null // the client's route table, from the seed
  18. shell = null // { key, instance, view, sites, root }
  19. page = null
  20. appHead = null // the app's manifest head defaults, off the seed: what a page
  21. // member that is null falls back to (title, description, image,
  22. // favicon, and the app's own meta list)
  23. slotEl = null // the shell element whose children are the route component
  24. // WHERE IN IT (ticket #81's finding): the shell's slot may stand beside other elements
  25. // of the shell — `body { header { … } slot }` — and the page's nodes are then not all
  26. // of slotEl's children. `slotAt` is the index of the page's first element among them,
  27. // and `slotStart` the open mark of the region the page is (a page in a shell stands
  28. // between two marks like every region), so the page is claimed from the right element
  29. // and a navigation replaces what stands between the marks and nothing of the shell's.
  30. slotAt = 0
  31. slotStart = null
  32. detachedSlot = null // the slot element a shell region's rebuild took out of the DOM (see paint)
  33. socket = null // the websocket carrier, while it is open
  34. socketOpened = false // it has opened (a socket that closes without opening is a failed reconnect)
  35. retryMs = 500 // the wait before the next reconnect: doubles per failure up to 15 s, reset on open
  36. navigating = false // a navigation is running (show); navNext is the one asked for meanwhile
  37. navNext = null
  38. pinging = false // the ping interval is started once, with the first socket
  39. pongDue = null // the timer that takes the socket for dead when its pong is late
  40. nextId = 0 // the pairing id an emit's ack comes back on
  41. pending = {} // 'k<i>' → the resolver waiting for that ack
  42. // AN ANSWER THAT ARRIVES BEFORE ITS PAGE IS MOUNTED (ticket #80). A component's root
  43. // runs while `instantiate` constructs it, and an `emit server` there goes out at once
  44. // (over REST: the socket opens after boot). What the face raised for this tab comes
  45. // back in the ack, before `page` — or, on a navigation, the NEXT page — is the mount
  46. // that holds the listener; delivered then, it reached the old mounts or none and was
  47. // lost. While a mount is being built the frames wait here, and go out in arrival
  48. // order once it stands.
  49. mounting = false
  50. held = []
  51. blueprints = {} // component key → { module, view }: every tree the page can
  52. // build, taken from each seed and never dropped (takeBlueprints)
  53. strict = false // the gates' client: a repaint that finds a stale site throws (staleSite)
  54. painting = 0 // repaints running: 0 when the page stands still (the gates wait for it)
  55. turning = false // a patch is running (patch): the ones asked for meanwhile wait in patchQueue
  56. patchQueue = []
  57. sidNext = 0 // every site's own number, so a repaint can tell one that has left
  58. dropGen = 0 // counts the drops of sites (dropSitesIn): a repaint's list is checked again after one
  59. pendingDrops = [] // ranges that left holding a host's fill: that host's sites and children in them
  60. // go in settle(), once every mount on the walk has been written back
  61. pendingFills = [] // fills a region rebuild anchored but could not build in place:
  62. // the host is named by address and re-entered in settle() below
  63. lateJobs = [] // late patches (#98, #99): see latePatch
  64. lateDue = false
  65. // THE EVENTS A REFERENCE OVERWROTE ON THE COMPOSITION'S ROOT, while the walk is inside
  66. // that composition and has not yet reached an element. A composed child's View may be a
  67. // bare reference to another component (routger's dialog-form.hl is one), so the element
  68. // that FINALLY stands as the child's root can be any number of references deeper — this
  69. // rides down through them and is spent on the first elements the walk claims or builds,
  70. // which are exactly those roots.
  71. rootOverwrites = null
  72. // IS THE PAGE ON THE DOCUMENT? A shell decides per request whether its render contains
  73. // a `slot` (COMPONENTS §7, "THE PARENT IS THE GATE"): `if (loggedIn) { main { slot } }`
  74. // standing false renders no page at all, and there is then nothing to claim. False here
  75. // means there IS no page: the server did not construct it and the seed carries none
  76. // (creator, 2026-08-26 — the parent is the gate). It is asked for and constructed when
  77. // the region holding the slot turns true, and dropped again when it turns false.
  78. pageShown = false
  79. pageArrived = false // a region's rebuild constructed the page: its held frames go out after it
  80. // THE OFFLINE LAYER (COMPONENTS §10), live only when the seed says the app keeps pages
  81. // offline. `store` is the IndexedDB database, opened at boot; `unacked` holds what a
  82. // queueable emit sent on the socket carried until its ack, so a socket that closes
  83. // under it queues the emit instead of losing it; `flushing` is true while the queue
  84. // is replayed; `retryTimer` is the reconnect that is waiting.
  85. store = null
  86. unacked = {}
  87. replays = {} // 'k<i>' → what ends a replayed entry's wait when its socket closes
  88. // AN EMIT IN FLIGHT OUTLIVES ITS SOCKET (ticket #107). Every emit that went out on the
  89. // socket is here until its ack, as the frame it was: a socket that closes under it
  90. // hands it on to the POST fallback or the next socket, with the same `q`, and the
  91. // server's exactly-once path answers the repeat instead of running the face again.
  92. // `post` is true while the POST fallback carries it, `lost` once a POST failed.
  93. inflight = {} // 'k<i>' → { text; event; post; lost; }
  94. // A PAGE BEING LEFT starts nothing. Firefox closes the page's socket the moment a
  95. // document navigation starts, right after `beforeunload` and long before `pagehide`;
  96. // a reconnect or a POST made then, and an answer that loads a document, would cancel
  97. // the navigation the user started. `beforeunload` is only a maybe (a download, a
  98. // navigation the server answers with 204), so after it the page takes itself for
  99. // staying once `stayMs` have passed and it is still here.
  100. leaving = false
  101. stayTimer = null
  102. static stayMs = 3000
  103. flushing = false
  104. retryTimer = null
  105. owner = '' // whose the kept copy is (settleWho): the last user logged in
  106. onlineBound = false // the online/offline listeners are on the window (syncOnline)
  107. holding = false // an anonymous page over a user's copy: nothing is kept or laid over it
  108. heldBack = false // the server said a queued entry is for another user than the session's
  109. queueRetryTimer = null // a round that did not fully drain tries again after queueRetryWait
  110. // ---- THE PACKAGE'S BROWSER SURFACE: what `import { … } from 'hl:web'` binds ------
  111. //
  112. // A file's STATICS are what a braced import may take (the language's one rule for
  113. // `import { db } from './store.hl'`, and the same rule across a package's url),
  114. // so everything this half offers an app's component is declared here and nowhere
  115. // else. A static belongs to the CLASS, which is why these two reach each other
  116. // and why they are the SAME pair for every component module the page imports —
  117. // the browser loads this file once.
  118. //
  119. // `live` IS THE PAGE'S ONE CLIENT, written by the root below. The statics cannot
  120. // see an instance (they are evaluated at file load, before there is one), and a
  121. // static that is a FUNCTION does not need to: its body runs at the CALL, long
  122. // after the root has put the constructed client here.
  123. static live = { client = null }
  124. // PROGRAMMATIC NAVIGATION — the same act a click on a same-origin `<a>` performs,
  125. // asked for by code instead of by a pointer: a new history entry, then the next
  126. // route's tree and state over the boundary and into the shell's slot. A handler
  127. // that has just been told an id and must go to that thing's page has no link to
  128. // click, and this is what it calls instead. Everything about what a navigation IS
  129. // stays in `goTo` below; this is only the way in from outside the file.
  130. static navigate = (path) => { return live.client.goTo(path) }
  131. // A NOTIFICATION, now or at a set time (COMPONENTS §10, ticket #95). `at` is epoch
  132. // milliseconds. The browser is asked for permission the first time. It is shown by
  133. // the service worker when the app has one (so an installed app shows it as the app),
  134. // else by the page. A notification AT A SET TIME is a timer of this page: it fires
  135. // while the page is open — a browser offers no way to wake a closed app at a time
  136. // without a server push, and this does not pretend otherwise.
  137. static notify = (title, options) => { return live.client.showNotice(title, options) }
  138. static notifyAt = (at, title, options) => { return live.client.noticeAt(at, title, options) }
  139. // …or after `ms` milliseconds
  140. static notifyIn = (ms, title, options) => { return live.client.noticeIn(ms, title, options) }
  141. // FORGET WHAT THIS BROWSER KEPT OFFLINE (ticket #125): the kept state, the queue and the
  142. // worker's kept documents. hl:web does it itself when the session's user changes (a log-out
  143. // included) at the next page it is told about; an app that logs out in place calls this.
  144. static forgetOffline = () => { return live.client.forgetOffline() }
  145. // ---- boot: construct the instances the server rendered, claim their DOM ----------
  146. // THE PAGE'S CLIENT, PUBLISHED TO THE FILE'S OWN CLASS, before anything else the
  147. // root does: `boot()` below constructs the components, and a component's module
  148. // may reach `navigate` from the moment it is loaded.
  149. Client.live.client = this
  150. // A VIEW EXPRESSION in a module's code is evaluated by view.hl's one `evalExpr`, so the
  151. // language's own operators answer in the browser as they do on the server
  152. window.__hlX = (x, inst, rows) => { return view.evalExpr(x, inst, rows) }
  153. seed = JSON.parse(seedText)
  154. router = new HlwRouter(routes = seed.routes)
  155. boot()
  156. boot() {
  157. mounting = true
  158. strict = seed.strict == true
  159. takeBlueprints(seed.blueprints)
  160. if (seed.shell != null) {
  161. shell = instantiate(seed.shell, [], 'shell')
  162. shell.isShell = true
  163. // (the BODY ELEMENT, nothing to do with `rootKind` below — a mount's `rootKind`
  164. // says which of the two roots it hangs from, 'page' or 'shell'. This field held
  165. // the DOM node under the same name until 2026-09-14, and the shell's address
  166. // therefore read as an element: every lift and every fill re-entry from a
  167. // composition IN THE SHELL resolved against the page instead, so a composed
  168. // control there wrote nothing — creator, W15.)
  169. shell.root = document.body
  170. // the body node's children are claimed against document.body, so the body
  171. // element takes the node itself here: a region hosted directly on it (an `if`
  172. // at the shell's root) rebuilds from it (measured 2026-09-13: a lone child
  173. // under `body` stayed on screen after its condition turned false)
  174. claim(&shell, document.body)
  175. }
  176. page = seed.page != null ? instantiate(seed.page, [], 'page') : null
  177. // no shell: the page IS the document's body content
  178. if (shell == null) { slotEl = document.body }
  179. // THE SHELL RENDERED NO SLOT — its region stood false for this request, so the
  180. // document carries no page. Claiming one anyway walked the page's tree against the
  181. // SHELL's own elements and silently attached the page's nodes to them ("claim: no
  182. // element for … under …" as soon as the two shapes differ), and the page never
  183. // appeared when the region later turned true. It waits instead.
  184. if (slotEl != null && page != null) {
  185. slotEl.__hlCur = slotStart
  186. claim(&page, slotEl, slotAt)
  187. pageShown = true
  188. }
  189. // the head members are sites of this mount from here on. They are NOT painted
  190. // now: the server's `document()` already wrote them into this document, and a
  191. // write to one of them repaints it the way every other member's write does.
  192. appHead = seed.head
  193. if (page != null) { headSites(&page) }
  194. release()
  195. listen()
  196. syncOnline()
  197. if (seed.offline != null) { standIn() }
  198. if (seed.offline != null) { offlineBoot() }
  199. connect()
  200. }
  201. // A DOCUMENT THAT STANDS IN FOR THIS URL (#124): the worker answered an unvisited param page
  202. // with a kept document of the same component, so the seed's parameters are another url's.
  203. // This url's are the truth: they go into the instance, and what they feed is derived again
  204. // here (what only the server can derive stays as that document had it, until a push).
  205. standIn() {
  206. if (page == null || seed.page == null) { return null }
  207. let m = router.match(location.pathname)
  208. if (m == null || m.route.component != true) { return null }
  209. let mine = JSON.stringify(m.params)
  210. if (mine == JSON.stringify(seed.page.params)) { return null }
  211. for (k of m.params.keys()) { page.instance[k] = m.params[k] }
  212. seed.page.params = m.params
  213. patch(page.chain, 'page', moved(&page, m.params.keys()), [])
  214. return null
  215. }
  216. // THE NEXT PAGE INTO THE SLOT, and nothing else of the shell's: the slot's element may
  217. // hold the shell's own elements beside the page (`body { header { … } slot }`), which
  218. // emptying it took with the old page. The new page is built where the old one stood.
  219. placePage(&m) {
  220. if (!standing(slotEl, slotStart)) {
  221. // the page is all the slot's element holds: empty it, as it always was
  222. slotEl.replaceChildren()
  223. create(&m, slotEl)
  224. return null
  225. }
  226. for (n of between(slotStart)) { n.remove() }
  227. let before = slotEl.childNodes.length
  228. create(&m, slotEl)
  229. let made = Array.from(slotEl.childNodes).slice(before)
  230. for (n of made) { slotEl.insertBefore(n, slotStart.__hlEnd) }
  231. return null
  232. }
  233. // the frames that waited for the mounts (above), now that they stand
  234. release() {
  235. mounting = false
  236. let frames = held
  237. held = []
  238. for (f of frames) { outward(f) }
  239. return null
  240. }
  241. // a mount's instance: the module LOADED (hlLoad — the loader primitive, which
  242. // on this target is the dynamic import of the url the server compiled the
  243. // component to), the class constructed, then the server's state laid over it.
  244. // The import is the browser's own cache: a second mount of the same component
  245. // fetches nothing.
  246. // The route's params and the server's state are the construction's NAMED
  247. // ARGUMENTS, pinned before the root runs — so a member the server evaluated
  248. // (a seeded one, declared without its initializer in this realm's projection)
  249. // is already there when a later line reads it, and a client-evaluable
  250. // initializer keeps the server's value instead of recomputing it.
  251. // THE BLUEPRINT TABLE, read by component key: the url the component's module is
  252. // served at and its View tree, one copy for the whole page. Every mount's tree is
  253. // read from here — the page's, the shell's, and every child's — and so is every
  254. // child the browser has to build itself.
  255. //
  256. // IT ONLY GROWS. Each seed carries the table for ITS route, and a navigation takes
  257. // the new entries WITHOUT dropping the old: a key is a component and a component is
  258. // one tree, so an entry is never wrong, and constructing a mount awaits its module —
  259. // long enough for a second navigation to have replaced the seed underneath it. A
  260. // table that was replaced left that half-built page reading the wrong route's
  261. // entries ("no blueprint for components/post.hl", on `/`, measured in the reference
  262. // gate 2026-09-13).
  263. takeBlueprints(table) {
  264. for (k of table.keys()) {
  265. if (blueprints[k] == null) { blueprints[k] = table[k] }
  266. }
  267. return null
  268. }
  269. blueprintOf(key) {
  270. return blueprints[key]
  271. }
  272. treeOf(key) {
  273. let bp = blueprintOf(key)
  274. // the table covers every component the page can reach, so this is a torn seed
  275. // and not a missing feature — say so loudly and paint nothing for that mount
  276. if (bp == null) { console.error('framework: the seed carries no blueprint for ' + key)
  277. return [] }
  278. return bp.view
  279. }
  280. // `chain` and `root` are the mount's ADDRESS: the kid keys from the page (or the
  281. // shell) down to it. A mount record is a value, so a site cannot hold the mount that
  282. // owns its fill — it holds this address, and `fillAt` re-enters the mount by
  283. // reference from the root when the fill has to be rebuilt (see settle below).
  284. instantiate(m, chain, root) {
  285. let args = {}
  286. for (k of m.state.keys()) { args[k] = m.state[k] }
  287. for (k of m.params.keys()) { args[k] = m.params[k] }
  288. let inst = hlLoad(m.module, args)
  289. // THE TREE COMES FROM THE TABLE, by this mount's key: a View belongs to the
  290. // COMPONENT, and the seed carries one copy of it however many mounts share it.
  291. let out = { key = m.key; instance = inst; view = treeOf(m.key); sites = []; isShell = false; kids = {}; bindings = []; chain = chain; rootKind = root; }
  292. // THE MIRROR: the components this one composes are instances here too, exactly
  293. // as the server stacked them — one per component node, constructed with the
  294. // bindings the server evaluated; a binding to a host MEMBER is remembered, so a
  295. // host write reaches the child's member and repaints the child's sites
  296. for (k of m.kids.keys()) { out.kids[k] = instantiate(m.kids[k], childChain(chain, k), root) }
  297. bindKids(&out, out.view)
  298. // A BOUND MEMBER'S VALUE IS THE HOST'S, HERE. The seed carried the server's
  299. // value, which is right for data and null for a FUNCTION (JSON has no form for
  300. // one) — and a function the host hands its child (`PostForm { onCancel = cancel
  301. // }`) is the way a child talks upward without knowing its host: it calls what it
  302. // was given, and the function writes the host's members through the instance
  303. // that wrote it. So every member binding is applied from the host instance now.
  304. for (b of out.bindings) {
  305. let kid = out.kids[b.kid]
  306. if (kid != null) {
  307. kid.instance[b.target] = bindingValue(b, &out)
  308. out.kids[b.kid] = kid
  309. }
  310. }
  311. return out
  312. }
  313. bindKids(&m, nodes) {
  314. for (n of nodes) {
  315. if (n.k == 'component') {
  316. // THE EDGE STANDS WHETHER OR NOT THE SERVER MOUNTED THE CHILD: a branch that does
  317. // not stand is not mounted, and the browser builds the child when it comes to
  318. // stand — which must then follow the host's member (every reader of `m.bindings`
  319. // passes over an edge whose child is not there)
  320. {
  321. // A BINDING IS A READ at the reference site: a member (`count = likes`)
  322. // or a field path off one (`postId = row.id`). Both follow the host's
  323. // write — the field path under the name it starts at.
  324. for (b of n.bindings) {
  325. // A MEMBER THAT ONLY FEEDS A BINDING still follows a write: `repaint`
  326. // walks the bindings after the sites, so a host member no element of the
  327. // host shows still reaches the child that was bound to it (measured
  328. // 2026-09-13 on the social app: `FollowButton { following =
  329. // authorFollowed }` stayed false). Under the digest that needed the
  330. // member to be in the compared map; under the write sets it needs
  331. // nothing but the edge itself.
  332. if (b.member != null) { m.bindings.push({ name = b.member; kid = view.kidKey(n.path); target = b.name; ref = null; }) }
  333. else if (b.ref != null) { m.bindings.push({ name = b.ref.name; kid = view.kidKey(n.path); target = b.name; ref = b.ref; }) }
  334. }
  335. }
  336. // the fill is THIS mount's fragment: a reference inside it binds against this instance
  337. if (n.fill != null) { bindKids(&m, n.fill) }
  338. } else if (n.k == 'el') {
  339. bindKids(&m, n.children)
  340. } else if (n.k == 'if') {
  341. bindKids(&m, n.then)
  342. bindKids(&m, n.other)
  343. }
  344. }
  345. return null
  346. }
  347. // a body-rooted View's children are the body's children
  348. childrenOf(nodes) {
  349. if (nodes.length == 1 && nodes[0].k == 'el' && nodes[0].tag == 'body') { return nodes[0].children }
  350. return nodes
  351. }
  352. // ---- WHAT THE COMPILE STEP ANSWERED, asked by name ---------------------------------
  353. // A local event's write set stands at its own listener and a region's names at its own
  354. // region: the compile step wrote them into the statements. These three are asked for by
  355. // NAME at run time and cannot be — an inbound frame names its event, a routine a host
  356. // handed down names the member that holds it, and a write set names the derivations
  357. // that follow it — so the component's compiled half carries them beside its walks.
  358. // Nothing here reduces a table: the answers are already the answers.
  359. eventWrites(key, event) {
  360. let comp = compiledOf(key)
  361. if (comp == null) { return [] }
  362. let names = comp.ev[event]
  363. return names == null ? [] : names
  364. }
  365. memberWrites(key, name) {
  366. let comp = compiledOf(key)
  367. if (comp == null) { return [] }
  368. let names = comp.mw[name]
  369. return names == null ? [] : names
  370. }
  371. derivationsOf(key) {
  372. let comp = compiledOf(key)
  373. if (comp == null) { return [] }
  374. return comp.dv
  375. }
  376. // ---- THE COMPILED COMPONENT: this framework's own output for this file ------------
  377. // The old hl:web walked a tree the seed carries — it pairs an element with a node, asks a table
  378. // what the element shows, and asks `view.value` what each bound spot holds. hl:web
  379. // ships the answers as the component's own code (plugins/web/compile.hl), and the
  380. // functions below are the FIXED runtime that code calls: the mount records, the keyed
  381. // region diff, the binding edges, the derivations, the socket and navigation stay here,
  382. // where they belong to the framework and not to any one component.
  383. //
  384. // EVERY CALL INTO GENERATED CODE PASSES BY REFERENCE. A plain call copies its
  385. // arguments (the language's value semantics), which for an instance holding a 2000-row
  386. // list would copy the list on every paint; `&` at the call site hands the value itself,
  387. // the way every walk in this file already hands a mount record.
  388. compiledOf(key) {
  389. let all = window.__hlC
  390. if (all == null) { return null }
  391. return all[key]
  392. }
  393. // the walk of a component's whole View, of one `for` body, of an `if` branch, or of
  394. // the fragment a reference fills — each under its own site
  395. walkOf(key, slot, site) {
  396. let comp = compiledOf(key)
  397. if (comp == null) { return null }
  398. if (slot == 'v') { return comp.v }
  399. if (slot == 'b') { return comp.b[site] }
  400. if (slot == 't') { return comp.t[site] }
  401. if (slot == 'e') { return comp.e[site] }
  402. if (slot == 'f') { return comp.f[site] }
  403. return null
  404. }
  405. // ONE ENTRY INTO GENERATED CODE. `f` is the walk, `m` the mount it runs for, `o` the
  406. // mount whose fragment a `slot` inside it would place.
  407. run(f, &m, &o, host, i, rows, rk, fill, cr, opt) {
  408. if (f == null) { return i }
  409. let c = this
  410. return f(&c, &m, &o, &host, i, &rows, rk, &fill, cr, &opt)
  411. }
  412. acqFail(tag, host) {
  413. console.error('claim: no element for', tag, 'under', host == null ? null : host.tagName)
  414. return null
  415. }
  416. // the classes a reference's `#Child` rule put on the composition root, beside whatever
  417. // class the root itself carries (view.hl withRootClasses, on the build walk)
  418. rootClasses(el, opt) {
  419. if (opt == null || opt.classes == null || opt.classes.length == 0) { return null }
  420. let add = ''
  421. for (c of opt.classes) { add = add == '' ? c : add + ' ' + c }
  422. let have = el.getAttribute('class')
  423. el.setAttribute('class', have == null || have == '' ? add : have + ' ' + add)
  424. return null
  425. }
  426. // AN ELEMENT THAT SHOWS SOMETHING is a site of this mount. The names come from the
  427. // generated call — the compile step read them off the View — and the two-way `value`
  428. // bind is applied where the element is a form control the member paints.
  429. siteAt(&m, el, names, site, valueMember, isSelect, rows) {
  430. let keep = []
  431. for (name of names) {
  432. // the SHELL's `slot` is where the route component hangs: swapped by navigation,
  433. // never painted (the compile step already drops `Style`, folded at build)
  434. if (!(name == 'slot' && m.isShell)) { keep.push(name) }
  435. }
  436. if (valueMember != null && rows[valueMember] == null && el.value != null) {
  437. bindValue(&m, valueMember, el, isSelect)
  438. }
  439. if (keep.length > 0) { m.sites.push({ sid = nextSid(); el = el; site = site; names = keep; region = false; rows = rows; }) }
  440. return null
  441. }
  442. applySelectAt(&m, el, v) {
  443. el.value = v == null ? '' : '' + v
  444. return null
  445. }
  446. // A HANDLER, WIRED AT CREATION. The literal is the element's own — its named entries
  447. // are the generated `__hlLit`, the `for` rows in scope ride on it, and the owner is the
  448. // instance BY REFERENCE (a handler writing a copy would repaint nothing).
  449. onAt(&m, el, event, site, rows, wrote, touched) {
  450. if (el.__hlLits == null) { el.__hlLits = {} }
  451. el.__hlLits[event] = literalFor(&m, el)
  452. let at = m.chain
  453. let root = m.rootKind
  454. let rowNames = rows == null ? [] : rows.keys()
  455. el.addEventListener(event, (ev) => {
  456. // THE DEFAULT ACTION OF `submit` IS A PAGE NAVIGATION (COMPONENTS.md §6b), so the
  457. // framework suppresses it itself — a handler declared with no parameter has no
  458. // `ev` to call `preventDefault()` on, and one declared with it may call it again,
  459. // harmlessly. This is the FIRST statement, unawaited, for the same reason `onClick`
  460. // claims a link before anything else: called after an `await`, it lands a
  461. // microtask late, once the browser already navigated (mission 132 / ticket submit).
  462. if (event == 'submit') { ev.preventDefault() }
  463. let lit = el.__hlLits[event]
  464. for (rk of rowNames) { lit[rk] = el.__hlRows[rk] }
  465. fire(&lit, event, ev)
  466. el.__hlLits[event] = lit
  467. patch(at, root, wrote, touched)
  468. })
  469. return null
  470. }
  471. // THE SHELL'S SLOT: where the route component hangs. On the build walk the re-created
  472. // element adopts the page's nodes, and a page the shell never rendered is built here.
  473. slotShell(&m, host, i, cr) {
  474. slotEl = host
  475. if (!cr) {
  476. // claiming: the page's elements stand here, and the shell's own elements after
  477. // the slot come after them
  478. slotAt = i
  479. if (seed.page == null) { return i }
  480. // the page's region: its marks, and the elements between them the shell steps over
  481. slotStart = markAt(host, false, true)
  482. slotStart.__hlEnd = closeOf(slotStart)
  483. host.__hlCur = slotStart.__hlEnd
  484. let span = 0
  485. for (n of between(slotStart)) { if (n.nodeType == 1) { span = span + 1 } }
  486. return i + span
  487. }
  488. slotAt = host.children.length
  489. let start = markAt(host, true, true)
  490. if (detachedSlot != null && detachedSlot != host) {
  491. // the page's own nodes: what stood between its marks, or all of the old host's
  492. let moving = standing(detachedSlot, slotStart) ? between(slotStart) : Array.from(detachedSlot.childNodes)
  493. for (node of moving) { host.appendChild(node) }
  494. }
  495. detachedSlot = null
  496. if (!pageShown) {
  497. // THE PAGE ARRIVES WITH ITS SLOT: it was never constructed while the shell
  498. // placed none, so it is asked for now — the route's state, from the server
  499. if (page == null) { page = arrive() }
  500. if (page != null) {
  501. page.sites = []
  502. create(&page, host)
  503. headSites(&page)
  504. wearHead(&page)
  505. pageShown = true
  506. }
  507. }
  508. start.__hlEnd = markAt(host, true, false)
  509. slotStart = start
  510. return i
  511. }
  512. // THE ROUTE'S PAGE, for a slot that just opened: the navigation face answers it for
  513. // this path, told the slot stands here, and the mount is constructed from that. The
  514. // frames it raises wait until the rebuild that placed it is done (pageArrived).
  515. arrive() {
  516. let next = emit server page(location.pathname + location.search, true)
  517. if (next == null || next.page == null) { return null }
  518. takeBlueprints(next.blueprints)
  519. seed.page = next.page
  520. mounting = true
  521. pageArrived = true
  522. return instantiate(next.page, [], 'page')
  523. }
  524. // THE SLOT CLOSED: the page is dropped, not kept aside — the next time the slot opens
  525. // it is constructed again. The head falls back to the app's own.
  526. dropPage() {
  527. slotEl = null
  528. pageShown = false
  529. page = null
  530. slotStart = null
  531. seed.page = null
  532. let none = { instance = {}; }
  533. wearHead(&none)
  534. announceMounts()
  535. return null
  536. }
  537. // a rebuild that constructed the page: what waited for it goes out, and the server
  538. // learns the tab mounts it
  539. arrived() {
  540. if (!pageArrived) { return null }
  541. pageArrived = false
  542. release()
  543. announceMounts()
  544. return null
  545. }
  546. // A CHILD'S SLOT: the fragment its HOST wrote, built in the host's frame. The fill names
  547. // that frame by address, and `run` re-enters it when this walk is not it.
  548. slotFill(&m, &o, host, i, fill, cr) {
  549. if (fill == null) { return i }
  550. if (cr && fill.detached) {
  551. let anchor = document.createComment('hl:slot')
  552. host.appendChild(anchor)
  553. pendingFills[] = { key = fill.key; site = fill.site; rows = fill.rows; rowKey = fill.rowKey; outer = fill.outer; chain = fill.chain; rootKind = fill.rootKind; anchor = anchor; }
  554. return i
  555. }
  556. if (ownsFill(o, fill)) {
  557. let f = walkOf(fill.key, 'f', fill.site)
  558. return run(f, &o, &o, host, i, fill.rows, fill.rowKey, fill.outer, cr, null)
  559. }
  560. return fillFromRoot(fill, host, i, cr)
  561. }
  562. // the fill's owner, re-entered from the page or the shell by the address it carries
  563. fillFromRoot(fill, host, at, cr) {
  564. if (fill.rootKind == 'shell') {
  565. if (shell == null) { return at }
  566. return fillInto(&shell, fill, 0, host, at, cr)
  567. }
  568. if (page == null) { return at }
  569. return fillInto(&page, fill, 0, host, at, cr)
  570. }
  571. fillInto(&m, fill, depth, host, at, cr) {
  572. if (depth >= fill.chain.length) {
  573. let f = walkOf(fill.key, 'f', fill.site)
  574. return run(f, &m, &m, host, at, fill.rows, fill.rowKey, fill.outer, cr, null)
  575. }
  576. let kid = m.kids[fill.chain[depth]]
  577. if (kid == null) { return at }
  578. let out = fillInto(&kid, fill, depth + 1, host, at, cr)
  579. m.kids[fill.chain[depth]] = kid
  580. return out
  581. }
  582. // the fill context a reference hands its child: the fragment's owner by address, the
  583. // host's rows and rowKey, and the fill the host itself was standing in
  584. fillFor(&m, spec, rows, rowKey, fill) {
  585. if (!spec.fill) { return null }
  586. return { key = m.key; site = spec.site; rows = rows; rowKey = rowKey; outer = fill; chain = m.chain; rootKind = m.rootKind; detached = false; }
  587. }
  588. // ---- THE MARKS: every region stands between two comments ---------------------------
  589. // THE INVARIANT (tickets #103, #104). Every region — an `if`, a `for`, each row of a
  590. // `for`, the shell's page — stands on its host between an OPEN mark (a comment `[`)
  591. // and a CLOSE mark (`]`), and ITS NODES ARE EXACTLY THE NODES BETWEEN THEM. A region
  592. // inside another one on the same host stands between the outer one's marks. So:
  593. // - a region's nodes are never remembered, they are read off the DOM between its
  594. // marks when they are needed — whatever a region inside it did since, they are
  595. // right (a list taken when the branch was built went stale, #103);
  596. // - a region is replaced by removing what stands between its marks and putting the
  597. // new nodes before its close mark; a row moves and leaves WITH its marks, so a row
  598. // is always whole and in its list;
  599. // - nothing outside a region is touched by it, and nothing inside it outlives it: the
  600. // sites registered in the nodes it drops are dropped with them, and a region whose
  601. // marks have left its host has nothing to paint.
  602. // The server writes the same marks (compile.hl `markOpen`), the claim walk takes them in
  603. // document order and the build walk makes them, so a region looks the same however it
  604. // came to stand — and text beside a region never runs into the region's own text.
  605. mark(open) { return document.createComment(open ? '[' : ']') }
  606. isMark(n) { return n != null && n.nodeType == 8 && (n.data == '[' || n.data == ']') }
  607. // the nodes strictly between a region's marks
  608. between(start) {
  609. let out = []
  610. let end = start.__hlEnd
  611. let n = start.nextSibling
  612. while (n != null && n != end) { out.push(n) n = n.nextSibling }
  613. return out
  614. }
  615. // …and with the marks themselves: a row as it moves or leaves
  616. spanOf(start) {
  617. let out = [start]
  618. for (n of between(start)) { out.push(n) }
  619. out.push(start.__hlEnd)
  620. return out
  621. }
  622. // a region stands while both its marks are on its host
  623. standing(host, start) {
  624. return start != null && start.parentNode == host && start.__hlEnd != null && start.__hlEnd.parentNode == host
  625. }
  626. // A REGION'S MARK, made (building) or taken (claiming). The claim takes the next mark
  627. // after the host's cursor: the walk meets the regions in document order, so it is this
  628. // region's. A page whose marks do not match its tree is said so, and gets a mark where
  629. // the walk stands.
  630. markAt(host, cr, open) {
  631. if (cr) {
  632. let made = mark(open)
  633. host.appendChild(made)
  634. return made
  635. }
  636. let n = host.__hlCur == null ? host.firstChild : host.__hlCur.nextSibling
  637. while (n != null && !isMark(n)) { n = n.nextSibling }
  638. let mk = null
  639. if (n != null && n.data == (open ? '[' : ']')) { mk = n }
  640. else {
  641. console.error('claim: no region mark under', host.tagName)
  642. mk = mark(open)
  643. host.insertBefore(mk, host.__hlCur == null ? host.firstChild : host.__hlCur.nextSibling)
  644. }
  645. host.__hlCur = mk
  646. return mk
  647. }
  648. // the close mark that pairs with an open one, past the regions inside it
  649. closeOf(start) {
  650. let depth = 0
  651. let n = start.nextSibling
  652. while (n != null) {
  653. if (isMark(n)) {
  654. if (n.data == '[') { depth = depth + 1 }
  655. else if (depth == 0) { return n }
  656. else { depth = depth - 1 }
  657. }
  658. n = n.nextSibling
  659. }
  660. let made = mark(false)
  661. start.parentNode.appendChild(made)
  662. return made
  663. }
  664. // THE SITES IN NODES THAT LEAVE go with them: an element's, a text leaf's, and a
  665. // region's whose open mark is among them. Marked and walked up once per site.
  666. //
  667. // NO CALL BETWEEN READING THE LIST AND WRITING IT BACK. Every call in this language is
  668. // awaited, and an await lets another repaint run — one that registers sites of its
  669. // own, which a list read before it and written after it would lose. The loops that
  670. // read `m.sites` and write it back hold property reads, index writes and `[] =` only.
  671. dropSitesIn(&m, host, nodes) {
  672. if (nodes.length == 0) { return null }
  673. for (n of nodes) { n.__hlGone = true }
  674. let kept = []
  675. let dropped = 0
  676. for (site of m.sites) {
  677. let n = (site.region && site.el == host && site.start != null) ? site.start : site.el
  678. let gone = false
  679. while (n != null && n != host && !gone) {
  680. if (n.__hlGone == true) { gone = true }
  681. n = n.parentNode
  682. }
  683. if (gone) { dropped = dropped + 1 } else { kept[] = site }
  684. }
  685. if (dropped > 0) {
  686. m.sites = kept
  687. dropGen = dropGen + 1
  688. }
  689. for (n of nodes) { n.__hlGone = null }
  690. return null
  691. }
  692. // THE SITES A WALK REGISTERED ON THE FRAGMENT IT BUILT INTO belong to the host it lands
  693. // in — this mount's, and those of the children composed straight onto the fragment,
  694. // whose own regions (a child's View may begin with an `if`) stand on the same host.
  695. // Those children are named by address on the fragment as the walk composes them
  696. // (kidAt, `__hlOnFrag`), so only they are re-entered.
  697. rehome(&m, src, dst, before) {
  698. sitesOnto(&m, src, dst, before)
  699. let due = src.__hlOnFrag == null ? [] : src.__hlOnFrag
  700. src.__hlOnFrag = null
  701. let base = m.chain == null ? 0 : m.chain.length
  702. for (chain of due) {
  703. if (chain.length > base) { rehomeAt(&m, chain, base, src, dst) }
  704. }
  705. return null
  706. }
  707. // (`after`: the sites numbered after it are the walk's — an index would move when
  708. // another repaint drops sites in the meantime)
  709. sitesOnto(&m, src, dst, after) {
  710. let k = 0
  711. while (k < m.sites.length) {
  712. let e = m.sites[k]
  713. if (e.el == src && e.sid > after) {
  714. e.el = dst
  715. m.sites[k] = e
  716. }
  717. k = k + 1
  718. }
  719. return null
  720. }
  721. rehomeAt(&m, chain, depth, src, dst) {
  722. if (depth >= chain.length) { sitesOnto(&m, src, dst, 0) return null }
  723. let kid = m.kids[chain[depth]]
  724. if (kid == null) { return null }
  725. rehomeAt(&kid, chain, depth + 1, src, dst)
  726. m.kids[chain[depth]] = kid
  727. return null
  728. }
  729. // A RANGE LEAVES THE PAGE — a branch, a row: every site registered in it goes (at any
  730. // depth: an element's, a text's, a region's whose marks are in it), and so does every
  731. // child composed in it (its nodes carry its address), with its own sites, its children
  732. // and its handlers.
  733. // A range can hold a FILL — a host's fragment placed at this component's `slot` — whose
  734. // sites and children are the host's: `fill` names that host (and the one it was filled
  735. // by in turn), and those go in settle(), where a host is re-entered by its address after
  736. // every mount on this walk has been written back.
  737. dropRange(&m, host, nodes, fill) {
  738. dropSitesIn(&m, host, nodes)
  739. let others = disposeIn(&m, nodes)
  740. let f = fill
  741. while (f != null) {
  742. pendingDrops.push({ chain = f.chain; rootKind = f.rootKind; host = host; nodes = nodes; kids = others; })
  743. others = []
  744. f = f.outer
  745. }
  746. return null
  747. }
  748. // THE CHILDREN COMPOSED IN NODES go: those of this mount at once, and the others (a
  749. // host's, composed in its fill) are answered for the caller to hand on
  750. disposeIn(&m, nodes) {
  751. let chains = []
  752. for (n of nodes) { kidsFiledIn(n, &chains) }
  753. let base = m.chain == null ? [] : m.chain
  754. let others = []
  755. for (c of chains) {
  756. // (a range inside a child never takes the child itself, nor a mount above it: the
  757. // child's own nodes carry its address, a region of its own among them)
  758. let above = c.length <= base.length && JSON.stringify(base.slice(0, c.length)) == JSON.stringify(c)
  759. if (!above) {
  760. if (c.length > base.length && JSON.stringify(c.slice(0, base.length)) == JSON.stringify(base)) { disposeAt(&m, c, base.length) }
  761. else { others.push(c) }
  762. }
  763. }
  764. return others
  765. }
  766. // the ranges a fill's host still has to let go of (see dropRange)
  767. dropPending() {
  768. if (pendingDrops.length == 0) { return null }
  769. let due = pendingDrops.slice(0)
  770. pendingDrops = []
  771. for (d of due) {
  772. if (d.rootKind == 'shell') {
  773. if (shell != null) { dropAt(&shell, d, 0) }
  774. } else if (page != null) { dropAt(&page, d, 0) }
  775. }
  776. return null
  777. }
  778. dropAt(&m, d, depth) {
  779. if (depth >= d.chain.length) {
  780. dropSitesIn(&m, d.host, d.nodes)
  781. for (c of d.kids) { if (c.length > depth) { disposeAt(&m, c, depth) } }
  782. return null
  783. }
  784. let kid = m.kids[d.chain[depth]]
  785. if (kid == null) { return null }
  786. dropAt(&kid, d, depth + 1)
  787. m.kids[d.chain[depth]] = kid
  788. return null
  789. }
  790. // a node in a range whose host's sites are still to go (dropPending)
  791. dropDue(n) {
  792. let top = n.getRootNode()
  793. for (d of pendingDrops) { if (d.nodes.includes(top)) { return true } }
  794. return false
  795. }
  796. // the children whose nodes are this node or stand under it (kidAt files them there)
  797. kidsFiledIn(n, &out) {
  798. if (n.__hlKidChains != null) { for (c of n.__hlKidChains) { out.push(c) } }
  799. if (n.nodeType != 1) { return null }
  800. let w = document.createTreeWalker(n, 4294967295)
  801. let c = w.nextNode()
  802. while (c != null) {
  803. if (c.__hlKidChains != null) { for (k of c.__hlKidChains) { out.push(k) } }
  804. c = w.nextNode()
  805. }
  806. return null
  807. }
  808. disposeAt(&m, chain, depth) {
  809. if (depth == chain.length - 1) {
  810. if (m.kids[chain[depth]] != null) { delete m.kids[chain[depth]] }
  811. return null
  812. }
  813. let kid = m.kids[chain[depth]]
  814. if (kid == null) { return null }
  815. disposeAt(&kid, chain, depth + 1)
  816. m.kids[chain[depth]] = kid
  817. return null
  818. }
  819. // a node no longer on the page: not in the document, not in a fragment a walk is
  820. // still building, not in the slot a shell's rebuild holds aside
  821. placedOff(n) {
  822. if (n.isConnected) { return false }
  823. if (n.getRootNode().nodeType == 11) { return false }
  824. if (detachedSlot != null && detachedSlot.contains(n)) { return false }
  825. return true
  826. }
  827. nextSid() {
  828. sidNext = sidNext + 1
  829. return sidNext
  830. }
  831. isLive(&m, s) {
  832. for (e of m.sites) { if (e.sid == s.sid) { return true } }
  833. return false
  834. }
  835. liveSids(&m) {
  836. let out = {}
  837. for (e of m.sites) { out[e.sid] = true }
  838. return out
  839. }
  840. // A SITE WHOSE NODES LEFT THE PAGE, found by a repaint. Every range that leaves takes
  841. // its sites with it (dropRange), so this is a bug: the gates' client raises it as an
  842. // uncaught exception (the page's error event, what every gate fails on), a page in the
  843. // field says so on the console; neither paints it.
  844. staleSite(s) {
  845. let msg = 'hl:web: a repaint found a site whose nodes have left the page (site ' + s.site + ')'
  846. if (strict) { window.reportError(window.Reflect.construct(window.Error, [msg])) }
  847. else { console.error(msg) }
  848. return null
  849. }
  850. // A RUN OF TEXT LEAVES BESIDE ELEMENTS OR REGIONS (compile.hl `mixed`): one text node,
  851. // kept on the element under the run's index so a paint rewrites it and nothing else.
  852. // `k` null is a text the walk only has to step over (literal, or painted as a site of
  853. // its own by `textAt`). Built, it is appended. Claimed, it is the text node right
  854. // after the host's cursor — the last node the walk took there — which the server may
  855. // have run together with the readable form's line break after it, so it is split off
  856. // by the value it has now. A run that rendered empty has no node and gets one.
  857. leafAt(host, cr, i, k, text) {
  858. let t = leafNode(host, cr, text)
  859. if (k != null) {
  860. if (host.__hlLeaves == null) { host.__hlLeaves = {} }
  861. host.__hlLeaves[k] = t
  862. }
  863. return null
  864. }
  865. leafNode(host, cr, text) {
  866. let t = null
  867. if (cr) {
  868. t = document.createTextNode(text)
  869. host.appendChild(t)
  870. return t
  871. }
  872. let at = host.__hlCur == null ? host.firstChild : host.__hlCur.nextSibling
  873. if (at != null && at.nodeType == 3 && text != '') {
  874. if (at.data == text) { t = at }
  875. else if (at.data.startsWith(text)) { at.splitText(text.length) t = at }
  876. else {
  877. // a line break of the readable form in front of it (a filled slot's line)
  878. let lead = at.data.length - at.data.trimStart().length
  879. if (lead > 0 && at.data.startsWith(View.newline) && at.data.slice(lead).startsWith(text)) {
  880. t = at.splitText(lead)
  881. if (t.data != text) { t.splitText(text.length) }
  882. }
  883. }
  884. }
  885. if (t == null) {
  886. t = document.createTextNode(text)
  887. host.insertBefore(t, at)
  888. }
  889. host.__hlCur = t
  890. return t
  891. }
  892. // A TEXT LEAF STANDING IN A LIST (an `if` branch, a `for` body): its own text node, and
  893. // a site of the member it reads — or of the row it stands in — painted by rewriting it.
  894. textAt(&m, host, cr, readFn, names, rows, rk) {
  895. let inst = m.instance
  896. let rws = rows
  897. let t = leafNode(host, cr, readFn(&inst, &rws))
  898. if (names.length > 0 || (rk != null && rk != '')) {
  899. m.sites.push({ sid = nextSid(); el = t; site = null; names = names; region = false; text = true; rows = rows; rowKey = rk; read = readFn; })
  900. }
  901. return null
  902. }
  903. paintText(&m, s) {
  904. let inst = m.instance
  905. let rws = s.rows
  906. let f = s.read
  907. let v = f(&inst, &rws)
  908. let t = s.el
  909. if (t.data != v) { t.data = v }
  910. return null
  911. }
  912. // ---- a `for`: the region, its rows, each between its marks -------------------------
  913. forAt(&m, &o, host, i, site, rowName, listFn, rows, rk, fill, cr, names) {
  914. let start = markAt(host, cr, true)
  915. regionAt(&m, host, site, rows, rk, fill, true, rowName, listFn, names, start)
  916. let box = { keys = []; rows = {}; }
  917. let inst = m.instance
  918. let rws = rows
  919. let entries = listFn(&inst, &rws)
  920. let body = walkOf(m.key, 'b', site)
  921. if (entries != null) {
  922. let keys = listKeys(entries)
  923. let ri = 0
  924. for (entry of entries) {
  925. let inner = rows + {}
  926. inner[rowName] = entry
  927. let rs = markAt(host, cr, true)
  928. let out = run(body, &m, &o, host, cr ? 0 : i, inner, rk + '#' + keys[ri], fill, cr, null)
  929. if (!cr) { i = out }
  930. rs.__hlEnd = markAt(host, cr, false)
  931. box.keys.push(keys[ri])
  932. box.rows[keys[ri]] = { start = rs; entry = entry; }
  933. ri = ri + 1
  934. }
  935. }
  936. start.__hlEnd = markAt(host, cr, false)
  937. start.__hlBox = box
  938. return i
  939. }
  940. // an `if`: the branch that stands, between its marks
  941. ifAt(&m, &o, host, i, site, condFn, rows, rk, fill, cr, names) {
  942. let start = markAt(host, cr, true)
  943. regionAt(&m, host, site, rows, rk, fill, false, null, condFn, names, start)
  944. let inst = m.instance
  945. let rws = rows
  946. let branch = condFn(&inst, &rws) ? walkOf(m.key, 't', site) : walkOf(m.key, 'e', site)
  947. let out = run(branch, &m, &o, host, i, rows, rk, fill, cr, null)
  948. start.__hlEnd = markAt(host, cr, false)
  949. return out
  950. }
  951. // A REGION SITE carries what the patch needs and nothing else: the names its list or
  952. // its condition reads (the compiler's answer, by this site), the read itself as the
  953. // generated closure, and its open mark — the region itself, one per row it stands in.
  954. regionAt(&m, host, site, rows, rowKey, fill, isFor, rowName, readFn, names, start) {
  955. // a row-scoped read is its row's, and the language resolved it as one, so the
  956. // compile step names none here: an empty set is a region no member can move. INSIDE
  957. // A ROW it is still registered, with no names: the row's new record moves it
  958. // (reseatRows, ticket #86). Outside one nothing ever can.
  959. if (names == null) { names = [] }
  960. if (names.length == 0 && (rowKey == null || rowKey == '')) { return null }
  961. let held = null
  962. if (fill != null) { held = fill + {} held.detached = true }
  963. m.sites.push({ sid = nextSid(); el = host; site = site; names = names; region = true; rows = rows; rowKey = rowKey; fill = held; isFor = isFor; row = rowName; read = readFn; start = start; })
  964. return null
  965. }
  966. // ---- a composed child --------------------------------------------------------------
  967. // The reference's own description is written at its use site by the compile step: what
  968. // it binds, what it overwrites, the classes it puts on the child's roots.
  969. kidAt(&m, host, i, spec, rows, rk, fill, cr, opt) {
  970. let kk = spec.kid + rk
  971. let kid = m.kids[kk]
  972. if (kid == null) { kid = mintFrom(&m, spec, rows, childChain(m.chain, kk)) }
  973. if (kid == null) { return i }
  974. bindFrom(&kid, &m, spec, rows)
  975. if (cr) { kid.sites = [] }
  976. kid.chain = childChain(m.chain, kk)
  977. kid.rootKind = m.rootKind
  978. let firstAt = cr ? host.children.length : i
  979. // WHAT A REFERENCE OVERWRITES REACHES THE FIRST ELEMENT, however deep. A child
  980. // whose View is a bare reference to another component renders no element of its
  981. // own, so the events the host overwrote travel on through it — the old hl:web kept that in
  982. // a field that the first element consumes; here it rides on the option the walk is
  983. // handed, and a reference with ons of its own replaces it.
  984. let over = []
  985. if (spec.ons != null && spec.ons.length > 0) { for (o of spec.ons) { over.push(o.event) } }
  986. else if (opt != null && opt.over != null) { over = opt.over }
  987. let kidOpt = { over = over; classes = spec.classes; }
  988. let f = walkOf(kid.key, 'v', null)
  989. // a child composed straight onto a fragment has its regions on the host the fragment
  990. // lands in: the fragment names it, for the re-homing (rehome)
  991. if (cr && host.nodeType == 11) {
  992. let onFrag = host.__hlOnFrag == null ? [] : host.__hlOnFrag
  993. onFrag.push(kid.chain)
  994. host.__hlOnFrag = onFrag
  995. }
  996. let nodesBefore = host.childNodes.length
  997. let curBefore = host.__hlCur
  998. let out = run(f, &kid, &m, host, cr ? 0 : i, {}, '', fillFor(&m, spec, rows, rk, fill), cr, kidOpt)
  999. if (!cr) { i = out }
  1000. // THE NODES THE CHILD PUT ON ITS HOST CARRY ITS ADDRESS: when a range holding them
  1001. // leaves the page, the child goes with it (dropRange)
  1002. let made = []
  1003. if (cr) {
  1004. let k = nodesBefore
  1005. while (k < host.childNodes.length) { made.push(host.childNodes[k]) k = k + 1 }
  1006. } else {
  1007. let n = curBefore == null ? host.firstChild : curBefore.nextSibling
  1008. let stop = host.__hlCur == null ? null : host.__hlCur.nextSibling
  1009. while (n != null && n != stop) { made.push(n) n = n.nextSibling }
  1010. }
  1011. for (n of made) {
  1012. let filed = n.__hlKidChains == null ? [] : n.__hlKidChains
  1013. filed.push(kid.chain)
  1014. n.__hlKidChains = filed
  1015. }
  1016. if (cr || kid.bindings.length == 0) { kid.bindings = [] bindKids(&kid, kid.view) }
  1017. let roots = rootsBetween(host, firstAt, cr ? host.children.length : i)
  1018. refOnsFrom(&m, spec, roots, rows)
  1019. for (el of roots) { el.__hlKid = true }
  1020. m.kids[kk] = kid
  1021. return i
  1022. }
  1023. // the value a binding of this reference carries, off the host instance
  1024. specValue(b, &m, rows) {
  1025. if (b.text != null) { return b.value }
  1026. if (b.member != null) { return view.value({ k = 'member'; name = b.member; }, m.instance, rows) }
  1027. if (b.ref != null) {
  1028. let inst = m.instance
  1029. let rws = rows
  1030. let f = b.ref
  1031. return f(&inst, &rws)
  1032. }
  1033. return null
  1034. }
  1035. // A CHILD A REGION BUILDS is born with its address: the host's chain and its own key.
  1036. // Every patch, and every late patch a function or a waiting handler of it asks for, is
  1037. // filed and cancelled by that address (tickets #98, #99).
  1038. mintFrom(&m, spec, rows, chain) {
  1039. let bp = blueprintOf(spec.key)
  1040. if (bp == null) { return null }
  1041. let args = {}
  1042. for (b of spec.bindings) { args[b.name] = specValue(b, &m, rows) }
  1043. let inst = hlLoad(bp.module, args)
  1044. return { key = spec.key; instance = inst; view = bp.view; sites = []; isShell = false; kids = {}; bindings = []; chain = chain; rootKind = m.rootKind; }
  1045. }
  1046. bindFrom(&kid, &m, spec, rows) {
  1047. for (b of spec.bindings) { kid.instance[b.name] = specValue(b, &m, rows) }
  1048. return null
  1049. }
  1050. // A HANDLER WRITTEN ON A REFERENCE is the HOST's: its literal is minted here with the
  1051. // reference's bindings as its own entries, and its write set is filed under the
  1052. // reference's own `on` site in the host's table.
  1053. refOnsFrom(&m, spec, els, rows) {
  1054. if (spec.ons == null || spec.ons.length == 0) { return null }
  1055. let named = {}
  1056. for (b of spec.bindings) { named[b.name] = specValue(b, &m, rows) }
  1057. for (k of rows.keys()) { named[k] = rows[k] }
  1058. let inst = m.instance
  1059. let lit = hlLiteralNew(spec.site, named, &inst)
  1060. for (el of els) {
  1061. for (o of spec.ons) { listenRef(&m, &lit, el, o) }
  1062. }
  1063. return null
  1064. }
  1065. // ---- claim: walk the tree in lockstep with the DOM the same tree produced --------
  1066. // Round one granularity: an element whose children include a member is a SITE for
  1067. // each of those members; a repaint rewrites that element's children from the tree.
  1068. // `i` is the running index into `host.children`; a `for` consumes one run of
  1069. // elements per entry, an `if` the run of the branch that stands.
  1070. claim(&m, host, Number at = 0) {
  1071. let f = walkOf(m.key, 'v', null)
  1072. return run(f, &m, &m, host, at, {}, '', null, false, null)
  1073. }
  1074. // a kid's address: its host's, plus the key the host holds it under
  1075. childChain(chain, kk) {
  1076. let out = chain == null ? [] : chain.slice(0)
  1077. out.push(kk)
  1078. return out
  1079. }
  1080. // the keys a list has, in order: the record's id (view.rowKey), an index where the row
  1081. // is no record, and a suffix where one id stands twice. THE SERVER DOES NOT DEDUPE —
  1082. // its `mountKids` gives two rows of one id one mount key; a list with repeated ids is
  1083. // written down here rather than answered twice.
  1084. listKeys(entries) {
  1085. let out = []
  1086. let seen = {}
  1087. let ri = 0
  1088. for (entry of entries) {
  1089. let k = view.rowKey(entry, ri)
  1090. if (seen[k] != null) { k = k + ':' + ri }
  1091. seen[k] = true
  1092. out.push(k)
  1093. ri = ri + 1
  1094. }
  1095. return out
  1096. }
  1097. // ---- the patch: old keys against new ---------------------------------------------
  1098. // A write to the list is answered by a DIFF and nothing else: the rows that are gone
  1099. // are removed, the rows that arrived are built, the rows that stayed are MOVED where
  1100. // the order changed and are left alone where it did not — and each of them is handed
  1101. // the record it now stands for, which rewrites only the attributes and the text the
  1102. // DOM does not already hold. The region's element is never emptied. Each row is the
  1103. // span between its own marks, and every row stands between the list's marks.
  1104. paintFor(&m, s) {
  1105. let host = s.el
  1106. let start = s.start
  1107. if (!standing(host, start)) {
  1108. // dropped while this paint was on its way (a paint that was running when the
  1109. // range holding it left, and comes back for another round): it is gone, and
  1110. // nothing is painted — a site still REGISTERED here is the bug
  1111. if (!isLive(&m, s)) { return null }
  1112. if (start != null && dropDue(start)) { return null }
  1113. return staleSite(s)
  1114. }
  1115. let box = start.__hlBox
  1116. if (box == null) { return null }
  1117. // ONE PAINT OF A LIST AT A TIME. Building a row can wait (a composed child's module
  1118. // loads on first use), and a second write to the list in that time — a push right
  1119. // behind the handler's own write — read the box before the first paint had filed
  1120. // its row, and built the row again. A paint that arrives while one runs is
  1121. // remembered, and the running one paints again from the list as it then stands.
  1122. if (start.__hlBusy != null) { start.__hlBusy = 'again' return null }
  1123. start.__hlBusy = 'busy'
  1124. let inst = m.instance
  1125. let rws = s.rows
  1126. let read = s.read
  1127. let entries = read(&inst, &rws)
  1128. if (entries == null) { entries = [] }
  1129. // the keys this list has now, in order
  1130. let keys = listKeys(entries)
  1131. let next = []
  1132. let ri = 0
  1133. for (entry of entries) {
  1134. next.push({ key = keys[ri]; entry = entry; })
  1135. ri = ri + 1
  1136. }
  1137. // 1. THE ROWS THAT ARE GONE: their nodes, their sites and their children's
  1138. // mounts leave together.
  1139. let keep = {}
  1140. for (r of next) { keep[r.key] = true }
  1141. for (k of box.keys) {
  1142. if (keep[k] == null) { dropRow(&m, s, &box, k) }
  1143. }
  1144. // 2. THE ROWS IN ORDER. `at` is the node standing where the next row belongs: a
  1145. // row already there advances it, a row that is not is moved or built before it.
  1146. let at = start.__hlEnd
  1147. let found = false
  1148. for (k of box.keys) {
  1149. if (!found && keep[k] != null && box.rows[k] != null) { at = box.rows[k].start found = true }
  1150. }
  1151. let placed = []
  1152. let changed = []
  1153. for (r of next) {
  1154. let held = box.rows[r.key]
  1155. if (held == null) {
  1156. buildRow(&m, s, &box, r, at)
  1157. } else if (held.start == at) {
  1158. // already in place: step over it
  1159. at = held.start.__hlEnd.nextSibling
  1160. if (updateRow(&m, s, &box, r)) { changed.push(box.rows[r.key]) }
  1161. } else {
  1162. moveRow(host, held, at)
  1163. if (updateRow(&m, s, &box, r)) { changed.push(box.rows[r.key]) }
  1164. }
  1165. placed.push(r.key)
  1166. }
  1167. box.keys = placed
  1168. start.__hlBox = box
  1169. if (changed.length > 0) { reseatRows(&m, s, changed) }
  1170. let again = start.__hlBusy == 'again'
  1171. start.__hlBusy = null
  1172. if (again) { paintFor(&m, s) }
  1173. return null
  1174. }
  1175. // a row's own elements: the ones between its marks
  1176. rowEls(held) {
  1177. let out = []
  1178. for (n of between(held.start)) { if (n.nodeType == 1) { out.push(n) } }
  1179. return out
  1180. }
  1181. // A ROW LEAVES: its span goes off the document, the sites that were registered inside
  1182. // it are dropped (they point at nodes nobody can see), and so do the mounts of the
  1183. // children it held — a child of a row is keyed by that row (view.rowKey).
  1184. dropRow(&m, s, &box, key) {
  1185. let held = box.rows[key]
  1186. if (held == null) { return null }
  1187. let host = s.el
  1188. let nodes = spanOf(held.start)
  1189. dropRange(&m, host, nodes, s.fill)
  1190. for (n of nodes) { n.remove() }
  1191. delete box.rows[key]
  1192. return null
  1193. }
  1194. // A ROW MOVES, marks and all: `moveBefore` where the browser has it, because it keeps
  1195. // an element's state (focus, a playing video, an open dialog) across the move;
  1196. // `insertBefore` else.
  1197. moveRow(host, held, at) {
  1198. for (n of spanOf(held.start)) {
  1199. if (host.moveBefore != null) { host.moveBefore(n, at) }
  1200. else { host.insertBefore(n, at) }
  1201. }
  1202. return null
  1203. }
  1204. // A ROW ARRIVES: built into a fragment between its marks and put in with one insertion,
  1205. // because the walk appends and the row's place is where `at` stands — which may be in
  1206. // the middle of the region. What the walk registered on the fragment belongs to the host.
  1207. buildRow(&m, s, &box, r, at) {
  1208. let host = s.el
  1209. let inner = s.rows + {}
  1210. inner[s.row] = r.entry
  1211. let bin = document.createDocumentFragment()
  1212. let body = walkOf(m.key, 'b', s.site)
  1213. let before = sidNext
  1214. let rs = markAt(bin, true, true)
  1215. run(body, &m, &m, bin, 0, inner, (s.rowKey == null ? '' : s.rowKey) + '#' + r.key, s.fill, true, null)
  1216. rs.__hlEnd = markAt(bin, true, false)
  1217. rehome(&m, bin, host, before)
  1218. // THE LIST LEFT WHILE THE ROW WAS BEING BUILT (a module to load is a wait, and a
  1219. // branch around the list can be replaced in it): the row never stands, and what
  1220. // its walk registered goes with it
  1221. if (!standing(host, s.start)) {
  1222. dropRange(&m, host, Array.from(bin.childNodes), s.fill)
  1223. return null
  1224. }
  1225. host.insertBefore(bin, at)
  1226. box.rows[r.key] = { start = rs; entry = r.entry; }
  1227. return null
  1228. }
  1229. // A ROW STAYS, AND THE RECORD IT SHOWS MAY BE ANOTHER ONE — `upsert` hands the row a
  1230. // new record under the same id, and `items[0].title = 'edited'` writes into the one it
  1231. // already has. Both are answered here: the row's elements are handed the record (their
  1232. // `__hlRows`, which is also what a handler on the row reads), and every attribute and
  1233. // text leaf is rewritten ONLY where the DOM does not already hold the value. That is
  1234. // why a push into 2000 rows costs no mutation on the 2000 that did not change.
  1235. updateRow(&m, s, &box, r) {
  1236. let held = box.rows[r.key]
  1237. if (held == null) { return false }
  1238. // the row holds its own copy of the record, so the two are compared by what they say
  1239. let changed = JSON.stringify(held.entry) != JSON.stringify(r.entry)
  1240. held.entry = r.entry
  1241. box.rows[r.key] = held
  1242. for (el of rowEls(held)) { refreshRow(&m, el, s.row, r.entry) }
  1243. rebindRow(&m, s, r)
  1244. return changed
  1245. }
  1246. // A ROW THAT NOW SHOWS ANOTHER RECORD HANDS IT TO THE SITES INSIDE IT (ticket #86). A
  1247. // row without an id is keyed by its place, so a new list of the same shape keeps every
  1248. // row — and refreshRow repaints only the row's own elements. The sites registered in
  1249. // the row still hold the rows of the moment they were built: a nested `for` read the
  1250. // old record's list, and a `td` inside it the old inner record. Each site whose element
  1251. // stands in a changed row takes the new record, and the regions among them are patched
  1252. // again, outermost first — a nested row that changed in turn does the same for its own.
  1253. // One pass over the sites, marked rows and an ancestor walk, not a scan per row.
  1254. reseatRows(&m, s, changed) {
  1255. let host = s.el
  1256. // every node of a changed row carries its record for the walk up — its text and
  1257. // its regions' marks as well as its elements, so a text leaf or an `if` standing
  1258. // in the row directly on the host takes the record too
  1259. for (held of changed) { for (n of between(held.start)) { n.__hlReseat = held.entry } }
  1260. // (one pass that reads and writes the list back, with no call in it — see
  1261. // dropSitesIn — and the paints after it)
  1262. let hits = []
  1263. let k = 0
  1264. while (k < m.sites.length) {
  1265. let site = m.sites[k]
  1266. if (site.el != null && site.rows != null && !(site.region && site.start == s.start)) {
  1267. let n = (site.region && site.el == host && site.start != null) ? site.start : site.el
  1268. let hit = null
  1269. while (n != null && n != host && hit == null) {
  1270. if (n.__hlReseat != null) { hit = n }
  1271. n = n.parentNode
  1272. }
  1273. if (hit != null) {
  1274. let rows = site.rows + {}
  1275. rows[s.row] = hit.__hlReseat
  1276. site.rows = rows
  1277. m.sites[k] = site
  1278. hits[] = site
  1279. }
  1280. }
  1281. k = k + 1
  1282. }
  1283. let regions = []
  1284. let texts = []
  1285. for (site of hits) {
  1286. if (site.region) { regions.push(site.start) }
  1287. if (site.text == true) { texts.push(site) }
  1288. }
  1289. for (held of changed) { for (n of between(held.start)) { n.__hlReseat = null } }
  1290. for (t of texts) { paintText(&m, t) }
  1291. for (r of regions) {
  1292. // read again from the mount: a region an outer one already patched away is gone
  1293. let site = null
  1294. for (e of m.sites) { if (e.region && e.start == r) { site = e } }
  1295. if (site != null) {
  1296. if (site.isFor) { paintFor(&m, site) } else { rebuildIf(&m, site) }
  1297. }
  1298. }
  1299. return null
  1300. }
  1301. // A ROW'S CHILDREN TAKE THEIR BINDINGS AGAIN. A reference inside a `for` body is not
  1302. // in `m.bindings` — that list is built from the View's own walk, which does not enter a
  1303. // `for` (the row is the scope, and there is one child per row) — so a host member that
  1304. // feeds a row's child used to reach it only because the region REBUILT and the child
  1305. // was minted again with the value of the moment (the framework gate's own case:
  1306. // home.hl writes `noteOpen`, every row's RowNote takes it and hands it to RowMark, and
  1307. // the grandchild's region appears). Nothing rebuilds any more, so the edge is walked
  1308. // here — and only what MOVED is written and repainted, or every list write would
  1309. // repaint every row's child.
  1310. rebindRow(&m, s, r) {
  1311. let rk = (s.rowKey == null ? '' : s.rowKey) + '#' + r.key
  1312. let inner = s.rows + {}
  1313. inner[s.row] = r.entry
  1314. rebindNodes(&m, rowBody(m.key, s.site), inner, rk)
  1315. return null
  1316. }
  1317. // THE ROW'S OWN NODES, for the binding edges a row's child takes again. The tree is
  1318. // still what the seed carries (it is the mount's `view`), and this is a read of it, not
  1319. // a walk of the DOM: the compiled row factory builds, this re-binds.
  1320. rowBody(key, site) {
  1321. let bp = blueprints[key]
  1322. if (bp == null) { return [] }
  1323. return bodyIn(bp.view, site)
  1324. }
  1325. bodyIn(nodes, site) {
  1326. for (n of nodes) {
  1327. if (n.k == 'for' && n.site == site) { return n.body }
  1328. if (n.k == 'el') { let got = bodyIn(n.children, site) if (got.length > 0) { return got } }
  1329. if (n.k == 'if') {
  1330. let a = bodyIn(n.then, site)
  1331. if (a.length > 0) { return a }
  1332. let b = bodyIn(n.other, site)
  1333. if (b.length > 0) { return b }
  1334. }
  1335. if (n.k == 'for') { let got = bodyIn(n.body, site) if (got.length > 0) { return got } }
  1336. if (n.k == 'component' && n.fill != null) { let got = bodyIn(n.fill, site) if (got.length > 0) { return got } }
  1337. }
  1338. return []
  1339. }
  1340. rebindNodes(&m, nodes, rows, rk) {
  1341. for (n of nodes) {
  1342. if (n.k == 'component') {
  1343. let kk = view.kidKey(n.path) + rk
  1344. let kid = m.kids[kk]
  1345. if (kid != null) {
  1346. let moved = []
  1347. for (b of n.bindings) {
  1348. let v = null
  1349. if (b.text != null) { v = view.refValue(b) }
  1350. else if (b.member != null) { v = view.value({ k = 'member'; name = b.member; }, m.instance, rows) }
  1351. else if (b.ref != null) { v = view.value(b.ref, m.instance, rows) }
  1352. if (kid.instance[b.name] != v) { kid.instance[b.name] = v moved.push(b.name) }
  1353. }
  1354. if (moved.length > 0) { repaintAll(&kid, moved) }
  1355. m.kids[kk] = kid
  1356. }
  1357. if (n.fill != null) { rebindNodes(&m, n.fill, rows, rk) }
  1358. } else if (n.k == 'el') {
  1359. rebindNodes(&m, n.children, rows, rk)
  1360. } else if (n.k == 'if') {
  1361. rebindNodes(&m, n.then, rows, rk)
  1362. rebindNodes(&m, n.other, rows, rk)
  1363. }
  1364. }
  1365. return null
  1366. }
  1367. refreshRow(&m, el, name, entry) {
  1368. // A COMPOSED CHILD'S DOM IS ITS OWN MOUNT'S: its elements read the CHILD's
  1369. // instance, and painting them from this one would show the wrong values. The walk
  1370. // stops at the roots a reference claimed (`__hlKid`, set where they are claimed
  1371. // and built) — and at any element another component's walk built (`__hlKey`): a
  1372. // root the child's own region built after the reference was claimed is its too.
  1373. // The walk goes on INTO it: a fill this host wrote stands inside the child's
  1374. // elements, and that is this host's row again.
  1375. if (el.__hlKid == true || (el.__hlKey != null && el.__hlKey != m.key)) {
  1376. let inside = Array.from(el.children)
  1377. for (k of inside) { refreshRow(&m, k, name, entry) }
  1378. return null
  1379. }
  1380. if (el.__hlRows != null) {
  1381. let next = el.__hlRows + {}
  1382. next[name] = entry
  1383. el.__hlRows = next
  1384. // (The literal a handler on this element fires takes its row entries from
  1385. // `__hlRows` when it fires — see `bind` — so there is nothing to update here.
  1386. // A handler written on a REFERENCE (bindRefOns) mints one literal for the
  1387. // child's roots and does not: its bindings are re-applied by rebindRow, its
  1388. // own entries are not.)
  1389. }
  1390. rowPaint(&m, el)
  1391. let kids = Array.from(el.children)
  1392. for (k of kids) { refreshRow(&m, k, name, entry) }
  1393. return null
  1394. }
  1395. // ONE ELEMENT OF A ROW, REDRAWN WHERE IT IS WRONG. The guard is the DOM's own value,
  1396. // not a remembered one: nothing is serialised and nothing is compared to a digest.
  1397. rowPaint(&m, el) {
  1398. let site = el.__hlSite
  1399. if (site == null) { return null }
  1400. let comp = compiledOf(el.__hlKey == null ? m.key : el.__hlKey)
  1401. if (comp == null) { return null }
  1402. let entry = comp.p[site]
  1403. if (entry == null) { return null }
  1404. let f = entry.r
  1405. if (f == null) { return null }
  1406. let inst = m.instance
  1407. let rws = el.__hlRows
  1408. let e = el
  1409. f(&e, &inst, &rws)
  1410. return null
  1411. }
  1412. bindValue(&m, name, el, isSelect) {
  1413. let held = m.instance[name] == null ? '' : '' + m.instance[name]
  1414. // A SELECT IS PAINTED BY THE MEMBER FIRST: what it shows is one of its options,
  1415. // and the member decides which. The server already marked it (view.hl `selected`),
  1416. // so this is a no-op there; on a select the client built it is the paint. Only
  1417. // then is the read-back right — before it, an unpainted select reports its FIRST
  1418. // option and the read-back would write that back into the member.
  1419. if (isSelect && held != '') { el.value = held }
  1420. if (el.value != held) { m.instance[name] = el.value liftValue(m.chain, m.rootKind, name, el.value) }
  1421. el.addEventListener('input', (ev) => {
  1422. m.instance[name] = el.value
  1423. // AND UP THROUGH THE REFERENCE THAT BOUND IT, if this control stands inside a
  1424. // composed child: the host's member is what the app reads (see liftValue)
  1425. liftValue(m.chain, m.rootKind, name, el.value)
  1426. })
  1427. return null
  1428. }
  1429. // ---- `value` IS TWO-WAY THROUGH A COMPOSITION TOO --------------------------------
  1430. // `value = member` on a control is the framework's two-way name: the member paints the
  1431. // field and the field writes the member (mission 132). A composed child is a mount of
  1432. // its own, so that write landed on the CHILD's member and stopped there — `Field
  1433. // { value = who }` left the host's `who` empty while the DOM held what was typed, and
  1434. // routger's login submit read null (creator, W12). The host→child binding is an edge
  1435. // the mount already carries; this is the SAME edge run backwards on input, and only
  1436. // for the name `value`: every other binding name stays one-way.
  1437. //
  1438. // The child cannot name its host (a mount record is a value), so it names it by the
  1439. // ADDRESS it already carries — the kid keys from the page or the shell — and the walk
  1440. // below re-enters the host by reference. It carries on upward as long as the reference
  1441. // it came through was itself a `value` binding, which is what makes a component whose
  1442. // View is a bare reference to a field reach the page's member through both hops.
  1443. liftValue(chain, root, target, value) {
  1444. if (target != 'value') { return null }
  1445. if (chain == null || chain.length == 0) { return null }
  1446. if (root == 'shell') {
  1447. if (shell == null) { return null }
  1448. liftInto(&shell, chain, 0, target, value)
  1449. } else {
  1450. if (page == null) { return null }
  1451. liftInto(&page, chain, 0, target, value)
  1452. }
  1453. return null
  1454. }
  1455. // walk the address down to the mount that HOLDS the last key — that mount is the host —
  1456. // and write what its reference bound to the child's `target`, repainting its own sites
  1457. liftInto(&m, chain, at, target, value) {
  1458. if (at >= chain.length - 1) {
  1459. for (b of m.bindings) {
  1460. // a field path (`value = row.text`) names no member to write back into
  1461. if (b.kid == chain[at] && b.target == target && b.ref == null) {
  1462. if (m.instance[b.name] != value) {
  1463. m.instance[b.name] = value
  1464. repaintAll(&m, [b.name])
  1465. }
  1466. liftValue(m.chain, m.rootKind, b.name, value)
  1467. }
  1468. }
  1469. return null
  1470. }
  1471. let kid = m.kids[chain[at]]
  1472. if (kid == null) { return null }
  1473. liftInto(&kid, chain, at + 1, target, value)
  1474. m.kids[chain[at]] = kid
  1475. return null
  1476. }
  1477. // THE ELEMENT'S LITERAL: a value of the class the module minted for the literal's
  1478. // SITE (`file:line:col`, the id the JavaScript target registers at load), with the
  1479. // element's own named entries — its attributes as the tree has them — and the
  1480. // `for` row variables in scope as its own entries, the way the language builds a
  1481. // row's literal (the row rides on the value). Built HERE and not looked up in the
  1482. // instance's View: a row created after the list changed has no value there, and
  1483. // a name-path cannot address ordered content. The handlers read own entries first,
  1484. // then the owner's members — the owner is the component instance.
  1485. literalFor(&m, el) {
  1486. let rows = el.__hlRows
  1487. let inst = m.instance
  1488. let rws = rows
  1489. // THE ELEMENT'S OWN ENTRIES, compiled: its attributes by name, with the reads
  1490. // written in (compile.hl `litNamed`)
  1491. let mk = el.__hlLit
  1492. let named = mk == null ? {} : mk(&inst, &rws)
  1493. for (k of rows.keys()) { named[k] = rows[k] }
  1494. // the owner by REFERENCE: a call argument is copied, and a handler writing a
  1495. // copy's member would repaint nothing
  1496. return hlLiteralNew(el.__hlSite, named, &inst)
  1497. }
  1498. // WHAT A HANDLER WROTE, REPAINTED — the whole of the answer to a local event.
  1499. // `&m` is the mount whose instance the handler's owner is, so a write reaches this
  1500. // mount's sites and, through its bindings, the children that read the member.
  1501. //
  1502. // AND UPWARD, THROUGH A ROUTINE THE HOST HANDED DOWN. `Badge { onHide = hideBadge }`
  1503. // gives the child a FUNCTION of the host's; the child's handler calls it and the
  1504. // host's member moves. The child's own table cannot say so — the member it called
  1505. // has no initializer there — but the HOST's does: the binding edge names the host
  1506. // member (`hideBadge`), and the host's table says what calling it writes. That is
  1507. // the same table read one mount up, not a new mechanism.
  1508. //
  1509. // ONE PATCH AT A TIME, in the order they were asked for. A patch awaits (a composed
  1510. // child's module loads on first use, and every call in the language is awaited), and a
  1511. // second one — the next handler, a frame from the server — running into the first
  1512. // would read a branch the first is in the middle of replacing. A patch asked for while
  1513. // one runs is queued, and the running one goes on with it; the state a handler wrote
  1514. // is already on the instance, so the queued patch paints what stands when it runs.
  1515. patch(chain, root, names, targets) {
  1516. patchQueue[] = { chain = chain; root = root; names = names; targets = targets; }
  1517. if (turning) { return null }
  1518. turning = true
  1519. while (patchQueue.length > 0) {
  1520. let job = patchQueue[0]
  1521. patchQueue = patchQueue.slice(1)
  1522. patchAt(job.chain, job.root, job.names)
  1523. patchUp(job.chain, job.root, job.targets)
  1524. settle()
  1525. }
  1526. turning = false
  1527. return null
  1528. }
  1529. // ---- A WRITE AFTER ITS LISTENER (tickets #98, #99) --------------------------------
  1530. // A listener repaints its handler's write set when the handler is done. What runs
  1531. // LATER is repainted the same way, from the same tables, when it is done:
  1532. // • a function made inside a component (an XMLHttpRequest's `onload`, a timer, a
  1533. // Promise's executor, a `then`) — the module writes it as `hlFnSite(…)`, and its
  1534. // return is announced here with its site; its write set is the file's `fns`
  1535. // table entry, closed over the methods and handlers it calls (a local `emit`);
  1536. // • a handler that is still waiting after the task that ran it (a network
  1537. // answer, a Promise): the runtime says so (`hlRealm.handlerWaiting`), and its
  1538. // write set is painted then, and again by its listener when it ends.
  1539. // Both are LATE patches: queued, and run after the current task, unless a patch of
  1540. // the same mount paints those members first — so a function called and a handler
  1541. // finished within one task are painted once, by their listener. (`lateJobs` and
  1542. // `lateDue` are declared at the top: boot() patches before a member declared down
  1543. // here is initialized.)
  1544. latePatch(chain, root, names) {
  1545. if (names == null || names.length == 0) { return null }
  1546. lateJobs[] = { at = addressOf(chain, root); chain = chain; root = root; names = names.slice(0); }
  1547. if (!lateDue) {
  1548. lateDue = true
  1549. setTimeout(() => { runLate() }, 0)
  1550. }
  1551. return null
  1552. }
  1553. runLate() {
  1554. lateDue = false
  1555. let jobs = lateJobs
  1556. lateJobs = []
  1557. for (j of jobs) {
  1558. if (j.names.length > 0) { patch(j.chain, j.root, j.names, []) }
  1559. }
  1560. return null
  1561. }
  1562. // `names` were painted at this mount: a late job there has nothing left of them to do
  1563. lateDone(chain, root, names) {
  1564. if (lateJobs.length == 0 || names == null) { return null }
  1565. let at = addressOf(chain, root)
  1566. let i = 0
  1567. while (i < lateJobs.length) {
  1568. if (lateJobs[i].at == at) {
  1569. let left = []
  1570. for (n of lateJobs[i].names) { if (!names.includes(n)) { left.push(n) } }
  1571. lateJobs[i].names = left
  1572. }
  1573. i = i + 1
  1574. }
  1575. return null
  1576. }
  1577. addressOf(chain, root) {
  1578. return root + ':' + (chain == null ? '' : chain.join('/'))
  1579. }
  1580. // a handler still waiting after the task that ran it: what it writes is painted now
  1581. // (and again by its listener when it ends)
  1582. on hlRealm.handlerWaiting(ownerId, event) {
  1583. let at = null
  1584. if (page != null) { at = mountById(&page, ownerId) }
  1585. if (at == null && shell != null) { at = mountById(&shell, ownerId) }
  1586. if (at == null) { return null }
  1587. latePatch(at.chain, at.root, eventWrites(at.key, event))
  1588. return null
  1589. }
  1590. on hlRealm.fnReturned(ownerId, site) {
  1591. let at = null
  1592. if (page != null) { at = mountById(&page, ownerId) }
  1593. if (at == null && shell != null) { at = mountById(&shell, ownerId) }
  1594. if (at == null) { return null }
  1595. latePatch(at.chain, at.root, fnWrites(at.key, site))
  1596. return null
  1597. }
  1598. mountById(&m, id) {
  1599. if (m.instance.__hlOwnerId == id) { return { key = m.key; chain = m.chain; root = m.rootKind; } }
  1600. for (k of m.kids.keys()) {
  1601. let kid = m.kids[k]
  1602. let at = mountById(&kid, id)
  1603. m.kids[k] = kid
  1604. if (at != null) { return at }
  1605. }
  1606. return null
  1607. }
  1608. // what the function made at `site` writes: the compile step's `fn` table
  1609. fnWrites(key, site) {
  1610. let comp = compiledOf(key)
  1611. if (comp == null || comp.fn == null) { return [] }
  1612. let names = comp.fn[site]
  1613. return names == null ? [] : names
  1614. }
  1615. // A MOUNT RECORD IS A VALUE (the rule this whole file is written around), so a
  1616. // listener cannot hold the live one — the record it was bound against is a copy the
  1617. // walk wrote back, and a region rebuild replaces it again. It holds the mount's
  1618. // ADDRESS instead and the walk re-enters from the root, exactly as `liftInto` and
  1619. // `fillAt` do. `refresh()` used to get this for free by starting at the root every
  1620. // time; a write set has to say where it lands.
  1621. patchAt(chain, root, names) {
  1622. if (names == null || names.length == 0) { return null }
  1623. if (root == 'shell') {
  1624. if (shell == null) { return null }
  1625. patchDown(&shell, chain, 0, names)
  1626. } else {
  1627. if (page == null) { return null }
  1628. patchDown(&page, chain, 0, names)
  1629. }
  1630. return null
  1631. }
  1632. patchDown(&m, chain, at, names) {
  1633. if (chain == null || at >= chain.length) {
  1634. repaintAll(&m, names)
  1635. return null
  1636. }
  1637. let kid = m.kids[chain[at]]
  1638. if (kid == null) { return null }
  1639. patchDown(&kid, chain, at + 1, names)
  1640. m.kids[chain[at]] = kid
  1641. return null
  1642. }
  1643. patchUp(chain, root, targets) {
  1644. if (targets == null || targets.length == 0) { return null }
  1645. if (chain == null || chain.length == 0) { return null }
  1646. if (root == 'shell') {
  1647. if (shell == null) { return null }
  1648. patchInto(&shell, chain, 0, targets)
  1649. } else {
  1650. if (page == null) { return null }
  1651. patchInto(&page, chain, 0, targets)
  1652. }
  1653. return null
  1654. }
  1655. // walk the address down to the mount that HOLDS the last key — that mount is the host
  1656. // — and repaint what the routines it bound into the child write (and carry on upward,
  1657. // because the host may have received them from ITS host)
  1658. patchInto(&m, chain, at, targets) {
  1659. if (at >= chain.length - 1) {
  1660. let names = []
  1661. let up = []
  1662. for (b of m.bindings) {
  1663. if (b.kid == chain[at] && b.ref == null && targets.includes(b.target)) {
  1664. if (!up.includes(b.name)) { up.push(b.name) }
  1665. for (w of memberWrites(m.key, b.name)) { if (!names.includes(w)) { names.push(w) } }
  1666. }
  1667. }
  1668. repaintAll(&m, names)
  1669. patchUp(m.chain, m.rootKind, up)
  1670. return null
  1671. }
  1672. let kid = m.kids[chain[at]]
  1673. if (kid == null) { return null }
  1674. patchInto(&kid, chain, at + 1, targets)
  1675. m.kids[chain[at]] = kid
  1676. return null
  1677. }
  1678. // one listener, in its own frame so the event name it closes over is this one.
  1679. // `&m` is the HOST — a handler written on a reference is the host's, and its write
  1680. // set is filed under the reference's own `on` site in the host's table.
  1681. listenRef(&m, &lit, el, o) {
  1682. let wrote = o.sets[0]
  1683. let touched = o.sets[1]
  1684. let at = m.chain
  1685. let root = m.rootKind
  1686. el.addEventListener(o.event, (ev) => {
  1687. // same reason as `onAt`: the framework, not the handler, owns `submit`'s default
  1688. if (o.event == 'submit') { ev.preventDefault() }
  1689. fire(&lit, o.event, ev)
  1690. patch(at, root, wrote, touched)
  1691. })
  1692. return null
  1693. }
  1694. // the elements a composed child rendered directly under `host`, between two marks in
  1695. // the child list — its roots, whatever its tree put there
  1696. rootsBetween(host, firstAt, to) {
  1697. let out = []
  1698. let i = firstAt
  1699. while (i < to) {
  1700. if (host.children[i] != null) { out.push(host.children[i]) }
  1701. i = i + 1
  1702. }
  1703. return out
  1704. }
  1705. fire(&lit, event, ev) {
  1706. if (event == 'click') { emit lit.click(ev) return null }
  1707. if (event == 'input') { emit lit.input(ev) return null }
  1708. if (event == 'change') { emit lit.change(ev) return null }
  1709. if (event == 'submit') { emit lit.submit(ev) return null }
  1710. if (event == 'keydown') { emit lit.keydown(ev) return null }
  1711. if (event == 'keyup') { emit lit.keyup(ev) return null }
  1712. if (event == 'focus') { emit lit.focus(ev) return null }
  1713. if (event == 'blur') { emit lit.blur(ev) return null }
  1714. if (event == 'dblclick') { emit lit.dblclick(ev) return null }
  1715. if (event == 'pointerdown') { emit lit.pointerdown(ev) return null }
  1716. if (event == 'pointermove') { emit lit.pointermove(ev) return null }
  1717. if (event == 'pointerup') { emit lit.pointerup(ev) return null }
  1718. if (event == 'pointercancel') { emit lit.pointercancel(ev) return null }
  1719. // EVERY OTHER STANDARD DOM EVENT, by name (ticket #31): `on mouseover()` was bound
  1720. // and never ran, because only the thirteen names above were written out. The
  1721. // language's own dynamic emit dispatches the same handler an `emit lit.x(ev)` does.
  1722. if (domEvents.includes(event)) { hlEmitArgs(&lit, event, [ev]) return null }
  1723. console.warn('framework: not a standard DOM event, so nothing handles it here:', event)
  1724. return null
  1725. }
  1726. // THE STANDARD DOM EVENTS a View handler may name beyond the thirteen `fire` writes out.
  1727. // A name outside this list — a custom event an element dispatches itself — is not bound
  1728. // yet: whether a View may handle one is the creator's to rule (ticket #31).
  1729. static domEvents = ['mouseover' 'mouseout' 'mouseenter' 'mouseleave' 'mousedown' 'mouseup' 'mousemove' 'contextmenu' 'wheel' 'auxclick'
  1730. 'pointerover' 'pointerout' 'pointerenter' 'pointerleave' 'gotpointercapture' 'lostpointercapture'
  1731. 'touchstart' 'touchmove' 'touchend' 'touchcancel'
  1732. 'keypress' 'focusin' 'focusout' 'beforeinput' 'compositionstart' 'compositionupdate' 'compositionend'
  1733. 'select' 'selectionchange' 'invalid' 'reset' 'search' 'toggle' 'cancel' 'close'
  1734. 'drag' 'dragstart' 'dragend' 'dragenter' 'dragleave' 'dragover' 'drop'
  1735. 'copy' 'cut' 'paste' 'scroll' 'scrollend' 'resize' 'load' 'error' 'abort'
  1736. 'play' 'pause' 'ended' 'playing' 'timeupdate' 'volumechange' 'seeking' 'seeked' 'loadeddata' 'loadedmetadata' 'canplay' 'canplaythrough' 'waiting' 'ratechange' 'durationchange' 'emptied' 'stalled' 'suspend' 'progress'
  1737. 'animationstart' 'animationend' 'animationiteration' 'animationcancel' 'transitionstart' 'transitionend' 'transitionrun' 'transitioncancel']
  1738. // ---- C2: MEMBERS WITH INITIALIZERS ARE DERIVATIONS -------------------------------
  1739. // "Members with initializers are derivations that re-run locally when what they read
  1740. // changes" (CONCEPT §2). Everything that sentence needs is compiler output: the
  1741. // module's `derivations` table says what each initializer reads, and the class
  1742. // carries each initializer as a callable keyed by its own site (`__derive__`, which
  1743. // the emitter writes for EVERY member of EVERY class — it knows nothing of a View and
  1744. // nothing of this framework; the root itself calls it instead of carrying a second
  1745. // copy of the expression).
  1746. //
  1747. // So a write is followed by this: every derivation that reads a member which MOVED
  1748. // runs again, in declaration order, and what it changes has moved too. The sites are
  1749. // repainted afterwards, once per member — which is why this answers with the whole
  1750. // moved set instead of painting as it goes.
  1751. //
  1752. // A DERIVATION IS NOT ITS OWN INPUT. A second assignment to a member at a root IS a
  1753. // second initializer of that member (`out = out + 'x'` is one the language accepts),
  1754. // and re-running that on a write to `out` would accumulate rather than derive. The
  1755. // member's own name is therefore not one of its inputs here. Measured 2026-09-14:
  1756. // none of the four reference apps has an initializer that reads its own member.
  1757. //
  1758. // THE BOUND IS THE NUMBER OF DERIVATIONS. One pass answers a file written top to
  1759. // bottom; the rounds are there for a file that is not, and they stop where a cycle
  1760. // between two members would otherwise spin.
  1761. moved(&m, names) {
  1762. let out = names.slice(0)
  1763. let list = derivationsOf(m.key)
  1764. if (list.length == 0) { return out }
  1765. // A CLASS THAT COMPUTES NOTHING HERE HAS NO CALLABLE: every member of it is a
  1766. // declaration without an initializer, a static, or the other realm's (demo-blog's
  1767. // post page is one — its `post` comes from the server). The table still lists
  1768. // those initializers, so the method is asked for before it is used.
  1769. if (m.instance.__derive__ == null) { return out }
  1770. let rounds = 0
  1771. let again = true
  1772. while (again && rounds <= list.length) {
  1773. again = false
  1774. rounds = rounds + 1
  1775. for (d of list) {
  1776. // AN EXPLICIT WRITE OUTRANKS A COMPUTED DEFAULT — the language's own rule
  1777. // for construction (hlNew pins the caller's values while the root runs: "a
  1778. // run-body assignment is a computed DEFAULT; an explicit caller value
  1779. // outranks it"), read here for the same relation between a handler and an
  1780. // initializer. demo-blog's post page is the case: its `postsChanged`
  1781. // handler writes `title`, `paragraphs` and the rest from what the server
  1782. // answered, and those members' initializers would otherwise recompute them
  1783. // from a `_post` the browser never refreshed. What the handler wrote stands;
  1784. // everything DOWNSTREAM of it is derived.
  1785. if (names.includes(d.name)) { }
  1786. // A SERVER REFERENCE SITE (compile.hl `derivList`'s `srv`: the initializer
  1787. // calls an imported class, e.g. `conversations = … ? store.chatsOf(…) : []`)
  1788. // is NEVER re-derived here, session sync or not — it is evaluated on the
  1789. // SERVER, per request, and a browser build may not even carry what it calls
  1790. // (routger, 2026-09-27: reached hl:time, which ships no client half, the
  1791. // moment `session` moving from a sync made this member "moved" too). Its
  1792. // fresh value ships in `sync` instead (WebFramework.hl `serverSyncNames`)
  1793. // and lands here through the `names.includes(d.name)` branch above, an
  1794. // explicit write like any other — this branch only stops the LOCAL derive
  1795. // when the server did not (yet) ship one, leaving the site stale rather
  1796. // than broken.
  1797. else if (d.srv) { }
  1798. else if (feedsFrom(d, out)) {
  1799. let before = m.instance[d.name]
  1800. // THE CLASS'S OWN CALLABLE, by the site the table named it with.
  1801. // A member this realm does not compute (a server-realm initializer,
  1802. // a static, the View) has no branch there and nothing happens.
  1803. m.instance.__derive__(d.site)
  1804. if (m.instance[d.name] != before) {
  1805. if (!out.includes(d.name)) { out.push(d.name) again = true }
  1806. }
  1807. }
  1808. }
  1809. }
  1810. return out
  1811. }
  1812. // does this derivation read one of the members that moved — its own name aside?
  1813. feedsFrom(d, names) {
  1814. for (r of d.reads) {
  1815. if (r != d.name && names.includes(r)) { return true }
  1816. }
  1817. return false
  1818. }
  1819. // A WRITE SET, DERIVED AND THEN PAINTED: the one way in for every caller that knows
  1820. // which members moved. Painting is the last act, so a derived member's sites are
  1821. // drawn once, with its final value.
  1822. repaintAll(&m, names) {
  1823. if (names == null || names.length == 0) { return null }
  1824. // whatever paints these members here, a late patch of them here has nothing left to do
  1825. lateDone(m.chain, m.rootKind, names)
  1826. painting = painting + 1
  1827. for (n of moved(&m, names)) { repaint(&m, n) }
  1828. painting = painting - 1
  1829. return null
  1830. }
  1831. // ---- repaint: a member was written; every site that reads it is redrawn ---------
  1832. write(&m, name, value) {
  1833. m.instance[name] = value
  1834. patch(m.chain, m.rootKind, [name], [])
  1835. return null
  1836. }
  1837. repaint(&m, name) {
  1838. let sites = m.sites.slice(0) // a paint may add sites; walk what was there
  1839. // …and may DROP some: a branch that goes takes the sites inside it, and a site that
  1840. // is no longer registered is not painted (the list is checked again after a drop)
  1841. let gen = dropGen
  1842. let live = null
  1843. for (s of sites) {
  1844. if (s.names.includes(name)) {
  1845. if (dropGen != gen) { live = liveSids(&m) gen = dropGen }
  1846. if (live == null || live[s.sid] == true) { paint(&m, s, name) }
  1847. }
  1848. }
  1849. // DOWN THE BINDINGS: a child bound to this member at its reference site gets
  1850. // the value as its own member and repaints its own sites — the host knows the
  1851. // binding, the child knows nothing
  1852. for (b of m.bindings) {
  1853. // (a child whose branch is not standing is not composed: nothing to hand down)
  1854. if (b.name == name && m.kids[b.kid] != null) {
  1855. let kid = m.kids[b.kid]
  1856. kid.instance[b.target] = bindingValue(b, &m)
  1857. // …and the CHILD's own derivations follow the member it was given
  1858. repaintAll(&kid, [b.target])

Only the first lines are shown.

Branches

Latest commits

  • 12595e47mission 035: theme re-vendored from layouts.worldapi.org 0222f67 (two corner radii: radiusSmall 5px, radiusLarge 10px); the tracker's 18 own radii -> radiusSmall/radiusLarge (--layout-radius is gone); check-theme 0; gates 379/0, 32/0, 53/0, 229/0, 26/0; real copy: every computed radius in {0, 5px, 10px, 50%}mre
  • e7305014tracker: report 032 (art + photos)mre
  • fbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmre
  • 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
  • e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
  • b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
  • f8ffa4dbtracker: report 032 + The Remaining + #39mre
  • 7565a863tracker: LOG timemre
  • b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
  • 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
  • 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
  • 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
  • 8751adb8tracker: report 032mre
  • 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
  • 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
  • 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
  • 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
  • eb3b9205tracker: report 031mre
  • 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre