Three parts, one repo:
src/— phone-width layout checks (Playwright). The dynamic layout-measurement layer (L2 of the layout-quality architecture): render a screen at true phone geometry and assert about the painted pixels, not the source. Consumed over npm by twelve Angular frontends.android/— the WebView app shell (Gradle). The Activity every wrapper app is a WebView around: system-bar insets, page-coloured bars, back through SPA history, restore-on-reopen. Consumed by path, as a composite build.scaffold/—@xinutec/ui-scaffold, the Angular app frame. The top bar, where up goes from each screen, and sheets and dialogs the back gesture closes. A package of its own, because it has to be built by ng-packagr: see below.
They share a repo because they are the same job seen from two sides — one keeps the web UI honest about phone geometry, the other is the frame that UI is shown in on a phone — and because the alternative was eight hand-maintained copies of one Activity, already drifting.
Neither half can disturb the other's consumers. files: ["dist"] in
package.json means android/ never reaches an npm consumer's node_modules,
and npm consumers pin a SHA, so an Android commit is invisible to them until they
bump. Gradle consumers resolve by path against whatever is checked out, so a
change to src/ is invisible to them entirely.
Every Angular app in the fleet draws the same top bar and wires its overlays into history the same way, from here. The console in memview is where it was written.
<ui-scaffold title="…" [menu]="…">, once, above the router. The root screen and any other screen without up (a top-level destination the menu reaches) get a leadingmenu(when the app passes one) and the screen's own name, else the app's; a screen whose route declares up getsarrow_backand its own name. What the element holds is drawn at the end on every screen; a page adds its own actions after it with<ng-template scaffoldActions>.- A count on the menu button:
[menuBadge]="n"draws Material's badge on it (hidden at 0), and[menuLabel]says it in words, for example'Menu — 2 sync conflicts need attention'. - Every route but the root declares
data.upordata.top: true(dev-lint #1793 flags one with neither).topis a peer main screen that keeps the menu, for an app with several equal ones: a destination the menu lists, as a navigation drawer's are. A utility screen (settings) is drilled in and declares up.TOPandUPname the keys. - Up is declared on the route, never set by a page:
{ path: 's/:id/w/:run', data: { up: '/s/:id' } }. The long form,{ path: '/s/:id', keep: ['task'], label: 'the session' }, carries query parameters over and names the arrow for a screen reader. Up is the parent screen, not history, and a declaration is what a lint can check without running the app. - A screen with several parents (a detail many lists open, a settings
screen reached from anywhere) adds
opener: true: up returns to the screen that opened it, as back does, and goes topathonly when nothing in the app did — Android's "Up vs Back" for a screen with multiple entry points. scaffoldTitle(() => …)in a page's constructor names the screen for as long as the page is on view.Sheets.open(…)andDialogs.open(…)in place ofMatBottomSheetandMatDialog: each overlay gets a history entry, so a phone's back gesture closes the overlay and only the overlay.
Why a second package. The harness is built by plain tsc, and an Angular
decorator leaving it "carries metadata an AOT build cannot instantiate" (see
src/awake.ts), which is why its runtime pieces are plain classes with a thin
adapter in each app. A bar is a template, so it needs Angular's own library
build: ng-packagr, in partial compilation mode.
Its build is committed (scaffold/dist/), not run on install. Built by an
app's install, it failed in every app's CI: pnpm builds a git dependency inside
its store, pnpm/action-setup puts the store under a node_modules directory,
and TypeScript does not emit a file whose path runs through node_modules, so
only the entry file was compiled (memview CI, 2026-09-27; reproduced by building
under any node_modules path, and ng-packagr overrides a tsconfig that lists
the roots). Shipped built, an install also skips the ~190 packages the build
needs. The gate rebuilds it and fails if the result differs from what is
committed; the build is byte-for-byte reproducible.
Installing it names the directory: pnpm add "github:xinutec/ui-harness#<sha>&path:/scaffold". It runs nothing on install,
so it needs no allowBuilds entry. Its own lockfile is in scaffold/; it is
not a workspace member of this root.
Extracted from the life app's e2e harness after it caught, in one week: a 497px toggle row in a 380px sheet, nested scrollers that broke swipe, and a suite that had silently run at 1280×720 while claiming 390px.
The measurement code imports only types from @playwright/test (erased at
compile), so the built JS pulls in no copy of the runner — load-bearing,
because two @playwright/test instances make every suite die with "No tests
found". The consuming app resolves the one real @playwright/test from its own
node_modules (declared here as a peerDependency).
Consumers load compiled JS + .d.ts from dist/, not TypeScript source:
Playwright only transpiles TS outside node_modules, so a TS-source package
would be unimportable from an installed dependency. dist/ is gitignored; the
prepare script builds it at install time (tsc), so a plain git clone
install produces a ready-to-load package. (This replaces the old in-monorepo
mechanism of importing src/ui-harness.ts by relative path — that only worked
because both lived in the same tree.)
files therefore ships src/ and tsconfig.build.json as well as dist/,
so the package can be rebuilt from what it publishes. That matters for consumers
that install with --ignore-scripts, which is every pure Nix build: prepare
never runs there, so dist/ is absent, and with files: ["dist"] alone the
installed package was just a package.json — nothing to import and nothing to
compile, which is how thoth's packaged build failed (2026-08-01). Shipping the
sources costs a few kB and makes "install without running my code, then build it
yourself" a supported path rather than a dead end.
For the same reason @types/node is a real dependency, not a devDependency:
src/serve.ts uses process and node: imports, so it is needed to COMPILE
the package, and a consumer building from source gets nothing from a dev-only
declaration. @playwright/test stays a peer — only types are imported from
it, and a second copy of the runner makes every suite report "No tests found".
Installed as a public git dependency — anonymous https clone, no registry,
no token, no .npmrc:
pnpm add -D github:xinutec/ui-harness # @playwright/test is a peer — apps already have itThis package imposes no package manager on you. dist/ is not committed, so
your install clones the repo and runs its prepare — and prepare is
tsc -p tsconfig.build.json, naming no package manager at all rather than
npm run build as it did until 2026-08-09. Every consumer is on pnpm and so is
this repo, but nothing here would break one that was not.
In a Docker build on node:alpine, add git so the install can clone the dep:
RUN apk add --no-cache git ca-certificates && npm install -g pnpm && pnpm install --frozen-lockfile
(what life/Dockerfile does).
// frontend/e2e/ui-pages.spec.ts
import { expectNoTextOverlaps, expectNoHorizontalOverflow, expectViewportIsPhone } from '@xinutec/ui-harness';An app no longer writes a Playwright config; it says what it is and gets one.
// frontend/e2e/harness.mjs — the app-specific half, read by BOTH the config
// and the static server, so they cannot disagree.
/** @type {import('@xinutec/ui-harness/config').HarnessSpec} */
export default {
app: 'life', // must be in the APPS table (it IS the port)
dist: 'dist/life-web/browser', // the built bundle to serve
api: { '/api/me': { userId: 'test' } }, // fallback for unrouted /api/ calls
};// frontend/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import { phoneConfig } from '@xinutec/ui-harness/config';
import harness from './e2e/harness.mjs';
export default defineConfig(phoneConfig(harness, devices, { goldens: true }));The port is an allocation, so it is allocated. APPS in src/config.ts is
an ordered list and a port is an index into it — two apps cannot share one, and
an app the list does not name gets a loud error rather than a number someone
guessed. This replaces two fleet lint rules that read eleven hand-written
configs looking for collisions after the fact. They found real ones: recall and
utterance both on 4293 (recall's suite went 8/8 pass to 8/8 fail purely by
running at the same time), health and memview both on 4273, and three servers
that defaulted to the port of the app they had been copied from.
devices is passed in rather than imported here. That keeps this module
free of a runtime @playwright/test (see above), and it means an app hands over
its device table without getting to choose from it — devices['Desktop Chrome']
in a phone-width suite is not expressible. For the same reason HarnessOptions
has no use or projects: overriding those is the failure, so it is a type
error rather than a convention. What an app may still set: testMatch,
timeout, goldens.
webServer.command is generated and runs dist/serve.js, the fleet's one
static server for a production bundle — SPA fallback, the content types the
service worker needs, containment above the bundle, and the /api/ stub from
the spec. To serve a build by hand, from frontend/:
node node_modules/@xinutec/ui-harness/dist/serve.js # no arguments: the spec has themAn app that serves its own bundle (thoth's Swift binary is the thing under test)
sets server: { command: (port) => …, cwd, host, reuseExistingServer } and still
takes the allocated port.
Every app's suite includes one viewport self-guard spec:
test('the suite really runs at phone geometry', async ({ page }) => {
await page.goto('/');
await expectViewportIsPhone(page);
});expectNoTextOverlaps(page, testInfo, rootSel?, tol?)— no two pieces of painted text share pixels. Glyph-level (Range.getClientRects()), rects clipped to every overflow-clipping ancestor; same-node fragment pairs skipped.expectNoHorizontalOverflow(page, testInfo, rootSel?, allow?, tol?)— nothing escapes sideways, on EITHER edge; intended horizontal scrollers are an explicit allow-list (computedoverflow-xis a trap). The left edge matters more than it sounds: a right-hand spill announces itself by scrolling the page, while content pushed off the left is silent — LTR gives no scroll room left of the origin, so it is simply unreadable, and a right-edge-only check stays green. (This check was right-edge-only until life's wellbeing chart shipped with its axis words off the screen and three passing tests.)expectNoOccludedControls(page, sels, rootSel?)— interactive controls aren't hidden behind other paint (a FAB sunk under the bottom nav).expectViewportIsPhone(page, width?)— the checker-checker: fails loudly if device emulation ever silently drops.expectNoClippedText(page, testInfo, rootSel?, minPx?)— no visible text is permanently sheared by an overflow-clipping ancestor. The scroll test keeps it honest: text scrolled out of a scroller comes back, so only a clip the container cannot scroll away counts.expectIconFontLoaded(page, family?)— the icon font actually loaded (no tofu boxes for Material Icons).expectNoClippedIcons(page, testInfo, rootSel?, minPx?)— every icon has room for its glyph.mat-iconcarriesoverflow: hidden, which voids themin-width: autofloor that stops a flex item collapsing below its content, so an icon beside a long text sibling absorbs the row's shrink and is clipped rather than scaled — recall's status banner painted 9.6px of a 24px hourglass while the shorter sentence beside it lost 0.7px and looked fine. No other check sees it: shrinking is what avoids overflow, nothing overlaps, nothing is occluded, andexpectNoClippedTextskips icon ligatures by design. Measures the painted box against font-size, notscrollWidth, so an unloaded icon font (whose content is the literal ligature word) cannot flag every icon in the app — that failure belongs toexpectIconFontLoaded, which is worth calling beside it.expectCanvasLegible(page, testInfo, sel?, minRatio?, minPainted?)— a canvas's marks are actually visible against the page behind them. Canvas is the one place the stylesheet does not reach: an unparseable colour assigned tofillStyleis ignored in silence, leaving the previous value (black, on a fresh context). Material's system tokens compute tolight-dark(#…, #…), which no canvas can parse, so passing one straight through paints black on a dark background with nothing anywhere reporting a problem — and nothing else here can see it, since the layout checks measure geometry, unit tests never rasterise, and the page stays valid. Solidly-painted pixels only (alpha > 200), scored against the nearest opaque ancestor rather thandocument.body, which several fleet apps leave transparent. Call it underpage.emulateMedia({ colorScheme })for BOTH schemes — the classic form of this bug is invisible in light mode. It catches marks that are illegible, not merely wrong-coloured; dev-lint'sDL-CANVAS-SYSTEM-TOKENcovers the known cause statically.expectUpInTheBar(page)— on a screen whose route declares up, the scaffold's bar leads witharrow_backand its heading names the screen. Call it on every detail screen the suite visits: the scaffold's own tests cannot see which routes an app declared.expectBackClosesOverlay(page, open)—openopens a sheet or dialog; back closes it and leaves the URL alone. An overlay opened pastSheets/Dialogshas no history entry, so back leaves the screen and the check names that.MISSING_BUNDLE_RECOVERYandexpectRecoversFromMissingBundle(page, url, ready)— a service worker can serve an index naming amain-*.jsa later deploy removed, and the app never starts; its own recovery is inside that bundle. Every service-worker app pastesMISSING_BUNDLE_RECOVERYintoindex.html's<head>, before any bundle: when the bundle fails to load it unregisters the worker, deletes itsngsw:caches and reloads, at most once a minute. The check refuses the bundle once and expectsreadyafter the reload; it fails rather than passes if it never saw the bundle requested.swipeUp(page, opts?)— a real CDP touch flick, not ascrollTopshortcut.expectReachableByScroll(page, locator, scrollerSel)— swipe until the target is on-screen; fails if a nested-scroller fight keeps it unreachable.
android/ is a Gradle build publishing one library, org.xinutec:shell — the
Activity the fleet's eight WebView wrappers are. An app declares its URL and its
opt-ins; the shell owns system-bar insets (including the IME), bars painted with
the page's own colour, restore-on-reopen filtered so a spent login callback can't
strand the app, and back through SPA history on the modern dispatcher.
Consumed as a composite build, resolved by path, with no version anywhere:
// the app's android/settings.gradle.kts
includeBuild("../../ui-harness/android") {
dependencySubstitution {
substitute(module("org.xinutec:shell")).using(project(":main"))
}
}Full API, the manifest attributes an app still owns, and how to build it: android/README.md.
pnpm install --frozen-lockfile && pnpm test runs the web half's own specs. Three kinds:
tests/measurement.spec.ts — page.setContent DOM fixtures for the layout
checks (ellipsis-phantom, clip-model and icon-glyph-vs-badge false positives,
real overlap/overflow detection, the allow-list); tests/config.spec.ts — the
port allocation and the shape of the config it hands out; tests/serve.spec.ts
— the static server against a real bundle-shaped directory. pnpm run build
compiles src/ → dist/.
Twelve frontends consume this (coach, fleetwatch, gamepads, health, home, life,
memview, messages, observe, recall, thoth, utterance) and all of them now take
their config from it, so a change here lands everywhere at once — run their
ui-check after anything that touches config.ts or serve.ts.
gate.dhall covers both halves and is what the pre-commit hook runs — twelve
named checks: the pnpm install, build and specs, then the shell's unit tests and an
assembleDebug of life against it. Run it with
nix run ../dev-lint#gate -- . gate.json; each row names the dev shell it needs,
so there is no wrapper. Eight apps ride on the Android half, so a red run there
is a real regression in every one of them.
The life consumer build is unconditional: it used to print SKIPPED when life was not checked out beside this repo, and a green run that skipped it is not the same green as one that did it.