tracker
All repositories: gitoria
20.3 KB
// The framework's STYLESHEET — the Style convention of COMPONENTS §4, as the// archived hl:web implemented it (archive/hlweb-v1/plugins-web/web_css.hl), re-read// 2026-09-12 after a version built from the doc alone got most of it wrong.//// STRUCTURE FROM THE SYNTAX, VALUES FROM THE INSTANCE. A hybrid's keys come back// alphabetical, so the ORDER of rules, declarations and media conditions is read// off the syntax (hlSyntax entries are authored order) while every VALUE is read// off the constructed instance at the same path — the language has already folded// the statics (`background = dark`) and merged `base + { … }` by then.//// WHAT A RULE NAME MEANS (the archive's `placesFor`, per file):// `@root` / `':root'` a CONTRIBUTION: collected from every file into ONE// `:root { }` at the top of the sheet, in order// `'@media …'` / `@media` a BLOCK of rules: collected at the END of the sheet,// (`@supports`, the same shape and place for the other two queries// `@container`) (ticket #66, proposal 5)// one `@media` per distinct query, first-seen order;// `@media { minWidth = 960 … }` composes the query from// conditions (number → px, string verbatim)// `'@keyframes name'` an ANIMATION: its stops (`'from'`, `to`, `'50%'`) with// their declarations, emitted where it stands (ticket #66, 4)// selector-shaped verbatim (`'.icon'`, `'#page-anchor'`, `'a:hover'`)// referenced `Style.x` the class the View wrote (tag-prefix strip): `.x`, or// `tag.stripped`// `#x` id the View writes `id = 'x'` on an element: `#x` (R3)// `#x` local otherwise matched by NAME to this file's elements `x` (and// a composed child's root for `#Child`): `tag.<file class>`// own-name a rule named like an element of this file's View (or// body/html): the TAG selector, no class// anything else DEAD: dropped and listed// In a STYLES FILE (root members, no View) a bare name is a tag, kebab-cased.// NESTED under a rule, a name of PSEUDOS is that rule's pseudo-class, not a// descendant: `a { hover { … } }` → `a:hover` (ticket #66, proposal 1).// A rule with no declarations and nested rules is a CONTAINER: not emitted.// Two rules emitting one selector with different bodies: refused.import HlwCssVar from './cssvar.hl'static NL = ""static SELECTOR_CHARS = ":-.#>[]()*+~,/ %@=^|!"static HEADER = "/* GENERATED by hl:web — ONE global stylesheet.Components author their Style rules per file; only the rules a View actuallyREFERENCES are emitted (dead rules are dropped and listed at boot). Class namesfollow the tag-prefix strip rule: rule `headerMain` referenced inside `header`emits class=\"main\" under the TAG-QUALIFIED selector `header.main`; under anyother tag it keeps `.headerMain`. */"Boolean minify = falseview = null // the View walk: kebab, className, localClass live theregen = 0 // the analysis generation the syntax handles belong to// per sheetparts = []seen = {}dead = []mediaOrder = []mediaParts = []rootDecls = []// ---- names ----------------------------------------------------------------------// ---- css variables (creator's ruling 2026-09-13) --------------------------------// `bg = var('#FFF')` declares a custom property as a VALUE; the walker names it after// its member, writes it into the sheet's one `:root`, and rewrites every use.static counter = { n = 0 }// `var('#FFF', dark = '#000')` carries its dark-scheme value too (ticket #66,// proposal 2): the sheet writes it into `:root` under `(prefers-color-scheme: dark)`static var = (value, dark = null) => {counter.n = counter.n + 1return new HlwCssVar(value = value, id = counter.n, dark = dark)}static DARK = '@media (prefers-color-scheme: dark)'// ---- units as functions (ticket #66, proposal 3) ---------------------------------// a number is emitted unitless as written; `rem(1.5)`, `px(12)`, `pct(50)` answer the// TEXT with its unit — '1.5rem', '12px', '50%' — so a value says what it measuresstatic rem = (n) => { return '' + n + 'rem' }static px = (n) => { return '' + n + 'px' }static pct = (n) => { return '' + n + '%' }vars = {} // id → { name value } for the sheet being builtvarNames = {} // name → trueisVar(v) { return v != null && hlTypeName(v) == 'Instance' }// the variables a file's root members declare, named after the membercollectVars(f) {for (r of fileRules(f)) {if (!r.isBlock && isVar(r.value)) {r.value.name = r.keyvars['' + r.value.id] = { name = r.key; value = r.value.value; }varNames[r.key] = true}}// a token the file INHERITS: its own instance's copy, under the parent's name (ticket #39)if (f.aliases != null) {for (a of f.aliases) {a.value.name = a.keyvars['' + a.value.id] = { name = a.key; value = a.value.value; }}}return null}// a value's text with every variable reference resolved: a variable itself →// var(--name); a composed string carries placeholders var(--hlv<id>) → var(--name)varText(v) {if (isVar(v)) {let d = vars['' + v.id]if (d == null) { hlError("a css variable is used before any styles file declared it as a root member — `name = var(…)` at the root of the styles file") }return 'var(--' + view.kebab(d.name) + ')'}let s = '' + vif (s.indexOf('var(--hlv') < 0) { return s }for (id of vars.keys()) { s = s.replaceAll('var(--hlv' + id + ')', 'var(--' + view.kebab(vars[id].name) + ')') }return s}// the pseudo-classes a nested entry may name, camelCase as written (kebab-cased on emit)static PSEUDOS = ['hover', 'focus', 'focusVisible', 'focusWithin', 'active', 'visited', 'target','disabled', 'enabled', 'checked', 'required', 'optional', 'valid', 'invalid', 'readOnly','placeholderShown', 'firstChild', 'lastChild', 'onlyChild', 'firstOfType', 'lastOfType', 'empty']isPseudo(name) { return PSEUDOS.includes(name) }// the selectors a nested entry composes under its enclosing rule's `sels`: a// pseudo-class sticks to each (`a:hover`), anything else is a descendant (`a span`)nestedSels(f, sels, key) {let out = []if (isPseudo(key)) {for (p of sels) { out.push(p + ':' + view.kebab(key)) }return out}let kidSels = selectorsFor(f, key)for (p of sels) { for (s of kidSels) { out.push(p + ' ' + s) } }return out}isKeyframes(name) { return name == '@keyframes' || name.startsWith('@keyframes ') }isRootBlock(name) { return name == '@root' || name == ':root' }// THE THREE QUERIES share one shape: `@media`, `@supports`, `@container` — bare with// conditions as scalars, or spelled out in the keystatic QUERIES = ['@media', '@supports', '@container']queryKind(name) {for (k of QUERIES) {if (name == k || name.startsWith(k + ' ') || name.startsWith(k + '(')) { return k }}return ''}isMediaBlock(name) { return queryKind(name) != '' }mediaLiteral(name) { return name.slice(queryKind(name).length, name.length).trim() }localName(name) { return name.startsWith('#') ? name.slice(1) : '' }selectorShaped(name) {if (name.length == 0) { return false }if ('0123456789'.indexOf(name.charAt(0)) >= 0) { return true }let i = 0while (i < name.length) {if (SELECTOR_CHARS.indexOf(name.charAt(i)) >= 0) { return true }i = i + 1}return false}// ---- one block's syntax, with its values --------------------------------------// the entries of a hybrid node: [{ key node value isBlock }] in authored order,// values read off `hybrid` (the instance's evaluated member at the same path)entriesOf(key, node, hybrid) {let out = []let n = node == 0 ? null : hlSyntax(gen, key, node)if (n == null || n.tag != 'object_expression') {// A MERGED RULE (`extR = baseR + { top = 'auto' }`, COMPONENTS §4: "Style gets it// for free") has no literal to read: its entries are the merged VALUE's, and a// nested block is read the same way. It used to read as no entries at all, so the// View wrote the class and the sheet carried no rule for it (mission 322 row 9).if (isHybridValue(hybrid)) {for (k of hybrid.keys()) { out.push({ key = k; node = 0; value = hybrid[k]; isBlock = isHybridValue(hybrid[k]); }) }}return out}for (h of n.entries) {let e = hlSyntax(gen, key, h)if (e != null && e.tag == 'object_property') {let v = hlSyntax(gen, key, e.value)let val = hybrid != null ? hybrid[e.key] : nullout.push({ key = e.key; node = e.value; value = val; isBlock = v != null && (v.tag == 'object_expression' || (v.tag == 'binary_expression' && isHybridValue(val))); })}}guardMedia(key, out)return out}// a hybrid VALUE (a rule), as opposed to a scalar declaration or a var() tokenisHybridValue(v) { return v != null && hlTypeName(v) == 'Hybrid' }// TWO BLOCKS UNDER ONE `@media` NAME (creator, 2026-09-13; measured in the social// app's styles file). This walk reads a block's STRUCTURE off the syntax but its// VALUES off the CONSTRUCTED INSTANCE, by name — and a name declared twice holds// one value, the second. Both blocks then read the second one's hybrid, so the// first comes out with no conditions and no declarations (`@media (prefers-color-scheme: )`)// and nothing in the sheet says why. Refused here rather than emitted garbled.guardMedia(key, entries) {let mediaSeen = {}for (e of entries) {if (isMediaBlock(e.key)) {if (mediaSeen[e.key] != null) {hlError(key + " declares '" + e.key + "' twice. A block's values are read off the constructed instance BY NAME, so the second binding is what BOTH blocks read and the first one comes out empty. Spell the two queries out as distinct keys — '@media (prefers-color-scheme: dark)' = { … } and '@media (min-width: 640px)' = { … } — or move the second block into a component's own Style")}mediaSeen[e.key] = true}}return null}// a value as CSS text: a string as written, a number unitless, a static already// folded, a css variable (or a string composed with one) resolved to var(--name)text(v) {return varText(v)}declLine(k, v) {if (minify) { return view.kebab(k) + ':' + text(v) + ';' }return ' ' + view.kebab(k) + ': ' + text(v) + ';' + NL}// a media condition: `minWidth = 960` → `(min-width: 960px)`, a string verbatimcondition(k, v) {if (hlTypeName(v) == 'Number') { return '(' + view.kebab(k) + ': ' + v + 'px)' }return '(' + view.kebab(k) + ': ' + v + ')'}// the whole prelude, `@media (a) and (b)`: an enclosing query of the SAME kind is// joined with `and`; one of another kind cannot be, in a sheet written flatqueryText(kind, outer, literal, conds, name) {let parts = []if (outer != '') {if (!outer.startsWith(kind + ' ')) { hlError("'" + name + "' stands inside '" + outer + "' — two kinds of query cannot be joined into one; write the block beside it, not inside") }parts.push(outer.slice(kind.length + 1, outer.length))}if (literal != '') { parts.push(literal) }for (c of conds) { parts.push(c) }let out = ''for (p of parts) { out = out == '' ? p : out + ' and ' + p }return kind + ' ' + out}// ---- where a rule lands (the archive's placesFor) -------------------------------// `f` is the file block: { key cls refs tags hasView isStyles }; refs are what the// View wrote ([{ tag rule }]), tags the element names the file's own View renders.selectorsFor(f, name) {if (f.isStyles) {if (selectorShaped(name)) { return [name] }return [view.domTag(name)]}let local = localName(name)// the View writes `id = 'x'`: `#x` is that id, nothing else (ticket #51, R3)if (local != '' && f.ids != null && f.ids.includes(local)) { return [name] }if (local != '' && !selectorShaped(local)) {let out = []for (r of f.refs) {if (r.rule == name) {let sel = view.domTag(r.tag) + '.' + view.localClass(f.key)if (!out.includes(sel)) { out.push(sel) }}}return out}if (selectorShaped(name)) { return [name] }let classes = []for (r of f.refs) {if (r.rule == name) {let cls = view.className(name, r.tag)if (!classes.includes(cls)) { classes.push(cls) }}}if (classes.length == 0) {if (f.tags.includes(name) || name == 'body' || name == 'html') { return [view.domTag(name)] }return []}let out = []for (cls of classes) {if (cls == name) { out.push('.' + view.kebab(cls)) }else { out.push(view.domTag(name.slice(0, name.length - cls.length)) + '.' + view.kebab(cls)) }}return out}// ---- emission -------------------------------------------------------------------putRule(f, name, selector, body, query) {let first = seen[query + '|' + selector]if (first != null) {if (first.body == body) { return null }hlError("style rule '" + selector + "' is emitted twice with different bodies — '" + first.rule + "' in " + first.file + " and '" + name + "' in " + f.key + " both emit that selector; put the declarations in ONE rule, or rename one of them")}seen[query + '|' + selector] = { body = body; rule = name; file = f.key; }if (query == '') {parts.push(minify ? selector + '{' + body + '}' : NL + selector + ' {' + NL + body + '}' + NL)} else {mediaPut(query, selector, body)}return null}mediaPut(query, selector, body) {if (!mediaOrder.includes(query)) { mediaOrder.push(query) }// inside a media block every line is indented two spaces (the archive's goldens)let inner = minify ? selector + '{' + body + '}' : ' ' + selector + ' {' + NL + ' ' + body.replace(NL + ' ', NL + ' ') + ' }' + NLmediaParts.push({ query = query; text = inner; })return null}// ONE RULE: its own declarations under `sels`, its nested rules composed below it// (descendant), its media blocks under the query; a container emits nothing itselfemitRule(f, name, sels, entries, query) {let body = ''let kids = []let medias = []for (e of entries) {if (e.isBlock) {if (isMediaBlock(e.key)) { medias.push(e) } else { kids.push(e) }} else if (e.value != null) {body = body + declLine(e.key, e.value)}}if (body != '') {for (sel of sels) { putRule(f, name, sel, body, query) }}for (k of kids) {if (isKeyframes(k.key)) { hlError("'" + k.key + "' inside '" + name + "' in " + f.key + " — an animation is not a rule's child: write it at the top of the Style or of the styles file") }let composed = nestedSels(f, sels, k.key)if (composed.length == 0) {dead.push(f.cls + '.' + name + ' ' + k.key)} else {emitRule(f, name + ' ' + k.key, composed, entriesOf(f.key, k.node, k.value), query)}}for (m of medias) {if (query != '') { hlError("'" + m.key + "' inside '" + name + "' is already under a query — state both conditions in ONE block") }mediaBlock(f, m.key, m.node, m.value, sels, name, '')}return null}// ONE `@media` BLOCK, at the top of a Style (prefixes empty) or inside a rule// (prefixes = the enclosing rule's selectors): scalar entries are CONDITIONS// (only for the bare `@media` spelling), block entries are RULES under the query;// inside a rule, scalar entries are the ENCLOSING RULE's declarations under it.mediaBlock(f, name, node, hybrid, prefixes, outer, outerQuery) {let literal = mediaLiteral(name)let conds = []let own = ''let rules = []let overrides = ''for (e of entriesOf(f.key, node, hybrid)) {if (e.isBlock) { rules.push(e) }// a css VARIABLE re-assigned under the query: `bg = '#000'` inside// `@media { prefersColorScheme = 'dark' … }` is `:root { --bg: #000 }` thereelse if (prefixes.length == 0 && varNames[e.key] != null) { overrides = overrides + declLine('--' + view.kebab(e.key), e.value) }else if (literal == '' && prefixes.length == 0) { conds.push(condition(e.key, e.value)) }else if (prefixes.length > 0) { own = own + declLine(e.key, e.value) }else { hlError("'" + name + "' already spells its query out, so '" + e.key + "' is a declaration directly inside an at-rule — put it in a rule inside the block") }}let q = queryText(queryKind(name), outerQuery, literal, conds, name)if (overrides != '') { putRule(f, name, ':root', overrides, q) }if (own != '') { for (sel of prefixes) { putRule(f, outer, sel, own, q) } }for (r of rules) {if (isMediaBlock(r.key)) { mediaBlock(f, r.key, r.node, r.value, prefixes, outer, q) }else if (isKeyframes(r.key)) { hlError("'" + r.key + "' inside '" + name + "' in " + f.key + " — an animation is not scoped by a query: write it at the top of the Style or of the styles file") }else {let composed = prefixes.length > 0 ? nestedSels(f, prefixes, r.key) : selectorsFor(f, r.key)if (composed.length == 0) { dead.push(f.cls + '.' + r.key + ' (in ' + name + ')') }else { emitRule(f, r.key, composed, entriesOf(f.key, r.node, r.value), q) }}}return null}// a file's rules in authored order: [{ key node value isBlock }] — a component's// Style entries, or a styles file's root members (its inherits' first)fileRules(f) {return f.rules}// ONE `'@keyframes name' = { 'from' = { … } to { … } '50%' = { … } }`: every entry is// a stop, its key the stop's selector verbatim, its entries declarationskeyframes(f, r) {let name = r.key.slice(10, r.key.length).trim()if (name == '') { hlError("'@keyframes' in " + f.key + " names no animation — write the name into the key: '@keyframes fade' = { 'from' = { opacity = 0 } to { opacity = 1 } }") }let body = ''for (st of entriesOf(f.key, r.node, r.value)) {if (!st.isBlock) { hlError("'" + st.key + "' in '" + r.key + "' (" + f.key + ") is a declaration, and an animation holds stops — 'from', to, '50%' — each a block of declarations") }let decls = ''for (d of entriesOf(f.key, st.node, st.value)) {if (d.isBlock) { hlError("'" + d.key + "' in the stop '" + st.key + "' of '" + r.key + "' (" + f.key + ") is a block — a stop holds declarations only") }if (d.value != null) { decls = decls + (minify ? declLine(d.key, d.value) : ' ' + declLine(d.key, d.value)) }}body = body + (minify ? st.key + '{' + decls + '}' : ' ' + st.key + ' {' + NL + decls + ' }' + NL)}putRule(f, r.key, '@keyframes ' + name, body, '')return null}// ---- the sheet ------------------------------------------------------------------// `files` are the blocks in cascade order: the styles file (with its inherits// walked first by the caller), then every served component.sheet(files) {parts = []seen = {}dead = []mediaOrder = []mediaParts = []rootDecls = []vars = {}varNames = {}for (f of files) { collectVars(f) }// the declared variables come first in :root, in declaration order; their dark// values open the dark-scheme query with a :root of their own (an explicit// `@media { prefersColorScheme = 'dark' … }` re-assignment lands after it and wins)let darkBody = ''for (f of files) {for (r of fileRules(f)) {if (!r.isBlock && isVar(r.value)) {rootDecls.push(declLine('--' + view.kebab(r.key), r.value.value))if (r.value.dark != null) { darkBody = darkBody + declLine('--' + view.kebab(r.key), r.value.dark) }}}}if (darkBody != '') { mediaPut(DARK, ':root', darkBody) }for (f of files) {for (r of fileRules(f)) {if (!r.isBlock) {// a scalar root member of a styles file is not a rule (a variable was taken above)} else if (isMediaBlock(r.key)) {mediaBlock(f, r.key, r.node, r.value, [], '', '')} else if (isKeyframes(r.key)) {keyframes(f, r)} else if (isRootBlock(r.key)) {for (d of entriesOf(f.key, r.node, r.value)) {if (d.isBlock) { hlError("'" + r.key + "' holds custom properties only — '" + d.key + "' is a rule; put it beside the block, not inside it") }rootDecls.push(declLine(d.key, d.value))}} else {let sels = selectorsFor(f, r.key)if (sels.length == 0) { dead.push(f.cls + '.' + r.key + ' bound to nothing (no reference, no matching element)') }else { emitRule(f, r.key, sels, entriesOf(f.key, r.node, r.value), '') }}}}let out = minify ? '' : HEADERif (rootDecls.length > 0) {let body = ''for (d of rootDecls) { body = body + d }out = out + (minify ? ':root{' + body + '}' : NL + ':root {' + NL + body + '}' + NL)}for (p of parts) { out = out + p }for (q of mediaOrder) {let inner = ''for (p of mediaParts) { if (p.query == q) { inner = inner + p.text } }out = out + (minify ? q + '{' + inner + '}' : NL + q + ' {' + NL + inner + '}' + NL)}if (dead.length > 0) {for (d of dead) { console.log('style DROPPED: ' + d) }}return out}
Branches
- mainmain branch
Latest commits
- 5cb85d75deploy.sh: a backup taken while a background job writes (tar exit 1) is a warning; archive checked with gzip -tmre
- 9abda75dtracker: report 035mre
- 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