diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 9549b569a1..acfcb122db 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -379,6 +379,13 @@ jobs: - name: Widget tool pages run: npm run check:widget-pages && npm run test:widget-pages + # The widget cards: npm run cards:widgets draws public/widgets//card.png in black and + # white from the spec's own TITLE and DESCRIPTION, in node alone (own WOFF2 and deflate), + # so the same spec gives the same bytes. The check refuses a gallery tool with no card and + # a drawn card that lags its spec; hand-drawn cards are held to being present. + - name: Widget cards + run: npm run check:widget-cards + # The courses: specs/course/courses.t27 names them in order (FPGA at # #/course, AI numbers at #/ai-numbers), 27 lessons each over the gallery's # widgets; the generator refuses a lesson whose widget is gone, a gallery diff --git a/AGENTS.md b/AGENTS.md index 3a9114cf1f..5f299743dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,58 @@ Read the blog skill before writing content. Preserve the user's img2img triptych, mandatory hashtags, truthful limitations and relevant service offer. Never omit the work report for a small, documentation-only or automation PR. +## New course to open a topic (Queen and every bee) + +When a topic needs explaining, the answer is a course: **9 modules of 3 +lessons each, 27 lessons** (owner, 2026-10-07). One topic is one course; two topics are two +courses, chained (the last lesson opens lesson 1 of the next). Live chain: +0 t27 basics -> 1 FPGA -> 2 AI numbers. + +The recipe is a t27 module, not this prose: +[specs/course_recipe/course-27.t27](specs/course_recipe/course-27.t27) (steps, +rules, gates, cast commands; its tests hold the order). Planned courses: +[specs/course_recipe/roadmap.t27](specs/course_recipe/roadmap.t27). Read both +first; this section only points at them. + +In short, so the Queen can assign it: + +1. Copy the newest course spec in `apps/website/specs/course/` with its `-ru` + twin, rename ID / ROUTE / SHARE_PATH, register it in `courses.t27`. +2. Every lesson: one widget unique across all courses, one t27 spec that + compiles in the browser, black-and-white infographic or card. +3. Run the generators from `apps/website` (all-or-nothing): + `widgets-from-spec.mjs`, `widget-pages-from-spec.mjs`, + `course-from-spec.mjs`, `course-pages.mjs`; then the `check:course*`, + `check:widgets`, `check:onboarding` gates. +4. Casts are recorded only through `tri` commands; a lesson without a recording + says "pending", never fakes one. +5. The same PR carries its blog post (`body` English + `ruBody` Russian) and the + work report above. +6. Main moved under the PR: merge `origin/main` in, never rebase/force push. + A merged trinity PR is not live: ship through the + `release(board): ship trinity ` PR in gHashTag/999-multibots-telegraf. + +Traps found by the first course built from this section alone (trinity#1470, +full list in its "Recipe gaps"): + +- Rename **ID, ROUTE, SHARE_PATH, RU_CONTRACT and GENERATED** in the copied spec. +- Code edits the recipe needs: route, starfield root and nav entry in `src/`; + blog post registered in `src/data/blog/index.ts` and `posts.ts`. +- `gallery.t27` keeps each category as one unbroken block: insert the new + widgets inside their category, then fix the index asserts in the gallery + tests; `course-from-spec.test.mjs` asserts the course count, bump it. +- Full gate list: `check:widgets`, `test:widgets-spec`, `check:widget-pages` + (run widget-pages before widgets), `check:course`, `test:course`, + `check:course-pages`, `check:course-recipe`, `check:onboarding`, + `check:play`, `tsc --noEmit`. +- A pending lesson still needs a gallery entry; ship it as a + `public/term//` placeholder with `pending`, never a fake widget. +- Russian widget words go in `i18n/widgets.ru.json` under the contract + `specs/widgets/i18n/widgets-ru.t27` (no Cyrillic in specs); the Russian text + must keep the same digits as the English. +- Work-report format: copy the `` block of trinity#1463. +- A lesson's widget card is generated, never drawn by hand: `npm run cards:widgets` in `apps/website` draws `public/widgets//card.png` in black and white from the widget spec's TITLE and DESCRIPTION (`--id ` redraws one), and `check:widget-cards` fails on a missing or stale card. + **Version**: 2.0 **Date**: 2026-04-04 **Status**: Active diff --git a/apps/website/package.json b/apps/website/package.json index 50cc30d175..7e172c1c49 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -125,6 +125,8 @@ "gen:course-pages": "node scripts/course-pages.mjs", "check:course-pages": "node scripts/course-pages.mjs --check && node --test scripts/course-pages.test.mjs", "check:widget-pages": "node scripts/widget-pages-from-spec.mjs --check", + "cards:widgets": "node scripts/widget-cards-from-spec.mjs", + "check:widget-cards": "node scripts/widget-cards-from-spec.mjs --check && node --test scripts/widget-cards-from-spec.test.mjs", "test:widget-pages": "node --test scripts/widget-pages-from-spec.test.mjs", "check:igla-board": "node scripts/igla-board-from-spec.mjs --check && node qa/igla-board-contract.mjs", "check:roadmap-game": "node --experimental-strip-types qa/roadmap-game-contract.mjs", diff --git a/apps/website/scripts/widget-cards-from-spec.mjs b/apps/website/scripts/widget-cards-from-spec.mjs new file mode 100644 index 0000000000..b132fec360 --- /dev/null +++ b/apps/website/scripts/widget-cards-from-spec.mjs @@ -0,0 +1,714 @@ +#!/usr/bin/env node +// widget-cards-from-spec.mjs -- the share card (public/widgets//card.png) of every widget +// tool, drawn from its spec's own words, in black and white, by node alone. +// +// A widget tool's card is the picture X and Telegram show under a link to its page. Until this +// file, every card was drawn by hand in a headless browser that had to be killed afterwards. Here +// the card is a function of three inputs and nothing else: +// +// specs/widgets/.t27 TITLE, DESCRIPTION and CATEGORY (read through the real compiler) +// specs/widgets/gallery.t27 TOOL_BRAND, CATEGORY_NAMES and ORIGIN (the words around a card) +// public/fonts/*.woff2 the site's own fonts, decoded here (WOFF2 + glyf transform + gvar) +// +// No browser, no native module, no dependency outside node: the WOFF2 is unpacked with node's +// brotli, outlines are filled by a scanline rasterizer with fixed 4x vertical supersampling and +// exact horizontal coverage, and the PNG is written by a fixed-Huffman deflate in this file -- +// node's zlib is NOT used to compress, because its output depends on the zlib build and the CPU, +// and a card must be byte-identical wherever it is drawn. Same spec, same fonts: same bytes. So +// --check can redraw a card in memory and compare, and needs nothing installed. +// +// A drawn card says so: a tEXt chunk `Software` = GENERATOR. A card without it was drawn by hand +// (every card before this file); --check holds those only to being present, and --all or +// --id replaces one with a drawn card. +// +// node scripts/widget-cards-from-spec.mjs draw every gallery tool that has no card +// node scripts/widget-cards-from-spec.mjs --id draw this one (replacing a hand-drawn card) +// node scripts/widget-cards-from-spec.mjs --all draw every gallery tool +// node scripts/widget-cards-from-spec.mjs --check fail on a missing card or a stale drawn one +// +// After writing a card this re-runs widget-pages-from-spec.mjs: the page's og:image carries +// ?v=, so a new card means a new page. +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { brotliDecompressSync, inflateSync } from 'node:zlib' +import { fileURLToPath } from 'node:url' +import { SITE, constsOf, loadCompiler } from './agents-from-specs.mjs' +import { CARD, GALLERY_SPEC, SPEC_DIR, pngSize } from './widget-pages-from-spec.mjs' + +const WASM = 'public/t27/t27_compiler.wasm' +export const GENERATOR = 'widget-cards-from-spec' +export const cardOut = (id) => `public/widgets/${id}/card.png` +export const FONTS = { display: 'public/fonts/outfit-latin.woff2', mono: 'public/fonts/jetbrains-mono-latin.woff2' } + +// ------------------------------------------------------------------------------- WOFF2 -> glyphs + +const KNOWN_TAGS = ['cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ', 'VORG', 'EBDT', 'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', 'GPOS', 'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc', 'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop', 'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill'] + +/** The sfnt tables of a WOFF2 file, decompressed; glyf/loca stay in their transformed form. */ +function woff2Tables(buf) { + if (buf.toString('latin1', 0, 4) !== 'wOF2') throw new Error('not a WOFF2 file') + const numTables = buf.readUInt16BE(12) + const compressed = buf.readUInt32BE(20) + let o = 48 + const base128 = () => { + let v = 0 + for (let i = 0; i < 5; i++) { const c = buf[o++]; v = v * 128 + (c & 127); if (!(c & 128)) return v } + throw new Error('bad UIntBase128') + } + const dir = [] + for (let i = 0; i < numTables; i++) { + const flags = buf[o++] + const tag = (flags & 63) === 63 ? buf.toString('latin1', o, (o += 4)) : KNOWN_TAGS[flags & 63] + const version = flags >> 6 + const origLength = base128() + const transformed = tag === 'glyf' || tag === 'loca' ? version === 0 : version !== 0 + dir.push({ tag, transformed, length: transformed ? base128() : origLength }) + } + const data = brotliDecompressSync(buf.subarray(o, o + compressed)) + const tables = {} + let p = 0 + for (const t of dir) { tables[t.tag] = { data: data.subarray(p, p + t.length), transformed: t.transformed }; p += t.length } + return tables +} + +/** Reads the WOFF2 transformed glyf table into outlines: [{ contours: [[{x,y,on}]], components }]. */ +function transformedGlyphs(t) { + const numGlyphs = t.readUInt16BE(4) + let o = 36 + const take = (n) => { const s = t.subarray(o, o + n); o += n; return s } + const sizes = [8, 12, 16, 20, 24, 28, 32].map((at) => t.readUInt32BE(at)) + const [nContourS, nPointsS, flagS, glyphS, compositeS] = sizes.map(take) + let nc = 0, np = 0, fl = 0, gl = 0, co = 0 + const u255 = (s, at) => { + const c = s[at] + if (c === 253) return [s.readUInt16BE(at + 1), 3] + if (c === 255) return [s[at + 1] + 253, 2] + if (c === 254) return [s[at + 1] + 506, 2] + return [c, 1] + } + const sign = (f, v) => (f & 1 ? v : -v) + const glyphs = [] + for (let g = 0; g < numGlyphs; g++) { + const n = nContourS.readInt16BE(nc); nc += 2 + if (n === 0) { glyphs.push({ contours: [], components: [] }); continue } + if (n > 0) { + const counts = [] + for (let c = 0; c < n; c++) { const [v, k] = u255(nPointsS, np); np += k; counts.push(v) } + const contours = [] + let x = 0, y = 0 + for (const count of counts) { + const pts = [] + for (let i = 0; i < count; i++) { + const raw = flagS[fl++] + const on = !(raw >> 7) + const f = raw & 0x7f + const b = glyphS + let dx, dy + if (f < 10) { dx = 0; dy = sign(f, ((f & 14) << 7) + b[gl]); gl += 1 } + else if (f < 20) { dx = sign(f, (((f - 10) & 14) << 7) + b[gl]); dy = 0; gl += 1 } + else if (f < 84) { const b0 = f - 20, b1 = b[gl]; dx = sign(f, 1 + (b0 & 0x30) + (b1 >> 4)); dy = sign(f >> 1, 1 + ((b0 & 0x0c) << 2) + (b1 & 0x0f)); gl += 1 } + else if (f < 120) { const b0 = f - 84; dx = sign(f, 1 + (Math.floor(b0 / 12) << 8) + b[gl]); dy = sign(f >> 1, 1 + (((b0 % 12) >> 2) << 8) + b[gl + 1]); gl += 2 } + else if (f < 124) { const b2 = b[gl + 1]; dx = sign(f, (b[gl] << 4) + (b2 >> 4)); dy = sign(f >> 1, ((b2 & 0x0f) << 8) + b[gl + 2]); gl += 3 } + else { dx = sign(f, (b[gl] << 8) + b[gl + 1]); dy = sign(f >> 1, (b[gl + 2] << 8) + b[gl + 3]); gl += 4 } + x += dx; y += dy + pts.push({ x, y, on }) + } + contours.push(pts) + } + const [, k] = u255(glyphS, gl); gl += k // instruction length; the instructions are not needed + glyphs.push({ contours, components: [] }) + continue + } + // Composite: the records as in glyf, then (if any asks) an instruction length in the glyph stream. + const components = [] + let flags, instructions = false + do { + flags = compositeS.readUInt16BE(co) + const glyph = compositeS.readUInt16BE(co + 2) + co += 4 + let dx, dy + if (flags & 1) { dx = compositeS.readInt16BE(co); dy = compositeS.readInt16BE(co + 2); co += 4 } + else { dx = compositeS.readInt8(co); dy = compositeS.readInt8(co + 1); co += 2 } + let m = [1, 0, 0, 1] + const f2 = (at) => compositeS.readInt16BE(at) / 16384 + if (flags & 0x8) { const s = f2(co); m = [s, 0, 0, s]; co += 2 } + else if (flags & 0x40) { m = [f2(co), 0, 0, f2(co + 2)]; co += 4 } + else if (flags & 0x80) { m = [f2(co), f2(co + 2), f2(co + 4), f2(co + 6)]; co += 8 } + if (flags & 0x100) instructions = true + components.push({ glyph, dx: flags & 2 ? dx : 0, dy: flags & 2 ? dy : 0, m }) + } while (flags & 0x20) + if (instructions) { const [, k] = u255(glyphS, gl); gl += k } + glyphs.push({ contours: [], components }) + } + return glyphs +} + +/** The cmap as a Map codepoint -> glyph id (formats 4 and 12). */ +function cmapOf(t) { + const n = t.readUInt16BE(2) + let best = null + for (let i = 0; i < n; i++) { + const pid = t.readUInt16BE(4 + i * 8), eid = t.readUInt16BE(6 + i * 8), off = t.readUInt32BE(8 + i * 8) + const format = t.readUInt16BE(off) + if ((format === 12 || format === 4) && (pid === 0 || (pid === 3 && (eid === 1 || eid === 10)))) if (!best || format === 12) best = off + } + if (best === null) throw new Error('font has no Unicode cmap') + const map = new Map() + if (t.readUInt16BE(best) === 12) { + const groups = t.readUInt32BE(best + 12) + for (let g = 0; g < groups; g++) { + const at = best + 16 + g * 12, start = t.readUInt32BE(at), end = t.readUInt32BE(at + 4), gid = t.readUInt32BE(at + 8) + for (let c = start; c <= end && c < 0x10000; c++) map.set(c, gid + c - start) + } + return map + } + const segs = t.readUInt16BE(best + 6) / 2 + const ends = best + 14, starts = ends + segs * 2 + 2, deltas = starts + segs * 2, ranges = deltas + segs * 2 + for (let s = 0; s < segs; s++) { + const end = t.readUInt16BE(ends + s * 2), start = t.readUInt16BE(starts + s * 2) + const delta = t.readInt16BE(deltas + s * 2), range = t.readUInt16BE(ranges + s * 2) + for (let c = start; c <= end && c !== 0xffff; c++) { + let gid + if (range === 0) gid = (c + delta) & 0xffff + else { gid = t.readUInt16BE(ranges + s * 2 + range + (c - start) * 2); if (gid) gid = (gid + delta) & 0xffff } + if (gid) map.set(c, gid) + } + } + return map +} + +/** gvar deltas for one glyph at one normalized coordinate per axis: { dx, dy } over points + 4 phantoms. */ +function gvarDeltas(gvar, gid, coords, outline, nPoints) { + const axisCount = gvar.readUInt16BE(4) + const sharedCount = gvar.readUInt16BE(6), sharedAt = gvar.readUInt32BE(8) + const long = gvar.readUInt16BE(14) & 1 + const dataAt = gvar.readUInt32BE(16) + const offAt = (i) => (long ? gvar.readUInt32BE(20 + i * 4) : gvar.readUInt16BE(20 + i * 2) * 2) + const start = dataAt + offAt(gid), end = dataAt + offAt(gid + 1) + const total = nPoints + 4 + const dx = new Float64Array(total), dy = new Float64Array(total) + if (end <= start) return { dx, dy } + const f2 = (at) => gvar.readInt16BE(at) / 16384 + const shared = [] + for (let s = 0; s < sharedCount; s++) shared.push(Array.from({ length: axisCount }, (_, a) => f2(sharedAt + (s * axisCount + a) * 2))) + const word = gvar.readUInt16BE(start) + const count = word & 0x0fff + let serial = start + gvar.readUInt16BE(start + 2) + let h = start + 4 + const readPoints = () => { + let n = gvar[serial++] + if (n === 0) return null + if (n & 0x80) n = ((n & 0x7f) << 8) | gvar[serial++] + const pts = [] + let last = 0 + while (pts.length < n) { + const ctl = gvar[serial++] + const run = (ctl & 0x7f) + 1 + for (let r = 0; r < run && pts.length < n; r++) { + const v = ctl & 0x80 ? gvar.readUInt16BE((serial += 2) - 2) : gvar[serial++] + last += v + pts.push(last) + } + } + return pts + } + const readDeltas = (n) => { + const out = [] + while (out.length < n) { + const ctl = gvar[serial++] + const run = (ctl & 0x3f) + 1 + for (let r = 0; r < run && out.length < n; r++) { + if (ctl & 0x80) out.push(0) + else if (ctl & 0x40) { out.push(gvar.readInt16BE(serial)); serial += 2 } + else { out.push(gvar.readInt8(serial)); serial += 1 } + } + } + return out + } + const sharedPoints = word & 0x8000 ? readPoints() : undefined + for (let v = 0; v < count; v++) { + const size = gvar.readUInt16BE(h), index = gvar.readUInt16BE(h + 2) + h += 4 + let peak + if (index & 0x8000) { peak = Array.from({ length: axisCount }, (_, a) => f2(h + a * 2)); h += axisCount * 2 } + else peak = shared[index & 0x0fff] + let lo = null, hi = null + if (index & 0x4000) { + lo = Array.from({ length: axisCount }, (_, a) => f2(h + a * 2)); h += axisCount * 2 + hi = Array.from({ length: axisCount }, (_, a) => f2(h + a * 2)); h += axisCount * 2 + } + const next = serial + size + let scalar = 1 + for (let a = 0; a < axisCount; a++) { + const p = peak[a], c = coords[a] + if (p === 0) continue + if (c === 0 || (c < 0) !== (p < 0)) { scalar = 0; break } + if (lo) { + if (c < lo[a] || c > hi[a]) { scalar = 0; break } + if (c < p) scalar *= (c - lo[a]) / (p - lo[a]) + else if (c > p) scalar *= (hi[a] - c) / (hi[a] - p) + } else if (Math.abs(c) < Math.abs(p)) scalar *= c / p + else if (Math.abs(c) > Math.abs(p)) { scalar = 0; break } + } + if (scalar !== 0) { + const pts = index & 0x2000 ? readPoints() : sharedPoints + const n = pts ? pts.length : total + const xs = readDeltas(n), ys = readDeltas(n) + const tx = new Float64Array(total), ty = new Float64Array(total), touched = new Uint8Array(total) + for (let i = 0; i < n; i++) { const p = pts ? pts[i] : i; if (p < total) { tx[p] = xs[i]; ty[p] = ys[i]; touched[p] = 1 } } + if (pts && outline) interpolateUntouched(outline, tx, ty, touched) + for (let i = 0; i < total; i++) { dx[i] += tx[i] * scalar; dy[i] += ty[i] * scalar } + } + serial = next + } + return { dx, dy } +} + +/** IUP: an untouched point takes its delta from the touched points either side of it in its contour. */ +function interpolateUntouched(contours, tx, ty, touched) { + let base = 0 + for (const c of contours) { + const n = c.length + const idx = [] + for (let i = 0; i < n; i++) if (touched[base + i]) idx.push(i) + if (idx.length === 0) { base += n; continue } + if (idx.length === 1) { + for (let i = 0; i < n; i++) if (!touched[base + i]) { tx[base + i] = tx[base + idx[0]]; ty[base + i] = ty[base + idx[0]] } + base += n; continue + } + for (let k = 0; k < idx.length; k++) { + const a = idx[k], b = idx[(k + 1) % idx.length] + for (let i = (a + 1) % n; i !== b; i = (i + 1) % n) { + for (const [axis, d] of [['x', tx], ['y', ty]]) { + const pa = c[a][axis], pb = c[b][axis], pi = c[i][axis] + const da = d[base + a], db = d[base + b] + let v + if (pa === pb) v = da === db ? da : 0 + else { + const [lo, dlo, hi, dhi] = pa < pb ? [pa, da, pb, db] : [pb, db, pa, da] + v = pi <= lo ? dlo : pi >= hi ? dhi : dlo + ((pi - lo) * (dhi - dlo)) / (hi - lo) + } + d[base + i] = v + } + } + } + base += n + } +} + +/** + * A font from WOFF2 bytes, optionally at a variation instance ({ wght: 700 }). + * Returns { unitsPerEm, ascender, descender, glyph(codepoint) -> { advance, contours } }. + */ +export function loadFont(bytes, axes = {}) { + const T = woff2Tables(bytes) + if (!T.glyf?.transformed) throw new Error('expected a WOFF2 with a transformed glyf table') + const unitsPerEm = T.head.data.readUInt16BE(18) + const hhea = T.hhea.data + const ascender = hhea.readInt16BE(4), descender = hhea.readInt16BE(6) + const nMetrics = hhea.readUInt16BE(34) + const hm = T.hmtx.data + const advanceOf = T.hmtx.transformed + ? (g) => hm.readUInt16BE(1 + Math.min(g, nMetrics - 1) * 2) + : (g) => hm.readUInt16BE(Math.min(g, nMetrics - 1) * 4) + const cmap = cmapOf(T.cmap.data) + const raw = transformedGlyphs(T.glyf.data) + // Normalized axis coordinates from fvar (and avar, when the font has one). + let coords = null + if (T.fvar && T.gvar && Object.keys(axes).length) { + const fv = T.fvar.data + const at = fv.readUInt16BE(4), count = fv.readUInt16BE(8), size = fv.readUInt16BE(10) + coords = [] + for (let a = 0; a < count; a++) { + const q = at + a * size + const tag = fv.toString('latin1', q, q + 4) + const min = fv.readInt32BE(q + 4) / 65536, def = fv.readInt32BE(q + 8) / 65536, max = fv.readInt32BE(q + 12) / 65536 + const v = Math.min(max, Math.max(min, axes[tag] ?? def)) + let n = v < def ? (v - def) / (def - min) : v > def ? (v - def) / (max - def) : 0 + if (T.avar) n = avarMap(T.avar.data, a, n) + coords.push(n) + } + if (coords.every((c) => c === 0)) coords = null + } + const cache = new Map() + const outline = (gid, depth = 0) => { + if (cache.has(gid)) return cache.get(gid) + const g = raw[gid] ?? { contours: [], components: [] } + let contours = g.contours + let advance = advanceOf(gid) + if (g.components.length && depth < 8) { + contours = g.components.flatMap((c) => outline(c.glyph, depth + 1).contours.map((pts) => pts.map((p) => ({ x: c.m[0] * p.x + c.m[2] * p.y + c.dx, y: c.m[1] * p.x + c.m[3] * p.y + c.dy, on: p.on })))) + } else if (coords && g.contours.length) { + const n = g.contours.reduce((s, c) => s + c.length, 0) + const { dx, dy } = gvarDeltas(T.gvar.data, gid, coords, g.contours, n) + let i = 0 + contours = g.contours.map((c) => c.map((p) => ({ x: p.x + dx[i], y: p.y + dy[i++], on: p.on }))) + advance += dx[n + 1] - dx[n] + } else if (coords) { + const { dx } = gvarDeltas(T.gvar.data, gid, coords, [], 0) + advance += dx[1] - dx[0] + } + const out = { advance, contours } + cache.set(gid, out) + return out + } + return { + unitsPerEm, ascender, descender, + glyph: (cp) => outline(cmap.get(cp) ?? cmap.get(0x3f) ?? 0), + } +} + +function avarMap(avar, axis, n) { + let o = 8 + for (let a = 0; a < axis; a++) o += 2 + avar.readUInt16BE(o) * 4 + const count = avar.readUInt16BE(o) + const map = Array.from({ length: count }, (_, i) => [avar.readInt16BE(o + 2 + i * 4) / 16384, avar.readInt16BE(o + 4 + i * 4) / 16384]) + for (let i = 1; i < map.length; i++) { + if (n <= map[i][0]) { + const [a0, b0] = map[i - 1], [a1, b1] = map[i] + return a1 === a0 ? b0 : b0 + ((n - a0) * (b1 - b0)) / (a1 - a0) + } + } + return n +} + +// ------------------------------------------------------------------------------- rasterizer + +/** A grey canvas: luminance 0 (black) .. 1 (white), one float per pixel. */ +export function canvas(width, height, background = 0) { + return { width, height, px: new Float64Array(width * height).fill(background) } +} + +export function fillRect(cv, x, y, w, h, lum) { + for (let j = Math.max(0, y); j < Math.min(cv.height, y + h); j++) + for (let i = Math.max(0, x); i < Math.min(cv.width, x + w); i++) cv.px[j * cv.width + i] = lum +} + +const SUB = 4 // sub-scanlines per pixel row +const CURVE_STEPS = 8 // line segments per quadratic + +/** Line segments of a glyph's contours placed at (x, baseline) and scaled to `size` pixels per em. */ +function edgesOf(glyph, x, baseline, scale) { + const edges = [] + for (const c of glyph.contours) { + if (c.length < 2) continue + const P = c.map((p) => ({ x: x + p.x * scale, y: baseline - p.y * scale, on: p.on })) + // Start on an on-curve point; between two off-curve points there is an implied on-curve midpoint. + let s = P.findIndex((p) => p.on) + let pts = P + if (s < 0) { const m = { x: (P[0].x + P[1].x) / 2, y: (P[0].y + P[1].y) / 2, on: true }; pts = [m, ...P.slice(1), P[0]]; s = 0 } + const ring = [...pts.slice(s), ...pts.slice(0, s)] + let cur = ring[0] + let ctrl = null + const line = (a, b) => { if (a.y !== b.y) edges.push([a.x, a.y, b.x, b.y]) } + const quad = (a, q, b) => { + let prev = a + for (let k = 1; k <= CURVE_STEPS; k++) { + const t = k / CURVE_STEPS, u = 1 - t + const p = { x: u * u * a.x + 2 * u * t * q.x + t * t * b.x, y: u * u * a.y + 2 * u * t * q.y + t * t * b.y } + line(prev, p) + prev = p + } + } + for (let i = 1; i <= ring.length; i++) { + const p = ring[i % ring.length] + if (p.on) { if (ctrl) quad(cur, ctrl, p); else line(cur, p); cur = p; ctrl = null } + else if (ctrl) { const m = { x: (ctrl.x + p.x) / 2, y: (ctrl.y + p.y) / 2 }; quad(cur, ctrl, m); cur = m; ctrl = p } + else ctrl = p + } + if (ctrl) quad(cur, ctrl, ring[0]) + } + return edges +} + +/** Fills edges (nonzero winding) and blends `lum` into the canvas by coverage. */ +function fillEdges(cv, edges, lum) { + if (!edges.length) return + let y0 = Infinity, y1 = -Infinity, x0 = Infinity, x1 = -Infinity + for (const e of edges) { y0 = Math.min(y0, e[1], e[3]); y1 = Math.max(y1, e[1], e[3]); x0 = Math.min(x0, e[0], e[2]); x1 = Math.max(x1, e[0], e[2]) } + const top = Math.max(0, Math.floor(y0)), bottom = Math.min(cv.height - 1, Math.ceil(y1)) + const left = Math.max(0, Math.floor(x0)), right = Math.min(cv.width, Math.ceil(x1) + 1) + const row = new Float64Array(right - left + 1) + for (let py = top; py <= bottom; py++) { + row.fill(0) + for (let s = 0; s < SUB; s++) { + const yc = py + (s + 0.5) / SUB + const xs = [] + for (const [ax, ay, bx, by] of edges) { + if ((ay <= yc && yc < by) || (by <= yc && yc < ay)) xs.push([ax + ((yc - ay) * (bx - ax)) / (by - ay), by > ay ? 1 : -1]) + } + xs.sort((a, b) => a[0] - b[0] || a[1] - b[1]) + // Walk once: a span opens when the winding leaves 0 and closes when it returns. + let wind = 0 + let from = 0 + for (const [x, d] of xs) { + const was = wind + wind += d + if (was === 0 && wind !== 0) from = x + else if (was !== 0 && wind === 0) addSpan(row, from - left, x - left, 1 / SUB) + } + } + for (let i = 0; i < row.length; i++) { + const c = Math.min(1, row[i]) + if (c <= 0) continue + const at = py * cv.width + left + i + if (left + i >= cv.width) break + cv.px[at] = cv.px[at] * (1 - c) + lum * c + } + } +} + +function addSpan(row, a, b, weight) { + if (b <= a) return + a = Math.max(0, a); b = Math.min(row.length, b) + for (let i = Math.floor(a); i < Math.ceil(b); i++) row[i] += (Math.min(b, i + 1) - Math.max(a, i)) * weight +} + +export const measure = (font, text, size) => [...text].reduce((w, ch) => w + font.glyph(ch.codePointAt(0)).advance, 0) * (size / font.unitsPerEm) + +/** Draws one line of text with its baseline at y; returns the x after the last glyph. */ +export function drawText(cv, font, text, x, y, size, lum, { tracking = 0 } = {}) { + const scale = size / font.unitsPerEm + let pen = x + for (const ch of text) { + const g = font.glyph(ch.codePointAt(0)) + fillEdges(cv, edgesOf(g, pen, y, scale), lum) + pen += g.advance * scale + tracking + } + return pen +} + +/** Greedy word wrap to a pixel width. */ +export function wrap(font, text, size, width) { + const lines = [] + let cur = '' + for (const word of String(text).split(/\s+/).filter(Boolean)) { + const next = cur ? `${cur} ${word}` : word + if (cur && measure(font, next, size) > width) { lines.push(cur); cur = word } + else cur = next + } + if (cur) lines.push(cur) + return lines +} + +// ------------------------------------------------------------------------------- PNG + +const CRC = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0 }) +const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0 } +const adler32 = (buf) => { let a = 1, b = 0; for (const x of buf) { a = (a + x) % 65521; b = (b + a) % 65521 } return ((b << 16) | a) >>> 0 } + +const LBASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258] +const LEXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0] +const DBASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577] +const DEXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13] + +/** + * zlib stream of `data`: one fixed-Huffman deflate block, LZ77 matches only at distance 1 (a run) + * and at distance `stride` (the row above). Small for a card that is mostly one colour, and the + * same bytes on every machine, which a zlib build does not promise. + */ +export function deflateFixed(data, stride) { + const out = [0x78, 0x01] + let acc = 0, nbits = 0 + const bits = (v, n) => { for (let i = 0; i < n; i++) { acc |= ((v >> i) & 1) << nbits; if (++nbits === 8) { out.push(acc); acc = 0; nbits = 0 } } } + const huff = (code, len) => { for (let i = len - 1; i >= 0; i--) bits((code >> i) & 1, 1) } + const lit = (s) => (s < 144 ? huff(0x30 + s, 8) : s < 256 ? huff(0x190 + s - 144, 9) : s < 280 ? huff(s - 256, 7) : huff(0xc0 + s - 280, 8)) + const last = (table, v) => { let i = table.length - 1; while (table[i] > v) i--; return i } + bits(1, 1); bits(1, 2) + const n = data.length + for (let i = 0; i < n;) { + let best = 0, dist = 0 + for (const d of [1, stride]) { + if (d > i) continue + let l = 0 + while (l < 258 && i + l < n && data[i + l] === data[i + l - d]) l++ + if (l > best) { best = l; dist = d } + } + if (best >= 3) { + const li = last(LBASE, best) + lit(257 + li); bits(best - LBASE[li], LEXTRA[li]) + const di = last(DBASE, dist) + huff(di, 5); bits(dist - DBASE[di], DEXTRA[di]) + i += best + } else lit(data[i++]) + } + lit(256) + if (nbits) out.push(acc) + const a = adler32(data) + out.push(a >>> 24, (a >>> 16) & 255, (a >>> 8) & 255, a & 255) + return Buffer.from(out) +} + +const chunk = (type, body) => { + const head = Buffer.alloc(8) + head.writeUInt32BE(body.length, 0) + head.write(type, 4, 'latin1') + const crc = Buffer.alloc(4) + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), body])), 0) + return Buffer.concat([head, body, crc]) +} + +/** An 8-bit greyscale PNG of a canvas, with tEXt chunks. */ +export function encodePng(cv, text = {}) { + const { width, height, px } = cv + const raw = Buffer.alloc((width + 1) * height) + for (let y = 0; y < height; y++) { + raw[y * (width + 1)] = 0 + for (let x = 0; x < width; x++) raw[y * (width + 1) + 1 + x] = Math.max(0, Math.min(255, Math.round(px[y * width + x] * 255))) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4) + ihdr[8] = 8; ihdr[9] = 0; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0 + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + ...Object.entries(text).map(([k, v]) => chunk('tEXt', Buffer.from(`${k}\0${v}`, 'latin1'))), + chunk('IDAT', deflateFixed(raw, width + 1)), + chunk('IEND', Buffer.alloc(0)), + ]) +} + +/** The tEXt chunks of a PNG, as an object. */ +export function pngText(bytes) { + const out = {} + for (let o = 8; o + 8 <= bytes.length;) { + const len = bytes.readUInt32BE(o), type = bytes.toString('latin1', o + 4, o + 8) + if (type === 'tEXt') { const s = bytes.toString('latin1', o + 8, o + 8 + len); const z = s.indexOf('\0'); out[s.slice(0, z)] = s.slice(z + 1) } + if (type === 'IEND') break + o += 12 + len + } + return out +} + +/** The pixels of a PNG this file wrote (8-bit grey, filter 0), for tests. */ +export function decodeOwnPng(bytes) { + const { width, height } = pngSize(bytes) + let o = 8 + const idat = [] + while (o < bytes.length) { + const len = bytes.readUInt32BE(o), type = bytes.toString('latin1', o + 4, o + 8) + if (type === 'IDAT') idat.push(bytes.subarray(o + 8, o + 8 + len)) + o += 12 + len + } + return { width, height, raw: inflateSync(Buffer.concat(idat)) } +} + +// ------------------------------------------------------------------------------- the card + +const WHITE = 1, GREY = 0.62, RULE = 0.28 +const M = 64 // margin + +/** The card for one tool: its words, the gallery's words around them, the fonts. Returns PNG bytes. */ +export function drawCard({ tool, gallery, fonts }) { + const { width, height } = CARD + const cv = canvas(width, height, 0) + // Frame and rule. + fillRect(cv, 16, 16, width - 32, 1, RULE); fillRect(cv, 16, height - 17, width - 32, 1, RULE) + fillRect(cv, 16, 16, 1, height - 32, RULE); fillRect(cv, width - 17, 16, 1, height - 32, RULE) + fillRect(cv, M, height - 108, width - 2 * M, 1, RULE) + // Kicker: brand / category, in the mono face, spaced. + const cat = gallery.CATEGORY_NAMES[gallery.CATEGORY_IDS.indexOf(tool.CATEGORY)] ?? tool.CATEGORY + drawText(cv, fonts.mono, `${gallery.TOOL_BRAND} / ${cat}`.toUpperCase(), M, 76, 22, WHITE, { tracking: 2 }) + // Title, as large as fits in three lines; the line under it, as large as fits in the rest. + const textW = width - 2 * M + const bodyTop = 112, bodyBottom = height - 140 + let layout = null + for (const [ts, ds] of [[76, 30], [68, 30], [62, 28], [56, 28], [50, 26], [46, 24], [42, 22]]) { + const tl = wrap(fonts.display, tool.TITLE, ts, textW) + const dl = wrap(fonts.mono, tool.DESCRIPTION, ds, textW) + const h = tl.length * ts * 1.08 + 28 + dl.length * ds * 1.38 // a little over the real height: room to spare + layout = { ts, ds, tl, dl } + if (tl.length <= 3 && bodyTop + h <= bodyBottom) break + } + let y = bodyTop + layout.ts * 0.92 + for (const [i, l] of layout.tl.entries()) { if (i) y += layout.ts * 1.08; drawText(cv, fonts.display, l, M, Math.round(y), layout.ts, WHITE) } + y += layout.ts * 0.25 + 28 + layout.ds * 0.9 + for (const l of layout.dl) { if (y > bodyBottom + layout.ds) break; drawText(cv, fonts.mono, l, M, Math.round(y), layout.ds, GREY); y += layout.ds * 1.38 } + // Foot: where the widget lives, and the spec it was drawn from. + const url = `${gallery.ORIGIN.replace(/^https?:\/\//, '')}widgets/${tool.ID}` + drawText(cv, fonts.mono, url, M, height - 58, 26, WHITE) + const src = `${SPEC_DIR}/${tool.ID}.t27` + drawText(cv, fonts.mono, src, width - M - measure(fonts.mono, src, 18), height - 60, 18, GREY) + return encodePng(cv, { Software: GENERATOR, Source: src }) +} + +/** The fonts a card is drawn in, from the site's own files. */ +export function loadFonts(root = SITE) { + return { + display: loadFont(readFileSync(join(root, FONTS.display)), { wght: 700 }), + mono: loadFont(readFileSync(join(root, FONTS.mono)), { wght: 500 }), + } +} + +// ------------------------------------------------------------------------------- the gallery + +const valuesOf = (analysis) => Object.fromEntries(Object.entries(constsOf(analysis)).map(([k, v]) => [k, v.value])) + +/** Every tool widget the gallery lists, with its spec's words: [{ id, tool }] plus the gallery words. */ +export function galleryTools(analyze, root = SITE) { + const gallery = valuesOf(analyze(readFileSync(join(root, GALLERY_SPEC), 'utf8'))) + const problems = [] + const tools = [] + gallery.WIDGET_IDS.forEach((id, i) => { + if (gallery.WIDGET_KINDS[i] !== 'tool') return + const file = `${SPEC_DIR}/${id}.t27` + if (!existsSync(join(root, file))) { problems.push(`${GALLERY_SPEC}: tool ${id} has no ${file}`); return } + const tool = valuesOf(analyze(readFileSync(join(root, file), 'utf8'))) + for (const k of ['ID', 'TITLE', 'DESCRIPTION', 'CATEGORY']) if (typeof tool[k] !== 'string' || !tool[k].trim()) problems.push(`${file}: ${k} is missing or empty`) + tools.push({ id, tool }) + }) + return { gallery, tools, problems } +} + +/** What --check says about one card: null when it is fine. */ +export function cardProblem({ id, tool }, gallery, fonts, root = SITE) { + const path = join(root, cardOut(id)) + if (!existsSync(path)) return `${cardOut(id)} is missing; run npm run cards:widgets` + const bytes = readFileSync(path) + const size = pngSize(bytes) + if (!size || size.width !== CARD.width || size.height !== CARD.height) return `${cardOut(id)} must be a ${CARD.width}x${CARD.height} PNG` + if (pngText(bytes).Software !== GENERATOR) return null // drawn by hand: held to being present only + if (!drawCard({ tool, gallery, fonts }).equals(bytes)) return `${cardOut(id)} is stale: specs/widgets/${id}.t27 or the gallery says something else now; run npm run cards:widgets -- --id ${id}` + return null +} + +async function main() { + const args = process.argv.slice(2) + const check = args.includes('--check') + const all = args.includes('--all') + const idAt = args.indexOf('--id') + const only = idAt >= 0 ? args[idAt + 1] : null + const analyze = await loadCompiler(readFileSync(join(SITE, WASM))) + const { gallery, tools, problems } = galleryTools(analyze) + if (only && !tools.some((t) => t.id === only)) problems.push(`--id ${only}: no tool widget of that id in ${GALLERY_SPEC}`) + if (problems.length) { for (const p of problems) console.error(' ' + p); process.exit(1) } + const fonts = loadFonts() + if (check) { + const bad = tools.map((t) => cardProblem(t, gallery, fonts)).filter(Boolean) + if (bad.length) { + console.error(`${GENERATOR} --check: ${bad.length} problem(s)`) + for (const p of bad) console.error(' ' + p) + process.exit(1) + } + const drawn = tools.filter((t) => pngText(readFileSync(join(SITE, cardOut(t.id)))).Software === GENERATOR).length + console.log(`${GENERATOR}: up to date; ${tools.length} tool card(s), ${drawn} drawn from spec and current, ${tools.length - drawn} drawn by hand and present`) + return + } + const targets = tools.filter((t) => (only ? t.id === only : all || !existsSync(join(SITE, cardOut(t.id))))) + const written = [] + for (const t of targets) { + const png = drawCard({ tool: t.tool, gallery, fonts }) + const path = join(SITE, cardOut(t.id)) + if (existsSync(path) && readFileSync(path).equals(png)) continue + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, png) + written.push(`${cardOut(t.id)} (${png.length} bytes)`) + } + console.log(`${GENERATOR}: drew ${written.length} card(s)${written.length ? `: ${written.join(', ')}` : ''}; ${tools.length} tool widget(s) in the gallery`) + if (written.length) execFileSync(process.execPath, [join(SITE, 'scripts/widget-pages-from-spec.mjs')], { cwd: SITE, stdio: 'inherit' }) +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((e) => { console.error(e); process.exit(1) }) +} diff --git a/apps/website/scripts/widget-cards-from-spec.test.mjs b/apps/website/scripts/widget-cards-from-spec.test.mjs new file mode 100644 index 0000000000..2daae3539b --- /dev/null +++ b/apps/website/scripts/widget-cards-from-spec.test.mjs @@ -0,0 +1,72 @@ +// Run: node --test scripts/widget-cards-from-spec.test.mjs +// Holds the widget card generator to what --check relies on: its PNG is a real PNG, the same spec +// gives the same bytes, and a changed word gives a card --check calls stale. +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, cpSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { inflateSync } from 'node:zlib' +import { SITE, loadCompiler } from './agents-from-specs.mjs' +import { GENERATOR, cardOut, cardProblem, decodeOwnPng, deflateFixed, drawCard, galleryTools, loadFonts, measure, pngText } from './widget-cards-from-spec.mjs' +import { CARD, pngSize } from './widget-pages-from-spec.mjs' + +const analyze = await loadCompiler(readFileSync(join(SITE, 'public/t27/t27_compiler.wasm'))) +const { gallery, tools, problems } = galleryTools(analyze) +const fonts = loadFonts() +const first = tools[0] + +test('the gallery names tool widgets whose specs carry the words a card is drawn from', () => { + assert.deepEqual(problems, []) + assert.ok(tools.length > 0) +}) + +test('the deflate stream inflates back to its input, runs and repeated rows included', () => { + const stride = 41 + const data = Buffer.alloc(stride * 60) + for (let i = 0; i < data.length; i++) data[i] = i % stride === 0 ? 0 : (i * 7) % 300 < 150 ? 0 : (i * 13) & 255 + for (let r = 10; r < 20; r++) data.copy(data, r * stride, 9 * stride, 10 * stride) + data.fill(255, 600, 600 + 700) + assert.deepEqual(inflateSync(deflateFixed(data, stride)), data) + assert.deepEqual(inflateSync(deflateFixed(Buffer.alloc(0), 1)), Buffer.alloc(0)) +}) + +test('a card is a 1200x630 greyscale PNG under the size limit that names its generator and spec', () => { + const png = drawCard({ tool: first.tool, gallery, fonts }) + assert.deepEqual(pngSize(png), { width: CARD.width, height: CARD.height }) + assert.ok(png.length < CARD.maxBytes) + assert.equal(pngText(png).Software, GENERATOR) + assert.equal(pngText(png).Source, `specs/widgets/${first.id}.t27`) + const { raw } = decodeOwnPng(png) + assert.equal(raw.length, (CARD.width + 1) * CARD.height) + const ink = raw.filter((b, i) => i % (CARD.width + 1) !== 0 && b > 128).length + assert.ok(ink > 5000, `the card has text on it (${ink} light pixels)`) +}) + +test('the same spec draws the same bytes', () => { + const a = drawCard({ tool: first.tool, gallery, fonts }) + const b = drawCard({ tool: first.tool, gallery, fonts: loadFonts() }) + assert.ok(a.equals(b)) +}) + +test('the fonts decode: bold is wider than regular, and every printable ASCII glyph has an advance', () => { + assert.ok(measure(fonts.display, 'Widget', 100) > 200) + for (let c = 33; c < 127; c++) assert.ok(fonts.mono.glyph(c).advance > 0, `mono glyph ${c}`) + assert.equal(fonts.mono.glyph(65).advance, fonts.mono.glyph(105).advance, 'mono is monospaced') +}) + +test('--check: a missing card and a drawn card whose title changed are problems; a current one is not', () => { + const root = mkdtempSync(join(tmpdir(), 'widget-cards-')) + const out = join(root, cardOut(first.id)) + assert.match(cardProblem(first, gallery, fonts, root), /is missing/) + mkdirSync(join(root, `public/widgets/${first.id}`), { recursive: true }) + writeFileSync(out, drawCard({ tool: first.tool, gallery, fonts })) + assert.equal(cardProblem(first, gallery, fonts, root), null) + const renamed = { ...first, tool: { ...first.tool, TITLE: `${first.tool.TITLE} again` } } + assert.match(cardProblem(renamed, gallery, fonts, root), /is stale/) + // A hand-drawn card (no generator mark) is held to being present and the right size only. + cpSync(join(SITE, cardOut(first.id)), out) + if (pngText(readFileSync(out)).Software !== GENERATOR) assert.equal(cardProblem(renamed, gallery, fonts, root), null) + writeFileSync(out, Buffer.from('not a png')) + assert.match(cardProblem(first, gallery, fonts, root), /must be a 1200x630 PNG/) +}) diff --git a/apps/website/src/data/blog/bodies/a-widget-card-drawn-from-its-spec.ts b/apps/website/src/data/blog/bodies/a-widget-card-drawn-from-its-spec.ts new file mode 100644 index 0000000000..224b0b3cfd --- /dev/null +++ b/apps/website/src/data/blog/bodies/a-widget-card-drawn-from-its-spec.ts @@ -0,0 +1,97 @@ +import type { Block } from '../types' + +// Numbers here come from apps/website/scripts/widget-cards-from-spec.mjs and its test file, run on +// 2026-10-07 on one Mac (node 22): the 27 tool widgets of specs/widgets/gallery.t27, the card of +// gatle drawn twice with the same sha-256, and the two negative controls described in the post. + +export const body: Block[] = [ + { + kind: 'p', + text: 'Every widget tool on t27.ai has a share card: the 1200 x 630 picture X and Telegram show under a link to its page. All 27 of them were drawn by hand, the last batch in a headless browser that does not exit on its own. Nothing checked that a card still said what its spec says. Now `npm run cards:widgets` draws the card from the widget spec\'s own words, in black and white, with node and nothing else, and `check:widget-cards` fails when a gallery widget has no card or a drawn card no longer matches its spec.', + }, + { kind: 'h', text: 'What the card is made of' }, + { + kind: 'ul', + items: [ + 'The title and the line under it are TITLE and DESCRIPTION from `specs/widgets/.t27`, read through the same t27 compiler the site ships.', + 'The words around them, the brand and the category name, come from `specs/widgets/gallery.t27`. The footer names the widget\'s address and the spec file it was drawn from.', + 'The letters are the site\'s own fonts, decoded from WOFF2 in the script: Outfit at weight 700 for the title, JetBrains Mono for the rest.', + ], + }, + { kind: 'h', text: 'Why no browser, and no image library' }, + { + kind: 'p', + text: 'A check that redraws a card and compares it byte for byte needs a card that comes out the same everywhere. A browser screenshot does not promise that, and the site has no image library in its dependencies. So the script does the whole job itself: it unpacks the fonts with node\'s brotli, applies the font\'s weight variation to get bold, fills the outlines with a scanline rasterizer at four sub-rows per pixel, and writes the PNG with its own deflate. It does not use node\'s zlib to compress, because zlib\'s output may differ between builds. A card is about 45 KB and takes one to two seconds.', + }, + { + kind: 'p', + text: 'A drawn card carries a text chunk naming its generator and its spec. The check redraws only those cards. A card without the mark was drawn by hand; the check holds it to being present and 1200 x 630, and `--id ` replaces it with a drawn one.', + }, + { kind: 'h', text: 'The proof' }, + { + kind: 'ol', + items: [ + 'Delete `public/widgets/gatle/card.png`: the check fails with rc 1 and names the missing card.', + 'Run `npm run cards:widgets`: it draws that one card (46,566 bytes), rewrites the widget pages whose card link carries the card\'s hash, and the check passes.', + 'Draw it again: zero cards written, and the sha-256 is the same, 3850b6cd...', + 'Change gatle\'s TITLE from "today\'s t27 module" to "today\'s t27 spec": the check fails and calls the card stale. Redraw it with `--id gatle`, and the check passes.', + 'Restore the spec, the card and the pages with git. The 27 cards on main are the hand-drawn ones; this change replaces none of them.', + ], + }, + { kind: 'h', text: 'What it does not do yet' }, + { + kind: 'ul', + items: [ + 'It was run on one machine. The arithmetic is plain IEEE doubles and no zlib, so the bytes should match elsewhere. The CI run of the check is the first test of that.', + 'No kerning and no hinting; text is wrapped word by word on glyph widths.', + 'The fonts are the Latin subsets: a Russian title would come out as question marks, so the cards are English only.', + 'Only the 27 widget tools (`public/widgets//`) are covered. Lesson cards, player cards and recording cards keep their own generators.', + ], + }, +] + +export const ruBody: Block[] = [ + { + kind: 'p', + text: 'У каждого инструмента-виджета на t27.ai есть карточка для ссылки: картинка 1200 x 630, которую X и Telegram показывают под ссылкой на его страницу. Все 27 нарисованы вручную, последняя партия — в безголовом браузере, который сам не завершается. Совпадает ли карточка с тем, что написано в спеке, никто не проверял. Теперь `npm run cards:widgets` рисует карточку из слов самой спеки виджета, чёрным по белому, одним node, без сторонних библиотек. А `check:widget-cards` падает, если у виджета из галереи нет карточки или нарисованная карточка разошлась со спекой.', + }, + { kind: 'h', text: 'Из чего состоит карточка' }, + { + kind: 'ul', + items: [ + 'Заголовок и строка под ним — это TITLE и DESCRIPTION из `specs/widgets/.t27`, прочитанные тем же компилятором t27, что работает на сайте.', + 'Слова вокруг них — бренд и название категории — берутся из `specs/widgets/gallery.t27`. Внизу — адрес виджета и файл спеки, из которой нарисована карточка.', + 'Буквы — собственные шрифты сайта, которые скрипт сам распаковывает из WOFF2: Outfit с насыщенностью 700 для заголовка, JetBrains Mono для остального.', + ], + }, + { kind: 'h', text: 'Почему без браузера и без графической библиотеки' }, + { + kind: 'p', + text: 'Проверка, которая перерисовывает карточку и сравнивает её побайтно, требует, чтобы карточка везде получалась одинаковой. Снимок браузера этого не обещает, а графической библиотеки среди зависимостей сайта нет. Поэтому скрипт делает всю работу сам. Он распаковывает шрифты встроенным в node brotli и применяет вариацию насыщенности шрифта, чтобы получить жирное начертание. Контуры он заливает построчно, по четыре подстроки на пиксель, и записывает PNG своим deflate. Встроенный zlib из node для сжатия не используется: его вывод может отличаться от сборки к сборке. Карточка весит около 45 КБ и рисуется за одну-две секунды.', + }, + { + kind: 'p', + text: 'В нарисованной карточке есть текстовый блок с именем генератора и спеки. Перерисовывает проверка только такие карточки. Карточка без этой метки нарисована вручную: от неё проверка требует только наличия и размера 1200 x 630, а `--id ` заменяет её нарисованной.', + }, + { kind: 'h', text: 'Доказательство' }, + { + kind: 'ol', + items: [ + 'Удаляем `public/widgets/gatle/card.png` — проверка падает с кодом 1 и называет недостающую карточку.', + 'Запускаем `npm run cards:widgets` — он рисует одну эту карточку (46 566 байт), переписывает страницы виджетов, у которых в ссылке на карточку стоит её хэш, и проверка проходит.', + 'Рисуем ещё раз — записано ноль карточек, sha-256 тот же, 3850b6cd...', + 'Меняем TITLE у gatle с "today\'s t27 module" на "today\'s t27 spec" — проверка падает и называет карточку устаревшей. Перерисовываем с `--id gatle` — проверка проходит.', + 'Возвращаем спеку, карточку и страницы через git. В main остаются 27 карточек, нарисованных вручную; это изменение ни одну из них не заменяет.', + ], + }, + { kind: 'h', text: 'Чего пока нет' }, + { + kind: 'ul', + items: [ + 'Запуск был на одной машине. Вся арифметика — обычные числа IEEE двойной точности, и zlib не используется, так что на других машинах байты должны совпасть. Первой проверкой этого станет запуск в CI.', + 'Нет кернинга и хинтинга; текст переносится по словам, по ширине глифов.', + 'Шрифты — латинские подмножества: русский заголовок вышел бы вопросительными знаками, поэтому карточки только английские.', + 'Покрыты только 27 инструментов-виджетов (`public/widgets//`). У карточек уроков, плееров и записей остаются свои генераторы.', + ], + }, +] diff --git a/apps/website/src/data/blog/bodies/the-queen-knows-how-to-build-a-course.ts b/apps/website/src/data/blog/bodies/the-queen-knows-how-to-build-a-course.ts new file mode 100644 index 0000000000..fb783c7de0 --- /dev/null +++ b/apps/website/src/data/blog/bodies/the-queen-knows-how-to-build-a-course.ts @@ -0,0 +1,19 @@ +import type { Block } from '../types' + +export const body: Block[] = [ + { "kind": "h", "text": "What changed" }, + { "kind": "p", "text": "The recipe for a t27 course used to live in one agent's private skill folder, where the Queen and her bees could not read it. It now lives in the repository as two t27 modules: specs/course_recipe/course-27.t27 (the steps, rules, gates and cast commands, with tests that hold their order) and specs/course_recipe/roadmap.t27 (the planned courses). AGENTS.md gains a short section that points at them, so any agent asked to open a topic knows the shape: 9 modules of 3 lessons, one topic per course, courses chained." }, + { "kind": "h", "text": "What is checked" }, + { "kind": "p", "text": "Both modules compile clean in the site's own wasm compiler: course-27.t27 has 7 tests with 26 asserts, roadmap.t27 has 4 tests with 30 asserts. Nothing yet runs them in CI; that is the next gap." }, + { "kind": "h", "text": "Limits" }, + { "kind": "p", "text": "A recipe is not a course. No new lessons ship with this PR, and the casts of the existing courses are still pending where the post of each course says so." } +] + +export const ruBody: Block[] = [ + { "kind": "h", "text": "Что изменилось" }, + { "kind": "p", "text": "Рецепт курса t27 жил в личной папке навыков одного агента, куда Королева и её пчёлы не могли заглянуть. Теперь он лежит в репозитории двумя модулями t27: specs/course_recipe/course-27.t27 (шаги, правила, шлюзы и команды записи, а тесты держат их порядок) и specs/course_recipe/roadmap.t27 (план курсов). В AGENTS.md появился короткий раздел со ссылкой на них, так что любой агент, которому поручили раскрыть тему, знает форму: 9 модулей по 3 урока, одна тема — один курс, курсы идут цепочкой." }, + { "kind": "h", "text": "Что проверено" }, + { "kind": "p", "text": "Оба модуля чисто собираются компилятором сайта на wasm: в course-27.t27 7 тестов и 26 проверок, в roadmap.t27 4 теста и 30 проверок. В CI их пока ничто не запускает — это следующая дыра." }, + { "kind": "h", "text": "Ограничения" }, + { "kind": "p", "text": "Рецепт — ещё не курс. Новых уроков в этом PR нет, а записи существующих курсов по-прежнему ждут там, где об этом сказано в посте каждого курса." } +] diff --git a/apps/website/src/data/blog/index.ts b/apps/website/src/data/blog/index.ts index 36fe7f4b98..937d353b8c 100644 --- a/apps/website/src/data/blog/index.ts +++ b/apps/website/src/data/blog/index.ts @@ -116,6 +116,37 @@ export const postsIndex: PostMeta[] = [ ], }, }, + { + slug: "a-widget-card-drawn-from-its-spec", + title: "A widget's share card, drawn from its spec", + summary: "[run on one machine; none of the 27 hand-drawn cards replaced; Latin fonts only, no kerning; widget tools only] Every widget tool on t27.ai had a share card drawn by hand, and nothing checked that it still matched its spec. npm run cards:widgets now draws the card in black and white from the spec's TITLE and DESCRIPTION, with node alone: its own WOFF2 decoder, rasterizer and deflate, so the same spec gives the same bytes. check:widget-cards fails on a missing card and on a drawn card that lags its spec.", + date: "2026-10-07", + readingMinutes: 4, + tags: ["t27", "Widgets", "Generated assets"], + receipts: [ + { label: "The script: apps/website/scripts/widget-cards-from-spec.mjs", href: "https://github.com/gHashTag/trinity/blob/feat/widget-cards-from-spec/apps/website/scripts/widget-cards-from-spec.mjs" }, + { label: "The gallery the cards are drawn for: specs/widgets/gallery.t27", href: "https://github.com/gHashTag/trinity/blob/main/apps/website/specs/widgets/gallery.t27" }, + { label: "Gatle, the widget used in the proof", href: "https://t27.ai/widgets/gatle/" }, + { label: "trinity#1471: the generator, the check and this post", href: "https://github.com/gHashTag/trinity/pull/1471" }, + { label: "W3C WOFF File Format 2.0, the font container the script decodes", href: "https://www.w3.org/TR/WOFF2/" }, + { label: "RFC 1951, DEFLATE, the fixed Huffman block the PNG is written with", href: "https://www.rfc-editor.org/rfc/rfc1951" }, + ], + openQuestions: [ + "It was run on one Mac with node 22; that the bytes match on another machine is argued from the arithmetic, not yet measured. The CI run of the check is the first test.", + "None of the 27 cards on main was replaced: they are hand-drawn and the check holds them to being present only, so a stale hand-drawn card still passes.", + "The fonts are the Latin subsets, so a title in Russian would come out as question marks; there is no kerning and no hinting.", + ], + published: true, + ru: { + title: "Карточка виджета, нарисованная по его спеке", + summary: "[запуск на одной машине; ни одна из 27 нарисованных вручную карточек не заменена; только латинские шрифты, без кернинга; только инструменты-виджеты] У каждого инструмента-виджета на t27.ai была карточка для ссылки, нарисованная вручную, и никто не проверял, совпадает ли она со спекой. Теперь npm run cards:widgets рисует её чёрным по белому из TITLE и DESCRIPTION спеки, одним node: свой декодер WOFF2, своя растеризация и свой deflate, поэтому одна и та же спека даёт одни и те же байты. check:widget-cards падает, если карточки нет или нарисованная карточка разошлась со спекой.", + openQuestions: [ + "Запуск был на одном Mac с node 22. Что на другой машине байты совпадут, следует из арифметики, но ещё не измерено. Первой проверкой станет запуск в CI.", + "Ни одна из 27 карточек в main не заменена: они нарисованы вручную, и проверка требует от них только наличия, поэтому устаревшая ручная карточка всё ещё проходит.", + "Шрифты — латинские подмножества, поэтому русский заголовок вышел бы вопросительными знаками; кернинга и хинтинга нет.", + ], + }, + }, { slug: "nine-modules-of-three-again", title: "Every t27 course is 9 modules of 3 lessons again", @@ -253,6 +284,29 @@ export const postsIndex: PostMeta[] = [ ], }, }, + { + slug: "the-queen-knows-how-to-build-a-course", + title: "The Queen now knows how to build a course", + summary: "[a recipe, not a course; the recipe modules compile but no CI job runs their tests yet] The t27 course recipe moved from one agent's private folder into the repository as two t27 modules, and AGENTS.md points at them, so the Queen can assign a new 27-lesson course for any topic.", + date: "2026-10-07", + readingMinutes: 2, + tags: ["t27", "Course", "Agents"], + receipts: [ + { label: "The recipe, specs/course_recipe/course-27.t27", href: "https://github.com/gHashTag/trinity/blob/main/specs/course_recipe/course-27.t27" }, + { label: "The plan, specs/course_recipe/roadmap.t27", href: "https://github.com/gHashTag/trinity/blob/main/specs/course_recipe/roadmap.t27" }, + ], + openQuestions: [ + "No CI job runs the recipe's tests yet.", + ], + published: true, + ru: { + title: "Королева теперь знает, как собрать курс", + summary: "[рецепт, а не курс; модули рецепта собираются, но их тесты пока не запускает CI] Рецепт курса t27 переехал из личной папки одного агента в репозиторий двумя модулями t27, а AGENTS.md ссылается на них, так что Королева может поручить новый курс из 27 уроков на любую тему.", + openQuestions: [ + "Тесты рецепта пока не запускает ни одно задание CI.", + ], + }, + }, { slug: "a-course-pr-carries-its-post", title: "A course PR now carries its blog post", diff --git a/apps/website/src/data/blog/posts.ts b/apps/website/src/data/blog/posts.ts index a3f3618c5b..00f4e0893d 100644 --- a/apps/website/src/data/blog/posts.ts +++ b/apps/website/src/data/blog/posts.ts @@ -4,6 +4,8 @@ import { body as body_course_three_verifying_hardware, ruBody as ruBody_course_t import { body as body_course_zero_t27_basics, ruBody as ruBody_course_zero_t27_basics } from './bodies/course-zero-t27-basics' import { body as body_tri_test_now_runs_the_tests, ruBody as ruBody_tri_test_now_runs_the_tests } from './bodies/tri-test-now-runs-the-tests' import { body as body_two_courses_twenty_seven_lessons_each, ruBody as ruBody_two_courses_twenty_seven_lessons_each } from './bodies/two-courses-twenty-seven-lessons-each' +import { body as body_a_widget_card_drawn_from_its_spec, ruBody as ruBody_a_widget_card_drawn_from_its_spec } from './bodies/a-widget-card-drawn-from-its-spec' +import { body as body_the_queen_knows_how_to_build_a_course, ruBody as ruBody_the_queen_knows_how_to_build_a_course } from './bodies/the-queen-knows-how-to-build-a-course' import { body as body_a_course_pr_carries_its_post, ruBody as ruBody_a_course_pr_carries_its_post } from './bodies/a-course-pr-carries-its-post' import { body as body_one_outlier_twenty_three_zeros, ruBody as ruBody_one_outlier_twenty_three_zeros } from './bodies/one-outlier-twenty-three-zeros' import { body as body_a_terminal_for_seven_backends_in_an_x_post, ruBody as ruBody_a_terminal_for_seven_backends_in_an_x_post } from './bodies/a-terminal-for-seven-backends-in-an-x-post' @@ -110,6 +112,8 @@ const bodies: Record = { 'course-zero-t27-basics': { body: body_course_zero_t27_basics, ruBody: ruBody_course_zero_t27_basics }, 'tri-test-now-runs-the-tests': { body: body_tri_test_now_runs_the_tests, ruBody: ruBody_tri_test_now_runs_the_tests }, 'two-courses-twenty-seven-lessons-each': { body: body_two_courses_twenty_seven_lessons_each, ruBody: ruBody_two_courses_twenty_seven_lessons_each }, + 'a-widget-card-drawn-from-its-spec': { body: body_a_widget_card_drawn_from_its_spec, ruBody: ruBody_a_widget_card_drawn_from_its_spec }, + 'the-queen-knows-how-to-build-a-course': { body: body_the_queen_knows_how_to_build_a_course, ruBody: ruBody_the_queen_knows_how_to_build_a_course }, 'a-course-pr-carries-its-post': { body: body_a_course_pr_carries_its_post, ruBody: ruBody_a_course_pr_carries_its_post }, 'one-outlier-twenty-three-zeros': { body: body_one_outlier_twenty_three_zeros, ruBody: ruBody_one_outlier_twenty_three_zeros }, 'a-terminal-for-seven-backends-in-an-x-post': { body: body_a_terminal_for_seven_backends_in_an_x_post, ruBody: ruBody_a_terminal_for_seven_backends_in_an_x_post }, diff --git a/specs/course_recipe/course-27.t27 b/specs/course_recipe/course-27.t27 new file mode 100644 index 0000000000..641009e6aa --- /dev/null +++ b/specs/course_recipe/course-27.t27 @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 +; course-27.t27 -- turn any topic into a t27 course: 9 modules of 3 lessons each +; This file IS the skill. SKILL.md next to it only points here. Every step, rule and gate +; below is a constant, and the test blocks hold the order and the shape, so a step cannot +; be dropped or moved without a red test. Compile and test it with the site's own compiler: +; any harness that calls loadCompiler (scripts/agents-from-specs.mjs) and runSpecTests (scripts/viewport-from-spec.mjs) in apps/website +; (or any harness that calls loadCompiler + runSpecTests from gHashTag/trinity +; apps/website/scripts). ASCII only, English only. + +; THE LIVING TEMPLATE is the newest course spec on the site, not a copy kept here: +; gHashTag/trinity apps/website/specs/course/ai-numbers.t27 (course 2) +; gHashTag/trinity apps/website/specs/course/courses.t27 (the catalog, the order) +; Copy the newest course spec, rename ID / ROUTE / SHARE_PATH / RU_CONTRACT, then replace +; the words. A template kept here would drift from the generator; that is the recorded +; defect of this codebase family (one rule in three files, two of them right). + +; WHAT A COURSE IS: 9 modules of 3 lessons each, 27 lessons (a TRI-27 word; +; owner's decision 2026-10-07: 9 modules of 3; a flattening to 27 modules of 1 was a +; misreading and was reverted). One course per topic. +; Two topics are two courses, chained: the last lesson of one opens lesson 1 of the next, +; and each has its own address, share pages and cards, so each can be shared on its own. +; Every lesson opens ONE main widget (unique across all courses) and ONE t27 spec that +; compiles clean in the reader's browser. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module course_27; + +pub const KIND : str = "skill"; +pub const SCHEMA_VERSION : u8 = 1; +pub const LESSONS : u8 = 27; +pub const MODULES : u8 = 9; +pub const LESSONS_PER_MODULE : u8 = 3; + +; Where the course lives (gHashTag/trinity, relative to apps/website). +pub const CATALOG : str = "specs/course/courses.t27"; +pub const TEMPLATE : str = "specs/course/ai-numbers.t27"; +pub const GALLERY : str = "specs/widgets/gallery.t27"; +pub const ROADMAP : str = "specs/course_recipe/roadmap.t27"; +; The first course of the catalog (course 0, t27-basics) shares at learn/, every later course at learn//; lesson pages stay at +; learn// whatever the course, so an old lesson link never breaks. +pub const SHARE_FIRST : str = "learn/"; +pub const SHARE_NEXT : str = "learn//"; + +; --- The steps, in order ----------------------------------------------------------------- +pub const STEP_COUNT : u8 = 14; +pub const STEP_IDS : [14]str = [ + "pick", + "specs", + "shape", + "words", + "russian", + "widgets", + "catalog", + "generate", + "preview", + "post", + "land", + "ship", + "social", + "learn" +]; +pub const STEP_SAYS : [14]str = [ + "Pick the topic from the roadmap and a lower-case id; the app route is #/, the share path learn//.", + "Find 27 lesson specs that compile clean in the browser player; a missing spec is written in gHashTag/t27 first (skill t27-spec-first) and reaches the site by the world scan, never hand-copied into the site.", + "Order 9 modules of 3 lessons each so each lesson uses only what earlier lessons taught; a topic that needs more than 27 lessons is two courses.", + "Copy the newest course spec, keep every SAY_ key the other courses have, and write the words: no superlatives, every number from a spec constant, a player run or a recording.", + "Write i18n/.ru.json and specs/course/-ru.t27: every field translated, the same digits and the same {N} placeholders as the English.", + "Give every lesson its own main widget; a new widget is a recording made with tri commands only, in black and white, with one planted one-line bug that fails exactly one named test and is put back in the same recording.", + "Add the course to specs/course/courses.t27: COURSE_COUNT, COURSE_IDS, COURSE_SPECS and the order test; prev and next links follow from the order.", + "Run widgets-from-spec, course-from-spec, course-pages (static pages, black-and-white cards, sitemap, hreflang) and onboarding-from-spec (llms.txt and agents.t27 name every course).", + "Preview the dev server: the course landing, lesson 1, the last lesson's link to the next course, a lesson of the previous course, a phone width.", + "Write the blog post in the same PR: English body and Russian ruBody, receipts that link the recordings and pages, limits said out loud, an SEO title and summary, and a black-and-white image for the networks.", + "Open the issue, then the PR with Closes #N and the t27-work-report block whose head_sha is the PR head; a reviewer bee merges with --match-head-commit, never --admin or --auto.", + "Ship through the gHashTag/999-multibots-telegraf release(board) PR and prove it live by the bundle, not by a green status.", + "Hand the post to the blog-share desk: one draft per network, logged per client in the CRM; nothing goes out without the owner's yes.", + "Save what this course taught into a tri command (a new one, or the old one updated) and into this file; the next course starts from both." +]; + +; --- The rules ----------------------------------------------------------------------------- +pub const RULE_COUNT : u8 = 10; +pub const RULE_IDS : [10]str = [ + "twenty-seven", + "spec-and-widget", + "casts-through-tri", + "black-and-white", + "post-per-pr", + "seo", + "no-superlatives", + "numbers-from-runs", + "no-reddit", + "owner-says-yes" +]; +pub const RULE_SAYS : [10]str = [ + "Every course is exactly 9 modules of 3 lessons each, 27 lessons; two topics are two courses, never one course of 54.", + "Every lesson opens one main widget and one t27 spec; the main widget is used by no other lesson of any course.", + "Every recording types tri commands only (sed -n to show lines, tri test, tri mutate plant, tri mutate spec, git diff); what the commands lack is added to tri first.", + "Cards, recordings and post images are white on black like the course page: no colour fields, no glows, widget pictures in greyscale.", + "A PR that changes a course carries its blog post, English and Russian, in the same PR.", + "Every course and every lesson has a static page with its own preview card, canonical link, hreflang pair and a line in learn/sitemap.xml; no page redirects.", + "No superlative in either language: no first, only, best, unique; in Russian say lesson 1, not the first lesson.", + "A number on a page comes from a spec constant, a test the player ran, or a recording; none is typed from memory.", + "No Reddit link on the course, the player or a post: r/t27ai is banned.", + "No post, mail or message leaves without the owner's explicit yes; ASK_NEEDS_YES stays true." +]; + +; --- The gates, all of them, before the PR ------------------------------------------------- +; Run from apps/website. A course PR is not ready while any of them is red. +pub const GATE_COUNT : u8 = 8; +pub const GATES : [8]str = [ + "npm run check:widgets", + "npm run test:widgets-spec", + "npm run check:widget-pages", + "npm run check:course", + "npm run test:course", + "npm run check:course-pages", + "npm run check:onboarding", + "npx tsc --noEmit -p ." +]; + +; --- Recording a lesson: the tri commands a cast types, in order --------------------------- +pub const CAST_STEP_COUNT : u8 = 5; +pub const CAST_STEPS : [5]str = [ + "sed -n p ", + "tri test ", + "tri mutate plant --file --line --from --to --expect ", + "tri mutate spec --file --fn ", + "git diff --exit-code " +]; + +test a_course_is_three_cubed { + assert LESSONS_PER_MODULE == 3; + assert MODULES == 9; + assert MODULES * LESSONS_PER_MODULE == LESSONS; + assert LESSONS == 3 * 3 * 3; +} + +test specs_come_before_words_and_widgets { + assert STEP_IDS[0] == "pick"; + assert STEP_IDS[1] == "specs"; + assert STEP_IDS[3] == "words"; + assert STEP_IDS[5] == "widgets"; +} + +test the_catalog_follows_the_widgets_and_the_generators_follow_the_catalog { + assert STEP_IDS[6] == "catalog"; + assert STEP_IDS[7] == "generate"; + assert STEP_IDS[8] == "preview"; +} + +test the_post_is_in_the_pr_and_the_owner_posts { + assert STEP_IDS[9] == "post"; + assert STEP_IDS[10] == "land"; + assert STEP_IDS[12] == "social"; + assert RULE_IDS[4] == "post-per-pr"; + assert RULE_IDS[9] == "owner-says-yes"; +} + +test the_experience_goes_back_into_tri { + assert STEP_COUNT == 14; + assert STEP_IDS[13] == "learn"; + assert RULE_IDS[2] == "casts-through-tri"; + assert CAST_STEPS[1] == "tri test "; +} + +test the_course_gates_are_all_named { + assert GATE_COUNT == 8; + assert GATES[3] == "npm run check:course"; + assert GATES[5] == "npm run check:course-pages"; + assert GATES[6] == "npm run check:onboarding"; +} + +test every_later_course_shares_under_its_own_path { + assert SHARE_FIRST == "learn/"; + assert SHARE_NEXT == "learn//"; +} diff --git a/specs/course_recipe/roadmap.t27 b/specs/course_recipe/roadmap.t27 new file mode 100644 index 0000000000..72d350e820 --- /dev/null +++ b/specs/course_recipe/roadmap.t27 @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +; roadmap.t27 -- the t27 courses after the three that are live, in the order they are planned +; Read with course-27.t27 next to it (the recipe). Compile and test: +; compile and test with the same harness as specs/course_recipe/course-27.t27 +; ASCII only, English only. A course moves from here to gHashTag/trinity +; apps/website/specs/course/courses.t27 when it ships; then it is deleted here, so the +; catalog stays the one home of what is live and this file the one home of what is not. + +; WHAT IS LIVE (2026-10-07): course 0 "t27-basics" (the t27 language, #/t27-basics, learn/), +; course 1 "course" (FPGA programming with t27, #/course, learn/course/) and course 2 +; "ai-numbers" (AI numbers with t27, #/ai-numbers, learn/ai-numbers/). Course 0 came in +; trinity PR stacked on #1456; making it first moved course 1's index from learn/ to +; learn/course/ (lesson pages stay at learn//). + +; WHAT A PLAN IS: an id, a title, the source it teaches from, the spec directories already +; on the site (public/t27/files/specs/) and 9 topic parts. Lessons are chosen when the +; course is built (step "specs" of the recipe), because a lesson needs a spec that compiles +; clean in the player AND its own widget; the widgets are the real cost of a course +; (course 2 needed 9 new ones), so a plan never promises a lesson list. + +; WHY THIS ORDER: each course uses what the earlier ones taught. GoldenFloat follows AI +; numbers (formats, scales); the catalog follows GoldenFloat (it measures GF beside every +; other format); the ternary computer follows the FPGA course and the ternary module of AI +; numbers; networks come last, on top of formats, trits and the machine. + +; NUMBERS ARE NOT TYPED HERE: the catalog's format count grows (read it from gHashTag/t27 +; tools/gen_formats_catalog.py, "parsed N formats"), and TNF has no arXiv id (journal +; manuscript MICPRO-D-26-00839, under review). Say neither from memory on a course page. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module course_roadmap; + +pub const KIND : str = "roadmap"; +pub const SCHEMA_VERSION : u8 = 1; +pub const STATUS : str = "planned"; +pub const LIVE_COUNT : u8 = 3; +pub const LIVE_IDS : [3]str = ["t27-basics", "course", "ai-numbers"]; + +pub const PLANNED_COUNT : u8 = 4; +pub const PLANNED_IDS : [4]str = ["goldenfloat", "golden-ruler", "ternary-computer", "networks"]; +pub const PLANNED_TITLES : [4]str = [ + "GoldenFloat with t27: a floating-point family cut by phi, from GF4 to GF1024", + "Golden Ruler with t27: a numeric format catalog and its bit-exact vectors", + "A ternary computer with t27: trits, gates, a register file and the TRI-27 machine", + "Neural networks with t27: layers, losses, optimizers and attention as specs" +]; +; The paper or manuscript each course teaches from; "" = the specs are the source. +pub const PLANNED_SOURCES : [4]str = [ + "arXiv:2606.05017", + "arXiv:2606.09686", + "", + "" +]; +; Spec directories on the site each course draws its lessons from (comma separated). +pub const PLANNED_SPEC_DIRS : [4]str = [ + "numeric/gf*,numeric/gft*,numeric/phi_ratio,numeric/lucas_accumulator,numeric/goldenfloat_family,numeric/gf_competitive,numeric/pellis_verify,math/phi_split_optimality,math/radix_economy", + "numeric/formats_catalog,numeric/formats,numeric/e8m0,numeric/bnf*,numeric/posit_ladder_control,numeric/requant_boundary,numeric/trinity_numeric_surface,numeric/bigint,conformance", + "isa,vsa", + "ml/activation,ml/layers,ml/loss,ml/optimizer,ml/recurrent,ml/transformer,ml/rl,nn" +]; + +; Owner's decision 2026-10-07: a course is 9 modules of 3 lessons each. What a plan fixes now +; is the 9 modules of every planned course, course after course (course k owns [9k .. 9k+8]); +; the 3 lessons of each module are chosen when the course is built. +pub const MODULES_PER_COURSE : u8 = 9; +pub const LESSONS_PER_MODULE : u8 = 3; +pub const PARTS_PER_COURSE : u8 = 9; +pub const PLANNED_PART_COUNT : u8 = 36; +pub const PLANNED_PARTS : [36]str = [ + "phi", "split", "gf-tiny", "gf-small", "gf-half", "gf-single", "gf-wide", "lucas", "rivals", + "catalog", "ieee", "fp8", "bfloat", "posit", "block-scales", "bnf", "requant", "vectors", + "trit", "logic", "trit-math", "registers", "memory", "control", "structures", "algorithms", "hypervectors", + "activations", "layers", "losses", "optimizers", "recurrence", "reinforcement", "attention", "rope", "transformer" +]; + +; What every plan still needs before it can ship, in the recipe's own step ids. +pub const BEFORE_SHIP : [4]str = ["specs", "widgets", "russian", "post"]; + +test four_courses_are_planned_and_none_is_live { + assert STATUS == "planned"; + assert PLANNED_COUNT == 4; + assert LIVE_COUNT == 3; + assert LIVE_IDS[0] == "t27-basics"; + assert PLANNED_IDS[0] != LIVE_IDS[0]; + assert PLANNED_IDS[0] != LIVE_IDS[1]; + assert PLANNED_IDS[0] != LIVE_IDS[2]; + assert PLANNED_IDS[1] != LIVE_IDS[0]; + assert PLANNED_IDS[1] != LIVE_IDS[1]; + assert PLANNED_IDS[1] != LIVE_IDS[2]; + assert PLANNED_IDS[2] != LIVE_IDS[0]; + assert PLANNED_IDS[2] != LIVE_IDS[1]; + assert PLANNED_IDS[2] != LIVE_IDS[2]; + assert PLANNED_IDS[3] != LIVE_IDS[0]; + assert PLANNED_IDS[3] != LIVE_IDS[1]; + assert PLANNED_IDS[3] != LIVE_IDS[2]; +} + +test every_plan_is_nine_modules_of_three_lessons { + assert MODULES_PER_COURSE == 9; + assert LESSONS_PER_MODULE == 3; + assert PARTS_PER_COURSE == MODULES_PER_COURSE; + assert PLANNED_PARTS[35] == "transformer"; + assert PLANNED_PART_COUNT == PLANNED_COUNT * PARTS_PER_COURSE; + assert PLANNED_PARTS[0] == "phi"; + assert PLANNED_PARTS[9] == "catalog"; + assert PLANNED_PARTS[18] == "trit"; + assert PLANNED_PARTS[27] == "activations"; + assert PLANNED_PARTS[35] == "transformer"; +} + +test goldenfloat_comes_next_and_teaches_from_its_paper { + assert PLANNED_IDS[0] == "goldenfloat"; + assert PLANNED_SOURCES[0] == "arXiv:2606.05017"; + assert PLANNED_IDS[1] == "golden-ruler"; + assert PLANNED_SOURCES[1] == "arXiv:2606.09686"; +} + +test widgets_are_the_cost_of_a_plan { + assert BEFORE_SHIP[1] == "widgets"; + assert BEFORE_SHIP[3] == "post"; +}