/* General settings: design prototype of the house-wide settings dialog. Same visual system as Space settings (cards in one flow, "?" help, inline color fields). Field keys mirror the product's SettingsDialogState; data stays in this origin's localStorage. */ import {registerDemoForm, notifyDemoFormClosed} from './demo-shell.js'; import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; const STORAGE_KEY = 'houseplan-general-settings-design-v1'; /* DEFAULT_FILL_COLORS from src/logic.ts; opacity kept as a fraction like the product. */ const DEFAULT_COLORS = { light_on:{c:'#ffd45c',a:0.18}, light_off:{c:'#9aa0a6',a:0.14}, light_none:{c:'#6b7480',a:0}, temp_cold:{c:'#4fc3f7',a:0.18}, temp_ok:{c:'#66d17a',a:0.18}, temp_hot:{c:'#ffd45c',a:0.18}, lqi_low:{c:'#f25a4a',a:0.18}, lqi_high:{c:'#4bd28f',a:0.18}, glow_base:{c:'#0d1b2a',a:0.5}, glow_light:{c:'#ffd9a0',a:0.85}, wall_fill:{c:'#ffffff',a:1}, }; const defaults = { showRoomTooltip:true, radarShowLive:true, zigbeeTopology:{enabled:false, z2mTopics:'zigbee2mqtt'}, colors:clone(DEFAULT_COLORS), glowRadius:3, bgMode:'daynight', bgColor:null, northDeg:null, sunRays:true, sunRayOrigin:'inner', }; /* Visual example for a fresh browser: matches the current stage (north 165°, Zigbee links on). */ const example = {...clone(defaults), northDeg:165, zigbeeTopology:{enabled:true, z2mTopics:'zigbee2mqtt'}}; const THEME_BG = '#edf1f2'; function load() { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (!data || typeof data !== 'object') return clone(example); const ok = Object.keys(defaults).every(k => k in data) && Object.keys(DEFAULT_COLORS).every(k => data.colors?.[k]?.c); return ok ? data : clone(example); } catch { return clone(example); } } let saved = load(); let draft = clone(saved); const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); /* Dialog shell: same structure and classes as the Space settings dialog. */ const dialog = document.createElement('dialog'); dialog.id = 'general-settings'; dialog.className = 'settings-dialog'; dialog.setAttribute('aria-labelledby', 'gs-title'); dialog.innerHTML = `

General settings

`; $('#demo-stage').append(dialog); const subDialog = document.createElement('dialog'); subDialog.id = 'gs-sub-dialog'; subDialog.className = 'sub-dialog'; subDialog.setAttribute('aria-labelledby', 'gs-sub-title'); document.body.append(subDialog); const content = $('#gs-content'); const scroller = $('#gs-scroll'); /* Help texts: product keys where they exist (gs.*.help, topology help). */ const helpCopy = { zigbee:['Zigbee links','Shows which Zigbee devices each device talks to directly. Line colour is link quality (LQI) on the same scale as the device’s LQI reading: red at 40 and below, green at 180 and above. A dashed line means quality was not reported. An arrow points to the next device on the way to the coordinator; a line without an arrow is a spare neighbour. Data comes from the last load and goes stale; links are shown with a mouse only.'], fills:['Room fill colors','Fill colors apply to every space; each color has its own opacity. Which fill mode a space uses is set in that space’s dialog.'], glowRadius:['Glow radius','Sets the default glow radius in metres or feet; a device-specific radius overrides it.'], bgMode:['Plan background','“Follows the Sun” uses sun.sun, falling back to the browser’s local clock; Static always shows the selected colour.'], north:['North on the plan','The angle is measured clockwise from the plan’s upward vertical; north is required for window rays, not for the “Follows the Sun” background.'], }; const help = attachHelp(content, scroller, helpCopy); /* Rendering helpers (same markup as Space settings). */ const card = (id, title, body, extra='') => `

${title}

${extra}
${body}
`; const hint = text => `

${text}

`; const callout = (text, warning=false) => `
${icon('info')}

${text}

`; const subsectionHeading = (text, key='') => `

${text}

${key?help.button(key):''}
`; function toggle(key, title, description, name, disabled=false) { return `
${icon(name)}
${description?`${description}`:''}
`; } function segmented(key, values, label) { return `
${values.map(([value,title])=>``).join('')}
`; } /* Color tiles: one card per color with a large swatch (system picker), name, hex and opacity. Groups of colors sit in one row. */ const onDark = hex => { const n = parseInt(hex.slice(1), 16); const r = n >> 16, g = (n >> 8) & 255, b = n & 255; return (0.299*r + 0.587*g + 0.114*b) < 150; }; function colorTile(key, label) { const {c, a} = draft.colors[key]; return `
${label}
${c.toUpperCase()}
`; } const colorTiles = items => `
${items.map(([k,l])=>colorTile(k,l)).join('')}
`; /* Single color: same plate as the Space settings color fields (label above, swatch, hex, optional opacity and Reset). */ function colorPlate(key, label, {opacity=true, reset=''}={}) { const {c, a} = draft.colors[key]; return `
${c.toUpperCase()}${opacity?``:''}${reset?``:''}
`; } const northNeedle = ''; function generalCard() { return card('general','Display', toggle('showRoomTooltip','Show the room information window on hover','Hover a room on the plan to see its summary.','tooltip')+ toggle('radarShowLive','Show live presence on the plan','Hides or shows all live radar targets. This does not start or stop observation recording.','radar') ); } function zigbeeCard() { const z = draft.zigbeeTopology, savedEnabled = saved.zigbeeTopology.enabled; const providers = z.enabled ? ` ${!savedEnabled?callout('Save this setting before loading topology data.'):''}
${subsectionHeading('ZHA')}${hint('Reads ZHA’s cached topology; it does not start a radio scan.')}
Data not loaded
${subsectionHeading('Zigbee2MQTT')}
${callout('A network-map scan can take 10 seconds to 2 minutes and may temporarily reduce Zigbee responsiveness.',true)}
Data not loaded
` : ''; return card('zigbee','Zigbee links', `
${icon('zigbee')}
Shows observed direct neighbours only. Nothing is fetched while you hover.
`+providers, help.button('zigbee') ); } function fillsCard() { return card('fills','Room fill colors', `
${subsectionHeading('Lights')}${colorTiles([['light_on','Lights on'],['light_off','All lights off'],['light_none','No light sources']])}
`+ `
${subsectionHeading('Temperature')}${colorTiles([['temp_cold','Cold'],['temp_ok','Comfortable'],['temp_hot','Hot']])}
`+ `
${subsectionHeading('Zigbee signal')}${colorTiles([['lqi_low','Weak signal'],['lqi_high','Strong signal']])}
`, help.button('fills') ); } function glowCard() { return card('glow','Light-source glow', colorTiles([['glow_base','House darkness'],['glow_light','Default light color / intensity']])+ `
${help.button('glowRadius')}
m
${errorSlot('glowRadius')}
` ); } function planCard() { const bgDetail = draft.bgMode==='static' ? `
${(draft.bgColor||THEME_BG).toUpperCase()}${draft.bgColor?``:''}
${hint(draft.bgColor?'Custom background color.':'Using the theme background.')}
` : ''; return card('plan','Plan', colorPlate('wall_fill','Wall fill')+ `
Plan background${help.button('bgMode')}
${segmented('bgMode',[['static','Static color'],['daynight','Follows the Sun']],'Plan background')}${bgDetail}
` ); } function sunCard() { return card('sun','Sun', `
${help.button('north')}
°${draft.northDeg!==null?``:''}
${errorSlot('northDeg')}`+ `
${toggle('sunRays','Sunlight through windows','Draw sun rays through window openings.','sun')}
`+ `
Sun rays${segmented('sunRayOrigin',[['inner','From the inner window corners'],['outer','From the outer window corners']],'Sun rays')}
` ); } function dataCard() { return card('data','Data', `
${subsectionHeading('Backup and transfer')}${hint('Download a portable JSON backup or preview and import a backup made by House Plan.')}
`+ `
${subsectionHeading('Plan maintenance')}${hint('Updates data models, aligns plan elements to the grid and merges redundant wall fragments. An exact report is shown before anything is stored.')}
` ); } /* Validation, chrome and state. */ function errorFor(key) { if (key==='glowRadius' && (draft.glowRadius==='' || !Number.isFinite(Number(draft.glowRadius)) || Number(draft.glowRadius)<=0)) return 'Enter a radius greater than 0.'; if (key==='northDeg' && draft.northDeg!==null && (draft.northDeg==='' || !Number.isInteger(Number(draft.northDeg)) || draft.northDeg<0 || draft.northDeg>359)) return 'Enter a whole number between 0° and 359°.'; return ''; } const errorFields = ['glowRadius','northDeg']; const allErrors = () => errorFields.map(k=>[k,errorFor(k)]).filter(([,m])=>m); const errorSlot = key => `
${errorFor(key)}
`; function renderMain() { closeHelp(); content.innerHTML = generalCard()+zigbeeCard()+fillsCard()+glowCard()+planCard()+sunCard()+dataCard(); } function updateChrome() { const errors = allErrors(); $('#gs-save').disabled = !dirty() || errors.length>0; const status = $('#gs-status'); status.classList.toggle('dirty', dirty()); status.hidden = !dirty() && !errors.length; status.textContent = dirty() ? 'Unsaved changes' : ''; if (errors.length) status.innerHTML = ``; errorFields.forEach(key => { const el = $(`#gs-error-${key}`), message = errorFor(key); if (el) { el.hidden = !message; el.textContent = message; } $$(`[data-field="${key}"]`, content).forEach(input => input.setAttribute('aria-invalid', String(!!message))); }); $$('[data-hex]', content).forEach(el => { const k = el.dataset.hex; el.textContent = (k==='bg' ? (draft.bgColor||THEME_BG) : draft.colors[k].c).toUpperCase(); }); const arrow = $('#gs-dial-arrow'); if (arrow) arrow.style.transform = `rotate(${draft.northDeg??0}deg)`; $('#gs-dial-arrow')?.parentElement.classList.toggle('unset', draft.northDeg===null); } const render = () => { renderMain(); updateChrome(); }; function rerenderKeepingScroll() { const top = scroller.scrollTop; renderMain(); updateChrome(); scroller.scrollTop = top; } function updateField(el) { const key = el.dataset.field; if (el.dataset.color) { draft.colors[el.dataset.color].c = el.value; el.parentElement.classList.toggle('on-dark', onDark(el.value)); updateChrome(); return; } if (el.dataset.opacity) { const n = Math.min(100, Math.max(0, Number(el.value))); if (Number.isFinite(n)) draft.colors[el.dataset.opacity].a = n/100; updateChrome(); return; } if (!key) return; if (key==='zigbeeEnabled') { draft.zigbeeTopology.enabled = el.checked; rerenderKeepingScroll(); return; } if (key==='z2mTopics') { draft.zigbeeTopology.z2mTopics = el.value; $('[data-action="z2m"]', content).lastChild.textContent = `Update map · ${el.value.split('\n')[0].trim()||'zigbee2mqtt'}`; updateChrome(); return; } if (key==='bgColor') { draft.bgColor = el.value; rerenderKeepingScroll(); return; } if (key==='northDeg') { draft.northDeg = el.value==='' ? null : Number(el.value); updateChrome(); if ((el.value==='')!==!$('[data-action="clear-north"]', content)) rerenderKeepingScroll(); return; } if (el.type==='checkbox') draft[key] = el.checked; else if (el.type==='number') draft[key] = el.value==='' ? '' : Number(el.value); else draft[key] = el.value; if (key==='bgMode') rerenderKeepingScroll(); else updateChrome(); } function handleAction(action) { if (action==='review-errors') { const first = allErrors()[0]; if (!first) return; const field = $(`[data-field="${first[0]}"]`, content); field?.scrollIntoView({block:'center'}); field?.focus({preventScroll:true}); return; } if (action==='bg-theme') draft.bgColor = null; if (action==='clear-north') draft.northDeg = null; if (action==='zha' || action==='z2m') { showToast('Topology data is loaded from Home Assistant in the product; this preview only shows the controls.'); return; } if (action==='export') { showToast('The product downloads a JSON backup here.'); return; } if (action==='import') { showToast('The product opens a file picker and previews the backup here.'); return; } if (action==='optimize') { showToast('The product shows an exact optimization report before storing anything.'); return; } rerenderKeepingScroll(); } /* Open, close, save. */ let closeDecision, lastSubTrigger; function openSettings() { saved = load(); draft = clone(saved); render(); if (!dialog.open) dialog.show(); scroller.scrollTop = 0; $('#gs-title').focus({preventScroll:true}); } function closeSettings() { dialog.close(); notifyDemoFormClosed('general-settings'); } function showSub(title, body, actions) { lastSubTrigger = document.activeElement; subDialog.innerHTML = `

${title}

${body}
${actions}
`; subDialog.showModal(); } function closeSub() { subDialog.close(); lastSubTrigger?.focus({preventScroll:true}); if (closeDecision) { const r = closeDecision; closeDecision = undefined; r(false); } } function requestClose() { if (!dirty()) { closeSettings(); return Promise.resolve(true); } showSub('Discard your changes?', '

Your changes to the general settings haven’t been saved.

', ''); $('[data-sub="close"].primary', subDialog).focus(); return new Promise(resolve => { closeDecision = resolve; }); } function saveSettings() { if (!dirty() || allErrors().length) return; const normalized = clone(draft); normalized.zigbeeTopology.z2mTopics = normalized.zigbeeTopology.z2mTopics.split('\n').map(s=>s.trim()).filter(Boolean).join('\n') || 'zigbee2mqtt'; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized)); } catch { showToast('Could not save locally. Enable browser storage and try again.'); return; } saved = clone(normalized); draft = clone(saved); closeSettings(); showToast('General settings saved in this browser.'); } content.addEventListener('input', e => { if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') return; updateField(e.target); }); content.addEventListener('change', e => { if (['checkbox','radio'].includes(e.target.type) || e.target.tagName==='SELECT') updateField(e.target); }); content.addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b.dataset.action); }); $('#gs-status').addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b.dataset.action); }); $('#gs-close').addEventListener('click', requestClose); $('#gs-cancel').addEventListener('click', requestClose); $('#gs-save').addEventListener('click', saveSettings); $('#gs-reset').addEventListener('click', () => { draft = clone(defaults); draft.zigbeeTopology.enabled = saved.zigbeeTopology.enabled; rerenderKeepingScroll(); showToast('Defaults restored. Save to keep them.'); }); dialog.addEventListener('cancel', e => { e.preventDefault(); requestClose(); }); dialog.addEventListener('keydown', e => { if (e.key==='Escape' && helpOpen()) { e.preventDefault(); e.stopPropagation(); closeHelp(); return; } if (e.key==='Escape' && !subDialog.open) { e.preventDefault(); e.stopPropagation(); requestClose(); } }); subDialog.addEventListener('cancel', e => { e.preventDefault(); closeSub(); }); subDialog.addEventListener('click', e => { const b = e.target.closest('button[data-sub]'); if (!b) return; if (b.dataset.sub==='close') { closeSub(); return; } if (b.dataset.sub==='discard') { const r = closeDecision; closeDecision = undefined; draft = clone(saved); closeSub(); closeSettings(); r?.(true); } }); window.addEventListener('beforeunload', e => { if (dialog.open && dirty()) { e.preventDefault(); e.returnValue=''; } }); hydrateIcons(dialog); registerDemoForm({id:'general-settings', title:'General settings', description:'House-wide display, fill colors, glow, background, sun and maintenance.', open:openSettings, requestClose});