gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain5cb85d75deploy.sh: a backup taken while a background job writes (tar exit 1) is a warning; archive checked with gzip -tmremain/theme/check-theme.mjs

6.9 KB

  1. #!/usr/bin/env node
  2. // theme/check-theme.mjs — THE THEME GATE HELPER an app runs: it flags what bypasses the WorldAPI theme in the app's own code.
  3. // node theme/check-theme.mjs [appDir] [--ignore <path prefix>]…
  4. // 1. HARD-CODED COLOURS: #hex, rgb()/rgba(), hsl()/hsla(), oklch(), and named colours (white, black, red …) in .hl and .css
  5. // files. Colours come from the theme's named tokens (shared/tokens.hl, `colorText`, 'var(--color-text)'). A fallback inside
  6. // `var(--token, …)` is allowed (the components of components.hybriel carry those so a copy works without the theme).
  7. // 2. DECORATIVE BORDERS: any border / borderTop … / borderColor that is not 0 / none. Allowed only where a border MEANS
  8. // something (docs/design-rules.md): on a line about details/summary (foldables), drop zones, focus or an invalid field,
  9. // or on a line that says so with the comment `// functional: <why>`.
  10. // 3. CORNER RADII: every borderRadius / borderTopLeftRadius … (css: border-radius, border-top-left-radius …) is one of the two
  11. // theme radii — radiusSmall (5px: buttons, pills, fields, tags, menu items, chips) or radiusLarge (10px: panels, modals,
  12. // popovers, dropdowns, tiles/posters), in css 'var(--radius-small)' / 'var(--radius-large)' — or 0, or '50%' for round
  13. // things (avatars, check circles). Each corner of a list counts ('radiusLarge + ' ' + radiusLarge + ' 0 0'' is fine).
  14. // Never scanned: plugins/ bin/ node_modules/ storage/ .scratch/ .git/ .sessions/ and shared/tokens.hl (the one place for raw
  15. // values). More paths to skip (e.g. a vendored component folder whose frames the theme removes): --ignore, or one prefix per
  16. // line in `.check-theme-ignore` in the app folder.
  17. // Output: one `file:line: problem — text` per finding, then `check-theme: N files, M problems`; exit 1 when M > 0.
  18. import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
  19. import { join, relative, resolve } from 'node:path';
  20. const args = process.argv.slice(2);
  21. let dir = '.';
  22. const ignore = ['plugins/', 'bin/', 'node_modules/', 'storage/', '.scratch/', '.git/', '.sessions/', 'shared/tokens.hl'];
  23. for (let i = 0; i < args.length; i++) {
  24. if (args[i] === '--ignore') ignore.push(args[++i]);
  25. else dir = args[i];
  26. }
  27. const APP = resolve(dir);
  28. const ignoreFile = join(APP, '.check-theme-ignore');
  29. if (existsSync(ignoreFile)) {
  30. for (const line of readFileSync(ignoreFile, 'utf8').split('\n')) {
  31. const p = line.replace(/#.*/, '').trim();
  32. if (p) ignore.push(p);
  33. }
  34. }
  35. function files(d) {
  36. const out = [];
  37. for (const name of readdirSync(d)) {
  38. const full = join(d, name);
  39. const rel = relative(APP, full);
  40. const st = statSync(full);
  41. if (ignore.some(p => rel === p.replace(/\/$/, '') || rel.startsWith(p.endsWith('/') ? p : p + '/') || rel === p)) continue;
  42. if (st.isDirectory()) out.push(...files(full));
  43. else if (/\.(hl|css)$/.test(name)) out.push(full);
  44. }
  45. return out;
  46. }
  47. const NAMED = 'white|black|red|green|blue|gray|grey|yellow|orange|purple|pink|brown|silver|navy|teal|maroon|olive|lime|aqua|fuchsia|cyan|magenta|gold|indigo|violet';
  48. const COLOUR = new RegExp(`(#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3}(?:[0-9a-fA-F]{2})?)?|#[0-9a-fA-F]{4})(?![0-9a-zA-Z_-])|\\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\\(`);
  49. const NAMED_RE = new RegExp(`[=:]\\s*['"]?[^'";]*\\b(${NAMED})\\b`, 'i');
  50. // a css declaration that sets a colour: hl (`color = '…'`, `background = …`) or css (`color: …`)
  51. const COLOUR_PROP = /\b(color|background(?:Color|-color)?|fill|stroke|borderColor|border-color|outlineColor|outline-color|boxShadow|box-shadow|accentColor|accent-color|caretColor|caret-color|textDecorationColor)\s*[=:]/i;
  52. const BORDER = /\b(border(?:Top|Right|Bottom|Left|Block|Inline)?(?:Start|End)?(?:Color|Width|Style)?|border(?:-(?:top|right|bottom|left|block|inline))?(?:-(?:start|end))?(?:-(?:color|width|style))?)\s*([=:])\s*(?:'([^']*)'|"([^"]*)"|([^;}\s]+))/g;
  53. const FUNCTIONAL = /details|summary|drop-zone|dropZone|\.drop|:focus|focus-visible|aria-invalid|\/\/\s*functional/i;
  54. const NO_BORDER = /^\s*(0|0px|none|0 none|transparent|inherit|initial|unset)\s*$/i;
  55. // a radius: hl `borderRadius = <quoted strings / names joined by +>`, css `border-radius: <value>`
  56. const RADIUS_NAME = 'border(?:Top|Bottom|Start|End)?(?:Left|Right|Start|End)?Radius|border(?:-(?:top|bottom|start|end)-(?:left|right|start|end))?-radius';
  57. const RADIUS_HL = new RegExp(`\\b(${RADIUS_NAME})'?\\s*=\\s*((?:'[^']*'|"[^"]*"|[\\w.$-]+)(?:\\s*\\+\\s*(?:'[^']*'|"[^"]*"|[\\w.$-]+))*)`, 'g');
  58. const RADIUS_CSS = new RegExp(`\\b(${RADIUS_NAME})\\s*:\\s*([^;}'"]+)`, 'g');
  59. const RADIUS_OK = /^(0|0px|50%|radiusSmall|radiusLarge|var\(--radius-(?:small|large)\)|inherit|initial|unset)$/;
  60. // the corner values of one radius that are not the theme's two (a token's fallback `var(--radius-small, 5px)` is fine)
  61. function badRadii(value) {
  62. const words = value.replace(/var\(\s*--radius-(small|large)\s*(?:,(?:[^()]|\([^()]*\))*)?\)/g, 'var(--radius-$1)').replace(/['"+]/g, ' ').split(/[\s/]+/);
  63. return words.filter(w => w && !RADIUS_OK.test(w));
  64. }
  65. // strip comments (whole-line `//`, trailing ` // …`, /* … */ on one line)
  66. function uncomment(line) {
  67. const s = line.replace(/\/\*.*?\*\//g, '');
  68. if (/^\s*\/\//.test(s)) return '';
  69. return s.replace(/\s\/\/\s.*$/, '');
  70. }
  71. // … and the allowed var(--x, fallback) fallbacks
  72. function code(line) {
  73. return uncomment(line).replace(/var\(\s*--[\w-]+\s*,\s*(?:[^()]|\([^()]*\))*\)/g, 'var()');
  74. }
  75. let problems = 0;
  76. const list = files(APP);
  77. for (const f of list) {
  78. const lines = readFileSync(f, 'utf8').split('\n');
  79. lines.forEach((raw, i) => {
  80. const s = code(raw);
  81. if (!s.trim()) return;
  82. const where = `${relative(APP, f)}:${i + 1}`;
  83. const m = s.match(COLOUR);
  84. if (m && !/^\s*import\b/.test(s)) { problems++; console.log(`${where}: hard-coded colour '${m[0]}' — use a theme token — ${raw.trim().slice(0, 140)}`); }
  85. else if (COLOUR_PROP.test(s)) {
  86. const n = s.match(NAMED_RE);
  87. if (n && !/currentColor|transparent/i.test(n[0].slice(-12))) { problems++; console.log(`${where}: named colour '${n[1]}' — use a theme token — ${raw.trim().slice(0, 140)}`); }
  88. }
  89. for (const b of s.matchAll(BORDER)) {
  90. const value = (b[3] ?? b[4] ?? b[5] ?? '').trim();
  91. if (b[1].toLowerCase().includes('radius')) continue;
  92. if (NO_BORDER.test(value)) continue;
  93. if (FUNCTIONAL.test(raw)) continue;
  94. problems++;
  95. console.log(`${where}: decorative border '${b[1]} ${b[2]} ${value || '…'}' — borderless unless it means something (docs/design-rules.md), else mark it '// functional: why' — ${raw.trim().slice(0, 140)}`);
  96. }
  97. const plain = uncomment(raw);
  98. for (const r of [...plain.matchAll(RADIUS_HL), ...plain.matchAll(RADIUS_CSS)]) {
  99. const bad = badRadii(r[2]);
  100. if (!bad.length) continue;
  101. problems++;
  102. console.log(`${where}: corner radius '${r[1]} ${r[2].trim()}' — only radiusSmall (5px, small items), radiusLarge (10px, panels, popups, tiles), '50%' for round things — ${raw.trim().slice(0, 140)}`);
  103. }
  104. });
  105. }
  106. console.log(`check-theme: ${list.length} files, ${problems} problems`);
  107. process.exit(problems ? 1 : 0);

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