$sloprun.dev

Shadow Workshop

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

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

▶ 4 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 single box-shadow always looks like a smudge, because real shadows aren't one blur — they're a tight dark one hugging the edge plus a wide faint one spilling far out. This is a bench for stacking up to six of them, with a card beside it showing the same total darkness collapsed into one flat layer so you can see exactly what the stacking buys you. Start on the elevation chips, walk 1 to 5, and notice the stack goes softer and further rather than darker; then drag the light around the scene and watch the wide layers travel further than the tight ones, the way they do in a room. When you like it, the CSS at the bottom is paste-ready.

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/shadow-designer" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Shadow Workshop — 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/shadow-designer

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1022 lines 48.8 KB index.html
demos/shadow-designer/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<title>Shadow Workshop — sloprun.dev</title>
<link rel="icon" href="data:,">
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
: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); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (alpha values; tokens themselves stay opaque hex) */
/* color-scheme must track the SAME conditions as the tokens, otherwise native
   widgets (checkboxes, focus rings, scrollbars) keep rendering in the OS scheme
   while the platform has forced the other one via data-theme. */
:root { color-scheme: light; --chrome: 0 1px 2px rgba(26,29,33,.05); --stage-bg: #F0EEE9; --swatch-card: #fff;
  /* the preset chips keep their OWN lit plate in both themes and at every scene
     lightness — they are controls, not part of the scene, so they must stay
     readable even when the visitor drags the background down to near black. */
  --chip-plate: #EDEBE6; --chip-box: #FFFFFF; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { color-scheme: dark; --chrome: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { color-scheme: dark; --chrome: 0 1px 2px rgba(0,0,0,.35); }
:root[data-theme="light"] { color-scheme: light; }

* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap { max-width: 1160px; margin: 0 auto; padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px; }

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 72ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede code { font-family: var(--font-mono); font-size: 13px; color: var(--ink); }

.stage { display: grid; grid-template-columns: minmax(0,1fr) 336px; gap: 16px; align-items: start; }
.left, .panel { display: flex; flex-direction: column; gap: 13px; min-width: 0; }

.card { background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--chrome); min-width: 0; }
.card h2 { margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
.card h2 .n { color: var(--accent); }

/* ── the scene ───────────────────────────────────────────────────────────── */
.scenecard { padding: 0; overflow: hidden; }
.scene {
  --stage-ink: rgba(0,0,0,.62); --stage-scrim: rgba(255,255,255,.6); --stage-edge: rgba(0,0,0,.10);
  --card-bg: #fff; --card-ink: #1A1D21; --card-line: #E4E2DD;
  position: relative; overflow: hidden; width: 100%;
  height: clamp(272px, 41vw, 340px);
  background: var(--stage-bg);
  touch-action: none; cursor: crosshair; user-select: none; -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
}
.cards { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  gap: clamp(16px, 5vw, 46px); padding: 16px; flex-wrap: wrap; }
.specimen { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 9px; }
.specimen.off { display: none; }
.pcard { width: clamp(126px, 20vw, 178px); height: clamp(88px, 14vw, 116px);
  border-radius: 12px; background: var(--card-bg); padding: 13px 14px;
  display: flex; flex-direction: column; gap: 8px; justify-content: center;
  transition: box-shadow .18s ease, background-color .18s ease; }
.scene.dragging .pcard { transition: none; }
.ptop { display: flex; align-items: center; gap: 7px; }
.pdot { width: 13px; height: 13px; border-radius: 4px; background: var(--card-ink); opacity: .82; flex: none; }
.pttl { height: 7px; width: 56%; border-radius: 3px; background: var(--card-ink); opacity: .74; }
.pln { display: block; height: 5px; border-radius: 3px; background: var(--card-line); }
.pln.w2 { width: 84%; } .pln.w3 { width: 58%; }
.pbtn { display: block; height: 15px; width: 46px; border-radius: 7px; background: var(--card-ink);
  opacity: .88; margin-top: 2px; }
.plabel { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .06em;
  color: var(--stage-ink); text-align: center; white-space: nowrap; }

.light { position: absolute; width: 22px; height: 22px; margin: -11px 0 0 -11px;
  border-radius: 50%; border: 2px solid var(--stage-ink); background: var(--stage-scrim);
  display: flex; align-items: center; justify-content: center; cursor: grab; z-index: 4;
  transition: left .18s ease, top .18s ease; }
.scene.dragging .light { transition: none; cursor: grabbing; }
.light::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--stage-ink); }
.light:focus { outline: none; }
.light:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.hud { position: absolute; left: 10px; top: 10px; display: flex; flex-wrap: wrap; gap: 4px 6px;
  pointer-events: none; z-index: 3; max-width: calc(100% - 20px); }
.hud span { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em;
  color: var(--stage-ink); background: var(--stage-scrim); border: 1px solid var(--stage-edge);
  border-radius: 999px; padding: 3px 8px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.hint { position: absolute; right: 10px; bottom: 10px; z-index: 3; pointer-events: none;
  font-family: var(--font-mono); font-size: 10.5px; color: var(--stage-ink);
  background: var(--stage-scrim); border: 1px solid var(--stage-edge);
  border-radius: 999px; padding: 4px 9px; transition: opacity .45s ease; max-width: calc(100% - 20px); }
.hint.gone { opacity: 0; }

.scenefoot { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center;
  padding: 9px 12px; border-top: 1px solid var(--line); }

/* ── layer list ──────────────────────────────────────────────────────────── */
.layers { display: flex; flex-direction: column; gap: 6px; }
.lrow { display: flex; align-items: stretch; gap: 5px; }
.lpick { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 9px;
  text-align: left; padding: 5px 7px; }
.lpick[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.lswatch { flex: none; width: 52px; height: 34px; border-radius: 6px; background: var(--stage-bg);
  display: flex; align-items: center; justify-content: center; overflow: hidden;
  border: 1px solid var(--line); }
.lswatch i { display: block; width: 22px; height: 14px; border-radius: 3px; background: var(--swatch-card); }
.lmeta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.lspec { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .01em; color: var(--ink);
  font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lname { font-family: var(--font-mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase;
  color: var(--muted); }
.lrow.muted .lspec, .lrow.muted .lname { opacity: .45; text-decoration: line-through; }
.lrow.muted .lswatch { opacity: .4; }
.icb { flex: none; width: 30px; padding: 0; display: flex; align-items: center; justify-content: center;
  font-family: var(--font-mono); font-size: 12px; }
.icb[aria-pressed="false"] { color: var(--muted); }

/* ── controls ────────────────────────────────────────────────────────────── */
.ctl { display: block; margin-bottom: 11px; }
.ctl:last-child { margin-bottom: 0; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 2px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
  font-variant-numeric: tabular-nums; }

input[type=range] { -webkit-appearance: none; appearance: none; display: block;
  width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer; }
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 15px; height: 15px;
  margin-top: -5.5px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line); }
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%;
  background: var(--accent); border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line); }
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }

button { font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface); border: 1px solid var(--line);
  border-radius: 8px; padding: 6px 10px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .45; cursor: default; }

.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chips button { flex: 1 1 auto; font-family: var(--font-mono); font-size: 11px;
  letter-spacing: .03em; padding: 6px 8px; }
.chips button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.chips.elev button { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column;
  align-items: center; gap: 5px; padding: 5px 4px 5px; }
/* each chip gets its own lit plate — a shadow is invisible on a dark panel */
.chips.elev .eplate { width: 100%; height: 68px; border-radius: 6px; background: var(--chip-plate);
  border: 1px solid var(--line); display: flex; align-items: center; justify-content: center; }
.chips.elev .ebox { width: 70%; max-width: 36px; height: 16px; border-radius: 3px;
  background: var(--chip-box); transform: translateY(-9px); }
.chips.elev button[aria-pressed="true"] .eplate { border-color: var(--accent); }

.row { display: flex; flex-wrap: wrap; gap: 6px; }
.row > button { flex: 1 1 auto; }

.toggle { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; cursor: pointer; }
.toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; cursor: pointer; flex: none; }
.toggle input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.seg { display: flex; gap: 0; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; border-radius: 0; flex: 1 1 auto; font-family: var(--font-mono);
  font-size: 11px; letter-spacing: .03em; background: var(--surface); }
.seg button + button { border-left: 1px solid var(--line); }
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
.seg button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── code out ────────────────────────────────────────────────────────────── */
.code { margin: 0; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.65;
  color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 8px;
  padding: 10px 11px; white-space: pre; overflow-x: auto; max-height: 190px; overflow-y: auto;
  font-variant-numeric: tabular-nums; }
.code .p { color: var(--muted); }
.copyrow { display: flex; align-items: center; gap: 9px; margin-top: 9px; flex-wrap: wrap; }
.copyrow .st { font-family: var(--font-mono); font-size: 11px; color: var(--muted); }
.copyrow .st.ok { color: var(--run); }

.facts { display: flex; flex-wrap: wrap; gap: 4px 6px; margin-top: 9px; }
.facts span { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .04em;
  color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 3px 8px;
  font-variant-numeric: tabular-nums; }
.facts span b { color: var(--ink); font-weight: 600; }

.cap { margin: 9px 0 0; font-size: 12px; line-height: 1.5; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap code { font-family: var(--font-mono); font-size: 11px; color: var(--ink); }
.cap.warn { color: var(--accent); }

footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  letter-spacing: .05em; padding-top: 2px; }

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

@media (max-width: 940px) {
  .stage { grid-template-columns: minmax(0,1fr); }
  .left, .panel { display: contents; }
  .scenecard { order: 1; } .presetcard { order: 2; } .layercard { order: 3; }
  .editcard { order: 4; } .bgcard { order: 5; } .csscard { order: 6; }
}
@media (max-width: 430px) {
  .cards { gap: 14px; padding: 12px; }
  .chips.elev button { padding: 7px 2px 5px; }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Shadow Workshop</h1>
    <p class="lede">
      One <code>box-shadow</code> looks like a smudge. Real shadows are several stacked on top of
      each other — a tight dark one near the edge, a wide faint one far out. Stack layers here,
      <b>drag the light around the scene</b>, and take the CSS with you.
    </p>
  </header>

  <main class="stage">

    <div class="left">

      <section class="card scenecard" aria-label="Preview">
        <div class="scene" id="scene">
          <div class="cards">
            <figure class="specimen">
              <div class="pcard" id="pcard" aria-hidden="true">
                <div class="ptop"><span class="pdot"></span><span class="pttl"></span></div>
                <span class="pln w2"></span><span class="pln w3"></span>
                <span class="pbtn"></span>
              </div>
              <figcaption class="plabel" id="labStack">3 layers</figcaption>
            </figure>
            <figure class="specimen" id="cmpWrap">
              <div class="pcard" id="fcard" aria-hidden="true">
                <div class="ptop"><span class="pdot"></span><span class="pttl"></span></div>
                <span class="pln w2"></span><span class="pln w3"></span>
                <span class="pbtn"></span>
              </div>
              <figcaption class="plabel" id="labFlat">1 layer · same ink</figcaption>
            </figure>
          </div>
          <div class="light" id="light" tabindex="0"
               aria-label="Light source. Drag it, or move it with the arrow keys; Home puts it back overhead."></div>
          <div class="hud" aria-hidden="true">
            <span>LIGHT <b id="hAng">270</b>°</span>
            <span>REACH <b id="hDist">1.00</b></span>
            <span>INK <b id="hInk">21</b>%</span>
          </div>
          <div class="hint" id="hint">drag anywhere to move the light · or tab to the dot, then arrows</div>
        </div>
        <div class="scenefoot">
          <label class="toggle"><input type="checkbox" id="cmpChk" checked>
            <span>Show the same ink as one flat layer</span></label>
        </div>
      </section>

      <section class="card bgcard" aria-label="Scene">
        <h2>The scene it sits on</h2>
        <div class="chips" id="bgChips" role="group" aria-label="Background colour">
          <button type="button" data-bg="paper" aria-pressed="true">paper</button>
          <button type="button" data-bg="white" aria-pressed="false">white</button>
          <button type="button" data-bg="sand" aria-pressed="false">sand</button>
          <button type="button" data-bg="sky" aria-pressed="false">sky</button>
          <button type="button" data-bg="slate" aria-pressed="false">slate</button>
        </div>
        <div class="ctl" style="margin-top:11px">
          <div class="ctl-top"><label for="bgL">Background lightness</label>
            <span class="val" id="bgLV" aria-hidden="true">93%</span></div>
          <input type="range" id="bgL" min="6" max="98" step="1" value="93">
        </div>
        <div class="ctl">
          <div class="ctl-top"><span style="font-size:13px">Card tone</span></div>
          <div class="seg" id="toneSeg" role="group" aria-label="Card tone">
            <button type="button" data-tone="light" aria-pressed="true">light card</button>
            <button type="button" data-tone="dark" aria-pressed="false">dark card</button>
          </div>
        </div>
        <div class="ctl">
          <div class="ctl-top"><span style="font-size:13px">Shadow tint</span>
            <span class="val" id="tintV" aria-hidden="true">rgb(15,17,21)</span></div>
          <div class="chips" id="tintChips" role="group" aria-label="Shadow tint">
            <button type="button" data-tint="neutral" aria-pressed="true">neutral</button>
            <button type="button" data-tint="warm" aria-pressed="false">warm</button>
            <button type="button" data-tint="cool" aria-pressed="false">cool</button>
            <button type="button" data-tint="plum" aria-pressed="false">plum</button>
          </div>
        </div>
        <p class="cap" id="bgNote">A shadow is just darkness added to whatever is behind it. Slide the
          background down toward black and watch it disappear.</p>
      </section>

      <section class="card csscard" aria-label="Generated CSS">
        <h2>The CSS <span class="n" id="cssN"></span></h2>
        <pre class="code" id="code"></pre>
        <div class="copyrow">
          <button type="button" id="copyBtn">Copy CSS</button>
          <span class="st" id="copySt" role="status"></span>
        </div>
        <div class="facts" id="facts"></div>
      </section>

    </div>

    <div class="panel">

      <section class="card presetcard" aria-label="Presets">
        <h2>Realistic elevation</h2>
        <div class="chips elev" id="elevChips" role="group" aria-label="Elevation level">
          <button type="button" data-e="1" aria-pressed="false" aria-label="Elevation 1, resting on the surface"><span class="eplate"><span class="ebox"></span></span>1</button>
          <button type="button" data-e="2" aria-pressed="false" aria-label="Elevation 2"><span class="eplate"><span class="ebox"></span></span>2</button>
          <button type="button" data-e="3" aria-pressed="true" aria-label="Elevation 3"><span class="eplate"><span class="ebox"></span></span>3</button>
          <button type="button" data-e="4" aria-pressed="false" aria-label="Elevation 4"><span class="eplate"><span class="ebox"></span></span>4</button>
          <button type="button" data-e="5" aria-pressed="false" aria-label="Elevation 5, lifted well clear"><span class="eplate"><span class="ebox"></span></span>5</button>
        </div>
        <p class="cap">Five heights, each built from three layers: a tight one that draws the edge,
          a middle one, and a wide faint one pulled in with negative spread. Higher up means
          <b>softer and further</b>, not darker.</p>
        <h2 style="margin-top:13px">Or start from a feel</h2>
        <div class="chips" id="feelChips" role="group" aria-label="Shadow style">
          <button type="button" data-f="smooth" aria-pressed="false">smooth</button>
          <button type="button" data-f="crisp" aria-pressed="false">crisp</button>
          <button type="button" data-f="harsh" aria-pressed="false">harsh</button>
          <button type="button" data-f="halo" aria-pressed="false">halo</button>
          <button type="button" data-f="well" aria-pressed="false">inset well</button>
        </div>
      </section>

      <section class="card layercard" aria-label="Layer stack">
        <h2>Layer stack <span class="n" id="stackN"></span></h2>
        <div class="layers" id="layerList"></div>
        <div class="row" style="margin-top:9px">
          <button type="button" id="addBtn">Add layer</button>
          <button type="button" id="dupBtn">Duplicate</button>
        </div>
        <p class="cap warn" id="capNote" hidden>Six layers is the limit here — remove one to add
          another. Real stacks rarely need more than four.</p>
        <p class="cap">The first layer in the list paints on top. Each row shows that layer
          <b>on its own</b> — the trick is that none of them look like much alone.</p>
      </section>

      <section class="card editcard" aria-label="Selected layer">
        <h2>Editing <span class="n" id="editN">layer 1</span></h2>

        <div class="ctl">
          <div class="ctl-top"><label for="sx">Sideways</label><span class="val" id="sxV">0px</span></div>
          <input type="range" id="sx" min="-40" max="40" step="1" value="0">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="sy">Down</label><span class="val" id="syV">2px</span></div>
          <input type="range" id="sy" min="-40" max="40" step="1" value="2">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="sb">Blur</label><span class="val" id="sbV">3px</span></div>
          <input type="range" id="sb" min="0" max="80" step="1" value="3">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="ss">Spread</label><span class="val" id="ssV">0px</span></div>
          <input type="range" id="ss" min="-30" max="30" step="1" value="0">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="so">Opacity</label><span class="val" id="soV">9%</span></div>
          <input type="range" id="so" min="0" max="100" step="0.5" value="9">
        </div>
        <div class="row" style="margin-top:11px; align-items:center">
          <label class="toggle" style="flex:1 1 auto"><input type="checkbox" id="insetChk">
            <span>Inset (paint it inside)</span></label>
          <button type="button" id="delBtn">Remove</button>
        </div>
        <p class="cap warn" id="offNote" hidden>This layer is switched off, so nothing you change
          here shows up yet. Flip it back <b>on</b> in the stack above.</p>
      </section>

    </div>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>

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

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

  var root = document.documentElement;
  function $(id) { return document.getElementById(id); }
  function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }

  /* platform theme messages (also works standalone via prefers-color-scheme) */
  window.addEventListener("message", function (e) {
    var d = e && e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
    }
  });

  /* ── model ─────────────────────────────────────────────────────────────── */
  var TINTS = { neutral: [15,17,21], warm: [74,42,16], cool: [18,32,78], plum: [54,18,60] };
  var BGS   = { paper: [42,10], white: [0,0], sand: [30,42], sky: [206,40], slate: [220,10] };

  var ELEV = {
    1: [[0,1,1,0,.10],[0,1,2,0,.08],[0,2,5,-1,.05]],
    2: [[0,1,2,0,.10],[0,2,5,-1,.08],[0,5,10,-2,.05]],
    3: [[0,2,3,0,.09],[0,4,8,-2,.08],[0,9,18,-4,.06]],
    4: [[0,3,5,-1,.08],[0,7,14,-3,.07],[0,14,28,-7,.06]],
    5: [[0,4,7,-1,.07],[0,10,20,-4,.07],[0,22,40,-10,.06]]
  };
  var FEELS = {
    smooth: [[0,1,2,0,.05],[0,2,4,0,.05],[0,4,8,0,.05],[0,8,16,0,.05],[0,16,32,0,.05]],
    crisp:  [[0,1,1,0,.14],[0,3,5,-1,.12]],
    harsh:  [[7,7,0,0,.90]],
    halo:   [[0,0,20,-2,.30],[0,0,6,0,.18]],
    well:   [[0,2,7,0,.16,true],[0,1,1,0,.10,true]]
  };

  var S = { tint: "neutral", bg: "paper", l: 93, tone: "light", compare: true, sel: 0, layers: [] };
  var LIGHT = { x: 0, y: -1 };
  var MAXL = 6;

  function mk(a) {
    return { x: a[0], y: a[1], blur: a[2], spread: a[3], op: a[4], inset: !!a[5], on: true,
             reach: Math.sqrt(a[0]*a[0] + a[1]*a[1]) };
  }
  function loadSet(list) {
    S.layers = list.map(mk);
    S.sel = 0;
    /* aim the light from the layer that reaches furthest */
    var d = null, best = -1;
    S.layers.forEach(function (l) { if (l.reach > best) { best = l.reach; d = l; } });
    if (d && best > 0.001) { LIGHT.x = -d.x / best; LIGHT.y = -d.y / best; }
    else {
      LIGHT.x = 0; LIGHT.y = -1;
      S.layers.forEach(function (l) { l.reach = 0; });
    }
    var anyReach = S.layers.some(function (l) { return l.reach > 0; });
    if (!anyReach) S.layers.forEach(function (l) { l.reach = Math.round(l.blur * 0.3); });
  }

  function applyLight() {
    var m = Math.sqrt(LIGHT.x*LIGHT.x + LIGHT.y*LIGHT.y);
    var ux = 0, uy = 0;
    if (m > 0.0001) { ux = -LIGHT.x / m; uy = -LIGHT.y / m; }
    S.layers.forEach(function (l) {
      l.x = clamp(Math.round(l.reach * m * ux), -40, 40);
      l.y = clamp(Math.round(l.reach * m * uy), -40, 40);
    });
  }
  function lightMag() { return Math.sqrt(LIGHT.x*LIGHT.x + LIGHT.y*LIGHT.y); }
  /* how far this layer travels per unit of light. With the light parked dead
     centre there is no direction to divide by, so the raw offset IS the reach. */
  function reachOf(l) {
    var d = Math.sqrt(l.x*l.x + l.y*l.y), m = lightMag();
    return m > 0.05 ? clamp(d / m, 0, 60) : d;
  }

  /* ── formatting ────────────────────────────────────────────────────────── */
  function px(v) { return v === 0 ? "0" : v + "px"; }
  function pxl(v) { return v + "px"; }
  function alpha(a) {
    var s = a.toFixed(3);
    s = s.replace(/0+$/, "").replace(/\.$/, "");
    return s === "" || s === "0" ? "0" : s;
  }
  function rgbaStr(a) {
    var t = TINTS[S.tint];
    return "rgba(" + t[0] + "," + t[1] + "," + t[2] + "," + alpha(a) + ")";
  }
  function layerCss(l) {
    return (l.inset ? "inset " : "") + px(l.x) + " " + px(l.y) + " " + px(l.blur) +
           (l.spread ? " " + px(l.spread) : "") + " " + rgbaStr(l.op);
  }
  function activeLayers() { return S.layers.filter(function (l) { return l.on && l.op > 0; }); }
  function shadowValue() {
    var a = activeLayers();
    if (!a.length) return "none";
    return a.map(layerCss).join(", ");
  }
  function pctFmt(op) {
    var v = op * 100;
    return (Math.abs(v - Math.round(v)) < 0.05 ? Math.round(v) : v.toFixed(1)) + "%";
  }

  /* one honest flatten: true composite alpha, ink-weighted geometry */
  function flatten() {
    var a = activeLayers();
    if (!a.length) return null;
    var keep = 1, w = 0, x = 0, y = 0, b = 0, s = 0, ins = 0;
    a.forEach(function (l) {
      keep *= (1 - l.op);
      w += l.op; x += l.x * l.op; y += l.y * l.op; b += l.blur * l.op; s += l.spread * l.op;
      if (l.inset) ins += l.op;
    });
    if (w <= 0) return null;
    return { x: Math.round(x/w), y: Math.round(y/w), blur: Math.round(b/w),
             spread: Math.round(s/w), op: 1 - keep, inset: ins > w/2, on: true };
  }

  /* ── colours for the scene ─────────────────────────────────────────────── */
  function hsl(h, s, l) { return "hsl(" + h + "," + s.toFixed(1) + "%," + l.toFixed(1) + "%)"; }
  /* The chip box is ~1/4 the size of the real preview card, so an elevation
     shadow drawn at true scale is a 1px smear you cannot tell from the next one
     along. Shrink the geometry to fit the plate but push the ink back up, so the
     thing the chips are FOR — 1 through 5 getting softer and further — is what
     you actually see. */
  var CHIP_K = 0.55, CHIP_INK = 2.4;
  function updateElevChips() {
    [].forEach.call(document.querySelectorAll("#elevChips button[data-e]"), function (b) {
      var box = b.querySelector(".ebox");
      if (!box) return;
      var set = ELEV[b.getAttribute("data-e")];
      box.style.boxShadow = set.map(function (a) {
        var sp = Math.round(a[3] * CHIP_K);
        return px(Math.round(a[0]*CHIP_K)) + " " + px(Math.round(a[1]*CHIP_K)) + " " +
               px(Math.round(a[2]*CHIP_K)) + (sp ? " " + px(sp) : "") + " " +
               rgbaStr(clamp(a[4] * CHIP_INK, 0, 1));
      }).join(", ");
    });
  }

  function paintScene() {
    var b = BGS[S.bg], h = b[0], sat = b[1], l = S.l;
    var stageBg = hsl(h, sat, l);
    root.style.setProperty("--stage-bg", stageBg);
    var light = l > 52;
    scene.style.setProperty("--stage-ink", light ? "rgba(0,0,0,.66)" : "rgba(255,255,255,.78)");
    scene.style.setProperty("--stage-scrim", light ? "rgba(255,255,255,.62)" : "rgba(0,0,0,.34)");
    scene.style.setProperty("--stage-edge", light ? "rgba(0,0,0,.10)" : "rgba(255,255,255,.18)");
    var cb, ci, cl;
    if (S.tone === "light") {
      cb = hsl(h, sat*0.35, 99); ci = hsl(h, sat*0.5, 20); cl = hsl(h, sat*0.4, 89);
    } else {
      cb = hsl(h, sat*0.5, 16);  ci = hsl(h, sat*0.3, 92); cl = hsl(h, sat*0.5, 27);
    }
    scene.style.setProperty("--card-bg", cb);
    scene.style.setProperty("--card-ink", ci);
    scene.style.setProperty("--card-line", cl);
    root.style.setProperty("--swatch-card", cb);
    updateElevChips();
  }

  /* ── elements ──────────────────────────────────────────────────────────── */
  var scene = $("scene"), pcard = $("pcard"), fcard = $("fcard"), cmpWrap = $("cmpWrap");
  var lightEl = $("light"), hint = $("hint"), live = $("live");
  var hAng = $("hAng"), hDist = $("hDist"), hInk = $("hInk");
  var labStack = $("labStack"), labFlat = $("labFlat");
  var layerList = $("layerList"), stackN = $("stackN"), editN = $("editN");
  var codeEl = $("code"), cssN = $("cssN"), factsEl = $("facts");
  var copyBtn = $("copyBtn"), copySt = $("copySt");
  var addBtn = $("addBtn"), dupBtn = $("dupBtn"), delBtn = $("delBtn");
  var bgNote = $("bgNote"), tintV = $("tintV"), bgLV = $("bgLV"), bgL = $("bgL"), offNote = $("offNote");
  var capNote = $("capNote");
  var cmpChk = $("cmpChk"), insetChk = $("insetChk");
  var sl = { x: $("sx"), y: $("sy"), blur: $("sb"), spread: $("ss"), op: $("so") };
  var lb = { x: $("sxV"), y: $("syV"), blur: $("sbV"), spread: $("ssV"), op: $("soV") };

  /* ── render ────────────────────────────────────────────────────────────── */
  function renderPreview() {
    pcard.style.boxShadow = shadowValue();
    var f = flatten();
    fcard.style.boxShadow = f ? layerCss(f) : "none";
    var n = activeLayers().length;
    labStack.textContent = n === 1 ? "1 layer" : n + " layers";
    labFlat.textContent = n === 0 ? "nothing to flatten"
                        : n === 1 ? "identical · already flat" : "1 layer · same ink";
    cmpWrap.className = "specimen" + (S.compare ? "" : " off");

    var m = lightMag();
    var deg = Math.round(((Math.atan2(LIGHT.y, LIGHT.x) * 180 / Math.PI) + 360) % 360);
    hAng.textContent = m < 0.02 ? "—" : String(deg);
    hDist.textContent = m.toFixed(2);
    var keep = 1;
    activeLayers().forEach(function (l) { keep *= (1 - l.op); });
    hInk.textContent = Math.round((1 - keep) * 100);

    lightEl.style.left = (50 + LIGHT.x * 26) + "%";
    lightEl.style.top  = (50 + LIGHT.y * 26) + "%";
  }

  function renderLayers() {
    var ae = document.activeElement, keep = null;
    if (ae && layerList.contains(ae) && ae.dataset && ae.dataset.k) {
      keep = ae.dataset.k + ":" + ae.dataset.i;
    }
    layerList.textContent = "";
    stackN.textContent = S.layers.length + " of " + MAXL;
    S.layers.forEach(function (l, i) {
      var row = document.createElement("div");
      row.className = "lrow" + (l.on ? "" : " muted");

      var pick = document.createElement("button");
      pick.type = "button"; pick.className = "lpick";
      pick.setAttribute("aria-pressed", i === S.sel ? "true" : "false");
      pick.dataset.k = "pick"; pick.dataset.i = i;

      var sw = document.createElement("span");
      sw.className = "lswatch";
      var chip = document.createElement("i");
      var reach = Math.max(Math.abs(l.x), Math.abs(l.y), l.blur, Math.abs(l.spread), 1);
      var k = clamp(9 / reach, 0.12, 1);
      chip.style.boxShadow = l.on ? (l.inset ? "inset " : "") +
        px(Math.round(l.x*k)) + " " + px(Math.round(l.y*k)) + " " + px(Math.round(l.blur*k)) +
        " " + px(Math.round(l.spread*k)) + " " + rgbaStr(l.op) : "none";
      sw.appendChild(chip);

      var meta = document.createElement("span");
      meta.className = "lmeta";
      var nm = document.createElement("span");
      nm.className = "lname";
      nm.textContent = "layer " + (i + 1) + (l.inset ? " · inset" : "");
      var spec = document.createElement("span");
      spec.className = "lspec";
      spec.textContent = px(l.x) + " " + px(l.y) + " " + px(l.blur) +
        (l.spread ? " " + px(l.spread) : "") + " · " + pctFmt(l.op);
      meta.appendChild(nm); meta.appendChild(spec);

      pick.appendChild(sw); pick.appendChild(meta);
      pick.addEventListener("click", function () { S.sel = i; syncSliders(); renderLayers(); });

      var eye = document.createElement("button");
      eye.type = "button"; eye.className = "icb";
      eye.setAttribute("aria-pressed", l.on ? "true" : "false");
      eye.setAttribute("aria-label", (l.on ? "Turn off" : "Turn on") + " layer " + (i + 1));
      eye.textContent = l.on ? "on" : "off";
      eye.dataset.k = "eye"; eye.dataset.i = i;
      eye.style.fontSize = "10px";
      eye.addEventListener("click", function () {
        l.on = !l.on;
        clearPresetChips();   /* the stack no longer matches the chip it came from */
        refresh();
        say("Layer " + (i + 1) + (l.on ? " on." : " off."));
      });

      var del = document.createElement("button");
      del.type = "button"; del.className = "icb";
      del.setAttribute("aria-label", "Remove layer " + (i + 1));
      del.textContent = "×";
      del.dataset.k = "del"; del.dataset.i = i;
      del.disabled = S.layers.length < 2;
      del.addEventListener("click", function () { removeAt(i); });

      row.appendChild(pick); row.appendChild(eye); row.appendChild(del);
      layerList.appendChild(row);
    });
    if (keep) {
      var parts = keep.split(":");
      var back = layerList.querySelector('[data-k="' + parts[0] + '"][data-i="' + parts[1] + '"]');
      if (!back) back = layerList.querySelector('[data-k="' + parts[0] + '"]');
      if (back && !back.disabled) back.focus();
    }
  }

  function renderCode() {
    var a = activeLayers();
    codeEl.textContent = "";
    function put(cls, txt) {
      var s = document.createElement("span");
      if (cls) s.className = cls;
      s.textContent = txt;
      codeEl.appendChild(s);
    }
    var text;
    if (!a.length) {
      put("p", "box-shadow: ");
      put("", "none;");
      text = "box-shadow: none;";
    } else if (a.length === 1) {
      put("p", "box-shadow: ");
      put("", layerCss(a[0]) + ";");
      text = "box-shadow: " + layerCss(a[0]) + ";";
    } else {
      put("p", "box-shadow:\n");
      a.forEach(function (l, i) {
        put("", "  " + layerCss(l) + (i < a.length - 1 ? ",\n" : ";"));
      });
      text = "box-shadow:\n" + a.map(function (l) { return "  " + layerCss(l); }).join(",\n") + ";";
    }
    codeEl.dataset.text = text;
    cssN.textContent = a.length + (a.length === 1 ? " layer" : " layers");

    var widest = 0, ink = 1, far = 0;
    a.forEach(function (l) {
      if (l.blur > widest) widest = l.blur;
      ink *= (1 - l.op);
      var d = Math.sqrt(l.x*l.x + l.y*l.y);
      if (d > far) far = d;
    });
    ink = a.length ? 1 - ink : 0;
    factsEl.textContent = "";
    var facts = [
      ["widest blur", widest + "px"],
      ["furthest offset", Math.round(far) + "px"],
      ["stacked darkness", Math.round(ink * 100) + "%"],
      ["length", text.replace(/\s+/g, " ").length + " chars"]
    ];
    facts.forEach(function (f) {
      var s = document.createElement("span");
      var b = document.createElement("b");
      b.textContent = f[1];
      s.appendChild(document.createTextNode(f[0] + " "));
      s.appendChild(b);
      factsEl.appendChild(s);
    });
  }

  function syncSliders() {
    /* a button that silently does nothing is a broken button — say so on screen */
    var full = S.layers.length >= MAXL;
    addBtn.disabled = full;
    dupBtn.disabled = full;
    capNote.hidden = !full;
    var l = S.layers[S.sel];
    if (!l) return;
    sl.x.value = l.x; sl.y.value = l.y; sl.blur.value = l.blur;
    sl.spread.value = l.spread; sl.op.value = (l.op * 100).toFixed(1);
    lb.x.textContent = pxl(l.x); lb.y.textContent = pxl(l.y);
    lb.blur.textContent = pxl(l.blur); lb.spread.textContent = pxl(l.spread);
    lb.op.textContent = pctFmt(l.op);
    insetChk.checked = l.inset;
    editN.textContent = "layer " + (S.sel + 1) + " of " + S.layers.length + (l.on ? "" : " · off");
    delBtn.disabled = S.layers.length < 2;
    offNote.hidden = l.on;
  }

  function noteBg() {
    if (S.l < 26) {
      bgNote.className = "cap warn";
      bgNote.textContent = "Barely anything left. A shadow only adds darkness, so on a near-black " +
        "background there is nothing left to darken — dark interfaces lift things with lighter " +
        "surfaces and hairline borders instead.";
    } else {
      bgNote.className = "cap";
      bgNote.textContent = "A shadow is just darkness added to whatever is behind it. Slide the " +
        "background down toward black and watch it disappear.";
    }
  }

  function refresh() {
    paintScene(); renderPreview(); renderLayers(); renderCode(); syncSliders(); noteBg();
    if (copySt.textContent) { copySt.textContent = ""; copySt.className = "st"; }
  }

  /* ── edits ─────────────────────────────────────────────────────────────── */
  function clearPresetChips() {
    [].forEach.call(document.querySelectorAll("#elevChips button, #feelChips button"), function (b) {
      b.setAttribute("aria-pressed", "false");
    });
  }
  function removeAt(i) {
    if (S.layers.length < 2) return;
    S.layers.splice(i, 1);
    if (S.sel >= S.layers.length) S.sel = S.layers.length - 1;
    clearPresetChips(); refresh();
    say("Layer " + (i + 1) + " removed. " + S.layers.length + " left.");
  }
  function say(t) { live.textContent = t; }

  Object.keys(sl).forEach(function (k) {
    sl[k].addEventListener("input", function () {
      var l = S.layers[S.sel];
      if (!l) return;
      var v = parseFloat(sl[k].value);
      if (isNaN(v)) return;
      if (k === "op") l.op = clamp(v / 100, 0, 1);
      else l[k] = Math.round(v);
      if (k === "x" || k === "y") l.reach = reachOf(l);
      clearPresetChips();
      paintScene(); renderPreview(); renderLayers(); renderCode();
      lb[k].textContent = (k === "op") ? pctFmt(l.op) : pxl(l[k]);
      copySt.textContent = ""; copySt.className = "st";
    });
  });

  insetChk.addEventListener("change", function () {
    var l = S.layers[S.sel];
    if (!l) return;
    l.inset = insetChk.checked;
    clearPresetChips(); refresh();
  });

  addBtn.addEventListener("click", function () {
    if (S.layers.length >= MAXL) return;
    var last = S.layers[S.layers.length - 1];
    /* Grow along the direction the light already implies, so a centred preset
       (halo) stays centred instead of being silently re-aimed downwards. */
    var m = lightMag(), ux = 0, uy = 0;
    if (m > 0.0001) { ux = -LIGHT.x / m; uy = -LIGHT.y / m; }
    var reach = last ? Math.min(56, Math.sqrt(last.x*last.x + last.y*last.y) * 2) : 2;
    var blur = Math.min(80, (last ? last.blur : 4) * 2 || 8);
    var nl = mk([clamp(Math.round(reach * m * ux), -40, 40),
                 clamp(Math.round(reach * m * uy), -40, 40), blur, 0, 0.06]);
    nl.reach = reach;
    S.layers.push(nl);
    S.sel = S.layers.length - 1;
    clearPresetChips(); refresh();
    say("Layer added. " + S.layers.length + " in the stack.");
  });

  dupBtn.addEventListener("click", function () {
    if (S.layers.length >= MAXL) return;
    var l = S.layers[S.sel];
    if (!l) return;
    var c = { x: l.x, y: l.y, blur: l.blur, spread: l.spread, op: l.op, inset: l.inset,
              on: true, reach: l.reach };
    S.layers.splice(S.sel + 1, 0, c);
    S.sel = S.sel + 1;
    clearPresetChips(); refresh();
    say("Layer duplicated.");
  });

  delBtn.addEventListener("click", function () { removeAt(S.sel); });

  /* presets */
  $("elevChips").addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-e]") : null;
    if (!b) return;
    clearPresetChips(); b.setAttribute("aria-pressed", "true");
    loadSet(ELEV[b.getAttribute("data-e")]);
    refresh();
    say("Elevation " + b.getAttribute("data-e") + ", three layers.");
  });
  $("feelChips").addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-f]") : null;
    if (!b) return;
    clearPresetChips(); b.setAttribute("aria-pressed", "true");
    var key = b.getAttribute("data-f");
    loadSet(FEELS[key]);
    refresh();
    say(key + " loaded, " + S.layers.length + (S.layers.length === 1 ? " layer." : " layers."));
  });

  /* scene controls */
  $("bgChips").addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-bg]") : null;
    if (!b) return;
    [].forEach.call(this.querySelectorAll("button"), function (x) { x.setAttribute("aria-pressed", "false"); });
    b.setAttribute("aria-pressed", "true");
    S.bg = b.getAttribute("data-bg");
    refresh();
  });
  $("toneSeg").addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-tone]") : null;
    if (!b) return;
    [].forEach.call(this.querySelectorAll("button"), function (x) { x.setAttribute("aria-pressed", "false"); });
    b.setAttribute("aria-pressed", "true");
    S.tone = b.getAttribute("data-tone");
    refresh();
  });
  $("tintChips").addEventListener("click", function (e) {
    var b = e.target.closest ? e.target.closest("button[data-tint]") : null;
    if (!b) return;
    [].forEach.call(this.querySelectorAll("button"), function (x) { x.setAttribute("aria-pressed", "false"); });
    b.setAttribute("aria-pressed", "true");
    S.tint = b.getAttribute("data-tint");
    var t = TINTS[S.tint];
    tintV.textContent = "rgb(" + t[0] + "," + t[1] + "," + t[2] + ")";
    refresh();
  });
  bgL.addEventListener("input", function () {
    S.l = clamp(parseInt(bgL.value, 10) || 0, 0, 100);
    bgLV.textContent = S.l + "%";
    paintScene(); renderPreview(); renderLayers(); noteBg();
  });
  cmpChk.addEventListener("change", function () { S.compare = cmpChk.checked; renderPreview(); });

  /* ── the light ─────────────────────────────────────────────────────────── */
  var dragging = false;
  function setLightFromPoint(cx, cy) {
    var r = scene.getBoundingClientRect();
    if (!r.width || !r.height) return;
    var nx = (cx - (r.left + r.width / 2)) / (r.width * 0.26);
    var ny = (cy - (r.top + r.height / 2)) / (r.height * 0.26);
    var m = Math.sqrt(nx*nx + ny*ny);
    if (m > 1.6) { nx = nx / m * 1.6; ny = ny / m * 1.6; }
    LIGHT.x = nx; LIGHT.y = ny;
    applyLight();
    clearPresetChips();
    renderPreview(); renderLayers(); renderCode(); syncSliders();
  }
  function hideHint() { if (hint && !hint.classList.contains("gone")) hint.classList.add("gone"); }

  scene.addEventListener("pointerdown", function (e) {
    dragging = true;
    scene.classList.add("dragging");
    hideHint();
    if (scene.setPointerCapture) { try { scene.setPointerCapture(e.pointerId); } catch (err) {} }
    setLightFromPoint(e.clientX, e.clientY);
    e.preventDefault();
  });
  scene.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    setLightFromPoint(e.clientX, e.clientY);
  });
  function endDrag() {
    if (!dragging) return;
    dragging = false;
    scene.classList.remove("dragging");
  }
  scene.addEventListener("pointerup", endDrag);
  scene.addEventListener("pointercancel", endDrag);
  window.addEventListener("blur", endDrag);

  lightEl.addEventListener("keydown", function (e) {
    var step = e.shiftKey ? 0.25 : 0.08, dx = 0, dy = 0;
    if (e.key === "ArrowLeft") dx = -step;
    else if (e.key === "ArrowRight") dx = step;
    else if (e.key === "ArrowUp") dy = -step;
    else if (e.key === "ArrowDown") dy = step;
    else if (e.key === "Home") { LIGHT.x = 0; LIGHT.y = -1; }
    else return;
    e.preventDefault();
    hideHint();
    /* clamp the MAGNITUDE, exactly as dragging does — clamping each axis on its
       own let the keyboard push the light out to 2.26 and off the plate. */
    var nx = LIGHT.x + dx, ny = LIGHT.y + dy;
    var nm = Math.sqrt(nx*nx + ny*ny);
    if (nm > 1.6) { nx = nx / nm * 1.6; ny = ny / nm * 1.6; }
    LIGHT.x = nx; LIGHT.y = ny;
    applyLight();
    clearPresetChips();
    renderPreview(); renderLayers(); renderCode(); syncSliders();
    var deg = Math.round(((Math.atan2(LIGHT.y, LIGHT.x) * 180 / Math.PI) + 360) % 360);
    say("Light at " + deg + " degrees, reach " + lightMag().toFixed(2) + ".");
  });
  lightEl.addEventListener("pointerdown", function (e) {
    e.stopPropagation();
    dragging = true; scene.classList.add("dragging"); hideHint();
    if (lightEl.setPointerCapture) { try { lightEl.setPointerCapture(e.pointerId); } catch (err) {} }
    e.preventDefault();
  });
  lightEl.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    setLightFromPoint(e.clientX, e.clientY);
  });
  lightEl.addEventListener("pointerup", endDrag);
  lightEl.addEventListener("pointercancel", endDrag);

  /* ── copy ──────────────────────────────────────────────────────────────── */
  function legacyCopy(t, done) {
    try {
      var ta = document.createElement("textarea");
      ta.value = t; ta.setAttribute("readonly", "");
      ta.style.position = "fixed"; ta.style.top = "-1000px"; ta.style.opacity = "0";
      document.body.appendChild(ta);
      ta.select();
      var ok = !!(document.execCommand && document.execCommand("copy"));
      document.body.removeChild(ta);
      done(ok);
    } catch (err) { done(false); }
  }
  copyBtn.addEventListener("click", function () {
    var t = codeEl.dataset.text || codeEl.textContent;
    function done(ok) {
      copySt.textContent = ok ? "copied ✓" : "couldn't reach the clipboard — select the text above";
      copySt.className = ok ? "st ok" : "st";
      say(ok ? "CSS copied." : "Copy blocked; select the text above.");
    }
    try {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(t).then(function () { done(true); },
                                              function () { legacyCopy(t, done); });
        return;
      }
    } catch (err) {}
    legacyCopy(t, done);
  });

  /* ── go ────────────────────────────────────────────────────────────────── */
  loadSet(ELEV[3]);
  S.compare = true;
  cmpChk.checked = true;
  bgLV.textContent = S.l + "%";
  var t0 = TINTS[S.tint];
  tintV.textContent = "rgb(" + t0[0] + "," + t0[1] + "," + t0[2] + ")";
  refresh();
})();
</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