/* Standalone design prototype. All data stays in this origin's localStorage. The field map for the product (SpaceDialogState, i18n keys, controls) is docs/FIELD-MAP.md in the handoff archive. */ import {registerDemoForm, openDemoForm, notifyDemoFormClosed} from './demo-shell.js'; import {DEFAULT_CUSTOM_FILL} from './room-fill.js'; import {$, $$, escape, clone, icon, hydrateIcons, showToast, attachHelp, closeHelp, helpOpen} from './ui.js'; const STORAGE_KEY = 'houseplan-space-settings-design-v1'; const base = { title:'Ground Floor', source:'draw', planName:'', planData:'', cellCm:5, showBorders:true, zeroWallStyle:'dashed', showNames:true, showLqi:false, hideDecor:false, hideOpenings:false, labelTemp:true, labelHum:true, labelLqi:true, labelLight:true, cardFontScale:100, roomColor:'#52636b', roomOpacity:85, fillMode:'custom', fillColor:DEFAULT_CUSTOM_FILL.c, fillOpacity:DEFAULT_CUSTOM_FILL.a*100, customFillSet:false, tempMin:20, tempMax:25, bgMode:'daynight', bgColor:'#edf1f2', bgColorSet:false, northDeg:null, sunRays:'inherit', glowEnabled:true, }; const initialSpaces = [ // Keep the approved visual example reproducible in a fresh browser. {id:'ground-floor', devices:50, settings:{...clone(base),zeroWallStyle:'solid',showLqi:true,roomColor:'#3b4245',fillColor:'#ededed',fillOpacity:100,customFillSet:true}}, {id:'first-floor', devices:0, settings:{...clone(base),title:'First Floor',source:'file',planName:'first-floor.svg',planData:'assets/sample-plan.svg'}}, ]; function loadSpaces() { try { const data = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (!Array.isArray(data) || !data.length) return clone(initialSpaces); const valid = data.every(s => typeof s.id === 'string' && Number.isInteger(s.devices) && s.settings && Object.entries(base).every(([k,v]) => k === 'northDeg' ? s.settings[k] === null || Number.isFinite(s.settings[k]) : typeof s.settings[k] === typeof v)); return valid ? data : clone(initialSpaces); } catch { return clone(initialSpaces); } } let spaces = loadSpaces(); let activeId = spaces[0].id; let saved = clone(spaces[0].settings); let draft = clone(saved); const customFill = () => draft.customFillSet?{c:draft.fillColor,a:Number(draft.fillOpacity)/100}:DEFAULT_CUSTOM_FILL; const currentScroller = () => $('#dialog-scroll'); let lastSubTrigger; let closeDecision; const dialog = $('#settings'); const content = $('#settings-content'); const subDialog = $('#sub-dialog'); const dirty = () => JSON.stringify(draft) !== JSON.stringify(saved); const activeSpace = () => spaces.find(s => s.id === activeId); function errorFor(key) { if(key === 'title' && !draft.title.trim()) return 'Enter a name for this space.'; if(key === 'cellCm' && (!Number.isFinite(Number(draft.cellCm)) || draft.cellCm === '' || Number(draft.cellCm)<.1 || Number(draft.cellCm)>1000)) return 'Enter a grid size between 0.1 and 1000 cm.'; if(key === 'source' && draft.source === 'file' && !draft.planData) return 'Choose a floor-plan image to continue.'; if(key === 'tempMax' && draft.fillMode === 'temp' && (draft.tempMin === '' || draft.tempMax === '' || !Number.isFinite(Number(draft.tempMin)) || !Number.isFinite(Number(draft.tempMax)) || Number(draft.tempMin)>=Number(draft.tempMax))) return 'The maximum must be higher than the minimum.'; if(key === 'northDeg' && draft.northDeg !== null && (draft.northDeg === '' || !Number.isInteger(Number(draft.northDeg)) || Number(draft.northDeg)<0 || Number(draft.northDeg)>359)) return 'Enter a whole number between 0° and 359°.'; if(key === 'cardFontScale' && (draft.cardFontScale === '' || draft.cardFontScale<50 || draft.cardFontScale>300)) return 'Choose a size between 50% and 300%.'; if((key==='roomOpacity' || (key==='fillOpacity' && draft.fillMode==='custom')) && (draft[key] === '' || draft[key]<0 || draft[key]>100)) return 'Enter an opacity between 0% and 100%.'; return ''; } const errorFields = ['title','cellCm','source','tempMax','northDeg','cardFontScale','roomOpacity','fillOpacity']; const allErrors = () => errorFields.map(key => [key,errorFor(key)]).filter(([,message]) => message); const errorSlot = key => `
${errorFor(key)}
`; function textField(key, label, options = {}) { return `
${options.hint ? `

${options.hint}

` : ''}${errorSlot(key)}
`; } const card = (id, title, body, extra='') => `

${title}

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

${text}

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

${text}

`; const helpCopy = { scale:['Grid cell size','The grid sets real-world lengths, areas, wall thickness and opening sizes. Changing it recalculates dimensions for the whole space without moving points on the plan.'], floorPlan:['Floor plan','Use an image as a background to draw over. Rooms and devices stay editable on top of it. Switching to drawing by hand keeps the uploaded image available to use again.'], borders:['Always show room borders','When off, patterned wall outlines and zero-thickness wall lines are hidden. Plain, light wall bands stay visible, so rooms remain separated. Doors, windows, room fills and lighting keep working.'], walls:['Zero-thickness walls','Dashed zero-thickness walls let lamp glow and sunlight pass through. Solid ones block both, even when their lines are hidden on the plan.'], fill:['Room fill','Custom uses a fixed color and opacity. Zigbee uses average signal; Lights distinguishes any on, all off and no sources; Temperature uses the comfort range. State colors and opacity come from general settings. Room overrides take priority. If no visible fill is available, enabled light-source glow adds base shading. Sunlight and glow remain separate effects.'], layers:['Visible layers','Hiding changes only what you see on the plan. Decorative elements remain available in the background editor. Doors and windows stay in the plan editor, while their sensors, sunlight and lamp glow keep working.'], names:['Show room names','Hiding room names also hides their values without clearing your selections. Room-card positions stay where you placed them.'], cardSize:['Room-card font size','100% is the default size. This scales every room card in this space while keeping individual card sizes and positions. Changes apply when you save.'], north:['North on the plan','North is measured clockwise from the top of the plan: 0° is up, 90° is right. Use general settings follows the home-wide value; a custom direction overrides it only for this space. Your plan does not rotate.'], light:['Light-source glow','Lamp glow and sunlight are independent. Hiding opening symbols does not stop either effect. Window glass still lets sunlight in when the window is closed.'], }; const help = attachHelp($('#settings-content'), $('#dialog-scroll'), helpCopy); const helpButton = key => help.button(key); const fieldHeading = (text,help,forId='') => `
${helpButton(help)}
`; const subsectionHeading = (text,help) => `

${text}

${helpButton(help)}
`; const northNeedle = ''; function toggle(key, title, description, name, invert=false, disabled=false, help='') { const checked = invert ? !draft[key] : draft[key]; return `
${icon(name)}
${help?helpButton(help):''}
${description?`${description}`:''}
`; } /* Compact density: one-line rows for uniform on/off lists. The group heading carries the shared explanation. */ function compactToggle(key, title, name, invert=false, disabled=false) { const checked = invert ? !draft[key] : draft[key]; return `
${icon(name)}
`; } const compactList = rows => `
${rows.join('')}
`; /* Icon tiles: a multi-select panel for the values shown on a room card. Pressed tiles use the segmented-control tint. */ function valueTiles(items, label, disabled=false) { return `
${items.map(([key,title,name,full])=>``).join('')}
`; } function select(key, label, values, note='') { return `
${note?hint(note):''}
`; } function segmented(key, values, label) { return `
${values.map(([value,title,decoration])=>``).join('')}
`; } function colorField(key, opacityKey, label, reset='') { const color=key==='fillColor'?customFill().c:draft[key]; const opacity=key==='fillColor'?customFill().a*100:draft[opacityKey]; return `
${color.toUpperCase()}${opacityKey?``:''}${reset?``:''}
${opacityKey?errorSlot(opacityKey):''}
`; } function generalSection() { const upload = draft.source === 'file' ? `
${draft.planData ? `
Selected floor-plan image
${escape(draft.planName)}Ready to use
` : `
${icon('upload')}

Drop your floor plan here

SVG, PNG, JPG or WebP · up to 2 MB in this preview
`}
${errorSlot('source')}` : ''; const scale = `
${fieldHeading('Grid cell size','scale','field-cellCm')}
cm / cell
${errorSlot('cellCm')}

Scale of the plan.

`; return card('general','Basics', `
${textField('title','Space name',{attributes:'maxlength="80" autocomplete="off"',hint:'Shown in the space tabs.'})}${scale}
`+ `${draft.cellCm!==saved.cellCm?info('The scale has changed. Calculated dimensions for this whole space will change when you save.',true):''}`+ `
${subsectionHeading('Floor plan','floorPlan')}
${upload}${draft.source==='draw'&&draft.planData?info('Your uploaded image is kept. Switch back to use it again.'):''}
` ); } function appearanceSection() { const fillOptions = [['custom','Custom'],['lqi','Zigbee'],['light','Lights'],['temp','Temperature']]; const fillDetail = draft.fillMode==='custom' ? colorField('fillColor','fillOpacity','Fill color','reset-fill') : draft.fillMode==='temp' ? `
Comfort range
${errorSlot('tempMax')}
ColdComfortHot
${hint('Below the minimum: cold. Within the range: comfort. Above the maximum: hot. Without a temperature reading, light-source glow can still add base shading.')}
` : hint(draft.fillMode==='lqi'?'Uses average room LQI: low at 40 or below, high at 180 or above. Colors and opacity follow the general palette.':'Uses separate colors for any light on, all lights off, and no light sources. Colors and opacity follow the general palette.'); return card('appearance','Appearance', toggle('showBorders','Always show room borders','Show patterned outlines around the walls.','borders',false,false,'borders')+ `
${fieldHeading('Zero-thickness walls','walls')}${segmented('zeroWallStyle',[['dashed','Dashed',''],['solid','Solid','']],'Zero-thickness walls')}
`+ colorField('roomColor','roomOpacity','Border & name color','reset-room')+ `
${subsectionHeading('Room fill','fill')}${segmented('fillMode',fillOptions,'Room fill')}${fillDetail}`+`
`+ `
${subsectionHeading('Visible layers','layers')}${compactList([compactToggle('hideDecor','Decorative layer','sofa',true),compactToggle('hideOpenings','Doors, windows & gates','door',true),compactToggle('showLqi','Zigbee signal next to devices','zigbee')])}`+`
` ); } function cardsSection() { return card('cards','Room cards', toggle('showNames','Show room names','Display room names and their selected values.','type',false,false,'names')+ `
Values on the card${valueTiles([['labelTemp','Temperature','temperature'],['labelHum','Humidity','humidity'],['labelLqi','Zigbee signal','zigbee','Average Zigbee signal'],['labelLight','Lights on / off','bulb']],'Values on the card',!draft.showNames)}${!draft.showNames?`
${icon('eyeOff')}Values are saved but hidden. to display them.
`:''}
`+ `
${helpButton('cardSize')}
50%300%
${errorSlot('cardFontScale')}${hint('Scales every room card in this space.')}`+`
`, '' ); } function lightSection() { return card('light','Sun & light', `
Plan background${segmented('bgMode',[['inherit','General settings'],['static','Static color'],['daynight','Follows the Sun']],'Plan background')}
`+ (draft.bgMode==='static' ? colorField('bgColor',null,'Background color','reset-background') : '')+ `
Sunlight through windows
${[['inherit','Use general settings'],['on','On'],['off','Off']].map(([v,t])=>``).join('')}
`+ `
${fieldHeading('North on the plan','north','north-mode')}
${draft.northDeg!==null?`
°
${errorSlot('northDeg')}
`:''}`+`
`+ `
${toggle('glowEnabled','Light-source glow','Show a soft glow around lights that are on.','bulb',false,false,'light')}`+`
` ); } function renderMain() { closeHelp(); $('#settings-content').innerHTML = generalSection()+appearanceSection()+cardsSection()+lightSection(); } function render() { renderMain(); updateChrome(); } function updateChrome() { $('#space-crumb').textContent = draft.title.trim() || 'Untitled space'; $('#save-settings').disabled = !dirty() || allErrors().length > 0; $('#save-status').classList.toggle('dirty',dirty()); $('#save-status').textContent = dirty() ? 'Unsaved changes' : ''; const errors=allErrors(); $('#save-status').hidden = !dirty() && !errors.length; if(errors.length)$('#save-status').innerHTML=``; errorFields.forEach(key => { const el = $(`#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 => {el.textContent=(el.dataset.hex==='fillColor'?customFill().c:draft[el.dataset.hex]).toUpperCase();}); if($('#dial-arrow'))$('#dial-arrow').style.transform=`rotate(${draft.northDeg??165}deg)`; } function updateField(el) { const key=el.dataset.field; if(!key)return; let value=el.type==='checkbox'?(el.dataset.invert?!el.checked:el.checked):el.type==='number'||el.type==='range'?(el.value===''?'':Number(el.value)):el.value; if(['fillColor','fillOpacity'].includes(key)&&!draft.customFillSet){draft.fillColor=DEFAULT_CUSTOM_FILL.c;draft.fillOpacity=DEFAULT_CUSTOM_FILL.a*100;} draft[key]=value; if(['fillColor','fillOpacity'].includes(key))draft.customFillSet=true; if(key==='bgColor')draft.bgColorSet=true; if(key==='cardFontScale')$$('[data-field="cardFontScale"]',content).filter(x=>x!==el).forEach(x=>{x.value=value;}); if(['source','fillMode','bgMode','showNames','zeroWallStyle'].includes(key)) { const scroll=currentScroller().scrollTop; renderMain();currentScroller().scrollTop=scroll; const replacement=$(`[data-field="${key}"]${el.type==='radio'?`:checked`:''}`,content);replacement?.focus({preventScroll:true}); } updateChrome(); } function renderPicker() { $('#space-picker').innerHTML=spaces.map(s=>``).join(''); } function openSettings() { saved=clone(activeSpace().settings);draft=clone(saved);render(); if(!dialog.open)dialog.show(); currentScroller().scrollTop=0; $('#dialog-title').focus({preventScroll:true}); } function closeSettings() { dialog.close();renderPicker();notifyDemoFormClosed('space-settings'); } function showSub(title, content, actions) { lastSubTrigger=document.activeElement; subDialog.innerHTML=`

${title}

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

Your changes to ${escape(saved.title)} haven’t been saved.

`,``); $('[data-sub="close"].primary',subDialog).focus(); return new Promise(resolve=>{closeDecision=resolve;}); } function persist(nextSpaces) { try {localStorage.setItem(STORAGE_KEY,JSON.stringify(nextSpaces));return true;} catch {showToast('Could not save locally. Try a smaller image or enable browser storage.');return false;} } function saveSettings() { if(!dirty()||allErrors().length)return; const normalized={...clone(draft),title:draft.title.trim()}; const next=spaces.map(s=>s.id===activeId?{...s,settings:normalized}:s); if(!persist(next))return; spaces=next;saved=clone(normalized);draft=clone(saved);closeSettings(); showToast('Space settings saved in this browser.'); } function openGallery() { showSub('Uploaded floor plans','

Choose an image for this space.

',''); } function readFile(file) { if(!file)return; if(!/\.(svg|png|jpe?g|webp)$/i.test(file.name)||!['image/svg+xml','image/png','image/jpeg','image/webp'].includes(file.type)){showToast('Choose an SVG, PNG, JPG or WebP image.');return;} if(file.size>2*1024*1024){showToast('Choose an image smaller than 2 MB for this preview.');return;} const reader=new FileReader(); reader.onload=()=>{ const image=new Image(); image.onload=()=>{draft.planData=String(reader.result);draft.planName=file.name;renderMain();updateChrome();}; image.onerror=()=>showToast('This image could not be opened. Choose another file.'); image.src=String(reader.result); }; reader.onerror=()=>showToast('Could not read the image. Please try again.');reader.readAsDataURL(file); } function openCopy() { let n=2;while(spaces.some(s=>s.settings.title===`${saved.title} (${n})`))n++; showSub('Copy space',`

Start a new space with the saved layout and display settings. Rooms and device placements are not copied.

This preview creates a local settings copy. The production action also copies the plan geometry.

${dirty()?info('The copy will use saved settings. Your unsaved changes stay in the original draft.'):''}`,``); $('#copy-name').focus();$('#copy-name').select(); } function openDelete() { if(activeSpace().devices>0&&spaces.length>1){ showSub('This space still has devices',`

${activeSpace().devices} devices are placed in ${escape(saved.title)}. Move or remove them before deleting this space.

`,``);return; } showSub('Delete this space?',`

Delete ${escape(saved.title)} from this design preview? This removes its locally saved settings.

`,``); $('[data-sub="close"].button',subDialog).focus(); } 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==='upload'){$('#plan-upload').click();return;} if(action==='gallery'){openGallery();return;} if(action==='reset-fill'){draft.fillColor=base.fillColor;draft.fillOpacity=base.fillOpacity;draft.customFillSet=false;} if(action==='reset-background'){draft.bgColor=base.bgColor;draft.bgColorSet=false;} if(action==='reset-room'){draft.roomColor=base.roomColor;draft.roomOpacity=base.roomOpacity;} if(action==='reset-font')draft.cardFontScale=100; if(action==='show-cards')draft.showNames=true; if(action==='inherit-north')draft.northDeg=null; const scroll=currentScroller().scrollTop;renderMain();updateChrome();currentScroller().scrollTop=scroll; } document.addEventListener('click',e=>{ const target=e.target.closest('button'); if(target?.dataset.space){activeId=target.dataset.space;renderPicker();} if(target?.dataset.action)handleAction(target.dataset.action); }); $('#settings-content').addEventListener('input',e=>{ if(['checkbox','radio','file'].includes(e.target.type)||e.target.tagName==='SELECT')return; updateField(e.target); }); $('#settings-content').addEventListener('change',e=>{ if(e.target.id==='plan-upload'){readFile(e.target.files[0]);return;} if(e.target.id==='north-mode'){draft.northDeg=e.target.value==='inherit'?null:165;renderMain();updateChrome();return;} if(['checkbox','radio'].includes(e.target.type)||e.target.tagName==='SELECT')updateField(e.target); if(e.target.dataset.field==='cellCm'){const scroll=currentScroller().scrollTop;renderMain();updateChrome();currentScroller().scrollTop=scroll;} }); $('#settings-content').addEventListener('dragover',e=>{const zone=e.target.closest('#upload-panel');if(zone){e.preventDefault();zone.classList.add('dragging');}}); $('#settings-content').addEventListener('dragleave',e=>{e.target.closest('#upload-panel')?.classList.remove('dragging');}); $('#settings-content').addEventListener('drop',e=>{const zone=e.target.closest('#upload-panel');if(zone){e.preventDefault();zone.classList.remove('dragging');readFile(e.dataTransfer.files[0]);}}); $('#close-settings').addEventListener('click',requestClose); $('#cancel-settings').addEventListener('click',requestClose); $('#save-settings').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'&&!subDialog.open){e.preventDefault();e.stopPropagation();requestClose();} }); $('#copy-space').addEventListener('click',openCopy); $('#delete-space').addEventListener('click',openDelete); subDialog.addEventListener('cancel',e=>{e.preventDefault();closeSub();}); subDialog.addEventListener('input',e=>{if(e.target.id==='copy-name')$('[data-sub="confirm-copy"]').disabled=!e.target.value.trim();}); subDialog.addEventListener('click',e=>{ const button=e.target.closest('button[data-sub]');if(!button)return; const action=button.dataset.sub; if(action==='close'){closeSub();return;} if(action==='discard'){ const resolve=closeDecision;closeDecision=undefined; draft=clone(saved);closeSub();closeSettings();resolve?.(true);return; } if(action==='choose-image'){draft.source='file';draft.planName=button.dataset.name;draft.planData='assets/sample-plan.svg';closeSub();renderMain();updateChrome();return;} if(action==='confirm-copy'){ const title=$('#copy-name').value.trim();if(!title)return; const copy={id:crypto.randomUUID(),devices:0,settings:{...clone(saved),title}}; const next=[...spaces,copy];if(!persist(next))return; spaces=next;closeSub();renderPicker();showToast(`“${title}” created. Open it from the space picker.`);return; } if(action==='confirm-delete'){ let next=spaces.filter(s=>s.id!==activeId); if(!next.length)next=[{id:'new-space',devices:0,settings:{...clone(base),title:'New space'}}]; if(!persist(next))return; spaces=next;activeId=next[0].id;closeSub();closeSettings();showToast('Space deleted from this preview.'); } }); window.addEventListener('beforeunload',e=>{if(dialog.open&&dirty()){e.preventDefault();e.returnValue='';}}); hydrateIcons();renderPicker(); registerDemoForm({id:'space-settings',title:'Space settings',description:'Layout, appearance, room cards and lighting for a space.',galleryControls:$('#space-examples'),open:openSettings,requestClose}); openDemoForm('space-settings');