ci: take the design from @hanzo/brand instead of copying it
The dashboard carried its own :root block — a hand-copy of the house palette, and being a copy it had already drifted off it. The status colours were GitHub Primer's (#3fb950 / #f85149 / #d29922) where the house says #10b981 / #ef4444 / #f59e0b. Every surface black was a shade wrong and blue-tinted (#0b0b0d) against a scale that is strictly neutral (--surface-0 #080808). The hairline border was an opaque #25252b where the house hairline is a 6% white wash. The font stacks named neither Geist Sans nor JetBrains Mono. Of nine colours exactly one — the accent — still matched. That is what a second component source looks like a few months in, and it is why this page had to stop being one. The values now come from @hanzo/brand, and they arrive as that package's own published artifact rather than as hex codes retyped here: styles/variables.css, which it ships as a plain custom-property sheet (exports["./styles/*"], documented for a bare <link>), vendored verbatim and go:embed-ed. dashboard.css holds what is left over — layout: what is a row, what sticks, what collapses on a phone — and names no colour, radius or type size of its own. Not a @hanzo/gui port, deliberately. gui is React over Tamagui and needs a bundler, which would put npm and a JS build on the path that ships the board you read when the builds are broken, and would trade one request that returns the answer for a shell that fetches it a second later. What this page ever needed from the design system was its token vocabulary, not its components — and gui's own shell reads these same var(--hanzo-*) names, so a Go binary and a React app now spend one vocabulary from one source. go.mod stays empty; the image stays the binary and a CA bundle. Two offline gates keep it honest, because vendoring alone would only move the copy rather than end it: - TestBrandCSSIsUpstreamBytes pins the sheet to the sha256 of the version it claims to be. Without it, "just darken that one border" is a one-character local edit that silently rebuilds the second palette and nothing ever catches it. This is go.sum's argument. - TestDashboardCSSNamesNoColours fails on any hex or rgb() the page writes for itself. The old :root block did not arrive wrong; it arrived one reasonable exception at a time. Neither gate touches the network, so proving we use one design system costs the pipeline no npm and no registry. TestRenderedPageShowsOnlyTheViewersOrg additionally pins at the HTML layer what scope_test.go pins at the predicate layer: a lux viewer's rendered page contains no other org's rows and no other org's name in the nav. The renderer is where that leak lived, and it is now the renderer that is asserted. Rendered and checked in a browser, not just compiled: every token resolves (surface-0 #080808, text-primary #fafafa, accent-muted #a78bfa, success/error/warning #10b981/#ef4444/#f59e0b, hairline rgba(255,255,255, .06), radius-card 8px, JetBrains Mono), color-scheme comes out dark via the sheet's own .dark hook, and header, chips, nav, table and footer now sit on one --space-6 gutter instead of the table drifting 12px left. Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
+6
-4
@@ -1,9 +1,11 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# ci — the ci.hanzo.ai dashboard. Pure-Go, no cgo, no assets: the page is
|
||||
# server-rendered from a template compiled into the binary, so the image is the
|
||||
# binary and a CA bundle. Nothing to serve from disk, nothing to go stale
|
||||
# against the code.
|
||||
# ci — the ci.hanzo.ai dashboard. Pure-Go, no cgo, no node: the page is
|
||||
# server-rendered from a template compiled into the binary, and the design
|
||||
# tokens it spends are @hanzo/brand's published stylesheet, go:embed-ed beside
|
||||
# it. So the image is still the binary and a CA bundle — nothing served from
|
||||
# disk, nothing to go stale against the code, and no JS toolchain on the path
|
||||
# that ships the board you read when the builds are broken.
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /build
|
||||
# Resolve through the module proxy: proxy.golang.org and sum.golang.org agree
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// brand.go — where this page's design values come from.
|
||||
//
|
||||
// They come from @hanzo/brand, the one place the fleet's palette, radii, type
|
||||
// scale and spacing are defined, and they arrive as that package's OWN
|
||||
// published artifact rather than as hex codes retyped here. The distinction is
|
||||
// the entire point. Until this file existed the template carried its own
|
||||
// :root block, and being a hand-copy it had already drifted off the house:
|
||||
// the status colours were GitHub Primer's (#3fb950 / #f85149 / #d29922) where
|
||||
// the house says #10b981 / #ef4444 / #f59e0b, the surface blacks were each a
|
||||
// shade wrong (#0b0b0d against --surface-0 #080808), and the hairline border
|
||||
// was a solid #25252b where the house hairline is a 6%-white wash. Only the
|
||||
// accent survived intact. A palette that is copied is a palette that diverges.
|
||||
//
|
||||
// Vendored, not fetched at build time, and compiled in rather than served off
|
||||
// disk. @hanzo/brand publishes this file as a plain custom-property sheet
|
||||
// (`exports["./styles/*"]`, documented for a bare <link>), so consuming it
|
||||
// costs no npm, no bundler and no React — the image build stays `go build`
|
||||
// against an empty go.mod, and the page stays one request that returns the
|
||||
// answer. That matters here more than anywhere: this dashboard is read when
|
||||
// the build system is broken, which is the worst possible moment for it to
|
||||
// need the build system in order to draw itself.
|
||||
//
|
||||
// Refreshing is a deliberate, reviewed act — fetch, then update the pin:
|
||||
//
|
||||
// curl -sSfo brand/variables.css https://unpkg.com/@hanzo/brand@<version>/styles/variables.css
|
||||
//
|
||||
//go:embed brand/variables.css
|
||||
var brandCSS string
|
||||
|
||||
// brandCSSVersion and brandCSSSHA256 record WHICH @hanzo/brand the bytes above
|
||||
// are, and a test rejects any other bytes. This is go.sum's argument, not
|
||||
// ceremony: without it, "just darken that one border" is a one-character local
|
||||
// edit that silently restores the second source of truth this file removed, and
|
||||
// nothing would ever catch it.
|
||||
const (
|
||||
brandCSSVersion = "1.4.5"
|
||||
brandCSSSHA256 = "941dfc0080343d25dc1ef2cd780290a2d8fe6cbd81136912281902f2e8e7741f"
|
||||
)
|
||||
|
||||
// dashboardCSS is what this page adds on top: layout, not design. Every colour,
|
||||
// radius and size in it is a var() into the sheet above.
|
||||
//
|
||||
//go:embed dashboard.css
|
||||
var dashboardCSS string
|
||||
|
||||
// pageCSS is the <style> body: tokens first, then the rules that spend them.
|
||||
// template.CSS because these are two compile-time constants, never input.
|
||||
func pageCSS() template.CSS { return template.CSS(brandCSS + dashboardCSS) }
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @hanzo/brand CSS Variables
|
||||
*
|
||||
* Hanzo is monochrome — the brand is ink, paper, and a neutral grayscale.
|
||||
* There is no brand hue; the brand color is the ink (dark) / paper (light).
|
||||
*
|
||||
* Usage:
|
||||
* @import '@hanzo/brand/styles/variables.css';
|
||||
* or link: <link rel="stylesheet" href="https://unpkg.com/@hanzo/brand/styles/variables.css">
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ===== Hanzo Brand (Monochrome: Hanzo Black ↔ Hanzo White) ===== */
|
||||
--hanzo-black: #0a0a0b;
|
||||
--hanzo-black-rgb: 10, 10, 11;
|
||||
--hanzo-white: #ffffff;
|
||||
--hanzo-white-rgb: 255, 255, 255;
|
||||
--hanzo-mono-50: #fafafa;
|
||||
--hanzo-mono-100: #f5f5f5;
|
||||
--hanzo-mono-200: #e5e5e5;
|
||||
--hanzo-mono-300: #d4d4d4;
|
||||
--hanzo-mono-400: #a3a3a3;
|
||||
--hanzo-mono-500: #737373;
|
||||
--hanzo-mono-600: #525252;
|
||||
--hanzo-mono-700: #404040;
|
||||
--hanzo-mono-800: #262626;
|
||||
--hanzo-mono-900: #171717;
|
||||
--hanzo-mono-950: #0a0a0a;
|
||||
|
||||
/* ===== Accent — the ONE Hanzo accent: PURPLE (palette = White · Gray · Purple).
|
||||
The monochrome base stays (primary action = white, neutrals = gray); purple is
|
||||
the single interactive/brand accent — links, active, focus, selection. NO blue,
|
||||
green, or orange. White-label tenants override --hanzo-accent per host so
|
||||
lux/zoo/pars never inherit Hanzo purple. ===== */
|
||||
--hanzo-accent: #8b5cf6; /* violet-500 */
|
||||
--hanzo-accent-hover: #7c3aed; /* violet-600 */
|
||||
--hanzo-accent-muted: #a78bfa; /* violet-400 — accent text on dark */
|
||||
--hanzo-accent-soft: rgba(139, 92, 246, 0.12); /* subtle fill / selected row */
|
||||
--hanzo-accent-rgb: 139, 92, 246;
|
||||
|
||||
/* ===== Layered surface blacks (Builder v2 — no gray panels; each subtly different) ===== */
|
||||
--surface-0: #080808; /* app background */
|
||||
--surface-1: #0d0d0d; /* panels */
|
||||
--surface-2: #111111; /* raised */
|
||||
--surface-3: #171717; /* controls / hover */
|
||||
|
||||
/* ===== Hairline border — 1px, almost invisible (no thick outlines) ===== */
|
||||
--border-hairline: rgba(255, 255, 255, 0.06);
|
||||
--border-hairline-strong: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* ===== Semantic radius (Builder v2): cards 8 · controls/toolbar 10 · preview/panels 12 ===== */
|
||||
--radius-card: 0.5rem; /* 8px */
|
||||
--radius-control: 0.625rem; /* 10px — buttons, toolbar, inputs */
|
||||
--radius-panel: 0.75rem; /* 12px — preview, large panels */
|
||||
|
||||
/* ===== Semantic type roles (Builder v2): heading 20 · body 14 · secondary 12 ===== */
|
||||
--text-heading: 1.25rem; /* 20px @ 600 */
|
||||
--text-body: 0.875rem; /* 14px @ 400 */
|
||||
--text-secondary: 0.75rem; /* 12px @ 400/500 */
|
||||
|
||||
/* ===== Semantic Aliases (monochrome; flips with scheme) ===== */
|
||||
--brand: var(--hanzo-black);
|
||||
--brand-light: var(--hanzo-mono-800);
|
||||
--brand-dark: #000000;
|
||||
--brand-hover: var(--hanzo-mono-900);
|
||||
--brand-secondary: var(--hanzo-mono-600);
|
||||
|
||||
/* ===== Dark Theme Backgrounds ===== */
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--bg-card: rgba(23, 23, 23, 0.5);
|
||||
|
||||
/* ===== Light Theme Backgrounds ===== */
|
||||
--bg-light: #ffffff;
|
||||
--bg-light-secondary: #fafafa;
|
||||
--bg-light-tertiary: #f5f5f5;
|
||||
|
||||
/* ===== Borders ===== */
|
||||
--border: #262626;
|
||||
--border-light: #e5e5e5;
|
||||
--border-focus: var(--hanzo-black);
|
||||
|
||||
/* ===== Text Colors (Dark Theme) ===== */
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a3a3a3;
|
||||
--text-muted: #737373;
|
||||
--text-disabled: #525252;
|
||||
|
||||
/* ===== Text Colors (Light Theme) ===== */
|
||||
--text-light-primary: #0a0a0b;
|
||||
--text-light-secondary: #525252;
|
||||
--text-light-muted: #737373;
|
||||
|
||||
/* ===== Neutral Scale ===== */
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #fafafa;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-300: #d4d4d4;
|
||||
--neutral-400: #a3a3a3;
|
||||
--neutral-500: #737373;
|
||||
--neutral-600: #525252;
|
||||
--neutral-700: #404040;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
--neutral-950: #0a0a0a;
|
||||
--neutral-1000: #000000;
|
||||
|
||||
/* ===== Semantic Colors ===== */
|
||||
--success: #10b981;
|
||||
--success-light: #34d399;
|
||||
--success-dark: #059669;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-light: #fcd34d;
|
||||
--warning-dark: #d97706;
|
||||
|
||||
--error: #ef4444;
|
||||
--error-light: #f87171;
|
||||
--error-dark: #dc2626;
|
||||
|
||||
--info: #3b82f6;
|
||||
--info-light: #60a5fa;
|
||||
--info-dark: #2563eb;
|
||||
|
||||
/* ===== Gradients ===== */
|
||||
--gradient-brand: linear-gradient(135deg, var(--hanzo-mono-800) 0%, var(--hanzo-black) 100%);
|
||||
--gradient-accent: linear-gradient(135deg, var(--hanzo-black) 0%, #000000 100%);
|
||||
--gradient-dark: linear-gradient(135deg, #0a0a0b 0%, #262626 100%);
|
||||
|
||||
/* ===== Spacing ===== */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
--space-24: 6rem;
|
||||
|
||||
/* ===== Border Radius ===== */
|
||||
--radius-sm: 0.125rem;
|
||||
--radius: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ===== Shadows ===== */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* ===== Transitions ===== */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ===== Typography ===== */
|
||||
--font-sans: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Geist Mono', ui-monospace, Monaco, monospace;
|
||||
|
||||
/* ===== Font Size scale (mirrors typography.ts `fontSize`) =====
|
||||
TIGHT app-first default — the compact developer-app register (linear.app /
|
||||
vercel.com), NOT a roomy marketing scale. Base is 14px, nav 13px, labels 11px.
|
||||
Every surface that imports @hanzo/brand inherits this; a brand/tenant can
|
||||
override any --font-size-* on :root to retune density on demand. */
|
||||
--font-size-xs: 0.6875rem; /* 11px — eyebrows / section labels */
|
||||
--font-size-sm: 0.8125rem; /* 13px — nav labels, dense body */
|
||||
--font-size-base: 0.875rem; /* 14px — base app text (was 16px) */
|
||||
--font-size-lg: 0.9375rem; /* 15px */
|
||||
--font-size-xl: 1.0625rem; /* 17px */
|
||||
--font-size-2xl: 1.3125rem; /* 21px */
|
||||
--font-size-3xl: 1.625rem; /* 26px */
|
||||
--font-size-4xl: 2rem; /* 32px */
|
||||
--font-size-5xl: 2.5rem; /* 40px */
|
||||
--font-size-6xl: 3.25rem; /* 52px */
|
||||
--font-size-7xl: 4rem; /* 64px */
|
||||
--font-size-8xl: 5.25rem; /* 84px */
|
||||
--font-size-9xl: 7rem; /* 112px */
|
||||
|
||||
/* ===== Z-index ladder (mirrors tokens.ts `zIndex`) ===== */
|
||||
--z-0: 0;
|
||||
--z-10: 10;
|
||||
--z-20: 20;
|
||||
--z-30: 30;
|
||||
--z-40: 40;
|
||||
--z-50: 50;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-popover: 500;
|
||||
--z-tooltip: 600;
|
||||
--z-notification: 700;
|
||||
}
|
||||
|
||||
/* Dark theme (default for Hanzo) */
|
||||
[data-theme="dark"],
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"],
|
||||
.light {
|
||||
--bg-primary: var(--bg-light);
|
||||
--bg-secondary: var(--bg-light-secondary);
|
||||
--bg-tertiary: var(--bg-light-tertiary);
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--border: var(--border-light);
|
||||
--text-primary: var(--text-light-primary);
|
||||
--text-secondary: var(--text-light-secondary);
|
||||
--text-muted: var(--text-light-muted);
|
||||
/* purple accent flips a shade deeper for contrast on paper */
|
||||
--hanzo-accent: #7c3aed;
|
||||
--hanzo-accent-hover: #6d28d9;
|
||||
--hanzo-accent-muted: #7c3aed;
|
||||
/* layered "blacks" become layered near-whites in light */
|
||||
--surface-0: #ffffff;
|
||||
--surface-1: #fafafa;
|
||||
--surface-2: #f5f5f5;
|
||||
--surface-3: #ededed;
|
||||
--border-hairline: rgba(0, 0, 0, 0.08);
|
||||
--border-hairline-strong: rgba(0, 0, 0, 0.12);
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* dashboard.css — this page's own rules: what is a row, what sticks, what
|
||||
collapses on a phone. It names no colour, radius or type size of its own;
|
||||
every such value is a var() into @hanzo/brand (see brand.go). That is the
|
||||
difference between consuming the design system and being a second copy of it,
|
||||
and it is asserted, not merely intended — see TestDashboardCSSNamesNoColours. */
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--surface-0);color:var(--text-primary);
|
||||
font-family:var(--font-sans);font-size:var(--font-size-base);line-height:1.5}
|
||||
header{display:flex;align-items:center;gap:var(--space-4);
|
||||
padding:var(--space-4) var(--space-6);background:var(--surface-1);
|
||||
border-bottom:1px solid var(--border-hairline)}
|
||||
h1{margin:0;font-size:var(--font-size-lg);font-weight:600;letter-spacing:-.01em}
|
||||
h1 span{color:var(--hanzo-accent-muted)}
|
||||
.meta{margin-left:auto;color:var(--text-secondary);font-size:var(--font-size-sm);text-align:right}
|
||||
|
||||
.strip{display:flex;gap:var(--space-2);padding:var(--space-4) var(--space-6);flex-wrap:wrap}
|
||||
.chip{padding:var(--space-2) var(--space-3);background:var(--surface-1);
|
||||
border:1px solid var(--border-hairline);border-radius:var(--radius-card);
|
||||
font-size:var(--font-size-sm);color:var(--text-secondary)}
|
||||
.chip b{color:var(--text-primary);font-weight:600}
|
||||
.chip.ok b{color:var(--success)} .chip.fail b{color:var(--error)}
|
||||
.chip.run b{color:var(--warning)} .chip.cancel b{color:var(--text-muted)}
|
||||
|
||||
nav{display:flex;gap:var(--space-2);padding:0 var(--space-6) var(--space-4);flex-wrap:wrap}
|
||||
nav a{padding:var(--space-1) var(--space-3);background:var(--surface-1);
|
||||
border:1px solid var(--border-hairline);border-radius:var(--radius-full);
|
||||
color:var(--text-secondary);text-decoration:none;font-size:var(--font-size-sm)}
|
||||
/* --hanzo-accent-soft is the house "selected row" fill; the active tab is the
|
||||
one place on this page that is a selection, so it is the one place it is used. */
|
||||
nav a.on{border-color:var(--hanzo-accent);background:var(--hanzo-accent-soft);color:var(--text-primary)}
|
||||
nav .who{margin-left:auto;align-self:center;color:var(--text-muted);font-size:var(--font-size-sm)}
|
||||
|
||||
/* The stale banner tints its own border colour rather than introducing an amber
|
||||
of its own — @hanzo/brand ships no warning-surface token, and inventing one
|
||||
here is exactly the drift this file exists to stop. */
|
||||
.warn{margin:0 var(--space-6) var(--space-4);padding:var(--space-3) var(--space-4);
|
||||
border:1px solid var(--warning);border-radius:var(--radius-card);
|
||||
background:color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
color:var(--warning-light);font-size:var(--font-size-sm)}
|
||||
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{position:sticky;top:0;background:var(--surface-1);text-align:left;
|
||||
font-size:var(--font-size-xs);text-transform:uppercase;letter-spacing:.06em;
|
||||
font-weight:600;color:var(--text-muted);padding:var(--space-2) var(--space-3);
|
||||
border-bottom:1px solid var(--border-hairline-strong)}
|
||||
td{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--border-hairline);
|
||||
vertical-align:top}
|
||||
/* The table is full-bleed but its text has to sit on the same gutter as the
|
||||
header, chips and nav above it, which are all --space-6 in. */
|
||||
th:first-child,td:first-child{padding-left:var(--space-6)}
|
||||
th:last-child,td:last-child{padding-right:var(--space-6)}
|
||||
tr:hover td{background:var(--surface-2)}
|
||||
a{color:inherit}
|
||||
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:var(--radius-full);
|
||||
margin-right:var(--space-2)}
|
||||
.dot.success{background:var(--success)} .dot.failure{background:var(--error)}
|
||||
.dot.running{background:var(--warning);animation:p 1.4s ease-in-out infinite}
|
||||
.dot.cancelled{background:var(--text-disabled)}
|
||||
@keyframes p{50%{opacity:.35}}
|
||||
|
||||
.repo{font-weight:600}
|
||||
.org{color:var(--text-muted)}
|
||||
.title{color:var(--text-secondary);max-width:42ch;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mono{font-family:var(--font-mono);font-size:var(--font-size-sm);color:var(--text-secondary)}
|
||||
.empty{padding:var(--space-12) var(--space-6);text-align:center;color:var(--text-secondary)}
|
||||
footer{padding:var(--space-4) var(--space-6);color:var(--text-muted);
|
||||
font-size:var(--font-size-sm);border-top:1px solid var(--border-hairline)}
|
||||
@media(max-width:760px){.hide-sm{display:none}}
|
||||
@@ -29,6 +29,12 @@ test:
|
||||
# request with no X-Org-Id and that `?org=` can only narrow. This service
|
||||
# shipped once with those properties absent and disclosed every org's build
|
||||
# metadata to the internet, so a red gate here must block the image.
|
||||
#
|
||||
# render_test.go is the other one that has to stay green: it pins the
|
||||
# vendored @hanzo/brand sheet to the hash of the version it claims to be and
|
||||
# rejects any colour the page names for itself. Both gates are offline —
|
||||
# checking that we use one design system costs this pipeline no npm, no
|
||||
# registry and no network.
|
||||
run: |
|
||||
set -e
|
||||
export GOWORK=off
|
||||
|
||||
@@ -13,6 +13,13 @@ import (
|
||||
// and a loading state to show the same rows a second later. The dashboard also
|
||||
// has to be readable when the thing it reports on is broken, which is exactly
|
||||
// when a build pipeline for its own frontend is the wrong dependency.
|
||||
//
|
||||
// That argument is about the BUILD, not about the design. The look is the
|
||||
// house's and is not restated here: the <style> block is @hanzo/brand's own
|
||||
// published token sheet plus this page's layout rules, both compiled in — see
|
||||
// brand.go. Server rendering and one design system are not in tension; only
|
||||
// server rendering and a JS component library are, and it is the tokens, not
|
||||
// the components, that this page ever needed.
|
||||
|
||||
// renderDashboard writes the page for ONE viewer. Every row it renders has
|
||||
// already passed v.visible — the template is never handed the full snapshot and
|
||||
@@ -121,60 +128,23 @@ func humanDur(d time.Duration) string {
|
||||
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
|
||||
// `class="dark"` is @hanzo/brand's own dark hook, not a local convention: the
|
||||
// sheet's :root IS the dark scale, and the class is what additionally sets
|
||||
// color-scheme so the scrollbars and form controls the browser draws match.
|
||||
// Elsewhere in the fleet next-themes toggles that class; this page has no JS and
|
||||
// no toggle, so it states its scheme once and means it.
|
||||
var tmpl = template.Must(template.New("ci").Funcs(template.FuncMap{
|
||||
"outcome": outcome,
|
||||
"dur": func(r Run) string { return humanDur(r.Duration()) },
|
||||
"ago": humanAge,
|
||||
"css": pageCSS,
|
||||
}).Parse(`<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<html lang="en" class="dark"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo CI</title>
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<style>
|
||||
:root{--bg:#0b0b0d;--panel:#141417;--line:#25252b;--fg:#e8e8ea;--dim:#8b8b95;
|
||||
--ok:#3fb950;--fail:#f85149;--run:#d29922;--accent:#8B5CF6}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||||
header{display:flex;align-items:center;gap:16px;padding:16px 24px;
|
||||
border-bottom:1px solid var(--line);background:var(--panel)}
|
||||
h1{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}
|
||||
h1 span{color:var(--accent)}
|
||||
.meta{margin-left:auto;color:var(--dim);font-size:12px;text-align:right}
|
||||
.strip{display:flex;gap:8px;padding:16px 24px;flex-wrap:wrap}
|
||||
.chip{padding:6px 12px;border:1px solid var(--line);border-radius:8px;
|
||||
background:var(--panel);font-size:12px;color:var(--dim)}
|
||||
.chip b{color:var(--fg);font-weight:600}
|
||||
.chip.ok b{color:var(--ok)} .chip.fail b{color:var(--fail)} .chip.run b{color:var(--run)}
|
||||
.chip.cancel b{color:var(--dim)}
|
||||
nav{display:flex;gap:6px;padding:0 24px 16px;flex-wrap:wrap}
|
||||
nav a{padding:5px 11px;border:1px solid var(--line);border-radius:999px;
|
||||
background:var(--panel);color:var(--dim);text-decoration:none;font-size:12px}
|
||||
nav a.on{border-color:var(--accent);color:var(--fg)}
|
||||
nav .who{margin-left:auto;align-self:center;color:var(--dim);font-size:12px}
|
||||
.warn{margin:0 24px 16px;padding:10px 14px;border:1px solid var(--run);
|
||||
border-radius:8px;background:#221b0c;color:#f0d58c;font-size:13px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{position:sticky;top:0;background:var(--panel);text-align:left;font-size:11px;
|
||||
text-transform:uppercase;letter-spacing:.06em;color:var(--dim);
|
||||
padding:10px 12px;border-bottom:1px solid var(--line);font-weight:600}
|
||||
td{padding:10px 12px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
tr:hover td{background:#111114}
|
||||
a{color:inherit}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:8px}
|
||||
.dot.success{background:var(--ok)} .dot.failure{background:var(--fail)}
|
||||
.dot.running{background:var(--run);animation:p 1.4s ease-in-out infinite}
|
||||
.dot.cancelled{background:#4a4a52}
|
||||
@keyframes p{50%{opacity:.35}}
|
||||
.repo{font-weight:600}
|
||||
.org{color:var(--dim)}
|
||||
.title{color:var(--dim);max-width:42ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--dim)}
|
||||
.empty{padding:48px 24px;text-align:center;color:var(--dim)}
|
||||
footer{padding:16px 24px;color:var(--dim);font-size:12px;border-top:1px solid var(--line)}
|
||||
@media(max-width:760px){.hide-sm{display:none}}
|
||||
</style></head><body>
|
||||
<style>{{css}}</style></head><body>
|
||||
|
||||
<header>
|
||||
<h1>Hanzo <span>CI</span></h1>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// render_test.go guards the two properties the view has to keep: that its design
|
||||
// values come from exactly one place, and that it can only ever draw rows the
|
||||
// viewer was already permitted to see.
|
||||
|
||||
// TestBrandCSSIsUpstreamBytes is the pin. The vendored sheet is only a source of
|
||||
// truth while it is byte-for-byte what @hanzo/brand published; the moment it can
|
||||
// be edited in place it is a fork wearing an upstream name, which is the exact
|
||||
// state this repo was in when it carried its own :root block.
|
||||
func TestBrandCSSIsUpstreamBytes(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte(brandCSS))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if got != brandCSSSHA256 {
|
||||
t.Fatalf("brand/variables.css is not @hanzo/brand@%s\n got %s\n want %s\n"+
|
||||
"A token refresh: re-fetch the sheet and set brandCSSSHA256 to the got value.\n"+
|
||||
"A local colour edit: make it in @hanzo/brand and release it, not here.",
|
||||
brandCSSVersion, got, brandCSSSHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// colourLiteral matches a value that decides an appearance on its own — a hex,
|
||||
// or an rgb()/hsl() function. `color-mix(in srgb, var(--x) ...)` is deliberately
|
||||
// not one of these: it derives from a token instead of naming a new colour.
|
||||
var colourLiteral = regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(`)
|
||||
|
||||
// TestDashboardCSSNamesNoColours is what makes "one source of truth" a fact
|
||||
// rather than an intention. Vendoring the sheet is only half the job; if the
|
||||
// page can still write a hex next to it, the second palette grows back one
|
||||
// "just this once" at a time — which is how the old :root block came to hold
|
||||
// GitHub's status colours instead of the house's.
|
||||
func TestDashboardCSSNamesNoColours(t *testing.T) {
|
||||
if m := colourLiteral.FindAllString(dashboardCSS, -1); len(m) > 0 {
|
||||
t.Fatalf("dashboard.css names colours directly: %v\n"+
|
||||
"Every colour must be a var() into @hanzo/brand; if the token you need "+
|
||||
"does not exist, add it there rather than here.", m)
|
||||
}
|
||||
if !strings.Contains(dashboardCSS, "var(--") {
|
||||
t.Fatal("dashboard.css references no tokens at all — it has stopped consuming the design system")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderedPageShowsOnlyTheViewersOrg drives the HTML, not the predicates.
|
||||
// scope_test.go proves visible() and orgs() are right; this proves the page is
|
||||
// actually built from them — the leak that started all of this was a handler
|
||||
// handing a template more than the viewer was owed, and a template cannot be
|
||||
// trusted to be careful with a snapshot it can see all of.
|
||||
func TestRenderedPageShowsOnlyTheViewersOrg(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
renderDashboard(w, snapshot{Runs: testRuns(), Repos: 3}, viewer{org: "lux"}, "", config{})
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, ">lux/<") {
|
||||
t.Fatal("lux viewer's own run is missing from the page")
|
||||
}
|
||||
// Rows: no other org's repo may be drawn.
|
||||
for _, leaked := range []string{">hanzo/<", ">zoo/<"} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("page rendered %s to a lux viewer", leaked)
|
||||
}
|
||||
}
|
||||
// Nav: nor may another org's NAME, which discloses who builds here even
|
||||
// when their runs are correctly hidden.
|
||||
for _, leaked := range []string{"/?org=hanzo", "/?org=zoo", "all orgs"} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("nav offered %q to a lux viewer", leaked)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user