Files
english/.opencode/skills/preview/templates/mermaid-flowchart.html
2026-04-12 01:06:31 +07:00

769 lines
23 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CI/CD Pipeline — Reference Template</title>
<!--
Reference template for the ck:preview skill: Mermaid diagrams.
Teal/cyan palette — distinctly different from terracotta (architecture)
and rose (data-table) templates so agents absorb variety.
Key patterns demonstrated:
- Teal/cyan palette (NOT indigo/violet)
- Dot-grid background atmosphere (different from radial gradient)
- Large heading (38px) for typographic contrast
- ESM import of Mermaid + @mermaid-js/layout-elk
- Mermaid theme: 'base' + full themeVariables, fontSize: 16px
- CSS overrides: .nodeLabel 16px, .edgeLabel 13px
- look: 'classic' for clean lines
- layout: 'elk' for better node positioning
- Zoom controls with scroll-to-zoom and drag-to-pan
- Both light and dark themes via prefers-color-scheme
- Staggered fade-in, reduced motion respect
-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@400;500;600;700&family=Fragment+Mono:wght@400&display=swap" rel="stylesheet">
<style>
/* ============ THEME ============ */
:root {
--font-body: 'Bricolage Grotesque', system-ui, sans-serif;
--font-mono: 'Fragment Mono', 'SF Mono', Consolas, monospace;
--bg: #f0fdfa;
--surface: #ffffff;
--border: rgba(0, 0, 0, 0.07);
--text: #134e4a;
--text-dim: #5f8a85;
--primary: #0d9488;
--primary-dim: rgba(13, 148, 136, 0.08);
--secondary: #0369a1;
--tertiary: #d97706;
--danger: #dc2626;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #042f2e;
--surface: #0a3d3a;
--border: rgba(255, 255, 255, 0.08);
--text: #ccfbf1;
--text-dim: #5eead4;
--primary: #2dd4bf;
--primary-dim: rgba(45, 212, 191, 0.14);
--secondary: #38bdf8;
--tertiary: #fbbf24;
--danger: #f87171;
}
}
/* ── Dark (manual toggle override) ── */
[data-theme="dark"] {
--bg: #042f2e;
--surface: #0a3d3a;
--border: rgba(255, 255, 255, 0.08);
--text: #ccfbf1;
--text-dim: #5eead4;
--primary: #2dd4bf;
--primary-dim: rgba(45, 212, 191, 0.14);
--secondary: #38bdf8;
--tertiary: #fbbf24;
--danger: #f87171;
}
/* ============ THEME TOGGLE ============ */
.theme-toggle {
position: fixed; top: 16px; right: 16px; z-index: 300;
width: 36px; height: 36px; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface);
color: var(--text-dim); cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 16px; transition: background 0.15s, color 0.15s;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.theme-toggle:hover { background: var(--surface); color: var(--text); }
/* ============ RESET + BASE ============ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background-color: var(--bg);
background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
background-size: 24px 24px;
color: var(--text);
font-family: var(--font-body);
padding: 40px;
min-height: 100vh;
}
/* ============ ANIMATION ============ */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.animate {
animation: fadeUp 0.4s ease-out both;
animation-delay: calc(var(--i, 0) * 0.06s);
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-delay: 0ms !important;
transition-duration: 0.01ms !important;
}
}
/* ============ LAYOUT ============ */
.container {
max-width: 1000px;
margin: 0 auto;
}
h1 {
font-size: 38px;
font-weight: 700;
letter-spacing: -1px;
margin-bottom: 6px;
text-wrap: balance;
}
.subtitle {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
margin-bottom: 32px;
}
.description {
font-size: 14px;
line-height: 1.7;
color: var(--text-dim);
margin-bottom: 24px;
max-width: 700px;
}
.description code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--primary-dim);
color: var(--primary);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ MERMAID CONTAINER ============ */
.mermaid-wrap {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px 24px;
overflow: auto;
margin-bottom: 24px;
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.zoom-controls {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
z-index: 10;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
}
.zoom-controls button {
width: 28px;
height: 28px;
border: none;
background: transparent;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 14px;
cursor: pointer;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease, color 0.15s ease;
}
.zoom-controls button:hover {
background: var(--border);
color: var(--text);
}
.mermaid-wrap { cursor: grab; }
.mermaid-wrap.is-panning { cursor: grabbing; user-select: none; }
/* ============ MULTI-DIAGRAM STRUCTURE ============ */
.diagram-shell {
position: relative;
}
.diagram-shell__hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
opacity: 0.7;
}
.mermaid-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
min-height: 300px;
}
.mermaid-canvas {
position: absolute;
top: 0;
left: 0;
}
.zoom-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
padding: 0 6px;
white-space: nowrap;
}
/* ============ MERMAID SVG OVERRIDES ============ */
.mermaid .nodeLabel {
font-family: var(--font-body) !important;
font-size: 16px !important;
}
.mermaid .edgeLabel {
font-family: var(--font-mono) !important;
font-size: 13px !important;
}
.mermaid .node rect,
.mermaid .node circle,
.mermaid .node polygon {
stroke-width: 1.5px !important;
}
.mermaid .edge-pattern-solid {
stroke-width: 1.5px !important;
}
/* ============ LEGEND ============ */
.legend {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
}
/* ============ CALLOUT ============ */
.callout {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--primary);
border-radius: 0 10px 10px 0;
padding: 16px 20px;
font-size: 13px;
line-height: 1.6;
color: var(--text-dim);
margin-top: 24px;
}
.callout strong { color: var(--text); }
.callout code {
font-family: var(--font-mono);
font-size: 11px;
background: var(--primary-dim);
color: var(--primary);
padding: 1px 5px;
border-radius: 3px;
}
/* ============ RESPONSIVE ============ */
@media (max-width: 768px) {
body { padding: 16px; }
h1 { font-size: 22px; }
.mermaid-wrap { padding: 16px 12px; }
}
</style>
</head>
<body>
<button class="theme-toggle" id="themeToggle" title="Toggle theme" aria-label="Toggle light/dark theme"></button>
<script>
(function() {
var t = document.getElementById('themeToggle');
var s = localStorage.getItem('theme');
var i = s || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
if (s) document.documentElement.setAttribute('data-theme', i);
t.textContent = i === 'dark' ? '\u2600' : '\u263E';
t.addEventListener('click', function() {
var c = document.documentElement.getAttribute('data-theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
var n = c === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', n);
localStorage.setItem('theme', n);
t.textContent = n === 'dark' ? '\u2600' : '\u263E';
});
})();
</script>
<div class="container">
<h1 class="animate" style="--i:0">CI/CD Pipeline</h1>
<p class="subtitle animate" style="--i:1">github actions &rarr; staging &rarr; production</p>
<p class="description animate" style="--i:2">
Every push to <code>main</code> triggers the full pipeline. Tests and linting run in parallel,
then the build step produces a Docker image. Staging deploys automatically; production requires
manual approval via a GitHub environment gate.
</p>
<section class="diagram-shell animate" style="--i:3">
<p class="diagram-shell__hint">
Ctrl/Cmd + wheel to zoom. Scroll to pan. Drag to pan when zoomed. Double-click to fit.
</p>
<div class="mermaid-wrap">
<div class="zoom-controls">
<button type="button" data-action="zoom-in" title="Zoom in">+</button>
<button type="button" data-action="zoom-out" title="Zoom out">&minus;</button>
<button type="button" data-action="zoom-fit" title="Smart fit">&#8634;</button>
<button type="button" data-action="zoom-one" title="1:1 zoom">1:1</button>
<button type="button" data-action="zoom-expand" title="Open full size">&#x26F6;</button>
<span class="zoom-label">Loading...</span>
</div>
<div class="mermaid-viewport">
<div class="mermaid mermaid-canvas"></div>
</div>
</div>
<script type="text/plain" class="diagram-source">
graph TD
A[Push to main] --> B{Branch?}
B -->|main| C[Run Tests]
B -->|feature| I[Run Tests]
I --> J[Preview Deploy]
C --> D[Lint + Type Check]
C --> E[Unit Tests]
C --> F[Integration Tests]
D --> G[Build Docker Image]
E --> G
F --> G
G --> H[Deploy to Staging]
H --> K{Smoke Tests Pass?}
K -->|Yes| L[Manual Approval]
K -->|No| M[Alert + Rollback]
L --> N[Deploy to Production]
N --> O[Health Check]
O --> P[Done]
</script>
</section>
<div class="legend animate" style="--i:4">
<div class="legend-item"><div class="legend-swatch" style="background:var(--primary)"></div> Automated step</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--tertiary)"></div> Decision gate</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--danger)"></div> Failure path</div>
<div class="legend-item"><div class="legend-swatch" style="background:var(--secondary)"></div> Success</div>
</div>
<div class="callout animate" style="--i:5">
<strong>ELK layout.</strong> The <code>layout: 'elk'</code> engine provides better node positioning
for complex graphs — it requires the separate <code>@mermaid-js/layout-elk</code> package (imported above).
Without it, Mermaid silently falls back to dagre.
</div>
</div>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import elkLayouts from 'https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk/dist/mermaid-layout-elk.esm.min.mjs';
const config = {
fitPadding: 28,
minHeight: 360,
maxHeightPx: 960,
maxHeightVh: 0.84,
maxInitialZoom: 1.8,
minZoom: 0.08,
maxZoom: 6.5,
zoomStep: 0.14,
readabilityFloor: 0.58
};
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
let activeDrag = null;
addEventListener('mousemove', (e) => activeDrag?.onMove(e));
addEventListener('mouseup', () => {
activeDrag?.onEnd();
activeDrag = null;
});
const isDark = matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.registerLayoutLoaders(elkLayouts);
mermaid.initialize({
startOnLoad: false,
theme: 'base',
look: 'classic',
layout: 'elk',
themeVariables: {
fontFamily: "'Bricolage Grotesque', system-ui, sans-serif",
fontSize: '16px',
primaryColor: isDark ? '#115e59' : '#ccfbf1',
primaryBorderColor: isDark ? '#2dd4bf' : '#0d9488',
primaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
secondaryColor: isDark ? '#0c4a6e' : '#e0f2fe',
secondaryBorderColor: isDark ? '#38bdf8' : '#0369a1',
secondaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
tertiaryColor: isDark ? '#2e2618' : '#fffbeb',
tertiaryBorderColor: isDark ? '#fbbf24' : '#d97706',
tertiaryTextColor: isDark ? '#ccfbf1' : '#134e4a',
lineColor: isDark ? '#5eead4' : '#5f8a85',
noteBkgColor: isDark ? '#115e59' : '#fefce8',
noteTextColor: isDark ? '#ccfbf1' : '#134e4a',
noteBorderColor: isDark ? '#fbbf24' : '#d97706',
}
});
function initDiagram(shell) {
const wrap = shell.querySelector('.mermaid-wrap');
const viewport = shell.querySelector('.mermaid-viewport');
const canvas = shell.querySelector('.mermaid-canvas');
const source = shell.querySelector('.diagram-source');
const label = shell.querySelector('.zoom-label');
if (!wrap || !viewport || !canvas || !source || !label) {
console.error('initDiagram: missing required elements in', shell);
return;
}
let zoom = 1;
let fitMode = 'contain';
let panX = 0;
let panY = 0;
let svgW = 0;
let svgH = 0;
let sx = 0;
let sy = 0;
let spx = 0;
let spy = 0;
let touchDist = 0;
let touchCx = 0;
let touchCy = 0;
function constrainPan() {
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const rW = svgW * zoom;
const rH = svgH * zoom;
const pad = config.fitPadding;
panX = (rW + pad * 2 <= vpW)
? (vpW - rW) / 2
: clamp(panX, vpW - rW - pad, pad);
panY = (rH + pad * 2 <= vpH)
? (vpH - rH) / 2
: clamp(panY, vpH - rH - pad, pad);
}
function applyTransform() {
const svg = canvas.querySelector('svg');
if (!svg || !svgW) return;
constrainPan();
svg.style.width = (svgW * zoom) + 'px';
svg.style.height = (svgH * zoom) + 'px';
canvas.style.transform = `translate(${panX}px, ${panY}px)`;
label.textContent = Math.round(zoom * 100) + '% \u2014 ' + fitMode;
}
function canPan() {
const rW = svgW * zoom;
const rH = svgH * zoom;
return rW + config.fitPadding * 2 > viewport.clientWidth
|| rH + config.fitPadding * 2 > viewport.clientHeight;
}
function computeSmartFit() {
const vpW = viewport.clientWidth;
const vpH = viewport.clientHeight;
const aW = Math.max(80, vpW - config.fitPadding * 2);
const aH = Math.max(80, vpH - config.fitPadding * 2);
const contain = Math.min(aW / svgW, aH / svgH);
let z = contain;
let mode = 'contain';
if (contain < config.readabilityFloor) {
const chartR = svgH / svgW;
const vpR = vpH / Math.max(vpW, 1);
if (chartR >= vpR) {
z = aW / svgW;
mode = 'width-priority';
} else {
z = aH / svgH;
mode = 'height-priority';
}
}
return { zoom: clamp(z, config.minZoom, config.maxInitialZoom), mode };
}
function fitDiagram() {
if (!svgW) return;
const fit = computeSmartFit();
zoom = fit.zoom;
fitMode = fit.mode;
panX = (viewport.clientWidth - svgW * zoom) / 2;
panY = (viewport.clientHeight - svgH * zoom) / 2;
applyTransform();
}
function setOneToOne() {
zoom = clamp(1, config.minZoom, config.maxZoom);
fitMode = '1:1';
panX = (viewport.clientWidth - svgW * zoom) / 2;
panY = (viewport.clientHeight - svgH * zoom) / 2;
applyTransform();
}
function zoomAround(factor, cx, cy) {
const next = clamp(zoom * factor, config.minZoom, config.maxZoom);
const ratio = next / zoom;
panX = cx - ratio * (cx - panX);
panY = cy - ratio * (cy - panY);
zoom = next;
fitMode = 'custom';
applyTransform();
}
function readSvgNaturalSize(svg) {
let w = 0;
let h = 0;
if (svg.viewBox?.baseVal?.width > 0) {
w = svg.viewBox.baseVal.width;
h = svg.viewBox.baseVal.height;
}
if (!w) {
w = parseFloat(svg.getAttribute('width')) || 0;
h = parseFloat(svg.getAttribute('height')) || 0;
}
if (!w) {
const b = svg.getBBox();
w = b.width;
h = b.height;
}
if (!w) {
const r = svg.getBoundingClientRect();
w = r.width || 1000;
h = r.height || 700;
}
if (!svg.getAttribute('viewBox')) {
svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
}
return { w, h };
}
function setAdaptiveHeight() {
if (!svgW) return;
const usableW = Math.max(280, wrap.getBoundingClientRect().width - 2);
const idealH = (svgH / svgW) * usableW + config.fitPadding * 2;
const maxVp = Math.floor(innerHeight * config.maxHeightVh);
const hardMax = Math.min(config.maxHeightPx, Math.max(config.minHeight + 40, maxVp));
wrap.style.height = Math.round(clamp(idealH, config.minHeight, hardMax)) + 'px';
}
function openInNewTab() {
const svg = canvas.querySelector('svg');
if (!svg) return;
const clone = svg.cloneNode(true);
clone.style.width = '';
clone.style.height = '';
// Use the same isDark value that was used to render the Mermaid theme
// This ensures the background matches the baked-in SVG colors
const bg = isDark ? '#042f2e' : '#f0fdfa';
const html = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Diagram</title><style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:${bg};padding:40px;box-sizing:border-box}
svg{max-width:100%;max-height:90vh;height:auto}
</style></head><body>${clone.outerHTML}</body></html>`;
open(URL.createObjectURL(new Blob([html], { type: 'text/html' })), '_blank');
}
async function render() {
try {
const code = source.textContent.trim();
if (!code) {
label.textContent = 'Error: Empty source';
return;
}
const id = 'diagram-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8);
const { svg } = await mermaid.render(id, code);
canvas.innerHTML = svg;
const svgNode = canvas.querySelector('svg');
if (!svgNode) {
label.textContent = 'Error: No SVG';
return;
}
const size = readSvgNaturalSize(svgNode);
svgW = size.w;
svgH = size.h;
svgNode.removeAttribute('width');
svgNode.removeAttribute('height');
svgNode.style.maxWidth = 'none';
svgNode.style.display = 'block';
setAdaptiveHeight();
fitDiagram();
} catch (err) {
console.error('Mermaid render failed:', err);
label.textContent = 'Error: ' + (err.message || 'Render failed');
}
}
const actions = {
'zoom-in': () => zoomAround(1 + config.zoomStep, viewport.clientWidth / 2, viewport.clientHeight / 2),
'zoom-out': () => zoomAround(1 / (1 + config.zoomStep), viewport.clientWidth / 2, viewport.clientHeight / 2),
'zoom-fit': fitDiagram,
'zoom-one': setOneToOne,
'zoom-expand': openInNewTab
};
Object.entries(actions).forEach(([action, handler]) => {
wrap.querySelector(`[data-action="${action}"]`)?.addEventListener('click', handler);
});
viewport.addEventListener('dblclick', fitDiagram);
viewport.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const rect = viewport.getBoundingClientRect();
const factor = e.deltaY < 0 ? 1 + config.zoomStep : 1 / (1 + config.zoomStep);
zoomAround(factor, e.clientX - rect.left, e.clientY - rect.top);
return;
}
if (canPan()) {
e.preventDefault();
panX -= e.deltaX;
panY -= e.deltaY;
applyTransform();
}
}, { passive: false });
viewport.addEventListener('mousedown', (e) => {
if (e.target.closest('.zoom-controls') || !canPan()) return;
wrap.classList.add('is-panning');
sx = e.clientX;
sy = e.clientY;
spx = panX;
spy = panY;
e.preventDefault();
activeDrag = {
onMove: (ev) => {
panX = spx + (ev.clientX - sx);
panY = spy + (ev.clientY - sy);
applyTransform();
},
onEnd: () => {
wrap.classList.remove('is-panning');
}
};
});
viewport.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
spx = panX;
spy = panY;
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
touchDist = Math.sqrt(dx * dx + dy * dy);
const r = viewport.getBoundingClientRect();
touchCx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - r.left;
touchCy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - r.top;
}
}, { passive: true });
viewport.addEventListener('touchmove', (e) => {
if (e.touches.length === 1 && canPan()) {
if (touchDist > 0) {
sx = e.touches[0].clientX;
sy = e.touches[0].clientY;
spx = panX;
spy = panY;
touchDist = 0;
}
e.preventDefault();
panX = spx + (e.touches[0].clientX - sx);
panY = spy + (e.touches[0].clientY - sy);
applyTransform();
} else if (e.touches.length === 2 && touchDist > 0) {
e.preventDefault();
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const d = Math.sqrt(dx * dx + dy * dy);
zoomAround(d / touchDist, touchCx, touchCy);
touchDist = d;
}
}, { passive: false });
new ResizeObserver(() => {
if (svgW) {
setAdaptiveHeight();
fitDiagram();
}
}).observe(wrap);
render();
}
document.querySelectorAll('.diagram-shell').forEach(initDiagram);
</script>
</body>
</html>