$sloprun.dev

JSON Toolbox

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

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

▶ 7 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 paste-and-poke JSON workbench that opens on a corner bakery's storefront record: foldable tree, live size and node counts, and pretty/minify round-trips that keep key order and number formatting exactly as written. It runs a hand-written parser instead of JSON.parse, so a broken document gets one plain-English sentence and a caret under the exact character — "there is a comma just before this }" beats "Unexpected token }". Click any row to copy its path ($.menu[1].stock.onHand), or type bun to filter keys and values at once with every matching branch opened for you. Hit broken to watch the error panel work, and drop a .json file on the editor if you have one — nothing ever leaves the tab.

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

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 1594 lines 68.9 KB index.html
demos/json-tools/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>JSON Toolbox — 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); }

/* ---- value-type palette (deliberately no green: green is earned) ---- */
:root {
  --t-str:  #1D4ED8;  --t-str-bg:  #E7EDFC;
  --t-num:  #A64B00;  --t-num-bg:  #FDEEDD;
  --t-bool: #6D28D9;  --t-bool-bg: #EEE7FB;
  --t-null: #6B7280;  --t-null-bg: #ECEBE7;
  --t-obj:  #0F766E;  --t-obj-bg:  #E1F0EE;
  --t-arr:  #B02A6B;  --t-arr-bg:  #FBE6F0;
  --mark:   rgba(192, 86, 0, .22);
  --mark-edge: rgba(192, 86, 0, .60);
  --accent-ink: #8F4100;  /* accent as *text* on accent-soft: --accent itself is only 4.2:1 there */
  --sel:    #FFF3E6;
  --danger-soft: #FBE9E8;
  --warn:   #8A5A00;  --warn-soft: #FBF1DC;
  --shadow: 0 1px 2px rgba(16, 18, 22, .05), 0 8px 24px -18px rgba(16, 18, 22, .35);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --t-str:  #8AB4FF;  --t-str-bg:  #1B2942;
    --t-num:  #FFB454;  --t-num-bg:  #38290F;
    --t-bool: #C4A2FF;  --t-bool-bg: #2C2247;
    --t-null: #8C939C;  --t-null-bg: #262A32;
    --t-obj:  #56C8BC;  --t-obj-bg:  #14312E;
    --t-arr:  #F58AB9;  --t-arr-bg:  #3B1B2C;
    --mark:   rgba(255, 180, 84, .26);
    --mark-edge: rgba(255, 180, 84, .70);
    --accent-ink: #FFB454;
    --sel:    #2A2118;
    --danger-soft: #3A1D1C;
    --warn:   #E0AE55;  --warn-soft: #322817;
    --shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 10px 30px -20px rgba(0, 0, 0, .9);
  }
}
:root[data-theme="dark"] {
  --t-str:  #8AB4FF;  --t-str-bg:  #1B2942;
  --t-num:  #FFB454;  --t-num-bg:  #38290F;
  --t-bool: #C4A2FF;  --t-bool-bg: #2C2247;
  --t-null: #8C939C;  --t-null-bg: #262A32;
  --t-obj:  #56C8BC;  --t-obj-bg:  #14312E;
  --t-arr:  #F58AB9;  --t-arr-bg:  #3B1B2C;
  --mark:   rgba(255, 180, 84, .26);
  --mark-edge: rgba(255, 180, 84, .70);
  --accent-ink: #FFB454;
  --sel:    #2A2118;
  --danger-soft: #3A1D1C;
  --warn:   #E0AE55;  --warn-soft: #322817;
  --shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 10px 30px -20px rgba(0, 0, 0, .9);
}

/* native chrome (textarea caret, select menu, scrollbars) has to follow the theme too —
   without this a platform-forced dark theme on a light OS paints light scrollbars. */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; max-width: 100%; overflow-x: hidden; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 22px 16px 36px; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.mono { font-family: var(--font-mono); }

/* ---- header ---- */
header.top { margin-bottom: 14px; }
.titlerow { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
h1 { font-size: 1.4rem; margin: 0; letter-spacing: -0.015em; }
.lede { margin: 7px 0 10px; color: var(--muted); max-width: 68ch; font-size: .94rem; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
  font-family: var(--font-mono); font-size: .69rem; letter-spacing: .02em;
  padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
  color: var(--muted); background: var(--surface); white-space: nowrap;
}

/* ---- controls ---- */
.bar {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px;
  padding: 9px 10px; margin-bottom: 12px;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow);
}
.grp { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.grp + .grp { margin-left: auto; }
.lbl {
  font-family: var(--font-mono); font-size: .68rem; letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); white-space: nowrap;
}
button, select, input { font: inherit; color: inherit; }
.btn {
  font-family: var(--font-mono); font-size: .76rem;
  padding: 6px 10px; border: 1px solid var(--line); border-radius: 7px;
  background: var(--surface); color: var(--ink); cursor: pointer;
  transition: background .12s ease, border-color .12s ease, color .12s ease;
  white-space: nowrap;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-ink); }
.btn[disabled] { opacity: .45; cursor: not-allowed; }
.btn[disabled]:hover { background: var(--surface); border-color: var(--line); }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
.seg .btn { border: 0; border-radius: 0; }
.seg .btn + .btn { border-left: 1px solid var(--line); }
select {
  font-family: var(--font-mono); font-size: .76rem; padding: 6px 8px;
  border: 1px solid var(--line); border-radius: 7px; background: var(--surface); cursor: pointer;
}

/* ---- stat strip ---- */
.stats {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
  gap: 1px; background: var(--line); border: 1px solid var(--line);
  border-radius: var(--radius); overflow: hidden; margin-bottom: 10px;
}
.stat { background: var(--surface); padding: 8px 10px; min-width: 0; }
.stat .k {
  display: block; font-family: var(--font-mono); font-size: .64rem; letter-spacing: .07em;
  text-transform: uppercase; color: var(--muted); margin-bottom: 2px; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
.stat .v {
  display: block; font-family: var(--font-mono); font-size: 1.02rem; font-weight: 600;
  letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stat .v small { font-size: .68rem; font-weight: 500; color: var(--muted); margin-left: 3px; }
.stat.is-ok .v { color: var(--run); }
.stat.is-bad .v { color: var(--danger); }

/* ---- composition bar ---- */
.comp { margin-bottom: 12px; }
.compbar {
  display: flex; height: 9px; border-radius: 999px; overflow: hidden;
  border: 1px solid var(--line); background: var(--surface);
}
.compbar span { display: block; height: 100%; min-width: 2px; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 7px; }
.legend b {
  display: inline-flex; align-items: center; gap: 5px;
  font-family: var(--font-mono); font-size: .69rem; font-weight: 500; color: var(--muted);
}
.legend i { width: 8px; height: 8px; border-radius: 2px; display: inline-block; }
.legend em { font-style: normal; color: var(--ink); }

/* ---- panes ---- */
.panes { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; align-items: start; }
.card {
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
  box-shadow: var(--shadow); overflow: hidden; display: flex; flex-direction: column; min-width: 0;
}
.card > header {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding: 7px 10px; border-bottom: 1px solid var(--line); background: var(--bg);
}
.tag {
  font-family: var(--font-mono); font-size: .69rem; color: var(--muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tag em { font-style: normal; color: var(--ink); }
.pill {
  font-family: var(--font-mono); font-size: .68rem; padding: 2px 8px; border-radius: 999px;
  border: 1px solid var(--line); color: var(--muted); background: var(--surface); white-space: nowrap;
}
.pill.ok { color: var(--run); background: var(--surface); border-color: var(--run); font-weight: 600; }
.pill.bad { color: var(--danger); background: var(--surface); border-color: var(--danger); font-weight: 600; }
.pill.warn { color: var(--warn); background: var(--surface); border-color: var(--warn); }
.spacer { margin-left: auto; }

/* ---- editor ---- */
:root { --paneh: clamp(300px, 46vh, 470px); }
.ed { position: relative; display: flex; height: var(--paneh); min-height: 0; background: var(--surface); }
.gutter {
  position: relative; overflow: hidden; flex: 0 0 auto; height: 100%;
  padding: 10px 8px 10px 10px; border-right: 1px solid var(--line);
  background: var(--bg); user-select: none;
}
.gutter .gi {
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6;
  color: var(--muted); text-align: right; white-space: pre; will-change: transform;
}
.gutter .gi b { display: block; font-weight: 400; }
.gutter .gi b.bad { color: var(--danger); font-weight: 700; }
textarea#src {
  flex: 1 1 auto; min-width: 0; display: block;
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.6;
  padding: 10px; border: 0; resize: none; background: transparent; color: var(--ink);
  height: 100%; tab-size: 2; white-space: pre; overflow: auto;
}
textarea#src:focus { outline: 0; }
.ed.focused { box-shadow: inset 0 0 0 2px var(--accent); }
.ed.drop { box-shadow: inset 0 0 0 2px var(--accent); background: var(--accent-soft); }

/* ---- error strip ---- */
.err {
  border-top: 1px solid var(--line); background: var(--surface);
  box-shadow: inset 3px 0 0 var(--danger); padding: 9px 10px 9px 13px;
}
.err .hd { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.err .where { font-family: var(--font-mono); font-size: .72rem; color: var(--danger); font-weight: 600; white-space: nowrap; }
.err .msg { font-size: .88rem; color: var(--ink); }
.err .hint { font-size: .82rem; color: var(--muted); margin-top: 3px; }
.err pre {
  font-family: var(--font-mono); font-size: 12px; line-height: 1.5; margin: 7px 0 0;
  padding: 7px 8px; background: var(--bg); border: 1px solid var(--line);
  border-radius: 6px; overflow-x: auto; color: var(--ink); white-space: pre;
}
.err pre .ln { color: var(--muted); }
.err pre .caret { color: var(--danger); font-weight: 700; }
.err .acts { margin-top: 8px; display: flex; gap: 6px; flex-wrap: wrap; }
.note { border-top: 1px solid var(--line); background: var(--warn-soft); padding: 7px 10px; font-size: .82rem; color: var(--ink); }
.note code { font-family: var(--font-mono); font-size: .78rem; color: var(--warn); }
.hintbar {
  border-top: 1px solid var(--line); padding: 7px 10px; font-size: .78rem; color: var(--muted);
  background: var(--surface);
}
kbd {
  font-family: var(--font-mono); font-size: .72rem; padding: 1px 5px; border: 1px solid var(--line);
  border-bottom-width: 2px; border-radius: 4px; background: var(--bg); color: var(--ink); white-space: nowrap;
}

/* ---- search ---- */
.searchwrap { position: relative; flex: 1 1 150px; min-width: 110px; display: flex; }
.searchwrap input {
  width: 100%; font-family: var(--font-mono); font-size: .78rem; padding: 5px 26px 5px 9px;
  border: 1px solid var(--line); border-radius: 7px; background: var(--surface); color: var(--ink);
}
.searchwrap input::placeholder { color: var(--muted); opacity: 1; }
.searchwrap input::-webkit-search-cancel-button,
.searchwrap input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
.xbtn {
  position: absolute; right: 3px; top: 50%; transform: translateY(-50%);
  width: 20px; height: 20px; line-height: 1; border: 0; border-radius: 50%;
  background: transparent; color: var(--muted); cursor: pointer; font-size: 15px;
}
.xbtn:hover { color: var(--ink); background: var(--accent-soft); }

/* ---- tree ---- */
.tree {
  height: var(--paneh); overflow: auto; padding: 6px 0 8px;
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55;
}
.tree.stale { opacity: .55; }
.row {
  display: flex; align-items: center; gap: 0; padding: 1px 12px 1px 0;
  cursor: pointer; border: 1px solid transparent; border-left: 0; border-right: 0;
  scroll-margin: 30px; min-width: min-content; white-space: nowrap; flex-wrap: nowrap;
}
.row > * { flex: 0 0 auto; }
.row:hover { background: var(--accent-soft); }
.row[aria-selected="true"] { background: var(--sel); border-color: var(--accent); }
.row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 0; }
.tw {
  position: relative; flex: 0 0 auto; width: 16px; height: 19px; margin-right: 2px; padding: 0;
  border: 0; background: transparent; color: var(--muted); cursor: pointer;
  font-size: 10px; line-height: 19px; text-align: center; border-radius: 3px;
}
/* widen the fold target without moving anything; on touch the rows grow too */
.tw::after { content: ''; position: absolute; left: -6px; right: -6px; top: 0; bottom: 0; }
@media (pointer: coarse) {
  .row { padding-top: 3px; padding-bottom: 3px; }
  .tw { height: 25px; line-height: 25px; }
}
.tw:hover { color: var(--accent-ink); background: var(--accent-soft); }
.tw svg { width: 9px; height: 9px; transition: transform .12s ease; vertical-align: middle; }
.row.open > .tw svg { transform: rotate(90deg); }
.tw.blank { visibility: hidden; }
.k { color: var(--ink); font-weight: 600; white-space: pre; }
.idx { color: var(--muted); white-space: pre; }
.colon { color: var(--muted); margin-right: 5px; }
.v { white-space: pre; overflow-wrap: normal; }
.v.s { color: var(--t-str); }
.v.n { color: var(--t-num); }
.v.b { color: var(--t-bool); }
.v.z { color: var(--t-null); font-style: italic; }
.v.o, .v.a { color: var(--muted); }
.count {
  font-size: .66rem; padding: 0 5px; margin-left: 6px; border-radius: 999px;
  white-space: nowrap; vertical-align: 1px;
}
.count.o { color: var(--t-obj); background: var(--t-obj-bg); }
.count.a { color: var(--t-arr); background: var(--t-arr-bg); }
.prev { color: var(--muted); margin-left: 6px; }
.copyhint {
  margin-left: 8px; font-size: .66rem; color: var(--accent-ink); opacity: 0;
  white-space: nowrap; flex: 0 0 auto;
}
.row:hover .copyhint, .row:focus-visible .copyhint { opacity: 1; }
mark { background: var(--mark); color: inherit; box-shadow: inset 0 -1px 0 var(--mark-edge); border-radius: 2px; padding: 0 1px; }
.empty { padding: 22px 14px; color: var(--muted); font-size: .84rem; font-family: var(--font-sans); text-align: center; }
.more { padding: 8px 14px; color: var(--muted); font-size: .72rem; }

/* ---- path bar ---- */
.pathbar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--line); background: var(--bg); flex-wrap: wrap; }
.pathbar input {
  flex: 1 1 130px; min-width: 90px; font-family: var(--font-mono); font-size: .76rem;
  padding: 5px 8px; border: 1px solid var(--line); border-radius: 7px;
  background: var(--surface); color: var(--ink);
}
.flash { font-family: var(--font-mono); font-size: .7rem; padding: 3px 8px; border-radius: 999px; white-space: nowrap; }
.flash { border: 1px solid transparent; font-weight: 600; }
.flash.ok { color: var(--run); background: var(--surface); border-color: var(--run); }
.flash.bad { color: var(--danger); background: var(--surface); border-color: var(--danger); }

footer { margin-top: 20px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); text-align: center; }
.sr { 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: 900px) {
  .panes { grid-template-columns: minmax(0, 1fr); }
  .grp + .grp { margin-left: 0; }
  :root { --paneh: clamp(260px, 40vh, 400px); }
}
@media (max-width: 480px) {
  .wrap { padding: 16px 10px 26px; }
  h1 { font-size: 1.2rem; }
  .btn { padding: 6px 8px; font-size: .72rem; }
  .stats { grid-template-columns: repeat(auto-fit, minmax(92px, 1fr)); }
  .stat .v { font-size: .92rem; }
  .tree, textarea#src, .gutter .gi { font-size: 12px; }
  .copyhint, .prev { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
  .btn:active { transform: none; }
}
</style>
</head>
<body>
<main class="wrap">

  <header class="top">
    <div class="titlerow">
      <h1>JSON Toolbox</h1>
      <span class="pill" id="verdict">checking…</span>
    </div>
    <p class="lede">
      Paste JSON on the left and it parses as you type: a foldable tree on the right,
      live size and node counts, one-click path copying, and search across every key and value.
      Broken JSON gets a plain sentence and a caret pointing at the exact character.
    </p>
    <div class="chips">
      <span class="chip">hand-written parser</span>
      <span class="chip">nothing leaves this tab</span>
      <span class="chip">zero dependencies</span>
    </div>
  </header>

  <div class="bar">
    <div class="grp">
      <span class="lbl">format</span>
      <div class="seg" role="group" aria-label="Formatting">
        <button class="btn" id="btnPretty" type="button" aria-pressed="false">pretty</button>
        <button class="btn" id="btnMinify" type="button" aria-pressed="false">minify</button>
      </div>
      <label class="lbl" for="indent">indent</label>
      <select id="indent">
        <option value="2" selected>2 spaces</option>
        <option value="4">4 spaces</option>
        <option value="tab">tab</option>
      </select>
    </div>
    <div class="grp">
      <span class="lbl">sample</span>
      <button class="btn" id="s-bakery" type="button">bakery</button>
      <button class="btn" id="s-playlist" type="button">playlist</button>
      <button class="btn" id="s-garden" type="button">greenhouse</button>
      <button class="btn" id="s-broken" type="button">broken</button>
      <button class="btn" id="btnCopyAll" type="button">copy all</button>
    </div>
  </div>

  <div class="stats" id="stats"></div>

  <div class="comp">
    <div class="compbar" id="compbar" role="img" aria-label="Share of values by type"></div>
    <div class="legend" id="legend"></div>
  </div>

  <div class="panes">

    <section class="card" aria-labelledby="lbl-in">
      <header>
        <span class="tag" id="lbl-in">input · <em id="lineCount">0 lines</em></span>
        <span class="pill spacer" id="fmtState">—</span>
      </header>
      <div class="ed" id="ed">
        <div class="gutter" id="gutter" aria-hidden="true"><div class="gi" id="gutterInner"></div></div>
        <textarea id="src" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
                  aria-label="JSON source text" aria-describedby="edHint"></textarea>
      </div>
      <div class="err" id="err" hidden></div>
      <div class="note" id="dupes" hidden></div>
      <p class="hintbar" id="edHint" style="margin:0">
        Edits parse live. Drop a <kbd>.json</kbd> file on this panel to load it.
      </p>
    </section>

    <section class="card" aria-labelledby="lbl-tree">
      <header>
        <span class="sr" id="lbl-tree">JSON tree</span>
        <div class="searchwrap">
          <input id="q" type="search" placeholder="search keys + values…" aria-label="Search keys and values" autocomplete="off">
          <button class="xbtn" id="qClear" type="button" aria-label="Clear search" hidden>&times;</button>
        </div>
        <button class="btn" id="btnExpand" type="button">expand</button>
        <button class="btn" id="btnCollapse" type="button">collapse</button>
        <span class="pill" id="qState" hidden></span>
      </header>
      <div class="tree" id="tree" role="tree" aria-label="JSON structure"></div>
      <div class="pathbar">
        <span class="lbl">path</span>
        <input id="path" readonly value="$" aria-label="Path of the selected row">
        <button class="btn" id="btnCopyPath" type="button">copy path</button>
        <button class="btn" id="btnCopyVal" type="button">copy value</button>
        <span class="flash" id="flash" hidden></span>
      </div>
      <p class="hintbar" style="margin:0">
        Click any row to copy its path. Keys: <kbd>↑</kbd><kbd>↓</kbd> move,
        <kbd>→</kbd><kbd>←</kbd> fold, <kbd>Enter</kbd> copy path, <kbd>v</kbd> copy value.
      </p>
    </section>

  </div>

  <p class="sr" role="status" aria-live="polite" id="live"></p>
  <footer>demo · sloprun.dev</footer>
</main>

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

  /* ---------------- platform theme bridge (standalone works 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')) {
      document.documentElement.setAttribute('data-theme', d.theme);
    }
  });

  var $ = function (id) { return document.getElementById(id); };
  var el = {
    src: $('src'), gutter: $('gutter'), gi: $('gutterInner'), ed: $('ed'),
    err: $('err'), dupes: $('dupes'), tree: $('tree'), stats: $('stats'),
    compbar: $('compbar'), legend: $('legend'), q: $('q'), qClear: $('qClear'),
    path: $('path'), flash: $('flash'), live: $('live'), verdict: $('verdict'),
    fmtState: $('fmtState'), lineCount: $('lineCount'), indent: $('indent')
  };

  /* ---------------- samples ---------------- */
  var SAMPLES = {};

  SAMPLES.bakery = [
    '{',
    '  "shop": {',
    '    "name": "Rye & Ramble",',
    '    "opened": 2016,',
    '    "neighbourhood": "Elm Street",',
    '    "wifi": true,',
    '    "phone": null,',
    '    "hours": {',
    '      "mon": "07:00-15:00",',
    '      "tue": "07:00-15:00",',
    '      "wed": "07:00-15:00",',
    '      "thu": "07:00-17:00",',
    '      "fri": "07:00-18:00",',
    '      "sat": "08:00-18:00",',
    '      "sun": "closed"',
    '    },',
    '    "tags": ["bakery", "coffee", "dog friendly", "cash or card"]',
    '  },',
    '  "menu": [',
    '    {',
    '      "id": "sourdough-large",',
    '      "name": "Country sourdough",',
    '      "price": 6.5,',
    '      "vegan": true,',
    '      "allergens": ["wheat"],',
    '      "stock": { "onHand": 12, "unit": "loaves", "bakedAt": "05:40" },',
    '      "ratings": [5, 4.5, 5, 4, 5]',
    '    },',
    '    {',
    '      "id": "cardamom-bun",',
    '      "name": "Cardamom bun",',
    '      "price": 3.75,',
    '      "vegan": false,',
    '      "allergens": ["wheat", "milk", "egg"],',
    '      "stock": { "onHand": 0, "unit": "buns", "bakedAt": "06:15" },',
    '      "ratings": [5, 5, 5, 4.5]',
    '    },',
    '    {',
    '      "id": "rye-sandwich",',
    '      "name": "Rye & cheese sandwich",',
    '      "price": 8,',
    '      "vegan": false,',
    '      "allergens": ["rye", "milk"],',
    '      "stock": { "onHand": 5, "unit": "boxes", "bakedAt": "07:10" },',
    '      "ratings": [4, 4.5, 3.5]',
    '    },',
    '    {',
    '      "id": "oat-cookie",',
    '      "name": "Oat & raisin cookie",',
    '      "price": 2.25,',
    '      "vegan": true,',
    '      "allergens": ["oats"],',
    '      "stock": { "onHand": 34, "unit": "cookies", "bakedAt": "06:50" },',
    '      "ratings": []',
    '    }',
    '  ],',
    '  "orders": [',
    '    {',
    '      "ref": "A-1042",',
    '      "placedAt": "2024-05-14T08:12:00Z",',
    '      "items": [',
    '        { "sku": "sourdough-large", "qty": 1 },',
    '        { "sku": "oat-cookie", "qty": 3 }',
    '      ],',
    '      "total": 13.25,',
    '      "paid": true,',
    '      "note": "slice it, please"',
    '    },',
    '    {',
    '      "ref": "A-1043",',
    '      "placedAt": "2024-05-14T08:31:00Z",',
    '      "items": [{ "sku": "cardamom-bun", "qty": 6 }],',
    '      "total": 22.5,',
    '      "paid": false,',
    '      "note": null',
    '    }',
    '  ],',
    '  "today": {',
    '    "loavesBaked": 214,',
    '    "wasteKg": 1.8,',
    '    "busiestHour": "08:00",',
    '    "specials": { "morning": "cinnamon knot", "afternoon": null }',
    '  }',
    '}'
  ].join('\n');

  SAMPLES.playlist = [
    '{',
    '  "playlist": "Kitchen table, 7am",',
    '  "shuffle": false,',
    '  "minutes": 42.6,',
    '  "owner": { "name": "Nadia", "since": 2011, "public": true },',
    '  "tracks": [',
    '    {',
    '      "title": "Paper Boats",',
    '      "artist": "Halfway Home",',
    '      "seconds": 214,',
    '      "year": 2019,',
    '      "moods": ["calm", "warm"],',
    '      "plays": { "week": 12, "total": 388 },',
    '      "liked": true',
    '    },',
    '    {',
    '      "title": "Slow Kettle",',
    '      "artist": "Marta Fen",',
    '      "seconds": 187,',
    '      "year": 2021,',
    '      "moods": ["calm"],',
    '      "plays": { "week": 3, "total": 51 },',
    '      "liked": false',
    '    },',
    '    {',
    '      "title": "Six Blue Windows",',
    '      "artist": "The Longer Way",',
    '      "seconds": 305,',
    '      "year": 2015,',
    '      "moods": ["dreamy", "warm", "rainy"],',
    '      "plays": { "week": 27, "total": 1204 },',
    '      "liked": true',
    '    },',
    '    {',
    '      "title": "Almost Awake",',
    '      "artist": "Halfway Home",',
    '      "seconds": 241,',
    '      "year": 2019,',
    '      "moods": [],',
    '      "plays": { "week": 0, "total": 9 },',
    '      "liked": null',
    '    }',
    '  ],',
    '  "lastPlayed": { "track": 2, "at": "2024-03-02T07:05:11Z", "device": "kitchen speaker" }',
    '}'
  ].join('\n');

  SAMPLES.garden = [
    '{',
    '  "greenhouse": "Back garden, bed 3",',
    '  "sensor": { "id": "gh-03", "battery": 0.62, "online": true, "firmware": "2.4.1" },',
    '  "readings": [',
    '    { "at": "06:00", "tempC": 14.2, "humidity": 0.81, "soil": 0.44, "lightLux": 120 },',
    '    { "at": "09:00", "tempC": 19.7, "humidity": 0.68, "soil": 0.41, "lightLux": 9800 },',
    '    { "at": "12:00", "tempC": 26.9, "humidity": 0.52, "soil": 0.33, "lightLux": 41200 },',
    '    { "at": "15:00", "tempC": 25.1, "humidity": 0.57, "soil": 0.29, "lightLux": 26400 },',
    '    { "at": "18:00", "tempC": 18.4, "humidity": 0.72, "soil": 0.27, "lightLux": 3100 }',
    '  ],',
    '  "plants": [',
    '    { "name": "Tomato", "variety": "Gardener\'s Delight", "planted": "2024-03-19", "fruiting": true, "notes": ["needs tying", "side shoots removed"] },',
    '    { "name": "Basil", "variety": "Genovese", "planted": "2024-04-02", "fruiting": false, "notes": [] },',
    '    { "name": "Chilli", "variety": "Padron", "planted": "2024-03-28", "fruiting": true, "notes": ["first pick next week"] }',
    '  ],',
    '  "watering": { "auto": true, "litresPerDay": 2.5, "lastRun": "18:30", "nextRun": null },',
    '  "alerts": []',
    '}'
  ].join('\n');

  SAMPLES.broken = [
    '{',
    '  "shop": "Rye & Ramble",',
    '  "open": true,',
    '  "menu": [',
    '    { "name": "Country sourdough", "price": 6.50 },',
    '    { "name": "Cardamom bun", "price": 3.75, },',
    '    { "name": "Oat cookie", "price": 2.25 }',
    '  ],',
    '  "note": \'ask about day-old bread\'',
    '}'
  ].join('\n');

  /* ---------------- the parser ----------------
     Hand-written so every failure can be explained in a plain sentence with an
     exact line, column and caret. Produces a node tree that keeps key order and
     the raw text of every number. ------------------------------------------- */

  function ParseError(msg, index, hint) {
    this.name = 'ParseError'; this.message = msg; this.index = index; this.hint = hint || '';
  }
  ParseError.prototype = Object.create(Error.prototype);

  function parse(text) {
    var i = 0, N = text.length;
    var lineStarts = [0], c;
    for (var k = 0; k < N; k++) { if (text.charCodeAt(k) === 10) lineStarts.push(k + 1); }

    function lineOf(idx) {
      var lo = 0, hi = lineStarts.length - 1;
      while (lo < hi) { var mid = (lo + hi + 1) >> 1; if (lineStarts[mid] <= idx) lo = mid; else hi = mid - 1; }
      return lo;
    }
    function fail(idx, msg, hint) {
      if (idx > N) idx = N;
      var e = new ParseError(msg, idx, hint);
      var ln = lineOf(idx);
      e.line = ln + 1;
      e.col = idx - lineStarts[ln] + 1;
      var end = (ln + 1 < lineStarts.length) ? lineStarts[ln + 1] - 1 : N;
      e.lineText = text.slice(lineStarts[ln], end).replace(/\r$/, '');
      throw e;
    }
    function show(idx) {
      if (idx >= N) return 'the end of the text';
      var ch = text[idx];
      if (ch === '\n') return 'a line break';
      if (ch === '\r') return 'a carriage return';
      if (ch === '\t') return 'a tab';
      if (ch === ' ') return 'a space';
      if (ch === '') return 'an invisible byte-order mark';
      if (text.charCodeAt(idx) < 32) return 'an invisible control character';
      return '`' + ch + '`';
    }
    function ws() {
      while (i < N) {
        c = text.charCodeAt(i);
        if (c === 32 || c === 9 || c === 10 || c === 13) { i++; continue; }
        if (c === 47) { /* / */
          fail(i, 'JSON does not allow comments.',
               'Delete the ' + (text[i + 1] === '*' ? '/* … */ block' : '// line') + ' — comments are a JavaScript feature, not a JSON one.');
        }
        break;
      }
    }

    function parseString(what) {
      var start = i;
      i++; /* opening quote */
      var out = '';
      while (true) {
        if (i >= N) fail(start, 'This ' + what + ' never closes.', 'Add a closing double quote at the end of the text that starts here.');
        var ch = text[i];
        if (ch === '"') { i++; break; }
        if (ch === '\n') fail(i, 'A ' + what + ' cannot run onto the next line.', 'Close the quote before the line break, or write the break as \\n inside the text.');
        if (ch === '\\') {
          var esc = text[i + 1];
          if (esc === undefined) fail(i, 'The text ends in the middle of an escape.', 'A backslash must be followed by one of " \\\\ / b f n r t or u.');
          if ('"\\/bfnrt'.indexOf(esc) >= 0) {
            out += ({ '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' })[esc];
            i += 2; continue;
          }
          if (esc === 'u') {
            var hex = text.slice(i + 2, i + 6);
            if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail(i, 'Broken \\u escape.', 'A \\u escape needs exactly four hex digits, like \\u00e9.');
            out += String.fromCharCode(parseInt(hex, 16));
            i += 6; continue;
          }
          fail(i + 1, 'Unknown escape `\\' + esc + '`.', 'Inside JSON text the allowed escapes are \\" \\\\ \\/ \\b \\f \\n \\r \\t and \\uXXXX.');
        }
        if (text.charCodeAt(i) < 32) fail(i, 'A control character sits inside this ' + what + '.', 'Write tabs and line breaks as \\t and \\n instead of pasting them raw.');
        out += ch; i++;
      }
      return { type: 'string', value: out, raw: text.slice(start, i), start: start, end: i, children: null };
    }

    function parseNumber() {
      var start = i;
      if (text[i] === '-') i++;
      var digitsStart = i;
      while (i < N && text[i] >= '0' && text[i] <= '9') i++;
      if (i === digitsStart) fail(i, 'A minus sign with no number after it.', 'Write something like -12 or -0.5.');
      if (text[digitsStart] === '0' && i - digitsStart > 1) {
        var lz = text.slice(digitsStart, i), lzFixed = lz.replace(/^0+/, '') || '0';
        fail(digitsStart, 'Numbers cannot have extra leading zeros.', 'Write ' + lzFixed + ' instead of ' + lz + '.');
      }
      if (text[i] === '.') {
        i++;
        var fs = i;
        while (i < N && text[i] >= '0' && text[i] <= '9') i++;
        if (i === fs) fail(i, 'A decimal point with no digits after it.', 'JSON needs a digit on both sides: write 1.0, not 1.');
      }
      if (text[i] === 'e' || text[i] === 'E') {
        i++;
        if (text[i] === '+' || text[i] === '-') i++;
        var es = i;
        while (i < N && text[i] >= '0' && text[i] <= '9') i++;
        if (i === es) fail(i, 'An exponent with no digits after it.', 'Write something like 1e6 or 2.5e-3.');
      }
      var raw = text.slice(start, i);
      return { type: 'number', value: Number(raw), raw: raw, start: start, end: i, children: null };
    }

    function parseWord() {
      var start = i;
      while (i < N && /[A-Za-z_$0-9]/.test(text[i])) i++;
      var w = text.slice(start, i);
      if (w === 'true' || w === 'false') {
        return { type: 'boolean', value: w === 'true', raw: w, start: start, end: i, children: null };
      }
      if (w === 'null') return { type: 'null', value: null, raw: w, start: start, end: i, children: null };
      var low = w.toLowerCase();
      if (low === 'true' || low === 'false' || low === 'null') {
        fail(start, '`' + w + '` must be written in lower case.', 'JSON only knows `true`, `false` and `null` — exactly like that.');
      }
      if (low === 'undefined' || low === 'nan' || low === 'infinity') {
        fail(start, 'JSON has no `' + w + '`.', 'Use `null` for a missing value; JSON numbers are always ordinary finite numbers.');
      }
      fail(start, 'Bare word `' + w + '` is not a JSON value.', 'Text has to be wrapped in double quotes: "' + w + '".');
    }

    function parseValue(depth) {
      if (depth > 400) fail(i, 'This structure nests too deeply to read.', 'JSON Toolbox stops at 400 levels — something is probably unbalanced.');
      ws();
      if (i >= N) fail(i, 'The text ends where a value should be.', 'A JSON document holds exactly one value: an object, array, text, number, true, false or null.');
      var ch = text[i];
      if (ch === '{') return parseObject(depth);
      if (ch === '[') return parseArray(depth);
      if (ch === '"') return parseString('piece of text');
      if (ch === '-' || (ch >= '0' && ch <= '9')) return parseNumber();
      if (/[A-Za-z_$]/.test(ch)) return parseWord();
      if (ch === '') fail(i, 'There is an invisible byte-order mark here.', 'Some editors bury a hidden BOM at the very start of a file — delete the first character and the rest parses fine.');
      if (ch === "'") fail(i, 'JSON text uses double quotes, not single ones.', "Swap the ' … ' around this value for \" … \".");
      if (ch === '.') fail(i, 'A number cannot start with a decimal point.', 'Write 0.5 rather than .5.');
      if (ch === '+') fail(i, 'A number cannot start with a plus sign.', 'Just write the digits: 12 rather than +12.');
      if (ch === '}' || ch === ']') fail(i, 'Found ' + show(i) + ' where a value should be.', 'Something was closed before anything was put inside it.');
      if (ch === ',') fail(i, 'Found a comma where a value should be.', 'Two commas in a row, or a comma before the first item.');
      fail(i, 'Found ' + show(i) + ' where a value should be.', 'Values are objects, arrays, quoted text, numbers, true, false or null.');
    }

    function parseObject(depth) {
      var start = i, startLine = lineOf(i) + 1;
      i++;
      var node = { type: 'object', children: [], start: start, end: -1, raw: null, value: null };
      var seen = Object.create(null), dupes = [];
      ws();
      if (text[i] === '}') { i++; node.end = i; node.dupes = dupes; return node; }
      while (true) {
        ws();
        if (i >= N) fail(i, 'This object is never closed.', 'The `{` on line ' + startLine + ' still needs a matching `}`.');
        if (text[i] === '}') fail(i, 'There is a comma just before this `}`.', 'JSON does not allow a trailing comma — delete the comma after the last property.');
        if (text[i] !== '"') {
          if (text[i] === "'") fail(i, 'Property names use double quotes, not single ones.', "Write \"name\" rather than 'name'.");
          if (/[A-Za-z_$]/.test(text[i])) {
            var wstart = i, j = i;
            while (j < N && /[A-Za-z_$0-9]/.test(text[j])) j++;
            fail(wstart, 'Property names must be quoted.', 'Write "' + text.slice(wstart, j) + '": … — unquoted names are JavaScript, not JSON.');
          }
          fail(i, 'Found ' + show(i) + ' where a property name should be.', 'Inside an object every entry looks like "name": value.');
        }
        var keyNode = parseString('property name');
        if (seen[keyNode.value]) { if (dupes.indexOf(keyNode.value) < 0) dupes.push(keyNode.value); }
        seen[keyNode.value] = true;
        ws();
        if (text[i] !== ':') {
          if (i >= N) fail(i, 'The text ends right after a property name.', 'Every property needs a value: "' + keyNode.value + '": something.');
          if (text[i] === ',' || text[i] === '}') fail(i, 'Property "' + keyNode.value + '" has no value.', 'Write "' + keyNode.value + '": value — a name on its own is not enough.');
          fail(i, 'Expected a `:` after "' + keyNode.value + '", found ' + show(i) + '.', 'The name and its value are separated by a colon.');
        }
        i++;
        var v = parseValue(depth + 1);
        v.key = keyNode.value;
        v.keyStart = keyNode.start;
        node.children.push(v);
        ws();
        if (text[i] === ',') { i++; continue; }
        if (text[i] === '}') { i++; node.end = i; node.dupes = dupes; return node; }
        if (i >= N) fail(i, 'This object is never closed.', 'The `{` on line ' + startLine + ' still needs a matching `}`.');
        if (text[i] === ']') fail(i, 'An object is being closed with `]`.', 'The `{` on line ' + startLine + ' has to end with `}`.');
        fail(i, 'Expected `,` or `}` after this value, found ' + show(i) + '.', 'Properties are separated by commas, and the last one is followed by `}`.');
      }
    }

    function parseArray(depth) {
      var start = i, startLine = lineOf(i) + 1;
      i++;
      var node = { type: 'array', children: [], start: start, end: -1, raw: null, value: null };
      ws();
      if (text[i] === ']') { i++; node.end = i; return node; }
      while (true) {
        ws();
        if (i >= N) fail(i, 'This list is never closed.', 'The `[` on line ' + startLine + ' still needs a matching `]`.');
        if (text[i] === ']') fail(i, 'There is a comma just before this `]`.', 'JSON does not allow a trailing comma — delete the comma after the last item.');
        var v = parseValue(depth + 1);
        v.index = node.children.length;
        node.children.push(v);
        ws();
        if (text[i] === ',') { i++; continue; }
        if (text[i] === ']') { i++; node.end = i; return node; }
        if (i >= N) fail(i, 'This list is never closed.', 'The `[` on line ' + startLine + ' still needs a matching `]`.');
        if (text[i] === '}') fail(i, 'A list is being closed with `}`.', 'The `[` on line ' + startLine + ' has to end with `]`.');
        fail(i, 'Expected `,` or `]` after this item, found ' + show(i) + '.', 'Items are separated by commas, and the last one is followed by `]`.');
      }
    }

    if (!text.trim()) {
      var e0 = new ParseError('Nothing to read yet.', 0, 'Paste some JSON, or pick one of the samples above.');
      e0.line = 1; e0.col = 1; e0.lineText = ''; e0.empty = true;
      throw e0;
    }
    var root = parseValue(0);
    ws();
    if (i < N) {
      fail(i, 'Extra content after the end of the document.',
           'A JSON file holds exactly one value. Everything from here on is left over — wrap the whole thing in `[ … ]` if you meant a list.');
    }
    return root;
  }

  /* ---------------- helpers ---------------- */
  var IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
  function esc(s) {
    return String(s).replace(/[&<>"]/g, function (c) {
      return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&quot;';
    });
  }
  function seg(key) { return IDENT.test(key) ? '.' + key : '[' + JSON.stringify(key) + ']'; }
  function childPath(path, child, idx) {
    return child.key !== undefined ? path + seg(child.key) : path + '[' + idx + ']';
  }
  function bytesOf(s) {
    if (window.TextEncoder) { try { return new TextEncoder().encode(s).length; } catch (e) {} }
    return unescape(encodeURIComponent(s)).length;
  }
  function fmtBytes(n) {
    if (n < 1024) return { v: String(n), u: 'B' };
    if (n < 1024 * 1024) return { v: (n / 1024).toFixed(n < 10240 ? 1 : 0), u: 'KB' };
    return { v: (n / 1048576).toFixed(1), u: 'MB' };
  }
  function group(n) { return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); }
  function clip(s, max) { return s.length > max ? s.slice(0, max - 1) + '…' : s; }

  /* serialise our own node tree — no JSON.parse anywhere in the pipeline */
  function ser(node, ind, cur) {
    var nl = ind ? '\n' : '', pad = cur + ind, close = ind ? cur : '';
    if (node.type === 'object') {
      if (!node.children.length) return '{}';
      var parts = node.children.map(function (c) {
        return pad + JSON.stringify(c.key) + ':' + (ind ? ' ' : '') + ser(c, ind, pad);
      });
      return '{' + nl + parts.join(',' + nl) + nl + close + '}';
    }
    if (node.type === 'array') {
      if (!node.children.length) return '[]';
      var items = node.children.map(function (c) { return pad + ser(c, ind, pad); });
      return '[' + nl + items.join(',' + nl) + nl + close + ']';
    }
    if (node.type === 'string') return JSON.stringify(node.value);
    return node.raw;
  }
  function indentStr() {
    var v = el.indent.value;
    return v === 'tab' ? '\t' : v === '4' ? '    ' : '  ';
  }

  /* ---------------- state ---------------- */
  var S = {
    root: null, err: null, dupes: [], collapsed: Object.create(null),
    qCollapsed: Object.create(null),   /* folds made while a search is active */
    sel: '$', rows: [], q: '', stale: false, matches: 0
  };

  function scalarText(node) {
    if (node.type === 'string') return node.value;
    return node.raw;
  }
  function preview(node) {
    var bits = [], i, c;
    if (node.type === 'object') {
      for (i = 0; i < node.children.length && i < 3; i++) {
        c = node.children[i];
        bits.push(c.key + ': ' + (c.type === 'object' ? '{…}' : c.type === 'array' ? '[…]' : clip(c.type === 'string' ? '"' + c.value + '"' : c.raw, 16)));
      }
      return '{ ' + bits.join(', ') + (node.children.length > 3 ? ', …' : '') + ' }';
    }
    for (i = 0; i < node.children.length && i < 4; i++) {
      c = node.children[i];
      bits.push(c.type === 'object' ? '{…}' : c.type === 'array' ? '[…]' : clip(c.type === 'string' ? '"' + c.value + '"' : c.raw, 14));
    }
    return '[ ' + bits.join(', ') + (node.children.length > 4 ? ', …' : '') + ' ]';
  }

  /* ---------------- stats ---------------- */
  var TYPE_ORDER = ['object', 'array', 'string', 'number', 'boolean', 'null'];
  var TYPE_VAR = { object: '--t-obj', array: '--t-arr', string: '--t-str', number: '--t-num', boolean: '--t-bool', 'null': '--t-null' };
  var TYPE_LABEL = { object: 'objects', array: 'lists', string: 'text', number: 'numbers', boolean: 'true/false', 'null': 'null' };

  function stats(root, text) {
    var counts = { object: 0, array: 0, string: 0, number: 0, boolean: 0, 'null': 0 };
    var nodes = 0, keys = 0, depth = 0, longest = 0, dupes = [];
    (function walk(n, d) {
      nodes++; counts[n.type]++;
      if (d > depth) depth = d;
      if (n.key !== undefined) keys++;
      if (n.children) {
        if (n.type === 'array' && n.children.length > longest) longest = n.children.length;
        if (n.dupes && n.dupes.length) { n.dupes.forEach(function (k) { if (dupes.indexOf(k) < 0) dupes.push(k); }); }
        for (var i = 0; i < n.children.length; i++) walk(n.children[i], d + 1);
      }
    })(root, 1);
    var min = ser(root, '', ''), pretty = ser(root, indentStr(), '');
    var b = bytesOf(text), mb = bytesOf(min);
    return {
      counts: counts, nodes: nodes, keys: keys, depth: depth, longest: longest,
      dupes: dupes, min: min, pretty: pretty, bytes: b, minBytes: mb,
      saved: b > 0 ? Math.max(0, Math.round((1 - mb / b) * 100)) : 0
    };
  }

  function tile(k, v, u, cls) {
    return '<div class="stat' + (cls ? ' ' + cls : '') + '"><span class="k">' + esc(k) + '</span>' +
           '<span class="v">' + esc(v) + (u ? '<small>' + esc(u) + '</small>' : '') + '</span></div>';
  }

  function renderStats(st, text, blank) {
    if (!st) {
      var fb = fmtBytes(bytesOf(text));
      el.stats.innerHTML = tile('size', fb.v, fb.u) + tile('lines', group(text.split('\n').length)) +
        tile('nodes', '—') + tile('keys', '—') + tile('depth', '—') +
        (blank ? tile('parse', 'waiting') : tile('parse', 'failed', '', 'is-bad'));
      el.compbar.innerHTML = ''; el.legend.innerHTML = '';
      document.querySelector('.comp').hidden = true;
      return;
    }
    var a = fmtBytes(st.bytes), m = fmtBytes(st.minBytes);
    el.stats.innerHTML =
      tile('size', a.v, a.u) +
      tile('minified', m.v, m.u) +
      tile('saved', st.saved + '%', '') +
      tile('nodes', group(st.nodes)) +
      tile('keys', group(st.keys)) +
      tile('max depth', String(st.depth));

    var total = st.nodes || 1, barHtml = '', legHtml = '';
    TYPE_ORDER.forEach(function (t) {
      var n = st.counts[t];
      if (!n) return;
      var pct = (n / total) * 100;
      barHtml += '<span style="width:' + pct.toFixed(2) + '%;background:var(' + TYPE_VAR[t] + ')" title="' +
                 esc(n + ' ' + TYPE_LABEL[t]) + '"></span>';
      legHtml += '<b><i style="background:var(' + TYPE_VAR[t] + ')"></i>' + esc(TYPE_LABEL[t]) +
                 ' <em>' + group(n) + '</em></b>';
    });
    el.compbar.innerHTML = barHtml;
    el.legend.innerHTML = legHtml;
    document.querySelector('.comp').hidden = false;
  }

  /* ---------------- gutter + editor chrome ---------------- */
  function renderGutter(text, badLine) {
    var total = text.split('\n').length;
    var lines = Math.min(total, 20000);   /* numbering stops here; the count stays honest */
    var out = '';
    for (var i = 1; i <= lines; i++) out += '<b' + (i === badLine ? ' class="bad"' : '') + '>' + i + '</b>';
    el.gi.innerHTML = out;
    el.gi.style.transform = 'translateY(' + (-el.src.scrollTop) + 'px)';
    el.lineCount.textContent = group(total) + (total === 1 ? ' line' : ' lines');
  }
  el.src.addEventListener('scroll', function () {
    el.gi.style.transform = 'translateY(' + (-el.src.scrollTop) + 'px)';
  });
  el.src.addEventListener('focus', function () { el.ed.classList.add('focused'); });
  el.src.addEventListener('blur', function () { el.ed.classList.remove('focused'); });

  /* ---------------- error panel ---------------- */
  function renderError(e, text) {
    var lineText = e.lineText || '', col = e.col || 1;
    /* expand tabs so the caret lands under the right character */
    var expanded = '', vcol = col;
    for (var i = 0; i < lineText.length; i++) {
      if (lineText[i] === '\t') { var add = 2 - (expanded.length % 2); expanded += '  '.slice(0, add); if (i < col - 1) vcol += add - 1; }
      else expanded += lineText[i];
    }
    var from = 0;
    if (vcol > 60) { from = vcol - 45; expanded = '…' + expanded.slice(from); vcol = vcol - from + 1; }
    expanded = clip(expanded, 90);
    var num = String(e.line || 1), gut = num + ' │ ';
    var caret = new Array(gut.length + vcol).join(' ') + '^';
    el.err.innerHTML =
      '<div class="hd">' +
      (e.empty ? '' : '<span class="where">line ' + esc(num) + ', col ' + esc(String(col)) + '</span>') +
      '<span class="msg">' + esc(e.message) + '</span></div>' +
      (e.hint ? '<div class="hint">' + esc(e.hint) + '</div>' : '') +
      (e.empty ? '' :
        '<pre><span class="ln">' + esc(gut) + '</span>' + esc(expanded) + '\n' +
        '<span class="caret">' + esc(caret) + '</span></pre>' +
        '<div class="acts"><button class="btn" id="btnJump" type="button">jump to it</button></div>');
    el.err.hidden = false;
    var jump = document.getElementById('btnJump');
    if (jump) jump.addEventListener('click', function () {
      el.src.focus();
      var at = Math.min(e.index, el.src.value.length);
      try { el.src.setSelectionRange(at, Math.min(at + 1, el.src.value.length)); } catch (err) {}
      el.src.scrollTop = Math.max(0, ((e.line || 1) - 1) * 20 - el.src.clientHeight / 2);
      el.gi.style.transform = 'translateY(' + (-el.src.scrollTop) + 'px)';
    });
  }

  /* ---------------- tree ---------------- */
  var CHEV = '<svg viewBox="0 0 8 10" aria-hidden="true"><path d="M1 1l5 4-5 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
  var MAX_ROWS = 2500;

  function matchSets(root, q) {
    var keep = Object.create(null), hit = Object.create(null), n = 0;
    (function walk(node, path, parents, inside) {
      var self = [];
      if (node.key !== undefined && node.key.toLowerCase().indexOf(q) >= 0) self.push('key');
      if (!node.children && scalarText(node).toLowerCase().indexOf(q) >= 0) self.push('value');
      var mine = self.length > 0;
      if (mine) {
        n++; hit[path] = self;
        for (var p = 0; p < parents.length; p++) keep[parents[p]] = true;
      }
      /* a match keeps its whole subtree, so an opened branch is never empty */
      if (mine || inside) keep[path] = true;
      if (node.children) {
        var next = parents.concat([path]);
        for (var i = 0; i < node.children.length; i++) {
          walk(node.children[i], childPath(path, node.children[i], i), next, mine || inside);
        }
      }
    })(root, '$', [], false);
    return { keep: keep, hit: hit, n: n };
  }

  function buildRows() {
    var rows = [], q = S.q, ms = null;
    S.matches = 0;
    if (!S.root) { S.rows = rows; return { rows: rows, matches: 0 }; }
    if (q) ms = matchSets(S.root, q);
    (function push(node, path, depth) {
      if (ms && !ms.keep[path]) return;
      var kids = node.children && node.children.length;
      var open = ms ? !S.qCollapsed[path] : !S.collapsed[path];
      rows.push({
        node: node, path: path, depth: depth, kids: !!kids, open: !!(kids && open),
        hit: ms ? (ms.hit[path] || null) : null
      });
      if (kids && open) {
        for (var i = 0; i < node.children.length; i++) {
          push(node.children[i], childPath(path, node.children[i], i), depth + 1);
        }
      }
    })(S.root, '$', 0);
    S.rows = rows;
    S.matches = ms ? ms.n : 0;
    return { rows: rows, matches: S.matches };
  }

  function markup(text, q, hitting) {
    var safe = esc(text);
    if (!q || !hitting) return safe;
    var lower = text.toLowerCase(), out = '', at = 0, k;
    while ((k = lower.indexOf(q, at)) >= 0) {
      out += esc(text.slice(at, k)) + '<mark>' + esc(text.slice(k, k + q.length)) + '</mark>';
      at = k + q.length;
      if (q.length === 0) break;
    }
    out += esc(text.slice(at));
    return out || safe;
  }

  function renderTree() {
    var built = buildRows(), rows = built.rows;
    el.tree.innerHTML = '';
    if (!rows.length) {
      el.tree.innerHTML = '<p class="empty">' + (
        S.q ? 'No key or value contains <b class="mono">' + esc(S.q) + '</b>.'
            : (S.err && !S.err.empty) ? 'Fix the problem on the left and the tree shows up here.'
            : 'Paste some JSON on the left, or pick a sample above.') + '</p>';
      updateSearchState(built.matches);
      return;
    }
    var frag = document.createDocumentFragment();
    var shown = Math.min(rows.length, MAX_ROWS);
    for (var i = 0; i < shown; i++) {
      var r = rows[i], node = r.node, html = '';
      var div = document.createElement('div');
      div.className = 'row' + (r.open ? ' open' : '');
      div.setAttribute('role', 'treeitem');
      div.setAttribute('aria-level', String(r.depth + 1));
      div.setAttribute('tabindex', '-1');
      div.dataset.i = String(i);
      div.style.paddingLeft = (8 + r.depth * 14) + 'px';
      if (r.kids) div.setAttribute('aria-expanded', r.open ? 'true' : 'false');
      if (r.path === S.sel) div.setAttribute('aria-selected', 'true');

      html += r.kids
        ? '<button class="tw" type="button" tabindex="-1" aria-hidden="true">' + CHEV + '</button>'
        : '<span class="tw blank" aria-hidden="true"></span>';

      var hitKey = r.hit && r.hit.indexOf('key') >= 0;
      var hitVal = r.hit && r.hit.indexOf('value') >= 0;

      if (node.key !== undefined) {
        html += '<span class="k">' + markup(node.key, S.q, hitKey) + '</span><span class="colon">:</span>';
      } else if (node.index !== undefined) {
        html += '<span class="idx">' + node.index + '</span><span class="colon">:</span>';
      }

      if (node.type === 'object' || node.type === 'array') {
        var isObj = node.type === 'object';
        var n = node.children.length;
        var label = n === 0 ? (isObj ? '{ }' : '[ ]') : (isObj ? '{' : '[');
        html += '<span class="v ' + (isObj ? 'o' : 'a') + '">' + label + '</span>';
        if (n) {
          html += '<span class="count ' + (isObj ? 'o' : 'a') + '">' + n + (isObj ? (n === 1 ? ' key' : ' keys') : (n === 1 ? ' item' : ' items')) + '</span>';
          if (!r.open) html += '<span class="prev">' + esc(clip(preview(node), 58)) + '</span>';
        }
      } else if (node.type === 'string') {
        var t = clip(node.value, 96);
        html += '<span class="v s">"' + markup(t, S.q, hitVal) + '"</span>';
      } else if (node.type === 'number') {
        html += '<span class="v n">' + markup(node.raw, S.q, hitVal) + '</span>';
      } else if (node.type === 'boolean') {
        html += '<span class="v b">' + markup(node.raw, S.q, hitVal) + '</span>';
      } else {
        html += '<span class="v z">' + markup(node.raw, S.q, hitVal) + '</span>';
      }
      html += '<span class="copyhint">click = copy path</span>';
      div.innerHTML = html;
      frag.appendChild(div);
    }
    if (rows.length > shown) {
      var more = document.createElement('p');
      more.className = 'more';
      more.textContent = '… ' + group(rows.length - shown) + ' more rows hidden — collapse a branch or search to narrow it down.';
      frag.appendChild(more);
    }
    el.tree.appendChild(frag);
    var selEl = el.tree.querySelector('[aria-selected="true"]');
    (selEl || el.tree.querySelector('.row')).setAttribute('tabindex', '0');
    updateSearchState(built.matches);
  }

  function updateSearchState(matches) {
    /* keyed off the box, not the query: whitespace-only text still needs a way out */
    el.qClear.hidden = !el.q.value;
    if (!S.q) return;
    el.live.textContent = matches + (matches === 1 ? ' match' : ' matches');
  }

  /* ---------------- selection, copying ---------------- */
  var flashTimer = 0;
  function flash(msg, kind) {
    el.flash.textContent = msg;
    el.flash.className = 'flash ' + (kind === 'bad' ? 'bad' : 'ok');
    el.flash.hidden = false;
    el.live.textContent = msg;
    if (flashTimer) clearTimeout(flashTimer);
    flashTimer = setTimeout(function () { el.flash.hidden = true; }, 1800);
  }
  function legacyCopy(text) {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.setAttribute('readonly', '');
    ta.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0';
    document.body.appendChild(ta);
    ta.select();
    var ok = false;
    try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
    document.body.removeChild(ta);
    return ok;
  }
  function copy(text, label) {
    var done = function () { flash('copied ' + label, 'ok'); };
    var fallback = function () {
      if (legacyCopy(text)) done();
      else { el.path.focus(); el.path.select(); flash('copy blocked — select and copy', 'bad'); }
    };
    if (navigator.clipboard && navigator.clipboard.writeText) {
      try {
        navigator.clipboard.writeText(text).then(done, fallback);
        return;
      } catch (e) { /* fall through */ }
    }
    fallback();
  }

  function rowByPath(path) {
    for (var i = 0; i < S.rows.length; i++) if (S.rows[i].path === path) return i;
    return -1;
  }
  function domRow(i) { return el.tree.querySelector('.row[data-i="' + i + '"]'); }

  function select(i, focus) {
    var r = S.rows[i];
    if (!r) return;
    S.sel = r.path;
    var prev = el.tree.querySelector('[aria-selected="true"]');
    if (prev) prev.removeAttribute('aria-selected');
    /* exactly one row is ever in the tab order */
    var prevTab = el.tree.querySelector('.row[tabindex="0"]');
    if (prevTab) prevTab.setAttribute('tabindex', '-1');
    var node = domRow(i);
    if (node) {
      node.setAttribute('aria-selected', 'true');
      node.setAttribute('tabindex', '0');
      if (focus) node.focus();
    } else {
      /* the selected row sits past the render cap — keep exactly one row tabbable
         anyway, or the whole tree drops out of the tab order */
      var first = el.tree.querySelector('.row');
      if (first) first.setAttribute('tabindex', '0');
    }
    el.path.value = r.path;
  }

  function valueText(i) {
    var r = S.rows[i];
    if (!r) return '';
    return ser(r.node, indentStr(), '');
  }

  function toggle(i, force) {
    var r = S.rows[i];
    if (!r || !r.kids) return;
    var map = S.q ? S.qCollapsed : S.collapsed;
    var open = force === undefined ? !r.open : force;
    if (open) delete map[r.path]; else map[r.path] = true;
    var keep = S.sel;
    /* read focus BEFORE the re-render blows the focused row away */
    var hadFocus = !!(document.activeElement && document.activeElement.classList &&
                      document.activeElement.classList.contains('row'));
    renderTree();
    var at = rowByPath(keep);
    /* collapsing a branch can swallow the selected row — land on the branch itself */
    if (at < 0) at = rowByPath(r.path);
    if (at >= 0) select(at, hadFocus);
  }

  /* ---------------- tree events ---------------- */
  el.tree.addEventListener('click', function (e) {
    var row = e.target.closest ? e.target.closest('.row') : null;
    if (!row) return;
    var i = Number(row.dataset.i);
    var tw = e.target.closest ? e.target.closest('.tw') : null;
    if (tw) { select(i, false); toggle(i); return; }
    select(i, false);
    copy(S.rows[i].path, S.rows[i].path);
  });

  el.tree.addEventListener('keydown', function (e) {
    var row = e.target.closest ? e.target.closest('.row') : null;
    if (!row) return;
    var i = Number(row.dataset.i), r = S.rows[i], last = Math.min(S.rows.length, MAX_ROWS) - 1;
    if (!r) return;
    if (e.ctrlKey || e.metaKey || e.altKey) return;   /* leave Ctrl/Cmd shortcuts alone */
    var k = e.key;
    if (k === 'ArrowDown') { e.preventDefault(); if (i < last) select(i + 1, true); }
    else if (k === 'ArrowUp') { e.preventDefault(); if (i > 0) select(i - 1, true); }
    else if (k === 'ArrowRight') {
      e.preventDefault();
      if (r.kids && !r.open) toggle(i, true);
      else if (i < last) select(i + 1, true);
    } else if (k === 'ArrowLeft') {
      e.preventDefault();
      if (r.kids && r.open) toggle(i, false);
      else {
        for (var j = i - 1; j >= 0; j--) { if (S.rows[j].depth < r.depth) { select(j, true); break; } }
      }
    } else if (k === 'Home') { e.preventDefault(); select(0, true); }
    else if (k === 'End') { e.preventDefault(); select(last, true); }
    else if (k === 'Enter' || k === ' ') { e.preventDefault(); copy(r.path, r.path); }
    else if (k === 'v' || k === 'V') { e.preventDefault(); copy(valueText(i), 'value of ' + r.path); }
  });

  /* ---------------- folding helpers ---------------- */
  function clearSearch() {
    el.q.value = ''; S.q = ''; S.qCollapsed = Object.create(null);
  }
  function resetFolds() {
    S.collapsed = Object.create(null);
    if (!S.root) return;
    (function walk(n, path, depth) {
      if (!n.children || !n.children.length) return;
      if (depth >= 2) S.collapsed[path] = true;
      for (var i = 0; i < n.children.length; i++) walk(n.children[i], childPath(path, n.children[i], i), depth + 1);
    })(S.root, '$', 0);
  }
  /* folds the map the tree is actually reading right now, so a live search survives */
  function setAllFolds(collapse) {
    var map = Object.create(null);
    if (collapse && S.root) {
      (function walk(n, path, depth) {
        if (!n.children || !n.children.length) return;
        if (depth >= 1) map[path] = true;
        for (var i = 0; i < n.children.length; i++) walk(n.children[i], childPath(path, n.children[i], i), depth + 1);
      })(S.root, '$', 0);
    }
    if (S.q) S.qCollapsed = map; else S.collapsed = map;
  }

  /* ---------------- the main pass ---------------- */
  var lastPretty = '', lastMin = '';

  function run() {
    var text = el.src.value, badLine = 0;
    var root = null, e = null;
    try {
      root = parse(text);
    } catch (ex) {
      e = (ex instanceof ParseError) ? ex : (function () {
        var g = new ParseError('This text could not be read as JSON.', 0, 'Try one of the samples above to see a working document.');
        g.line = 1; g.col = 1; g.lineText = ''; g.empty = true;
        return g;
      })();
    }

    if (root) {
      S.root = root; S.err = null; S.stale = false;
      var st = stats(root, text);
      lastPretty = st.pretty; lastMin = st.min;
      renderStats(st, text);
      el.err.hidden = true;
      el.verdict.className = 'pill ok';
      el.verdict.textContent = 'parsed ok · ' + group(st.nodes) + ' nodes';
      el.tree.classList.remove('stale');

      if (st.dupes.length) {
        el.dupes.hidden = false;
        el.dupes.innerHTML = 'Duplicate key' + (st.dupes.length > 1 ? 's' : '') + ' — only the last copy survives in most readers: ' +
          st.dupes.slice(0, 4).map(function (k) { return '<code>' + esc(k) + '</code>'; }).join(', ') +
          (st.dupes.length > 4 ? ' <code>+' + (st.dupes.length - 4) + ' more</code>' : '');
      } else { el.dupes.hidden = true; }

      var trimmed = text.replace(/^\s+|\s+$/g, '');
      var label = trimmed === st.pretty ? 'pretty · ' + (el.indent.value === 'tab' ? 'tab' : el.indent.value + ' spaces')
                : trimmed === st.min ? 'minified' : 'custom spacing';
      el.fmtState.textContent = label;
      el.fmtState.className = 'pill spacer';
      el.fmtState.hidden = false;
      document.getElementById('btnPretty').setAttribute('aria-pressed', trimmed === st.pretty ? 'true' : 'false');
      document.getElementById('btnMinify').setAttribute('aria-pressed', trimmed === st.min ? 'true' : 'false');
    } else {
      S.err = e;
      badLine = e.empty ? 0 : e.line;
      renderError(e, text);
      renderStats(null, text, !!e.empty);
      el.dupes.hidden = true;
      el.verdict.className = 'pill bad';
      el.verdict.textContent = e.empty ? 'empty' : 'error on line ' + e.line;
      el.fmtState.textContent = '';
      el.fmtState.className = 'pill spacer';
      el.fmtState.hidden = true;
      document.getElementById('btnPretty').setAttribute('aria-pressed', 'false');
      document.getElementById('btnMinify').setAttribute('aria-pressed', 'false');
      if (S.root) { S.stale = true; el.tree.classList.add('stale'); }
      else { S.stale = false; el.tree.classList.remove('stale'); }
    }

    renderGutter(text, badLine);
    refreshTree();
  }

  function countMatches() { return S.matches || 0; }

  function load(text) {
    text = String(text).replace(/^\uFEFF/, '');   /* editors love to leave a BOM behind */
    el.src.value = text;
    S.root = null; S.sel = '$';
    el.q.value = ''; S.q = ''; S.qCollapsed = Object.create(null);
    el.src.scrollTop = 0;
    var pre = null;
    try { pre = parse(text); } catch (e) { pre = null; }
    S.root = pre;
    resetFolds();
    if (!pre) S.root = null;
    run();
  }

  function refreshTree() {
    renderTree();
    var at = rowByPath(S.sel);
    if (at >= 0) select(at, false);
    else if (S.rows.length) select(0, false);
    else el.path.value = S.sel;
    var qs = document.getElementById('qState');
    if (S.q) {
      qs.hidden = false;
      var n = countMatches();
      qs.textContent = n ? n + (n === 1 ? ' hit · branches opened' : ' hits · branches opened') : 'no hits';
      qs.className = 'pill' + (n ? '' : ' bad');
    } else if (S.stale) {
      qs.hidden = false;
      qs.textContent = 'last good parse';
      qs.className = 'pill warn';
    } else { qs.hidden = true; }
  }

  /* ---------------- controls ---------------- */
  function reformat(kind) {
    /* S.root can be a *stale* tree from the last good parse — reformatting from it
       would silently overwrite whatever the editor holds now. Refuse instead. */
    if (!S.root || S.err) { flash(S.err && S.err.empty ? 'nothing to format yet' : 'fix the error first', 'bad'); return; }
    var before = el.src.value;
    var out = kind === 'min' ? ser(S.root, '', '') : ser(S.root, indentStr(), '');
    if (out === before.replace(/^\s+|\s+$/g, '')) { flash('already ' + (kind === 'min' ? 'minified' : 'pretty'), 'ok'); return; }
    el.src.value = out;
    el.src.scrollTop = 0;
    run();
    flash(kind === 'min' ? 'minified' : 'prettified', 'ok');
  }
  document.getElementById('btnPretty').addEventListener('click', function () { reformat('pretty'); });
  document.getElementById('btnMinify').addEventListener('click', function () { reformat('min'); });
  el.indent.addEventListener('change', function () {
    if (S.root && el.src.value.replace(/^\s+|\s+$/g, '') === lastPretty) reformat('pretty');
    else run();
  });

  document.getElementById('s-bakery').addEventListener('click', function () { load(SAMPLES.bakery); flash('loaded bakery sample', 'ok'); });
  document.getElementById('s-playlist').addEventListener('click', function () { load(SAMPLES.playlist); flash('loaded playlist sample', 'ok'); });
  document.getElementById('s-garden').addEventListener('click', function () { load(SAMPLES.garden); flash('loaded greenhouse sample', 'ok'); });
  document.getElementById('s-broken').addEventListener('click', function () {
    load(SAMPLES.broken);
    el.live.textContent = 'loaded a document with a deliberate mistake';
  });
  document.getElementById('btnCopyAll').addEventListener('click', function () {
    copy(el.src.value, 'the whole document');
  });

  document.getElementById('btnExpand').addEventListener('click', function () {
    setAllFolds(false); refreshTree();
  });
  document.getElementById('btnCollapse').addEventListener('click', function () {
    setAllFolds(true); refreshTree();
  });
  document.getElementById('btnCopyPath').addEventListener('click', function () {
    copy(el.path.value, el.path.value);
  });
  document.getElementById('btnCopyVal').addEventListener('click', function () {
    var at = rowByPath(S.sel);
    if (at < 0) { flash('pick a row first', 'bad'); return; }
    copy(valueText(at), 'value of ' + S.sel);
  });

  /* search */
  var qTimer = 0;
  el.q.addEventListener('input', function () {
    if (qTimer) clearTimeout(qTimer);
    qTimer = setTimeout(function () {
      var next = el.q.value.trim().toLowerCase();
      if (next !== S.q) { S.q = next; S.qCollapsed = Object.create(null); }
      refreshTree();
    }, 90);
  });
  el.q.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && el.q.value) { e.preventDefault(); clearSearch(); refreshTree(); }
  });
  el.qClear.addEventListener('click', function () {
    clearSearch(); refreshTree(); el.q.focus();
  });

  /* typing */
  var tTimer = 0;
  el.src.addEventListener('input', function () {
    if (tTimer) clearTimeout(tTimer);
    tTimer = setTimeout(run, 130);
  });

  /* drop a file (read locally — nothing is uploaded anywhere) */
  function isFileDrag(e) {
    var dt = e.dataTransfer;
    if (!dt) return false;
    if (dt.files && dt.files.length) return true;
    var types = dt.types;
    if (!types) return false;
    for (var i = 0; i < types.length; i++) if (types[i] === 'Files') return true;
    return false;
  }
  ['dragenter', 'dragover'].forEach(function (t) {
    el.ed.addEventListener(t, function (e) {
      if (!isFileDrag(e)) return;          /* a plain text drag belongs to the textarea */
      e.preventDefault(); el.ed.classList.add('drop');
    });
  });
  ['dragleave', 'dragend'].forEach(function (t) {
    el.ed.addEventListener(t, function () { el.ed.classList.remove('drop'); });
  });
  function loadFile(f) {
    if (!f) return;
    if (f.size > 4 * 1024 * 1024) { flash('that file is over 4 MB', 'bad'); return; }
    var rd = new FileReader();
    rd.onload = function () { load(String(rd.result)); flash('loaded ' + f.name, 'ok'); };
    rd.onerror = function () { flash('could not read that file', 'bad'); };
    rd.readAsText(f);
  }
  /* one handler for the whole document: a *file* drop that misses the editor would
     otherwise make the browser navigate to it and throw the demo away. Text drags are
     left alone so you can still drag a snippet straight into the editor. */
  document.addEventListener('dragover', function (e) {
    if (isFileDrag(e)) e.preventDefault();
  });
  document.addEventListener('drop', function (e) {
    el.ed.classList.remove('drop');
    if (!isFileDrag(e)) return;
    e.preventDefault();
    loadFile(e.dataTransfer.files && e.dataTransfer.files[0]);
  });

  /* "/" jumps to search when you are not typing in a field */
  document.addEventListener('keydown', function (e) {
    if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
    var t = e.target, tag = t && t.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
    e.preventDefault();
    el.q.focus();
    el.q.select();
  });

  /* ---------------- go ---------------- */
  load(SAMPLES.bakery);
})();
</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