From 0433308d8c26c33735a223993255e1b6481d8419 Mon Sep 17 00:00:00 2001 From: Isorg Date: Wed, 5 Aug 2026 17:01:46 +0000 Subject: [PATCH 1/6] Timer v2 phase 1: single visibility owner + first-paint gate renderAll no longer writes style.display for the six data-driven widgets (avg stack, reentries, chips in play, next break, ends at, payouts); it records availability in DATA_AVAIL and new syncVisibility() is the only writer of display for themable elements, combining theme visibility with data availability and the edit-mode force-show rules. This kills the bug where a theme-hidden widget reappeared on the next 2s poll. renderAll writes go through change-guarded setText/setHtml helpers (groundwork for fit-text). applyTheme's image/streaming blocks now manage only src and availability. Server-side, _timer_theme.php gains the missing ends_at defaults + color var and stops emitting visibility/order CSS (JS owns them); a theme-pending class on hides the stage until the first applyTheme, with a 2.5s head-script failsafe so a JS error can never blank a remote display. Co-Authored-By: Claude Fable 5 --- www/_timer_theme.php | 44 +------ www/timer.php | 290 +++++++++++++++++++++++-------------------- 2 files changed, 161 insertions(+), 173 deletions(-) diff --git a/www/_timer_theme.php b/www/_timer_theme.php index 0fe264d..2502db2 100644 --- a/www/_timer_theme.php +++ b/www/_timer_theme.php @@ -22,6 +22,7 @@ function timer_theme_defaults(): array { 'rebuys' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], 'chips_in_play' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], 'next_break' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], + 'ends_at' => ['visible'=>true,'color'=>'#94a3b8','scale'=>1.0], 'streaming' => ['visible'=>false,'scale'=>1.0,'url'=>''], ], 'tray' => ['bg_color'=>'#1e293b','button_color'=>'#e2e8f0','accent_color'=>'#2563eb'], @@ -116,6 +117,7 @@ function timer_theme_css_vars(array $props): string { '--timer-rebuys-color' => $el['rebuys']['color'] ?? '#94a3b8', '--timer-chips-color' => $el['chips_in_play']['color'] ?? '#94a3b8', '--timer-nextbreak-color' => $el['next_break']['color'] ?? '#94a3b8', + '--timer-endsat-color' => $el['ends_at']['color'] ?? '#94a3b8', '--timer-tray-button-bg' => $tray['bg_color'] ?? '#1e293b', '--timer-tray-button-color' => $tray['button_color'] ?? '#e2e8f0', '--timer-accent' => $tray['accent_color'] ?? '#2563eb', @@ -140,45 +142,9 @@ function timer_theme_css_vars(array $props): string { } $css .= "}\n"; - // Element visibility — emit `display:none` for hidden elements so first paint matches. - $visMap = [ - 'event_name' => '.timer-event-name', - 'player_count' => '#playerWrap', - 'pool_total' => '#poolWrap', - 'level_label' => '.timer-level-label', - 'blinds' => '.timer-blinds', - 'clock' => '.timer-clock', - 'paused_label' => '#pausedLabel', - 'next_level' => '.timer-next', - 'avg_stack' => '#avgStackWrap', - 'payouts' => '#payoutsWrap', - 'qr' => '#qrWrap', - 'image' => '#themeImage', - 'rebuys' => '#rebuysWrap', - 'chips_in_play' => '#chipsInPlayWrap', - 'next_break' => '#nextBreakWrap', - 'streaming' => '#streamingWrap', - ]; - foreach ($visMap as $key => $sel) { - $visible = $el[$key]['visible'] ?? true; - if (!$visible) { - $css .= "{$sel} { display: none !important; }\n"; - } - } - - // Order — emit CSS `order` for the four main display elements. - $orderMap = [ - 'level_label' => '.timer-level-label', - 'blinds' => '.timer-blinds', - 'clock' => '.timer-clock', - 'next_level' => '.timer-next', - ]; - foreach ($orderMap as $key => $sel) { - $ord = (int)($el[$key]['order'] ?? 0); - if ($ord > 0) { - $css .= "{$sel} { order: {$ord}; }\n"; - } - } + // Visibility and flex-order are JS-owned (syncVisibility / applyTheme in + // timer.php). Emitting them here too is the parallel-path pattern that let + // ends_at drift; the theme-pending gate on covers first paint instead. return $css; } diff --git a/www/timer.php b/www/timer.php index b7e6513..c05ff0c 100644 --- a/www/timer.php +++ b/www/timer.php @@ -269,7 +269,7 @@ $themeCss = timer_theme_css_vars($themeProps); ?> - + @@ -278,6 +278,11 @@ + @@ -1437,6 +1506,8 @@ + + @@ -1488,7 +1559,11 @@ -
+
+ + +
+ '); + // Box size (v2): numeric W/H steppers in stage-%, live while resizing. + var _fb = activeLayoutFree(); + var _bx = _fb && _fb[key]; + if (_bx && typeof _bx.w === 'number') { + rows.push('' + + '
' + + '' + + 'W ' + + 'H ' + + '%
'); + } + // Reset position rows.push('' + '
' @@ -5700,9 +6041,12 @@ function onInspectorScale(key, delta) { function resetElementPosition(key) { var pe = (window.TIMER_THEME.elements || {})[key]; if (pe) delete pe.pos; - // Re-capture from a sensible default so the element stays draggable. - if (LAYOUT_DEFAULT_POS[key]) pe.pos = { x: LAYOUT_DEFAULT_POS[key].x, y: LAYOUT_DEFAULT_POS[key].y }; + var free = activeLayoutFree(true); + delete free[key]; + // Re-seed from the heuristic default so the element stays draggable. + if (BOX_DEFAULTS[key]) free[key] = Object.assign({}, BOX_DEFAULTS[key]); applyTheme(window.TIMER_THEME); + if (LAYOUT_EDIT_ON) { checkOverlaps(); syncRzOverlay(); scheduleFit(); } } function refreshHiddenInInspector() { @@ -5711,6 +6055,35 @@ function refreshHiddenInInspector() { if (LAYOUT_SELECTED_KEY) renderInspector(LAYOUT_SELECTED_KEY); } +// Inspector W/H stepper commit — resizes the box keeping its top-left anchored. +function onInspectorBox(key, dim, val) { + var free = activeLayoutFree(true); + var b = free[key]; + if (!validBox(b)) return; + var v = parseFloat(val); + if (isNaN(v)) { refreshBoxRow(key); return; } + if (dim === 'w') b.w = Math.max(4, Math.min(100 - b.x, v)); + if (dim === 'h') b.h = Math.max(3, Math.min(100 - b.y, v)); + var node = document.querySelector(THEME_SELECTORS[key]); + if (node) applyBoxToNode(node, b); + syncRzOverlay(); + checkOverlaps(); + scheduleFit(); + refreshBoxRow(key); +} + +// Sync the inspector's Box row inputs after a handle-drag resize. +function refreshBoxRow(key) { + if (LAYOUT_SELECTED_KEY !== key) return; + var row = document.getElementById('ins_box_' + key); + var free = activeLayoutFree(); + var b = free && free[key]; + if (!row || !validBox(b)) return; + var inputs = row.querySelectorAll('input'); + if (inputs[0]) inputs[0].value = Math.round(b.w); + if (inputs[1]) inputs[1].value = Math.round(b.h); +} + // ─── §7.18 Generic drag-by-header helper for the pill & inspector panel ── function makePanelDraggable(panel, handle) { if (!panel || !handle) return; @@ -5799,6 +6172,7 @@ function onUp() { // First theme application done — lift the first-paint gate (the head-script // failsafe would lift it at 2.5s anyway if we never got here). document.documentElement.classList.remove('theme-pending'); +if (typeof initResizeHandles === 'function' && document.getElementById('rzOverlay')) initResizeHandles(); // Viewport-shape changes re-apply layout + fit (debounced in reapplyLayout). window.addEventListener('resize', reapplyLayout); window.addEventListener('orientationchange', reapplyLayout); From 2cbf264cbe2c22f4d5b4f958ce52729d264bc505 Mon Sep 17 00:00:00 2001 From: Isorg Date: Wed, 5 Aug 2026 18:10:14 +0000 Subject: [PATCH 4/6] Timer v2 phase 4: zones mode Themes can now run a structured layout where overlap is impossible: five fixed zones (top bar / left rail / center stage / right rail / bottom bar) on a CSS grid over #layoutStage, with elements assigned per zone in an ordered list (layouts.landscape.zones.{opts,assign}). applyLayout() reparents element nodes into their zones with minimal-move insertBefore sync (the streaming iframe never reloads from unrelated changes) and restores original DOM slots when leaving zones mode; both the free-box and zones sub-trees persist so mode switching loses nothing. Zone items compress (min-height:0 + shrink) and fit-text shrinks into the bound, so a center stage holding clock+blinds+labels self-fits instead of clipping. Empty zones collapse to zero outside the editor. Editor: Free/Zones segmented switcher in the pill, dashed zone bounds with name labels, click-to-select + wheel-resize on zone items, Objects panel grouped by zone with per-row zone dropdown, in-zone reorder arrows, and an Unplaced group; per-zone size/align/gap controls in the Page inspector. Grid gets explicit align/justify stretch so the container's flex centering can't leak in and size zones by content. Co-Authored-By: Claude Fable 5 --- www/timer.php | 454 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 445 insertions(+), 9 deletions(-) diff --git a/www/timer.php b/www/timer.php index 3acb303..1fc09d1 100644 --- a/www/timer.php +++ b/www/timer.php @@ -1157,6 +1157,80 @@ padding: 0.1rem 0.45rem; border-radius: 10px; } + /* ─── Zones mode: fixed 5-zone grid; overlap impossible by construction ─── */ + .gn-zone { display: none; } + body.layout-zones #layoutStage { + display: grid; + grid-template-areas: "top top top" "left center right" "bottom bottom bottom"; + grid-template-rows: minmax(0, var(--zone-top-size, 12%)) minmax(0, 1fr) minmax(0, var(--zone-bottom-size, 10%)); + grid-template-columns: minmax(0, var(--zone-left-size, 16%)) minmax(0, 1fr) minmax(0, var(--zone-right-size, 16%)); + /* .timer-container's flex align-items:center would otherwise leak in + and size zones by content (letting a huge clock burst its track). */ + align-items: stretch; + justify-items: stretch; + } + /* The flow containers empty out in zones mode (their children reparent). */ + body.layout-zones .timer-info-bar, + body.layout-zones .timer-display { display: none; } + body.layout-zones .gn-zone { + display: flex; + min-width: 0; + min-height: 0; + overflow: hidden; + align-items: center; + justify-content: var(--zone-align, center); + gap: calc(var(--zone-gap, 2) * 1vmin); + padding: 0.25rem; + box-sizing: border-box; + } + #zoneTop { grid-area: top; flex-direction: row; flex-wrap: wrap; } + #zoneBottom { grid-area: bottom; flex-direction: row; flex-wrap: wrap; } + #zoneLeft { grid-area: left; flex-direction: column; } + #zoneCenter { grid-area: center; flex-direction: column; text-align: center; } + #zoneRight { grid-area: right; flex-direction: column; } + /* min-height:0 + shrink lets a too-tall column compress its items, which + gives fit-text a real height bound to shrink text into — a center + stage with clock+blinds+labels self-fits instead of clipping. */ + body.layout-zones .gn-zone > * { min-width: 0; min-height: 0; max-width: 100%; flex-shrink: 1; overflow: hidden; } + /* Zone-mode geometry overrides for widgets whose base CSS absolutely + positions or sizes them — inside a zone they are plain flex items. */ + body.layout-zones .gn-zone > .timer-avgstack, + body.layout-zones .gn-zone > .timer-payouts, + body.layout-zones .gn-zone > .timer-qr { + position: static; + } + body.layout-zones .gn-zone > .timer-image { + position: static; + transform: none; + max-width: 100%; max-height: 100%; + } + body.layout-zones .gn-zone > .timer-stream { + position: static; + width: 100%; + } + /* Edit chrome: visible zone bounds + labels while editing. */ + body.layout-edit.layout-zones .gn-zone { + outline: 1px dashed rgba(148, 163, 184, 0.55); + outline-offset: -2px; + position: relative; + } + body.layout-edit.layout-zones .gn-zone::before { + content: attr(data-zone); + position: absolute; + top: 2px; left: 5px; + font-size: 0.58rem; + color: rgba(148, 163, 184, 0.85); + text-transform: uppercase; + letter-spacing: 0.06em; + pointer-events: none; + } + body.layout-edit .gn-zone > .is-selected { + outline: 3px solid #2563eb; + outline-offset: 2px; + } + .pill-mode-seg { display: inline-flex; border-radius: 6px; overflow: hidden; border: 1px solid #475569; } + .pill-mode-seg button { border: none !important; border-radius: 0 !important; margin: 0 !important; } + .pill-mode-seg button.mode-on { background: #2563eb !important; color: #fff !important; } /* QR keeps its current size unless theme overrides; transform stacks translate+scale. */ #qrWrap.timer-positioned { transform: translate(-50%, -50%) scale(var(--timer-qr-scale, 1)); @@ -1354,7 +1428,8 @@ .layout-eye:hover { transform: scale(1.25); } .layout-eye.is-hidden { color: #64748b; border-color: #64748b; } body.layout-edit .timer-positioned .layout-eye, - body.layout-edit .timer-boxed .layout-eye { display: inline-flex; } + body.layout-edit .timer-boxed .layout-eye, + body.layout-edit .gn-zone .layout-eye { display: inline-flex; } /* Selected element gets a different (solid blue) outline. */ body.layout-edit .timer-positioned.is-selected, body.layout-edit .timer-boxed.is-selected { @@ -1501,6 +1576,10 @@
+ + + + @@ -1564,6 +1643,13 @@
+ +
+
+
+
+
' + '
'); + // Per-zone options (zones mode only): size of the four edge zones, content + // alignment, and gap between items. + if (window.TIMER_THEME.mode === 'zones') { + var zc = activeLayoutZones(true); + rows.push('
Zones
'); + ZONE_LIST.forEach(function (zn) { + var o = zc.opts[zn] || {}; + var size = Math.max(5, Math.min(45, parseFloat(o.size) || ZONE_DEFAULT_SIZE[zn] || 0)); + var alignSel = ['start', 'center', 'end'].map(function (a) { + return ''; + }).join(''); + rows.push('
' + + '' + + (zn !== 'center' + ? '%' + : '') + + '' + + '' + + '
'); + }); + } + rows.push('
Toolbar
'); rows.push('
' + '
'); @@ -6055,6 +6479,18 @@ function refreshHiddenInInspector() { if (LAYOUT_SELECTED_KEY) renderInspector(LAYOUT_SELECTED_KEY); } +// Page-inspector zone option commit (size / align / gap for one zone). +function onZoneOpt(zone, field, val) { + if (!ZONE_NAMES[zone]) return; + var z = activeLayoutZones(true); + var o = z.opts[zone] = z.opts[zone] || {}; + if (field === 'size') o.size = Math.max(5, Math.min(45, parseFloat(val) || ZONE_DEFAULT_SIZE[zone])); + if (field === 'gap') o.gap = Math.max(0, Math.min(8, parseFloat(val) || 0)); + if (field === 'align') o.align = { start: 1, center: 1, end: 1 }[val] ? val : 'center'; + applyTheme(window.TIMER_THEME); + scheduleFit(); +} + // Inspector W/H stepper commit — resizes the box keeping its top-left anchored. function onInspectorBox(key, dim, val) { var free = activeLayoutFree(true); From 9b5b2b9dd2215b69327c718ab7cddf96084ac6cd Mon Sep 17 00:00:00 2001 From: Isorg Date: Wed, 5 Aug 2026 18:28:21 +0000 Subject: [PATCH 5/6] Timer v2 phase 5: per-orientation layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A theme now carries independent landscape and portrait layouts (layouts.landscape / layouts.portrait, each with free + zones sub-trees). The display picks the orientation matching the viewport via matchMedia and falls back to the other when one is empty, re-applying live on resize/rotate/fullscreen — verified end-to-end: distinct layouts saved through update_theme swap correctly when the viewport flips, without a reload. All box/zone readers and writers route through currentLayoutOrientationKey(); in the editor an orientation segment (desktop/phone) pins which layout is being edited, letterboxing the stage to a simulated 16:9 or 9:16 rect (all geometry is stage-relative, so the simulation is exact; viewport-fixed snap guides hide). First edit of an undefined orientation seeds an in-memory copy of the defined one, and a copy-from-other button (with confirm) does the same on demand — overlap outlines immediately flag what doesn't survive the aspect change. The QR, image, and streaming elements — previously body-level siblings whose percentages resolved against the viewport — are adopted into #layoutStage at init so boxed geometry is truly stage-relative. Co-Authored-By: Claude Fable 5 --- www/timer.php | 189 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 177 insertions(+), 12 deletions(-) diff --git a/www/timer.php b/www/timer.php index 1fc09d1..68cad28 100644 --- a/www/timer.php +++ b/www/timer.php @@ -1231,6 +1231,11 @@ .pill-mode-seg { display: inline-flex; border-radius: 6px; overflow: hidden; border: 1px solid #475569; } .pill-mode-seg button { border: none !important; border-radius: 0 !important; margin: 0 !important; } .pill-mode-seg button.mode-on { background: #2563eb !important; color: #fff !important; } + /* Letterboxed orientation simulation: framed stage, viewport-fixed snap + guides hidden (their coordinates are stage-relative only in spirit). */ + .stage-letterbox #layoutStage { outline: 2px solid rgba(37, 99, 235, 0.65); outline-offset: 2px; z-index: 5; } + .stage-letterbox .center-guide-v, .stage-letterbox .center-guide-h, + .stage-letterbox .align-guide-v, .stage-letterbox .align-guide-h { display: none !important; } /* QR keeps its current size unless theme overrides; transform stacks translate+scale. */ #qrWrap.timer-positioned { transform: translate(-50%, -50%) scale(var(--timer-qr-scale, 1)); @@ -1580,6 +1585,11 @@ + + + + + @@ -2272,6 +2282,8 @@ function scheduleFit() { function reapplyLayout() { clearTimeout(_relayoutTimer); _relayoutTimer = setTimeout(function () { + if (typeof applyStageLetterbox === 'function') applyStageLetterbox(); + if (typeof setOrientationButtonsUI === 'function') setOrientationButtonsUI(); if (window.TIMER_THEME) applyTheme(window.TIMER_THEME); scheduleFit(); }, 150); @@ -4203,7 +4215,8 @@ function applyTheme(props) { // 2. legacy center point pos {x,y} (% of viewport) — .timer-positioned // 3. neither — element stays in flex flow (or its zone, in zones mode) var zonesActive = props.mode === 'zones'; - var freeMap = (props.layouts && props.layouts.landscape && props.layouts.landscape.free) || null; + var _lo = currentLayoutOrientationKey(props); + var freeMap = (props.layouts && props.layouts[_lo] && props.layouts[_lo].free) || null; for (var k2 in THEME_SELECTORS) { var node2 = document.querySelector(THEME_SELECTORS[k2]); if (!node2) continue; @@ -4291,7 +4304,8 @@ function syncVisibility() { var zonesOn = theme.mode === 'zones'; var zAssign = null; if (zonesOn) { - var zc = (theme.layouts && theme.layouts.landscape && theme.layouts.landscape.zones) || {}; + var _so = currentLayoutOrientationKey(theme); + var zc = (theme.layouts && theme.layouts[_so] && theme.layouts[_so].zones) || {}; zAssign = zc.assign || {}; } for (var k in THEME_SELECTORS) { @@ -4808,20 +4822,57 @@ function setAsDefaultTheme() { streaming: { x: 62, y: 16, w: 30, h: 28 }, }; -// Accessor for the active orientation's free-box map. Phase 3 uses a single -// 'landscape' layout; Phase 5 keys this by the live/edited orientation. +// ─── Per-orientation layouts: a theme carries layouts.landscape and +// layouts.portrait; the display renders the one matching the viewport and +// falls back to the other when it's empty. The editor can pin an orientation +// (EDIT_ORIENTATION) and letterbox the stage to simulate it. ─── +var EDIT_ORIENTATION = null; // editor override; null = follow the viewport + +function getActiveOrientation() { + try { return window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape'; } + catch (e) { return window.innerHeight > window.innerWidth ? 'portrait' : 'landscape'; } +} + +function otherOrientation(o) { return o === 'portrait' ? 'landscape' : 'portrait'; } + +function hasLayoutContent(l) { + if (!l) return false; + if (l.free && Object.keys(l.free).length) return true; + if (l.zones && l.zones.assign && Object.keys(l.zones.assign).length) return true; + return false; +} + +// Which orientation's layout should RENDER right now. +function resolveLayoutOrientation(props) { + var o = getActiveOrientation(); + var L = props && props.layouts; + if (!L) return o; + if (hasLayoutContent(L[o])) return o; + if (hasLayoutContent(L[otherOrientation(o)])) return otherOrientation(o); + return o; +} + +// The orientation every reader/writer targets: the editor's pin wins in edit +// mode; otherwise the resolved display orientation. +function currentLayoutOrientationKey(props) { + if (typeof LAYOUT_EDIT_ON !== 'undefined' && LAYOUT_EDIT_ON && EDIT_ORIENTATION) return EDIT_ORIENTATION; + return resolveLayoutOrientation(props || window.TIMER_THEME); +} + +// Accessor for the target orientation's free-box map. function activeLayoutFree(create) { var t = window.TIMER_THEME; if (!t) return null; + var o = currentLayoutOrientationKey(t); if (!create) { - return (t.layouts && t.layouts.landscape && t.layouts.landscape.free) || null; + return (t.layouts && t.layouts[o] && t.layouts[o].free) || null; } t.layouts = t.layouts || {}; - t.layouts.landscape = t.layouts.landscape || {}; - t.layouts.landscape.free = t.layouts.landscape.free || {}; + t.layouts[o] = t.layouts[o] || {}; + t.layouts[o].free = t.layouts[o].free || {}; t.schema = 2; if (t.mode !== 'zones') t.mode = 'free'; - return t.layouts.landscape.free; + return t.layouts[o].free; } function validBox(b) { @@ -4872,12 +4923,13 @@ function applyBoxToNode(node, b) { function activeLayoutZones(create) { var t = window.TIMER_THEME; if (!t) return null; + var o = currentLayoutOrientationKey(t); if (!create) { - return (t.layouts && t.layouts.landscape && t.layouts.landscape.zones) || null; + return (t.layouts && t.layouts[o] && t.layouts[o].zones) || null; } t.layouts = t.layouts || {}; - t.layouts.landscape = t.layouts.landscape || {}; - var z = t.layouts.landscape.zones = t.layouts.landscape.zones || {}; + t.layouts[o] = t.layouts[o] || {}; + var z = t.layouts[o].zones = t.layouts[o].zones || {}; z.opts = z.opts || {}; z.assign = z.assign || {}; t.schema = 2; @@ -4922,7 +4974,8 @@ function applyLayout(props) { } return; } - var zc = (props.layouts && props.layouts.landscape && props.layouts.landscape.zones) || {}; + var _zo = currentLayoutOrientationKey(props); + var zc = (props.layouts && props.layouts[_zo] && props.layouts[_zo].zones) || {}; var assign = zc.assign || {}; var opts = zc.opts || {}; // Per-zone options → CSS vars/styles. @@ -5031,6 +5084,12 @@ function enterLayoutEdit() { delete pe.pos; } }); + // Edit the orientation the user is physically on; if it has no layout yet + // but the other does, start from a copy of it (in-memory until Save). + EDIT_ORIENTATION = getActiveOrientation(); + ensureOrientationSeeded(EDIT_ORIENTATION); + applyStageLetterbox(); + if (window.TIMER_THEME.mode !== 'zones') seedBoxes(); applyTheme(window.TIMER_THEME); @@ -5038,6 +5097,7 @@ function enterLayoutEdit() { openObjectsPanel(); checkOverlaps(); setModeButtonsUI(); + setOrientationButtonsUI(); } // Measure every visible element's current rendered rect into a v2 box (stage-%). @@ -5230,6 +5290,98 @@ function setModeButtonsUI() { if (zb) zb.classList.toggle('mode-on', m === 'zones'); } +// ─── Editor orientation switching + letterboxed simulation ─── +function setOrientationButtonsUI() { + var o = (LAYOUT_EDIT_ON && EDIT_ORIENTATION) ? EDIT_ORIENTATION : getActiveOrientation(); + var lb = document.getElementById('orientLandBtn'); + var pb = document.getElementById('orientPortBtn'); + if (lb) lb.classList.toggle('mode-on', o === 'landscape'); + if (pb) pb.classList.toggle('mode-on', o === 'portrait'); +} + +// Simulate the non-matching orientation by letterboxing the stage to a centered +// 16:9 / 9:16 rect. All geometry is stage-relative (boxes, drags, resize), so +// the simulation is exact; only the viewport-fixed snap GUIDES go dark. +function applyStageLetterbox() { + var stage = document.getElementById('layoutStage'); + if (!stage) return; + var real = getActiveOrientation(); + var want = (LAYOUT_EDIT_ON && EDIT_ORIENTATION) ? EDIT_ORIENTATION : real; + if (!LAYOUT_EDIT_ON || want === real) { + stage.style.position = ''; + stage.style.left = ''; stage.style.top = ''; + stage.style.width = ''; stage.style.height = ''; + document.body.classList.remove('stage-letterbox'); + return; + } + var vw = window.innerWidth, vh = window.innerHeight, w, h; + if (want === 'portrait') { + h = vh * 0.95; w = h * 9 / 16; + if (w > vw * 0.95) { w = vw * 0.95; h = w * 16 / 9; } + } else { + w = vw * 0.95; h = w * 9 / 16; + if (h > vh * 0.95) { h = vh * 0.95; w = h * 16 / 9; } + } + stage.style.position = 'fixed'; + stage.style.left = ((vw - w) / 2) + 'px'; + stage.style.top = ((vh - h) / 2) + 'px'; + stage.style.width = w + 'px'; + stage.style.height = h + 'px'; + document.body.classList.add('stage-letterbox'); +} + +// First edit of an undefined orientation starts from a copy of the defined one +// (in-memory; persists only on Save). Display never seeds — it just falls back. +function ensureOrientationSeeded(o) { + var t = window.TIMER_THEME; + if (!t) return; + t.layouts = t.layouts || {}; + var other = otherOrientation(o); + if (!hasLayoutContent(t.layouts[o]) && hasLayoutContent(t.layouts[other])) { + t.layouts[o] = JSON.parse(JSON.stringify(t.layouts[other])); + } +} + +function setEditOrientation(o) { + if (!LAYOUT_EDIT_ON || (o !== 'landscape' && o !== 'portrait') || EDIT_ORIENTATION === o) return; + deselectElement(); + EDIT_ORIENTATION = o; + ensureOrientationSeeded(o); + detachAllDragHandlers(); + removeAllEyeIcons(); + applyStageLetterbox(); + applyTheme(window.TIMER_THEME); + renderAll(); + if (window.TIMER_THEME.mode !== 'zones') { seedBoxes(); applyTheme(window.TIMER_THEME); } + attachAllDragHandlers(); + checkOverlaps(); + renderObjectsPanel(); + setOrientationButtonsUI(); + scheduleFit(); +} + +function copyFromOtherOrientation() { + var o = EDIT_ORIENTATION || getActiveOrientation(); + var other = otherOrientation(o); + var t = window.TIMER_THEME; + if (!t || !hasLayoutContent(t.layouts && t.layouts[other])) { + pkAlert('The ' + other + ' layout is empty — nothing to copy.'); + return; + } + pkConfirm('Replace the ' + o + ' layout with a copy of the ' + other + ' one? Overlap warnings will show anything that does not survive the aspect change.').then(function (ok) { + if (!ok) return; + t.layouts[o] = JSON.parse(JSON.stringify(t.layouts[other])); + detachAllDragHandlers(); + removeAllEyeIcons(); + applyTheme(t); + renderAll(); + attachAllDragHandlers(); + checkOverlaps(); + renderObjectsPanel(); + scheduleFit(); + }); +} + // ─── Live preview toggle: position against real data instead of placeholders ─── window.LIVE_PREVIEW = false; function toggleLivePreview() { @@ -5243,6 +5395,8 @@ function toggleLivePreview() { function exitLayoutEdit(keep) { if (!LAYOUT_EDIT_ON) return; LAYOUT_EDIT_ON = false; + EDIT_ORIENTATION = null; + applyStageLetterbox(); // clears any letterboxed simulation document.body.classList.remove('layout-edit'); detachAllDragHandlers(); deselectElement(); @@ -6603,6 +6757,17 @@ function onUp() { }); // ─── §7.19 Init ───────────────────────────────────────────────── +// Adopt body-level themable elements into the stage so boxed %-geometry is +// stage-relative — vital when the stage letterboxes for orientation preview. +// Runs before the first applyTheme so ORIGINAL_SLOTS capture the new homes. +(function adoptIntoStage() { + var stage = document.getElementById('layoutStage'); + if (!stage) return; + ['qrWrap', 'themeImage', 'streamingWrap'].forEach(function (id) { + var n = document.getElementById(id); + if (n && !n.closest('#layoutStage')) stage.appendChild(n); + }); +})(); if (window.TIMER_THEME) applyTheme(window.TIMER_THEME); renderAll(); // First theme application done — lift the first-paint gate (the head-script From 1cddfcb64f251902a6754024f632c5296ffb061b Mon Sep 17 00:00:00 2001 From: Isorg Date: Wed, 5 Aug 2026 18:45:56 +0000 Subject: [PATCH 6/6] Zones rework: drag-to-zone, draggable boundaries, cleaner chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner feedback on the first zones cut: ugly defaults, clunky Objects- panel-only editing, too rigid, plus two bugs. Reworked: (1) zone items are now GHOST-DRAGGABLE — pick one up, the zone under the pointer highlights, drop to reassign, with in-zone ordering derived from the drop position; the Objects panel dropdowns remain as the precise fallback. (2) Zone boundaries are draggable dividers (thin blue lines between zones, ns/ew cursors) that resize the top/bottom/left/right zones live, replacing number-field-only sizing. (3) Softer look: dimmer dashed bounds and labels, smaller default zone sizes (10/14/14/8). Bugs: the edit pill now wraps within the viewport (max-width + flex- wrap, centered) so its drag handle can never sit off-screen, and eye icons inside zones anchor to their own item (items are position: relative now; static items made every absolutely-positioned eye pile up at the zone corner and clip to a sliver at the zone edge). Co-Authored-By: Claude Fable 5 --- www/timer.php | 221 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 198 insertions(+), 23 deletions(-) diff --git a/www/timer.php b/www/timer.php index 68cad28..0e138bd 100644 --- a/www/timer.php +++ b/www/timer.php @@ -1190,27 +1190,32 @@ #zoneRight { grid-area: right; flex-direction: column; } /* min-height:0 + shrink lets a too-tall column compress its items, which gives fit-text a real height bound to shrink text into — a center - stage with clock+blinds+labels self-fits instead of clipping. */ - body.layout-zones .gn-zone > * { min-width: 0; min-height: 0; max-width: 100%; flex-shrink: 1; overflow: hidden; } + stage with clock+blinds+labels self-fits instead of clipping. + position:relative so each item's eye icon anchors to the ITEM, not + the zone (static items made every eye pile up at the zone corner). */ + body.layout-zones .gn-zone > * { min-width: 0; min-height: 0; max-width: 100%; flex-shrink: 1; overflow: hidden; position: relative; } /* Zone-mode geometry overrides for widgets whose base CSS absolutely positions or sizes them — inside a zone they are plain flex items. */ body.layout-zones .gn-zone > .timer-avgstack, body.layout-zones .gn-zone > .timer-payouts, body.layout-zones .gn-zone > .timer-qr { - position: static; + position: relative; + top: auto; left: auto; right: auto; bottom: auto; } body.layout-zones .gn-zone > .timer-image { - position: static; + position: relative; + top: auto; left: auto; right: auto; bottom: auto; transform: none; max-width: 100%; max-height: 100%; } body.layout-zones .gn-zone > .timer-stream { - position: static; + position: relative; + top: auto; left: auto; right: auto; bottom: auto; width: 100%; } - /* Edit chrome: visible zone bounds + labels while editing. */ + /* Edit chrome: subtle zone bounds + labels while editing. */ body.layout-edit.layout-zones .gn-zone { - outline: 1px dashed rgba(148, 163, 184, 0.55); + outline: 1px dashed rgba(148, 163, 184, 0.3); outline-offset: -2px; position: relative; } @@ -1218,12 +1223,29 @@ content: attr(data-zone); position: absolute; top: 2px; left: 5px; - font-size: 0.58rem; - color: rgba(148, 163, 184, 0.85); + font-size: 0.55rem; + color: rgba(148, 163, 184, 0.5); text-transform: uppercase; letter-spacing: 0.06em; pointer-events: none; } + /* Drop target while dragging an element across zones. */ + .gn-zone.zone-drop { + outline: 2px solid #2563eb !important; + outline-offset: -2px; + background: rgba(37, 99, 235, 0.1); + } + /* Draggable zone boundaries (edit mode): grab the line, resize the zone. */ + .gn-zdiv { display: none; position: absolute; z-index: 65; } + body.layout-edit.layout-zones .gn-zdiv { display: block; } + .gn-zdiv[data-div="top"] { left: 0; right: 0; top: calc(var(--zone-top-size, 10%) - 5px); height: 10px; cursor: ns-resize; } + .gn-zdiv[data-div="bottom"] { left: 0; right: 0; bottom: calc(var(--zone-bottom-size, 8%) - 5px); height: 10px; cursor: ns-resize; } + .gn-zdiv[data-div="left"] { top: 0; bottom: 0; left: calc(var(--zone-left-size, 14%) - 5px); width: 10px; cursor: ew-resize; } + .gn-zdiv[data-div="right"] { top: 0; bottom: 0; right: calc(var(--zone-right-size, 14%) - 5px); width: 10px; cursor: ew-resize; } + .gn-zdiv::after { content: ''; position: absolute; background: rgba(37, 99, 235, 0.45); border-radius: 2px; } + .gn-zdiv[data-div="top"]::after, .gn-zdiv[data-div="bottom"]::after { left: 0; right: 0; top: 4px; bottom: 4px; } + .gn-zdiv[data-div="left"]::after, .gn-zdiv[data-div="right"]::after { top: 0; bottom: 0; left: 4px; right: 4px; } + .gn-zdiv:hover::after, .gn-zdiv.is-dragging::after { background: #2563eb; } body.layout-edit .gn-zone > .is-selected { outline: 3px solid #2563eb; outline-offset: 2px; @@ -1369,18 +1391,24 @@ body.layout-edit .timer-back { display: none !important; } .layout-edit-pill { position: fixed; - top: 25%; - left: 25%; + top: 12%; + left: 50%; transform: translate(-50%, -50%); z-index: 999; background: rgba(15, 23, 42, 0.95); border: 1px solid #475569; - border-radius: 999px; + border-radius: 18px; padding: 0.35rem 0.65rem; display: none; gap: 0.4rem; align-items: center; box-shadow: 0 6px 20px rgba(0,0,0,0.4); + /* The pill grew controls (mode/orientation/copy/live): never let it + exceed the viewport — wrap to more rows instead, so the drag + handle can't end up off-screen. */ + max-width: calc(100vw - 16px); + flex-wrap: wrap; + justify-content: center; } body.layout-edit .layout-edit-pill { display: inline-flex; } .layout-edit-pill button { @@ -1435,6 +1463,9 @@ body.layout-edit .timer-positioned .layout-eye, body.layout-edit .timer-boxed .layout-eye, body.layout-edit .gn-zone .layout-eye { display: inline-flex; } + /* Inside a zone the eye must sit INSIDE the element: zones clip overflow, + so the default -1.4rem offset showed only a sliver of the button. */ + body.layout-edit .gn-zone .layout-eye { top: 0; left: 0; } /* Selected element gets a different (solid blue) outline. */ body.layout-edit .timer-positioned.is-selected, body.layout-edit .timer-boxed.is-selected { @@ -1660,6 +1691,11 @@
+ +
+
+
+