Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/website-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ jobs:
run: npm run check:explorer-languages
- name: Catalog generators
run: npm run test:sync-skills && npm run test:sync-crons
# The tiers, the touch minimum and the QA matrix come from
# specs/ui/viewport.t27 through the compiler; the two generated files
# must be the ones the vendored spec produces, and the generator must
# still refuse a spec whose own tests do not hold.
- name: Viewport spec
run: npm run check:viewport && npm run test:viewport-spec

- name: Build
run: npx vite build
Expand Down Expand Up @@ -144,6 +150,15 @@ jobs:
CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }}
run: npm run check:render -- --no-build

# The five explorers and the spec explorer at every size in the spec's
# matrix: one pane on a phone, two above, the deep-linked card open and
# on screen, 44px controls where a finger is the pointer, one scroller
# per pane, nothing sideways. Skips itself without Chrome.
- name: Explorer viewport contract
env:
CHROME_PATH: ${{ steps.chrome.outputs.chrome-path }}
run: npm run check:explorer-viewport -- --no-build

# A51: four PRs merged, every check green, and the live site changed
# nothing -- this app deploys from a second repository that holds build
# output only, copied there by hand. Every check above runs against the
Expand Down
9 changes: 6 additions & 3 deletions apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"check:spec-catalog": "node --experimental-strip-types qa/spec-catalog-contract.mjs",
"core": "node scripts/spec-core.mjs",
"check:shared-core": "node --experimental-strip-types qa/shared-spec-core.mjs",
"prebuild": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs",
"prebuild:ci": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs",
"prebuild": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs && node scripts/viewport-from-spec.mjs",
"prebuild:ci": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs && node scripts/viewport-from-spec.mjs",
"dev": "vite",
"build": "vite build",
"build:ci": "vite build",
Expand Down Expand Up @@ -69,7 +69,10 @@
"check:tools": "node --experimental-strip-types qa/tools-spec-contract.mjs",
"test:agents-specs": "node --test scripts/agents-from-specs.test.mjs",
"check:docs": "node --experimental-strip-types qa/docs-spec-contract.mjs",
"test:docs-specs": "node --test scripts/docs-from-specs.test.mjs"
"test:docs-specs": "node --test scripts/docs-from-specs.test.mjs",
"check:viewport": "node scripts/viewport-from-spec.mjs --check",
"test:viewport-spec": "node --test scripts/viewport-from-spec.test.mjs",
"check:explorer-viewport": "node qa/explorer-viewport-contract.mjs"
},
"dependencies": {
"@babylonjs/core": "^9.25.0",
Expand Down
186 changes: 186 additions & 0 deletions apps/website/public/t27/files/specs/ui/viewport.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// SPDX-License-Identifier: Apache-2.0
; specs/ui/viewport.t27 -- the one viewport contract of the t27.ai site (breakpoints, touch targets, QA matrix)
; Source of truth for how the site's explorers (#/specs, #/skills, #/crons, #/agents, #/tools,
; #/functions) lay themselves out at a given viewport. The site (gHashTag/trinity, apps/website)
; vendors this file byte-identical under public/t27/files/specs/ui/ and generates
; src/lib/viewport.generated.ts and src/styles/viewport.generated.css from it with the real
; compiler (scripts/viewport-from-spec.mjs); nothing below is parsed with a regex and no
; breakpoint is typed twice. The generator also evaluates the test blocks below against the
; declared constants, because the analyzer's typecheck.ok is necessary, not sufficient
; (it stays true for `assert 1 > 2`). ASCII only (L3), English only (LANG-EN).

; SOURCE (measured on live t27.ai, bundle index-B4FDqqXk.js, 2026-09-09 ~18:40 UTC, cloud
; Chromium, DPR 1; screenshots kept with the round-3 report):
; 390x844 #/tools?tool=mcp%2Fgitbutler only the 62-item list renders, the card
; tool_mcp_gitbutler is not in the viewport, document scrollHeight 924 > 844.
; Reproduced on a build of trinity main c881e7f: the hash written after mount is
; ignored (useHashParams reads the hash once), so the list pane stays and the
; document scrolls; a fresh navigation to the same deep link does open the card.
; 390x844 #/queen rail is a horizontal strip, 12 tiles of 44x74, about 6 visible;
; 7 interactive targets below 40 px; .queen27-hud-command.is-compact
; scrollWidth 612 > clientWidth 344.
; 1024x768 #/queen the rail of 12 does not fit: PROJECT hidden under COLLAPSE.
; 1920x1080 #/docs text column left-aligned, nested scrollers (Docs 2, Tools 3).
; Queen.css carries 36 media queries at 640/760/900/901/1100; the explorers use 760/1100
; in TSX; qa/queen-viewport-contract.mjs checks 1280 only. There was no single source.
; The Queen rail and HUD are described here (RAIL_*) but are not changed by this spec's
; first consumer (P0 = explorers); a QA contract may report them as warnings only.
; phi^2 + 1/phi^2 = 3 | TRINITY

module ui_viewport;

pub const KIND : str = "viewport";
pub const ID : str = "ui/viewport";
pub const NAME : str = "Site viewport contract";
; The generated artifacts the site derives from this file (relative to apps/website).
pub const GENERATED : [2]str = ["src/lib/viewport.generated.ts", "src/styles/viewport.generated.css"];

; --- Tiers -----------------------------------------------------------------------------
; Four tiers by CSS viewport width (window.innerWidth), inclusive upper bounds:
; phone : width <= PHONE_MAX
; tablet : PHONE_MAX < width <= TABLET_MAX
; desktop : TABLET_MAX < width <= DESKTOP_MAX
; wide : width > DESKTOP_MAX
pub const TIERS : [4]str = ["phone", "tablet", "desktop", "wide"];
pub const PHONE_MAX : u16 = 600;
pub const TABLET_MAX : u16 = 1024;
pub const DESKTOP_MAX : u16 = 1600;
; A pointer that cannot hover (touch) is read from the pointer:coarse media feature.
pub const COARSE_POINTER_QUERY : str = "(pointer: coarse)";
; Below this width the explorer header drops its subtitle and note (compact chrome). Not a
; tier: it is the 1100 the explorers' TSX already used, kept so the desktop tier renders the
; same at 1025-1099 as before this spec; it sits strictly inside the desktop tier.
pub const HEADER_CHROME_MAX : u16 = 1100;

; --- Panes -----------------------------------------------------------------------------
; phone: master-detail, list pane OR card pane, never both; a `?<key>=` deep link opens the
; card pane directly and the card carries a control back to the list.
; tablet: list and card side by side, the list's filters folded into one collapsible row.
; desktop and wide: unchanged three-pane layout (header, ladder, list + card).
pub const PHONE_PANES : u8 = 1;
pub const TABLET_PANES : u8 = 2;
pub const DESKTOP_PANES : u8 = 2;
; Exactly one vertical scroller per visible pane; the document itself never scrolls.
pub const ONE_SCROLLER_PER_PANE : bool = true;
pub const DOCUMENT_SCROLLS : bool = false;

; --- Targets ---------------------------------------------------------------------------
; Smallest interactive box (CSS px, each side) on phone and tablet; WCAG 2.5.5 uses 44.
pub const TOUCH_TARGET_MIN_PX : u8 = 44;
; The pane-switch control on a phone card ("<- list") must meet the same minimum.
pub const BACK_CONTROL_MIN_PX : u8 = 44;

; --- QA matrix -------------------------------------------------------------------------
; The six sizes every viewport contract iterates; widths and heights are the same six
; pairs written twice so an integer test can index them. "WxH" in CSS px.
pub const VIEWPORTS : [6]str = ["390x844", "600x900", "768x1024", "1024x768", "1280x800", "1920x1080"];
pub const VIEWPORT_WIDTHS : [6]u16 = [390, 600, 768, 1024, 1280, 1920];
pub const VIEWPORT_HEIGHTS : [6]u16 = [844, 900, 1024, 768, 800, 1080];
; The tier each matrix entry falls into, by the bounds above (checked by a test below).
pub const VIEWPORT_TIERS : [6]str = ["phone", "phone", "tablet", "tablet", "desktop", "wide"];
; The width the desktop layout must stay pixel-identical at across a change (before/after).
pub const DESKTOP_REFERENCE_WIDTH : u16 = 1280;

; --- Queen rail capacity (described, not changed here) ---------------------------------
; Numbers read from apps/website/src/pages/Queen.css of trinity main c881e7f and from the
; measurement above; each carries where it comes from.
; RAIL_TILES 12 src/components/queenHud.ts:35 (HUD_KEYS, twelve views)
; RAIL_TILE_W_COMPACT 44 Queen.css:5311-5312 (.is-compact .queen27-hud-cmd flex 0 0 44px, min-width 44px)
; RAIL_TILE_GAP_COMPACT 4 Queen.css:5302 (.is-compact gap: 4px)
; RAIL_TILE_H_COMPACT 74 measured at 390x844 (the CSS fixes no height; padding at Queen.css:5315)
; RAIL_CLIENT_W_PHONE 344 measured clientWidth of the compact rail at 390x844
; RAIL_TILE_H_COLUMN 48 Queen.css:3997-3998 (the rail's own budget comment: "six tile rows at ~48 px")
; RAIL_TILE_GAP_COLUMN 6 Queen.css:3868 (gap: 6px)
; RAIL_CHROME_H 148 Queen.css:4277 (hud-top height 64px) + Queen.css:4397 (hud-bottom height 84px)
; Capacity by viewport height in the one-column rail: (height - RAIL_CHROME_H) / RAIL_ROW_H_COLUMN,
; integer division. It predicts 11 tiles at 768 px, fewer than the 12 the rail carries, which
; is the 1024x768 observation above. Tag: model (a CSS budget plus one consistent observation),
; not a measurement of every height.
pub const RAIL_TILES : u8 = 12;
pub const RAIL_TILE_W_COMPACT : u8 = 44;
pub const RAIL_TILE_GAP_COMPACT : u8 = 4;
pub const RAIL_TILE_H_COMPACT : u8 = 74;
pub const RAIL_CLIENT_W_PHONE : u16 = 344;
pub const RAIL_TILE_H_COLUMN : u8 = 48;
pub const RAIL_TILE_GAP_COLUMN : u8 = 6;
pub const RAIL_CHROME_H : u16 = 148;
; One rail row: tile plus gap, for each mode (checked against the parts by a test).
pub const RAIL_ROW_H_COLUMN : u8 = 54;
pub const RAIL_ROW_W_COMPACT : u8 = 48;
; Tiles that must be visible without scrolling the rail, on every size in the matrix.
pub const RAIL_MIN_VISIBLE : u8 = 6;
; One entry per VIEWPORT_HEIGHTS, from the formula above.
pub const RAIL_CAPACITY_BY_HEIGHT : [6]u8 = [12, 13, 16, 11, 12, 17];

; --- Tests (typechecked by the compiler, evaluated by the site generator) ---------------

test breakpoints_are_monotone {
assert PHONE_MAX < TABLET_MAX;
assert TABLET_MAX < DESKTOP_MAX;
assert HEADER_CHROME_MAX > TABLET_MAX;
assert HEADER_CHROME_MAX <= DESKTOP_MAX;
}

test touch_target_is_at_least_44 {
assert TOUCH_TARGET_MIN_PX >= 44;
assert BACK_CONTROL_MIN_PX >= TOUCH_TARGET_MIN_PX;
}

test matrix_covers_every_tier {
assert VIEWPORT_WIDTHS[0] <= PHONE_MAX;
assert VIEWPORT_WIDTHS[1] <= PHONE_MAX;
assert VIEWPORT_WIDTHS[2] > PHONE_MAX;
assert VIEWPORT_WIDTHS[2] <= TABLET_MAX;
assert VIEWPORT_WIDTHS[3] <= TABLET_MAX;
assert VIEWPORT_WIDTHS[4] > TABLET_MAX;
assert VIEWPORT_WIDTHS[4] <= DESKTOP_MAX;
assert VIEWPORT_WIDTHS[5] > DESKTOP_MAX;
assert VIEWPORT_TIERS[0] == "phone";
assert VIEWPORT_TIERS[1] == "phone";
assert VIEWPORT_TIERS[2] == "tablet";
assert VIEWPORT_TIERS[3] == "tablet";
assert VIEWPORT_TIERS[4] == "desktop";
assert VIEWPORT_TIERS[5] == "wide";
assert DESKTOP_REFERENCE_WIDTH == VIEWPORT_WIDTHS[4];
}

test matrix_is_the_six_quoted_sizes {
assert VIEWPORTS[0] == "390x844";
assert VIEWPORTS[1] == "600x900";
assert VIEWPORTS[2] == "768x1024";
assert VIEWPORTS[3] == "1024x768";
assert VIEWPORTS[4] == "1280x800";
assert VIEWPORTS[5] == "1920x1080";
}

test phone_shows_one_pane {
assert PHONE_PANES == 1;
assert TABLET_PANES == 2;
assert ONE_SCROLLER_PER_PANE == true;
assert DOCUMENT_SCROLLS == false;
}

test rail_capacity_follows_the_css_budget {
assert RAIL_ROW_H_COLUMN == RAIL_TILE_H_COLUMN + RAIL_TILE_GAP_COLUMN;
assert RAIL_ROW_W_COMPACT == RAIL_TILE_W_COMPACT + RAIL_TILE_GAP_COMPACT;
// capacity * row fits the budget, one more row does not: integer division, per height
assert RAIL_CAPACITY_BY_HEIGHT[0] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[0] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[0] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[0] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[1] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[1] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[1] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[1] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[2] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[2] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[2] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[2] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[3] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[3] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[3] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[3] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[4] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[4] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[4] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[4] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[5] * RAIL_ROW_H_COLUMN <= VIEWPORT_HEIGHTS[5] - RAIL_CHROME_H;
assert RAIL_CAPACITY_BY_HEIGHT[5] * RAIL_ROW_H_COLUMN + RAIL_ROW_H_COLUMN > VIEWPORT_HEIGHTS[5] - RAIL_CHROME_H;
// the one-column rail cannot hold all twelve at 768 px: this is the 1024x768 observation
assert RAIL_CAPACITY_BY_HEIGHT[3] < RAIL_TILES;
// every size in the matrix still shows the minimum
assert RAIL_CAPACITY_BY_HEIGHT[3] >= RAIL_MIN_VISIBLE;
// the compact strip on a phone shows the minimum without scrolling, but not all twelve
assert RAIL_MIN_VISIBLE * RAIL_ROW_W_COMPACT <= RAIL_CLIENT_W_PHONE;
assert RAIL_TILES * RAIL_ROW_W_COMPACT > RAIL_CLIENT_W_PHONE;
}
Loading
Loading