/**
* 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)}