ModsCode / Gallery / Designs / CI pipeline
CI pipeline
A CI run as stages from left to right: job pills with line-art status glyphs and durations, elbow links, and dashed links into stages still waiting.


hooks/designs/ci-pipeline.mjs
// ci-pipeline: one CI run of four stages, build -> test (unit, lint and e2e in parallel) -> package ->
// deploy, drawn left to right. Each job is a 76 x 28 pill with an 18% ink outline (kanban's card; the
// failed and the running job take their state's colour at 55%) and a line-art status glyph drawn as
// paths: a check for passed, a cross for failed, a quarter-open ring for running, an empty dashed ring
// for waiting. Links are 1px elbows at 35% ink that bend halfway across the gap,
// as gantt's do; the fan-out and fan-in are one path each so shared legs are not drawn twice and do not
// darken. Links into stages that have not started are dashed 3 3 like candle's last-value line. Lint
// failed after 18s and e2e is still running at 3m 07s, so package and deploy wait: the running job is
// the one Claude orange item, the failed one the one red item. Static sample run. Sample data only.
const W = 380
const H = 192
const STAGE = '#FAF9F5'
const INK = '#141413'
const MUTED = '#73726C'
const UP = '#2C7A39'
const DOWN = '#AB2B3F'
const CLAUDE = '#D77757'
const FONT = "'Inter Tight','Segoe UI',system-ui,sans-serif"
const MONO = "ui-monospace,'Cascadia Mono',Consolas,monospace"
const f = n => n.toFixed(1)
const PILL = { w: 76, h: 28 } // at 72, "package" ended 3px short of the round end
const X0 = 12
const GAP = (W - 2 * X0 - 4 * PILL.w) / 3 // about 17.3px between two stage columns
const colX = c => X0 + c * (PILL.w + GAP)
const MID = 110 // the centre line of the single-job stages
const TEST_PITCH = 36
// One sample run on main. Durations are wall time; the running job shows its time so far.
const STAGES = [
{ name: 'BUILD', jobs: [{ name: 'build', state: 'passed', time: '1m 04s' }] },
{
name: 'TEST',
jobs: [
{ name: 'unit', state: 'passed', time: '2m 31s' },
{ name: 'lint', state: 'failed', time: '0m 18s' },
{ name: 'e2e', state: 'running', time: '3m 07s' },
],
},
{ name: 'PACKAGE', jobs: [{ name: 'package', state: 'waiting', time: 'waiting' }] },
{ name: 'DEPLOY', jobs: [{ name: 'deploy', state: 'waiting', time: 'waiting' }] },
]
const COLOUR = { passed: UP, failed: DOWN, running: CLAUDE, waiting: MUTED }
const text = (x, y, s, { size = 9, fill = MUTED, anchor = 'start', font = FONT } = {}) =>
`<text x="${f(x)}" y="${f(y)}"${anchor === 'start' ? '' : ` text-anchor="${anchor}"`} font-size="${size}" font-family="${font}" fill="${fill}">${s}</text>`
const jobY = (stage, k) => (stage.jobs.length === 1 ? MID : MID + (k - (stage.jobs.length - 1) / 2) * TEST_PITCH)
// A status glyph centred on (cx, cy): a 5.5px ring in the state's colour with a mark inside
function glyph(state, cx, cy) {
const c = COLOUR[state]
const ring = (op, dash = '') => `<circle cx="${f(cx)}" cy="${f(cy)}" r="5.5" fill="none" stroke="${c}" stroke-opacity="${op}"${dash}/>`
if (state === 'passed') return ring(1) + `<path d="M${f(cx - 2.6)} ${f(cy + 0.2)}L${f(cx - 0.8)} ${f(cy + 2)}L${f(cx + 2.7)} ${f(cy - 1.9)}" fill="none" stroke="${c}"/>`
if (state === 'failed') return ring(1) + `<path d="M${f(cx - 2)} ${f(cy - 2)}L${f(cx + 2)} ${f(cy + 2)}M${f(cx + 2)} ${f(cy - 2)}L${f(cx - 2)} ${f(cy + 2)}" fill="none" stroke="${c}"/>`
if (state === 'running') {
// A faint full track and a three-quarter arc from 12 o'clock, clockwise
return ring(0.25) + `<path d="M${f(cx)} ${f(cy - 5.5)}A5.5 5.5 0 1 1 ${f(cx - 5.5)} ${f(cy)}" fill="none" stroke="${c}"/>`
}
return ring(1, ' stroke-dasharray="3 3"')
}
function pill(job, x, cy) {
const y = cy - PILL.h / 2
const edge = job.state === 'failed' ? `stroke="${DOWN}" stroke-opacity="0.55"` : job.state === 'running' ? `stroke="${CLAUDE}" stroke-opacity="0.55"` : `stroke="${INK}" stroke-opacity="0.18"`
return [
`<rect x="${f(x + 0.5)}" y="${f(y + 0.5)}" width="${PILL.w - 1}" height="${PILL.h - 1}" rx="${(PILL.h - 1) / 2}" fill="none" ${edge}/>`,
glyph(job.state, x + 13, cy),
text(x + 24, cy - 1, job.name, { size: 10, fill: job.state === 'waiting' ? MUTED : INK }),
text(x + 24, cy + 9, job.time, { fill: job.state === 'running' ? CLAUDE : MUTED }),
].join('')
}
function draw() {
const parts = [`<rect width="${W}" height="${H}" rx="4" fill="${STAGE}"/>`]
// The run: number, branch, sha, subject; the elapsed time on the right
parts.push(text(12, 24, '#1213', { size: 13, fill: INK }))
parts.push(text(58, 24, 'main', { size: 10 }))
parts.push(text(86, 24, 'a3f9c21', { size: 10, font: MONO }))
parts.push(text(136, 24, 'fix: retry on 429', { size: 10, fill: INK }))
parts.push(text(W - 12, 24, '4m 12s', { size: 13, fill: INK, anchor: 'end' }))
parts.push(text(W - 60, 24, 'ELAPSED', { anchor: 'end' }))
STAGES.forEach((s, c) => parts.push(text(colX(c) + PILL.w / 2, 50, s.name, { anchor: 'middle' })))
// Links between neighbouring columns, drawn before the pills. One path per gap; a gap whose target
// stage has not started is dashed.
for (let c = 0; c < STAGES.length - 1; c++) {
const from = STAGES[c]
const to = STAGES[c + 1]
const x1 = colX(c) + PILL.w
const x2 = colX(c + 1)
const bend = (x1 + x2) / 2
let d = ''
from.jobs.forEach((_, i) => to.jobs.forEach((__, k) => {
d += `M${f(x1)} ${f(jobY(from, i))}H${f(bend)}V${f(jobY(to, k))}H${f(x2)}`
}))
const dash = to.jobs.every(j => j.state === 'waiting') ? ' stroke-dasharray="3 3"' : ''
parts.push(`<path d="${d}" fill="none" stroke="${INK}" stroke-opacity="0.35"${dash}/>`)
}
STAGES.forEach((s, c) => s.jobs.forEach((job, k) => parts.push(pill(job, colX(c), jobY(s, k)))))
// Counts by state on the caption line, right-aligned. One text with a tspan per count and dx="8"
// between them, so the renderer measures the words; stepping separate texts by an estimated 5px a
// character left 15px after PASSED and 5px after FAILED. Counts come from the jobs, so they cannot go stale.
const jobs = STAGES.flatMap(s => s.jobs)
const counts = ['passed', 'failed', 'running', 'waiting']
.map((st, i) => `<tspan${i ? ' dx="8"' : ''}>${jobs.filter(j => j.state === st).length} ${st.toUpperCase()}</tspan>`)
.join('')
parts.push(text(W - 12, H - 8, counts, { anchor: 'end' }))
parts.push(text(12, H - 8, 'SAMPLE PIPELINE'))
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${parts.join('')}</svg>`
}
let source // drawn on first use and kept: the pane loads every design, so none pays at load time
export default {
id: 'ci-pipeline',
title: 'CI pipeline',
category: 'code',
description: 'A CI run as stages from left to right: job pills with line-art status glyphs and durations, elbow links, and dashed links into stages still waiting.',
element: ui => ui.Svg({ source: (source ??= draw()), alt: `CI pipeline run across the ${STAGES.map(s => s.name.toLowerCase()).join(', ')} stages, each job with its state and duration, sample data`, width: W, height: H }),
}
To use it in a mod: copy the file to your mod's hooks/designs/ and return its element(ui) from a pane's ui.render. How a pane is built.
Code: PolyForm Noncommercial License 1.0.0: noncommercial use only. Required Notice: Copyright 2026 Haruki Nakada (https://modscode.com).