
// studio-panels.jsx  ·  Studio Kit  (portable copy of preview/tweaks-panel.jsx)
// Reusable Tweaks / Inspector panel shell + form-control helpers.
// DS-AGNOSTIC: the panel surface is a fixed light glass in BOTH themes by
// design; only the accent (--studio-accent) is themeable. Reads window.StudioKit
// for the tooltip-loader path + locale strings, falling back to English and a
// sibling-derived tooltip path when the kit core isn't loaded.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
//   const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
//     "primaryColor": "#D97757",
//     "palette": ["#D97757", "#29261b", "#f6f4ef"],
//     "fontSize": 16,
//     "density": "regular",
//     "dark": false
//   }/*EDITMODE-END*/;
//
//   function App() {
//     const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
//     return (
//       <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
//         Hello
//         <TweaksPanel>
//           <TweakSection label="Typography" />
//           <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
//                        onChange={(v) => setTweak('fontSize', v)} />
//           <TweakRadio  label="Density" value={t.density}
//                        options={['compact', 'regular', 'comfy']}
//                        onChange={(v) => setTweak('density', v)} />
//           <TweakSection label="Theme" />
//           <TweakColor  label="Primary" value={t.primaryColor}
//                        options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
//                        onChange={(v) => setTweak('primaryColor', v)} />
//           <TweakColor  label="Palette" value={t.palette}
//                        options={[['#D97757', '#29261b', '#f6f4ef'],
//                                  ['#475569', '#0f172a', '#f1f5f9']]}
//                        onChange={(v) => setTweak('palette', v)} />
//           <TweakToggle label="Dark mode" value={t.dark}
//                        onChange={(v) => setTweak('dark', v)} />
//         </TweaksPanel>
//       </div>
//     );
//   }
//
// ─────────────────────────────────────────────────────────────────────────────

const __TWEAKS_STYLE = `
  .twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
    max-height:calc(100vh - 32px);display:flex;flex-direction:column;
    --twk-enter:translateY(10px) scale(.98);
    transform:scale(var(--dc-inv-zoom,1)) var(--twk-enter);transform-origin:bottom right;
    background:rgba(250,249,247,.78);color:#29261b;
    -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
    border:.5px solid rgba(255,255,255,.6);border-radius:14px;
    box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
    font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden;
    opacity:0;will-change:transform,opacity;
    transition:opacity .16s ease-out,transform .24s cubic-bezier(.16,1,.3,1)}
  /* data-show flips on a frame after mount (enter) and off before unmount
     (exit). Keeping opacity/transform rule-driven means a frozen clock in a
     capture/eval context degrades to a static offset, never a stuck-invisible
     panel (same lesson as the tooltip opacity-parking pitfall). */
  .twk-panel[data-show="1"]{--twk-enter:translateY(0) scale(1);opacity:1}
  /* Top-anchored panels (adaptive anchor — see posRef/reanchor) keep the host
     inv-zoom scale pinned to the edge they grow from. */
  .twk-panel[data-anchor="top"]{transform-origin:top right}
  .twk-hd{display:flex;align-items:center;justify-content:space-between;
    padding:10px 8px 10px 14px;cursor:move;user-select:none;touch-action:none}
  .twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
  .twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
    width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
  .twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
  .twk-hd-tools{display:flex;align-items:center;gap:2px}
  .twk-hbtn{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.5);
    width:22px;height:22px;border-radius:6px;padding:0;flex:none;
    display:inline-flex;align-items:center;justify-content:center;cursor:default;
    transition:color .12s ease,background .12s ease}
  .twk-hbtn:hover{color:#29261b;background:rgba(0,0,0,.06)}
  .twk-body{padding:6px 14px 14px;display:flex;flex-direction:column;gap:10px;
    overflow-y:auto;overflow-x:hidden;min-height:0;
    scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
  .twk-body::-webkit-scrollbar{width:8px}
  .twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
  .twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
    border:2px solid transparent;background-clip:content-box}
  .twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
    border:2px solid transparent;background-clip:content-box}
  .twk-row{display:flex;flex-direction:column;gap:5px}
  .twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
  .twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
    color:rgba(41,38,27,.72)}
  .twk-lbl>span:first-child{font-weight:500}
  .twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}

  .twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
    color:rgba(41,38,27,.45);padding:10px 0 0;user-select:none;-webkit-user-select:none}
  .twk-sect:first-child{padding-top:0}
  /* Collapsible section header (opt-in via collapsibleSections / collapsible):
     label · separator line · chevron — the Theme Lab header vocabulary.
     Hovering anywhere in the section (header or controls) or focusing inside
     lifts the header contrast; while one section is active the others recede
     (suppressed during a header drag — see .twk-reordering). */
  .twk-sect-btn{appearance:none;border:0;background:transparent;cursor:default;
    width:100%;display:flex;align-items:center;gap:7px;
    font:inherit;font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
    color:rgba(41,38,27,.5);padding:10px 0 2px;text-align:left;
    user-select:none;-webkit-user-select:none;
    transition:color .14s ease}
  .twk-body>.twk-sect-grp:first-child .twk-sect-btn{padding-top:0}
  .twk-sect-btn:focus-visible{outline:none;color:rgba(41,38,27,.85)}
  .twk-sect-lbl{flex:none}
  .twk-sect-sep{flex:1 1 auto;border-top:.5px solid rgba(41,38,27,.15);transition:border-color .14s ease}
  .twk-sect-chev{flex:none;transition:transform .22s cubic-bezier(.3,.7,.4,1);opacity:.6}
  .twk-sect-btn[aria-expanded="false"] .twk-sect-chev{transform:rotate(-90deg)}
  .twk-sect-grp:is(:hover,:focus-within) .twk-sect-btn{color:rgba(41,38,27,.95);font-weight:700}
  .twk-sect-grp:is(:hover,:focus-within) .twk-sect-sep{border-color:rgba(41,38,27,.4)}
  .twk-body:not(.twk-reordering):has(.twk-sect-grp:is(:hover,:focus-within))
    .twk-sect-grp:not(:hover):not(:focus-within){opacity:.68}
  /* Animated collapse — a group wraps the header + a grid-rows collapser so
     the body height animates 0fr↔1fr (no JS height measuring). The body keeps
     overflow:hidden + min-height:0 so it clips while collapsed. The group is a
     single flex child of .twk-body, so its gap stays between sections only —
     no double-gap around a collapsed body. */
  .twk-sect-grp{display:flex;flex-direction:column;transition:opacity .16s ease}
  .twk-sect-collapse{display:grid;grid-template-rows:0fr;
    transition:grid-template-rows .24s cubic-bezier(.3,.7,.4,1)}
  .twk-sect-collapse[data-open="1"]{grid-template-rows:1fr}
  .twk-sect-collapse>.twk-sect-body{overflow:hidden;min-height:0;
    display:flex;flex-direction:column;gap:10px;padding-top:10px;
    transition:overflow 0s}
  /* While OPEN, let the section body overflow be visible so focus rings on the
     first/last controls (and the preset pills under a section label) aren't
     clipped by the collapse box. The delay reveals overflow only AFTER the
     open animation finishes; collapsing flips back to the base rule (no delay)
     so it clips immediately and the height animation stays clean. */
  .twk-sect-collapse[data-open="1"]>.twk-sect-body{overflow:visible;
    transition:overflow 0s .26s}
  /* Static (non-collapsible) panels keep the original flat body spacing. */
  .twk-sect-body{display:flex;flex-direction:column;gap:10px}
  /* Section drag-reorder (on with collapsibleSections; reorderableSections
     opts out). A dragged header stamps .twk-reordering on the scroll body:
     every section folds shut for the duration (collapse STATES are untouched
     — they restore when the class lifts), the recede effect above pauses, and
     the dragged section floats as a chip. Order persists per panel
     (localStorage twk-order:<ns>). */
  .twk-reorder .twk-sect-btn{cursor:move;touch-action:pan-y}
  .twk-body.twk-reordering{user-select:none;-webkit-user-select:none}
  .twk-body.twk-reordering *{cursor:move}
  .twk-body.twk-reordering .twk-sect-collapse[data-open]{grid-template-rows:0fr}
  .twk-body.twk-reordering .twk-sect-collapse>.twk-sect-body{overflow:hidden;transition:overflow 0s}
  .twk-sect-grp.twk-drag-src{position:relative;z-index:5;background:rgba(252,251,249,.96);
    border-radius:8px;box-shadow:0 6px 18px rgba(0,0,0,.16),0 0 0 .5px rgba(0,0,0,.08);
    padding:0 8px;margin:0 -8px}
  .twk-sect-grp.twk-drag-src .twk-sect-btn{padding:7px 0;color:rgba(41,38,27,.95);font-weight:700}

  .twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
    border:.5px solid rgba(0,0,0,.1);border-radius:7px;
    background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
  .twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
  select.twk-field{padding-right:22px;
    background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
    background-repeat:no-repeat;background-position:right 8px center}

  /* Styled select (TweakSelect) — replaces the native <select> so BOTH the
     trigger and its dropdown match the panel's fixed light-glass surface in
     EITHER page theme (a native popup is OS-drawn; reusing the DS .combo-list
     would flip dark with the page tokens). The list is PORTALED to <body>
     (position:fixed) to escape the panel's overflow:hidden + transform
     containing block — same rationale as the shared .tip. Its .twk-combo-*
     rules live in this global <style>, which is present precisely while the
     panel (and thus an open dropdown) is mounted. */
  .twk-selbtn{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;
    padding:0 8px;display:flex;align-items:center;gap:6px;text-align:left;
    border:.5px solid rgba(0,0,0,.1);border-radius:7px;
    background:rgba(255,255,255,.6);color:inherit;font:inherit;cursor:default;
    transition:background .12s,border-color .12s,box-shadow .14s ease-out}
  .twk-selbtn:hover{background:rgba(255,255,255,.88);border-color:rgba(0,0,0,.2)}
  .twk-selbtn[aria-expanded="true"]{background:rgba(255,255,255,.94);border-color:color-mix(in srgb,var(--studio-accent,#2362a2) 50%,transparent)}
  .twk-selval{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
  .twk-selval.placeholder{color:rgba(41,38,27,.4)}
  .twk-selchev{flex:none;width:10px;height:6px;opacity:.5;color:rgba(41,38,27,.7);
    transition:transform .2s cubic-bezier(.16,1,.3,1),opacity .12s}
  .twk-selbtn:hover .twk-selchev{opacity:.85}
  .twk-selbtn[aria-expanded="true"] .twk-selchev{transform:rotate(180deg);opacity:.95}

  .twk-combo-list{position:fixed;z-index:2147483647;box-sizing:border-box;margin:0;
    list-style:none;padding:4px;min-width:120px;overflow-y:auto;overflow-x:hidden;
    background:rgba(252,251,249,.9);color:#29261b;
    -webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
    border:.5px solid rgba(0,0,0,.12);border-radius:10px;
    box-shadow:0 1px 0 rgba(255,255,255,.6) inset,0 12px 34px rgba(0,0,0,.22);
    font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;
    scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent;
    transform-origin:top center;animation:twkComboIn .16s cubic-bezier(.16,1,.3,1)}
  .twk-combo-list[data-up="1"]{transform-origin:bottom center;animation-name:twkComboInUp}
  /* Opacity starts at .4 (never 0) so a frozen-clock capture can't park the
     list invisible — the visible motion is the small translate (tip lesson). */
  @keyframes twkComboIn{from{opacity:.4;transform:translateY(-5px)}to{opacity:1;transform:none}}
  @keyframes twkComboInUp{from{opacity:.4;transform:translateY(5px)}to{opacity:1;transform:none}}
  .twk-combo-list::-webkit-scrollbar{width:8px}
  .twk-combo-list::-webkit-scrollbar-track{background:transparent;margin:2px}
  .twk-combo-list::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
    border:2px solid transparent;background-clip:content-box}
  .twk-combo-item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;
    cursor:default;color:inherit;white-space:nowrap}
  .twk-combo-lbl{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
  .twk-combo-check{flex:none;width:12px;height:12px;color:var(--studio-accent,#2362a2);opacity:0;transition:opacity .1s}
  .twk-combo-item[aria-selected="true"]{font-weight:600}
  .twk-combo-item[aria-selected="true"] .twk-combo-check{opacity:1}
  .twk-combo-item[data-active="1"]{background:color-mix(in srgb,var(--studio-accent,#2362a2) 12%,transparent)}
  .twk-combo-item[aria-selected="true"][data-active="1"]{background:color-mix(in srgb,var(--studio-accent,#2362a2) 18%,transparent)}

  .twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
    border-radius:999px;background:rgba(0,0,0,.12);outline:none}
  .twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
    width:14px;height:14px;border-radius:50%;background:#fff;
    border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
  .twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
    background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}

  .twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
    background:rgba(0,0,0,.06);user-select:none;touch-action:none}
  .twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
    background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
    transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
  .twk-seg.dragging .twk-seg-thumb{transition:none}
  .twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
    background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
    border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
    overflow-wrap:anywhere}

  .twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
    background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
  .twk-toggle[data-on="1"]{background:#34c759}
  .twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
    background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
  .twk-toggle[data-on="1"] i{transform:translateX(14px)}

  .twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
    border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
  .twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
    user-select:none;padding-right:8px;touch-action:none}
  .twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
    font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
    outline:none;color:inherit;-moz-appearance:textfield}
  .twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
    -webkit-appearance:none;margin:0}
  .twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}

  .twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
    background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
  .twk-btn:hover{background:rgba(0,0,0,.88)}
  .twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
  .twk-btn.secondary:hover{background:rgba(0,0,0,.1)}

  .twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
    border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
    background:transparent;flex-shrink:0}
  .twk-swatch::-webkit-color-swatch-wrapper{padding:0}
  .twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
  .twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}

  .twk-chips{display:flex;gap:6px}
  .twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
    padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
    box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
    transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
  .twk-chip:hover{transform:translateY(-1px);
    box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
  .twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
    0 2px 6px rgba(0,0,0,.15)}
  .twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
    display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
  .twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
  .twk-chip>span>i:first-child{box-shadow:none}
  .twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
    filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}

  /* Focus ring — the panel is ALWAYS a light, semi-transparent glass surface,
     even when the host page is in dark mode (its background never flips). So
     its controls own a fixed LIGHT-mode ring here rather than inheriting the
     host's theme-aware --shadow-focus, which is tuned for dark surfaces and
     reads wrong on the pale panel. A light spacer + blue halo, kept tight
     (3.5px) so it never clips against the panel's rounded overflow box or the
     body's overflow-x:hidden scroll edge. Specificity (0,1,1)/(0,2,0) beats the
     host's :where() base ring cleanly. */
  /* Keycaps inside the panel — the panel keeps ONE fixed glass styling in both
     themes, so <kbd> must NOT inherit the page's theme-aware kbd tokens (the
     dark-mode remap rendered unreadable caps on the panel). Fixed light chip,
     same vocabulary as the panel's own pills. */
  .twk-panel kbd{font:600 .82em var(--font-mono,ui-monospace,monospace);
    padding:1px 4.5px;border-radius:4px;white-space:nowrap;
    color:rgba(41,38,27,.78);background:rgba(255,255,255,.85);
    border:1px solid rgba(0,0,0,.16);border-bottom-width:2px}
  .twk-panel :is(button,select,input,[tabindex]):focus-visible{
    outline:none;border-radius:7px;position:relative;z-index:2;
    box-shadow:0 0 0 2px rgba(250,249,247,.95),
      0 0 0 3.5px color-mix(in srgb,var(--studio-accent,#2362a2) 55%,transparent)}
  .twk-panel .twk-slider:focus-visible{border-radius:999px}
  .twk-panel .twk-toggle:focus-visible{border-radius:999px}
  .twk-panel .twk-chip:focus-visible{border-radius:6px}
  .twk-panel .twk-x:focus-visible{border-radius:6px}

  /* Self-contained reduced-motion honouring — the DS relies on a global policy;
     the portable kit collapses its own motion so it behaves standalone too. */
  @media (prefers-reduced-motion: reduce){
    .twk-panel{transition:none}
    .twk-seg-thumb{transition:none}
    .twk-sect-chev{transition:none}
    .twk-sect-collapse{transition:none}
    .twk-sect-grp{transition:none}
    .twk-combo-list{animation:none}
    .twk-selchev{transition:none}
  }
`;

// ── Collapsible-section context ─────────────────────────────────────────────
// When a panel opts into collapsible sections, every <TweakSection> reads this
// context to render a clickable header + chevron and persist its open state.
// Default (no provider / collapsible:false) keeps the original static heading,
// so existing preview cards are unchanged.
const TwkSectionCtx = React.createContext({ collapsible: false, ns: 'twk' });

// ── Section drag-reorder engine ─────────────────────────────────────────────
// Shared by TweaksPanel (on by default with collapsibleSections) and custom
// panels hosting kit sections outside a TweaksPanel (the Theme Lab). Owns:
// the persisted order (localStorage twk-order:<ns>), the pointer drag
// (mouse/pen: 5px slop before it becomes a drag so plain clicks still toggle;
// touch: 350ms hold so the panel still scrolls — a window-level non-passive
// touchmove blocker owns the gesture once armed), live re-slotting with a
// FLIP glide, edge auto-scroll, Escape cancel, and drop persistence.
// DOM contract:
//   - container = the scrolling .twk-body (attach the hook's containerRef;
//     add class twk-reorder when enabled so headers show the move cursor),
//   - every reorderable section root carries data-twk-sec="<id>",
//   - headers wire onPointerDown={beginDrag(id)},
//   - controls inside a header that must NOT start a drag (chevron, pin)
//     carry data-twk-noreorder.
// The transient classes (twk-reordering on the container, twk-drag-src on the
// dragged section) are applied imperatively — the owners keep their className
// props STATIC across renders, so React never wipes them (established panel
// pattern). Collapse states are NOT touched: the fold-while-dragging is pure
// CSS off .twk-reordering, so the previous open/closed mix restores on drop.
const TWK_ORDER_PREFIX = 'twk-order:';
function twkReadOrder(ns) {
  try {
    const v = JSON.parse(localStorage.getItem(TWK_ORDER_PREFIX + ns) || 'null');
    return Array.isArray(v) ? v.map(String) : null;
  } catch (e) { return null; }
}
// Saved order first (ids the list no longer has are dropped), then any ids the
// saved order doesn't know yet, in their natural order.
function twkApplyOrder(ids, order) {
  if (!order || !order.length) return ids.slice();
  const have = new Set(ids);
  const out = order.filter((id) => have.has(id));
  const seen = new Set(out);
  ids.forEach((id) => { if (!seen.has(id)) out.push(id); });
  return out;
}
// Persist without losing sections that are not currently rendered (the Theme
// Lab's basic mode hides several): the previous saved order is the carrier —
// visible ids are re-sequenced in place, hidden ids keep their slots.
function twkMergeOrder(oldOrder, ids, ordered) {
  const base = oldOrder && oldOrder.length ? oldOrder.slice() : ids.slice();
  ids.forEach((id) => { if (base.indexOf(id) === -1) base.push(id); });
  const vis = new Set(ids);
  let vi = 0;
  return base.map((id) => (vis.has(id) ? ordered[vi++] : id));
}

function useTwkSectionReorder({ ns, ids, enabled = true, panelShift = null }) {
  const [order, setOrder] = React.useState(() => twkReadOrder(ns));
  const containerRef = React.useRef(null);
  const sessRef = React.useRef(null);
  const idsKey = ids.join('\u0000');
  const orderedIds = React.useMemo(() => twkApplyOrder(ids, order), [idsKey, order]); // eslint-disable-line react-hooks/exhaustive-deps
  const orderedRef = React.useRef(orderedIds); orderedRef.current = orderedIds;
  const idsRef = React.useRef(ids); idsRef.current = ids;
  const nsRef = React.useRef(ns); nsRef.current = ns;
  const enabledRef = React.useRef(enabled); enabledRef.current = enabled;
  // Optional host seam (floating panels): { shift(dy)→movedPx, reset() } —
  // lets the drag move the WHOLE panel so the dragged section stays under the
  // cursor with its neighbours adjacent (see TweaksPanel's dragShift).
  const panelShiftRef = React.useRef(panelShift); panelShiftRef.current = panelShift;

  const rmNow = () => {
    try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; }
  };
  // Restart-safe WAA glide (tagged twk-flip; in-flight copies cancelled).
  const glide = (el, fromDy, dur) => {
    if (!fromDy || rmNow()) return;
    el.getAnimations().forEach((a) => { if (a.id === 'twk-flip') a.cancel(); });
    try {
      const a = el.animate(
        [{ transform: 'translateY(' + fromDy + 'px)' }, { transform: 'translateY(0)' }],
        { duration: dur, easing: 'cubic-bezier(.3,.7,.4,1)' },
      );
      a.id = 'twk-flip';
    } catch (e) {}
  };

  // FLIP bookkeeping: layout tops per id, refreshed after every render AND
  // right before a mid-drag re-slot (an edge auto-scroll between renders would
  // otherwise poison the deltas).
  const topsRef = React.useRef(null);
  const measureTops = React.useCallback(() => {
    const c = containerRef.current;
    if (!c) { topsRef.current = null; return; }
    const next = new Map();
    c.querySelectorAll('[data-twk-sec]').forEach((el) => {
      next.set(el.getAttribute('data-twk-sec'), el.getBoundingClientRect().top);
    });
    topsRef.current = next;
  }, []);
  React.useLayoutEffect(() => {
    const c = containerRef.current;
    const sess = sessRef.current;
    if (!c) { topsRef.current = null; return; }
    const prev = topsRef.current;
    if (sess && sess.active && prev) {
      const scale = sess.scale || 1;
      c.querySelectorAll('[data-twk-sec]').forEach((el) => {
        const id = el.getAttribute('data-twk-sec');
        if (id === sess.id || !prev.has(id)) return;
        const delta = (prev.get(id) - el.getBoundingClientRect().top) / scale;
        if (Math.abs(delta) > 0.5) glide(el, delta, 150);
      });
    }
    measureTops();
  });
  // A drag must not outlive the panel (close/unmount mid-drag).
  React.useEffect(() => () => {
    const s = sessRef.current;
    if (s && s.teardown) s.teardown(false);
  }, []);

  const beginDrag = (id) => (e) => {
    if (!enabledRef.current || sessRef.current) return;
    if (e.button != null && e.button !== 0) return;
    if (e.target && e.target.closest && e.target.closest('[data-twk-noreorder], input, select, textarea')) return;
    const c = containerRef.current;
    if (!c || orderedRef.current.length < 2) return;
    const capEl = e.currentTarget;
    const isTouch = e.pointerType === 'touch';
    const cssEsc = (s) => (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/"/g, '\\"'));
    const findEl = () => c.querySelector('[data-twk-sec="' + cssEsc(id) + '"]');
    if (!findEl()) return;
    const sess = {
      id, active: false, pointerId: e.pointerId, isTouch,
      x0: e.clientX, y0: e.clientY, lastY: e.clientY,
      grabDY: 0, translate: 0, scale: 1,
      startOrder: orderedRef.current.slice(),
      // The session's WORKING order is authoritative during the drag — React
      // state (setOrder) only mirrors it for rendering. orderedRef refreshes
      // on re-render, so a pointerup landing in the same frame as the last
      // move would otherwise commit a stale order.
      order: orderedRef.current.slice(),
      holdTimer: 0, blockTouch: null, ctxBlock: null, squelch: null, teardown: null,
    };
    sessRef.current = sess;

    // ── Drag diagnostics (?twkdebug in the URL, or localStorage twk-debug=1) ──
    // On-screen overlay so user screenshots carry full context: the slot grid
    // (pink dashed lines + slot index), the chip's INTENDED centre (solid
    // green — what reslot targets) vs its PAINTED centre (dotted blue), and a
    // readout of every value the engine used (cur→idx, clamp, panel shift,
    // sess vs DOM order — ⚠ flags a lagging React commit). Logs each reslot
    // to the console too. Removed 8s after drop so post-drop screenshots
    // still show the COMMIT frame. Dev-only; zero cost when the flag is off.
    let dbg = null;
    try {
      // Opt-in: set localStorage twk-debug=1, or put "twkdebug" anywhere in
      // the URL (query or hash). Kept permanently as the engine's debugging
      // window — full field guide in notes/drag-reorder-debug.md.
      if (/twkdebug/.test(location.href) || localStorage.getItem('twk-debug') === '1') {
        document.querySelectorAll('[data-twk-dbg]').forEach((n) => n.remove());
        dbg = {
          n: 0, el: null,
          ensure() {
            if (this.el && this.el.isConnected) return this.el;
            const d = document.createElement('div');
            d.setAttribute('data-twk-dbg', '');
            d.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none;font:10px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;';
            document.body.appendChild(d);
            this.el = d;
            return d;
          },
          draw(info) {
            const root = this.ensure();
            const br = info.c.getBoundingClientRect();
            const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
            let h = '';
            (info.mids || []).forEach((m, i) => {
              h += '<div style="position:fixed;left:' + br.left + 'px;width:' + br.width + 'px;top:' + m + 'px;border-top:1px dashed rgba(230,0,90,.6)"></div>' +
                   '<div style="position:fixed;left:' + (br.left + br.width - 16) + 'px;top:' + (m - 14) + 'px;color:#e0005a;font-weight:700">' + i + '</div>';
            });
            if (info.aimCy != null) h += '<div style="position:fixed;left:' + br.left + 'px;width:' + br.width + 'px;top:' + info.aimCy + 'px;border-top:2px solid rgba(0,170,70,.95)"></div>';
            if (info.chipCy != null) h += '<div style="position:fixed;left:' + br.left + 'px;width:' + br.width + 'px;top:' + info.chipCy + 'px;border-top:2px dotted rgba(30,90,255,.95)"></div>';
            h += '<div style="position:fixed;left:8px;bottom:8px;max-width:min(62vw,540px);background:rgba(15,18,28,.93);color:#eef2f8;padding:7px 9px;border-radius:7px;white-space:pre">' +
                 '<b style="color:#8fe3a8">' + esc(info.head) + '</b>\n' + (info.lines || []).filter(Boolean).map(esc).join('\n') + '</div>';
            root.innerHTML = h;
            try { console.log('[twk-reorder]', info.head + ' | ' + (info.lines || []).filter(Boolean).join(' | ')); } catch (err) {}
          },
          kill(ms) {
            const el = this.el;
            this.el = null;
            if (el) setTimeout(() => { try { el.remove(); } catch (err) {} }, ms || 0);
          },
        };
      }
    } catch (err) {}

    const activate = () => {
      if (sess.active || sessRef.current !== sess) return;
      sess.active = true;
      const el = findEl();
      const r = el.getBoundingClientRect();
      sess.scale = el.offsetWidth ? r.width / el.offsetWidth : 1;
      sess.grabDY = Math.max(6, sess.lastY - r.top);
      sess.translate = 0;
      c.classList.add('twk-reordering');
      el.classList.add('twk-drag-src');
      try { if (capEl && capEl.setPointerCapture) capEl.setPointerCapture(sess.pointerId); } catch (err) {}
      // Swallow the click that follows a real drag (it would toggle a section).
      sess.squelch = (ev) => { ev.stopPropagation(); ev.preventDefault(); };
      c.addEventListener('click', sess.squelch, true);
      if (isTouch) {
        // Own the gesture from here on: without this the browser starts a pan
        // on the first move after the hold and pointercancel kills the drag.
        sess.blockTouch = (ev) => { if (ev.cancelable) ev.preventDefault(); };
        window.addEventListener('touchmove', sess.blockTouch, { passive: false });
        sess.ctxBlock = (ev) => ev.preventDefault();
        window.addEventListener('contextmenu', sess.ctxBlock, true);
      }
      measureTops();
      // Sections FOLD over ~240ms after activation — with a stationary pointer
      // no pointermove fires, so re-place the chip (and let the panel-follow
      // catch up) as the container resizes. Live-only enhancement (RO is
      // frozen in offscreen capture iframes; pointer moves still drive all
      // core behavior).
      try {
        sess.ro = new ResizeObserver(() => {
          if (sessRef.current === sess && sess.active) { place(true); reslot(); }
        });
        sess.ro.observe(c);
      } catch (err) {}
    };

    // Untransformed extent of the slot stack (flow positions — FLIP glides and
    // the chip's own translate subtracted out). The chip is clamped INSIDE it:
    // past the last slot a translated chip only extends the body's scrollable
    // overflow, and edge-scrolling into that self-made space grows the panel
    // with emptiness (drag-down runaway caught by user, Jul 2026).
    const stackBounds = () => {
      let top = Infinity;
      let bottom = -Infinity;
      c.querySelectorAll('[data-twk-sec]').forEach((el) => {
        const r = el.getBoundingClientRect();
        let ty = 0;
        try {
          const tr = getComputedStyle(el).transform;
          if (tr && tr !== 'none') ty = new DOMMatrixReadOnly(tr).m42 * (sess.scale || 1);
        } catch (err) {}
        if (r.top - ty < top) top = r.top - ty;
        if (r.top - ty + r.height > bottom) bottom = r.top - ty + r.height;
      });
      return top === Infinity ? null : { top, bottom };
    };

    const place = (allowShift) => {
      const el = findEl();
      if (!el) return;
      const r = el.getBoundingClientRect();
      const slotTop = r.top - sess.translate * sess.scale;
      // The header shrinks as sections fold — clamp the grab offset so the
      // chip stays under the pointer instead of floating above it.
      const grab = Math.min(sess.grabDY, Math.max(4, r.height - 6));
      // The insertion index tracks the chip's INTENDED centre (cursor minus
      // grab offset, plus half the chip) — NOT its clamped visual centre.
      // stackBounds (below) pins the visible chip inside the stack so a
      // drag-down can't extend the scroll area, but that same pin caps the
      // visual centre ~half a slot short of the first and last rows, which
      // made those two positions unreachable. aimCy stays unclamped so every
      // slot, including the extremes, is a valid drop target.
      sess.aimCy = sess.lastY - grab + r.height / 2;
      const sb = stackBounds();
      let delta = sess.lastY - grab - slotTop;
      // Floating panels: when the FOLD displaces the stack (ResizeObserver
      // ticks — never pointer moves, or the panel would chase the cursor 1:1
      // and the chip could never cross its neighbours), move the PANEL so the
      // dragged section stays under the cursor with its neighbours right next
      // to it; the chip translates whatever the viewport clamp couldn't absorb.
      let shifted = 0;
      if (allowShift) {
        const ps = panelShiftRef.current;
        if (ps && ps.shift) { shifted = ps.shift(delta) || 0; delta -= shifted; }
      }
      // Keep the chip inside the stack (the bounds move WITH the panel on a
      // shift, so the pre-shift numbers stay valid — the shift cancels out).
      if (sb) delta = Math.max(sb.top - slotTop, Math.min(sb.bottom - r.height - slotTop, delta));
      sess.translate = delta / sess.scale;
      el.style.transform = 'translateY(' + sess.translate + 'px)';
      if (dbg) sess.dbgP = { slotTop: slotTop, tr: sess.translate, sb: sb, shifted: shifted };
    };

    const reslot = () => {
      const cur = sess.order.indexOf(sess.id);
      if (cur === -1) return;
      const src = findEl();
      if (!src) return;
      const sr = src.getBoundingClientRect();
      // Target the chip's INTENDED centre (set in place() from cursor + grab
      // offset + half the chip). It equals the visual centre everywhere the
      // chip moves freely — so drops still land where the eye expects, not one
      // slot above — but unlike the visual centre it is NOT clamped by
      // stackBounds, so the first and last slots stay reachable. Falls back to
      // the visual centre only if place() somehow hasn't run yet this drag.
      const cy = sess.aimCy != null ? sess.aimCy : sr.top + sr.height / 2;
      // Build the SETTLED slot grid: untransformed midpoints (transforms
      // subtracted — the chip's inline translate and FLIP glides alike) of
      // EVERY section, sorted. Reorders only permute slot OCCUPANTS, so this
      // grid is identical even while a React commit still lags sess.order by
      // a splice — the old "count siblings above the chip" rule read that
      // lagging assignment, and a quick drag re-spliced against stale rows on
      // every move, walking the drop slot several rows away from the chip
      // (user-caught with screenshots, Jul 2026).
      const mids = [];
      c.querySelectorAll('[data-twk-sec]').forEach((el) => {
        const r = el.getBoundingClientRect();
        let ty = 0;
        try {
          const tr = getComputedStyle(el).transform;
          if (tr && tr !== 'none') ty = new DOMMatrixReadOnly(tr).m42 * (sess.scale || 1);
        } catch (err) {}
        mids.push(r.top - ty + r.height / 2);
      });
      mids.sort((a, b) => a - b);
      // Target the slot whose centre sits nearest the chip's intended centre
      // — the gap lands exactly where the chip visually is, and the flip
      // boundary (midway between slot centres) stays put across commits, so
      // there is nothing to oscillate.
      let idx = 0;
      for (let i = 1; i < mids.length; i++) {
        if (Math.abs(mids[i] - cy) < Math.abs(mids[idx] - cy)) idx = i;
      }
      if (dbg) {
        const domIds = [];
        c.querySelectorAll('[data-twk-sec]').forEach((el) => domIds.push(el.getAttribute('data-twk-sec')));
        const p = sess.dbgP || {};
        dbg.draw({
          c: c, mids: mids, aimCy: cy, chipCy: sr.top + sr.height / 2,
          head: 'twk-reorder r3 slot-grid  #' + (++dbg.n) + '  cur ' + cur + ' \u2192 idx ' + idx + (idx !== cur ? '  SPLICE' : ''),
          lines: [
            'lastY ' + Math.round(sess.lastY) + '  aimCy ' + Math.round(cy) + '  chipCy ' + Math.round(sr.top + sr.height / 2) + '  grabDY ' + Math.round(sess.grabDY) + '  scale ' + Math.round(sess.scale * 1000) / 1000,
            'slotTop ' + (p.slotTop != null ? Math.round(p.slotTop) : '?') + '  translate ' + (p.tr != null ? Math.round(p.tr) : '?') + (p.sb ? '  stack [' + Math.round(p.sb.top) + ', ' + Math.round(p.sb.bottom) + ']' : '  stack ?') + (p.shifted ? '  panelShift ' + Math.round(p.shifted) : ''),
            'mids ' + mids.map((m) => Math.round(m)).join(' '),
            'sess ' + sess.order.map((s) => s.slice(0, 4)).join(','),
            'dom  ' + domIds.map((s) => s.slice(0, 4)).join(',') + (domIds.join('\u0000') !== sess.order.join('\u0000') ? '   \u26a0 DOM lags sess.order' : ''),
          ],
        });
      }
      if (idx !== cur) {
        measureTops();
        const next = sess.order.filter((x) => x !== sess.id);
        next.splice(idx, 0, sess.id);
        sess.order = next;
        setOrder(next);
      }
    };

    const edgeScroll = () => {
      const br = c.getBoundingClientRect();
      const zone = 28;
      if (sess.lastY < br.top + zone) c.scrollTop -= Math.ceil((br.top + zone - sess.lastY) * 0.35);
      else if (sess.lastY > br.bottom - zone) c.scrollTop += Math.ceil((sess.lastY - (br.bottom - zone)) * 0.35);
    };

    const move = (ev) => {
      if (ev.pointerId !== sess.pointerId) return;
      if (!sess.active) {
        sess.lastY = ev.clientY;
        const d = Math.abs(ev.clientX - sess.x0) + Math.abs(ev.clientY - sess.y0);
        if (isTouch) { if (d > 8) teardown(false); return; } // finger drifted → it's a scroll
        if (d < 5) return;
        activate();
      }
      const br = c.getBoundingClientRect();
      sess.lastY = Math.max(br.top + 4, Math.min(br.bottom - 4, ev.clientY));
      if (ev.cancelable) ev.preventDefault();
      edgeScroll();
      place();
      reslot();
    };

    const teardown = (commit) => {
      if (sessRef.current !== sess) return;
      clearTimeout(sess.holdTimer);
      if (sess.ro) { try { sess.ro.disconnect(); } catch (err) {} }
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', cancelEv);
      window.removeEventListener('keydown', onKey, true);
      if (sess.blockTouch) window.removeEventListener('touchmove', sess.blockTouch);
      if (sess.ctxBlock) window.removeEventListener('contextmenu', sess.ctxBlock, true);
      if (sess.active) {
        // A racy burst can leave the last computed slot stale if the pointer
        // rested afterwards (no move = no reslot) — resolve the target once
        // more from the final chip position before committing.
        if (commit) reslot();
        if (dbg) {
          dbg.draw({ c: c, mids: [], head: (commit ? 'COMMIT' : 'CANCEL') + '  final order ' + sess.order.map((s) => s.slice(0, 4)).join(','), lines: ['overlay clears in 8s'] });
          dbg.kill(8000);
        }
        const el = findEl();
        c.classList.remove('twk-reordering');
        if (el) {
          el.classList.remove('twk-drag-src');
          const t = sess.translate;
          el.style.transform = '';
          glide(el, t, 190); // settle into the final slot
        }
        if (sess.squelch) setTimeout(() => c.removeEventListener('click', sess.squelch, true), 0);
        const ps = panelShiftRef.current;
        if (ps && ps.reset) ps.reset(); // panel glides back to its pre-drag spot
        if (commit) {
          try {
            localStorage.setItem(
              TWK_ORDER_PREFIX + nsRef.current,
              JSON.stringify(twkMergeOrder(twkReadOrder(nsRef.current), idsRef.current, sess.order)),
            );
          } catch (err) {}
        } else {
          setOrder(sess.startOrder);
        }
      }
      sessRef.current = null;
    };
    sess.teardown = teardown;

    const up = (ev) => { if (ev.pointerId === sess.pointerId) teardown(true); };
    const cancelEv = (ev) => { if (ev.pointerId === sess.pointerId) teardown(false); };
    const onKey = (ev) => {
      if (ev.key !== 'Escape' || !sess.active) return;
      ev.stopPropagation(); ev.preventDefault();
      teardown(false);
    };

    if (isTouch) sess.holdTimer = setTimeout(activate, 350);
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', cancelEv);
    window.addEventListener('keydown', onKey, true);
  };

  return { orderedIds, beginDrag, containerRef };
}

// ── skStr ───────────────────────────────────────────────────────────────────
// Locale-aware lookup for the panel's OWN driver strings (close, scrub label,
// select placeholder, deck controls). Reads window.StudioKit.t when the kit
// core is present; otherwise returns the English fallback. Author-supplied
// labels (TweakSection label, control labels) arrive as props and are NOT
// routed through here.
function skStr(key, fallback) {
  try {
    if (typeof window !== 'undefined' && window.StudioKit && typeof window.StudioKit.t === 'function') {
      return window.StudioKit.t(key, fallback);
    }
  } catch (e) {}
  return fallback;
}

// ── ensureTweaksTooltip ─────────────────────────────────────────────────────
// Guarantees the styled-tooltip driver (the floating .tip chip) is present so
// [data-tip] on panel controls renders a styled tip. Prefers the Studio Kit
// loader (StudioKit.ensureTooltip — handles the CSS link + the configured
// path); falls back to self-loading the driver as a sibling when the kit core
// isn't loaded (derived from this module's own <script src>, since a Babel-
// transpiled module can't use document.currentScript). No-op once loaded.
function ensureTweaksTooltip() {
  try {
    if (typeof window === 'undefined') return;
    if (window.StudioKit && typeof window.StudioKit.ensureTooltip === 'function') {
      window.StudioKit.ensureTooltip();
      return;
    }
    if (window.__tipInit || document.querySelector('script[data-twk-tip]')) return;
    const self = document.querySelector('script[src*="studio-panels"]') ||
                 document.querySelector('script[src*="tweaks-panel"]');
    if (!self || !self.src) return;
    const rel = self.src.indexOf('studio-panels') !== -1
      ? '../tooltip/studio-tooltip.js' : '_tooltip.js';
    const s = document.createElement('script');
    s.src = new URL(rel, self.src).href;
    s.setAttribute('data-twk-tip', '');
    document.head.appendChild(s);
  } catch (e) {}
}

// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
  const [values, setValues] = React.useState(defaults);
  // Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
  // useState-style call doesn't write a "[object Object]" key into the persisted
  // JSON block.
  const setTweak = React.useCallback((keyOrEdits, val) => {
    const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
      ? keyOrEdits : { [keyOrEdits]: val };
    setValues((prev) => ({ ...prev, ...edits }));
    window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
    // Same-window signal so in-page listeners (deck-stage rail thumbnails)
    // can react — the parent message only reaches the host, not peers.
    window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
  }, []);
  return [values, setTweak];
}

// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', noDeckControls = false, collapsibleSections = false, reorderableSections, persistKey, registerToggle, onOpenChange, hostProtocol = true, disableKeyToggle = false, children }) {
  const [open, setOpen] = React.useState(false);
  // Report open-state to a parent (the Inspector mirrors it onto its floating
  // toggle button). Fires on every change incl. the panel's own ✕ close.
  React.useEffect(() => { if (typeof onOpenChange === 'function') onOpenChange(open); }, [open, onOpenChange]);
  // When the panel is opened via a keyboard shortcut we move focus INTO it
  // (keyboard-friendly). Set by keyToggle/onKey before opening; consumed by the
  // focus effect once the enter transition has flipped `show`.
  const focusOnOpenRef = React.useRef(false);
  // Animated mount/unmount. `mounted` keeps the panel in the DOM through its
  // exit transition; `show` drives the data-show attribute (flips a frame
  // after mount for the enter, and off before unmount for the exit).
  const [mounted, setMounted] = React.useState(false);
  const [show, setShow] = React.useState(false);
  React.useEffect(() => {
    if (open) { setMounted(true); return undefined; }
    setShow(false);
    if (!mounted) return undefined;
    const id = setTimeout(() => setMounted(false), 280); // > transform .24s
    return () => clearTimeout(id);
  }, [open, mounted]);
  React.useEffect(() => {
    if (!(open && mounted)) return undefined;
    const r = requestAnimationFrame(() => setShow(true));
    return () => cancelAnimationFrame(r);
  }, [open, mounted]);
  const dragRef = React.useRef(null);
  // Ensure the shared styled-tooltip driver is present so [data-tip] on panel
  // controls renders the .tip chip (works for host-protocol AND inspector
  // panels, which pass hostProtocol={false}). No-op if already loaded.
  React.useEffect(() => { ensureTweaksTooltip(); }, []);
  // Auto-inject a rail toggle when a <deck-stage> is on the page. The
  // toggle drives the deck's per-viewer _railVisible via window message;
  // state is mirrored from the same localStorage key the deck reads so
  // the control reflects reality across reloads. The mechanism is the
  // message — authors who want custom placement can post it directly
  // and pass noDeckControls to suppress this one.
  const hasDeckStage = React.useMemo(
    () => typeof document !== 'undefined' && !!document.querySelector('deck-stage'),
    [],
  );
  // deck-stage enables its rail in connectedCallback, but this panel can
  // mount before that element has upgraded. The initial read catches the
  // common case; the listener covers mounting first. (Older deck-stage.js
  // copies still wait for the host's __omelette_rail_enabled postMessage —
  // same listener handles those.)
  const [railEnabled, setRailEnabled] = React.useState(
    () => hasDeckStage && !!document.querySelector('deck-stage')?._railEnabled,
  );
  React.useEffect(() => {
    if (!hasDeckStage || railEnabled) return undefined;
    const onMsg = (e) => {
      if (e.data && e.data.type === '__omelette_rail_enabled') setRailEnabled(true);
    };
    window.addEventListener('message', onMsg);
    return () => window.removeEventListener('message', onMsg);
  }, [hasDeckStage, railEnabled]);
  const [railVisible, setRailVisible] = React.useState(() => {
    try { return localStorage.getItem('deck-stage.railVisible') !== '0'; } catch (e) { return true; }
  });
  const toggleRail = (on) => {
    setRailVisible(on);
    window.postMessage({ type: '__deck_rail_visible', on }, '*');
  };
  // Floating position: x = offset from the right edge; the VERTICAL anchor is
  // adaptive — mode 'top'|'bottom' + y = offset from THAT edge. Sections
  // collapsing/expanding resize the panel, and the anchored edge stays put, so
  // the panel grows away from the nearer viewport edge instead of pushing
  // itself off-screen (the old fixed bottom anchor did exactly that when the
  // panel was parked near the top and then expanded). mode null = no anchor
  // decision yet (renders as the natural bottom-right spawn); reanchor()
  // resolves it — a full-height panel resolves to the configurable default
  // (StudioKit.config.panels.anchorDefault; 'top' when absent, incl. the DS
  // twin, per the host DS's choice).
  const posRef = React.useRef({ x: 16, y: 16, mode: null });
  const PAD = 16;
  const anchorDefault = () => {
    try {
      const v = window.StudioKit && window.StudioKit.config && window.StudioKit.config.panels && window.StudioKit.config.panels.anchorDefault;
      if (v === 'top' || v === 'bottom') return v;
    } catch (e) {}
    return 'top';
  };

  const clampToViewport = React.useCallback(() => {
    const panel = dragRef.current;
    if (!panel) return;
    const w = panel.offsetWidth, h = panel.offsetHeight;
    const p = posRef.current;
    p.x = Math.min(Math.max(PAD, window.innerWidth - w - PAD), Math.max(PAD, p.x));
    // Same clamp for either vertical mode: when the growing edge runs out of
    // room the offset shrinks, so the panel continues expanding the OTHER way
    // until it takes the whole available height (then .twk-body scrolls).
    p.y = Math.min(Math.max(PAD, window.innerHeight - h - PAD), Math.max(PAD, p.y));
    panel.style.right = p.x + 'px';
    if (p.mode === 'top') { panel.style.top = p.y + 'px'; panel.style.bottom = 'auto'; }
    else { panel.style.bottom = p.y + 'px'; panel.style.top = 'auto'; }
    panel.setAttribute('data-anchor', p.mode === 'top' ? 'top' : 'bottom');
  }, []);

  // Re-decide the vertical anchor from where the panel RESTS: nearer the top
  // edge → anchor top (expands toward the bottom), nearer the bottom → anchor
  // bottom (expands toward the top). A full-height panel keeps its LAST anchor
  // (no proximity signal); if it never had one, the configured default applies.
  // Runs on drop (drag end) and after every panel/viewport resize.
  const reanchor = React.useCallback(() => {
    const panel = dragRef.current;
    if (!panel) return;
    const h = panel.offsetHeight, vh = window.innerHeight;
    const p = posRef.current;
    if (h >= vh - 2 * PAD - 1) {
      if (!p.mode) { p.mode = anchorDefault(); p.y = PAD; }
    } else {
      const top = p.mode === 'top' ? p.y : vh - p.y - h;
      const mode = top <= vh - top - h ? 'top' : 'bottom';
      p.mode = mode;
      p.y = mode === 'top' ? top : vh - top - h;
    }
    clampToViewport();
  }, [clampToViewport]);

  // Drag-to-reorder panel FOLLOW: while a section header is dragged the
  // sections fold, which can strand the dragged chip far from its neighbours
  // — the reorder engine asks the HOST (via panelShift) to move the whole
  // panel so the dragged section stays under the cursor with its neighbours
  // adjacent. shift() absorbs what the viewport clamp allows and reports how
  // far the panel actually moved (the chip translates the rest); reset()
  // restores the pre-drag position on drop/cancel with a settle glide
  // (composite:'add' so the base inv-zoom scale survives; reduced-motion
  // skips it).
  const dragShift = React.useMemo(() => {
    let saved = null;
    return {
      shift(dy) {
        const panel = dragRef.current;
        if (!panel || !dy) return 0;
        const p = posRef.current;
        if (!saved) saved = { mode: p.mode, y: p.y };
        const before = panel.getBoundingClientRect().top;
        p.y += p.mode === 'top' ? dy : -dy;
        clampToViewport();
        return panel.getBoundingClientRect().top - before;
      },
      reset() {
        if (!saved) return;
        const panel = dragRef.current;
        const p = posRef.current;
        const before = panel ? panel.getBoundingClientRect().top : 0;
        p.mode = saved.mode; p.y = saved.y;
        saved = null;
        if (!panel) return;
        clampToViewport();
        const d = before - panel.getBoundingClientRect().top;
        let rm = false;
        try { rm = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
        if (Math.abs(d) > 1 && !rm) {
          try {
            const a = panel.animate(
              [{ transform: 'translateY(' + d + 'px)' }, { transform: 'translateY(0)' }],
              { duration: 200, easing: 'cubic-bezier(.3,.7,.4,1)', composite: 'add' },
            );
            a.id = 'twk-flip';
          } catch (e) {}
        }
      },
    };
  }, [clampToViewport]);

  // ── Collapsible-section census + collapse/expand-all + drag-reorder ───────
  // Sections report their open state (registerOpen) so the adaptive header
  // button knows whether to collapse or expand; the button bumps allCmd and
  // every section follows (each persisting its own key). Reorder: TweakSection
  // children are re-sequenced by the persisted order and their headers become
  // drag handles (useTwkSectionReorder). Mixed children are safe — non-section
  // children keep their positions; duplicate labels disable reorder (the ids
  // would collide).
  const sectNs = persistKey || title;
  const openMapRef = React.useRef(new Map());
  const [, bumpCensus] = React.useReducer((x) => x + 1, 0);
  const registerOpen = React.useCallback((sid, openState) => {
    const m = openMapRef.current;
    if (openState == null) m.delete(sid); else m.set(sid, openState);
    bumpCensus();
  }, []);
  const [allCmd, setAllCmd] = React.useState({ n: 0, collapsed: false });
  const anySectCollapsed = Array.from(openMapRef.current.values()).some((v) => v === false);
  const kidsArr = React.Children.toArray(children);
  const isSect = (k) => React.isValidElement(k) && k.type === TweakSection;
  const sectIdOf = (k) => String(k.props.id || k.props.label || '');
  const sectIds = kidsArr.filter(isSect).map(sectIdOf);
  const reorderOn = !!collapsibleSections && sectIds.length > 1 &&
    new Set(sectIds).size === sectIds.length &&
    (reorderableSections == null ? true : !!reorderableSections);
  const reorder = useTwkSectionReorder({ ns: sectNs, ids: sectIds, enabled: reorderOn, panelShift: dragShift });
  let kidsOut = children;
  if (reorderOn) {
    const byId = new Map();
    kidsArr.forEach((k) => { if (isSect(k)) byId.set(sectIdOf(k), k); });
    const queue = reorder.orderedIds.map((oid) => byId.get(oid)).filter(Boolean);
    let qi = 0;
    kidsOut = kidsArr.map((k) => (isSect(k) ? queue[qi++] : k));
  }

  React.useEffect(() => {
    if (!open || !mounted) return;
    reanchor();
    const onSize = () => { clampToViewport(); reanchor(); };
    if (typeof ResizeObserver === 'undefined') {
      window.addEventListener('resize', onSize);
      return () => window.removeEventListener('resize', onSize);
    }
    const ro = new ResizeObserver(onSize);
    ro.observe(document.documentElement);
    // The panel itself: section collapse/expand resizes it — clamp keeps it
    // on-screen mid-transition, reanchor updates the mode once it rests.
    if (dragRef.current) ro.observe(dragRef.current);
    return () => ro.disconnect();
  }, [open, mounted, clampToViewport, reanchor]);

  // Host protocol (toolbar / standalone-cluster integration). An Inspector
  // panel driven purely via registerToggle passes hostProtocol={false} so it
  // is NOT detected as a generic Tweaks panel (no __edit_mode_available) and
  // ignores the activate/deactivate broadcast.
  React.useEffect(() => {
    if (!hostProtocol) return undefined;
    const onMsg = (e) => {
      const t = e?.data?.type;
      if (t === '__activate_edit_mode') setOpen(true);
      else if (t === '__deactivate_edit_mode') setOpen(false);
    };
    window.addEventListener('message', onMsg);
    window.parent.postMessage({ type: '__edit_mode_available' }, '*');
    // Same-window signal for in-page chrome (the standalone Inspector cluster's
    // Tweaks button): the postMessage above targets window.parent, which is NOT
    // this window when the file is embedded in an iframe (DS tab / playground),
    // so a listener on THIS window would never hear it.
    window.__fpTweaksPanelMounted = true;
    window.dispatchEvent(new Event('fp-tweaks-available'));
    return () => window.removeEventListener('message', onMsg);
  }, [hostProtocol]);

  // Same-window open-state broadcast — lets in-page chrome (the standalone
  // Inspector cluster's Tweaks button) mirror the panel no matter WHAT toggled
  // it: the host toolbar, the panel's own ✕, the T/backtick key, or the cluster
  // itself. postMessage can't serve here — the panel talks to window.parent,
  // which is the HOST app when this file runs inside the preview iframe, so a
  // same-window listener never hears the ✕/key close (the exact desync this
  // fixes). Listeners must be reflect-only (no re-toggle) so there's no echo.
  React.useEffect(() => {
    if (!hostProtocol) return;
    window.dispatchEvent(new CustomEvent('fp-tweaks-open-change', { detail: { open } }));
  }, [hostProtocol, open]);

  // Unified toggle used by the internal T-key handler AND by external callers
  // (e.g. the component playground routes its `T` / backtick shortcuts here so
  // they work even while focus is inside a component iframe). Opening locally
  // does NOT re-post __edit_mode_available — the host would answer with its
  // current (off) toolbar state and echo a deactivate, slamming the panel shut
  // on the same keypress (the documented reopen-blink pitfall). Closing posts
  // __edit_mode_dismissed so the host toolbar flips off in lockstep.
  const keyToggle = React.useCallback((force) => {
    setOpen((prev) => {
      const next = typeof force === 'boolean' ? force : !prev;
      if (next === prev) return prev;
      if (next) focusOnOpenRef.current = true;
      else if (hostProtocol) window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
      return next;
    });
  }, []);

  // Expose the toggle to a parent that wants to drive it (playground). Passing
  // disableKeyToggle then suppresses the built-in document T-key listener so the
  // parent owns the keyboard contract (and can also reach focus inside iframes).
  React.useEffect(() => {
    if (typeof registerToggle === 'function') registerToggle(keyToggle);
  }, [registerToggle, keyToggle]);

  // Move focus into the panel when it was opened via keyboard.
  React.useEffect(() => {
    if (!(open && mounted && show) || !focusOnOpenRef.current) return;
    focusOnOpenRef.current = false;
    const panel = dragRef.current;
    if (!panel) return;
    const first = panel.querySelector(
      '.twk-body button, .twk-body select, .twk-body input, .twk-body [tabindex]',
    );
    (first || panel.querySelector('.twk-x') || panel).focus();
  }, [open, mounted, show]);

  // T-key toggle — show/hide the panel without going through the host
  // toolbar. Useful in standalone "Present > New tab" views where the
  // host toolbar isn't visible. Suppressed when disableKeyToggle is set (the
  // parent owns the keys then). Ignored while typing in form fields, with any
  // modifier key down, or when a tweak field inside this panel has focus.
  React.useEffect(() => {
    if (disableKeyToggle) return undefined;
    const onKey = (e) => {
      // T (mnemonic) or backtick ` (alt, matches the cluster button hint "T | `").
      if (e.key !== 't' && e.key !== 'T' && e.key !== '`' && e.code !== 'Backquote') return;
      if (e.ctrlKey || e.metaKey || e.altKey) return;
      const el = e.target;
      if (!el) return;
      const tag = (el.tagName || '').toLowerCase();
      if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
      if (el.isContentEditable) return;
      e.preventDefault();
      keyToggle();
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [disableKeyToggle, keyToggle]);

  const dismiss = () => {
    setOpen(false);
    if (hostProtocol) window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
  };

  const onDragStart = (e) => {
    const panel = dragRef.current;
    if (!panel) return;
    // Pointer events (not mouse) so the header drags on touch / pen too; capture
    // keeps moves tracking outside the header, and touch-action:none on .twk-hd
    // stops the browser claiming the drag gesture for scroll.
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
    const r = panel.getBoundingClientRect();
    const sx = e.clientX, sy = e.clientY;
    const startRight = window.innerWidth - r.right;
    const startBottom = window.innerHeight - r.bottom;
    const startTop = r.top;
    const move = (ev) => {
      const p = posRef.current;
      p.x = startRight - (ev.clientX - sx);
      p.y = p.mode === 'top' ? startTop + (ev.clientY - sy) : startBottom - (ev.clientY - sy);
      clampToViewport();
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', up);
      reanchor(); // the drop point is the anchor decision ("last anchor point")
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', up);
  };

  if (!mounted) return null;
  return (
    <>
      <style>{__TWEAKS_STYLE}</style>
      <div ref={dragRef} className="twk-panel" data-noncommentable="" data-show={show ? '1' : '0'}
           style={{ right: posRef.current.x,
                    top: posRef.current.mode === 'top' ? posRef.current.y : 'auto',
                    bottom: posRef.current.mode === 'top' ? 'auto' : posRef.current.y }}>
        <div className="twk-hd" onPointerDown={onDragStart}>
          <b>{title}</b>
          <div className="twk-hd-tools">
            {collapsibleSections && (
              <button className="twk-hbtn" type="button"
                      aria-label={anySectCollapsed ? skStr('panel.sections.expandAll', 'Expand all sections') : skStr('panel.sections.collapseAll', 'Collapse all sections')}
                      data-tip={anySectCollapsed ? skStr('panel.sections.expandAll', 'Expand all sections') : skStr('panel.sections.collapseAll', 'Collapse all sections')}
                      onPointerDown={(e) => e.stopPropagation()}
                      onClick={() => setAllCmd((cmd) => ({ n: cmd.n + 1, collapsed: !anySectCollapsed }))}>
                {anySectCollapsed ? (
                  <svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true"><path d="M2.5 4 5.5 1.3 8.5 4M2.5 7l3 2.7L8.5 7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
                ) : (
                  <svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true"><path d="M2.5 1.3 5.5 4l3-2.7M2.5 9.7 5.5 7l3 2.7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
                )}
              </button>
            )}
            <button className="twk-x" aria-label={skStr('panel.closeAria', 'Close tweaks')} data-tip={skStr('panel.close', 'Close')}
                    onPointerDown={(e) => e.stopPropagation()}
                    onClick={dismiss}>✕</button>
          </div>
        </div>
        <div className={reorderOn ? 'twk-body twk-reorder' : 'twk-body'} ref={reorder.containerRef}>
          <TwkSectionCtx.Provider value={{ collapsible: collapsibleSections, ns: sectNs,
              reorder: reorderOn ? { beginDrag: reorder.beginDrag, managed: sectIds } : null,
              registerOpen: collapsibleSections ? registerOpen : null, allCmd }}>
            {kidsOut}
            {hasDeckStage && railEnabled && !noDeckControls && (
              <TweakSection label={skStr('panel.deck.section', 'Deck')} reorderable={false}>
                <TweakToggle label={skStr('panel.deck.rail', 'Thumbnail rail')} value={railVisible} onChange={toggleRail} />
              </TweakSection>
            )}
          </TwkSectionCtx.Provider>
        </div>
      </div>
    </>
  );
}

// ── Layout helpers ──────────────────────────────────────────────────────────

function TweakSection({ id, label, defaultOpen = true, reorderable = true, children }) {
  const ctx = React.useContext(TwkSectionCtx);
  const secId = String(id || label || '');
  const storageKey = 'twk-collapse:' + ctx.ns + ':' + (id || label);
  const [open, setOpen] = React.useState(() => {
    if (!ctx.collapsible) return true;
    try {
      const v = localStorage.getItem(storageKey);
      if (v === '0') return false;
      if (v === '1') return true;
    } catch (e) {}
    return defaultOpen;
  });

  // A collapsed section's controls are still in the DOM (height 0, clipped), so
  // without this they stay TAB-focusable and tooltip-able. `inert` removes the
  // collapsed body from focus order + the a11y tree (set via the DOM property
  // for cross-version support). See notes/pitfalls.md (collapsed-but-present).
  const bodyRef = React.useRef(null);
  React.useEffect(() => { if (bodyRef.current) bodyRef.current.inert = !open; }, [open]);

  const setOpenPersist = (next) => {
    setOpen(next);
    try { localStorage.setItem(storageKey, next ? '1' : '0'); } catch (e) {}
  };

  // Report open-state to the panel (drives the adaptive collapse/expand-all
  // header button) and follow its broadcast.
  const registerOpen = ctx.registerOpen;
  React.useEffect(() => {
    if (!ctx.collapsible || !registerOpen) return undefined;
    registerOpen(secId, open);
    return () => registerOpen(secId, null);
  }, [ctx.collapsible, registerOpen, secId, open]);
  const allN = ctx.allCmd ? ctx.allCmd.n : 0;
  const allRef = React.useRef(allN);
  React.useEffect(() => {
    if (allN === allRef.current) return;
    allRef.current = allN;
    if (ctx.collapsible) setOpenPersist(!ctx.allCmd.collapsed);
  }, [allN]); // eslint-disable-line react-hooks/exhaustive-deps

  if (!ctx.collapsible) {
    return (
      <>
        <div className="twk-sect">{label}</div>
        {children}
      </>
    );
  }

  const toggle = () => setOpenPersist(!open);
  // Only sections the panel MANAGES may join the drag machinery. A
  // TweakSection reaching the DOM through a wrapper COMPONENT child (not a
  // direct <TweakSection> child) is invisible to the panel's child scan —
  // it is never part of the reorder ids nor re-sequenced — yet it renders
  // here with ctx.reorder set. If it carried data-twk-sec it would join the
  // slot GRID and stackBounds while being absent from sess.order: a 7-slot
  // grid spliced into a 5-item order rendered the drop gap rows below the
  // chip (cos-library Tweaks, user-caught via the ?twkdebug overlay — the
  // sess-vs-dom readout showed the set mismatch). Unmanaged sections render
  // as plain fixed sections: no handle, no grid membership.
  const managed = !!(ctx.reorder && ctx.reorder.managed && ctx.reorder.managed.indexOf(secId) !== -1);
  const canDrag = !!(ctx.reorder && reorderable && managed);

  return (
    <div className="twk-sect-grp" data-twk-sec={canDrag ? secId : undefined}>
      <button type="button" className="twk-sect-btn" aria-expanded={open} onClick={toggle}
              onPointerDown={canDrag ? ctx.reorder.beginDrag(secId) : undefined}>
        <span className="twk-sect-lbl">{label}</span>
        <span className="twk-sect-sep" aria-hidden="true"></span>
        <svg className="twk-sect-chev" width="9" height="6" viewBox="0 0 10 6" aria-hidden="true">
          <path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5"
                strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </button>
      <div className="twk-sect-collapse" data-open={open ? '1' : '0'}>
        <div className="twk-sect-body" ref={bodyRef} aria-hidden={!open}>
          {children}
        </div>
      </div>
    </div>
  );
}

function TweakRow({ label, value, children, inline = false }) {
  return (
    <div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
      <div className="twk-lbl">
        <span>{label}</span>
        {value != null && <span className="twk-val">{value}</span>}
      </div>
      {children}
    </div>
  );
}

// ── Controls ────────────────────────────────────────────────────────────────

function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
  return (
    <TweakRow label={label} value={`${value}${unit}`}>
      <input type="range" className="twk-slider" min={min} max={max} step={step}
             value={value} onChange={(e) => onChange(Number(e.target.value))} />
    </TweakRow>
  );
}

function TweakToggle({ label, value, onChange }) {
  return (
    <div className="twk-row twk-row-h">
      <div className="twk-lbl"><span>{label}</span></div>
      <button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
              role="switch" aria-checked={!!value}
              onClick={() => onChange(!value)}><i /></button>
    </div>
  );
}

function TweakRadio({ label, value, options, onChange }) {
  const trackRef = React.useRef(null);
  const [dragging, setDragging] = React.useState(false);
  // The active value is read by pointer-move handlers attached for the lifetime
  // of a drag — ref it so a stale closure doesn't fire onChange for every move.
  const valueRef = React.useRef(value);
  valueRef.current = value;

  // Segments wrap mid-word once per-segment width runs out. The track is
  // ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
  // to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
  // options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
  // back to a dropdown rather than wrap.
  const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
  const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
  const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
  if (!fitsAsSegments) {
    // <select> emits strings — map back to the original option value so the
    // fallback stays type-preserving (numbers, booleans) like the segment path.
    const resolve = (s) => {
      const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
      return m === undefined ? s : typeof m === 'object' ? m.value : m;
    };
    return <TweakSelect label={label} value={value} options={options}
                        onChange={(s) => onChange(resolve(s))} />;
  }
  const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
  const idx = Math.max(0, opts.findIndex((o) => o.value === value));
  const n = opts.length;

  const segAt = (clientX) => {
    const r = trackRef.current.getBoundingClientRect();
    const inner = r.width - 4;
    const i = Math.floor(((clientX - r.left - 2) / inner) * n);
    return opts[Math.max(0, Math.min(n - 1, i))].value;
  };

  // Keyboard: the segments are real focusable <button>s, so Enter/Space fire
  // their click (handled per-button below — the pointer path alone left them
  // dead to the keyboard); arrows/Home/End on the group change the VALUE
  // directly (classic radiogroup model). Bail on modifier keys.
  const onKey = (e) => {
    if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
    const k = e.key;
    let next = null;
    if (k === 'ArrowLeft' || k === 'ArrowUp') next = Math.max(0, idx - 1);
    else if (k === 'ArrowRight' || k === 'ArrowDown') next = Math.min(n - 1, idx + 1);
    else if (k === 'Home') next = 0;
    else if (k === 'End') next = n - 1;
    if (next == null) return;
    e.preventDefault(); e.stopPropagation();
    if (opts[next].value !== valueRef.current) onChange(opts[next].value);
  };

  const onPointerDown = (e) => {
    setDragging(true);
    const v0 = segAt(e.clientX);
    if (v0 !== valueRef.current) onChange(v0);
    const move = (ev) => {
      if (!trackRef.current) return;
      const v = segAt(ev.clientX);
      if (v !== valueRef.current) onChange(v);
    };
    const up = () => {
      setDragging(false);
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', up);
  };

  return (
    <TweakRow label={label}>
      <div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown} onKeyDown={onKey}
           className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
        <div className="twk-seg-thumb"
             style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
                      width: `calc((100% - 4px) / ${n})` }} />
        {opts.map((o) => (
          <button key={o.value} type="button" role="radio" aria-checked={o.value === value}
                  onClick={() => { if (o.value !== valueRef.current) onChange(o.value); }}>
            {o.label}
          </button>
        ))}
      </div>
    </TweakRow>
  );
}

// TweakSelect — styled combobox replacing the native <select>. Same API as
// before ({ label, value, options, onChange }; options are strings or
// { value, label }) and onChange still emits the option value as a STRING, so
// TweakRadio's dropdown fallback (which maps the string back to the typed
// value) is unchanged. See the .twk-selbtn / .twk-combo-* CSS for why the list
// is portaled. Full ARIA-1.2 combobox keyboard model + type-ahead.
function TweakSelect({ label, value, options, onChange, placeholder }) {
  const opts = React.useMemo(
    () => (options || []).map((o) => (typeof o === 'object' ? o : { value: o, label: String(o) })),
    [options],
  );
  const curIdx = opts.findIndex((o) => String(o.value) === String(value));
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState(() => (curIdx >= 0 ? curIdx : 0));
  const [pos, setPos] = React.useState(null);
  const triggerRef = React.useRef(null);
  const listRef = React.useRef(null);
  const typeRef = React.useRef({ buf: '', t: 0 });
  const uid = React.useRef('twksel-' + Math.random().toString(36).slice(2, 8)).current;
  const canPortal = typeof ReactDOM !== 'undefined' && ReactDOM.createPortal;

  // Wheel scans options ONLY while the control is ENGAGED (user feedback,
  // Jul 2026): dropdown OPEN → wheel over the trigger STEPS THE COMMITTED
  // VALUE in place, list stays open (round 2 feedback — moving just the
  // highlight left no way to actually change anything); CLOSED trigger with
  // FOCUS → steps the committed value (mirrors ←/→). A plain hover-scroll
  // passes through untouched — the old always-on cycling made a panel with a
  // column of combos unscrollable, changing values in the wheel's path.
  // NATIVE non-passive listener — React root wheel listeners are passive, so
  // an onWheel prop could never preventDefault the panel scroll.
  const curRef = React.useRef(curIdx); curRef.current = curIdx;
  const openRef = React.useRef(open); openRef.current = open;
  const onChangeRef = React.useRef(onChange); onChangeRef.current = onChange;
  React.useEffect(() => {
    const el = triggerRef.current;
    if (!el) return undefined;
    const onWheel = (e) => {
      const dir = e.deltaY > 0 ? 1 : -1;
      if (!openRef.current && document.activeElement !== el) return;   // not engaged — let the panel scroll
      e.preventDefault(); e.stopPropagation();
      const from = curRef.current < 0 ? 0 : curRef.current;
      const i = Math.max(0, Math.min(opts.length - 1, from + dir));
      if (openRef.current) setActive(i);
      if (i !== curRef.current) { const o = opts[i]; if (o) onChangeRef.current(String(o.value)); }
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [opts]);

  // Position the portaled list from the trigger's live viewport rect. Flip
  // above the trigger when there isn't room below and there is above.
  const place = React.useCallback(() => {
    const el = triggerRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const vh = window.innerHeight || document.documentElement.clientHeight;
    const vw = window.innerWidth || document.documentElement.clientWidth;
    const below = vh - r.bottom - 8;
    const above = r.top - 8;
    const want = Math.min(264, opts.length * 30 + 8);
    const up = below < Math.min(want, 150) && above > below;
    setPos({
      left: Math.round(Math.max(8, Math.min(r.left, vw - r.width - 8))),
      width: Math.round(r.width),
      up,
      top: up ? null : Math.round(r.bottom + 4),
      bottom: up ? Math.round(vh - r.top + 4) : null,
      maxH: Math.max(96, Math.round((up ? above : below) - 4)),
    });
  }, [opts.length]);

  // While open: place before paint, then KEEP the list anchored by re-placing
  // from the trigger's live rect on any host scroll/resize (round-2 feedback —
  // the old close-on-scroll ALSO closed on the LIST's OWN scrolls: the
  // capture-phase listener saw the keyboard-follow scrollTop writes and wheel
  // scrolling, so arrowing to an off-screen option dismissed the dropdown).
  // The list's own scroll needs no re-anchor; outside pointerdown still closes.
  React.useLayoutEffect(() => {
    if (!open) return undefined;
    place();
    const onScroll = (e) => {
      if (listRef.current && e.target === listRef.current) return;   // list's own scroll
      place();
    };
    const onDown = (e) => {
      const t = e.target;
      if ((triggerRef.current && triggerRef.current.contains(t)) ||
          (listRef.current && listRef.current.contains(t))) return;
      setOpen(false);
    };
    window.addEventListener('scroll', onScroll, true);
    window.addEventListener('resize', onScroll);
    document.addEventListener('pointerdown', onDown, true);
    return () => {
      window.removeEventListener('scroll', onScroll, true);
      window.removeEventListener('resize', onScroll);
      document.removeEventListener('pointerdown', onDown, true);
    };
  }, [open, place]);

  // Wheel INSIDE the open (portaled) list: an OVERFLOWING list scrolls via a
  // manual clamped scrollTop write — preventDefault stops the browser's
  // scroll-chaining into the page/panel at the list's ends. A fully-visible
  // list passes the wheel to the panel body under the trigger (found by
  // walking the trigger's ancestors — the list is portaled to <body>, so
  // native chaining would scroll the PAGE, not the panel); the re-place-on-
  // scroll above keeps the open list anchored while the panel moves.
  React.useEffect(() => {
    if (!open) return undefined;
    const el = listRef.current;
    if (!el) return undefined;
    const onWheel = (e) => {
      if (el.scrollHeight > el.clientHeight + 1) {
        e.preventDefault(); e.stopPropagation();
        el.scrollTop += e.deltaY;
        return;
      }
      let n = triggerRef.current && triggerRef.current.parentElement;
      while (n) {
        const cs = getComputedStyle(n);
        if (/(auto|scroll)/.test(cs.overflowY) && n.scrollHeight > n.clientHeight + 1) {
          e.preventDefault(); e.stopPropagation();
          n.scrollTop += e.deltaY;
          return;
        }
        n = n.parentElement;
      }
      // no scrollable host — let the page handle it (list re-places itself)
    };
    el.addEventListener('wheel', onWheel, { passive: false });
    return () => el.removeEventListener('wheel', onWheel);
  }, [open, pos]);

  // Keep the active option scrolled into view (manual — never scrollIntoView).
  React.useEffect(() => {
    if (!open || !listRef.current) return;
    const node = listRef.current.querySelector('[data-active="1"]');
    if (!node) return;
    const lr = listRef.current.getBoundingClientRect();
    const ir = node.getBoundingClientRect();
    if (ir.top < lr.top) listRef.current.scrollTop -= (lr.top - ir.top);
    else if (ir.bottom > lr.bottom) listRef.current.scrollTop += (ir.bottom - lr.bottom);
  }, [open, active]);

  const openMenu = (startIdx) => {
    setActive(startIdx != null ? startIdx : (curIdx >= 0 ? curIdx : 0));
    setOpen(true);
  };
  const commit = (i) => {
    const o = opts[i];
    if (o) onChange(String(o.value));
    setOpen(false);
    if (triggerRef.current) triggerRef.current.focus();
  };
  const typeAhead = (ch) => {
    const s = typeRef.current;
    const now = Date.now();
    s.buf = (now - s.t > 600 ? '' : s.buf) + ch.toLowerCase();
    s.t = now;
    const low = (x) => String(x).toLowerCase();
    let i = opts.findIndex((o) => low(o.label).startsWith(s.buf));
    if (i < 0) i = opts.findIndex((o) => low(o.label).includes(s.buf));
    if (i >= 0) { setActive(i); if (!open) openMenu(i); }
  };

  // stopPropagation on every handled key so the document-level panel T-key and
  // the inspector letter/backslash shortcuts don't also fire while typing here.
  const onKey = (e) => {
    const k = e.key;
    if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && k !== ' ') {
      e.preventDefault(); e.stopPropagation(); typeAhead(k); return;
    }
    if (!open) {
      // Closed-state value stepping — the DS combo model: ←/→ step, Home/End
      // jump, ↓/↑/Enter/Space OPEN (ARIA combobox default). Commit stays closed.
      const stepTo = (i) => {
        const o = opts[Math.max(0, Math.min(opts.length - 1, i))];
        if (o && String(o.value) !== String(value)) onChange(String(o.value));
      };
      if (k === 'ArrowLeft')  { e.preventDefault(); e.stopPropagation(); stepTo((curIdx < 0 ? 0 : curIdx) - 1); return; }
      if (k === 'ArrowRight') { e.preventDefault(); e.stopPropagation(); stepTo((curIdx < 0 ? 0 : curIdx) + 1); return; }
      if (k === 'Home')       { e.preventDefault(); e.stopPropagation(); stepTo(0); return; }
      if (k === 'End')        { e.preventDefault(); e.stopPropagation(); stepTo(opts.length - 1); return; }
      if (k === 'ArrowDown' || k === 'ArrowUp' || k === 'Enter' || k === ' ') {
        e.preventDefault(); e.stopPropagation(); openMenu(); return;
      }
      return;
    }
    if (k === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); setActive((a) => Math.min(opts.length - 1, a + 1)); return; }
    if (k === 'ArrowUp')   { e.preventDefault(); e.stopPropagation(); setActive((a) => Math.max(0, a - 1)); return; }
    if (k === 'Home')      { e.preventDefault(); e.stopPropagation(); setActive(0); return; }
    if (k === 'End')       { e.preventDefault(); e.stopPropagation(); setActive(opts.length - 1); return; }
    if (k === 'Enter' || k === ' ') { e.preventDefault(); e.stopPropagation(); commit(active); return; }
    if (k === 'Escape')    { e.preventDefault(); e.stopPropagation(); setOpen(false); if (triggerRef.current) triggerRef.current.focus(); return; }
    if (k === 'Tab')       { setOpen(false); return; }  // let Tab move focus
  };

  const current = curIdx >= 0 ? opts[curIdx] : null;
  const menu = (open && pos) ? (
    <ul ref={listRef} className="twk-combo-list" role="listbox" id={uid}
        data-up={pos.up ? '1' : '0'} data-noncommentable=""
        style={{ left: pos.left, width: pos.width,
                 top: pos.top != null ? pos.top : undefined,
                 bottom: pos.bottom != null ? pos.bottom : undefined,
                 maxHeight: pos.maxH }}>
      {opts.map((o, i) => (
        <li key={String(o.value)} id={uid + '-' + i} role="option"
            className="twk-combo-item" aria-selected={i === curIdx}
            data-active={i === active ? '1' : '0'}
            onMouseEnter={() => setActive(i)} onClick={() => commit(i)}>
          <span className="twk-combo-lbl">{o.label}</span>
          <svg className="twk-combo-check" viewBox="0 0 14 14" aria-hidden="true">
            <path d="M3 7.2 5.8 10 11 4.2" fill="none" stroke="currentColor"
                  strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </li>
      ))}
    </ul>
  ) : null;

  return (
    <TweakRow label={label}>
      <button type="button" ref={triggerRef} className="twk-selbtn"
              role="combobox" aria-haspopup="listbox" aria-expanded={open}
              aria-controls={uid}
              aria-activedescendant={open ? uid + '-' + active : undefined}
              onClick={() => (open ? setOpen(false) : openMenu())} onKeyDown={onKey}>
        <span className={current ? 'twk-selval' : 'twk-selval placeholder'}>
          {current ? current.label : (placeholder || skStr('panel.select.placeholder', 'Select\u2026'))}
        </span>
        <svg className="twk-selchev" viewBox="0 0 10 6" aria-hidden="true">
          <path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.5"
                strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </button>
      {menu && (canPortal ? ReactDOM.createPortal(menu, document.body) : menu)}
    </TweakRow>
  );
}

function TweakText({ label, value, placeholder, onChange }) {
  return (
    <TweakRow label={label}>
      <input className="twk-field" type="text" value={value} placeholder={placeholder}
             onChange={(e) => onChange(e.target.value)} />
    </TweakRow>
  );
}

function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
  const clamp = (n) => {
    if (min != null && n < min) return min;
    if (max != null && n > max) return max;
    return n;
  };
  const startRef = React.useRef({ x: 0, val: 0 });
  const onScrubStart = (e) => {
    e.preventDefault();
    startRef.current = { x: e.clientX, val: value };
    const decimals = (String(step).split('.')[1] || '').length;
    const move = (ev) => {
      const dx = ev.clientX - startRef.current.x;
      const raw = startRef.current.val + dx * step;
      const snapped = Math.round(raw / step) * step;
      onChange(clamp(Number(snapped.toFixed(decimals))));
    };
    const up = () => {
      window.removeEventListener('pointermove', move);
      window.removeEventListener('pointerup', up);
      window.removeEventListener('pointercancel', up);
    };
    window.addEventListener('pointermove', move);
    window.addEventListener('pointerup', up);
    window.addEventListener('pointercancel', up);
  };
  return (
    <div className="twk-num">
      <span className="twk-num-lbl" data-tip={skStr('panel.number.scrub', 'Drag to adjust')} onPointerDown={onScrubStart}>{label}</span>
      <input type="number" value={value} min={min} max={max} step={step}
             onChange={(e) => onChange(clamp(Number(e.target.value)))} />
      {unit && <span className="twk-num-unit">{unit}</span>}
    </div>
  );
}

// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
  const h = String(hex).replace('#', '');
  const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
  const n = parseInt(x.slice(0, 6), 16);
  if (Number.isNaN(n)) return true;
  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
  return r * 299 + g * 587 + b * 114 > 148000;
}

const __TwkCheck = ({ light }) => (
  <svg viewBox="0 0 14 14" aria-hidden="true">
    <path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
          strokeLinecap="round" strokeLinejoin="round"
          stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
  </svg>
);

// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
  if (!options || !options.length) {
    return (
      <div className="twk-row twk-row-h">
        <div className="twk-lbl"><span>{label}</span></div>
        <input type="color" className="twk-swatch" value={value}
               onChange={(e) => onChange(e.target.value)} />
      </div>
    );
  }
  // Native <input type=color> emits lowercase hex per the HTML spec, so
  // compare case-insensitively. String() guards JSON.stringify(undefined),
  // which returns the primitive undefined (no .toLowerCase).
  const key = (o) => String(JSON.stringify(o)).toLowerCase();
  const cur = key(value);
  return (
    <TweakRow label={label}>
      <div className="twk-chips" role="radiogroup">
        {options.map((o, i) => {
          const colors = Array.isArray(o) ? o : [o];
          const [hero, ...rest] = colors;
          const sup = rest.slice(0, 4);
          const on = key(o) === cur;
          return (
            <button key={i} type="button" className="twk-chip" role="radio"
                    aria-checked={on} data-on={on ? '1' : '0'}
                    aria-label={colors.join(', ')} data-tip={colors.join(' · ')}
                    style={{ background: hero }}
                    onClick={() => onChange(o)}>
              {sup.length > 0 && (
                <span>
                  {sup.map((c, j) => <i key={j} style={{ background: c }} />)}
                </span>
              )}
              {on && <__TwkCheck light={__twkIsLight(hero)} />}
            </button>
          );
        })}
      </div>
    </TweakRow>
  );
}

function TweakButton({ label, onClick, secondary = false }) {
  return (
    <button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
            onClick={onClick}>{label}</button>
  );
}

const __StudioKitPanels = {
  useTweaks, TweaksPanel, TweakSection, TweakRow,
  TweakSlider, TweakToggle, TweakRadio, TweakSelect,
  TweakText, TweakNumber, TweakColor, TweakButton,
  useTwkSectionReorder,
};
// Namespaced surface (preferred): window.StudioKit.Panels.TweaksPanel, …
if (typeof window !== 'undefined') {
  window.StudioKit = window.StudioKit || {};
  window.StudioKit.Panels = Object.assign(window.StudioKit.Panels || {}, __StudioKitPanels);
  // Expose the panel stylesheet string so a docs / standalone page can render
  // controls OUTSIDE a mounted <TweaksPanel> with correct styling (inject once).
  window.StudioKit.Panels.styleCss = __TWEAKS_STYLE;
  // Also expose the bare globals so a Babel page can use <TweaksPanel/> etc.
  // directly (matches the original tweaks-panel.jsx contract). Do NOT load this
  // module AND preview/tweaks-panel.jsx on the same page — the globals collide.
  Object.assign(window, __StudioKitPanels);
}
