/* 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='') => `

${title}

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

${text}

`; const fieldHeading = (text, key, forId='') => `
${key?help.button(key):''}
`; function segmented(key, values, label, current) { return `
${values.map(([value,title])=>``).join('')}
`; } function toggle(key, title, description, name, checked) { return `
${icon(name)}
${description?`${description}`:''}
`; } 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', `
${errorSlot('nameSel')}
`+ `
${fieldHeading('Home Assistant area','area','rs-area')}
` ); } function fillCard() { const inherit = draft.roomFill === ''; const effective = inherit ? SPACE.fill : draft.roomFill; const custom = draft.roomCustomFill || SPACE.customFill; const own = !!draft.roomCustomFill; const modeDetail = inherit ? hint(`Following the space: ${({none:'no fill',lqi:'Zigbee signal',light:'Lights',temp:'Temperature',custom:'Custom color'})[SPACE.fill]}.`) : `
${segmented('roomFill',[['none','None'],['lqi','Zigbee'],['light','Lights'],['temp','Temperature'],['custom','Custom']],'Fill in this room',draft.roomFill)}
`; const colorDetail = effective==='custom' && !inherit ? `
${custom.c.toUpperCase()}${own?``:''}
${hint(own?'Custom color for this room.':'Using the space color until you change it.')}
` : ''; const tempDetail = effective==='temp' ? `
${fieldHeading('Comfort range','tempRange')}
${errorSlot('tempRange')}
ColdComfortHot
${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 ? `
${open?`
${SOURCES.filter(s=>!srcFilter||(s.label+' '+s.sub).toLowerCase().includes(srcFilter.toLowerCase())).map(s=>``).join('')||'

Nothing matches.

'}
`:''}
` : ''; return `
${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});