$sloprun.dev

Gradient Studio

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

A CSS gradient is one long line of text, which is exactly why it is miserable to write by hand: you end up guessing at an angle, guessing where 52% falls, and reloading to find out. Here the guesses are on screen — drag the stops along the ramp, turn the dial, drag inside the panel itself to move a radial center — and the background line rewrites itself as you go, a color swatch printed beside every hex so the code reads like the picture.

Try the blending row first: flip a gradient from srgb to oklab and the muddy grey band in the middle disappears — the same colors, a different path between them. Then tick "repeat the ramp", pull the band down near 10%, and watch three stops turn into brushed metal.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/gradient-maker" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Gradient Studio — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/gradient-maker

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1351 lines 56.5 KB index.html
demos/gradient-maker/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gradient Studio — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
   Identity: instrument-panel. Machine facts in mono; human words in sans.
   Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }

/* ---------- local tokens ---------- */
:root { --field: #FBFBF9; --sunk: #F1F0EB; --knob: #1A1D21; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --field: #171A20; --sunk: #12141A; --knob: #E8E6E1; }
}
:root[data-theme="dark"] { --field: #171A20; --sunk: #12141A; --knob: #E8E6E1; }

*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; padding: 18px 16px 26px; line-height: 1.5; font-size: 15px; overflow-x: hidden; }
.wrap { max-width: 1180px; margin: 0 auto; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- header ---------- */
header { margin-bottom: 16px; }
.kicker {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 6px;
}
h1 { font-size: clamp(21px, 4.8vw, 28px); line-height: 1.18; margin: 0 0 6px; letter-spacing: -.015em; }
.lede { margin: 0; color: var(--muted); max-width: 68ch; font-size: clamp(13.5px, 3.4vw, 15px); }

/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: minmax(0,1fr); align-items: start; }
@media (min-width: 940px) { .grid { grid-template-columns: minmax(0,1.35fr) minmax(0,.85fr); } }
.col { display: grid; gap: 14px; align-content: start; min-width: 0; }
/* Single column: flatten the two columns and re-sequence so the stack reads
   see it → shape it → tune it → browse → take the code. */
@media (max-width: 939px) {
  .col { display: contents; }
  #cardPreview { order: 1 } #cardShape { order: 2 } #cardStops { order: 3 }
  #cardPresets { order: 4 } #cardCode  { order: 5 }
}

.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; min-width: 0; }
.card > h2 {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 10px; font-weight: 600;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.card > h2 .spacer { flex: 1 1 auto; }
.hint { font-size: 12.5px; color: var(--muted); margin: 9px 0 0; }
.tag {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  border: 1px solid var(--line); border-radius: 999px; padding: 1px 7px; letter-spacing: .02em;
  text-transform: none;
}

/* ---------- buttons ---------- */
.btn {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 12px; cursor: pointer; line-height: 1.2;
  transition: background .12s ease, border-color .12s ease, transform .08s ease, color .12s ease;
}
.btn:hover { border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn.tiny { font-size: 12px; padding: 5px 9px; font-family: var(--font-mono); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); font-weight: 600; }
.btn[disabled] { opacity: .45; cursor: not-allowed; }
.btn[disabled]:hover { border-color: var(--line); }
.btn.ok { background: var(--run-soft); border-color: var(--run); color: var(--run); font-weight: 600; }
@media (pointer: coarse) {
  .btn { padding: 10px 14px; }
  .btn.tiny { padding: 8px 11px; }
}
.row { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }

/* ---------- tabs ---------- */
.tabs { display: flex; gap: 4px; background: var(--sunk); border: 1px solid var(--line); border-radius: 9px; padding: 3px; }
.tab {
  flex: 1 1 0; font: inherit; font-size: 13px; font-family: var(--font-mono);
  background: transparent; color: var(--muted); border: 0; border-radius: 7px;
  padding: 8px 6px; cursor: pointer; letter-spacing: .02em;
  transition: background .12s ease, color .12s ease;
}
.tab:hover { color: var(--ink); }
.tab[aria-selected="true"] { background: var(--surface); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.08); }

/* ---------- checkerboard (alpha) ---------- */
.checkerbg {
  background-color: var(--surface);
  background-image:
    linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
    linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
  background-size: 16px 16px; background-position: 0 0, 8px 8px;
}
.checkersm { background-size: 8px 8px; background-position: 0 0, 4px 4px; }

/* ---------- preview ---------- */
.stage {
  position: relative; border: 1px solid var(--line); border-radius: 9px; overflow: hidden;
  height: clamp(190px, 38vh, 340px); touch-action: pan-y;
}
.stage.aim { cursor: crosshair; touch-action: none; }
.paint { position: absolute; inset: 0; }
.center-dot {
  position: absolute; width: 26px; height: 26px; margin: -13px 0 0 -13px; padding: 0;
  border-radius: 50%; border: 2px solid #fff; background: transparent; cursor: grab;
  box-shadow: 0 0 0 1.5px rgba(0,0,0,.55), inset 0 0 0 1.5px rgba(0,0,0,.55);
  touch-action: none;
}
.center-dot::after {
  content: ""; position: absolute; inset: 9px; border-radius: 50%;
  background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,.5);
}
.center-dot:active { cursor: grabbing; }
.stage-readout {
  position: absolute; left: 8px; bottom: 8px; z-index: 3;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .02em;
  padding: 3px 7px; border-radius: 6px; pointer-events: none;
  background: color-mix(in srgb, var(--surface) 86%, transparent);
  border: 1px solid var(--line); color: var(--ink);
}

/* ---------- stop track ---------- */
.track {
  position: relative; height: 42px; border: 1px solid var(--line); border-radius: 8px;
  overflow: visible; margin: 4px 0 22px; touch-action: none; cursor: copy;
}
.track.full { cursor: not-allowed; }
.ramp { position: absolute; inset: 0; border-radius: 7px; }
.handles { position: absolute; inset: 0; pointer-events: none; }
.stop {
  position: absolute; top: 50%; width: 22px; height: 30px; margin: -15px 0 0 -11px; padding: 2px;
  border-radius: 6px; border: 1.5px solid var(--ink); background: var(--surface);
  pointer-events: auto; cursor: grab; touch-action: none;
  box-shadow: 0 1px 3px rgba(0,0,0,.28);
}
.stop::after {
  content: ""; position: absolute; left: 50%; bottom: -7px; width: 2px; height: 6px;
  margin-left: -1px; background: var(--ink);
}
.stop:active { cursor: grabbing; }
.stop.sel { border-color: var(--accent); border-width: 2.5px; transform: scale(1.08); z-index: 4; }
.stop-sw { display: block; width: 100%; height: 100%; border-radius: 3px; overflow: hidden; }
.stop-sw i { display: block; width: 100%; height: 100%; }
.ticks {
  position: absolute; left: 0; right: 0; top: 100%; margin-top: 9px; height: 12px;
  font-family: var(--font-mono); font-size: 10px; color: var(--muted);
  pointer-events: none;
}
.ticks span { position: absolute; transform: translateX(-50%); white-space: nowrap; }
.ticks span:first-child { transform: none; }
.ticks span:last-child { transform: translateX(-100%); }

/* ---------- editor ---------- */
.editor { border-top: 1px dashed var(--line); margin-top: 14px; padding-top: 12px; }
.editor-head { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 10px; }
.big-sw {
  width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--line);
  flex: 0 0 auto; overflow: hidden;
}
.big-sw > i { display: block; width: 100%; height: 100%; }
.hexfield {
  font: inherit; font-family: var(--font-mono); font-size: 13px; width: 10ch;
  background: var(--field); color: var(--ink); border: 1px solid var(--line);
  border-radius: 7px; padding: 6px 8px; text-transform: lowercase;
}
.hexfield.bad { border-color: var(--danger); }
.numfield {
  font: inherit; font-family: var(--font-mono); font-size: 13px; width: 8ch;
  background: var(--field); color: var(--ink); border: 1px solid var(--line);
  border-radius: 7px; padding: 6px 8px;
}
.sliders { display: grid; gap: 7px; }
.sl { display: grid; grid-template-columns: 2.6ch 1fr 5ch; align-items: center; gap: 9px; }
.sl label { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.sl output { font-family: var(--font-mono); font-size: 11.5px; color: var(--ink); text-align: right; }

input[type=range] {
  -webkit-appearance: none; appearance: none; width: 100%; height: 20px;
  background: transparent; cursor: pointer; margin: 0;
}
input[type=range]::-webkit-slider-runnable-track {
  height: 12px; border-radius: 7px; border: 1px solid var(--line);
  background-color: var(--field); background-image: var(--track, none);
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 16px; height: 16px; margin-top: -3px;
  border-radius: 50%; background: var(--surface); border: 2px solid var(--knob);
  box-shadow: 0 1px 2px rgba(0,0,0,.3);
}
input[type=range]::-moz-range-track {
  height: 12px; border-radius: 7px; border: 1px solid var(--line);
  background-color: var(--field); background-image: var(--track, none);
}
input[type=range]::-moz-range-thumb {
  width: 14px; height: 14px; border-radius: 50%; background: var(--surface);
  border: 2px solid var(--knob); box-shadow: 0 1px 2px rgba(0,0,0,.3);
}
/* The alpha slider is the one track whose left end means "see-through", so it
   gets the checkerboard under the ramp instead of the flat field colour —
   otherwise 0% alpha just reads as "white" (light) or "near-black" (dark). */
#sA::-webkit-slider-runnable-track {
  background-color: var(--surface);
  background-image: var(--track, none),
    linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
    linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
  background-size: auto, 8px 8px, 8px 8px;
  background-position: 0 0, 0 0, 4px 4px;
}
#sA::-moz-range-track {
  background-color: var(--surface);
  background-image: var(--track, none),
    linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%),
    linear-gradient(45deg, var(--line) 25%, transparent 25%, transparent 75%, var(--line) 75%);
  background-size: auto, 8px 8px, 8px 8px;
  background-position: 0 0, 0 0, 4px 4px;
}

.chk { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; cursor: pointer; }
.chk input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }

/* ---------- dial ---------- */
.dialwrap { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.dial { width: 118px; height: 118px; flex: 0 0 auto; touch-action: none; cursor: grab; border-radius: 50%; }
.dial:active { cursor: grabbing; }
.dial .face { fill: var(--field); stroke: var(--line); stroke-width: 1.5; }
.dial .tick { stroke: var(--line); stroke-width: 2; stroke-linecap: round; }
.dial .tick.major { stroke: var(--muted); }
.dial .needle { stroke: var(--accent); stroke-width: 3; stroke-linecap: round; }
.dial .knob { fill: var(--accent); stroke: var(--surface); stroke-width: 2; }
.dial .hub { fill: var(--muted); }
.dial:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.dialside { display: grid; gap: 8px; min-width: 0; flex: 1 1 176px; align-content: center; }
#angleChips { display: grid; grid-template-columns: repeat(3, auto); justify-content: start; gap: 5px; }

/* ---------- geometry field rows ---------- */
.field { display: grid; gap: 6px; margin-top: 11px; }
.field > .lbl {
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .07em;
  text-transform: uppercase; color: var(--muted);
}
.seg { display: flex; flex-wrap: wrap; gap: 5px; }
.seg .btn { flex: 1 1 auto; }

/* ---------- presets ---------- */
.chips { display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; }
.chip {
  display: grid; gap: 7px; padding: 7px; text-align: left; cursor: pointer;
  background: var(--surface); border: 1px solid var(--line); border-radius: 9px;
  font: inherit; font-size: 12.5px; color: var(--ink);
  transition: border-color .12s ease, transform .08s ease;
}
.chip:hover { border-color: var(--accent); }
.chip:active { transform: translateY(1px); }
.chip .thumb {
  height: 34px; border-radius: 6px; border: 1px solid var(--line); overflow: hidden;
}
.chip .thumb i { display: block; width: 100%; height: 100%; }
.chip .nm { line-height: 1.25; }

/* ---------- code ---------- */
.codebox {
  background: var(--sunk); border: 1px solid var(--line); border-radius: 8px;
  padding: 11px 12px; overflow-x: auto;
}
pre.code {
  margin: 0; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.65;
  white-space: pre; color: var(--ink); tab-size: 2;
}
.t-prop { color: var(--muted); }
.t-fn { color: var(--accent); font-weight: 600; }
.t-num { color: var(--ink); }
.t-col { white-space: nowrap; }
.dot {
  display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 4px;
  border: 1px solid var(--line); vertical-align: baseline; position: relative; top: 1px;
}

/* ---------- footer ---------- */
footer {
  margin-top: 20px; padding-top: 12px; border-top: 1px solid var(--line);
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
  .btn:active, .chip:active { transform: none; }
  .stop.sel { transform: none; }
}
@media (max-width: 380px) {
  body { padding: 14px 11px 22px; }
  .card { padding: 12px; }
  .chips { grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <p class="kicker">css · gradients</p>
    <h1>Gradient Studio</h1>
    <p class="lede">Drag the color stops, spin the dial, pick a shape — the panel repaints as you go and the
      exact <span style="font-family:var(--font-mono)">background</span> line you would paste into a stylesheet
      is written out below it.</p>
  </header>

  <div class="grid">

    <!-- ============ LEFT ============ -->
    <div class="col">

      <section class="card" id="cardPreview">
        <h2>Preview <span class="spacer"></span><span class="tag" id="typeTag">linear</span></h2>
        <div class="stage checkerbg" id="stage">
          <div class="paint" id="paint"></div>
          <button class="center-dot" id="centerDot" type="button" hidden
                  aria-label="Gradient center position. Use the arrow keys to move it."></button>
          <span class="stage-readout" id="stageOut">135deg</span>
        </div>
        <p class="hint" id="stageHint">Turn the dial to change the direction.</p>
      </section>

      <section class="card" id="cardStops">
        <h2>Color stops <span class="tag" id="stopCount">3</span><span class="spacer"></span>
          <span class="tag" id="trackTag">click the bar to add</span></h2>

        <div class="track checkerbg" id="track">
          <div class="ramp" id="ramp"></div>
          <div class="handles" id="handles"></div>
          <div class="ticks" aria-hidden="true"><span>0%</span><span style="left:50%">50%</span><span style="left:100%">100%</span></div>
        </div>

        <div class="row">
          <button class="btn tiny" id="addStop">+ stop</button>
          <button class="btn tiny" id="delStop">remove</button>
          <button class="btn tiny" id="spread">even spacing</button>
          <button class="btn tiny" id="reverse">reverse</button>
          <button class="btn tiny" id="shuffle">surprise me</button>
        </div>

        <div class="editor">
          <div class="editor-head">
            <span class="big-sw checkerbg checkersm" aria-hidden="true"><i id="bigSw"></i></span>
            <input class="hexfield" id="hex" type="text" spellcheck="false" autocomplete="off"
                   inputmode="text" aria-label="Hex color of the selected stop">
            <label class="sr-only" for="posNum">Position of the selected stop, percent</label>
            <input class="numfield" id="posNum" type="number" min="0" max="100" step="0.5">
            <span class="tag">%</span>
            <span class="tag" id="stopLabel">stop 1 / 3</span>
          </div>
          <div class="sliders">
            <div class="sl"><label for="sH">H</label><input type="range" id="sH" min="0" max="360" step="1"><output id="oH">0</output></div>
            <div class="sl"><label for="sS">S</label><input type="range" id="sS" min="0" max="100" step="1"><output id="oS">0</output></div>
            <div class="sl"><label for="sL">L</label><input type="range" id="sL" min="0" max="100" step="1"><output id="oL">0</output></div>
            <div class="sl"><label for="sA">A</label><input type="range" id="sA" min="0" max="100" step="1"><output id="oA">100</output></div>
          </div>
        </div>
      </section>

      <section class="card" id="cardCode">
        <h2>Generated CSS <span class="spacer"></span>
          <button class="btn tiny" id="copy">copy</button></h2>
        <div class="codebox"><pre class="code" id="code"></pre></div>
        <p class="hint">Paste this straight onto any element. Nothing else is needed.</p>
      </section>

    </div>

    <!-- ============ RIGHT ============ -->
    <div class="col">

      <section class="card" id="cardShape">
        <h2>Shape</h2>
        <div class="tabs" role="tablist" aria-label="Gradient type">
          <button class="tab" role="tab" id="tab-linear" data-type="linear" aria-selected="true" aria-controls="geo">linear</button>
          <button class="tab" role="tab" id="tab-radial" data-type="radial" aria-selected="false" aria-controls="geo" tabindex="-1">radial</button>
          <button class="tab" role="tab" id="tab-conic"  data-type="conic"  aria-selected="false" aria-controls="geo" tabindex="-1">conic</button>
        </div>

        <div id="geo" role="tabpanel" aria-labelledby="tab-linear">

          <div class="field" id="angleField">
            <span class="lbl" id="angleLbl">Direction</span>
            <div class="dialwrap">
              <svg class="dial" id="dial" viewBox="0 0 100 100" tabindex="0" role="slider"
                   aria-labelledby="angleLbl" aria-valuemin="0" aria-valuemax="359" aria-valuenow="135">
                <circle class="face" cx="50" cy="50" r="40"></circle>
                <g class="tick major"><line x1="50" y1="12" x2="50" y2="19"></line><line x1="88" y1="50" x2="81" y2="50"></line><line x1="50" y1="88" x2="50" y2="81"></line><line x1="12" y1="50" x2="19" y2="50"></line></g>
                <g class="tick"><line x1="76.9" y1="23.1" x2="72.7" y2="27.3"></line><line x1="76.9" y1="76.9" x2="72.7" y2="72.7"></line><line x1="23.1" y1="76.9" x2="27.3" y2="72.7"></line><line x1="23.1" y1="23.1" x2="27.3" y2="27.3"></line></g>
                <line class="needle" id="needle" x1="50" y1="50" x2="50" y2="18"></line>
                <circle class="hub" cx="50" cy="50" r="3"></circle>
                <circle class="knob" id="knob" cx="50" cy="18" r="6.5"></circle>
              </svg>
              <div class="dialside">
                <div class="row">
                  <input class="numfield" id="angleNum" type="number" min="0" max="359" step="1" aria-label="Angle in degrees">
                  <span class="tag">deg</span>
                </div>
                <div class="row" id="angleChips"></div>
              </div>
            </div>
          </div>

          <div class="field" id="shapeField" hidden>
            <span class="lbl">Shape</span>
            <div class="seg" id="shapeSeg">
              <button class="btn tiny" data-shape="circle" aria-pressed="true">circle</button>
              <button class="btn tiny" data-shape="ellipse" aria-pressed="false">ellipse</button>
            </div>
          </div>

          <div class="field" id="sizeField" hidden>
            <span class="lbl">Reach</span>
            <div class="seg" id="sizeSeg">
              <button class="btn tiny" data-size="closest-side" aria-pressed="false">closest side</button>
              <button class="btn tiny" data-size="closest-corner" aria-pressed="false">closest corner</button>
              <button class="btn tiny" data-size="farthest-side" aria-pressed="false">farthest side</button>
              <button class="btn tiny" data-size="farthest-corner" aria-pressed="true">farthest corner</button>
            </div>
          </div>

          <div class="field" id="centerField" hidden>
            <span class="lbl">Center</span>
            <div class="row">
              <input class="numfield" id="cxNum" type="number" min="0" max="100" step="1" aria-label="Center x, percent">
              <span class="tag">x&nbsp;%</span>
              <input class="numfield" id="cyNum" type="number" min="0" max="100" step="1" aria-label="Center y, percent">
              <span class="tag">y&nbsp;%</span>
              <button class="btn tiny" id="recenter">recenter</button>
            </div>
          </div>

          <div class="field">
            <span class="lbl">Tiling</span>
            <div class="row">
              <label class="chk"><input type="checkbox" id="repeat"> repeat the ramp</label>
            </div>
            <div class="sl" id="bandRow" hidden>
              <label for="band" style="grid-column:1">band</label>
              <input type="range" id="band" min="4" max="100" step="1">
              <output id="oBand">25%</output>
            </div>
          </div>

          <div class="field" id="interpField" hidden>
            <span class="lbl">Blending</span>
            <div class="seg" id="interpSeg">
              <button class="btn tiny" data-interp="srgb" aria-pressed="true">srgb</button>
              <button class="btn tiny" data-interp="oklab" aria-pressed="false">oklab</button>
              <button class="btn tiny" data-interp="oklch" aria-pressed="false">oklch</button>
            </div>
            <p class="hint" id="interpHint">Plain sRGB blending — what a browser does by default.</p>
          </div>

        </div>
      </section>

      <section class="card" id="cardPresets">
        <h2>Presets</h2>
        <div class="chips" id="chips"></div>
      </section>

    </div>
  </div>

  <footer>
    <span>demo · sloprun.dev</span>
    <span id="engine">pure css output · no dependencies</span>
  </footer>
</div>

<p class="sr-only" id="live" role="status" aria-live="polite"></p>

<script>
(function () {
  "use strict";

  /* ---------- platform theme bridge ---------- */
  addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      document.documentElement.setAttribute("data-theme", d.theme);
    }
  });

  var $ = function (id) { return document.getElementById(id); };
  var clamp = function (v, a, b) { return v < a ? a : v > b ? b : v; };
  var live = $("live");
  var sayTimer = null;
  function say(msg) {
    clearTimeout(sayTimer);
    sayTimer = setTimeout(function () { live.textContent = msg; }, 60);
  }

  /* ---------- color math ---------- */
  function hslToRgb(h, s, l) {
    h = ((h % 360) + 360) % 360; s = clamp(s, 0, 100) / 100; l = clamp(l, 0, 100) / 100;
    var c = (1 - Math.abs(2 * l - 1)) * s;
    var x = c * (1 - Math.abs((h / 60) % 2 - 1));
    var m = l - c / 2, r = 0, g = 0, b = 0;
    if (h < 60) { r = c; g = x; }
    else if (h < 120) { r = x; g = c; }
    else if (h < 180) { g = c; b = x; }
    else if (h < 240) { g = x; b = c; }
    else if (h < 300) { r = x; b = c; }
    else { r = c; b = x; }
    return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
  }
  function rgbToHsl(r, g, b) {
    r /= 255; g /= 255; b /= 255;
    var mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
    var l = (mx + mn) / 2, h = 0, s = 0;
    if (d) {
      s = d / (1 - Math.abs(2 * l - 1));
      if (mx === r) h = 60 * (((g - b) / d) % 6);
      else if (mx === g) h = 60 * ((b - r) / d + 2);
      else h = 60 * ((r - g) / d + 4);
    }
    return [((h % 360) + 360) % 360, clamp(s * 100, 0, 100), l * 100];
  }
  function hx(n) { n = clamp(Math.round(n), 0, 255); return (n < 16 ? "0" : "") + n.toString(16); }
  function toHex(s) { var c = hslToRgb(s.h, s.s, s.l); return "#" + hx(c[0]) + hx(c[1]) + hx(c[2]); }
  function parseHex(str) {
    var m = String(str).trim().replace(/^#/, "");
    if (/^[0-9a-fA-F]{3}$/.test(m)) m = m[0] + m[0] + m[1] + m[1] + m[2] + m[2];
    if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
    return [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)];
  }
  function cssColor(s) {
    if (s.a >= 1) return toHex(s);
    var c = hslToRgb(s.h, s.s, s.l);
    return "rgb(" + c[0] + " " + c[1] + " " + c[2] + " / " + Math.round(s.a * 100) + "%)";
  }
  function fnum(n) { return String(Math.round(n * 10) / 10); }

  /* ---------- state ---------- */
  var MAX_STOPS = 12;
  var uid = 0;
  function mkStop(hexStr, pos, a) {
    var rgb = parseHex(hexStr) || [128, 128, 128];
    var h = rgbToHsl(rgb[0], rgb[1], rgb[2]);
    return { id: "s" + (++uid), h: h[0], s: h[1], l: h[2], a: a == null ? 1 : a, pos: pos };
  }
  var state = {
    type: "linear", angle: 135, from: 0,
    shape: "circle", size: "farthest-corner", cx: 50, cy: 50,
    repeat: false, band: 25, interp: "srgb",
    stops: [], sel: null
  };

  /* ---------- presets ---------- */
  var PRESETS = [
    { name: "Sunset Fade", type: "linear", angle: 160,
      stops: [["#ffb347", 0], ["#e0567a", 52], ["#4b2c8f", 100]] },
    { name: "Deep Water", type: "linear", angle: 200,
      stops: [["#023047", 0], ["#219ebc", 55], ["#8ecae6", 100]] },
    { name: "Peach Sorbet", type: "linear", angle: 135,
      stops: [["#ffe6c9", 0], ["#ffb199", 48], ["#ff6f91", 100]] },
    { name: "Morning Mist", type: "linear", angle: 180,
      stops: [["#f2f7f7", 0], ["#c9e4e7", 45], ["#8fb8c9", 100]] },
    { name: "Spotlight", type: "radial", shape: "circle", size: "closest-side", cx: 50, cy: 34,
      stops: [["#fff6de", 0], ["#f0a202", 42], ["#25303f", 100]] },
    { name: "Color Wheel", type: "conic", from: 0, cx: 50, cy: 50,
      stops: [["#ff4d4d", 0], ["#ffd93d", 17], ["#6bcB77", 33], ["#4d96ff", 50], ["#9b5de5", 67], ["#f15bb5", 83], ["#ff4d4d", 100]] },
    { name: "Brushed Steel", type: "linear", angle: 100, repeat: true, band: 9,
      stops: [["#d9dde2", 0], ["#f4f6f8", 40], ["#aeb6c0", 100]] },
    { name: "Glass Fade", type: "linear", angle: 180,
      stops: [["#ffffff", 0, 0.85], ["#ffffff", 100, 0]] }
  ];

  function applyPreset(p) {
    state.type = p.type;
    state.angle = p.angle != null ? p.angle : 135;
    state.from = p.from != null ? p.from : 0;
    state.shape = p.shape || "circle";
    state.size = p.size || "farthest-corner";
    state.cx = p.cx != null ? p.cx : 50;
    state.cy = p.cy != null ? p.cy : 50;
    state.repeat = !!p.repeat;
    state.band = p.band != null ? p.band : 25;
    state.interp = p.interp || "srgb";
    state.stops = p.stops.map(function (t) { return mkStop(t[0], t[1], t[2]); });
    state.sel = state.stops[0].id;
    syncControls();
    render();
  }

  /* ---------- gradient strings ---------- */
  function interpPart() { return state.interp === "srgb" ? "" : " in " + state.interp; }
  function headParts() {
    if (state.type === "linear") return fnum(state.angle) + "deg" + interpPart();
    if (state.type === "radial") {
      return state.shape + " " + state.size + " at " + fnum(state.cx) + "% " + fnum(state.cy) + "%" + interpPart();
    }
    return "from " + fnum(state.from) + "deg at " + fnum(state.cx) + "% " + fnum(state.cy) + "%" + interpPart();
  }
  function stopParts() {
    var scale = state.repeat ? state.band / 100 : 1;
    return state.stops.map(function (s) { return cssColor(s) + " " + fnum(s.pos * scale) + "%"; });
  }
  function fnName() { return (state.repeat ? "repeating-" : "") + state.type + "-gradient"; }
  function gradientCss() { return fnName() + "(" + [headParts()].concat(stopParts()).join(", ") + ")"; }
  function rampCss() {
    var list = state.stops.map(function (s) { return cssColor(s) + " " + fnum(s.pos) + "%"; });
    return "linear-gradient(90deg" + interpPart() + ", " + list.join(", ") + ")";
  }
  function codeText() {
    var parts = [headParts()].concat(stopParts());
    var one = "background: " + fnName() + "(" + parts.join(", ") + ");";
    if (one.length <= 64) return one;
    return "background: " + fnName() + "(\n  " + parts.join(",\n  ") + "\n);";
  }

  /* ---------- element refs ---------- */
  var stage = $("stage"), paint = $("paint"), centerDot = $("centerDot"), stageOut = $("stageOut");
  var track = $("track"), ramp = $("ramp"), handles = $("handles");
  var code = $("code"), copyBtn = $("copy");
  var hexIn = $("hex"), posNum = $("posNum"), bigSw = $("bigSw"), stopLabel = $("stopLabel"), stopCount = $("stopCount");
  var sH = $("sH"), sS = $("sS"), sL = $("sL"), sA = $("sA");
  var oH = $("oH"), oS = $("oS"), oL = $("oL"), oA = $("oA");
  var dial = $("dial"), needle = $("needle"), knob = $("knob"), angleNum = $("angleNum");
  var lastCode = "";

  /* ---------- selection helpers ---------- */
  function selIndex() {
    for (var i = 0; i < state.stops.length; i++) if (state.stops[i].id === state.sel) return i;
    return 0;
  }
  function selStop() { return state.stops[selIndex()]; }
  function sortStops() {
    state.stops.sort(function (a, b) { return a.pos - b.pos; });
  }

  /* ---------- stop handles ---------- */
  var handleEls = {};
  var dragId = null;

  function posFromX(clientX) {
    var r = ramp.getBoundingClientRect();
    if (!r.width) return 0;
    return clamp((clientX - r.left) / r.width * 100, 0, 100);
  }

  function setPos(id, pos, snap) {
    var st = null;
    for (var i = 0; i < state.stops.length; i++) if (state.stops[i].id === id) st = state.stops[i];
    if (!st) return;
    if (snap) pos = Math.round(pos / 5) * 5;
    st.pos = clamp(Math.round(pos * 10) / 10, 0, 100);
    sortStops();
    render();
  }

  function bindHandle(el, id) {
    el.addEventListener("pointerdown", function (e) {
      e.preventDefault(); e.stopPropagation();
      state.sel = id; dragId = id;
      try { el.setPointerCapture(e.pointerId); } catch (_) {}
      el.focus();
      render();
    });
    el.addEventListener("pointermove", function (e) {
      if (dragId !== id) return;
      setPos(id, posFromX(e.clientX), e.shiftKey);
    });
    function end(e) {
      if (dragId !== id) return;
      dragId = null;
      try { el.releasePointerCapture(e.pointerId); } catch (_) {}
    }
    el.addEventListener("pointerup", end);
    el.addEventListener("pointercancel", end);
    el.addEventListener("keydown", function (e) {
      var st = null, i;
      for (i = 0; i < state.stops.length; i++) if (state.stops[i].id === id) st = state.stops[i];
      if (!st) return;
      var step = e.shiftKey ? 10 : 1, k = e.key, done = true;
      if (k === "ArrowLeft" || k === "ArrowDown") setPos(id, st.pos - step, false);
      else if (k === "ArrowRight" || k === "ArrowUp") setPos(id, st.pos + step, false);
      else if (k === "Home") setPos(id, 0, false);
      else if (k === "End") setPos(id, 100, false);
      else if (k === "Delete" || k === "Backspace") { e.preventDefault(); removeStop(id); return; }
      else done = false;
      if (done) { e.preventDefault(); state.sel = id; render(); }
    });
    el.addEventListener("focus", function () {
      if (state.sel !== id) { state.sel = id; render(); }
    });
  }

  function renderHandles() {
    var seen = {};
    state.stops.forEach(function (s, i) {
      seen[s.id] = 1;
      var el = handleEls[s.id];
      if (!el) {
        el = document.createElement("button");
        el.className = "stop"; el.type = "button";
        el.setAttribute("role", "slider");
        el.setAttribute("aria-valuemin", "0");
        el.setAttribute("aria-valuemax", "100");
        var sw = document.createElement("span");
        sw.className = "stop-sw checkerbg checkersm";
        sw.appendChild(document.createElement("i"));
        el.appendChild(sw);
        handles.appendChild(el);
        handleEls[s.id] = el;
        bindHandle(el, s.id);
      }
      el.style.left = s.pos + "%";
      el.firstChild.firstChild.style.background = cssColor(s);
      el.setAttribute("aria-valuenow", String(Math.round(s.pos)));
      el.setAttribute("aria-valuetext", Math.round(s.pos) + " percent, " + toHex(s));
      el.setAttribute("aria-label", "Color stop " + (i + 1) + " of " + state.stops.length);
      el.classList.toggle("sel", s.id === state.sel);
    });
    Object.keys(handleEls).forEach(function (k) {
      if (!seen[k]) {
        var el = handleEls[k];
        if (el.parentNode) el.parentNode.removeChild(el);
        delete handleEls[k];
      }
    });
  }

  function colorAt(pos) {
    var st = state.stops;
    if (!st.length) return { h: 0, s: 0, l: 50, a: 1 };
    if (pos <= st[0].pos) return { h: st[0].h, s: st[0].s, l: st[0].l, a: st[0].a };
    var lastS = st[st.length - 1];
    if (pos >= lastS.pos) return { h: lastS.h, s: lastS.s, l: lastS.l, a: lastS.a };
    for (var i = 0; i < st.length - 1; i++) {
      var a = st[i], b = st[i + 1];
      if (pos >= a.pos && pos <= b.pos) {
        var span = b.pos - a.pos;
        var t = span ? (pos - a.pos) / span : 0;
        var ca = hslToRgb(a.h, a.s, a.l), cb = hslToRgb(b.h, b.s, b.l);
        var mix = rgbToHsl(
          ca[0] + (cb[0] - ca[0]) * t,
          ca[1] + (cb[1] - ca[1]) * t,
          ca[2] + (cb[2] - ca[2]) * t
        );
        return { h: mix[0], s: mix[1], l: mix[2], a: a.a + (b.a - a.a) * t };
      }
    }
    return { h: st[0].h, s: st[0].s, l: st[0].l, a: st[0].a };
  }

  function addStopAt(pos) {
    if (state.stops.length >= MAX_STOPS) {
      say("That is the limit — " + MAX_STOPS + " stops. Remove one to add another.");
      return null;
    }
    pos = clamp(Math.round(pos * 10) / 10, 0, 100);
    var c = colorAt(pos);
    var st = { id: "s" + (++uid), h: c.h, s: c.s, l: c.l, a: c.a, pos: pos };
    state.stops.push(st);
    sortStops();
    state.sel = st.id;
    render();
    say("Stop added at " + Math.round(pos) + " percent. " + state.stops.length + " stops.");
    return st;
  }

  function removeStop(id) {
    if (state.stops.length <= 2) { say("Two stops is the minimum."); return; }
    var i = -1, j;
    for (j = 0; j < state.stops.length; j++) if (state.stops[j].id === id) i = j;
    if (i < 0) return;
    state.stops.splice(i, 1);
    state.sel = state.stops[Math.min(i, state.stops.length - 1)].id;
    render();
    var el = handleEls[state.sel];
    if (el) el.focus();
    say("Stop removed. " + state.stops.length + " stops left.");
  }

  track.addEventListener("pointerdown", function (e) {
    if (e.target.closest && e.target.closest(".stop")) return;
    e.preventDefault();
    var st = addStopAt(posFromX(e.clientX));
    if (!st) return;
    var el = handleEls[st.id];
    if (el) {
      el.focus();
      dragId = st.id;
      try { el.setPointerCapture(e.pointerId); } catch (_) {}
    }
  });

  /* ---------- editor ---------- */
  function setTrack(el, img) { el.style.setProperty("--track", img); }

  function renderEditor() {
    var s = selStop();
    if (!s) return;
    var hexStr = toHex(s);
    bigSw.style.background = cssColor(s);
    stopLabel.textContent = "stop " + (selIndex() + 1) + " / " + state.stops.length;
    stopCount.textContent = String(state.stops.length);
    if (document.activeElement !== hexIn) {
      hexIn.value = hexStr;
      hexIn.classList.remove("bad");
      hexIn.removeAttribute("aria-invalid");
    }
    if (document.activeElement !== posNum) posNum.value = String(Math.round(s.pos * 10) / 10);
    if (document.activeElement !== sH) sH.value = String(Math.round(s.h));
    if (document.activeElement !== sS) sS.value = String(Math.round(s.s));
    if (document.activeElement !== sL) sL.value = String(Math.round(s.l));
    if (document.activeElement !== sA) sA.value = String(Math.round(s.a * 100));
    oH.textContent = Math.round(s.h) + "°";
    oS.textContent = Math.round(s.s) + "%";
    oL.textContent = Math.round(s.l) + "%";
    oA.textContent = Math.round(s.a * 100) + "%";
    setTrack(sH, "linear-gradient(90deg,#ff0000,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)");
    setTrack(sS, "linear-gradient(90deg," + toHex({ h: s.h, s: 0, l: s.l }) + "," + toHex({ h: s.h, s: 100, l: s.l }) + ")");
    setTrack(sL, "linear-gradient(90deg,#000000," + toHex({ h: s.h, s: s.s, l: 50 }) + ",#ffffff)");
    setTrack(sA, "linear-gradient(90deg,rgb(255 255 255 / 0)," + toHex(s) + ")");
    var full = state.stops.length >= MAX_STOPS;
    $("delStop").disabled = state.stops.length <= 2;
    $("addStop").disabled = full;
    track.classList.toggle("full", full);
    $("trackTag").textContent = full ? "limit " + MAX_STOPS + " stops" : "click the bar to add";
  }

  [["sH", "h", 1], ["sS", "s", 1], ["sL", "l", 1], ["sA", "a", 0.01]].forEach(function (t) {
    $(t[0]).addEventListener("input", function () {
      var s = selStop(); if (!s) return;
      s[t[1]] = parseFloat(this.value) * t[2];
      render();
    });
  });

  hexIn.addEventListener("input", function () {
    var rgb = parseHex(this.value);
    if (!rgb) { this.classList.add("bad"); this.setAttribute("aria-invalid", "true"); return; }
    this.classList.remove("bad");
    this.removeAttribute("aria-invalid");
    var s = selStop(); if (!s) return;
    var h = rgbToHsl(rgb[0], rgb[1], rgb[2]);
    s.h = h[0]; s.s = h[1]; s.l = h[2];
    render();
  });
  hexIn.addEventListener("blur", function () {
    this.classList.remove("bad");
    this.removeAttribute("aria-invalid");
    renderEditor();
  });

  posNum.addEventListener("input", function () {
    var v = parseFloat(this.value);
    if (isNaN(v)) return;
    var s = selStop(); if (!s) return;
    s.pos = clamp(v, 0, 100);
    sortStops();
    render();
  });

  /* Number fields are only written back while they are NOT focused, so a field
     left holding an out-of-range or empty string (typed "500", cleared it) has
     to be re-synced from state the moment focus leaves it. */
  ["posNum", "angleNum", "cxNum", "cyNum"].forEach(function (id) {
    $(id).addEventListener("blur", function () { render(); });
  });

  $("addStop").addEventListener("click", function () {
    var gap = 0, at = 50, i;
    for (i = 0; i < state.stops.length - 1; i++) {
      var d = state.stops[i + 1].pos - state.stops[i].pos;
      if (d > gap) { gap = d; at = state.stops[i].pos + d / 2; }
    }
    var st = addStopAt(at);
    if (!st) return;
    var el = handleEls[st.id];
    if (el) el.focus();
  });
  $("delStop").addEventListener("click", function () { removeStop(state.sel); });
  $("spread").addEventListener("click", function () {
    var n = state.stops.length;
    state.stops.forEach(function (s, i) { s.pos = n === 1 ? 50 : Math.round(i / (n - 1) * 1000) / 10; });
    render(); say("Stops spaced evenly.");
  });
  $("reverse").addEventListener("click", function () {
    var cols = state.stops.map(function (s) { return { h: s.h, s: s.s, l: s.l, a: s.a }; }).reverse();
    state.stops.forEach(function (s, i) { s.h = cols[i].h; s.s = cols[i].s; s.l = cols[i].l; s.a = cols[i].a; });
    render(); say("Colors reversed.");
  });

  /* ---------- surprise me ---------- */
  function rnd(a, b) { return a + Math.random() * (b - a); }
  $("shuffle").addEventListener("click", function () {
    var base = rnd(0, 360);
    var spreadH = [18, 40, 70, 150, 180][Math.floor(rnd(0, 5))];
    var dir = Math.random() < 0.5 ? -1 : 1;
    var n = Math.random() < 0.45 ? 2 : 3;
    var stops = [], i;
    for (i = 0; i < n; i++) {
      var t = n === 1 ? 0 : i / (n - 1);
      var s = {
        id: "s" + (++uid),
        h: (((base + dir * spreadH * t) % 360) + 360) % 360,
        s: rnd(52, 88),
        l: rnd(70, 78) - t * rnd(22, 44),
        a: 1,
        pos: Math.round(t * 100)
      };
      stops.push(s);
    }
    state.stops = stops;
    state.sel = stops[0].id;
    var roll = Math.random();
    state.type = roll < 0.68 ? "linear" : roll < 0.9 ? "radial" : "conic";
    state.angle = (Math.round(rnd(0, 24)) * 15) % 360;
    state.from = (Math.round(rnd(0, 24)) * 15) % 360;
    state.cx = Math.round(rnd(25, 75)); state.cy = Math.round(rnd(25, 75));
    state.repeat = false;
    syncControls();
    render();
    say("New gradient rolled: " + state.type + ", " + n + " stops.");
  });

  /* ---------- type tabs ---------- */
  var tabs = Array.prototype.slice.call(document.querySelectorAll(".tab"));
  function setType(t, focus) {
    state.type = t;
    syncControls();
    render();
    if (focus) {
      var el = document.getElementById("tab-" + t);
      if (el) el.focus();
    }
    say(t + " gradient");
  }
  tabs.forEach(function (tab, i) {
    tab.addEventListener("click", function () { setType(tab.dataset.type, false); });
    tab.addEventListener("keydown", function (e) {
      var k = e.key, j = -1;
      if (k === "ArrowRight") j = (i + 1) % tabs.length;
      else if (k === "ArrowLeft") j = (i - 1 + tabs.length) % tabs.length;
      else if (k === "Home") j = 0;
      else if (k === "End") j = tabs.length - 1;
      if (j >= 0) { e.preventDefault(); setType(tabs[j].dataset.type, true); }
    });
  });

  /* ---------- dial ---------- */
  function dialValue() { return state.type === "conic" ? state.from : state.angle; }
  function setDial(v, snap) {
    v = ((Math.round(v) % 360) + 360) % 360;
    if (snap) v = (Math.round(v / 15) * 15) % 360;
    if (state.type === "conic") state.from = v; else state.angle = v;
    render();
  }
  function dialFromPoint(cx0, cy0) {
    var r = dial.getBoundingClientRect();
    var dx = cx0 - (r.left + r.width / 2);
    var dy = cy0 - (r.top + r.height / 2);
    return (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
  }
  var dialDrag = false;
  dial.addEventListener("pointerdown", function (e) {
    e.preventDefault(); dialDrag = true;
    try { dial.setPointerCapture(e.pointerId); } catch (_) {}
    dial.focus();
    setDial(dialFromPoint(e.clientX, e.clientY), e.shiftKey);
  });
  dial.addEventListener("pointermove", function (e) {
    if (!dialDrag) return;
    setDial(dialFromPoint(e.clientX, e.clientY), e.shiftKey);
  });
  function dialEnd(e) {
    if (!dialDrag) return;
    dialDrag = false;
    try { dial.releasePointerCapture(e.pointerId); } catch (_) {}
  }
  dial.addEventListener("pointerup", dialEnd);
  dial.addEventListener("pointercancel", dialEnd);
  dial.addEventListener("keydown", function (e) {
    var step = e.shiftKey ? 15 : 1, k = e.key, hit = true;
    if (k === "ArrowLeft" || k === "ArrowDown") setDial(dialValue() - step, false);
    else if (k === "ArrowRight" || k === "ArrowUp") setDial(dialValue() + step, false);
    else if (k === "Home") setDial(0, false);
    else if (k === "PageUp") setDial(dialValue() + 45, false);
    else if (k === "PageDown") setDial(dialValue() - 45, false);
    else hit = false;
    if (hit) e.preventDefault();
  });
  angleNum.addEventListener("input", function () {
    var v = parseFloat(this.value);
    if (isNaN(v)) return;
    if (state.type === "conic") state.from = ((Math.round(v) % 360) + 360) % 360;
    else state.angle = ((Math.round(v) % 360) + 360) % 360;
    render();
  });
  [0, 45, 90, 135, 180, 270].forEach(function (a) {
    var b = document.createElement("button");
    b.className = "btn tiny"; b.type = "button"; b.textContent = a + "°";
    b.addEventListener("click", function () { setDial(a, false); });
    $("angleChips").appendChild(b);
  });

  function renderDial() {
    var v = dialValue();
    var rad = v * Math.PI / 180;
    var kx = 50 + 32 * Math.sin(rad), ky = 50 - 32 * Math.cos(rad);
    needle.setAttribute("x2", kx.toFixed(2));
    needle.setAttribute("y2", ky.toFixed(2));
    knob.setAttribute("cx", kx.toFixed(2));
    knob.setAttribute("cy", ky.toFixed(2));
    dial.setAttribute("aria-valuenow", String(Math.round(v)));
    dial.setAttribute("aria-valuetext", Math.round(v) + " degrees");
    if (document.activeElement !== angleNum) angleNum.value = String(Math.round(v));
  }

  /* ---------- center pad on the preview ---------- */
  function setCenterFromPoint(clientX, clientY) {
    var r = stage.getBoundingClientRect();
    if (!r.width || !r.height) return;
    state.cx = clamp(Math.round((clientX - r.left) / r.width * 100), 0, 100);
    state.cy = clamp(Math.round((clientY - r.top) / r.height * 100), 0, 100);
    render();
  }
  var centerDrag = false;
  function startCenter(e) {
    if (state.type === "linear") return;
    e.preventDefault();
    centerDrag = true;
    try { stage.setPointerCapture(e.pointerId); } catch (_) {}
    setCenterFromPoint(e.clientX, e.clientY);
  }
  stage.addEventListener("pointerdown", startCenter);
  stage.addEventListener("pointermove", function (e) {
    if (!centerDrag) return;
    setCenterFromPoint(e.clientX, e.clientY);
  });
  function centerEnd(e) {
    if (!centerDrag) return;
    centerDrag = false;
    try { stage.releasePointerCapture(e.pointerId); } catch (_) {}
    say("Center at " + state.cx + ", " + state.cy + " percent.");
  }
  stage.addEventListener("pointerup", centerEnd);
  stage.addEventListener("pointercancel", centerEnd);
  centerDot.addEventListener("keydown", function (e) {
    var step = e.shiftKey ? 10 : 1, k = e.key, hit = true;
    if (k === "ArrowLeft") state.cx = clamp(state.cx - step, 0, 100);
    else if (k === "ArrowRight") state.cx = clamp(state.cx + step, 0, 100);
    else if (k === "ArrowUp") state.cy = clamp(state.cy - step, 0, 100);
    else if (k === "ArrowDown") state.cy = clamp(state.cy + step, 0, 100);
    else if (k === "Home") { state.cx = 50; state.cy = 50; }
    else hit = false;
    if (hit) { e.preventDefault(); render(); }
  });
  $("cxNum").addEventListener("input", function () {
    var v = parseFloat(this.value); if (isNaN(v)) return;
    state.cx = clamp(Math.round(v), 0, 100); render();
  });
  $("cyNum").addEventListener("input", function () {
    var v = parseFloat(this.value); if (isNaN(v)) return;
    state.cy = clamp(Math.round(v), 0, 100); render();
  });
  $("recenter").addEventListener("click", function () {
    state.cx = 50; state.cy = 50; render(); say("Center reset to the middle.");
  });

  /* ---------- shape / size / blending segments ---------- */
  function bindSeg(wrapId, key, attr, after) {
    var wrap = $(wrapId);
    wrap.addEventListener("click", function (e) {
      var b = e.target.closest("button");
      if (!b || !b.dataset[attr]) return;
      state[key] = b.dataset[attr];
      syncControls();
      render();
      if (after) after();
    });
  }
  bindSeg("shapeSeg", "shape", "shape");
  bindSeg("sizeSeg", "size", "size");
  var INTERP_HINT = {
    srgb: "Plain sRGB blending — what a browser does by default.",
    oklab: "Perceptual blending: no muddy grey band in the middle.",
    oklch: "Perceptual, and it travels around the hue wheel."
  };
  bindSeg("interpSeg", "interp", "interp", function () {
    say("Blending set to " + state.interp);
  });

  $("repeat").addEventListener("change", function () {
    state.repeat = this.checked;
    syncControls();
    render();
    say(state.repeat ? "Ramp repeats every " + state.band + " percent." : "Repeat off.");
  });
  $("band").addEventListener("input", function () {
    state.band = clamp(parseInt(this.value, 10) || 25, 4, 100);
    render();
  });

  /* ---------- copy ---------- */
  var copyTimer = null;
  function copyResult(good) {
    clearTimeout(copyTimer);
    copyBtn.classList.toggle("ok", good);
    copyBtn.textContent = good ? "copied ✓" : "select + copy";
    say(good ? "CSS copied to the clipboard." : "Clipboard is blocked here — the code is selected, press control or command C.");
    copyTimer = setTimeout(function () {
      copyBtn.classList.remove("ok");
      copyBtn.textContent = "copy";
    }, 1800);
  }
  function selectCode() {
    try {
      var sel = window.getSelection();
      var range = document.createRange();
      range.selectNodeContents(code);
      sel.removeAllRanges();
      sel.addRange(range);
    } catch (_) {}
  }
  function fallbackCopy() {
    var good = false;
    try {
      var ta = document.createElement("textarea");
      ta.value = lastCode;
      ta.setAttribute("readonly", "");
      ta.style.position = "fixed";
      ta.style.top = "-1000px";
      ta.style.opacity = "0";
      document.body.appendChild(ta);
      ta.select();
      good = document.execCommand("copy");
      document.body.removeChild(ta);
    } catch (_) { good = false; }
    if (!good) selectCode();
    copyResult(!!good);
  }
  copyBtn.addEventListener("click", function () {
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(lastCode).then(function () { copyResult(true); }, fallbackCopy);
        return;
      }
    } catch (_) {}
    fallbackCopy();
  });

  /* ---------- code block ---------- */
  var TOK = /(#[0-9a-fA-F]{6}\b|rgb\([^)]*\)|-?\d+(?:\.\d+)?(?:deg|%)|\b(?:repeating-)?(?:linear|radial|conic)-gradient\b|\bbackground\b)/g;
  function renderCode() {
    var txt = codeText();
    lastCode = txt;
    while (code.firstChild) code.removeChild(code.firstChild);
    var i = 0, m;
    TOK.lastIndex = 0;
    while ((m = TOK.exec(txt)) !== null) {
      if (m.index > i) code.appendChild(document.createTextNode(txt.slice(i, m.index)));
      var t = m[0], sp = document.createElement("span");
      if (t.charAt(0) === "#" || t.indexOf("rgb(") === 0) {
        sp.className = "t-col";
        var dot = document.createElement("i");
        dot.className = "dot";
        dot.style.background = t;
        sp.appendChild(dot);
        sp.appendChild(document.createTextNode(t));
      } else if (/gradient$/.test(t)) { sp.className = "t-fn"; sp.textContent = t; }
      else if (t === "background") { sp.className = "t-prop"; sp.textContent = t; }
      else { sp.className = "t-num"; sp.textContent = t; }
      code.appendChild(sp);
      i = m.index + t.length;
    }
    if (i < txt.length) code.appendChild(document.createTextNode(txt.slice(i)));
  }

  /* ---------- control visibility ---------- */
  function syncControls() {
    tabs.forEach(function (t) {
      var on = t.dataset.type === state.type;
      t.setAttribute("aria-selected", on ? "true" : "false");
      t.tabIndex = on ? 0 : -1;
    });
    $("geo").setAttribute("aria-labelledby", "tab-" + state.type);

    var isLinear = state.type === "linear";
    var isRadial = state.type === "radial";
    var isConic = state.type === "conic";

    $("angleField").hidden = isRadial;
    $("angleLbl").textContent = isConic ? "Start angle" : "Direction";
    $("shapeField").hidden = !isRadial;
    $("sizeField").hidden = !isRadial;
    $("centerField").hidden = isLinear;
    $("bandRow").hidden = !state.repeat;
    $("repeat").checked = state.repeat;
    $("band").value = String(state.band);
    $("oBand").textContent = state.band + "%";

    Array.prototype.forEach.call($("shapeSeg").children, function (b) {
      b.setAttribute("aria-pressed", b.dataset.shape === state.shape ? "true" : "false");
    });
    Array.prototype.forEach.call($("sizeSeg").children, function (b) {
      b.setAttribute("aria-pressed", b.dataset.size === state.size ? "true" : "false");
    });
    Array.prototype.forEach.call($("interpSeg").children, function (b) {
      b.setAttribute("aria-pressed", b.dataset.interp === state.interp ? "true" : "false");
    });
    $("interpHint").textContent = INTERP_HINT[state.interp] || INTERP_HINT.srgb;

    centerDot.hidden = isLinear;
    stage.classList.toggle("aim", !isLinear);
    centerDot.tabIndex = isLinear ? -1 : 0;

    $("stageHint").textContent = isLinear
      ? "Turn the dial to change the direction. Click the bar below to drop in another color stop."
      : "Drag anywhere on the panel to move the center, or focus the ring and use the arrow keys.";

    /* If the control that had keyboard focus just got hidden (e.g. the dial when
       switching to radial), park focus on the active tab instead of dropping it
       on <body>. */
    var act = document.activeElement;
    if (act && act.closest && act.closest("#geo") && act.closest("[hidden]")) {
      var t = document.getElementById("tab-" + state.type);
      if (t) t.focus();
    }
  }

  /* ---------- master render ---------- */
  function render() {
    var g = gradientCss();
    paint.style.backgroundImage = g;
    ramp.style.backgroundImage = rampCss();
    renderHandles();
    renderEditor();
    renderDial();
    renderCode();
    centerDot.style.left = state.cx + "%";
    centerDot.style.top = state.cy + "%";
    if (document.activeElement !== $("cxNum")) $("cxNum").value = String(state.cx);
    if (document.activeElement !== $("cyNum")) $("cyNum").value = String(state.cy);
    $("typeTag").textContent = (state.repeat ? "repeating " : "") + state.type;
    stageOut.textContent = state.type === "linear"
      ? Math.round(state.angle) + "deg"
      : state.type === "conic"
        ? "from " + Math.round(state.from) + "deg · " + state.cx + "% " + state.cy + "%"
        : state.shape + " · " + state.cx + "% " + state.cy + "%";
    $("oBand").textContent = state.band + "%";
  }

  /* ---------- presets ui ----------
     The thumbnail paints the preset's REAL gradient function, not a linear
     stand-in, so a radial or conic chip previews what clicking it will do. */
  function presetCss(p) {
    var num = function (v, d) { return v != null ? v : d; };
    var head;
    if (p.type === "linear") head = num(p.angle, 135) + "deg";
    else if (p.type === "radial") {
      head = (p.shape || "circle") + " " + (p.size || "farthest-corner") +
             " at " + num(p.cx, 50) + "% " + num(p.cy, 50) + "%";
    } else {
      head = "from " + num(p.from, 0) + "deg at " + num(p.cx, 50) + "% " + num(p.cy, 50) + "%";
    }
    var sc = p.repeat ? num(p.band, 25) / 100 : 1;
    var list = p.stops.map(function (t) {
      var rgb = parseHex(t[0]) || [0, 0, 0];
      var a = t[2] == null ? 1 : t[2];
      var col = a >= 1 ? t[0] : "rgb(" + rgb[0] + " " + rgb[1] + " " + rgb[2] + " / " + Math.round(a * 100) + "%)";
      return col + " " + fnum(t[1] * sc) + "%";
    });
    return (p.repeat ? "repeating-" : "") + p.type + "-gradient(" +
           [head].concat(list).join(", ") + ")";
  }

  PRESETS.forEach(function (p) {
    var b = document.createElement("button");
    b.className = "chip"; b.type = "button";
    var th = document.createElement("span");
    th.className = "thumb checkerbg checkersm";
    var thi = document.createElement("i");
    thi.style.backgroundImage = presetCss(p);
    th.appendChild(thi);
    var nm = document.createElement("span");
    nm.className = "nm";
    nm.textContent = p.name;
    b.appendChild(th); b.appendChild(nm);
    b.addEventListener("click", function () { applyPreset(p); say(p.name + " loaded."); });
    $("chips").appendChild(b);
  });

  /* ---------- blending support probe ----------
     Only offer the blending row if every shape we can generate accepts the
     `in <space>` keyword, so no combination of controls can produce a line the
     browser silently drops. */
  function okGrad(s) {
    return !!(window.CSS && CSS.supports && CSS.supports("background-image", s));
  }
  var canInterp =
    okGrad("linear-gradient(90deg in oklab, red, blue)") &&
    okGrad("radial-gradient(circle farthest-corner at 50% 50% in oklab, red, blue)") &&
    okGrad("conic-gradient(from 0deg at 50% 50% in oklab, red, blue)") &&
    okGrad("repeating-linear-gradient(90deg in oklch, red, blue 25%)");
  if (canInterp) $("interpField").hidden = false;

  /* ---------- boot ---------- */
  applyPreset(PRESETS[0]);
  window.addEventListener("resize", function () { /* percentages handle it; keep readouts fresh */ renderHandles(); });
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong