/** * Renders every tile the same way, turning each one's `TileView` into the same * markup whatever produced it. The shell around them wraps the grid and the * wide tiles in the page, together with the client that listens for updates. */ import type { Status } from "./types.ts"; export { renderTile } from "./tile-render.ts"; import { REPOS_PATH } from "./repo-page-href.ts"; import { BOTTOM_CHART_RULES, DASHBOARD_GRID_RULE, TILE_BOX_RULE, tileContentRules, } from "./chart-layout.ts"; import { escapeHtml, SPARKLINE_HEIGHT, } from "./lib.ts"; import { STATUS_EDGE, STATUS_WASH, TEXTURE_ALPHA, TEXTURE_WIDTH, } from "./palette.ts"; import { faviconHref, faviconLink, type FaviconStatus } from "./favicon.ts"; import { GREEN_STAR_RULES } from "./green-star.ts"; import { paintStatusFavicon } from "./favicon-client.ts"; import { followUpdates, liveUpdateStream } from "./stream-client.ts"; import { paintDashboardMessageInput } from "./dashboard-message-client.ts"; import { reconcileTiles } from "./tiles-client.ts"; import { DASHBOARD_MESSAGE_FADE_MS, DASHBOARD_MESSAGE_MAX_LENGTH, DASHBOARD_MESSAGE_VISIBLE_MS, dashboardMessageOpacity, type DashboardMessage, } from "./dashboard-message.ts"; import { DASHBOARD_THEME_CLIENT, DASHBOARD_THEME_HEAD, DASHBOARD_THEME_STYLES, dashboardThemeToggle, statusLayer, } from "./theme.ts"; const FAVICON_PNG_HREFS = JSON.stringify({ good: faviconHref("good"), warn: faviconHref("warn"), bad: faviconHref("bad"), "bad-crying": faviconHref("bad-crying"), }); const PAINT_STATUS_FAVICON = paintStatusFavicon.toString(); const LIVE_UPDATE_STREAM = liveUpdateStream.toString(); const FOLLOW_UPDATES = followUpdates.toString(); const DASHBOARD_MESSAGE_OPACITY = dashboardMessageOpacity.toString(); const PAINT_DASHBOARD_MESSAGE_INPUT = paintDashboardMessageInput.toString(); const RECONCILE_TILES = reconcileTiles.toString(); // How long past the refresh interval the freshness indicator stays orange before // it turns red. The page treats the same span of silence from the server as a // stream that has stopped delivering, and reconnects. const STALE_GRACE_MS = 10_000; export const FAVICON_CRY_AFTER_MS = 60 * 60 * 1000; // A texture tile is this tall, and one line's worth of it repeats down the // layer at that spacing. The width is the pattern's own period, so the tile // holds a whole number of them and repeats across without a seam. const TEXTURE_HEIGHT = 24; /** * A tiling mask of one stroked path, for the texture that sits behind a tile's * flat color wash. The mask lets CSS paint the path with the active theme's * status color. The path is drawn in a `period` by `TEXTURE_HEIGHT` space, and * its corners come to a point. * * A path has to begin and end on the left and right edges of that space, * heading the same way at both, so the two ends butt together where the * pattern repeats. The round cap is what makes that joint clean: it fills the * stroke out to the edge, and the part that reaches past is clipped away. */ function strokeTexture(path: string, period: number): string { const svg = `` + ``; const mask = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`; return `--texture-mask:${mask};mask-size:${period}px ${TEXTURE_HEIGHT}px;` + `-webkit-mask-size:${period}px ${TEXTURE_HEIGHT}px;` + `mask-image:var(--texture-mask);-webkit-mask-image:var(--texture-mask)`; } // The wave rises and falls once across its period, which is long enough for a // stroke this wide to follow without a crest reaching the trough below it. It // starts and ends halfway up a rise, which is where the pattern repeats. const WAVE_PERIOD = 60; const WAVE_TEXTURE = strokeTexture( `M0 12q${WAVE_PERIOD / 4} -8 ${WAVE_PERIOD / 2} 0t${WAVE_PERIOD / 2} 0`, WAVE_PERIOD, ) + `;background-color:${statusLayer("warn", TEXTURE_ALPHA)}`; // Two turns of the zig-zag, which puts its period at half the tile. It starts // and ends halfway along a run rather than on a corner, which keeps every // corner inside a tile, where the join between its two runs draws the point. const ZIGZAG_TEXTURE = strokeTexture( "M0 12l6-6 12 12 12-12 12 12 6-6", 48, ) + `;background-color:${statusLayer("bad", TEXTURE_ALPHA)}`; // Two copies of the same dot grid make a triangular lattice, where each dot has // six neighbors at one distance rather than a square lattice's four near ones // and four far ones. The copies are one dot spacing apart across, one spacing // times the square root of three apart down, and the second copy is shifted by // half of each. That puts its dots above the midpoint of the gap between two // dots of the first copy, one spacing away from both, and the same distance // again from the two below. const DOT_SPACING_PX = 16; const DOT_ROW_PX = DOT_SPACING_PX * Math.sqrt(3); const DOT_STIPPLE = `radial-gradient(${ statusLayer("unknown", 0.15) } 1px,transparent 1px)`; const DOT_TEXTURE = [ `background-image:${DOT_STIPPLE},${DOT_STIPPLE};`, `background-position:0 0,${DOT_SPACING_PX / 2}px ${ (DOT_ROW_PX / 2).toFixed(2) }px;`, `background-size:${DOT_SPACING_PX}px ${DOT_ROW_PX.toFixed(2)}px`, ].join(""); // The tallest a tile is taken to be against its own width. The grid gives a // tile at least 220 pixels across and lets its height follow its content, and // the tightest the two come is a little under square, on the narrowest layout // the grid produces. The margin above that carries a label or a sub line that // wraps onto another line or two. const MAX_TILE_ASPECT = 1.5; // The side of the square texture layer, as a percentage of the tile's width. // Turning the layer sweeps its corners inward, so half its side has to reach // the tile's corner from the tile's center — the tile's half-diagonal — at // whatever angle the texture is turned to. const TEXTURE_LAYER_PCT = Math.ceil(Math.hypot(1, MAX_TILE_ASPECT) * 100); /** * The texture a box of each class in `scopes` wears for its status, drawn in * the `.texture` layer it holds first, as its own first child or as the first * child of its link (linkedBox). The box needs a stacking context of its own * and to clip its overflow, as a tile has. */ export function textureRules(scopes: readonly string[]): string { const each = (status: Status) => scopes.map((scope) => `.${scope}.${status} .texture::before`).join(","); return `.texture{position:absolute;inset:0;z-index:-1;overflow:hidden;mask-image:linear-gradient(to bottom,#000 15%,transparent 70%)} .texture::before{content:"";position:absolute;top:50%;left:50%;width:${TEXTURE_LAYER_PCT}%;aspect-ratio:1;transform:translate(-50%,-50%) rotate(var(--turn,30deg))} ${each("unknown")}{${DOT_TEXTURE}} ${each("warn")}{${WAVE_TEXTURE};--turn:120deg} ${each("bad")}{${ZIGZAG_TEXTURE}}`; } const STATUSES: readonly Status[] = ["good", "warn", "bad", "unknown"]; // A tile's own color: the wash behind it and the border around it, both // stronger the more serious the status is. An unknown tile takes neither and // keeps the neutral border below. const TILE_RULES = STATUSES.filter((s) => s !== "unknown").map((s) => ` .tile.${s},.tile.wide.${s}{border-color:${ statusLayer(s, STATUS_EDGE[s]) };background:${statusLayer(s, STATUS_WASH[s])}}` ).join("\n"); const BIG_RULES = STATUSES.map((s) => `.big.${s}{color:var(--status-${s}-text)}` ) .join(""); type ViewerTimeElement = Pick; /** Replace marked absolute timestamps with the viewer's local wall-clock time. */ export function formatViewerTimes( times: Iterable = document.querySelectorAll< HTMLTimeElement >( "time[data-viewer-time][datetime]", ), formatter: { format(value: number): string } = new Intl.DateTimeFormat( undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23", }, ), ): void { for (const time of times) { const at = Date.parse(time.dateTime); if (Number.isFinite(at)) time.textContent = formatter.format(at); } } function renderShell( gridHtml: string, wideHtml: string, ago: number, refreshMs: number, shellVersion: string, status: FaviconStatus, serverRedSince: number | null = null, serverRedAgeMs: number | null = null, message: DashboardMessage = { text: "", updatedAt: null, revision: 0 }, ): string { return `Dashboard — LIVE ${DASHBOARD_THEME_HEAD} ${faviconLink(status)}
● LIVEgo/fabricwall
updated ${ago}s ago
${gridHtml}
${wideHtml}
▤ Repositories${dashboardThemeToggle()}
${DASHBOARD_THEME_CLIENT} `; } export function shell( gridHtml: string, wideHtml: string, ago: number, refreshMs: number, shellVersion: string, status: FaviconStatus, serverRedSince: number | null = null, serverRedAgeMs: number | null = null, message: DashboardMessage = { text: "", updatedAt: null, revision: 0 }, ): string { return renderShell( gridHtml, wideHtml, ago, refreshMs, shellVersion, status, serverRedSince, serverRedAgeMs, message, ); }