/* Room settings: design prototype of the per-room dialog (edit mode).
Same visual system as Space and General settings. Field keys mirror the product's room dialog state
(_nameSel, _areaSel, _roomFill, _roomCustomFill, _roomTempMin/Max, _roomTempSrc, _roomHumSrc, _roomNameScale, _roomLabelScale). */
import {registerDemoForm, notifyDemoFormClosed} from './demo-shell.js';
import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js';
const STORAGE_KEY = 'houseplan-room-settings-design-v1';
/* Demo context: the room lives in Ground Floor; the space fill is Custom #EDEDED, comfort range 20–25 °C. */
const SPACE = {title:'Ground Floor', fill:'custom', customFill:{c:'#ededed',a:1}, tempMin:20, tempMax:25, cardFontScale:1};
const AREAS = [['area_kitchen','Kitchen & Living'],['area_hallway','Hallway'],['area_garage','Garage']];
const SOURCES = [
{value:'device:aqara-th-1', label:'Kitchen climate sensor', sub:'Aqara TH'},
{value:'device:sonoff-snzb-2', label:'Living room sensor', sub:'SONOFF SNZB-02'},
{value:'entity:sensor.kitchen_temperature', label:'Kitchen temperature', sub:'sensor.kitchen_temperature'},
{value:'entity:sensor.kitchen_humidity', label:'Kitchen humidity', sub:'sensor.kitchen_humidity'},
{value:'entity:sensor.thermostat_current', label:'Thermostat current temperature', sub:'sensor.thermostat_current'},
];
const defaults = {
nameSel:'Kitchen & Living', areaSel:'area_kitchen',
roomFill:'', roomCustomFill:null, roomTempMin:'', roomTempMax:'',
roomTempSrc:'', roomHumSrc:'', roomNameScale:100, roomLabelScale:100,
};
function load() {
try {
const data = JSON.parse(localStorage.getItem(STORAGE_KEY));
return data && Object.keys(defaults).every(k => k in data) ? data : clone(defaults);
} catch { return clone(defaults); }
}
let saved = load();
let draft = clone(saved);
let srcOpen = null, srcFilter = '';
const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved);
/* Dialog shell. */
const dialog = document.createElement('dialog');
dialog.id = 'room-settings'; dialog.className = 'settings-dialog'; dialog.setAttribute('aria-labelledby', 'rs-title');
dialog.innerHTML = `
Room settings
${escape(SPACE.title)}
`;
$('#demo-stage').append(dialog);
const subDialog = document.createElement('dialog');
subDialog.id = 'rs-sub-dialog'; subDialog.className = 'sub-dialog'; subDialog.setAttribute('aria-labelledby', 'rs-sub-title');
document.body.append(subDialog);
const content = $('#rs-content');
const scroller = $('#rs-scroll');
const helpCopy = {
area:['Home Assistant area','Links the room to a Home Assistant area: its sensors feed the room card and its lights count as the room’s lights. Only areas not used by another room are listed.'],
fill:['Fill in this room','Overrides the space fill for this room only. “As the space” follows the space setting; Custom uses a fixed color that can also be inherited from the space until you change it.'],
tempRange:['Comfort range','This range applies only to this room. Empty fields inherit the corresponding space bounds; if the bounds are reversed, House Plan uses the smaller value as the minimum.'],
sources:['Sensor sources','By default the room card averages every temperature or humidity sensor in the room’s area. Pick a specific device or entity to show its reading instead.'],
sizes:['Font sizes','Scales the name and the metrics of this room card on top of the space-wide card size. 100% is the default.'],
};
const help = attachHelp(content, scroller, helpCopy);
/* Rendering helpers. */
const card = (id, title, body, extra='') => `
`;
}
const errorFor = key => {
if (key==='nameSel' && !draft.nameSel.trim()) return 'Enter a name for this room.';
if (key==='tempRange') {
const bad = v => v.trim()!=='' && !Number.isFinite(Number(v.replace(',','.')));
if (bad(draft.roomTempMin) || bad(draft.roomTempMax)) return 'Enter numbers, or leave a field empty to inherit the space bound.';
}
if ((key==='roomNameScale'||key==='roomLabelScale') && (draft[key]===''||draft[key]<50||draft[key]>300)) return 'Choose a size between 50% and 300%.';
return '';
};
const errorFields = ['nameSel','tempRange','roomNameScale','roomLabelScale'];
const allErrors = () => errorFields.map(k=>[k,errorFor(k)]).filter(([,m])=>m);
const errorSlot = key => `
${errorFor(key)}
`;
function basicsCard() {
return card('basics','Basics',
`
${segmented('roomFill',[['none','None'],['lqi','Zigbee'],['light','Lights'],['temp','Temperature'],['custom','Custom']],'Fill in this room',draft.roomFill)}
${draft.roomTempMin.trim()||draft.roomTempMax.trim()?``:hint(`Empty fields inherit the space bounds (${SPACE.tempMin}–${SPACE.tempMax} °C).`)}
`
: '';
return card('fill','Fill',
toggle('fillInherit','As the space','Use the fill mode set for the whole space.','layers',inherit)+modeDetail+colorDetail+tempDetail,
help.button('fill')
);
}
function sourceBlock(kind, label) {
const key = kind==='temp' ? 'roomTempSrc' : 'roomHumSrc';
const val = draft[key], open = srcOpen===kind;
const picked = SOURCES.find(s=>s.value===val);
const picker = val || open ? `
${label}${segmented(key+'Mode',[['average','Average of the room’s sensors'],['pick','Specific device or entity']],label,val||open?'pick':'average')}${picker}
`;
}
function sourcesCard() {
return card('sources','Sensor sources', sourceBlock('temp','Temperature')+sourceBlock('hum','Humidity'), help.button('sources'));
}
function sizeRow(key, label) {
return `
50%300%
${errorSlot(key)}
`;
}
function sizesCard() {
return card('sizes','Font sizes', sizeRow('roomNameScale','Room name size')+sizeRow('roomLabelScale','Metrics size')+hint(`On top of the space-wide card size (${Math.round(SPACE.cardFontScale*100)}%).`), help.button('sizes'));
}
function renderMain() {
closeHelp();
content.innerHTML = basicsCard()+fillCard()+sourcesCard()+sizesCard();
$$('.panel-scroll>.field-stack:first-child', content).forEach(el => { el.style.marginTop = '0'; });
}
function updateChrome() {
const errors = allErrors();
$('#rs-save').disabled = !dirty() || errors.length>0;
const status = $('#rs-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 = $(`#rs-error-${key}`), m = errorFor(key); if (el) { el.hidden = !m; el.textContent = m; } });
$$('[data-field="nameSel"]', content).forEach(i => i.setAttribute('aria-invalid', String(!!errorFor('nameSel'))));
const hex = $('[data-hex="fill"]', content); if (hex) hex.textContent = (draft.roomCustomFill||SPACE.customFill).c.toUpperCase();
}
const render = () => { renderMain(); updateChrome(); };
function rerender(focusSelector) {
const top = scroller.scrollTop; renderMain(); updateChrome(); scroller.scrollTop = top;
if (focusSelector) $(focusSelector, content)?.focus({preventScroll:true});
}
function updateField(el) {
const key = el.dataset.field; if (!key) return;
if (key==='fillInherit') { draft.roomFill = el.checked ? '' : (SPACE.fill==='custom'?'custom':SPACE.fill); if (el.checked) draft.roomCustomFill = null; rerender(); return; }
if (key==='roomFill') { draft.roomFill = el.value; if (el.value!=='custom') draft.roomCustomFill = null; rerender(`[data-field="roomFill"]:checked`); return; }
if (key==='fillColor' || key==='fillOpacity') {
const cur = draft.roomCustomFill || clone(SPACE.customFill);
if (key==='fillColor') cur.c = el.value; else { const n = Math.min(100, Math.max(0, Number(el.value))); if (Number.isFinite(n)) cur.a = n/100; }
const wasOwn = !!draft.roomCustomFill; draft.roomCustomFill = cur;
if (!wasOwn) rerender(`[data-field="${key}"]`); else updateChrome(); return;
}
if (key==='roomTempSrcMode' || key==='roomHumSrcMode') {
const kind = key==='roomTempSrcMode' ? 'temp' : 'hum', field = kind==='temp' ? 'roomTempSrc' : 'roomHumSrc';
if (el.value==='average') { draft[field] = ''; srcOpen = null; } else { srcOpen = kind; srcFilter = ''; }
rerender(); return;
}
if (key==='roomNameScale' || key==='roomLabelScale') { draft[key] = el.value==='' ? '' : Number(el.value); $$(`[data-field="${key}"]`, content).filter(x=>x!==el).forEach(x=>{ x.value = el.value; }); updateChrome(); return; }
draft[key] = el.value; updateChrome();
}
function handleAction(button) {
const action = button.dataset.action;
if (action==='review-errors') { const first = allErrors()[0]; if (!first) return; const field = $(`[data-field="${first[0]==='tempRange'?'roomTempMin':first[0]}"]`, content); field?.scrollIntoView({block:'center'}); field?.focus({preventScroll:true}); return; }
if (action==='reset-fill') draft.roomCustomFill = null;
if (action==='reset-temp') { draft.roomTempMin = ''; draft.roomTempMax = ''; }
if (action==='reset-roomNameScale') draft.roomNameScale = 100;
if (action==='reset-roomLabelScale') draft.roomLabelScale = 100;
if (action==='src-toggle') { srcOpen = srcOpen===button.dataset.kind ? null : button.dataset.kind; srcFilter = ''; rerender(srcOpen?`[data-src-filter="${srcOpen}"]`:`[data-action="src-toggle"][data-kind="${button.dataset.kind}"]`); return; }
if (action==='src-pick') { draft[button.dataset.kind==='temp'?'roomTempSrc':'roomHumSrc'] = button.dataset.value; srcOpen = null; rerender(`[data-action="src-toggle"][data-kind="${button.dataset.kind}"]`); return; }
rerender();
}
/* Open, close, save. */
let closeDecision, lastSubTrigger;
function openSettings() { saved = load(); draft = clone(saved); srcOpen = null; render(); if (!dialog.open) dialog.show(); scroller.scrollTop = 0; $('#rs-title').focus({preventScroll:true}); }
function closeSettings() { dialog.close(); notifyDemoFormClosed('room-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 ${escape(saved.nameSel)} 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), nameSel:draft.nameSel.trim()};
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('Room settings saved in this browser.');
}
content.addEventListener('input', e => {
if (e.target.dataset.srcFilter) { srcFilter = e.target.value; const pos = e.target.selectionStart; rerender(`[data-src-filter="${e.target.dataset.srcFilter}"]`); const f = $(`[data-src-filter="${srcOpen}"]`, content); if (f) f.setSelectionRange(pos, pos); return; }
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); });
$('#rs-status').addEventListener('click', e => { const b = e.target.closest('button[data-action]'); if (b) handleAction(b); });
$('#rs-close').addEventListener('click', requestClose);
$('#rs-cancel').addEventListener('click', requestClose);
$('#rs-save').addEventListener('click', saveSettings);
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' && srcOpen) { e.preventDefault(); e.stopPropagation(); const k = srcOpen; srcOpen = null; rerender(`[data-action="src-toggle"][data-kind="${k}"]`); 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:'room-settings', title:'Room settings', description:'Name, area, fill override, sensor sources and font sizes of one room.', open:openSettings, requestClose});