/* 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',
`
${segmented('bgMode',[['static','Static color'],['daynight','Follows the Sun']],'Plan background')}${bgDetail}
`
);
}
function sunCard() {
return card('sun','Sun',
`
${help.button('north')}
°${draft.northDeg!==null?``:''}
N
${northNeedle}
${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});