ModsCodeMERGEBLE LLC Search Claude Code Mods

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.

CI pipelineCI pipeline
Code and CI · 380×192 · sample data
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).