From 0c2049d3dc9fd368bb4239411092220e2750d357 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sat, 17 Oct 2026 22:02:56 +0700 Subject: [PATCH] spec(t27f): deck -- every reel scene is a presentation slide by default The default look of a T27F reel follows the Tome deck the owner pointed at: 16:9, dark aurora backdrop, glass card with a bold two-line headline and up to four rows, the map as the hero beside it (above it on portrait frames), a cover slide with one big headline. Numbers only, imported from reel.t27: layouts per scene kind, regions in permille, the reel camera window fitted whole into the hero so focus+context holds on every slide, type sizes per preset, card fades, palette and contrast. Decoration keeps off the status colours: Tome's cyan ribbons would read as verified on our map, so they are not used. Lab (t27c 90fc25b3b): 14/14 tests, 0 vacuous, 2 invariants proved. Five negative controls each fail their own test. Closes #9978 Co-Authored-By: Claude Opus 5.5 --- specs/OWNERS.md | 2 +- specs/t27f/deck.t27 | 661 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 specs/t27f/deck.t27 diff --git a/specs/OWNERS.md b/specs/OWNERS.md index 02869bc61b..38bf812cc9 100644 --- a/specs/OWNERS.md +++ b/specs/OWNERS.md @@ -28,7 +28,7 @@ | `docs/` | **Z-Zeta** (with **T-Queen** for `chapters/queen.t27`) | The system documentation of `t27.ai/#/docs` as `.t27` specs (`docs/system.t27`, `KIND = "docs"`; `docs/chapters/.t27`, `KIND = "docs-chapter"`): chapter ids, sections, sources, the figure and table the site generates; English prose in `docs/system/.md`, Russian via `i18n/docs-ru.t27` | | `ui/` | **T-Queen** | The site's viewport contract as a `.t27` spec (`ui/viewport.t27`, `KIND = "viewport"`): tiers by CSS viewport width, panes per tier, the 44 px touch minimum, the six-size QA matrix and the Queen rail capacity read from `Queen.css`; the site (gHashTag/trinity) generates `viewport.generated.ts` / `.css` from a vendored copy and evaluates the spec's own test blocks; `ui/queen_evidence.t27` (`module ui_queen_evidence`): the evidence constants of the Queen's FFN and Ternary Check panel, from which the site generates `queenEvidence.generated.ts`; canonical here since S10, byte-identical to the copy the site carried alone | | `catalog/` | **T-Queen** | How the site's spec catalog and the Queen map find the repositories that write `.t27` (`catalog/discovery.t27`, `KIND = "discovery"`): the GitHub owners scanned, the exclusions, what makes a candidate qualify (a declaration in the compiler's AST, not a file extension) and the limits; the site (gHashTag/trinity) vendors a byte-identical copy and `scripts/discover-t27-worlds.mjs` reads it through the compiler wasm before touching GitHub | -| `t27f/` | **T-Queen** | The T27F topic-to-value engine of gHashTag/trinity#1677 (`t27f/topic_engine.t27`, module `T27fTopicEngine`): the 3 x 9 = 27 lesson shape, intake and gap rules, keyword routing to one domain, the Bee dispatch cap, the evidence and approval gates, and progression that is never money; driven by the Claude Code skill `.claude/skills/t27f/SKILL.md` through the t27 MCP server; `t27f/reel.t27` (module `T27fReel`): the video format of a topic map and the still views of a Claude Code answer -- honeycomb geometry, focus+context camera, scene catalog, per-target limits, gap styling, the posting gate and the channel per host; numbers only, a host draws them | +| `t27f/` | **T-Queen** | The T27F topic-to-value engine of gHashTag/trinity#1677 (`t27f/topic_engine.t27`, module `T27fTopicEngine`): the 3 x 9 = 27 lesson shape, intake and gap rules, keyword routing to one domain, the Bee dispatch cap, the evidence and approval gates, and progression that is never money; driven by the Claude Code skill `.claude/skills/t27f/SKILL.md` through the t27 MCP server; `t27f/reel.t27` (module `T27fReel`): the video format of a topic map and the still views of a Claude Code answer -- honeycomb geometry, focus+context camera, scene catalog, per-target limits, gap styling, the posting gate and the channel per host; numbers only, a host draws them; `t27f/deck.t27` (module `T27fDeck`): the default look of a reel, every scene a presentation slide -- layouts per scene, regions, type sizes, glass card, palette kept off the status colours, contrast and card fades | | `trinity/` | **T-Queen** + **A-Architect** | The project manifest of gHashTag/trinity as `.t27` specs (`trinity/project.t27`, `KIND = "project"`; `trinity/capabilities/.t27`, `KIND = "capability"`): the pinned consumer revision, profiles, dispositions and evidence tags, dialect and build counts, dependency pins and the work packages of gHashTag/trinity#988; each card names its owner, canonical spec, implementation and generated paths, build targets, acceptance command and evidence; `tools/trinity_manifest.py` derives the inventory from a clean pinned checkout and holds the cards to it; `trinity/compiler_matrix.t27` (`KIND = "compiler-matrix"`) names the pinned compiler, the backends, the stages and the features of the executable subset with their fixtures under `bootstrap/tests/fixtures/trinity_matrix/`, and `tools/trinity_compiler_matrix.py` measures them; `trinity/build_graph.t27` (`KIND = "build-graph"`) declares the consumer's toolchain, pins, profiles, generated-file boundaries and receipt policy, and `tools/trinity_build_receipt.py` derives the graph, writes the receipts and judges drift; the consumer vendors a byte-identical copy and generates its manifest from it (S12). Shared contracts stay in their own subtrees (`numeric/`, `vsa/`, `queen/`, `tools/`, `docs/`, `ui/`) and are cited, never copied | Each subtree with substantial churn should keep a local **`OWNERS.md`** (see below). diff --git a/specs/t27f/deck.t27 b/specs/t27f/deck.t27 new file mode 100644 index 0000000000..5d7cabab99 --- /dev/null +++ b/specs/t27f/deck.t27 @@ -0,0 +1,661 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/t27f/deck.t27 -- the default look of a T27F reel: every scene is a presentation slide +; (gHashTag/trinity#1648, the reel format in specs/t27f/reel.t27). +; The owner asked for the reel to look, by default, like the slides of a Tome deck +; (tomeapp.ai/showcase/ccIzxAeYpq39I6HMWzwhe, studied 2026-10-17). What that deck does, measured off +; its ten 2752x1536 slides: +; - 16:9, a near-black backdrop crossed by soft aurora ribbons; +; - text sits on a glass card: a faint white fill, a thin light border, round corners; +; - big bold sans headlines, at most two lines, and at most four short rows under them; +; - the usual slide is a card on the left and one glowing object on the right; +; - the cover is one huge centred headline over the full-bleed picture, with a subtitle under it. +; What this file keeps and what it changes: +; - the hero object is always the honeycomb map itself, through the reel camera. Tome paints a +; generated picture there; a picture that does not come from the data would be a claim the map +; cannot back, so there is none; +; - Tome's ribbons include cyan. On our map teal and cyan mean verified and published, so the +; ribbons here keep to violet, magenta, pink and blue, and a test holds every ribbon colour away +; from every status colour; +; - a portrait frame stacks the map above the card instead of beside it. +; As in reel.t27, this file decides numbers and a host draws them: regions in permille of the +; frame, type sizes in pixels per preset, colours as 0xRRGGBB, opacities in permille. +; Contrast below is a luminance difference on sRGB values (Rec. 709 weights, no gamma), a proxy for +; legibility, not a WCAG ratio. +; Not claimed: no host draws this yet; the reel.t27 "not claimed" list still holds. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module T27fDeck; + +use t27f::reel::SCENE_TITLE; +use t27f::reel::SCENE_OVERVIEW; +use t27f::reel::SCENE_LENS; +use t27f::reel::SCENE_FOCUS; +use t27f::reel::SCENE_EVIDENCE; +use t27f::reel::SCENE_CALL; +use t27f::reel::SCENE_KIND_COUNT; +use t27f::reel::PRESET_WIDE; +use t27f::reel::PRESET_SQUARE; +use t27f::reel::PRESET_TALL; +use t27f::reel::PRESET_COUNT; +use t27f::reel::PRESET_W; +use t27f::reel::PRESET_H; +use t27f::reel::HEX_R; +use t27f::reel::FOCUS_HW; +use t27f::reel::OVERVIEW_HW; +use t27f::reel::TRANSITION_FRAMES; +use t27f::reel::MIN_SCENE_FRAMES; +use t27f::reel::half_height; +use t27f::reel::STYLE_GAP; +use t27f::reel::STYLE_MUTED; +use t27f::reel::STYLE_OPEN; +use t27f::reel::STYLE_DRAFT; +use t27f::reel::STYLE_VERIFIED; +use t27f::reel::STYLE_PUBLISHED; +use t27f::reel::STYLE_UNKNOWN; + +/// Looks. MAP is reel.t27 alone: the honeycomb fills the frame. DECK is the default. +pub const LOOK_MAP : u8 = 0; +pub const LOOK_DECK : u8 = 1; +pub const DEFAULT_LOOK : u8 = 1; +pub const DEFAULT_PRESET : u8 = 0; + +/// Slide layouts. COVER: one big headline over the dimmed map. STAGE: a headline strip over the map. +/// SPLIT: a glass card with the headline and rows, the map beside it or above it. +pub const LAYOUT_COVER : u8 = 0; +pub const LAYOUT_STAGE : u8 = 1; +pub const LAYOUT_SPLIT : u8 = 2; +pub const LAYOUT_COUNT : u8 = 3; +pub const LAYOUT_NONE : u8 = 255; + +/// Orientation: SIDE puts the map beside the card, STACK puts it above. A frame goes SIDE when it is +/// at least 1.3 times as wide as it is tall. +pub const ORIENT_SIDE : u8 = 0; +pub const ORIENT_STACK : u8 = 1; +pub const SIDE_MIN_ASPECT_PERMILLE : i32 = 1300; + +/// Regions of a slide. A region with x1 <= x0 is absent from that layout. +pub const REGION_TITLE : u8 = 0; +pub const REGION_SUBTITLE : u8 = 1; +pub const REGION_CARD : u8 = 2; +pub const REGION_HERO : u8 = 3; +pub const REGION_KICKER : u8 = 4; +pub const REGION_COUNT : u8 = 5; + +/// x0, y0, x1, y1 in permille of the frame, indexed ((layout * 2 + orient) * 5 + region) * 4. +/// The cover's hero is the full-bleed backdrop, so it alone may sit under text. +pub const REGIONS : [120]i32 = [ + 80, 300, 920, 620, 160, 640, 840, 740, 0, 0, 0, 0, 0, 0, 1000, 1000, 80, 900, 500, 960, + 80, 320, 920, 580, 100, 600, 900, 700, 0, 0, 0, 0, 0, 0, 1000, 1000, 80, 920, 920, 960, + 80, 50, 920, 180, 0, 0, 0, 0, 0, 0, 0, 0, 60, 210, 940, 900, 80, 920, 500, 965, + 80, 40, 920, 130, 0, 0, 0, 0, 0, 0, 0, 0, 60, 150, 940, 900, 80, 920, 920, 960, + 0, 0, 0, 0, 0, 0, 0, 0, 60, 140, 500, 860, 540, 80, 960, 920, 60, 900, 500, 950, + 0, 0, 0, 0, 0, 0, 0, 0, 60, 540, 940, 930, 60, 40, 940, 500, 60, 945, 940, 975 +]; +pub const SAFE_MARGIN_PERMILLE : i32 = 25; + +/// Type, in pixels per preset (wide, square, tall). The font never shrinks to make text fit: text that +/// does not fit is a second slide. +pub const COVER_PX : [3]i32 = [104, 120, 136]; +pub const HEADLINE_PX : [3]i32 = [52, 60, 72]; +pub const BODY_PX : [3]i32 = [22, 28, 34]; +pub const CAPTION_PX : [3]i32 = [15, 18, 24]; +pub const PAD_PX : [3]i32 = [28, 32, 44]; +pub const COVER_LEADING_PERMILLE : i32 = 1050; +pub const HEADLINE_LEADING_PERMILLE : i32 = 1150; +pub const ROW_LEADING_PERMILLE : i32 = 1600; +pub const HEADLINE_GAP_PX : i32 = 16; +/// Average advance of a glyph, in permille of the size: bold headline and regular body. +pub const HEADLINE_EM_PERMILLE : i32 = 560; +pub const BODY_EM_PERMILLE : i32 = 500; +pub const MAX_HEADLINE_LINES : i32 = 2; +pub const MAX_ROWS : i32 = 4; + +/// A focused cell on a slide has at least this radius in pixels. +pub const MIN_FOCUS_R_PX : i32 = 60; + +/// Card fades: in over the camera transition, out over the last frames of the scene, so text is never +/// on screen while the camera moves under it. +pub const FADE_OUT_FRAMES : i32 = 8; + +/// The palette, 0xRRGGBB. +pub const BACKDROP : i32 = 0x06060B; +pub const AURORA_COUNT : i32 = 4; +pub const AURORA : [4]i32 = [0x7C3AED, 0xC026D3, 0xF472B6, 0x3B82F6]; +pub const TEXT : i32 = 0xFFFFFF; +pub const TEXT_DIM : i32 = 0xC4C4D4; +pub const KICKER : i32 = 0x9CA3AF; +pub const WHITE : i32 = 0xFFFFFF; +/// Glass card: white fill and border opacity, corner radius in permille of the shorter side. +pub const GLASS_FILL_PERMILLE : i32 = 70; +pub const GLASS_BORDER_PERMILLE : i32 = 240; +pub const GLASS_RADIUS_PERMILLE : i32 = 32; +/// How much of the aurora shows through behind a card, and behind the cover headline; how strongly +/// the map shows behind the cover. +pub const AURORA_UNDER_CARD_PERMILLE : i32 = 300; +pub const AURORA_UNDER_COVER_PERMILLE : i32 = 450; +pub const COVER_MAP_PERMILLE : i32 = 250; + +/// Cell colours on the dark slide, indexed by reel.t27 STYLE_*: gap, muted, open, draft, verified, +/// published, unknown. GAP and UNKNOWN are also drawn dashed. +pub const STYLE_COLOUR : [7]i32 = [0xFF7A45, 0x3F3F50, 0x8B8BA7, 0xFBBF24, 0x2DD4BF, 0x5EEAD4, 0x6B7280]; +pub const STYLE_COLOUR_COUNT : i32 = 7; + +/// Legibility and separation thresholds. +pub const MIN_TEXT_CONTRAST : i32 = 150; +pub const MIN_DIM_CONTRAST : i32 = 110; +pub const MIN_KICKER_CONTRAST : i32 = 80; +pub const MIN_STATUS_DISTANCE : i32 = 60; +pub const MIN_DECOR_DISTANCE : i32 = 120; +pub const MIN_GAP_DISTANCE : i32 = 300; + +/// --- layout --- + +pub fn layout_for(kind: u8) -> u8 { + if (kind == SCENE_TITLE) { + return LAYOUT_COVER; + } + if (kind == SCENE_OVERVIEW or kind == SCENE_LENS) { + return LAYOUT_STAGE; + } + if (kind == SCENE_FOCUS or kind == SCENE_EVIDENCE or kind == SCENE_CALL) { + return LAYOUT_SPLIT; + } + return LAYOUT_NONE; +} + +pub fn orient(preset: u8) -> u8 { + const w : i64 = PRESET_W[preset]; + if (w * 1000 >= SIDE_MIN_ASPECT_PERMILLE * PRESET_H[preset]) { + return ORIENT_SIDE; + } + return ORIENT_STACK; +} + +/// One coordinate of a region in permille; j is 0 x0, 1 y0, 2 x1, 3 y1. +pub fn region_permille(layout: u8, o: u8, r: u8, j: i32) -> i32 { + const lay : i32 = layout; + const oi : i32 = o; + const ri : i32 = r; + const k : i32 = ((lay * 2 + oi) * 5 + ri) * 4 + j; + return REGIONS[@intCast(k)]; +} + +/// One coordinate of a region in pixels of a preset. +pub fn region_px(layout: u8, preset: u8, r: u8, j: i32) -> i32 { + const p : i64 = region_permille(layout, orient(preset), r, j); + if (j == 0 or j == 2) { + return (@divTrunc(p * PRESET_W[preset], 1000)) as i32; + } + return (@divTrunc(p * PRESET_H[preset], 1000)) as i32; +} + +pub fn has_region(layout: u8, preset: u8, r: u8) -> bool { + return region_px(layout, preset, r, 2) > region_px(layout, preset, r, 0); +} + +pub fn region_w(layout: u8, preset: u8, r: u8) -> i32 { + return region_px(layout, preset, r, 2) - region_px(layout, preset, r, 0); +} + +pub fn region_h(layout: u8, preset: u8, r: u8) -> i32 { + return region_px(layout, preset, r, 3) - region_px(layout, preset, r, 1); +} + +pub fn overlap(layout: u8, preset: u8, a: u8, b: u8) -> bool { + if (has_region(layout, preset, a) == false or has_region(layout, preset, b) == false) { + return false; + } + const ax0 : i32 = region_px(layout, preset, a, 0); + const ay0 : i32 = region_px(layout, preset, a, 1); + const ax1 : i32 = region_px(layout, preset, a, 2); + const ay1 : i32 = region_px(layout, preset, a, 3); + const bx0 : i32 = region_px(layout, preset, b, 0); + const by0 : i32 = region_px(layout, preset, b, 1); + const bx1 : i32 = region_px(layout, preset, b, 2); + const by1 : i32 = region_px(layout, preset, b, 3); + return ax0 < bx1 and bx0 < ax1 and ay0 < by1 and by0 < ay1; +} + +pub fn hero_is_backdrop(layout: u8) -> bool { + return layout == LAYOUT_COVER; +} + +/// --- the map inside the hero --- + +/// The reel camera sees a window 2*hw wide and 2*half_height tall in world units. On a slide that +/// window is fitted whole into the hero region (contain, centred), so everything the full-frame reel +/// shows, the slide shows too. The scale is a fraction: hero pixels over world units. +pub fn fit_by_width(hw: i32, layout: u8, preset: u8) -> bool { + const w : i64 = region_w(layout, preset, REGION_HERO); + const h : i64 = region_h(layout, preset, REGION_HERO); + const hh : i64 = half_height(hw, preset); + return w * hh <= h * hw; +} + +pub fn fit_num(hw: i32, layout: u8, preset: u8) -> i64 { + if (fit_by_width(hw, layout, preset)) { + return region_w(layout, preset, REGION_HERO); + } + return region_h(layout, preset, REGION_HERO); +} + +pub fn fit_den(hw: i32, layout: u8, preset: u8) -> i64 { + if (fit_by_width(hw, layout, preset)) { + return 2 * hw; + } + return 2 * half_height(hw, preset); +} + +pub fn hero_cx(layout: u8, preset: u8) -> i32 { + return @divTrunc(region_px(layout, preset, REGION_HERO, 0) + region_px(layout, preset, REGION_HERO, 2), 2); +} + +pub fn hero_cy(layout: u8, preset: u8) -> i32 { + return @divTrunc(region_px(layout, preset, REGION_HERO, 1) + region_px(layout, preset, REGION_HERO, 3), 2); +} + +/// World to slide pixels, for the camera (cx, cy, hw) of reel.t27. +pub fn slide_x(cx: i32, hw: i32, layout: u8, preset: u8, x: i32) -> i32 { + const d : i64 = x - cx; + return (@divTrunc(d * fit_num(hw, layout, preset), fit_den(hw, layout, preset))) as i32 + hero_cx(layout, preset); +} + +pub fn slide_y(cy: i32, hw: i32, layout: u8, preset: u8, y: i32) -> i32 { + const d : i64 = y - cy; + return (@divTrunc(d * fit_num(hw, layout, preset), fit_den(hw, layout, preset))) as i32 + hero_cy(layout, preset); +} + +pub fn slide_r(hw: i32, layout: u8, preset: u8) -> i32 { + const r : i64 = HEX_R; + return (@divTrunc(r * fit_num(hw, layout, preset), fit_den(hw, layout, preset))) as i32; +} + +/// --- text --- + +pub fn leading_px(size: i32, leading: i32) -> i32 { + return @divTrunc(size * leading + 999, 1000); +} + +/// Height a card needs for this many headline lines and rows. +pub fn card_text_h(lines: i32, rows: i32, preset: u8) -> i32 { + const head : i32 = lines * leading_px(HEADLINE_PX[preset], HEADLINE_LEADING_PERMILLE); + const body : i32 = rows * leading_px(BODY_PX[preset], ROW_LEADING_PERMILLE); + return 2 * PAD_PX[preset] + head + HEADLINE_GAP_PX + body; +} + +/// A slide's text is accepted only inside the rule (two lines, four rows) and only if it fits the +/// card at the fixed size. Anything more is a second slide. +pub fn text_fits(lines: i32, rows: i32, layout: u8, preset: u8) -> bool { + if (lines < 1 or lines > MAX_HEADLINE_LINES or rows < 0 or rows > MAX_ROWS) { + return false; + } + if (layout == LAYOUT_SPLIT) { + return card_text_h(lines, rows, preset) <= region_h(layout, preset, REGION_CARD); + } + if (layout == LAYOUT_STAGE) { + return rows == 0 and lines * leading_px(HEADLINE_PX[preset], HEADLINE_LEADING_PERMILLE) <= region_h(layout, preset, REGION_TITLE); + } + if (layout == LAYOUT_COVER) { + return rows == 0 and lines * leading_px(COVER_PX[preset], COVER_LEADING_PERMILLE) <= region_h(layout, preset, REGION_TITLE); + } + return false; +} + +/// Characters per line a host may set before it breaks the line. +pub fn chars_per_line(width_px: i32, size: i32, em: i32) -> i32 { + return @divTrunc(width_px * 1000, size * em); +} + +pub fn card_inner_w(preset: u8) -> i32 { + return region_w(LAYOUT_SPLIT, preset, REGION_CARD) - 2 * PAD_PX[preset]; +} + +/// Card opacity in permille at frame k of a scene n frames long. +pub fn card_alpha(k: i32, n: i32) -> i32 { + var a : i32 = @divTrunc(k * 1000, TRANSITION_FRAMES); + const b : i32 = @divTrunc((n - 1 - k) * 1000, FADE_OUT_FRAMES); + if (b < a) { + a = b; + } + if (a < 0) { + return 0; + } + if (a > 1000) { + return 1000; + } + return a; +} + +/// --- colour --- + +pub fn chan(c: i32, shift: i32) -> i32 { + if (shift == 16) { + return @rem(@divTrunc(c, 65536), 256); + } + if (shift == 8) { + return @rem(@divTrunc(c, 256), 256); + } + return @rem(c, 256); +} + +pub fn rgb(r: i32, g: i32, b: i32) -> i32 { + return r * 65536 + g * 256 + b; +} + +/// a under b at opacity alpha (permille), per channel. +pub fn mix(a: i32, b: i32, alpha: i32) -> i32 { + const r : i32 = chan(a, 16) + @divTrunc((chan(b, 16) - chan(a, 16)) * alpha, 1000); + const g : i32 = chan(a, 8) + @divTrunc((chan(b, 8) - chan(a, 8)) * alpha, 1000); + const bl : i32 = chan(a, 0) + @divTrunc((chan(b, 0) - chan(a, 0)) * alpha, 1000); + return rgb(r, g, bl); +} + +pub fn luma(c: i32) -> i32 { + return @divTrunc(2126 * chan(c, 16) + 7152 * chan(c, 8) + 722 * chan(c, 0), 10000); +} + +pub fn absdiff(a: i32, b: i32) -> i32 { + if (a > b) { + return a - b; + } + return b - a; +} + +/// Manhattan distance over the three channels. +pub fn distance(a: i32, b: i32) -> i32 { + return absdiff(chan(a, 16), chan(b, 16)) + absdiff(chan(a, 8), chan(b, 8)) + absdiff(chan(a, 0), chan(b, 0)); +} + +pub fn style_colour(style: u8) -> i32 { + if (style > STYLE_UNKNOWN) { + return STYLE_COLOUR[6]; + } + return STYLE_COLOUR[style]; +} + +pub fn style_dashed(style: u8) -> bool { + return style == STYLE_GAP or style >= STYLE_UNKNOWN; +} + +/// The brightest thing that can sit behind card text: the brightest ribbon dimmed for the card, under +/// the glass fill. +pub fn worst_under_card() -> i32 { + var worst : i32 = 0; + var i : i32 = 0; + while (i < AURORA_COUNT) { + const under : i32 = mix(mix(BACKDROP, AURORA[@intCast(i)], AURORA_UNDER_CARD_PERMILLE), WHITE, GLASS_FILL_PERMILLE); + if (luma(under) > worst) { + worst = luma(under); + } + i = i + 1; + } + return worst; +} + +/// The brightest thing behind the cover headline: a ribbon at cover strength, or a map cell at cover +/// map strength over it. +pub fn worst_under_cover() -> i32 { + var worst : i32 = 0; + var i : i32 = 0; + while (i < AURORA_COUNT) { + const ribbon : i32 = mix(BACKDROP, AURORA[@intCast(i)], AURORA_UNDER_COVER_PERMILLE); + var s : i32 = 0; + while (s < STYLE_COLOUR_COUNT) { + const cell : i32 = mix(ribbon, STYLE_COLOUR[@intCast(s)], COVER_MAP_PERMILLE); + if (luma(cell) > worst) { + worst = luma(cell); + } + s = s + 1; + } + if (luma(ribbon) > worst) { + worst = luma(ribbon); + } + i = i + 1; + } + return worst; +} + +/// --- tests --- + +test the_deck_is_the_default { + assert DEFAULT_LOOK == LOOK_DECK; + assert DEFAULT_PRESET == PRESET_WIDE; + // Tome slides are 2752x1536; the wide preset is 16:9. + assert PRESET_W[0] * 9 == PRESET_H[0] * 16; + assert orient(PRESET_WIDE) == ORIENT_SIDE; + assert orient(PRESET_SQUARE) == ORIENT_STACK; + assert orient(PRESET_TALL) == ORIENT_STACK; +} + +test every_scene_is_a_slide { + assert layout_for(SCENE_TITLE) == LAYOUT_COVER; + assert layout_for(SCENE_OVERVIEW) == LAYOUT_STAGE; + assert layout_for(SCENE_LENS) == LAYOUT_STAGE; + assert layout_for(SCENE_FOCUS) == LAYOUT_SPLIT; + assert layout_for(SCENE_EVIDENCE) == LAYOUT_SPLIT; + assert layout_for(SCENE_CALL) == LAYOUT_SPLIT; + var k : u8 = 0; + while (k < SCENE_KIND_COUNT) { + assert layout_for(k) < LAYOUT_COUNT; + k = k + 1; + } + assert layout_for(SCENE_KIND_COUNT) == LAYOUT_NONE; + assert layout_for(255) == LAYOUT_NONE; + // every layout has a hero, and every layout has somewhere to put its headline + var lay : u8 = 0; + while (lay < LAYOUT_COUNT) { + var p : u8 = 0; + while (p < PRESET_COUNT) { + assert has_region(lay, p, REGION_HERO); + assert has_region(lay, p, REGION_TITLE) or has_region(lay, p, REGION_CARD); + assert has_region(lay, p, REGION_KICKER); + p = p + 1; + } + lay = lay + 1; + } +} + +test regions_keep_the_safe_margin { + var lay : u8 = 0; + while (lay < LAYOUT_COUNT) { + var o : u8 = 0; + while (o < 2) { + var r : u8 = 0; + while (r < REGION_COUNT) { + const x0 : i32 = region_permille(lay, o, r, 0); + const y0 : i32 = region_permille(lay, o, r, 1); + const x1 : i32 = region_permille(lay, o, r, 2); + const y1 : i32 = region_permille(lay, o, r, 3); + if (x1 > x0) { + assert y1 > y0; + if ((r == REGION_HERO and hero_is_backdrop(lay)) == false) { + assert x0 >= SAFE_MARGIN_PERMILLE and y0 >= SAFE_MARGIN_PERMILLE; + assert x1 <= 1000 - SAFE_MARGIN_PERMILLE and y1 <= 1000 - SAFE_MARGIN_PERMILLE; + } + } + r = r + 1; + } + o = o + 1; + } + lay = lay + 1; + } +} + +test text_never_covers_the_map { + var lay : u8 = 0; + while (lay < LAYOUT_COUNT) { + var p : u8 = 0; + while (p < PRESET_COUNT) { + if (hero_is_backdrop(lay) == false) { + assert overlap(lay, p, REGION_TITLE, REGION_HERO) == false; + assert overlap(lay, p, REGION_CARD, REGION_HERO) == false; + assert overlap(lay, p, REGION_KICKER, REGION_HERO) == false; + } + assert overlap(lay, p, REGION_TITLE, REGION_SUBTITLE) == false; + assert overlap(lay, p, REGION_TITLE, REGION_KICKER) == false; + assert overlap(lay, p, REGION_CARD, REGION_KICKER) == false; + assert overlap(lay, p, REGION_SUBTITLE, REGION_KICKER) == false; + p = p + 1; + } + lay = lay + 1; + } +} + +test the_map_window_fits_the_hero { + // The whole camera window lands inside the hero, so focus_keeps_neighbours and + // overview_shows_the_whole_map of reel.t27 hold on every slide too. + var lay : u8 = 0; + while (lay < LAYOUT_COUNT) { + var p : u8 = 0; + while (p < PRESET_COUNT) { + var c : i32 = 0; + while (c < 2) { + var hw : i32 = FOCUS_HW; + if (c == 1) { + hw = OVERVIEW_HW; + } + const hh : i32 = half_height(hw, p); + assert slide_x(0, hw, lay, p, 0) == hero_cx(lay, p); + assert slide_y(0, hw, lay, p, 0) == hero_cy(lay, p); + assert slide_x(0, hw, lay, p, 0 - hw) >= region_px(lay, p, REGION_HERO, 0); + assert slide_x(0, hw, lay, p, hw) <= region_px(lay, p, REGION_HERO, 2); + assert slide_y(0, hw, lay, p, 0 - hh) >= region_px(lay, p, REGION_HERO, 1); + assert slide_y(0, hw, lay, p, hh) <= region_px(lay, p, REGION_HERO, 3); + c = c + 1; + } + p = p + 1; + } + lay = lay + 1; + } +} + +test the_focused_cell_reads_larger { + var p : u8 = 0; + while (p < PRESET_COUNT) { + const focus_r : i32 = slide_r(FOCUS_HW, LAYOUT_SPLIT, p); + const map_r : i32 = slide_r(OVERVIEW_HW, LAYOUT_STAGE, p); + assert focus_r >= MIN_FOCUS_R_PX; + assert 2 * focus_r >= 3 * map_r; + p = p + 1; + } +} + +test two_lines_and_four_rows_fit_every_preset { + var p : u8 = 0; + while (p < PRESET_COUNT) { + assert text_fits(MAX_HEADLINE_LINES, MAX_ROWS, LAYOUT_SPLIT, p); + assert text_fits(1, 0, LAYOUT_STAGE, p); + assert text_fits(MAX_HEADLINE_LINES, 0, LAYOUT_COVER, p); + assert region_h(LAYOUT_SPLIT, p, REGION_KICKER) >= CAPTION_PX[p]; + assert region_h(LAYOUT_COVER, p, REGION_SUBTITLE) >= leading_px(BODY_PX[p], ROW_LEADING_PERMILLE); + p = p + 1; + } +} + +test more_text_is_a_second_slide { + var p : u8 = 0; + while (p < PRESET_COUNT) { + assert text_fits(MAX_HEADLINE_LINES, MAX_ROWS + 1, LAYOUT_SPLIT, p) == false; + assert text_fits(MAX_HEADLINE_LINES + 1, 0, LAYOUT_SPLIT, p) == false; + assert text_fits(0, 1, LAYOUT_SPLIT, p) == false; + assert text_fits(1, 1, LAYOUT_STAGE, p) == false; + assert text_fits(1, 0, LAYOUT_NONE, p) == false; + p = p + 1; + } +} + +test lines_are_long_enough_to_read { + var p : u8 = 0; + while (p < PRESET_COUNT) { + assert chars_per_line(card_inner_w(p), HEADLINE_PX[p], HEADLINE_EM_PERMILLE) >= 14; + assert chars_per_line(card_inner_w(p), BODY_PX[p], BODY_EM_PERMILLE) >= 36; + assert chars_per_line(region_w(LAYOUT_COVER, p, REGION_TITLE), COVER_PX[p], HEADLINE_EM_PERMILLE) >= 10; + p = p + 1; + } +} + +test the_card_fades_and_never_pops { + var n : i32 = MIN_SCENE_FRAMES; + while (n <= 300) { + assert card_alpha(0, n) == 0; + assert card_alpha(n - 1, n) == 0; + assert card_alpha(TRANSITION_FRAMES, n) == 1000; + var k : i32 = 1; + while (k < n) { + const a : i32 = card_alpha(k, n); + assert a >= 0 and a <= 1000; + // up while the camera lands, flat, then down: never a jump of more than one step + assert absdiff(a, card_alpha(k - 1, n)) <= 1000 / FADE_OUT_FRAMES; + k = k + 1; + } + n = n + 1; + } +} + +test text_reads_on_glass { + const under : i32 = worst_under_card(); + assert luma(TEXT) - under >= MIN_TEXT_CONTRAST; + assert luma(TEXT_DIM) - under >= MIN_DIM_CONTRAST; + assert luma(KICKER) - under >= MIN_KICKER_CONTRAST; + assert luma(TEXT) - worst_under_cover() >= MIN_TEXT_CONTRAST; + // the glass is a tint, not a sheet: the backdrop still shows through + assert GLASS_FILL_PERMILLE > 0 and GLASS_FILL_PERMILLE < GLASS_BORDER_PERMILLE; +} + +test decoration_never_wears_a_status_colour { + var i : i32 = 0; + while (i < AURORA_COUNT) { + assert distance(AURORA[@intCast(i)], style_colour(STYLE_VERIFIED)) >= MIN_DECOR_DISTANCE; + assert distance(AURORA[@intCast(i)], style_colour(STYLE_PUBLISHED)) >= MIN_DECOR_DISTANCE; + assert distance(AURORA[@intCast(i)], style_colour(STYLE_GAP)) >= MIN_DECOR_DISTANCE; + i = i + 1; + } +} + +test gaps_stay_loud_on_a_dark_slide { + assert style_dashed(STYLE_GAP); + assert style_dashed(STYLE_UNKNOWN); + assert style_dashed(200); + assert style_dashed(STYLE_VERIFIED) == false; + assert style_dashed(STYLE_PUBLISHED) == false; + assert distance(style_colour(STYLE_GAP), style_colour(STYLE_VERIFIED)) >= MIN_GAP_DISTANCE; + assert distance(style_colour(STYLE_GAP), style_colour(STYLE_PUBLISHED)) >= MIN_GAP_DISTANCE; + assert style_colour(200) == style_colour(STYLE_UNKNOWN); + var a : i32 = 0; + while (a < STYLE_COLOUR_COUNT) { + var b : i32 = a + 1; + while (b < STYLE_COLOUR_COUNT) { + assert distance(STYLE_COLOUR[@intCast(a)], STYLE_COLOUR[@intCast(b)]) >= MIN_STATUS_DISTANCE; + b = b + 1; + } + a = a + 1; + } + // every status colour stands off the backdrop + var s : i32 = 0; + while (s < STYLE_COLOUR_COUNT) { + assert distance(STYLE_COLOUR[@intCast(s)], BACKDROP) >= MIN_STATUS_DISTANCE; + s = s + 1; + } +} + +test colour_arithmetic { + assert chan(0x123456, 16) == 0x12; + assert chan(0x123456, 8) == 0x34; + assert chan(0x123456, 0) == 0x56; + assert rgb(0x12, 0x34, 0x56) == 0x123456; + assert mix(0x000000, 0xFFFFFF, 0) == 0x000000; + assert mix(0x000000, 0xFFFFFF, 1000) == 0xFFFFFF; + assert mix(0x000000, 0xC8C8C8, 500) == 0x646464; + assert luma(0xFFFFFF) == 255; + assert luma(0x000000) == 0; + assert distance(0x102030, 0x102030) == 0; + assert distance(0x000000, 0x0A0B0C) == 33; +} + +invariant fades_fit_the_shortest_scene { + assert TRANSITION_FRAMES + FADE_OUT_FRAMES < MIN_SCENE_FRAMES; +} + +invariant tables_match_their_catalogs { + assert LAYOUT_COUNT * 2 * REGION_COUNT * 4 == 120 and STYLE_COLOUR_COUNT == STYLE_UNKNOWN + 1; +}