Files
english/.opencode/skills/preview/references/html-css-patterns.md
2026-04-12 01:06:31 +07:00

41 KiB
Raw Permalink Blame History

CSS Patterns for HTML Diagrams

Reusable patterns for layout, connectors, theming, and visual effects in self-contained HTML diagrams.

Theme Setup

Always define both light and dark palettes via custom properties. Start with whichever fits the chosen aesthetic, ensure both work.

Palette cohesion rule: Background, text, and accent colors must belong to the same color family. A warm palette (terracotta, cream, sage) should have warm grays for text-dim and warm-tinted borders — never mix warm accents with cool GitHub-gray backgrounds. Each page should feel like one intentional color story, not a generic template with an accent color dropped on top.

Semantic color richness: Define 5-6 semantic colors per palette, not just 3 node colors. Richer palettes give the page visual variety without clashing. Include status colors (--green, --red/danger, --amber) and secondary accents (--sage, --teal, --plum) so different sections can have distinct character while staying harmonious.

Light is the default. Dark activates via OS preference (@media) OR manual toggle ([data-theme="dark"]). The [data-theme] selector has higher specificity, so a manual toggle always wins.

/* ── Light (default) ── */
:root {
  --font-body: 'IBM Plex Sans', system-ui, sans-serif;
  --font-mono: 'IBM Plex Mono', 'SF Mono', Consolas, monospace;

  --bg: #faf7f5;
  --surface: #ffffff;
  --surface2: #f5f0ec;
  --surface-elevated: #fff9f5;
  --border: rgba(0, 0, 0, 0.07);
  --border-bright: rgba(0, 0, 0, 0.14);
  --text: #292017;
  --text-dim: #8a7e72;
  --text-bright: #1a1510;
  --accent: #c2410c;
  --accent-dim: rgba(194, 65, 12, 0.07);

  --node-a: #c2410c;
  --node-a-dim: rgba(194, 65, 12, 0.07);
  --node-b: #4d7c0f;
  --node-b-dim: rgba(77, 124, 15, 0.07);
  --node-c: #0f766e;
  --node-c-dim: rgba(15, 118, 110, 0.07);

  --green: #4d7c0f;
  --green-dim: rgba(77, 124, 15, 0.07);
  --red: #b91c1c;
  --red-dim: rgba(185, 28, 28, 0.07);
  --amber: #b45309;
  --amber-dim: rgba(180, 83, 9, 0.07);
  --sage: #65a30d;
  --sage-dim: rgba(101, 163, 13, 0.07);
  --teal: #0f766e;
  --teal-dim: rgba(15, 118, 110, 0.07);
  --plum: #9f1239;
  --plum-dim: rgba(159, 18, 57, 0.07);
}

/* ── Dark (OS preference fallback) ── */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #1a1412;
    --surface: #231d1a;
    --surface2: #2e2622;
    --surface-elevated: #352d28;
    --border: rgba(255, 255, 255, 0.06);
    --border-bright: rgba(255, 255, 255, 0.12);
    --text: #ede5dd;
    --text-dim: #a69889;
    --text-bright: #faf5f0;
    --accent: #fb923c;
    --accent-dim: rgba(251, 146, 60, 0.12);

    --node-a: #fb923c;
    --node-a-dim: rgba(251, 146, 60, 0.12);
    --node-b: #a3e635;
    --node-b-dim: rgba(163, 230, 53, 0.1);
    --node-c: #5eead4;
    --node-c-dim: rgba(94, 234, 212, 0.1);

    --green: #a3e635;
    --green-dim: rgba(163, 230, 53, 0.1);
    --red: #fca5a5;
    --red-dim: rgba(252, 165, 165, 0.1);
    --amber: #fbbf24;
    --amber-dim: rgba(251, 191, 36, 0.1);
    --sage: #bef264;
    --sage-dim: rgba(190, 242, 100, 0.1);
    --teal: #5eead4;
    --teal-dim: rgba(94, 234, 212, 0.1);
    --plum: #fda4af;
    --plum-dim: rgba(253, 164, 175, 0.1);
  }
}

/* ── Dark (manual toggle override) ── */
[data-theme="dark"] {
  --bg: #1a1412;
  --surface: #231d1a;
  --surface2: #2e2622;
  --surface-elevated: #352d28;
  --border: rgba(255, 255, 255, 0.06);
  --border-bright: rgba(255, 255, 255, 0.12);
  --text: #ede5dd;
  --text-dim: #a69889;
  --text-bright: #faf5f0;
  --accent: #fb923c;
  --accent-dim: rgba(251, 146, 60, 0.12);

  --node-a: #fb923c;
  --node-a-dim: rgba(251, 146, 60, 0.12);
  --node-b: #a3e635;
  --node-b-dim: rgba(163, 230, 53, 0.1);
  --node-c: #5eead4;
  --node-c-dim: rgba(94, 234, 212, 0.1);

  --green: #a3e635;
  --green-dim: rgba(163, 230, 53, 0.1);
  --red: #fca5a5;
  --red-dim: rgba(252, 165, 165, 0.1);
  --amber: #fbbf24;
  --amber-dim: rgba(251, 191, 36, 0.1);
  --sage: #bef264;
  --sage-dim: rgba(190, 242, 100, 0.1);
  --teal: #5eead4;
  --teal-dim: rgba(94, 234, 212, 0.1);
  --plum: #fda4af;
  --plum-dim: rgba(253, 164, 175, 0.1);
}

How it works: :root = light default. @media (prefers-color-scheme: dark) with :root:not([data-theme="light"]) respects OS preference unless user explicitly chose light. [data-theme="dark"] forces dark regardless of OS. No JS needed for the CSS — toggle button JS just sets the attribute.

Choosing a different palette: The above is the warm default. For other aesthetics, pick a preset from html-design-guidelines.md and extend it with the same semantic color structure (--green, --red, --amber, --sage, --teal, --plum). Every preset in that file defines the core variables; add the semantic layer on top to maintain richness. When using a different preset, replicate the three-tier pattern above (:root light, @media dark with :not([data-theme="light"]), [data-theme="dark"] override).

Theme Toggle Button (MANDATORY)

MUST include a theme toggle button in EVERY generated HTML page. This is non-negotiable — pages without the toggle are considered incomplete. Place it fixed in the top-right corner.

CSS

.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(--surface2);
  color: var(--text);
}

HTML + JS

Place the button as the first child of <body>. The script detects OS preference on load and persists manual choice in localStorage.

<button class="theme-toggle" id="themeToggle" title="Toggle theme" aria-label="Toggle light/dark theme"></button>

<script>
(function() {
  var toggle = document.getElementById('themeToggle');
  var saved = localStorage.getItem('theme');
  var initial = saved || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
  if (saved) document.documentElement.setAttribute('data-theme', initial);
  toggle.textContent = initial === 'dark' ? '\u2600' : '\u263E';
  toggle.addEventListener('click', function() {
    var current = document.documentElement.getAttribute('data-theme')
      || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    var next = current === 'light' ? 'dark' : 'light';
    document.documentElement.setAttribute('data-theme', next);
    localStorage.setItem('theme', next);
    toggle.textContent = next === 'dark' ? '\u2600' : '\u263E';
  });
})();
</script>

Symbols: \u2600 = sun (shown in dark mode — click to go light), \u263E = moon (shown in light mode — click to go dark). No emoji — these are Unicode dingbats that render consistently across platforms.

Typography Floor

Minimum readable font sizes for generated HTML pages. Smaller sizes strain readability, especially on high-DPI screens.

Element Minimum Recommended
Body / card content 15px 1516px
Code blocks 14px 14px
Table cells 14px 1415px
Table headers (mono uppercase) 12px 12px
List items 14px 15px
Section labels (mono uppercase) 11px 12px
Card labels (mono uppercase) 11px 11px
Status badges (mono) 12px 12px
TOC links 11px 12px
Callout body 15px 16px

Monospace uppercase labels are allowed at 11px because letter-spacing and uppercase improve legibility at small sizes. Body text and content must stay at 14px+.

Background Atmosphere

Flat backgrounds feel dead. Use subtle gradients or patterns.

/* Radial glow behind focal area */
body {
  background: var(--bg);
  background-image: radial-gradient(ellipse at 50% 0%, var(--accent-dim) 0%, transparent 60%);
}

/* Faint dot grid */
body {
  background-color: var(--bg);
  background-image: radial-gradient(circle, var(--border) 1px, transparent 1px);
  background-size: 24px 24px;
}

/* Diagonal subtle lines */
body {
  background-color: var(--bg);
  background-image: repeating-linear-gradient(
    -45deg, transparent, transparent 40px,
    var(--border) 40px, var(--border) 41px
  );
}

/* Gradient mesh (pick 2-3 positioned radials) */
body {
  background: var(--bg);
  background-image:
    radial-gradient(at 20% 20%, var(--node-a-dim) 0%, transparent 50%),
    radial-gradient(at 80% 60%, var(--node-b-dim) 0%, transparent 50%);
}

Never rely on browser default link colors. The default blue (#0000EE) has poor contrast on dark backgrounds. Style links with color: var(--accent) and keep underlines for discoverability. On dark backgrounds, use bright accents from the palette (--node-a, --teal, --sage). On light backgrounds, use deeper tones (--accent, --node-b, --node-c). Always use palette variables — never hardcode hex values for links.

Section / Card Components

The fundamental building block. A colored card representing a system component, pipeline step, or data entity.

IMPORTANT: Never use .node as a CSS class name. Mermaid.js internally uses .node on its SVG <g> elements with transform: translate(x, y) for positioning. Any page-level .node styles (hover transforms, box-shadows, transitions) will leak into Mermaid diagrams and break their layout. Use .ve-card instead (namespaced to avoid collisions with CSS frameworks like Bootstrap/Tailwind that also use .card).

.ve-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 16px 20px;
  position: relative;
}

/* Colored accent border (left or top) */
.ve-card--accent-a {
  border-left: 3px solid var(--node-a);
}

/* --- Depth tiers: vary card depth to signal importance --- */

/* Elevated: KPIs, key sections, anything that should pop */
.ve-card--elevated {
  background: var(--surface-elevated);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
}

/* Recessed: code blocks, secondary content, detail panels */
.ve-card--recessed {
  background: color-mix(in srgb, var(--bg) 70%, var(--surface) 30%);
  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.06);
  border-color: var(--border);
}

/* Hero: executive summaries, focal elements — demands attention */
.ve-card--hero {
  background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
  border-color: color-mix(in srgb, var(--border) 50%, var(--accent) 50%);
}

/* Glass: special-occasion overlay effect (use sparingly) */
.ve-card--glass {
  background: color-mix(in srgb, var(--surface) 60%, transparent 40%);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border-color: rgba(255, 255, 255, 0.1);
}

/* Section label (monospace, uppercase) */
.ve-card__label {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  color: var(--node-a);
  margin-bottom: 10px;
  display: flex;
  align-items: center;
  gap: 8px;
}

/* Colored dot indicator */
.ve-card__label::before {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
}

Code Blocks

Code blocks need explicit whitespace preservation and a max-height constraint. Without these, code runs together and long files overwhelm the page.

Basic Pattern

.code-block {
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1.5;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px;
  overflow-x: auto;
  /* CRITICAL: preserve line breaks and indentation */
  white-space: pre-wrap;
  word-break: break-word;
}

/* Constrain height for long code */
.code-block--scroll {
  max-height: 400px;
  overflow-y: auto;
}
<pre class="code-block code-block--scroll"><code>// Your code here
function example() {
  return true;
}</code></pre>

With File Header

.code-file {
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
}

.code-file__header {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-dim);
}

.code-file__body {
  font-family: var(--font-mono);
  font-size: 14px;
  line-height: 1.5;
  padding: 16px;
  background: var(--surface-elevated);
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 500px;
  overflow: auto;
}
<div class="code-file">
  <div class="code-file__header">
    <span>src/extension.ts</span>
  </div>
  <pre class="code-file__body"><code>export function activate() {
  // ...
}</code></pre>
</div>

Implementation Plans: Don't Dump Full Files

For implementation plans and architecture docs, don't display entire source files inline. Instead:

  1. Show structure, not code:

    <div class="file-structure">
      <div class="file-structure__path">src/extension.ts</div>
      <ul class="file-structure__outline">
        <li><code>activate()</code> — Entry point</li>
        <li><code>clearState()</code> — Reset extension state</li>
      </ul>
    </div>
    
  2. Use collapsible sections for full code:

    <details class="collapsible">
      <summary>Full implementation (87 lines)</summary>
      <pre class="code-file__body"><code>...</code></pre>
    </details>
    
  3. Show key snippets only — the 5-10 lines illustrating core logic.

Anti-patterns:

  • Displaying full source files inline (100+ lines overwhelming the page)
  • Code blocks without white-space: pre-wrap (code runs together)
  • No height constraint on long code (page becomes endless scroll)

Directory Tree

For file structures, use <pre> with monospace + white-space: pre. Tree connectors (├──, └──, ) only work when vertically aligned.

.dir-tree {
  font-family: var(--font-mono);
  font-size: 13px;
  line-height: 1.7;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 16px 20px;
  overflow-x: auto;
  white-space: pre;
}

.dir-tree .ann { color: var(--text-dim); font-size: 11px; font-style: italic; }
.dir-tree .hl  { color: var(--accent); font-weight: 600; }
<pre class="dir-tree">my-project/
├── src/
│   ├── <span class="hl">index.ts</span>       <span class="ann">— entry point</span>
│   └── utils/
└── README.md</pre>

Never render tree connectors inside wrapping text, flex children, or grid items — vertical pipes lose alignment and the hierarchy becomes unreadable.

Overflow Protection

Grid and flex children default to min-width: auto, which prevents them from shrinking below their content width.

Global rules

/* Every grid/flex child must be able to shrink */
.grid > *, .flex > *,
[style*="display: grid"] > *,
[style*="display: flex"] > * {
  min-width: 0;
}

/* Long text wraps instead of overflowing */
body {
  overflow-wrap: break-word;
}

Side-by-side comparison panels

.comparison {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}

.comparison > * {
  min-width: 0;
  overflow-wrap: break-word;
}

@media (max-width: 768px) {
  .comparison { grid-template-columns: 1fr; }
}

Never use display: flex on <li> for marker characters

Using display: flex on a list item creates an anonymous flex item for the remaining text. That anonymous item gets min-width: auto and you cannot set min-width: 0 on anonymous boxes. Lines with many inline <code> badges will overflow with no CSS fix possible.

Use absolute positioning for markers instead:

/* WRONG — causes overflow with inline code badges */
li {
  display: flex;
  align-items: baseline;
  gap: 6px;
}
li::before {
  content: '';
  flex-shrink: 0;
}

/* RIGHT — text wraps normally */
li {
  padding-left: 14px;
  position: relative;
}
li::before {
  content: '';
  position: absolute;
  left: 0;
}

List markers overlapping container borders

/* RIGHT — use inside positioning or adequate padding */
.card ol, .card ul {
  list-style-position: inside;
}

/* OR — adequate padding for outside markers */
.card ol, .card ul {
  padding-left: 2em;
}

/* OR — custom markers with absolute positioning */
.card ol {
  list-style: none;
  padding-left: 0;
  counter-reset: item;
}
.card ol li {
  counter-increment: item;
  padding-left: 2em;
  position: relative;
}
.card ol li::before {
  content: counter(item) ".";
  position: absolute;
  left: 0;
  color: var(--accent);
  font-weight: 600;
}

Rule of thumb: Any <ol> or <ul> inside a bordered container needs either list-style-position: inside or padding-left: 2em minimum.

Mermaid Containers

Mermaid diagrams have two common layout issues: they render too small to read, and they left-align leaving awkward dead space.

Centering (Required)

/* WRONG — diagram hugs left edge */
.mermaid-container {
  padding: 24px;
  border: 1px solid var(--border);
}

/* RIGHT — diagram centers in container */
.mermaid-wrap {
  display: flex;
  justify-content: center;
  align-items: flex-start;
  padding: 24px;
  border: 1px solid var(--border);
}

Scaling Small Diagrams

1. Increase fontSize in themeVariables (most effective):

mermaid.initialize({
  theme: 'base',
  themeVariables: {
    fontSize: '18px',  // default 16px, bump to 18-20px for complex diagrams
  }
});

2. CSS zoom for diagrams that still render too small:

.mermaid-wrap--scaled .mermaid {
  zoom: 1.3;
}

3. Constrain container width so the diagram doesn't float in dead space:

.mermaid-wrap--constrained {
  max-width: 800px;
  margin: 0 auto;
}

Rule of thumb: If the diagram has 10+ nodes or text is smaller than 12px rendered, increase fontSize to 18-20px or apply CSS zoom.

Zoom Controls — Full Pattern

Add zoom controls to every .mermaid-wrap container.

.mermaid-wrap {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 32px 24px;
  overflow: auto;
  /* CRITICAL: center the diagram */
  display: flex;
  justify-content: center;
  align-items: center;
  /* Prevent vertical flowcharts from compressing */
  min-height: 400px;
}

.mermaid-wrap--compact { min-height: 200px; }
.mermaid-wrap--tall { min-height: 600px; }

.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;
}

How the zoom/pan engine works: The SVG is rendered into .mermaid-canvas which is absolutely positioned inside .mermaid-viewport. Zooming sets the SVG's width and height styles directly. Panning applies transform: translate() to the canvas. The viewport has overflow: hidden to clip panned content.

HTML Structure

<section class="diagram-shell">
  <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 --> B
  </script>
</section>

Use one .diagram-shell per diagram. The source Mermaid text lives in <script type="text/plain" class="diagram-source">, so multiple diagrams can coexist without ID collisions.

JavaScript (Closure-Based)

const config = { /* fitPadding, zoom bounds, readabilityFloor */ };
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; });

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;
  }

  // Per-diagram state in closure
  let zoom = 1;
  let panX = 0;
  let panY = 0;

  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;
      // wire controls, fit, zoom/pan/touch handlers scoped to this shell
    } catch (err) {
      console.error('Mermaid render failed:', err);
      label.textContent = 'Error: ' + (err.message || 'Render failed');
    }
  }

  render();
}

document.querySelectorAll('.diagram-shell').forEach(initDiagram);

This pattern removes all hardcoded IDs and supports unlimited diagrams per page. For the full implementation (smart fit, pinch zoom, shared drag state), use the full template from the skill's templates/ directory.

⚠️ Never use bare <pre class="mermaid">. It renders but has no zoom/pan controls — diagrams become tiny and unusable. Always use the full diagram-shell pattern above.

Grid Layouts

Architecture Diagram (2-column with sidebar)

.arch-grid {
  display: grid;
  grid-template-columns: 260px 1fr;
  grid-template-rows: auto;
  gap: 20px;
  max-width: 1100px;
  margin: 0 auto;
}

.arch-grid__sidebar { grid-column: 1; }
.arch-grid__main { grid-column: 2; }
.arch-grid__full { grid-column: 1 / -1; }

Pipeline (horizontal steps)

.pipeline {
  display: flex;
  align-items: stretch;
  gap: 0;
  overflow-x: auto;
  padding-bottom: 8px;
}

.pipeline__step {
  min-width: 130px;
  flex-shrink: 0;
}

.pipeline__arrow {
  display: flex;
  align-items: center;
  padding: 0 4px;
  color: var(--border-bright);
  font-size: 18px;
  flex-shrink: 0;
}

/* Parallel branch within a pipeline */
.pipeline__parallel {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

Card Grid (dashboard / metrics)

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 16px;
}

Data Tables

Use real <table> elements for tabular data. Wrap in a scrollable container for wide tables.

/* Scrollable wrapper for wide tables */
.table-wrap {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  overflow: hidden;
}

.table-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

/* Base table */
.data-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
  line-height: 1.5;
}

/* Header */
.data-table thead {
  position: sticky;
  top: 0;
  z-index: 2;
}

.data-table th {
  background: var(--surface-elevated, var(--surface));
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--text-dim);
  text-align: left;
  padding: 12px 16px;
  border-bottom: 2px solid var(--border-bright);
  white-space: nowrap;
}

/* Cells */
.data-table td {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  vertical-align: top;
  color: var(--text);
}

/* Let text-heavy columns wrap naturally */
.data-table .wide {
  min-width: 200px;
  max-width: 500px;
}

/* Right-align numeric columns */
.data-table td.num,
.data-table th.num {
  text-align: right;
  font-variant-numeric: tabular-nums;
  font-family: var(--font-mono);
}

/* Alternating rows */
.data-table tbody tr:nth-child(even) {
  background: var(--accent-dim);
}

/* Row hover */
.data-table tbody tr {
  transition: background 0.15s ease;
}

.data-table tbody tr:hover {
  background: var(--border);
}

/* Last row: no bottom border */
.data-table tbody tr:last-child td {
  border-bottom: none;
}

/* Code inside cells */
.data-table code {
  font-family: var(--font-mono);
  font-size: 11px;
  background: var(--accent-dim);
  color: var(--accent);
  padding: 1px 5px;
  border-radius: 3px;
}

/* Secondary detail text */
.data-table small {
  display: block;
  color: var(--text-dim);
  font-size: 11px;
  margin-top: 2px;
}

Status Indicators

Styled spans for match/gap/warning states. Never use emoji.

.status {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 500;
  padding: 3px 10px;
  border-radius: 6px;
  white-space: nowrap;
}

.status--match {
  background: var(--green-dim, rgba(5, 150, 105, 0.1));
  color: var(--green, #059669);
}

.status--gap {
  background: var(--red-dim, rgba(239, 68, 68, 0.1));
  color: var(--red, #ef4444);
}

.status--warn {
  background: var(--orange-dim, rgba(217, 119, 6, 0.1));
  color: var(--orange, #d97706);
}

.status--info {
  background: var(--accent-dim);
  color: var(--accent);
}

/* Dot variant (compact, no text) */
.status-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  display: inline-block;
}

.status-dot--match { background: var(--green, #059669); }
.status-dot--gap { background: var(--red, #ef4444); }
.status-dot--warn { background: var(--orange, #d97706); }

Usage in table cells:

<td><span class="status status--match">Match</span></td>
<td><span class="status status--gap">Gap</span></td>
<td><span class="status status--warn">Partial</span></td>

Table Summary Row

.data-table tfoot td {
  background: var(--surface-elevated, var(--surface));
  font-weight: 600;
  font-size: 12px;
  border-top: 2px solid var(--border-bright);
  border-bottom: none;
  padding: 12px 16px;
}

Sticky First Column (for very wide tables)

.data-table th:first-child,
.data-table td:first-child {
  position: sticky;
  left: 0;
  z-index: 1;
  background: var(--surface);
}

.data-table tbody tr:nth-child(even) td:first-child {
  background: color-mix(in srgb, var(--surface) 95%, var(--accent) 5%);
}

Connectors

CSS Arrow (vertical, between stacked sections)

.flow-arrow {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
  color: var(--text-dim);
  font-family: var(--font-mono);
  font-size: 12px;
  padding: 6px 0;
}

.flow-arrow svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: var(--border-bright);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

Down arrow SVG (reuse inline):

<svg viewBox="0 0 20 20"><path d="M10 4 L10 16 M6 12 L10 16 L14 12"/></svg>

CSS Arrow (horizontal, between inline steps)

.h-arrow::after {
  content: '→';
  color: var(--border-bright);
  font-size: 18px;
  padding: 0 4px;
}

SVG Curved Connector (between arbitrary nodes)

<svg class="connectors" style="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;">
  <path d="M 150,100 C 150,200 350,100 350,200" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-dasharray="4 3"/>
  <!-- Arrowhead -->
  <polygon points="348,195 352,205 356,195" fill="var(--accent)"/>
</svg>

Position the parent container as position: relative to scope the SVG overlay.

Animations

Staggered Fade-In on Load

Define the keyframe once, then stagger via a --i CSS variable set per element.

@keyframes fadeUp {
  from { opacity: 0; transform: translateY(12px); }
  to { opacity: 1; transform: translateY(0); }
}

.ve-card {
  animation: fadeUp 0.4s ease-out both;
  animation-delay: calc(var(--i, 0) * 0.05s);
}

Set --i per element to control stagger order:

<div class="ve-card" style="--i: 0">First</div>
<div class="ve-card" style="--i: 1">Second</div>
<div class="ve-card" style="--i: 2">Third</div>

Hover Lift

.ve-card {
  transition: transform 0.2s ease, box-shadow 0.2s ease;
}

.ve-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}

Scale-Fade (for KPI cards, badges)

@keyframes fadeScale {
  from { opacity: 0; transform: scale(0.92); }
  to { opacity: 1; transform: scale(1); }
}

.kpi-card {
  animation: fadeScale 0.35s ease-out both;
  animation-delay: calc(var(--i, 0) * 0.06s);
}

SVG Draw-In (for connectors, path elements)

@keyframes drawIn {
  from { stroke-dashoffset: var(--path-length); }
  to { stroke-dashoffset: 0; }
}

/* Set --path-length to the path's getTotalLength() value */
.connector path {
  stroke-dasharray: var(--path-length);
  animation: drawIn 0.8s ease-in-out both;
  animation-delay: calc(var(--i, 0) * 0.1s);
}

CSS Counter (hero numbers without JS)

Uses @property to animate a custom property as an integer. Falls back to showing the final value in browsers without @property support.

@property --count {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@keyframes countUp {
  to { --count: var(--target); }
}

.kpi-card__value--animated {
  --target: 247;
  counter-reset: val var(--count);
  animation: countUp 1.2s ease-out forwards;
}

.kpi-card__value--animated::after {
  content: counter(val);
}

Choreography

Mix animation types by element role:

  • Cards: fadeUp — default entrance, reliable and subtle
  • KPI / badges: fadeScale — scale draws the eye to important numbers
  • SVG connectors: drawIn — reveals flow direction, pairs with card stagger
  • Hero numbers: countUp — counting motion signals "this number matters"
  • Stagger timing: calc(var(--i) * 0.06s) with lower --i on important elements (appear first)

Respect Reduced Motion

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

Sparklines and Simple Charts (Pure SVG)

<!-- Sparkline -->
<svg viewBox="0 0 100 30" style="width:100px;height:30px;">
  <polyline points="0,25 15,20 30,22 45,10 60,15 75,5 90,12 100,8"
    fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linecap="round"/>
</svg>

<!-- Progress bar -->
<div style="height:6px;background:var(--border);border-radius:3px;overflow:hidden;">
  <div style="height:100%;width:72%;background:var(--accent);border-radius:3px;"></div>
</div>

Responsive Breakpoint

Include a single breakpoint for narrow viewports:

@media (max-width: 768px) {
  .arch-grid { grid-template-columns: 1fr; }
  .pipeline { flex-wrap: wrap; gap: 8px; }
  .pipeline__arrow { display: none; }
  body { padding: 16px; }
}

Badges and Tags

.tag {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 500;
  padding: 2px 7px;
  border-radius: 4px;
  background: var(--node-a-dim);
  color: var(--node-a);
}

Lists Inside Nodes

.node-list {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 14px;
  line-height: 1.8;
}

.node-list li {
  padding-left: 14px;
  position: relative;
}

.node-list li::before {
  content: '';
  color: var(--text-dim);
  font-weight: 600;
  position: absolute;
  left: 0;
}

.node-list code {
  font-family: var(--font-mono);
  font-size: 11px;
  background: var(--accent-dim);
  color: var(--accent);
  padding: 1px 5px;
  border-radius: 3px;
}

KPI / Metric Cards

.kpi-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 16px;
}

.kpi-card {
  background: var(--surface-elevated);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 20px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.kpi-card__value {
  font-size: 36px;
  font-weight: 700;
  letter-spacing: -1px;
  line-height: 1.1;
  font-variant-numeric: tabular-nums;
}

.kpi-card__label {
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  color: var(--text-dim);
  margin-top: 6px;
}

.kpi-card__trend {
  font-family: var(--font-mono);
  font-size: 12px;
  margin-top: 4px;
}

.kpi-card__trend--up { color: var(--node-b, #059669); }
.kpi-card__trend--down { color: var(--red, #ef4444); }
<div class="kpi-row">
  <div class="kpi-card">
    <div class="kpi-card__value">247</div>
    <div class="kpi-card__label">Lines Added</div>
    <div class="kpi-card__trend kpi-card__trend--up">+34%</div>
  </div>
</div>

Before / After Panels

.diff-panels {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0;
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

.diff-panels > * { min-width: 0; overflow-wrap: break-word; }

.diff-panel__header {
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1px;
  padding: 10px 16px;
}

.diff-panel__header--before {
  background: var(--red-dim, rgba(239, 68, 68, 0.08));
  color: var(--red, #ef4444);
  border-bottom: 2px solid var(--red, #ef4444);
}

.diff-panel__header--after {
  background: var(--green-dim, rgba(5, 150, 105, 0.08));
  color: var(--green, #059669);
  border-bottom: 2px solid var(--green, #059669);
}

.diff-panel__body {
  padding: 16px;
  background: var(--surface);
  font-size: 15px;
  line-height: 1.6;
}

.diff-changed {
  background: var(--accent-dim);
  border-radius: 3px;
  padding: 0 3px;
}

@media (max-width: 768px) {
  .diff-panels { grid-template-columns: 1fr; }
}

Collapsible Sections

Native <details>/<summary> with styled disclosure. Zero JS, accessible. For lower-priority content: file maps, decision logs, reference sections.

details.collapsible {
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}

details.collapsible summary {
  padding: 14px 20px;
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
  list-style: none;
  display: flex;
  align-items: center;
  gap: 8px;
  color: var(--text);
  transition: background 0.15s ease;
}

details.collapsible summary:hover {
  background: var(--surface-elevated, var(--surface));
}

details.collapsible summary::-webkit-details-marker { display: none; }

/* Chevron indicator */
details.collapsible summary::before {
  content: '▸';
  font-size: 11px;
  color: var(--text-dim);
  transition: transform 0.15s ease;
}

details.collapsible[open] summary::before {
  transform: rotate(90deg);
}

details.collapsible .collapsible__body {
  padding: 16px 20px;
  border-top: 1px solid var(--border);
  font-size: 15px;
  line-height: 1.6;
}
<details class="collapsible">
  <summary>File Map (14 files changed)</summary>
  <div class="collapsible__body">
    <!-- content here -->
  </div>
</details>

Prose Page Elements

Patterns for documentation, articles, blog posts, and reading-first content. Optimize for sustained reading, not scanning.

Body Text Settings

.prose {
  font-size: clamp(17px, 1.1vw + 14px, 19px);
  line-height: 1.7;
  max-width: 65ch;
  text-wrap: pretty;
}

.prose p {
  margin-bottom: 1.5em;
}

.prose--narrow {
  max-width: 60ch;
  line-height: 1.8;
}

.prose--wide {
  max-width: 75ch;
  line-height: 1.6;
}

Lead Paragraph

.lead {
  font-size: 20px;
  line-height: 1.6;
  color: var(--text-bright);
  margin-bottom: 32px;
}

.lead--dropcap::first-letter {
  float: left;
  font-family: var(--font-display);
  font-size: 64px;
  font-weight: 600;
  line-height: 0.85;
  padding-right: 12px;
  padding-top: 6px;
  color: var(--accent);
}

Pull Quotes

/* Border left — most versatile */
.pullquote {
  margin: 48px 0;
  padding-left: 24px;
  border-left: 3px solid var(--accent);
}
.pullquote p {
  font-size: 22px;
  font-style: italic;
  line-height: 1.4;
  color: var(--text-bright);
  margin: 0;
}

/* Centered with quotation mark */
.pullquote--centered {
  margin: 56px 0;
  padding: 32px 40px;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  text-align: center;
  position: relative;
}
.pullquote--centered::before {
  content: '"';
  position: absolute;
  top: -12px;
  left: 50%;
  transform: translateX(-50%);
  background: var(--bg);
  padding: 0 16px;
  font-size: 48px;
  color: var(--accent);
  line-height: 1;
}

Callout Boxes

.callout {
  padding: 16px 20px;
  border-radius: 8px;
  border-left: 4px solid var(--callout-border);
  background: var(--callout-bg);
  margin: 24px 0;
}

.callout--info {
  --callout-border: var(--accent);
  --callout-bg: color-mix(in srgb, var(--accent) 10%, transparent);
}

.callout--warning {
  --callout-border: var(--amber);
  --callout-bg: color-mix(in srgb, var(--amber) 10%, transparent);
}

.callout--success {
  --callout-border: var(--green);
  --callout-bg: color-mix(in srgb, var(--green) 10%, transparent);
}

.callout__title {
  font-weight: 600;
  margin-bottom: 8px;
  color: var(--callout-border);
}

/* Lists inside callouts need padding fix */
.callout ul, .callout ol {
  padding-left: 1.5em;
  margin: 8px 0 0 0;
}

Theme Toggle

:root, [data-theme="light"] {
  --bg: #fafaf9;
  --surface: #ffffff;
  --text: #1c1917;
  --text-dim: #78716c;
  --border: #e7e5e4;
  --accent: #0d9488;
}

[data-theme="dark"] {
  --bg: #0c0a09;
  --surface: #1c1917;
  --text: #fafaf9;
  --text-dim: #a8a29e;
  --border: #292524;
  --accent: #14b8a6;
}
// Random initial theme
const themes = ['light', 'dark'];
document.documentElement.setAttribute('data-theme', themes[Math.floor(Math.random() * 2)]);

// Toggle
function toggleTheme() {
  const current = document.documentElement.getAttribute('data-theme');
  document.documentElement.setAttribute('data-theme', current === 'light' ? 'dark' : 'light');
}

Prose Anti-Patterns

Avoid in reading-first content:

  • Body text smaller than 16px
  • Line-height below 1.5
  • Measure wider than 75ch
  • Pull quotes every other paragraph
  • Busy background patterns behind text

Generated Images

For AI-generated illustrations embedded as base64 data URIs. Use /ck:ai-multimodal skill for image generation if available.

Hero Banner

.hero-img-wrap {
  position: relative;
  border-radius: 12px;
  overflow: hidden;
  margin-bottom: 24px;
}

.hero-img-wrap img {
  width: 100%;
  height: 240px;
  object-fit: cover;
  display: block;
}

/* Gradient fade into page background */
.hero-img-wrap::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 50%;
  background: linear-gradient(to top, var(--bg), transparent);
  pointer-events: none;
}
<div class="hero-img-wrap">
  <img src="data:image/png;base64,..." alt="Descriptive alt text">
</div>

Inline Illustration

.illus {
  text-align: center;
  margin: 24px 0;
}

.illus img {
  max-width: 480px;
  width: 100%;
  border-radius: 10px;
  border: 1px solid var(--border);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}

.illus figcaption {
  font-family: var(--font-mono);
  font-size: 11px;
  color: var(--text-dim);
  margin-top: 8px;
}

Side Accent

.accent-img {
  float: right;
  max-width: 200px;
  margin: 0 0 16px 24px;
  border-radius: 10px;
  border: 1px solid var(--border);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

@media (max-width: 768px) {
  .accent-img {
    float: none;
    max-width: 100%;
    margin: 0 0 16px 0;
  }
}