gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitdc40d859dc40d859tracker#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/0mredc40d859/plugins/web/css.hl

20.3 KB

  1. // The framework's STYLESHEET — the Style convention of COMPONENTS §4, as the
  2. // archived hl:web implemented it (archive/hlweb-v1/plugins-web/web_css.hl), re-read
  3. // 2026-09-12 after a version built from the doc alone got most of it wrong.
  4. //
  5. // STRUCTURE FROM THE SYNTAX, VALUES FROM THE INSTANCE. A hybrid's keys come back
  6. // alphabetical, so the ORDER of rules, declarations and media conditions is read
  7. // off the syntax (hlSyntax entries are authored order) while every VALUE is read
  8. // off the constructed instance at the same path — the language has already folded
  9. // the statics (`background = dark`) and merged `base + { … }` by then.
  10. //
  11. // WHAT A RULE NAME MEANS (the archive's `placesFor`, per file):
  12. // `@root` / `':root'` a CONTRIBUTION: collected from every file into ONE
  13. // `:root { }` at the top of the sheet, in order
  14. // `'@media …'` / `@media` a BLOCK of rules: collected at the END of the sheet,
  15. // (`@supports`, the same shape and place for the other two queries
  16. // `@container`) (ticket #66, proposal 5)
  17. // one `@media` per distinct query, first-seen order;
  18. // `@media { minWidth = 960 … }` composes the query from
  19. // conditions (number → px, string verbatim)
  20. // `'@keyframes name'` an ANIMATION: its stops (`'from'`, `to`, `'50%'`) with
  21. // their declarations, emitted where it stands (ticket #66, 4)
  22. // selector-shaped verbatim (`'.icon'`, `'#page-anchor'`, `'a:hover'`)
  23. // referenced `Style.x` the class the View wrote (tag-prefix strip): `.x`, or
  24. // `tag.stripped`
  25. // `#x` id the View writes `id = 'x'` on an element: `#x` (R3)
  26. // `#x` local otherwise matched by NAME to this file's elements `x` (and
  27. // a composed child's root for `#Child`): `tag.<file class>`
  28. // own-name a rule named like an element of this file's View (or
  29. // body/html): the TAG selector, no class
  30. // anything else DEAD: dropped and listed
  31. // In a STYLES FILE (root members, no View) a bare name is a tag, kebab-cased.
  32. // NESTED under a rule, a name of PSEUDOS is that rule's pseudo-class, not a
  33. // descendant: `a { hover { … } }` → `a:hover` (ticket #66, proposal 1).
  34. // A rule with no declarations and nested rules is a CONTAINER: not emitted.
  35. // Two rules emitting one selector with different bodies: refused.
  36. import HlwCssVar from './cssvar.hl'
  37. static NL = "
  38. "
  39. static SELECTOR_CHARS = ":-.#>[]()*+~,/ %@=^|!"
  40. static HEADER = "/* GENERATED by hl:web — ONE global stylesheet.
  41. Components author their Style rules per file; only the rules a View actually
  42. REFERENCES are emitted (dead rules are dropped and listed at boot). Class names
  43. follow the tag-prefix strip rule: rule `headerMain` referenced inside `header`
  44. emits class=\"main\" under the TAG-QUALIFIED selector `header.main`; under any
  45. other tag it keeps `.headerMain`. */
  46. "
  47. Boolean minify = false
  48. view = null // the View walk: kebab, className, localClass live there
  49. gen = 0 // the analysis generation the syntax handles belong to
  50. // per sheet
  51. parts = []
  52. seen = {}
  53. dead = []
  54. mediaOrder = []
  55. mediaParts = []
  56. rootDecls = []
  57. // ---- names ----------------------------------------------------------------------
  58. // ---- css variables (creator's ruling 2026-09-13) --------------------------------
  59. // `bg = var('#FFF')` declares a custom property as a VALUE; the walker names it after
  60. // its member, writes it into the sheet's one `:root`, and rewrites every use.
  61. static counter = { n = 0 }
  62. // `var('#FFF', dark = '#000')` carries its dark-scheme value too (ticket #66,
  63. // proposal 2): the sheet writes it into `:root` under `(prefers-color-scheme: dark)`
  64. static var = (value, dark = null) => {
  65. counter.n = counter.n + 1
  66. return new HlwCssVar(value = value, id = counter.n, dark = dark)
  67. }
  68. static DARK = '@media (prefers-color-scheme: dark)'
  69. // ---- units as functions (ticket #66, proposal 3) ---------------------------------
  70. // a number is emitted unitless as written; `rem(1.5)`, `px(12)`, `pct(50)` answer the
  71. // TEXT with its unit — '1.5rem', '12px', '50%' — so a value says what it measures
  72. static rem = (n) => { return '' + n + 'rem' }
  73. static px = (n) => { return '' + n + 'px' }
  74. static pct = (n) => { return '' + n + '%' }
  75. vars = {} // id → { name value } for the sheet being built
  76. varNames = {} // name → true
  77. isVar(v) { return v != null && hlTypeName(v) == 'Instance' }
  78. // the variables a file's root members declare, named after the member
  79. collectVars(f) {
  80. for (r of fileRules(f)) {
  81. if (!r.isBlock && isVar(r.value)) {
  82. r.value.name = r.key
  83. vars['' + r.value.id] = { name = r.key; value = r.value.value; }
  84. varNames[r.key] = true
  85. }
  86. }
  87. // a token the file INHERITS: its own instance's copy, under the parent's name (ticket #39)
  88. if (f.aliases != null) {
  89. for (a of f.aliases) {
  90. a.value.name = a.key
  91. vars['' + a.value.id] = { name = a.key; value = a.value.value; }
  92. }
  93. }
  94. return null
  95. }
  96. // a value's text with every variable reference resolved: a variable itself →
  97. // var(--name); a composed string carries placeholders var(--hlv<id>) → var(--name)
  98. varText(v) {
  99. if (isVar(v)) {
  100. let d = vars['' + v.id]
  101. 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") }
  102. return 'var(--' + view.kebab(d.name) + ')'
  103. }
  104. let s = '' + v
  105. if (s.indexOf('var(--hlv') < 0) { return s }
  106. for (id of vars.keys()) { s = s.replaceAll('var(--hlv' + id + ')', 'var(--' + view.kebab(vars[id].name) + ')') }
  107. return s
  108. }
  109. // the pseudo-classes a nested entry may name, camelCase as written (kebab-cased on emit)
  110. static PSEUDOS = ['hover', 'focus', 'focusVisible', 'focusWithin', 'active', 'visited', 'target',
  111. 'disabled', 'enabled', 'checked', 'required', 'optional', 'valid', 'invalid', 'readOnly',
  112. 'placeholderShown', 'firstChild', 'lastChild', 'onlyChild', 'firstOfType', 'lastOfType', 'empty']
  113. isPseudo(name) { return PSEUDOS.includes(name) }
  114. // the selectors a nested entry composes under its enclosing rule's `sels`: a
  115. // pseudo-class sticks to each (`a:hover`), anything else is a descendant (`a span`)
  116. nestedSels(f, sels, key) {
  117. let out = []
  118. if (isPseudo(key)) {
  119. for (p of sels) { out.push(p + ':' + view.kebab(key)) }
  120. return out
  121. }
  122. let kidSels = selectorsFor(f, key)
  123. for (p of sels) { for (s of kidSels) { out.push(p + ' ' + s) } }
  124. return out
  125. }
  126. isKeyframes(name) { return name == '@keyframes' || name.startsWith('@keyframes ') }
  127. isRootBlock(name) { return name == '@root' || name == ':root' }
  128. // THE THREE QUERIES share one shape: `@media`, `@supports`, `@container` — bare with
  129. // conditions as scalars, or spelled out in the key
  130. static QUERIES = ['@media', '@supports', '@container']
  131. queryKind(name) {
  132. for (k of QUERIES) {
  133. if (name == k || name.startsWith(k + ' ') || name.startsWith(k + '(')) { return k }
  134. }
  135. return ''
  136. }
  137. isMediaBlock(name) { return queryKind(name) != '' }
  138. mediaLiteral(name) { return name.slice(queryKind(name).length, name.length).trim() }
  139. localName(name) { return name.startsWith('#') ? name.slice(1) : '' }
  140. selectorShaped(name) {
  141. if (name.length == 0) { return false }
  142. if ('0123456789'.indexOf(name.charAt(0)) >= 0) { return true }
  143. let i = 0
  144. while (i < name.length) {
  145. if (SELECTOR_CHARS.indexOf(name.charAt(i)) >= 0) { return true }
  146. i = i + 1
  147. }
  148. return false
  149. }
  150. // ---- one block's syntax, with its values --------------------------------------
  151. // the entries of a hybrid node: [{ key node value isBlock }] in authored order,
  152. // values read off `hybrid` (the instance's evaluated member at the same path)
  153. entriesOf(key, node, hybrid) {
  154. let out = []
  155. let n = node == 0 ? null : hlSyntax(gen, key, node)
  156. if (n == null || n.tag != 'object_expression') {
  157. // A MERGED RULE (`extR = baseR + { top = 'auto' }`, COMPONENTS §4: "Style gets it
  158. // for free") has no literal to read: its entries are the merged VALUE's, and a
  159. // nested block is read the same way. It used to read as no entries at all, so the
  160. // View wrote the class and the sheet carried no rule for it (mission 322 row 9).
  161. if (isHybridValue(hybrid)) {
  162. for (k of hybrid.keys()) { out.push({ key = k; node = 0; value = hybrid[k]; isBlock = isHybridValue(hybrid[k]); }) }
  163. }
  164. return out
  165. }
  166. for (h of n.entries) {
  167. let e = hlSyntax(gen, key, h)
  168. if (e != null && e.tag == 'object_property') {
  169. let v = hlSyntax(gen, key, e.value)
  170. let val = hybrid != null ? hybrid[e.key] : null
  171. out.push({ key = e.key; node = e.value; value = val; isBlock = v != null && (v.tag == 'object_expression' || (v.tag == 'binary_expression' && isHybridValue(val))); })
  172. }
  173. }
  174. guardMedia(key, out)
  175. return out
  176. }
  177. // a hybrid VALUE (a rule), as opposed to a scalar declaration or a var() token
  178. isHybridValue(v) { return v != null && hlTypeName(v) == 'Hybrid' }
  179. // TWO BLOCKS UNDER ONE `@media` NAME (creator, 2026-09-13; measured in the social
  180. // app's styles file). This walk reads a block's STRUCTURE off the syntax but its
  181. // VALUES off the CONSTRUCTED INSTANCE, by name — and a name declared twice holds
  182. // one value, the second. Both blocks then read the second one's hybrid, so the
  183. // first comes out with no conditions and no declarations (`@media (prefers-color-scheme: )`)
  184. // and nothing in the sheet says why. Refused here rather than emitted garbled.
  185. guardMedia(key, entries) {
  186. let mediaSeen = {}
  187. for (e of entries) {
  188. if (isMediaBlock(e.key)) {
  189. if (mediaSeen[e.key] != null) {
  190. 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")
  191. }
  192. mediaSeen[e.key] = true
  193. }
  194. }
  195. return null
  196. }
  197. // a value as CSS text: a string as written, a number unitless, a static already
  198. // folded, a css variable (or a string composed with one) resolved to var(--name)
  199. text(v) {
  200. return varText(v)
  201. }
  202. declLine(k, v) {
  203. if (minify) { return view.kebab(k) + ':' + text(v) + ';' }
  204. return ' ' + view.kebab(k) + ': ' + text(v) + ';' + NL
  205. }
  206. // a media condition: `minWidth = 960` → `(min-width: 960px)`, a string verbatim
  207. condition(k, v) {
  208. if (hlTypeName(v) == 'Number') { return '(' + view.kebab(k) + ': ' + v + 'px)' }
  209. return '(' + view.kebab(k) + ': ' + v + ')'
  210. }
  211. // the whole prelude, `@media (a) and (b)`: an enclosing query of the SAME kind is
  212. // joined with `and`; one of another kind cannot be, in a sheet written flat
  213. queryText(kind, outer, literal, conds, name) {
  214. let parts = []
  215. if (outer != '') {
  216. 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") }
  217. parts.push(outer.slice(kind.length + 1, outer.length))
  218. }
  219. if (literal != '') { parts.push(literal) }
  220. for (c of conds) { parts.push(c) }
  221. let out = ''
  222. for (p of parts) { out = out == '' ? p : out + ' and ' + p }
  223. return kind + ' ' + out
  224. }
  225. // ---- where a rule lands (the archive's placesFor) -------------------------------
  226. // `f` is the file block: { key cls refs tags hasView isStyles }; refs are what the
  227. // View wrote ([{ tag rule }]), tags the element names the file's own View renders.
  228. selectorsFor(f, name) {
  229. if (f.isStyles) {
  230. if (selectorShaped(name)) { return [name] }
  231. return [view.domTag(name)]
  232. }
  233. let local = localName(name)
  234. // the View writes `id = 'x'`: `#x` is that id, nothing else (ticket #51, R3)
  235. if (local != '' && f.ids != null && f.ids.includes(local)) { return [name] }
  236. if (local != '' && !selectorShaped(local)) {
  237. let out = []
  238. for (r of f.refs) {
  239. if (r.rule == name) {
  240. let sel = view.domTag(r.tag) + '.' + view.localClass(f.key)
  241. if (!out.includes(sel)) { out.push(sel) }
  242. }
  243. }
  244. return out
  245. }
  246. if (selectorShaped(name)) { return [name] }
  247. let classes = []
  248. for (r of f.refs) {
  249. if (r.rule == name) {
  250. let cls = view.className(name, r.tag)
  251. if (!classes.includes(cls)) { classes.push(cls) }
  252. }
  253. }
  254. if (classes.length == 0) {
  255. if (f.tags.includes(name) || name == 'body' || name == 'html') { return [view.domTag(name)] }
  256. return []
  257. }
  258. let out = []
  259. for (cls of classes) {
  260. if (cls == name) { out.push('.' + view.kebab(cls)) }
  261. else { out.push(view.domTag(name.slice(0, name.length - cls.length)) + '.' + view.kebab(cls)) }
  262. }
  263. return out
  264. }
  265. // ---- emission -------------------------------------------------------------------
  266. putRule(f, name, selector, body, query) {
  267. let first = seen[query + '|' + selector]
  268. if (first != null) {
  269. if (first.body == body) { return null }
  270. 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")
  271. }
  272. seen[query + '|' + selector] = { body = body; rule = name; file = f.key; }
  273. if (query == '') {
  274. parts.push(minify ? selector + '{' + body + '}' : NL + selector + ' {' + NL + body + '}' + NL)
  275. } else {
  276. mediaPut(query, selector, body)
  277. }
  278. return null
  279. }
  280. mediaPut(query, selector, body) {
  281. if (!mediaOrder.includes(query)) { mediaOrder.push(query) }
  282. // inside a media block every line is indented two spaces (the archive's goldens)
  283. let inner = minify ? selector + '{' + body + '}' : ' ' + selector + ' {' + NL + ' ' + body.replace(NL + ' ', NL + ' ') + ' }' + NL
  284. mediaParts.push({ query = query; text = inner; })
  285. return null
  286. }
  287. // ONE RULE: its own declarations under `sels`, its nested rules composed below it
  288. // (descendant), its media blocks under the query; a container emits nothing itself
  289. emitRule(f, name, sels, entries, query) {
  290. let body = ''
  291. let kids = []
  292. let medias = []
  293. for (e of entries) {
  294. if (e.isBlock) {
  295. if (isMediaBlock(e.key)) { medias.push(e) } else { kids.push(e) }
  296. } else if (e.value != null) {
  297. body = body + declLine(e.key, e.value)
  298. }
  299. }
  300. if (body != '') {
  301. for (sel of sels) { putRule(f, name, sel, body, query) }
  302. }
  303. for (k of kids) {
  304. 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") }
  305. let composed = nestedSels(f, sels, k.key)
  306. if (composed.length == 0) {
  307. dead.push(f.cls + '.' + name + ' ' + k.key)
  308. } else {
  309. emitRule(f, name + ' ' + k.key, composed, entriesOf(f.key, k.node, k.value), query)
  310. }
  311. }
  312. for (m of medias) {
  313. if (query != '') { hlError("'" + m.key + "' inside '" + name + "' is already under a query — state both conditions in ONE block") }
  314. mediaBlock(f, m.key, m.node, m.value, sels, name, '')
  315. }
  316. return null
  317. }
  318. // ONE `@media` BLOCK, at the top of a Style (prefixes empty) or inside a rule
  319. // (prefixes = the enclosing rule's selectors): scalar entries are CONDITIONS
  320. // (only for the bare `@media` spelling), block entries are RULES under the query;
  321. // inside a rule, scalar entries are the ENCLOSING RULE's declarations under it.
  322. mediaBlock(f, name, node, hybrid, prefixes, outer, outerQuery) {
  323. let literal = mediaLiteral(name)
  324. let conds = []
  325. let own = ''
  326. let rules = []
  327. let overrides = ''
  328. for (e of entriesOf(f.key, node, hybrid)) {
  329. if (e.isBlock) { rules.push(e) }
  330. // a css VARIABLE re-assigned under the query: `bg = '#000'` inside
  331. // `@media { prefersColorScheme = 'dark' … }` is `:root { --bg: #000 }` there
  332. else if (prefixes.length == 0 && varNames[e.key] != null) { overrides = overrides + declLine('--' + view.kebab(e.key), e.value) }
  333. else if (literal == '' && prefixes.length == 0) { conds.push(condition(e.key, e.value)) }
  334. else if (prefixes.length > 0) { own = own + declLine(e.key, e.value) }
  335. 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") }
  336. }
  337. let q = queryText(queryKind(name), outerQuery, literal, conds, name)
  338. if (overrides != '') { putRule(f, name, ':root', overrides, q) }
  339. if (own != '') { for (sel of prefixes) { putRule(f, outer, sel, own, q) } }
  340. for (r of rules) {
  341. if (isMediaBlock(r.key)) { mediaBlock(f, r.key, r.node, r.value, prefixes, outer, q) }
  342. 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") }
  343. else {
  344. let composed = prefixes.length > 0 ? nestedSels(f, prefixes, r.key) : selectorsFor(f, r.key)
  345. if (composed.length == 0) { dead.push(f.cls + '.' + r.key + ' (in ' + name + ')') }
  346. else { emitRule(f, r.key, composed, entriesOf(f.key, r.node, r.value), q) }
  347. }
  348. }
  349. return null
  350. }
  351. // a file's rules in authored order: [{ key node value isBlock }] — a component's
  352. // Style entries, or a styles file's root members (its inherits' first)
  353. fileRules(f) {
  354. return f.rules
  355. }
  356. // ONE `'@keyframes name' = { 'from' = { … } to { … } '50%' = { … } }`: every entry is
  357. // a stop, its key the stop's selector verbatim, its entries declarations
  358. keyframes(f, r) {
  359. let name = r.key.slice(10, r.key.length).trim()
  360. if (name == '') { hlError("'@keyframes' in " + f.key + " names no animation — write the name into the key: '@keyframes fade' = { 'from' = { opacity = 0 } to { opacity = 1 } }") }
  361. let body = ''
  362. for (st of entriesOf(f.key, r.node, r.value)) {
  363. 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") }
  364. let decls = ''
  365. for (d of entriesOf(f.key, st.node, st.value)) {
  366. if (d.isBlock) { hlError("'" + d.key + "' in the stop '" + st.key + "' of '" + r.key + "' (" + f.key + ") is a block — a stop holds declarations only") }
  367. if (d.value != null) { decls = decls + (minify ? declLine(d.key, d.value) : ' ' + declLine(d.key, d.value)) }
  368. }
  369. body = body + (minify ? st.key + '{' + decls + '}' : ' ' + st.key + ' {' + NL + decls + ' }' + NL)
  370. }
  371. putRule(f, r.key, '@keyframes ' + name, body, '')
  372. return null
  373. }
  374. // ---- the sheet ------------------------------------------------------------------
  375. // `files` are the blocks in cascade order: the styles file (with its inherits
  376. // walked first by the caller), then every served component.
  377. sheet(files) {
  378. parts = []
  379. seen = {}
  380. dead = []
  381. mediaOrder = []
  382. mediaParts = []
  383. rootDecls = []
  384. vars = {}
  385. varNames = {}
  386. for (f of files) { collectVars(f) }
  387. // the declared variables come first in :root, in declaration order; their dark
  388. // values open the dark-scheme query with a :root of their own (an explicit
  389. // `@media { prefersColorScheme = 'dark' … }` re-assignment lands after it and wins)
  390. let darkBody = ''
  391. for (f of files) {
  392. for (r of fileRules(f)) {
  393. if (!r.isBlock && isVar(r.value)) {
  394. rootDecls.push(declLine('--' + view.kebab(r.key), r.value.value))
  395. if (r.value.dark != null) { darkBody = darkBody + declLine('--' + view.kebab(r.key), r.value.dark) }
  396. }
  397. }
  398. }
  399. if (darkBody != '') { mediaPut(DARK, ':root', darkBody) }
  400. for (f of files) {
  401. for (r of fileRules(f)) {
  402. if (!r.isBlock) {
  403. // a scalar root member of a styles file is not a rule (a variable was taken above)
  404. } else if (isMediaBlock(r.key)) {
  405. mediaBlock(f, r.key, r.node, r.value, [], '', '')
  406. } else if (isKeyframes(r.key)) {
  407. keyframes(f, r)
  408. } else if (isRootBlock(r.key)) {
  409. for (d of entriesOf(f.key, r.node, r.value)) {
  410. if (d.isBlock) { hlError("'" + r.key + "' holds custom properties only — '" + d.key + "' is a rule; put it beside the block, not inside it") }
  411. rootDecls.push(declLine(d.key, d.value))
  412. }
  413. } else {
  414. let sels = selectorsFor(f, r.key)
  415. if (sels.length == 0) { dead.push(f.cls + '.' + r.key + ' bound to nothing (no reference, no matching element)') }
  416. else { emitRule(f, r.key, sels, entriesOf(f.key, r.node, r.value), '') }
  417. }
  418. }
  419. }
  420. let out = minify ? '' : HEADER
  421. if (rootDecls.length > 0) {
  422. let body = ''
  423. for (d of rootDecls) { body = body + d }
  424. out = out + (minify ? ':root{' + body + '}' : NL + ':root {' + NL + body + '}' + NL)
  425. }
  426. for (p of parts) { out = out + p }
  427. for (q of mediaOrder) {
  428. let inner = ''
  429. for (p of mediaParts) { if (p.query == q) { inner = inner + p.text } }
  430. out = out + (minify ? q + '{' + inner + '}' : NL + q + ' {' + NL + inner + '}' + NL)
  431. }
  432. if (dead.length > 0) {
  433. for (d of dead) { console.log('style DROPPED: ' + d) }
  434. }
  435. return out
  436. }

Branches

Latest commits

  • 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
  • 2667da05tracker#30 (mission 025): /my/ pages from slim cached title cards, episode rows and watch sets (after the jobs /my/series 1.8 s -> 0.06 s, /my/unwatched 4.1 -> 0.18 s); timeline page shows its name once; franchise widget under the poster/title; movies with TV leftovers (First Contact) go through the details repair; tools/count-tmdb-ids.hl; gates 327/0, 52/0, 32/0mre
  • 3909810dantcolony#40: mission references in README/STATUS/docs point to the moved missionsmre
  • e63b1d28antcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 1cda451dtracker: Hybriel master 190aa11d (#127 both shapes, GC correctness fc838894) — conductor adopts despite /my/series 2.4x after jobs (memory 8.0 → 1.7 GB boot); see reports/071mre
  • c1fa2f2etracker (mission 071): Hybriel master 190aa11d measured on the real copy vs the live binary 8590df63 — NOT adopted (after the first-start jobs /my/series 2.4x slower, /series 1.6x, RSS swings 7.4-12.2 GB; fresh it is flat at 1.7-2.2 GB and /my/unwatched faster), vendor stays 8efba065, candidate kept in .scratch/w071/vendor-190aa11d; tests/kinds.mjs: collection seed off (its TMDB request broke check 1 in 1 of 4 runs); tests/realdata-071.sh + realdata-071-bench.mjs + tools/realdata-071-table.py; README + STATUS (numbers, how to repeat); gates 325/0, 32/0, 50/0mre
  • 8081350atracker docs (mission 070): README (summary, Config HL_GC_BYTES — kept at Hybriel's default, the 256 MiB setting is taken out of docker-compose.yml again: the jobs grew to 12+ GB with it too, see STATUS), Test (three gates), Deploy (first start ~50 min: kinds then seed, restart once after collections done, memory numbers), Vendored Hybriel 8efba065 + #48 audit, Files; STATUS mission 070 entry (merges, migrated counts, lambda audit, gates, RSS old vs new, how to repeat, open points); docs/kinds.md + docs/franchises.md job order; tests/realdata-070-*.sh, tools/count-migrated.hl, tools/ref-params.py, tools/lambda-audit.pymre
  • 1ad19c8ctracker: re-vendor Hybriel master 8efba065 (#126 GC by bytes, #48 lambda parameters copy) (mission 070): bin/hybriel sha256 50361e95…, plugins core crypto data fetch fs http http1 mpackdb proc smtp time web; lambda audit: 13 lambdas change a passed record/list (11 through a local alias), no caller relies on it — unchanged; 319 read-only lambda parameters get & (no copy per call: /my/schedule 2.35 → 0.46 s, /my/unwatched 13.7 → 5.7 s on the real copy); /my/unwatched one merge sort instead of n² inserts; docker-compose HL_GC_BYTES=268435456; deploy.sh runs kinds.mjs + franchises.mjs too (default ports 8700–8710); tests/realdata-070.mjs; gates browser 325/0, kinds 32/0, franchises 50/0mre
  • 46b21389tracker (mission 070, conductor): the sync never destroys migrated data — the one-time summary step MOVES a copied summary to migratedSummary (marker summaries-moved.txt) instead of clearing it; a migrated record's first title/genres/homepage/tagline TMDB replaces → migratedTitle/migratedGenres/migratedHomepage/migratedTagline, a migrated season's/episode's title/summary → migratedTitle/migratedSummary (set once); tests/peek-shows.hl prints them; gate 325/0mre
  • 749019b0Merge t19 (tracker#19 franchises + timelines) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs/faketmdb.mjs, both sides kept; franchise/timeline pages get #20's typed heading (Franchise | …, Timeline | …), their title links via titlePath (/movies|/series|/shows); the collection seed waits for repair, kinds and credits too; franchises.mjs URLs + 2 new checks; gates browser 323/0, kinds 32/0, franchises 50/0mre
  • daf49feaMerge t20 (tracker#20 typed headings + #21 series/shows split) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/search.hl/components/search.hl/browser.mjs/faketmdb.mjs, both sides kept; clock order backfill → repair → kinds → credits; withDetailsFields stores tmdbType + kind; gates: browser.mjs URLs → /series|/movies, typed h1 selectors; kinds.mjs repair/credits off, fixture name = seed name; browser 323/0, kinds 32/0mre
  • 20e09d89Merge t18 (tracker#18 delta sync) into main (mission 070): conflicts README/STATUS/show.hl/project.hl/browser.mjs, both sides kept; pageShowOf summary = summaryOfmre
  • f83571c3tracker#18 (mission 067): daily sync by change lists — TMDB /tv|movie/changes (since the stored day, paged) + TVmaze /updates/shows → only our changed titles (followed: full step, unfollowed: light step — changed seasons, no TVmaze), full walk on first run / gap > 14 days / failed list; show record refreshed (title, tmdbSummary, tagline, status, genres …; renamed titles re-indexed); summary = the creator's own text (page: summary > tmdbSummary > tvmazeSummary), one-time clear of copied summaries (9,647 on the live copy); gate 261, tests/realdata-018*.mjs, README + STATUSmre
  • 1ed5457etracker#20 + #21 (mission 068): typed headings "<Type> | <name>" in type colours; TV titles split into Series (/series) and Shows (/shows) by TMDB type + Reality/Talk/News genres — kind stored by sync/import/adult backfill + new kind backfill (resumes), /movies/<slug>, /shows/<slug> of a series/movie → 301, /my/series + /my/shows, home 5 tiles + 3 rows, search/filmography labels; gates kinds 32 + browser 266, tests/realdata-068.mjs, tools/count-kinds.hl, docs/kinds.md, README + STATUSmre
  • f2b00674Merge t26 (tracker#26 + #28) into main (mission 062): short ids for every new person (castPersonId, guest route), guest stars stored on the title and created as people only when opened (/person/tmdb/<id>?show=<id> → 302), lean watch/follow clicks (showRow a small object, cast/crew from the slug, watches cached per user, face rows only after a season toggle); gate 311, tests/realdata-062.mjs, README + STATUSmre
  • 0553b51ftracker#19 (mission 066): franchises and timelines — tables, /franchises, /franchises/<slug>, /timelines/<slug> (Timeline | Release sort, series by last episode), the Prequel | Timeline | Sequel widget with the franchise above, the creator's editor, TMDB collection seed in the app (resumes, paced); gate tests/franchises.mjs 48/0 + browser.mjs 266/0, tests/realdata-066.mjs, docs/franchises.md, README + STATUSmre
  • fa1f9dfatracker#29 (mission 063): unwatched check muted grey outline + check (accent only on hover), watched stays solid — no code regression, the accent outline read as ticked; gate checks real checks visibly (computed style + screenshot pixel) on /my/unwatched, show, movie, /my/movies; gate 266, tests/realdata-063.mjs, README + STATUSmre
  • 10bb3f93tracker#28 (mission 061): full cast (all seasons, main cast by episodes, guest stars) + crew (created by, directed by, written by, screenplay, story, music) — stored by the details completion, the daily sync, the search import (one details request) and a background credits job (resumes, RSS limit); show page collapsed after 20 with client-side Show all; showBySlug via a slug map; gate 249, tests/realdata-028.mjs, README + STATUSmre
  • d8b12d67tracker#27 (mission 060): short ids for movies, series and persons — old 702 kept (data/old-short-ids.json), new random [a-z0-9]{5} unique across both, claimed at creation, background backfill (resumes), shown under poster/photo, /<shortId> → 301; gate 259, tests/realdata-060*, README + STATUSmre
  • 25a50bc4tracker#26 (mission 059): titles from a filmography are completed — on open (skeleton, step-wise face showComplete, no reload) and by the in-app details repair (resumes, TMDB-paced, series in parts); cast from TMDB credits; gate 231, tests/realdata-026.mjs, README + STATUSmre