// // tweaks-panel.jsx // // Reusable Tweaks shell + form-control helpers. // // // // 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 , 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 ( // //
// // Hello // // // // // // setTweak('fontSize', v)} /> // // setTweak('density', v)} /> // // // // setTweak('primaryColor', v)} /> // // setTweak('palette', v)} /> // // setTweak('dark', v)} /> // // // //
// // ); // // } // // // // ───────────────────────────────────────────────────────────────────────────── // 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; // transform:scale(var(--dc-inv-zoom,1));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} // .twk-hd{display:flex;align-items:center;justify-content:space-between; // padding:10px 8px 10px 14px;cursor:move;user-select: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-body{padding:2px 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} // .twk-sect:first-child{padding-top:0} // .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,"); // background-repeat:no-repeat;background-position:right 8px center} // .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} // .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} // .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))} // `; // // ── 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, children }) { // const [open, setOpen] = React.useState(false); // const dragRef = React.useRef(null); // // Auto-inject a rail toggle when a 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 }, '*'); // }; // const offsetRef = React.useRef({ x: 16, y: 16 }); // const PAD = 16; // const clampToViewport = React.useCallback(() => { // const panel = dragRef.current; // if (!panel) return; // const w = panel.offsetWidth, h = panel.offsetHeight; // const maxRight = Math.max(PAD, window.innerWidth - w - PAD); // const maxBottom = Math.max(PAD, window.innerHeight - h - PAD); // offsetRef.current = { // x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)), // y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)), // }; // panel.style.right = offsetRef.current.x + 'px'; // panel.style.bottom = offsetRef.current.y + 'px'; // }, []); // React.useEffect(() => { // if (!open) return; // clampToViewport(); // if (typeof ResizeObserver === 'undefined') { // window.addEventListener('resize', clampToViewport); // return () => window.removeEventListener('resize', clampToViewport); // } // const ro = new ResizeObserver(clampToViewport); // ro.observe(document.documentElement); // return () => ro.disconnect(); // }, [open, clampToViewport]); // React.useEffect(() => { // 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' }, '*'); // return () => window.removeEventListener('message', onMsg); // }, []); // const dismiss = () => { // setOpen(false); // window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*'); // }; // const onDragStart = (e) => { // const panel = dragRef.current; // if (!panel) return; // const r = panel.getBoundingClientRect(); // const sx = e.clientX, sy = e.clientY; // const startRight = window.innerWidth - r.right; // const startBottom = window.innerHeight - r.bottom; // const move = (ev) => { // offsetRef.current = { // x: startRight - (ev.clientX - sx), // y: startBottom - (ev.clientY - sy), // }; // clampToViewport(); // }; // const up = () => { // window.removeEventListener('mousemove', move); // window.removeEventListener('mouseup', up); // }; // window.addEventListener('mousemove', move); // window.addEventListener('mouseup', up); // }; // if (!open) return null; // return ( // <> // //
//
// {title} // //
//
// {children} // {hasDeckStage && railEnabled && !noDeckControls && ( // // // // )} //
//
// // ); // } // // ── Layout helpers ────────────────────────────────────────────────────────── // function TweakSection({ label, children }) { // return ( // <> //
{label}
// {children} // // ); // } // function TweakRow({ label, value, children, inline = false }) { // return ( //
//
// {label} // {value != null && {value}} //
// {children} //
// ); // } // // ── Controls ──────────────────────────────────────────────────────────────── // function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { // return ( // // onChange(Number(e.target.value))} /> // // ); // } // function TweakToggle({ label, value, onChange }) { // return ( //
//
{label}
// //
// ); // } // 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) { // // onChange(e.target.value)}> // {options.map((o) => { // const v = typeof o === 'object' ? o.value : o; // const l = typeof o === 'object' ? o.label : o; // return ; // })} // // // ); // } // function TweakText({ label, value, placeholder, onChange }) { // return ( // // onChange(e.target.value)} /> // // ); // } // 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.addEventListener('pointermove', move); // window.addEventListener('pointerup', up); // }; // return ( //
// {label} // onChange(clamp(Number(e.target.value)))} /> // {unit && {unit}} //
// ); // } // // 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 }) => ( // // ); // // 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 ( //
//
{label}
// onChange(e.target.value)} /> //
// ); // } // // Native 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 ( // //
// {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 ( // // ); // })} //
//
// ); // } // function TweakButton({ label, onClick, secondary = false }) { // return ( // // ); // } // Object.assign(window, { // useTweaks, TweaksPanel, TweakSection, TweakRow, // TweakSlider, TweakToggle, TweakRadio, TweakSelect, // TweakText, TweakNumber, TweakColor, TweakButton, // });