// Inline home section configure popover (function() { 'use strict'; const LOG = (...args) => console.log('[KefinTweaks HomeSectionConfigure]', ...args); const WARN = (...args) => console.warn('[KefinTweaks HomeSectionConfigure]', ...args); const runtime = new Map(); let activePopover = null; let saveTimeout = null; let pendingHomeScreen = null; const pendingSaves = new Map(); /** @type {{ entry: object, parent: Node, nextSibling: Node|null, serverDefaults: object }|null} */ let pendingHideStash = null; const OPTION_MENU_MODAL_ID = 'kefin-section-option-menu'; const CARD_TITLE_MENU_MODAL_ID = 'kefin-section-card-title-menu'; const RESTORE_SECTION_MODAL_ID = 'kefin-section-restore-defaults'; const PUBLISH_SECTION_MODAL_ID = 'kefin-section-publish-defaults'; /** True when the current pointer gesture started inside a Pickr app (drag may end outside). */ let pickrGestureFromApp = false; function isPickrAppEventTarget(target) { return !!target?.closest?.('.pcr-app, .kefin-pickr-app'); } function onPickrGesturePointerDown(e) { if (!activePopover) return; pickrGestureFromApp = isPickrAppEventTarget(e.target); } function onPickrGesturePointerUp() { if (!activePopover) { pickrGestureFromApp = false; return; } // click fires after mouseup/pointerup; clear after that turn so capture click still sees the flag setTimeout(() => { pickrGestureFromApp = false; }, 0); } /** * Drag in Pickr + release outside synthesizes a click whose target is outside .pcr-app. * Stop that click so configure outsideHandler and Pickr's own dismiss do not run. */ function onPickrGestureClickCapture(e) { if (!activePopover || !pickrGestureFromApp) return; if (isPickrAppEventTarget(e.target)) return; e.stopImmediatePropagation(); e.preventDefault(); } document.addEventListener('pointerdown', onPickrGesturePointerDown, true); document.addEventListener('pointerup', onPickrGesturePointerUp, true); document.addEventListener('pointercancel', onPickrGesturePointerUp, true); document.addEventListener('click', onPickrGestureClickCapture, true); /** Accept hex (3/4/6/8), rgb(a), hsl(a), or other non-empty CSS color strings. */ function isCssColorString(value) { if (value == null) return false; const v = String(value).trim(); if (!v) return false; return /^(#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})|rgba?\(|hsla?\(|[a-zA-Z][\w-]*)$/i.test(v) || /^(rgb|hsl)a?\(/i.test(v); } function destroyPickrInstance(pickr) { if (!pickr) return; try { // Prefer destroy() — destroyAndRemove() can delete useAsButton trigger nodes. if (typeof pickr.destroy === 'function') pickr.destroy(); else if (typeof pickr.destroyAndRemove === 'function') pickr.destroyAndRemove(); } catch (_) { /* ignore */ } // Remove any orphaned Pickr apps left after a hard close mid-interaction. document.querySelectorAll('.pcr-app.kefin-pickr-app').forEach((el) => { try { el.remove(); } catch (__) { /* ignore */ } }); } /** * Create a nano-theme Pickr on el. Returns null if Pickr is unavailable. * @param {HTMLElement} el * @param {{ defaultColor?: string, useAsButton?: boolean, onChange?: (css: string) => void, onSave?: (css: string) => void }} opts */ function createPickrInstance(el, opts = {}) { if (!el || typeof window.Pickr?.create !== 'function') { WARN('Pickr unavailable'); return null; } // Pickr needs a laid-out anchor; zero-size / detached hosts crash in _rePositioningPicker. if (!el.isConnected) { WARN('Pickr host is not connected'); return null; } const defaultColor = isCssColorString(opts.defaultColor) ? String(opts.defaultColor).trim() : '#e8dab2'; let pickr; try { pickr = window.Pickr.create({ el, theme: 'nano', default: defaultColor, defaultRepresentation: 'HEXA', useAsButton: opts.useAsButton === true, appClass: 'kefin-pickr-app', components: { preview: true, opacity: true, hue: true, interaction: { hex: true, rgba: true, input: true, save: true } } }); } catch (error) { WARN('Pickr.create failed', error); return null; } const toCss = (color) => { if (!color) return ''; try { return color.toHEXA().toString(); } catch (_) { try { return color.toRGBA().toString(); } catch (__) { return ''; } } }; pickr.on('change', (color) => { const css = toCss(color); if (css) opts.onChange?.(css); }); pickr.on('save', (color) => { const css = toCss(color); if (css) opts.onSave?.(css); pickr.hide(); }); // Keep our anchored menus from eating Pickr clicks (transparent modal backdrop / outside handler). if (typeof opts.onShow === 'function' || typeof opts.onHide === 'function') { pickr.on('show', () => opts.onShow?.(pickr)); pickr.on('hide', () => opts.onHide?.(pickr)); } return pickr; } function setAnchoredMenuBackdropPassThrough(modalInstance, passThrough) { if (!modalInstance) return; if (modalInstance.backdrop) { modalInstance.backdrop.style.pointerEvents = passThrough ? 'none' : ''; } if (modalInstance.dialogContainer) { // Anchored menus already use none; when Pickr is open keep that and ensure it stays none. if (passThrough) modalInstance.dialogContainer.style.pointerEvents = 'none'; } } function getUserSettings() { return window.KefinTweaksConfig?.homeScreenConfig?.USER_HOME_SCREEN_SETTINGS || {}; } function isEnabled() { return getUserSettings().inlineConfigure !== false; } function getConfigApi() { return window.KefinUserHomeScreenConfig; } function getCachePresetOptions() { const presets = window.KefinHomeScreenAdvancedEditor?.getCachePresets?.() || buildFallbackCachePresets(); return presets.filter(p => p.key !== 'CUSTOM' && p.ms != null && Number.isFinite(Number(p.ms))); } function findCachePresetForTtl(ttl) { if (ttl == null || ttl === '') return null; const ttlNum = Number(ttl); if (!Number.isFinite(ttlNum)) return null; const presets = getCachePresetOptions(); const exact = presets.find(p => Number(p.ms) === ttlNum); if (exact) return exact; // Same human label as a preset (e.g. both round to "6 hours") → treat as that preset. // Prefer the closest ms when multiple presets share a label. const label = formatDurationLabel(ttlNum); const sameLabel = presets.filter(p => formatDurationLabel(Number(p.ms)) === label); if (!sameLabel.length) return null; return sameLabel.reduce((best, p) => ( Math.abs(Number(p.ms) - ttlNum) < Math.abs(Number(best.ms) - ttlNum) ? p : best )); } function buildCacheSelectOptionsHtml(currentTtl) { const presets = getCachePresetOptions(); const matched = findCachePresetForTtl(currentTtl); const ttlNum = currentTtl == null || currentTtl === '' ? NaN : Number(currentTtl); let html = presets .map((preset) => { const selected = matched && Number(preset.ms) === Number(matched.ms) ? ' selected' : ''; return ``; }) .join(''); // Only show Custom when the value doesn't correspond to any preset (exact or same label). if (Number.isFinite(ttlNum) && !matched) { html += ``; } return html; } function buildFallbackCachePresets() { const cache = window.KefinHomeConfig2?.CACHE || {}; return [ { key: 'FORCE_REFRESH', label: 'No Cache', ms: cache.FORCE_REFRESH_TTL ?? 0 }, { key: 'VERY_SHORT', label: 'Very Short (1 min)', ms: cache.VERY_SHORT_TTL ?? 60000 }, { key: 'SHORT', label: 'Short (5 min)', ms: cache.SHORT_TTL ?? 300000 }, { key: 'DEFAULT', label: 'Default (1 hour)', ms: cache.DEFAULT_TTL ?? 3600000 }, { key: 'LONG', label: 'Long (24 hours)', ms: cache.LONG_TTL ?? 86400000 }, { key: 'STATIC', label: 'Static (1 week)', ms: cache.STATIC_TTL ?? 604800000 }, { key: 'DISCOVERY', label: 'Discovery (6 hours)', ms: cache.DISCOVERY_TTL ?? 21600000 } ]; } function pluralUnit(count, singular, plural) { return count === 1 ? singular : plural; } function formatDurationLabel(ms) { if (ms == null) return 'Default'; if (ms === 0) return 'No Cache'; if (ms < 60000) { const n = Math.round(ms / 1000); return `${n} ${pluralUnit(n, 'second', 'seconds')}`; } if (ms < 3600000) { const n = Math.round(ms / 60000); return `${n} ${pluralUnit(n, 'minute', 'minutes')}`; } if (ms < 86400000) { const n = Math.round(ms / 3600000); return `${n} ${pluralUnit(n, 'hour', 'hours')}`; } const n = Math.round(ms / 86400000); return `${n} ${pluralUnit(n, 'day', 'days')}`; } function buildMinimalToggleSwitch(id, checked) { if (window.KefinTweaksUI?.buildToggleSlider) { return window.KefinTweaksUI.buildToggleSlider(id, checked, '', { includeHiddenCheckbox: true, wrapInLabel: false, cssClass: 'kefin-toggle-switch', knob: 'css' }); } const isEnabled = checked !== false; const safeId = escapeHtml(id); return ` `; } function bindToggleSwitch(toggleBtn, onChange) { if (!toggleBtn) return; toggleBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); const next = toggleBtn.getAttribute('data-enabled') !== 'true'; if (window.KefinTweaksUI?.updateToggleSwitchUI) { window.KefinTweaksUI.updateToggleSwitchUI(toggleBtn, next); } else { toggleBtn.dataset.enabled = String(next); toggleBtn.setAttribute('aria-pressed', String(next)); const checkboxId = toggleBtn.dataset.checkboxId; if (checkboxId) { const checkbox = document.getElementById(checkboxId); if (checkbox) checkbox.checked = next; } } onChange(next); }); } function registerRuntime(entry) { runtime.set(entry.sectionId, entry); } function getRuntime(sectionId) { return runtime.get(sectionId); } function updateRuntimeItemsLayout(sectionId, layout) { const entry = getRuntime(sectionId); if (!entry?.effectiveConfig) return; const resolved = layout === 'grid' ? 'grid' : (layout === true ? 'grid' : 'row'); if (resolved === 'row') { delete entry.effectiveConfig.itemsLayout; delete entry.effectiveConfig.gridExpanded; } else { entry.effectiveConfig.itemsLayout = resolved; entry.effectiveConfig.gridExpanded = true; } } function updateRuntimeUseGaplessCards(sectionId, useGaplessCards) { const entry = getRuntime(sectionId); if (!entry?.effectiveConfig) return; if (useGaplessCards === true) entry.effectiveConfig.useGaplessCards = true; else delete entry.effectiveConfig.useGaplessCards; } /** @deprecated Use updateRuntimeItemsLayout */ function updateRuntimeGridExpanded(sectionId, expanded) { updateRuntimeItemsLayout(sectionId, expanded === true ? 'grid' : 'row'); } window.KefinHomeScreenSectionRuntime = { register: registerRuntime, get: getRuntime, unregister(sectionId) { runtime.delete(sectionId); }, getAll() { return [...runtime.values()]; } }; async function loadHomeScreen() { if (pendingHomeScreen) return pendingHomeScreen; const api = getConfigApi(); if (!window.userHelper?.getUserDisplayPreferences) { return api.createEmptyHomeScreen(); } const { promise } = await window.userHelper.getUserDisplayPreferences(); const displayPrefs = await promise; return api.parseKefinTweaksHomeScreen(displayPrefs?.CustomPrefs); } function buildPrefOverrides(cfg) { const spotlight = cfg.spotlightConfig || {}; return { renderMode: resolveRenderMode(cfg), order: cfg.order, ttl: cfg.ttl, cardFormat: cfg.cardFormat, animationEnabled: spotlight.panAnimation, hideName: cfg.hideName === true, hideCardTitles: cfg.cardTitleVisibility === 'hidden' || cfg.hideCardTitles === true, cardTitlePosition: cfg.cardTitlePosition, borderStyle: cfg.borderStyle, borderColor: cfg.borderColor, spotlightLayout: spotlight.spotlightLayout, spotlightSize: spotlight.spotlightSize, spotlightTileCount: spotlight.tileCount, itemsLayout: cfg.itemsLayout === 'grid' || cfg.itemsLayout === 'row' ? cfg.itemsLayout : (cfg.gridExpanded === true ? 'grid' : 'row'), useGaplessCards: cfg.useGaplessCards === true, cardTitleCapitalization: cfg.cardTitleCapitalization, cardTitleFontFamily: cfg.cardTitleFontFamily, cardTitleFontSize: cfg.cardTitleFontSize, cardTitleColor: cfg.cardTitleColor }; } function resolveStoredSectionPrefId(sectionId, effectiveConfig) { const api = getConfigApi(); if (api?.getStoredSectionPrefId && effectiveConfig) { return api.getStoredSectionPrefId({ ...effectiveConfig, id: sectionId }); } return sectionId; } function sectionHasUserOverrides(entry, serverDefaults) { const api = getConfigApi(); const cfg = entry?.effectiveConfig; const defaults = serverDefaults || entry?.serverDefaults || {}; if (!api?.serializeSectionPref || !cfg) return false; const prefStr = api.serializeSectionPref( resolveStoredSectionPrefId(entry.sectionId, cfg), cfg.enabled !== false, buildPrefOverrides(cfg), defaults ); const parts = String(prefStr || '').split(';'); if (parts[1] === 'false') return true; return parts.slice(2).some((part) => part !== ''); } function applyServerDefaultsToConfig(cfg, defaults) { if (!cfg || !defaults) return; const renderMode = defaults.renderMode || 'Normal'; cfg.renderMode = renderMode; cfg.spotlight = renderMode === 'Spotlight'; cfg.order = defaults.order; cfg.ttl = defaults.ttl; cfg.cardFormat = defaults.cardFormat; cfg.cardTitlePosition = defaults.cardTitlePosition; cfg.cardTitleCapitalization = defaults.cardTitleCapitalization; cfg.cardTitleFontFamily = defaults.cardTitleFontFamily; cfg.cardTitleFontSize = defaults.cardTitleFontSize; cfg.enabled = true; if (defaults.hideName) cfg.hideName = true; else delete cfg.hideName; if (defaults.hideCardTitles) { cfg.cardTitleVisibility = 'hidden'; delete cfg.hideCardTitles; } else { delete cfg.cardTitleVisibility; delete cfg.hideCardTitles; } if (defaults.borderStyle) cfg.borderStyle = defaults.borderStyle; else delete cfg.borderStyle; if (defaults.borderColor) cfg.borderColor = defaults.borderColor; else delete cfg.borderColor; if (defaults.cardTitleColor) cfg.cardTitleColor = defaults.cardTitleColor; else delete cfg.cardTitleColor; if (defaults.itemsLayout && defaults.itemsLayout !== 'row') { cfg.itemsLayout = defaults.itemsLayout === 'grid' ? 'grid' : 'row'; if (cfg.itemsLayout === 'grid') cfg.gridExpanded = true; else delete cfg.gridExpanded; } else { delete cfg.itemsLayout; delete cfg.gridExpanded; } if (defaults.useGaplessCards === true) cfg.useGaplessCards = true; else delete cfg.useGaplessCards; cfg.spotlightConfig = { ...(cfg.spotlightConfig || {}), panAnimation: defaults.animationEnabled !== false && defaults.panAnimation !== false, spotlightLayout: defaults.spotlightLayout ?? 'Border', spotlightSize: defaults.spotlightSize ?? 'normal', tileCount: defaults.spotlightTileCount || 1 }; } function syncRestoreDefaultsButton(btn, dirty) { if (!btn) return; btn.disabled = !dirty; btn.classList.toggle('is-disabled', !dirty); btn.setAttribute('aria-disabled', String(!dirty)); } function openRestoreSectionDefaultsConfirm(onConfirm) { if (!window.ModalSystem?.create) { WARN('ModalSystem unavailable for restore confirm'); return null; } closeCardTitleMenu(); if (window.ModalSystem.isOpen?.(RESTORE_SECTION_MODAL_ID)) { window.ModalSystem.close(RESTORE_SECTION_MODAL_ID); } const modalId = RESTORE_SECTION_MODAL_ID; const content = `
This will restore the default settings for this section. Would you like to continue?
`; const footer = ` `; return window.ModalSystem.create({ id: modalId, title: 'Restore Defaults', content, footer, closeOnBackdrop: true, closeOnEscape: true, showCloseButton: true, onOpen: (modal) => { const confirmBtn = modal.dialogFooter?.querySelector('#kefin-section-restore-defaults-confirm'); confirmBtn?.addEventListener('click', async () => { confirmBtn.disabled = true; try { await onConfirm?.(); window.ModalSystem.close(modalId); } catch (e) { WARN('Failed to restore section defaults:', e); confirmBtn.disabled = false; } }); } }); } function openPublishSectionDefaultsConfirm({ onUpdate, onOk }) { if (!window.ModalSystem?.create) { WARN('ModalSystem unavailable for publish confirm'); return null; } closeCardTitleMenu(); if (window.ModalSystem.isOpen?.(PUBLISH_SECTION_MODAL_ID)) { window.ModalSystem.close(PUBLISH_SECTION_MODAL_ID); } const modalId = PUBLISH_SECTION_MODAL_ID; const content = `Apply these settings as the section's default?
`; const footer = ` `; return window.ModalSystem.create({ id: modalId, title: 'Publish Defaults', content, footer, closeOnBackdrop: true, closeOnEscape: true, showCloseButton: true, onOpen: (modal) => { const updateBtn = modal.dialogFooter?.querySelector('#kefin-section-publish-update'); const okBtn = modal.dialogFooter?.querySelector('#kefin-section-publish-ok'); const run = async (fn, btn) => { if (updateBtn) updateBtn.disabled = true; if (okBtn) okBtn.disabled = true; try { await fn?.(); window.ModalSystem.close(modalId); } catch (e) { WARN('Failed to publish section defaults:', e); if (updateBtn) updateBtn.disabled = false; if (okBtn) okBtn.disabled = false; } }; updateBtn?.addEventListener('click', () => run(onUpdate, updateBtn)); okBtn?.addEventListener('click', () => run(onOk, okBtn)); } }); } async function restoreSectionToServerDefaults(entry, serverDefaults) { const defaults = serverDefaults || entry.serverDefaults || {}; applyServerDefaultsToConfig(entry.effectiveConfig, defaults); pendingSaves.delete(entry.sectionId); clearTimeout(saveTimeout); saveTimeout = null; const api = getConfigApi(); let homeScreen = await loadHomeScreen(); if (pendingSaves.size > 0) { pendingSaves.forEach(({ effectiveConfig, serverDefaults: sd }, id) => { const prefStr = api.serializeSectionPref( resolveStoredSectionPrefId(id, effectiveConfig), effectiveConfig.enabled !== false, buildPrefOverrides(effectiveConfig), sd ); homeScreen = api.upsertSectionPref(homeScreen, prefStr); }); pendingSaves.clear(); } const storedId = resolveStoredSectionPrefId(entry.sectionId, entry.effectiveConfig); homeScreen.sections = (homeScreen.sections || []).filter( (s) => api.parseSectionPrefString?.(s)?.id !== storedId ); pendingHomeScreen = homeScreen; await api.saveKefinTweaksHomeScreen(homeScreen); pendingHomeScreen = null; if (entry.element && defaults.order != null) { entry.element.style.order = defaults.order; entry.element.dataset.order = String(defaults.order); } applyPresentationToElement(entry); rerenderSection(entry); closePopover(); } function scheduleSave(sectionId, effectiveConfig, serverDefaults) { pendingSaves.set(sectionId, { effectiveConfig, serverDefaults }); clearTimeout(saveTimeout); saveTimeout = setTimeout(async () => { const batch = new Map(pendingSaves); pendingSaves.clear(); try { const api = getConfigApi(); let homeScreen = await loadHomeScreen(); batch.forEach(({ effectiveConfig, serverDefaults }, id) => { const prefStr = api.serializeSectionPref( resolveStoredSectionPrefId(id, effectiveConfig), effectiveConfig.enabled !== false, buildPrefOverrides(effectiveConfig), serverDefaults ); homeScreen = api.upsertSectionPref(homeScreen, prefStr); }); pendingHomeScreen = homeScreen; await api.saveKefinTweaksHomeScreen(homeScreen); pendingHomeScreen = null; } catch (e) { WARN('Failed to save section preferences:', e); } }, 300); } const HIDE_NAME_DESCRIPTIONS = { on: 'The section name will not be visible.', off: 'The section name will be visible.' }; const BORDER_STYLE_OPTIONS = [ { value: '', label: 'Default' }, { value: 'stairstep', label: 'Single' }, { value: 'double', label: 'Double' }, { value: 'triple', label: 'Triple' }, { value: 'corner', label: 'Corner' }, { value: 'square', label: 'Square' }, { value: 'diamond', label: 'Diamond' }, { value: 'retro-poster', label: 'Retro Poster' }, { value: 'modern-poster', label: 'Modern Poster' }, { value: 'picture-frame', label: 'Picture Frame' }, { value: 'art-gallery', label: 'Art Gallery' }, { value: 'vintage', label: 'Vintage' }, { value: 'wood', label: 'Wood' }, { value: 'film-reel', label: 'Film Reel' }, { value: 'profile', label: 'Profile' } ]; function getEditorConstants() { return window.KefinHomeScreenEditorConstants || {}; } function getCardFormats() { return getEditorConstants().CARD_FORMATS || ['Poster', 'Thumb', 'Series Thumb', 'Series Poster', 'Backdrop', 'Square', 'Random', 'Button', 'Banner', 'Logo', 'Clear Art', 'Disc']; } function getCardTitleHorizontalAlignOptions() { return [ { value: 'default', label: 'Default' }, { value: 'left', label: 'Left' }, { value: 'center', label: 'Center' }, { value: 'right', label: 'Right' } ]; } function getCardTitleVerticalAlignOptions() { return [ { value: 'default', label: 'Default' }, { value: 'top', label: 'Top' }, { value: 'center', label: 'Center' }, { value: 'bottom', label: 'Bottom' } ]; } function parseCardTitleAlign(position) { const p = position && String(position).trim() ? String(position).trim() : ''; if (!p || p === 'default') { return { vertical: 'default', horizontal: 'default' }; } if (!p.startsWith('overlay-')) { const horizontal = (p === 'left' || p === 'center' || p === 'right') ? p : 'default'; return { vertical: 'default', horizontal }; } const rest = p.slice('overlay-'.length); if (rest === 'center') { return { vertical: 'center', horizontal: 'center' }; } const parts = rest.split('-'); if (parts.length >= 2) { const vertical = parts[0]; const horizontal = parts.slice(1).join('-'); return { vertical: (vertical === 'top' || vertical === 'center' || vertical === 'bottom') ? vertical : 'default', horizontal: (horizontal === 'center' || horizontal === 'right') ? horizontal : 'left' }; } return { vertical: 'default', horizontal: 'left' }; } function composeCardTitlePosition(horizontal, vertical) { const v = (vertical === 'top' || vertical === 'center' || vertical === 'bottom') ? vertical : 'default'; if (v === 'default') { if (horizontal === 'default' || !horizontal) return ''; if (horizontal === 'left') return 'left'; if (horizontal === 'center' || horizontal === 'right') return horizontal; return ''; } const h = (horizontal === 'center' || horizontal === 'right') ? horizontal : 'left'; if (v === 'center' && h === 'center') return 'overlay-center'; return `overlay-${v}-${h}`; } function setCardTitlePositionFromAlign(cfg, horizontal, vertical) { let h = horizontal; const v = vertical; if (v !== 'default' && h === 'default') { h = 'left'; } const next = composeCardTitlePosition(h, v); if (next) cfg.cardTitlePosition = next; else delete cfg.cardTitlePosition; } function getCardTitleCapitalizationOptions() { return [ { value: 'normal', label: 'Normal' }, { value: 'uppercase', label: 'Uppercase' }, { value: 'lowercase', label: 'Lowercase' }, { value: 'capitalize', label: 'Capitalize' } ]; } function getCardTitleFontFamilyOptions() { return [ { value: 'default', label: 'Default' }, { value: 'alfa-slab-one', label: 'Alfa Slab One' }, { value: 'anton', label: 'Anton' }, { value: 'changa-one', label: 'Changa One' }, { value: 'exo', label: 'Exo' }, { value: 'orbitron', label: 'Orbitron' }, { value: 'oswald', label: 'Oswald' }, { value: 'prata', label: 'Prata' }, { value: 'roboto', label: 'Roboto' }, { value: 'unbounded', label: 'Unbounded' }, { value: 'vt323', label: 'VT323' }, { value: 'verdana', label: 'Verdana' }, { value: 'courier-new', label: 'Courier New' } ]; } function getCardTitleFontSizeOptions() { return [ { value: 'normal', label: 'Normal' }, { value: 'small', label: 'Small' }, { value: 'large', label: 'Large' } ]; } function getSpotlightLayoutOptions() { return getEditorConstants().SPOTLIGHT_LAYOUT_OPTIONS || [ { value: 'Border', label: 'Border' }, { value: 'Borderless', label: 'Borderless' } ]; } function getSpotlightSizeOptions() { return getEditorConstants().SPOTLIGHT_SIZE_OPTIONS || [ { value: 'normal', label: 'Normal' }, { value: 'large', label: 'Large' }, { value: 'full', label: 'Full' } ]; } function resolveRenderMode(cfg) { const api = getConfigApi(); if (api?.resolveRenderMode) return api.resolveRenderMode(cfg); return cfg?.renderMode || (cfg?.spotlight === true ? 'Spotlight' : 'Normal'); } function isSpotlightSection(cfg) { return cfg?.renderMode === 'Spotlight' || cfg?.spotlight === true; } // 'Random' picks a mode at render time, so a re-render must keep whatever is on screen. function shouldRenderAsSpotlight(entry) { if (resolveRenderMode(entry?.effectiveConfig) === 'Random') { return entry?.element?.classList?.contains('spotlight-section') === true; } return isSpotlightSection(entry?.effectiveConfig); } // A section that was never spotlight has no spotlightConfig, so the popover controls and // the renderer would otherwise fall back to different defaults. function buildSpotlightConfigSeed(existingConfig, serverDefaults = {}) { const existing = existingConfig || {}; const admin = window.KefinHomeScreen?.getConfig?.()?.SPOTLIGHT_SETTINGS || {}; return { ...admin, ...existing, spotlightLayout: existing.spotlightLayout ?? admin.spotlightLayout ?? serverDefaults.spotlightLayout ?? 'Border', spotlightSize: existing.spotlightSize ?? admin.spotlightSize ?? serverDefaults.spotlightSize ?? 'normal', tileCount: existing.tileCount ?? admin.tileCount ?? serverDefaults.spotlightTileCount ?? 1 }; } function applyPresentationToElement(entry) { const cfg = entry.effectiveConfig; const el = entry.element; if (!el || !cfg) return; const titleContainer = el.querySelector('.sectionTitleContainer, .spotlight-section-title-container'); if (titleContainer && titleContainer.style.display === 'none') { titleContainer.style.display = ''; } if (cfg.hideName === true) { el.dataset.hideSectionName = 'true'; } else { delete el.dataset.hideSectionName; } // Prefer the explicit attribute name used by CSS; clear any legacy attr. delete el.dataset.hideName; const hideTitles = cfg.cardTitleVisibility === 'hidden' || cfg.hideCardTitles === true; if (hideTitles) { el.dataset.cardTitleVisibility = 'hidden'; } else { delete el.dataset.cardTitleVisibility; } if (cfg.borderStyle) { el.dataset.border = cfg.borderStyle; el.dataset.borderStyle = cfg.borderStyle; } else { delete el.dataset.border; delete el.dataset.borderStyle; } const borderColor = cfg.borderColor && String(cfg.borderColor).trim(); if (borderColor) { el.style.setProperty('--kefin-card-border-color', borderColor); } else { el.style.removeProperty('--kefin-card-border-color'); } if (window.cardBuilder?.ensureCardBorders) { window.cardBuilder.ensureCardBorders(el); } const titleColor = cfg.cardTitleColor && String(cfg.cardTitleColor).trim(); if (titleColor) { el.style.setProperty('--kefin-card-title-color', titleColor); } else { el.style.removeProperty('--kefin-card-title-color'); } if (cfg.cardTitlePosition) { el.dataset.cardTitlePosition = cfg.cardTitlePosition; } else { delete el.dataset.cardTitlePosition; } if (cfg.cardTitleCapitalization && cfg.cardTitleCapitalization !== 'normal') { el.dataset.cardTitleCapitalization = cfg.cardTitleCapitalization; } else { delete el.dataset.cardTitleCapitalization; } if (cfg.cardTitleFontFamily && cfg.cardTitleFontFamily !== 'default') { el.dataset.fontFamily = cfg.cardTitleFontFamily; } else { delete el.dataset.fontFamily; } if (cfg.cardTitleFontSize && cfg.cardTitleFontSize !== 'normal') { el.dataset.fontSize = cfg.cardTitleFontSize; } else { delete el.dataset.fontSize; } if (shouldRenderAsSpotlight(entry)) { const spotlight = cfg.spotlightConfig || {}; if (spotlight.spotlightLayout) { el.dataset.layout = spotlight.spotlightLayout; } if (spotlight.spotlightSize) { el.dataset.size = spotlight.spotlightSize; } if (spotlight.tileCount != null) { el.dataset.tileCount = String(spotlight.tileCount); } el.querySelectorAll('.spotlight-overlay').forEach(overlay => { const hasVisibleTitle = cfg.hideName !== true && !!el.querySelector('.spotlight-section-title'); overlay.classList.toggle('has-title', hasVisibleTitle); }); } } // Structural classes/attributes owned by each render mode's builder. When a section // switches modes the freshly built root must keep its own set instead of inheriting // the previous mode's. const SPOTLIGHT_MODE_CLASSES = ['spotlight-section', 'padded-left']; const NORMAL_MODE_CLASSES = ['verticalSection', 'emby-scroller-container', 'custom-scroller-container']; const SPOTLIGHT_MODE_ATTRS = ['data-layout', 'data-size', 'data-tile-count']; function replaceSectionElement(entry, newContent) { const cfg = entry.effectiveConfig; const isSpotlight = newContent.classList.contains('spotlight-section'); let preservedLayout = null; let preservedGapless = null; if (!isSpotlight) { const oldItemsContainer = entry.element.querySelector('.itemsContainer'); const layoutFromDom = oldItemsContainer?.getAttribute('data-layout'); preservedLayout = (layoutFromDom === 'grid' || layoutFromDom === 'row') ? layoutFromDom : (window.cardBuilder?.resolveItemsLayout?.(cfg) || 'row'); preservedGapless = oldItemsContainer?.getAttribute('data-gapless') === 'true' || cfg.useGaplessCards === true || window.cardBuilder?.resolveUseGaplessCards?.(cfg) === true; } const wasSpotlight = entry.element.classList.contains('spotlight-section'); const modeChanged = wasSpotlight !== isSpotlight; newContent.style.cssText = entry.element.style.cssText; if (modeChanged) { const staleClasses = wasSpotlight ? SPOTLIGHT_MODE_CLASSES : NORMAL_MODE_CLASSES; entry.element.classList.forEach(cls => { if (!staleClasses.includes(cls)) newContent.classList.add(cls); }); } else { newContent.className = entry.element.className; } Array.from(entry.element.attributes).forEach(attr => { if (attr.name === 'class') return; if (modeChanged && !isSpotlight && SPOTLIGHT_MODE_ATTRS.includes(attr.name)) return; newContent.setAttribute(attr.name, attr.value); }); if (cfg.order != null) { newContent.dataset.order = String(cfg.order); newContent.style.order = cfg.order; } if (cfg.cardFormat) { newContent.setAttribute('data-card-format', cfg.cardFormat); } entry.element.replaceWith(newContent); entry.element = newContent; registerRuntime(entry); applyPresentationToElement(entry); if (!isSpotlight && window.cardBuilder?.applyItemsLayoutState) { window.cardBuilder.invalidateLastRowPadding?.(newContent.querySelector('.itemsContainer')); window.cardBuilder.applyItemsLayoutState(newContent, preservedLayout || 'row', !!preservedGapless); } const titleContainer = newContent.querySelector('.sectionTitleContainer, .spotlight-section-title-container'); if (titleContainer && window.cardBuilder?.attachSectionControlButtons) { window.cardBuilder.attachSectionControlButtons(cfg, newContent, entry.cachedItems); } if (activePopover?.entry?.sectionId === entry.sectionId) { activePopover.entry = entry; activePopover.sectionElement = entry.element; activePopover.anchorButton = getConfigureAnchorButton(entry); const mountElement = preparePopoverMount(activePopover.anchorButton, entry.element); activePopover.mountElement = mountElement; if (activePopover.popover.parentElement !== mountElement) { mountElement.appendChild(activePopover.popover); } requestAnimationFrame(() => repositionActivePopover()); } } function rerenderSection(entry) { const cfg = entry.effectiveConfig; const items = entry.cachedItems; if (!items?.length) { applyPresentationToElement(entry); return; } let newContent = null; if (shouldRenderAsSpotlight(entry) && window.cardBuilder?.renderSpotlightSection) { const activeSlide = entry.element?.querySelector('.spotlight-item[data-active]'); const initialIndex = parseInt(activeSlide?.getAttribute('data-index'), 10); newContent = window.cardBuilder.renderSpotlightSection(items, cfg.name, { viewMoreUrl: cfg.viewMoreUrl, ...(cfg.spotlightConfig || {}), initialIndex: Number.isNaN(initialIndex) ? 0 : initialIndex }); } else if (window.cardBuilder?.renderCards) { newContent = window.cardBuilder.renderCards( items, cfg.name, cfg.viewMoreUrl, cfg.overflowCard, cfg.cardFormat, null, 'Ascending' ); } if (!newContent) return; replaceSectionElement(entry, newContent); } function positionFormatMenu(menu, anchorButton) { const btnRect = anchorButton.getBoundingClientRect(); const menuHeight = menu.offsetHeight; const gap = 4; const margin = 8; const spaceBelow = window.innerHeight - btnRect.bottom; const spaceAbove = btnRect.top; let top; if (spaceBelow >= menuHeight + gap || spaceBelow >= spaceAbove) { top = btnRect.bottom + gap; } else { top = btnRect.top - menuHeight - gap; } top = Math.min(Math.max(top, margin), window.innerHeight - menuHeight - margin); let left = btnRect.left; const maxLeft = window.innerWidth - menu.offsetWidth - margin; left = Math.min(Math.max(left, margin), maxLeft); menu.style.top = `${top}px`; menu.style.left = `${left}px`; } function styleAnchoredPopoverDialog(modal, anchorBtn) { if (!modal?.dialog || !anchorBtn) return; modal.dialog.classList.remove('centeredDialog', 'formDialog', 'smoothScrollY'); modal.dialog.style.position = 'fixed'; modal.dialog.style.margin = '0'; modal.dialog.style.maxHeight = 'none'; modal.dialog.style.minWidth = '8rem'; modal.dialog.style.width = 'auto'; modal.dialog.style.animation = '160ms ease-out 0s 1 normal both running scaleup'; if (modal.dialogContent) { modal.dialogContent.style.padding = '0.25em 0'; modal.dialogContent.style.overflow = 'hidden'; modal.dialogContent.style.minHeight = '0'; } if (modal.backdrop) { modal.backdrop.style.background = 'transparent'; } if (modal.dialogContainer) { modal.dialogContainer.style.pointerEvents = 'none'; } modal.dialog.style.pointerEvents = 'auto'; requestAnimationFrame(() => positionFormatMenu(modal.dialog, anchorBtn)); } function closeActiveFormatMenu() { if (window.ModalSystem?.isOpen?.(OPTION_MENU_MODAL_ID)) { window.ModalSystem.close(OPTION_MENU_MODAL_ID); } if (activePopover) { activePopover.formatMenu = null; activePopover.formatMenuAnchor = null; activePopover.formatMenuModalId = null; } } function closeCardTitleMenuOnly() { if (window.ModalSystem?.isOpen?.(CARD_TITLE_MENU_MODAL_ID)) { window.ModalSystem.close(CARD_TITLE_MENU_MODAL_ID); } if (activePopover) { activePopover.cardTitleMenu = null; activePopover.cardTitleMenuAnchor = null; activePopover.cardTitleMenuModalId = null; } } function closeCardTitleMenu() { closeActiveFormatMenu(); closeCardTitleMenuOnly(); } function trackFormatMenu(modal, anchorBtn, modalId) { if (!activePopover) return; activePopover.formatMenu = modal?.dialog || null; activePopover.formatMenuAnchor = anchorBtn; activePopover.formatMenuModalId = modalId; if (modal) { modal.onClose = () => { if (activePopover?.formatMenuModalId === modalId) { activePopover.formatMenu = null; activePopover.formatMenuAnchor = null; activePopover.formatMenuModalId = null; } }; } } function trackCardTitleMenu(modal, anchorBtn) { if (!activePopover) return; activePopover.cardTitleMenu = modal?.dialog || null; activePopover.cardTitleMenuAnchor = anchorBtn; activePopover.cardTitleMenuModalId = CARD_TITLE_MENU_MODAL_ID; if (modal) { modal.onClose = () => { closeActiveFormatMenu(); if (activePopover?.cardTitleMenuModalId === CARD_TITLE_MENU_MODAL_ID) { activePopover.cardTitleMenu = null; activePopover.cardTitleMenuAnchor = null; activePopover.cardTitleMenuModalId = null; } }; } } /** * Anchored option menu (card format, border style, spotlight size/tiles) via ModalSystem. * @param {HTMLElement} anchorBtn * @param {Array<{value:string,label:string}>} options * @param {{ * activeValue?: string, * onSelect: (value: string) => void, * colorControls?: { * value?: string, * onChange: (value: string) => void, * onClear: () => void * } * }} opts */ function openOptionMenu(anchorBtn, options, opts) { if (!window.ModalSystem?.create) { WARN('ModalSystem unavailable for option menu'); return null; } closeActiveFormatMenu(); if (!opts?.preserveCardTitleMenu) { closeCardTitleMenuOnly(); } const active = opts?.activeValue != null ? String(opts.activeValue) : ''; const colorControls = opts?.colorControls; const gaplessControls = opts?.gaplessControls; const colorEnabled = !!active; const colorValue = colorControls?.value && isCssColorString(colorControls.value) ? String(colorControls.value).trim() : '#e8dab2'; const renderOption = (opt) => ` `; let optionsHtml; if (colorControls) { const headerOpt = (options || [])[0]; const listOpts = (options || []).slice(1); optionsHtml = ` `; } else if (gaplessControls) { const gaplessChecked = gaplessControls.value === true; optionsHtml = ` `; } else { optionsHtml = (options || []).map(renderOption).join(''); } const content = ` `; let borderPickr = null; const modal = window.ModalSystem.create({ id: OPTION_MENU_MODAL_ID, title: null, content, footer: null, showCloseButton: false, closeOnBackdrop: true, closeOnEscape: true, fixedSize: false, onOpen: (modalInstance) => { styleAnchoredPopoverDialog(modalInstance, anchorBtn); const root = modalInstance.dialogContent; const colorRow = root?.querySelector('.kefin-section-format-color-row'); const colorSwatch = root?.querySelector('.kefin-section-color-swatch'); const colorReset = root?.querySelector('.kefin-section-color-reset'); const syncColorRowEnabled = (styleValue) => { if (!colorRow) return; const enabled = !!styleValue; colorRow.classList.toggle('is-disabled', !enabled); if (colorReset) colorReset.disabled = !enabled; if (colorSwatch) colorSwatch.disabled = !enabled; if (borderPickr) { if (enabled) borderPickr.enable(); else borderPickr.disable(); } }; const syncColorResetVisibility = (hasCustomColor) => { colorReset?.classList.toggle('is-hidden', !hasCustomColor); }; const setSwatchColor = (css) => { if (colorSwatch) colorSwatch.style.setProperty('--kefin-swatch-color', css || '#e8dab2'); }; // Lazy-init Pickr on first swatch click so opening the menu never depends on Pickr layout. const ensureBorderPickr = () => { if (borderPickr || !colorSwatch) return borderPickr; borderPickr = createPickrInstance(colorSwatch, { defaultColor: colorValue, useAsButton: true, onChange: (css) => { setSwatchColor(css); syncColorResetVisibility(true); colorControls.onChange?.(css); }, onSave: (css) => { setSwatchColor(css); syncColorResetVisibility(true); colorControls.onChange?.(css); }, onShow: () => setAnchoredMenuBackdropPassThrough(modalInstance, true), onHide: () => setAnchoredMenuBackdropPassThrough(modalInstance, false) }); if (!colorEnabled) borderPickr?.disable(); return borderPickr; }; colorSwatch?.addEventListener('click', (ev) => { ev.stopPropagation(); if (colorSwatch.disabled) return; if (!borderPickr) { const pickr = ensureBorderPickr(); // Same-event listeners added by Pickr won't run; open on next frame. requestAnimationFrame(() => pickr?.show()); } // After init, Pickr's useAsButton handler toggles open/close. }); root?.querySelectorAll('.kefin-section-format-option').forEach((optionBtn) => { optionBtn.addEventListener('click', (ev) => { ev.stopPropagation(); const value = optionBtn.dataset.value ?? ''; root.querySelectorAll('.kefin-section-format-option').forEach((btn) => { btn.classList.toggle('is-active', (btn.dataset.value ?? '') === value); }); if (colorControls) { syncColorRowEnabled(value); if (!value) { setSwatchColor('#e8dab2'); try { borderPickr?.setColor('#e8dab2', true); } catch (_) { /* ignore */ } syncColorResetVisibility(false); } } opts?.onSelect?.(value); }); }); if (colorControls) { colorReset?.addEventListener('click', (ev) => { ev.stopPropagation(); if (colorReset.disabled) return; setSwatchColor('#e8dab2'); try { borderPickr?.setColor('#e8dab2', true); } catch (_) { /* ignore */ } syncColorResetVisibility(false); colorControls.onClear?.(); }); } const gaplessInput = root?.querySelector('.kefin-section-format-gapless-input'); gaplessInput?.addEventListener('change', (ev) => { ev.stopPropagation(); gaplessControls?.onChange?.(!!gaplessInput.checked); }); gaplessInput?.addEventListener('click', (ev) => ev.stopPropagation()); } }); trackFormatMenu(modal, anchorBtn, OPTION_MENU_MODAL_ID); const prevFormatOnClose = modal.onClose; modal.onClose = () => { destroyPickrInstance(borderPickr); borderPickr = null; prevFormatOnClose?.(); }; return modal; } function areCardTitlesVisible(cfg) { return !(cfg?.cardTitleVisibility === 'hidden' || cfg?.hideCardTitles === true); } function syncCardTitleVisibilityButton(btn, visible) { if (!btn) return; const isOn = visible !== false; const icon = btn.querySelector('.material-icons'); if (icon) icon.textContent = isOn ? 'visibility' : 'visibility_off'; btn.classList.toggle('is-hidden-visibility', !isOn); const label = isOn ? 'Hide card titles' : 'Show card titles'; btn.title = label; btn.setAttribute('aria-label', label); btn.setAttribute('aria-pressed', String(isOn)); } /** * Anchored Card Title options menu: visibility + color, then nested icon option menus. */ function openCardTitleOptionsMenu(anchorBtn, entry, serverDefaults) { if (!window.ModalSystem?.create) { WARN('ModalSystem unavailable for card title options'); return null; } closeCardTitleMenu(); const cfg = entry.effectiveConfig; const titlesVisible = areCardTitlesVisible(cfg); const titleColor = cfg.cardTitleColor && isCssColorString(cfg.cardTitleColor) ? String(cfg.cardTitleColor).trim() : '#ffffff'; const hasCustomColor = !!(cfg.cardTitleColor && String(cfg.cardTitleColor).trim()); const content = ` `; let titlePickr = null; const modal = window.ModalSystem.create({ id: CARD_TITLE_MENU_MODAL_ID, title: null, content, footer: null, showCloseButton: false, closeOnBackdrop: true, closeOnEscape: true, fixedSize: false, onOpen: (modalInstance) => { styleAnchoredPopoverDialog(modalInstance, anchorBtn); const root = modalInstance.dialogContent; const visibilityBtn = root?.querySelector('.kefin-section-card-title-visibility-btn'); const colorRow = root?.querySelector('.kefin-section-card-title-color-row'); const colorBtn = root?.querySelector('.kefin-section-card-title-color-btn'); const colorReset = root?.querySelector('.kefin-section-card-title-color-reset'); const syncColorEnabled = (visible) => { if (!colorRow || !colorReset) return; colorRow.classList.toggle('is-disabled', !visible); colorReset.disabled = !visible; if (colorBtn) colorBtn.disabled = !visible; if (titlePickr) { if (visible) titlePickr.enable(); else titlePickr.disable(); } }; const syncColorResetVisibility = (hasColor) => { colorReset?.classList.toggle('is-hidden', !hasColor); }; const applyTitleChange = () => { persistAndApply(entry, serverDefaults, () => applyPresentationToElement(entry)); }; // Lazy-init on click — creating Pickr during onOpen was breaking the popover. const ensureTitlePickr = () => { if (titlePickr || !colorBtn) return titlePickr; titlePickr = createPickrInstance(colorBtn, { defaultColor: titleColor, useAsButton: true, onChange: (css) => { entry.effectiveConfig.cardTitleColor = css; syncColorResetVisibility(true); applyTitleChange(); }, onSave: (css) => { entry.effectiveConfig.cardTitleColor = css; syncColorResetVisibility(true); applyTitleChange(); }, onShow: () => setAnchoredMenuBackdropPassThrough(modalInstance, true), onHide: () => setAnchoredMenuBackdropPassThrough(modalInstance, false) }); if (!titlesVisible) titlePickr?.disable(); return titlePickr; }; visibilityBtn?.addEventListener('click', (ev) => { ev.stopPropagation(); closeActiveFormatMenu(); const nextVisible = !areCardTitlesVisible(entry.effectiveConfig); if (nextVisible) { delete entry.effectiveConfig.cardTitleVisibility; delete entry.effectiveConfig.hideCardTitles; } else { entry.effectiveConfig.cardTitleVisibility = 'hidden'; delete entry.effectiveConfig.hideCardTitles; } syncCardTitleVisibilityButton(visibilityBtn, nextVisible); syncColorEnabled(nextVisible); applyTitleChange(); }); colorBtn?.addEventListener('click', (ev) => { ev.stopPropagation(); closeActiveFormatMenu(); if (colorBtn.disabled) return; if (!titlePickr) { const pickr = ensureTitlePickr(); requestAnimationFrame(() => pickr?.show()); } // After init, Pickr's useAsButton handler toggles open/close. }); colorReset?.addEventListener('click', (ev) => { ev.stopPropagation(); closeActiveFormatMenu(); if (colorReset.disabled) return; try { titlePickr?.setColor('#ffffff', true); } catch (_) { /* ignore */ } delete entry.effectiveConfig.cardTitleColor; syncColorResetVisibility(false); applyTitleChange(); }); const openNested = (btn, options, getActiveValue, applyValue) => { if (!btn) return; btn.addEventListener('click', (ev) => { ev.stopPropagation(); openOptionMenu(btn, options, { preserveCardTitleMenu: true, activeValue: getActiveValue(), onSelect: (value) => { applyValue(value); applyTitleChange(); } }); }); }; openNested( root?.querySelector('.kefin-section-card-title-font-btn'), getCardTitleFontFamilyOptions(), () => entry.effectiveConfig.cardTitleFontFamily || 'default', (value) => { if (value && value !== 'default') entry.effectiveConfig.cardTitleFontFamily = value; else delete entry.effectiveConfig.cardTitleFontFamily; } ); openNested( root?.querySelector('.kefin-section-card-title-size-btn'), getCardTitleFontSizeOptions(), () => entry.effectiveConfig.cardTitleFontSize || 'normal', (value) => { if (value && value !== 'normal') entry.effectiveConfig.cardTitleFontSize = value; else delete entry.effectiveConfig.cardTitleFontSize; } ); openNested( root?.querySelector('.kefin-section-card-title-horizontal-btn'), getCardTitleHorizontalAlignOptions(), () => parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition).horizontal, (value) => { const align = parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition); setCardTitlePositionFromAlign(entry.effectiveConfig, value, align.vertical); } ); openNested( root?.querySelector('.kefin-section-card-title-vertical-btn'), getCardTitleVerticalAlignOptions(), () => parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition).vertical, (value) => { const align = parseCardTitleAlign(entry.effectiveConfig.cardTitlePosition); setCardTitlePositionFromAlign(entry.effectiveConfig, align.horizontal, value); } ); openNested( root?.querySelector('.kefin-section-card-title-caps-btn'), getCardTitleCapitalizationOptions(), () => entry.effectiveConfig.cardTitleCapitalization || 'normal', (value) => { if (value && value !== 'normal') entry.effectiveConfig.cardTitleCapitalization = value; else delete entry.effectiveConfig.cardTitleCapitalization; } ); } }); trackCardTitleMenu(modal, anchorBtn); const prevCardTitleOnClose = modal.onClose; modal.onClose = () => { destroyPickrInstance(titlePickr); titlePickr = null; prevCardTitleOnClose?.(); }; return modal; } function syncSpotlightLayoutButton(btn, layout) { if (!btn) return; const isBorderless = layout === 'Borderless'; const icon = btn.querySelector('.material-icons'); if (icon) icon.textContent = isBorderless ? 'crop_landscape' : 'fullscreen'; const nextLabel = isBorderless ? 'Switch to Border layout' : 'Switch to Borderless layout'; btn.title = nextLabel; btn.setAttribute('aria-label', nextLabel); btn.dataset.layout = isBorderless ? 'Borderless' : 'Border'; } function normalizeSpotlightTileCount(tileCount) { const n = parseInt(tileCount, 10); return n === 2 || n === 3 ? n : 1; } function nextSpotlightTileCount(tileCount) { const current = normalizeSpotlightTileCount(tileCount); return current === 1 ? 2 : current === 2 ? 3 : 1; } function syncSpotlightTilesButton(btn, tileCount) { if (!btn) return; const current = normalizeSpotlightTileCount(tileCount); const next = nextSpotlightTileCount(current); const icon = btn.querySelector('.material-icons'); if (icon) { icon.textContent = next === 1 ? 'looks_one' : next === 2 ? 'looks_two' : 'looks_3'; } const label = next === 1 ? '1 tile' : `${next} tiles`; btn.title = label; btn.setAttribute('aria-label', label); btn.dataset.tileCount = String(current); } function syncVisibilityButton(btn, enabled) { if (!btn) return; const isOn = enabled !== false; const icon = btn.querySelector('.material-icons'); if (icon) icon.textContent = isOn ? 'visibility' : 'visibility_off'; btn.classList.toggle('is-hidden-visibility', !isOn); const label = isOn ? 'Hide section' : 'Show section'; btn.title = label; btn.setAttribute('aria-label', label); btn.setAttribute('aria-pressed', String(isOn)); } function buildSelectOptionsHtml(options, selectedValue) { return options.map(opt => ` `).join(''); } function buildConfigureRow(label, controlHtml, descriptionHtml = '', options = {}) { const { stacked = false, controlId = '' } = options; if (stacked) { const labelFor = controlId ? ` for="${escapeHtml(controlId)}"` : ''; return `