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
- 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
- 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
- a386dc92tracker: reports 029 + 030mre
- 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
- d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
- 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
- 661c2592tracker: report 028mre
- 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
- d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
- 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
- 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre
- 186079b0tracker mission 028 (code order) 1/5 move: every root .hl except project.hl into lib/ (styles.hl into components/), import paths only; gates 342/0, 32/0, 52/0; real-copy pages identicalmre
- 4f47f181tracker: report 027mre
- dc1d4be4tracker mission 027: Hybriel master 06617221 vendored (plugin allocator fixes 3a781359 + 413f60e4); real copy RSS through first-start jobs + 400 loads flat ~2.55 GB (190aa11d 2.3 -> 5.6 GB), page times <= 1.1x; gates 342/0, 32/0, 52/0mre
- 84e1b3e1tracker: reports 025 + 026mre
- dc40d859tracker#31 (mission 026): duplicate titles merged — the 68 type+tmdbId pairs held by 157 records were the old tracker's (all migrated); merge.hl repair job (own clock, before the TMDB jobs) keeps one keeper per title (follows/watches > old short id > oldest), moves follows, watches, seasons, cast, credits, timelines, tombstones the rest (mergedInto, never deleted), slugs + short ids 301 to the keeper; stray seasons merged into their listed twin (Reacher S3 watches) or linked when watched; search import re-checks before its put; deploy.sh waits up to 90 s for 200; real copy 68 -> 0 dup ids, az5b2 follows/watches equal; gates 342/0, 32/0, 52/0mre