v0.4.1
This commit is contained in:
+123
-38
@@ -13,7 +13,7 @@ const preferredLanguage = getCookie('gree_controller_language') || DEFAULT_LANGU
|
||||
const preferredTheme = ['system', 'light', 'dark'].includes(getCookie('gree_controller_theme')) ? getCookie('gree_controller_theme') : 'system';
|
||||
|
||||
const app = {
|
||||
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {},
|
||||
devices: [], zones: [], schedules: [], automations: [], accessTokens: [], settings: null, system: {}, outdoorTemperature: null,
|
||||
token: localStorage.getItem('gree_controller_token') || '', ws: null, wsTimer: null,
|
||||
currentView: 'dashboard', loading: false, language: preferredLanguage, theme: preferredTheme, connectionStatus: 'connecting',
|
||||
languages: [], translations: {}, locales: {},
|
||||
@@ -39,7 +39,7 @@ function applyTheme() {
|
||||
: (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
const meta = $('#themeColorMeta');
|
||||
if (meta) meta.content = resolved === 'light' ? '#f5f7f6' : '#0a1110';
|
||||
if (meta) meta.content = resolved === 'light' ? '#f8faf9' : '#151515';
|
||||
const select = $('#themeSelect');
|
||||
if (select) select.value = app.theme;
|
||||
drawCurrentChartIfVisible();
|
||||
@@ -166,6 +166,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
app.accessTokens = data.access_tokens || [];
|
||||
app.settings = data.settings || null;
|
||||
app.system = data.system || {};
|
||||
app.outdoorTemperature = Number.isFinite(Number(data.outdoor_temperature)) ? Number(data.outdoor_temperature) : null;
|
||||
renderAll();
|
||||
if (showMessage) toast(tr('common.updated'));
|
||||
if ($('#tokenDialog').open) $('#tokenDialog').close();
|
||||
@@ -179,6 +180,7 @@ async function loadBootstrap(showMessage = false) {
|
||||
|
||||
function renderAll() {
|
||||
renderSummary();
|
||||
renderHouseClimate();
|
||||
renderDevices();
|
||||
renderZones();
|
||||
renderSchedules();
|
||||
@@ -206,6 +208,19 @@ function renderSummary() {
|
||||
].map(([label,value]) => `<div class="metric"><span>${esc(label)}</span><strong>${esc(value)}</strong></div>`).join('');
|
||||
}
|
||||
|
||||
function renderHouseClimate() {
|
||||
const node = $('#houseClimate'); if (!node || !app.settings) return;
|
||||
const mode = app.settings.house_mode || 'cool';
|
||||
const outdoor = app.outdoorTemperature == null ? tr('common.unavailable') : fmtTemp(app.outdoorTemperature);
|
||||
node.innerHTML = `<div class="house-climate-head"><div><span class="eyebrow">${esc(tr('house.seasonMode'))}</span><h3>${esc(tr('house.smartThermostat'))}</h3><p>${esc(tr('house.setpointStrategy'))}</p></div><div class="outside-pill"><small>${esc(tr('house.outdoor'))}</small><strong>${esc(outdoor)}</strong></div></div>
|
||||
<div class="house-mode-row">
|
||||
<button class="${mode==='cool'?'active':''}" data-action="house-mode" data-value="cool"><span class="mode-indicator"></span>${esc(tr('mode.cool'))}</button>
|
||||
<button class="${mode==='heat'?'active':''}" data-action="house-mode" data-value="heat"><span class="mode-indicator"></span>${esc(tr('mode.heat'))}</button>
|
||||
<button class="${mode==='off'?'active':''}" data-action="house-mode" data-value="off"><span class="mode-indicator"></span>${esc(tr('mode.off'))}</button>
|
||||
</div>
|
||||
<div class="preset-row house-preset-row">${['auto','comfort','sleep','away'].map(p=>`<button data-action="house-preset" data-value="${p}">${esc(p==='sleep'?tr('house.sleepAll'):p==='comfort'?tr('house.comfortAll'):p==='away'?tr('house.awayAll'):tr('house.autoAll'))}</button>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function deviceCard(device, detailed = false) {
|
||||
const modes = ['auto','cool','dry','fan','heat'];
|
||||
const fans = [0,1,3,5];
|
||||
@@ -257,24 +272,38 @@ function zoneControlSourceLabel(source) {
|
||||
})[source] || source || tr('zones.sourceUnavailable');
|
||||
}
|
||||
|
||||
function zonePresetLabel(preset) {
|
||||
const key = `preset.${preset || 'comfort'}`;
|
||||
return tr(key) === key ? (preset || 'comfort') : tr(key);
|
||||
}
|
||||
|
||||
function zoneCard(zone, detailed = true) {
|
||||
const device = app.devices.find(d => d.id === zone.device_id);
|
||||
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
|
||||
const sensorDetails = zone.sensor_source === 'device'
|
||||
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
|
||||
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
|
||||
const target = Number(zone.effective_setpoint ?? zone.setpoint);
|
||||
const manual = zone.manual_preset || 'auto';
|
||||
const mode = zone.inherit_house_mode ? 'house' : zone.mode;
|
||||
const override = zone.manual_override_until ? `${tr('zones.overrideUntil')} ${new Date(zone.manual_override_until).toLocaleTimeString(locale(), {hour:'2-digit',minute:'2-digit'})}` : tr('zones.scheduleControl');
|
||||
return `<article class="list-card zone-thermostat ${zone.demand ? 'demanding' : ''}">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zonePresetLabel(zone.active_preset))}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
<div class="thermostat-main"><div><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number.isFinite(target)?target.toFixed(1):'--'}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div><div><small>${esc(tr('zones.deviceTarget'))}</small><strong>${fmtTemp(zone.device_setpoint)}</strong></div></div>
|
||||
<div class="preset-row">
|
||||
${['auto','comfort','sleep','away'].map(preset=>`<button class="${manual===preset?'active':''}" data-action="zone-preset" data-id="${esc(zone.id)}" data-value="${preset}">${esc(preset==='sleep'?tr('zones.sleepNow'):zonePresetLabel(preset))}</button>`).join('')}
|
||||
</div>
|
||||
<div class="mode-row zone-mode-row"><button class="${mode==='house'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="house">${esc(tr('zones.followHouseShort'))}</button><button class="${mode==='heat'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${mode==='cool'?'active':''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
<div class="zone-state-line"><span>${zone.demand ? esc(tr('zones.requesting')) : esc(tr('zones.satisfied'))}</span><span>${esc(override)}</span></div>
|
||||
${detailed ? `<div class="sensor-detail">${esc(sensorDetails)}</div><div class="card-footer"><small>${esc(tr('zones.quickHintSmart'))}</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>` : ''}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
function renderZones() {
|
||||
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => {
|
||||
const device = app.devices.find(d => d.id === zone.device_id);
|
||||
const state = zone.enabled ? tr('common.active') : tr('common.disabled');
|
||||
const sensorDetails = zone.sensor_source === 'device'
|
||||
? `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature ?? device?.current_temperature)}`
|
||||
: `${tr('zones.greeTemp')}: ${fmtTemp(zone.device_temperature)} · ${tr('zones.externalTemp')}: ${fmtTemp(zone.external_temperature)} · ${tr('zones.usedSource')}: ${zoneControlSourceLabel(zone.control_temperature_source)}`;
|
||||
return `<article class="list-card">
|
||||
<div class="list-card-head"><div><h3>${esc(zone.name)}</h3><p>${esc(device?.name || tr('common.noDevice'))} · ${esc(zoneStrategyLabel(zone))}${zone.ha_entity_id ? ` · ${esc(zone.ha_entity_id)}` : ''}</p></div><span class="badge ${zone.enabled ? 'active' : ''}">${esc(state)}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('zones.measurement'))}</small><strong>${fmtTemp(zone.current_temperature)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(zone.setpoint)}</strong></div><div class="card-stat"><small>${esc(tr('zones.demand'))}</small><strong>${esc(tr(zone.demand ? 'common.on' : 'common.off'))}</strong></div></div>
|
||||
<div class="zone-quick-control">
|
||||
<div class="temperature-control compact"><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="-0.5">−</button><div class="target-temp compact">${Number(zone.setpoint).toFixed(1)}<small>°C</small></div><button data-action="zone-temperature" data-id="${esc(zone.id)}" data-delta="0.5">+</button></div>
|
||||
<div class="mode-row"><button class="${zone.mode === 'heat' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="heat">${esc(modeLabel('heat'))}</button><button class="${zone.mode === 'cool' ? 'active' : ''}" data-action="zone-mode" data-id="${esc(zone.id)}" data-value="cool">${esc(modeLabel('cool'))}</button></div>
|
||||
</div>
|
||||
<div class="sensor-detail">${esc(sensorDetails)}</div>
|
||||
<div class="card-footer"><small>${esc(tr('zones.quickHint'))}</small><div class="card-menu"><button data-action="edit-zone" data-id="${esc(zone.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-zone" data-id="${esc(zone.id)}">${esc(tr('actions.delete'))}</button></div></div>
|
||||
</article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||||
const empty = `<div class="empty"><strong>${esc(tr('zones.emptyTitle'))}</strong>${esc(tr('zones.emptyText'))}</div>`;
|
||||
$('#zoneList').innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, true)).join('') : empty;
|
||||
const dashboard = $('#dashboardZones');
|
||||
if (dashboard) dashboard.innerHTML = app.zones.length ? app.zones.map(zone => zoneCard(zone, false)).join('') : empty;
|
||||
}
|
||||
|
||||
function renderSchedules() {
|
||||
@@ -283,7 +312,7 @@ function renderSchedules() {
|
||||
const zone = app.zones.find(z => z.id === item.zone_id);
|
||||
const days = item.weekdays.map(day => dayNames[day-1]).join(', ');
|
||||
return `<article class="list-card"><div class="list-card-head"><div><h3>${esc(item.name)}</h3><p>${esc(zone?.name || tr('common.noZone'))} · ${esc(days)}</p></div><span class="badge ${item.enabled ? 'active' : ''}">${esc(item.enabled ? tr('common.enabled') : tr('common.disabled'))}</span></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.target'))}</small><strong>${fmtTemp(item.setpoint)}</strong></div></div>
|
||||
<div class="card-stats"><div class="card-stat"><small>${esc(tr('common.from'))}</small><strong>${esc(item.start_time)}</strong></div><div class="card-stat"><small>${esc(tr('common.to'))}</small><strong>${esc(item.end_time)}</strong></div><div class="card-stat"><small>${esc(tr('schedules.profile'))}</small><strong>${esc(item.preset === 'custom' ? fmtTemp(item.setpoint) : zonePresetLabel(item.preset))}</strong></div></div>
|
||||
<div class="card-footer"><small>${esc(tr('schedules.crossMidnight'))}</small><div class="card-menu"><button data-action="edit-schedule" data-id="${esc(item.id)}">${esc(tr('actions.edit'))}</button><button class="danger" data-action="delete-schedule" data-id="${esc(item.id)}">${esc(tr('actions.delete'))}</button></div></div></article>`;
|
||||
}).join('') : `<div class="empty"><strong>${esc(tr('schedules.emptyTitle'))}</strong>${esc(tr('schedules.emptyText'))}</div>`;
|
||||
}
|
||||
@@ -309,6 +338,8 @@ function fillSelects() {
|
||||
});
|
||||
const zoneSelect = $('#scheduleForm [name=zone_id]');
|
||||
if (zoneSelect) { const currentZone = zoneSelect.value; zoneSelect.innerHTML = zoneOptions; if ([...zoneSelect.options].some(o => o.value === currentZone)) zoneSelect.value = currentZone; }
|
||||
const presetZone = $('#schedulePresetZone');
|
||||
if (presetZone) { const currentZone = presetZone.value; presetZone.innerHTML = zoneOptions; if ([...presetZone.options].some(o => o.value === currentZone)) presetZone.value = currentZone; }
|
||||
const history = $('#historyDevice');
|
||||
if (history) { const historyCurrent = history.value; history.innerHTML = deviceOptions; if ([...history.options].some(o => o.value === historyCurrent)) history.value = historyCurrent; }
|
||||
}
|
||||
@@ -336,6 +367,9 @@ function renderSettings() {
|
||||
form.ha_token.value = '';
|
||||
form.ha_token.placeholder = app.settings.home_assistant?.token_configured ? tr('settings.haTokenSaved') : tr('settings.haLongLivedToken');
|
||||
form.ha_entity_id.value = app.settings.home_assistant?.default_entity_id || '';
|
||||
form.ha_outdoor_entity_id.value = app.settings.home_assistant?.outdoor_entity_id || '';
|
||||
form.ha_allow_invalid_tls.checked = !!app.settings.home_assistant?.allow_invalid_tls;
|
||||
form.outdoor_assist_enabled.checked = !!app.settings.outdoor_assist_enabled;
|
||||
$('#systemInfo').innerHTML = `<h3>${esc(tr('settings.systemState'))}</h3><div>${esc(tr('settings.version'))}: <strong>${esc(app.system.version || '—')}</strong></div><div>${esc(tr('settings.uptime'))}: <strong>${esc(formatDuration(app.system.uptime_seconds || 0))}</strong></div><div>${esc(tr('settings.apiAuth'))}: <strong>${esc(app.system.auth_required ? tr('settings.enabled') : tr('settings.disabled'))}</strong></div>`;
|
||||
}
|
||||
|
||||
@@ -414,22 +448,36 @@ function updateZoneSensorFields() {
|
||||
form.ha_entity_id.required = external;
|
||||
}
|
||||
|
||||
function updateSchedulePresetField() {
|
||||
const form = $('#scheduleForm'); if (!form) return;
|
||||
$('#scheduleSetpointField').hidden = form.preset.value !== 'custom';
|
||||
form.setpoint.required = form.preset.value === 'custom';
|
||||
}
|
||||
|
||||
function populateZone(id) {
|
||||
const item = app.zones.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#zoneForm'); form.reset(); fillSelects();
|
||||
Object.entries(item).forEach(([key,value]) => { if (form.elements[key] && value != null && typeof value !== 'object') form.elements[key].value = value; });
|
||||
form.mode_policy.value = item.inherit_house_mode ? 'house' : (item.mode || 'cool');
|
||||
if (Number(item.profile_version || 0) === 0) {
|
||||
if ((item.mode || 'cool') === 'heat') form.heat_comfort_setpoint.value = Number(item.setpoint ?? 21);
|
||||
else form.cool_comfort_setpoint.value = Number(item.setpoint ?? 23);
|
||||
}
|
||||
form.external_sensor_weight_percent.value = Math.round(Number(item.external_sensor_weight ?? 0.4) * 100);
|
||||
form.max_sensor_difference.value = Number(item.max_sensor_difference ?? 3);
|
||||
form.min_adjust_seconds.value = Number(item.min_adjust_seconds ?? 120);
|
||||
form.standby_offset_c.value = Number(item.standby_offset_c ?? 2);
|
||||
form.smart_fan.checked = item.smart_fan !== false;
|
||||
form.enabled.checked = item.enabled; updateZoneSensorFields(); openDialog('zoneDialog');
|
||||
}
|
||||
|
||||
function populateSchedule(id) {
|
||||
const item = app.schedules.find(v => v.id === id); if (!item) return;
|
||||
const form = $('#scheduleForm'); form.reset(); fillSelects();
|
||||
['id','name','zone_id','start_time','end_time','setpoint'].forEach(key => form.elements[key].value = item[key]);
|
||||
['id','name','zone_id','start_time','end_time','setpoint','preset'].forEach(key => { if (form.elements[key] && item[key] != null) form.elements[key].value = item[key]; });
|
||||
form.enabled.checked = item.enabled;
|
||||
$$('[name=weekday]', form).forEach(input => input.checked = item.weekdays.includes(Number(input.value)));
|
||||
openDialog('scheduleDialog');
|
||||
updateSchedulePresetField(); openDialog('scheduleDialog');
|
||||
}
|
||||
|
||||
function populateAutomation(id) {
|
||||
@@ -510,14 +558,15 @@ function connectWebSocket() {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.event === 'bootstrap') {
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; renderAll(); return;
|
||||
const data=message.data; app.devices=data.devices||[]; app.zones=data.zones||[]; app.schedules=data.schedules||[]; app.automations=data.automations||[]; app.settings=data.settings||app.settings; app.system=data.system||app.system; app.outdoorTemperature=Number.isFinite(Number(data.outdoor_temperature))?Number(data.outdoor_temperature):app.outdoorTemperature; renderAll(); return;
|
||||
}
|
||||
const data = message.data || {};
|
||||
if (['device.updated','device.created'].includes(message.event)) { updateDevice(data); renderSummary(); renderDevices(); }
|
||||
else if (message.event === 'device.deleted') { app.devices=app.devices.filter(v=>v.id!==data.id); renderAll(); }
|
||||
else if (message.event === 'devices.discovered') { (data.devices||[]).forEach(updateDevice); renderAll(); }
|
||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderZones(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); }
|
||||
else if (message.event === 'zone.updated') { const i=app.zones.findIndex(v=>v.id===data.id); if(i>=0) app.zones[i]=data; else app.zones.push(data); renderSummary(); renderHouseClimate(); renderZones(); }
|
||||
else if (message.event === 'settings.updated') { app.settings=data; renderSettings(); renderHouseClimate(); }
|
||||
else if (message.event === 'outdoor.updated') { app.outdoorTemperature=Number.isFinite(Number(data.temperature))?Number(data.temperature):null; renderHouseClimate(); }
|
||||
else if (message.event === 'log.created' && app.currentView === 'logs') loadLogs();
|
||||
} catch (_) {}
|
||||
};
|
||||
@@ -530,8 +579,18 @@ document.addEventListener('click', async event => {
|
||||
return;
|
||||
}
|
||||
if (button.dataset.go) { $('#moreDialog').close(); showView(button.dataset.go); return; }
|
||||
if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); openDialog(button.dataset.open); return; }
|
||||
if (button.dataset.open) { const form = document.getElementById(button.dataset.open.replace('Dialog','Form')); if (form) form.reset(); if (button.dataset.open === 'zoneDialog') updateZoneSensorFields(); if (button.dataset.open === 'scheduleDialog') updateSchedulePresetField(); openDialog(button.dataset.open); return; }
|
||||
if (button.hasAttribute('data-close')) { button.closest('dialog')?.close(); return; }
|
||||
if (button.dataset.scheduleTemplate) {
|
||||
const zoneId = $('#schedulePresetZone')?.value; if (!zoneId) return toast(tr('schedules.chooseZone'), true);
|
||||
if (!confirm(tr('schedules.replaceConfirm'))) return;
|
||||
try {
|
||||
button.disabled = true;
|
||||
await api(`/api/zones/${encodeURIComponent(zoneId)}/schedule-template`, {method:'POST', body:{template:button.dataset.scheduleTemplate}});
|
||||
await loadBootstrap(); toast(tr('schedules.templateApplied'));
|
||||
} catch (error) { toast(error.message, true); } finally { button.disabled = false; }
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.action; if (!action) return;
|
||||
const device = app.devices.find(v => v.id === button.dataset.device);
|
||||
if (action === 'power' && device) return sendDeviceCommand(device.id, {power:!device.power});
|
||||
@@ -543,8 +602,17 @@ document.addEventListener('click', async event => {
|
||||
if (action === 'bind' && device) { try { button.disabled=true; updateDevice(await api(`/api/devices/${encodeURIComponent(device.id)}/bind`,{method:'POST'})); renderAll(); toast(tr('devices.bound')); } catch(e){toast(e.message,true);} finally{button.disabled=false;} return; }
|
||||
if (action === 'rename-device' && device) return populateDeviceRename(device.id);
|
||||
if (action === 'delete-device') return deleteEntity('devices', button.dataset.device, 'label.device');
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) return sendZoneControl(zone.id,{setpoint:clamp(Number(zone.setpoint)+Number(button.dataset.delta),8,30)}); }
|
||||
if (action === 'house-mode') {
|
||||
try { app.settings = await api('/api/house/control',{method:'POST',body:{mode:button.dataset.value}}); renderHouseClimate(); toast(tr('house.modeUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
if (action === 'house-preset') {
|
||||
try { const result=await api('/api/house/preset',{method:'POST',body:{preset:button.dataset.value}}); app.zones=result.zones||app.zones; renderZones(); toast(tr('house.presetUpdated')); }
|
||||
catch(error){ toast(error.message,true); } return;
|
||||
}
|
||||
if (action === 'zone-temperature') { const zone=app.zones.find(v=>v.id===button.dataset.id); if(zone) { const base=Number(zone.effective_setpoint ?? zone.setpoint); return sendZoneControl(zone.id,{setpoint:clamp(base+Number(button.dataset.delta),8,30)}); } }
|
||||
if (action === 'zone-mode') return sendZoneControl(button.dataset.id,{mode:button.dataset.value});
|
||||
if (action === 'zone-preset') return sendZoneControl(button.dataset.id,{preset:button.dataset.value});
|
||||
if (action === 'edit-zone') return populateZone(button.dataset.id);
|
||||
if (action === 'delete-zone') return deleteEntity('zones', button.dataset.id, 'label.zone');
|
||||
if (action === 'edit-schedule') return populateSchedule(button.dataset.id);
|
||||
@@ -576,6 +644,7 @@ $('#logsRefresh').addEventListener('click', loadLogs);
|
||||
$('#languageSelect').addEventListener('change', event => setLanguage(event.target.value));
|
||||
$('#themeSelect').addEventListener('change', event => setTheme(event.target.value));
|
||||
$('#zoneForm [name=sensor_source]').addEventListener('change', updateZoneSensorFields);
|
||||
$('#scheduleForm [name=preset]').addEventListener('change', updateSchedulePresetField);
|
||||
|
||||
$('#tokenForm').addEventListener('submit', event => {
|
||||
event.preventDefault(); app.token = new FormData(event.currentTarget).get('token').trim();
|
||||
@@ -621,7 +690,15 @@ $('#deviceForm').addEventListener('submit', async event => {
|
||||
|
||||
$('#zoneForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id; const body={name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,mode:raw.mode,setpoint:Number(raw.setpoint),hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:Number(raw.max_sensor_difference)};
|
||||
const id=raw.id; const body={
|
||||
name:raw.name,device_id:raw.device_id,enabled:form.enabled.checked,
|
||||
mode:raw.mode_policy==='house'?'cool':raw.mode_policy,inherit_house_mode:raw.mode_policy==='house',setpoint:Number(raw.setpoint),
|
||||
cool_comfort_setpoint:Number(raw.cool_comfort_setpoint),cool_sleep_setpoint:Number(raw.cool_sleep_setpoint),cool_away_setpoint:Number(raw.cool_away_setpoint),
|
||||
heat_comfort_setpoint:Number(raw.heat_comfort_setpoint),heat_sleep_setpoint:Number(raw.heat_sleep_setpoint),heat_away_setpoint:Number(raw.heat_away_setpoint),
|
||||
hysteresis:Number(raw.hysteresis),min_on_seconds:Number(raw.min_on_seconds),min_off_seconds:Number(raw.min_off_seconds),
|
||||
min_adjust_seconds:Number(raw.min_adjust_seconds),standby_offset_c:Number(raw.standby_offset_c),smart_fan:form.smart_fan.checked,
|
||||
sensor_source:raw.sensor_source,ha_entity_id:raw.ha_entity_id||null,external_sensor_weight:Number(raw.external_sensor_weight_percent)/100,max_sensor_difference:Number(raw.max_sensor_difference)
|
||||
};
|
||||
try { await api(id?`/api/zones/${encodeURIComponent(id)}`:'/api/zones',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
@@ -629,7 +706,7 @@ $('#zoneForm').addEventListener('submit', async event => {
|
||||
$('#scheduleForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const id=raw.id, weekdays=$$('[name=weekday]:checked',form).map(v=>Number(v.value));
|
||||
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,setpoint:Number(raw.setpoint)};
|
||||
const body={name:raw.name,zone_id:raw.zone_id,enabled:form.enabled.checked,weekdays,start_time:raw.start_time,end_time:raw.end_time,preset:raw.preset,setpoint:Number(raw.setpoint||23)};
|
||||
try { await api(id?`/api/schedules/${encodeURIComponent(id)}`:'/api/schedules',{method:id?'PUT':'POST',body}); form.closest('dialog').close(); form.reset(); await loadBootstrap(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
@@ -642,11 +719,17 @@ $('#automationForm').addEventListener('submit', async event => {
|
||||
catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
function settingsBodyFromForm(form) {
|
||||
const raw=Object.fromEntries(new FormData(form));
|
||||
return {controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,house_mode:app.settings?.house_mode||'cool',control_strategy:'setpoint',outdoor_assist_enabled:form.outdoor_assist_enabled.checked,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id,outdoor_entity_id:raw.ha_outdoor_entity_id,allow_invalid_tls:form.ha_allow_invalid_tls.checked}};
|
||||
}
|
||||
|
||||
async function saveSettingsForm(form, notify=true) {
|
||||
app.settings=await api('/api/settings',{method:'PUT',body:settingsBodyFromForm(form)}); renderSettings(); renderHouseClimate(); if(notify) toast(tr('common.saved')); return app.settings;
|
||||
}
|
||||
|
||||
$('#settingsForm').addEventListener('submit', async event => {
|
||||
event.preventDefault(); const form=event.currentTarget, raw=Object.fromEntries(new FormData(form));
|
||||
const body={controller_id:raw.controller_id,simulator_enabled:form.simulator_enabled.checked,poll_interval_seconds:Number(raw.poll_interval_seconds),zone_interval_seconds:Number(raw.zone_interval_seconds),discovery_timeout_ms:Number(raw.discovery_timeout_ms),discovery_broadcast:raw.discovery_broadcast,home_assistant:{url:raw.ha_url,token:raw.ha_token,default_entity_id:raw.ha_entity_id}};
|
||||
try { app.settings=await api('/api/settings',{method:'PUT',body}); renderSettings(); toast(tr('common.saved')); }
|
||||
catch(error){toast(error.message,true);}
|
||||
event.preventDefault(); try { await saveSettingsForm(event.currentTarget, true); } catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
$('#createAccessToken').addEventListener('click', async event => {
|
||||
@@ -677,11 +760,12 @@ $('#copyAccessToken').addEventListener('click', async () => {
|
||||
|
||||
$('#haTest').addEventListener('click', async () => {
|
||||
const form=$('#settingsForm');
|
||||
if (form.ha_token.value || form.ha_url.value !== (app.settings?.home_assistant?.url || '')) {
|
||||
form.requestSubmit(); await new Promise(resolve=>setTimeout(resolve,250));
|
||||
}
|
||||
try { const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:form.ha_entity_id.value||null}}); toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)})); }
|
||||
catch(error){toast(error.message,true);}
|
||||
try {
|
||||
await saveSettingsForm(form, false);
|
||||
const entity = form.ha_entity_id.value || form.ha_outdoor_entity_id.value || null;
|
||||
const result=await api('/api/integrations/home-assistant/test',{method:'POST',body:{entity_id:entity}});
|
||||
toast(tr('toast.haTemperature',{temperature:result.temperature_c.toFixed(1)}));
|
||||
} catch(error){toast(error.message,true);}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => { if (app.currentView === 'history') loadHistory(); });
|
||||
@@ -693,6 +777,7 @@ async function startApplication() {
|
||||
await loadLanguages();
|
||||
applyTranslations();
|
||||
updateZoneSensorFields();
|
||||
updateSchedulePresetField();
|
||||
await loadBootstrap();
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect width="128" height="128" rx="30" fill="#0d1715"/>
|
||||
<path d="M64 25v43" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M42 42a39 39 0 1 0 44 0" fill="none" stroke="#5ee2a0" stroke-width="14" stroke-linecap="round"/>
|
||||
<rect width="128" height="128" rx="30" fill="#151515"/>
|
||||
<path d="M64 25v43" stroke="#3ecf8e" stroke-width="14" stroke-linecap="round"/>
|
||||
<path d="M42 42a39 39 0 1 0 44 0" fill="none" stroke="#3ecf8e" stroke-width="14" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 318 B After Width: | Height: | Size: 318 B |
+43
-13
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0a1110" id="themeColorMeta">
|
||||
<meta name="theme-color" content="#151515" id="themeColorMeta">
|
||||
<meta name="description" content="Local GREE air conditioner controller" data-i18n-content="meta.description">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
@@ -34,7 +34,7 @@
|
||||
<option value="light" data-i18n="theme.light">Light</option>
|
||||
<option value="dark" data-i18n="theme.dark">Dark</option>
|
||||
</select>
|
||||
<button class="icon-button" id="refreshButton" data-i18n-title="actions.refresh" data-i18n-aria="actions.refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
<button class="icon-button" id="refreshButton" data-i18n-title="actions.refresh" data-i18n-aria="actions.refresh" title="Refresh" aria-label="Refresh"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.34 5.66"/><path d="M20 4v7h-7"/></svg></button>
|
||||
<button class="primary compact" id="discoverButton" data-i18n="actions.discover">Discover</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -46,7 +46,10 @@
|
||||
<div class="hero-temp" id="heroTemperature">--<small>°C</small></div>
|
||||
</div>
|
||||
<div class="metrics" id="metrics"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Quick control</h2></div></div>
|
||||
<div class="panel house-climate" id="houseClimate"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.zones">Zones</span><h2 data-i18n="dashboard.quickThermostats">Quick thermostats</h2></div></div>
|
||||
<div class="list-grid dashboard-zones" id="dashboardZones"></div>
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="nav.devices">Devices</span><h2 data-i18n="dashboard.quickControl">Direct device control</h2></div></div>
|
||||
<div class="device-grid" id="dashboardDevices"></div>
|
||||
</section>
|
||||
|
||||
@@ -60,17 +63,29 @@
|
||||
|
||||
<section class="view" data-view="zones">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="zones.automation">Automation</span><h1 data-i18n="nav.zones">Zones</h1></div><button class="secondary" data-open="zoneDialog" data-i18n="zones.new">New zone</button></div>
|
||||
<p class="lead" data-i18n="zones.description">A zone controls a device using temperature, hysteresis and minimum cycle time.</p>
|
||||
<p class="lead" data-i18n="zones.description">Each zone is a fast thermostat. It normally follows the house heating/cooling mode and changes the AC setpoint instead of cycling power.</p>
|
||||
<div class="list-grid" id="zoneList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="schedules">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="schedules.calendar">Calendar</span><h1 data-i18n="nav.schedules">Schedules</h1></div><button class="secondary" data-open="scheduleDialog" data-i18n="actions.add">Add</button></div>
|
||||
<div class="panel schedule-template-panel">
|
||||
<div><h3 data-i18n="schedules.quickSetup">Quick schedule setup</h3><p class="field-note" data-i18n="schedules.quickSetupHint">Choose a zone and a ready-made daily rhythm. You can edit every generated entry afterwards.</p></div>
|
||||
<select id="schedulePresetZone"></select>
|
||||
<div class="template-grid">
|
||||
<button class="secondary" data-schedule-template="family" data-i18n="schedules.templateFamily">Family</button>
|
||||
<button class="secondary" data-schedule-template="child" data-i18n="schedules.templateChild">Child room</button>
|
||||
<button class="secondary" data-schedule-template="bedroom" data-i18n="schedules.templateBedroom">Bedroom</button>
|
||||
<button class="secondary" data-schedule-template="workday" data-i18n="schedules.templateWorkday">Workday</button>
|
||||
<button class="secondary" data-schedule-template="always" data-i18n="schedules.templateAlways">Always comfort</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-grid" id="scheduleList"></div>
|
||||
</section>
|
||||
|
||||
<section class="view" data-view="automations">
|
||||
<div class="section-heading"><div><span class="eyebrow" data-i18n="automations.rules">Rules</span><h1 data-i18n="nav.automations">Automations</h1></div><button class="secondary" data-open="automationDialog" data-i18n="actions.add">Add</button></div>
|
||||
<div class="panel automation-hint"><strong data-i18n="automations.smartHintTitle">Use thermostat presets for daily comfort</strong><p data-i18n="automations.smartHint">Schedules and zone presets handle normal heating/cooling automatically. Keep automations for special exceptions so they do not fight the thermostat.</p></div>
|
||||
<div class="list-grid" id="automationList"></div>
|
||||
</section>
|
||||
|
||||
@@ -107,7 +122,11 @@
|
||||
<p class="field-note wide" data-i18n="settings.haSensorInputHint">Optional. Used only when a room zone reads an external Home Assistant temperature sensor.</p>
|
||||
<label class="wide"><span>URL</span><input type="url" name="ha_url" placeholder="http://homeassistant.local:8123"></label>
|
||||
<label class="wide"><span data-i18n="settings.haLongLivedToken">Long-Lived Access Token</span><input type="password" name="ha_token" autocomplete="new-password" data-i18n-placeholder="settings.haTokenKeep" placeholder="Leave empty to keep the saved token"></label>
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.defaultEntity">Default room entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<label class="wide"><span data-i18n="settings.outdoorEntity">Outdoor temperature entity_id</span><input name="ha_outdoor_entity_id" placeholder="sensor.outdoor_temperature"></label>
|
||||
<label class="check wide"><input type="checkbox" name="outdoor_assist_enabled"> <span data-i18n="settings.outdoorAssist">Use outdoor temperature as smart-control assist</span></label>
|
||||
<label class="check wide"><input type="checkbox" name="ha_allow_invalid_tls"> <span data-i18n="settings.allowInvalidTls">Allow invalid/self-signed HTTPS certificate</span></label>
|
||||
<p class="field-note wide warning-note" data-i18n="settings.allowInvalidTlsHint">Use only for a trusted local Home Assistant server, for example https://10.87.65.2.</p>
|
||||
<div class="form-actions wide"><button type="button" class="secondary" id="haTest" data-i18n="settings.testHa">Test HA</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
<hr>
|
||||
<h3 data-i18n="settings.haIntegrationAccess">Home Assistant integration access</h3>
|
||||
@@ -127,11 +146,15 @@
|
||||
</main>
|
||||
|
||||
<nav class="bottom-nav" data-i18n-aria="nav.navigation" aria-label="Navigation">
|
||||
<button class="active" data-nav="dashboard"><span>⌂</span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
||||
<button data-nav="devices"><span>▣</span><b data-i18n="nav.devices">Devices</b></button>
|
||||
<button data-nav="zones"><span>◎</span><b data-i18n="nav.zones">Zones</b></button>
|
||||
<button data-nav="history"><span>⌁</span><b data-i18n="nav.history">History</b></button>
|
||||
<button data-nav="more"><span>•••</span><b data-i18n="nav.more">More</b></button>
|
||||
<button class="active" data-nav="dashboard"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M3 10.5 12 3l9 7.5"/><path d="M5.5 9.5V21h13V9.5"/><path d="M9 21v-7h6v7"/></svg></span><b data-i18n="nav.dashboard">Dashboard</b></button>
|
||||
<button data-nav="devices"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h4"/></svg></span><b data-i18n="nav.devices">Devices</b></button>
|
||||
<button data-nav="zones"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/></svg></span><b data-i18n="nav.zones">Zones</b></button>
|
||||
<button data-nav="history"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M4 19V5"/><path d="M4 19h16"/><path d="m7 15 4-4 3 2 5-6"/></svg></span><b data-i18n="nav.history">History</b></button>
|
||||
<button class="desktop-nav-only" data-nav="schedules"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/></svg></span><b data-i18n="nav.schedules">Schedules</b></button>
|
||||
<button class="desktop-nav-only" data-nav="automations"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="m13 2-8 12h7l-1 8 8-12h-7z"/></svg></span><b data-i18n="nav.automations">Automations</b></button>
|
||||
<button class="desktop-nav-only" data-nav="settings"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.86 2.86-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.08V21h-4v-.08A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.86-2.86.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.08-.4H3v-4h.08A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06L7.06 3.8l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.08V3h4v.08A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.86 2.86-.06.06A1.7 1.7 0 0 0 19.4 9c.36.3.7.65 1 1 .2.3.4.65.4 1v2c0 .35-.2.7-.4 1-.3.35-.64.7-1 1z"/></svg></span><b data-i18n="nav.settings">Settings</b></button>
|
||||
<button class="desktop-nav-only" data-nav="logs"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg></span><b data-i18n="nav.logs">Events</b></button>
|
||||
<button data-nav="more"><span><svg viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg></span><b data-i18n="nav.more">More</b></button>
|
||||
</nav>
|
||||
|
||||
<dialog id="moreDialog" class="sheet">
|
||||
@@ -194,14 +217,20 @@
|
||||
<div class="dialog-head"><h2 data-i18n="common.zone">Zone</h2><button type="button" data-close>×</button></div>
|
||||
<label><span data-i18n="common.name">Name</span><input name="name" required data-i18n-placeholder="placeholder.livingRoom" placeholder="Living room"></label>
|
||||
<label><span data-i18n="common.device">Device</span><select name="device_id" required></select></label>
|
||||
<div class="two"><label><span data-i18n="common.mode">Mode</span><select name="mode"><option value="cool" data-i18n="mode.cool">Cooling</option><option value="heat" data-i18n="mode.heat">Heating</option></select></label><label><span data-i18n="common.targetC">Target °C</span><input type="number" step="0.1" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||
<div class="two"><label><span data-i18n="zones.modePolicy">Mode policy</span><select name="mode_policy"><option value="house" data-i18n="zones.followHouse">Follow house mode</option><option value="cool" data-i18n="mode.cool">Cooling only</option><option value="heat" data-i18n="mode.heat">Heating only</option></select></label><label><span data-i18n="common.targetC">Manual target °C</span><input type="number" step="0.5" min="8" max="30" name="setpoint" value="23"></label></div>
|
||||
<details class="profile-editor"><summary data-i18n="zones.profileTemperatures">Comfort / sleep / away temperatures</summary>
|
||||
<div class="profile-grid"><strong data-i18n="mode.cool">Cooling</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="number" step="0.5" name="cool_comfort_setpoint" value="23"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="number" step="0.5" name="cool_sleep_setpoint" value="24.5"></label><label><span data-i18n="preset.away">Away</span><input type="number" step="0.5" name="cool_away_setpoint" value="27"></label></div>
|
||||
<div class="profile-grid"><strong data-i18n="mode.heat">Heating</strong><label><span data-i18n="preset.comfort">Comfort</span><input type="number" step="0.5" name="heat_comfort_setpoint" value="21"></label><label><span data-i18n="preset.sleep">Sleep</span><input type="number" step="0.5" name="heat_sleep_setpoint" value="19"></label><label><span data-i18n="preset.away">Away</span><input type="number" step="0.5" name="heat_away_setpoint" value="17"></label></div>
|
||||
</details>
|
||||
<div class="two"><label><span data-i18n="zones.hysteresis">Hysteresis °C</span><input type="number" step="0.1" min="0.1" max="5" name="hysteresis" value="0.6"></label><label><span data-i18n="zones.source">Temperature strategy</span><select name="sensor_source"><option value="combined" selected data-i18n="zones.combinedSensor">GREE + room sensor</option><option value="device" data-i18n="zones.greeSensor">GREE only</option><option value="home_assistant" data-i18n="zones.externalSensor">Room sensor only</option></select></label></div>
|
||||
<div id="externalSensorFields">
|
||||
<label><span data-i18n="zones.roomSensorEntity">Room sensor entity_id</span><input name="ha_entity_id" placeholder="sensor.living_room_temperature"></label>
|
||||
<div class="two"><label><span data-i18n="zones.roomSensorWeight">Room sensor weight %</span><input type="number" step="5" min="0" max="100" name="external_sensor_weight_percent" value="40"></label><label><span data-i18n="zones.maxDifference">Max. sensor difference °C</span><input type="number" step="0.1" min="0.1" max="20" name="max_sensor_difference" value="3.0"></label></div>
|
||||
<p class="field-note" data-i18n="zones.sensorHelp">The room sensor is assigned only to this zone. If it becomes unavailable, the controller falls back to the GREE sensor.</p>
|
||||
</div>
|
||||
<div class="two"><label><span data-i18n="zones.minOn">Min. ON (s)</span><input type="number" min="0" name="min_on_seconds" value="180"></label><label><span data-i18n="zones.minOff">Min. OFF (s)</span><input type="number" min="0" name="min_off_seconds" value="180"></label></div>
|
||||
<div class="two"><label><span data-i18n="zones.minAdjust">Minimum adjustment interval (s)</span><input type="number" min="15" name="min_adjust_seconds" value="120"></label><label><span data-i18n="zones.standbyOffset">Standby setpoint offset °C</span><input type="number" step="0.5" min="0.5" max="8" name="standby_offset_c" value="2"></label></div>
|
||||
<label class="check"><input type="checkbox" name="smart_fan" checked> <span data-i18n="zones.smartFan">Smart fan assist</span></label>
|
||||
<input type="hidden" name="min_on_seconds" value="180"><input type="hidden" name="min_off_seconds" value="180">
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
@@ -215,7 +244,8 @@
|
||||
<label><span data-i18n="common.zone">Zone</span><select name="zone_id" required></select></label>
|
||||
<fieldset><legend data-i18n="schedules.weekdays">Weekdays</legend><div class="days"><label><input type="checkbox" name="weekday" value="1" checked><span data-day="1">Mon</span></label><label><input type="checkbox" name="weekday" value="2" checked><span data-day="2">Tue</span></label><label><input type="checkbox" name="weekday" value="3" checked><span data-day="3">Wed</span></label><label><input type="checkbox" name="weekday" value="4" checked><span data-day="4">Thu</span></label><label><input type="checkbox" name="weekday" value="5" checked><span data-day="5">Fri</span></label><label><input type="checkbox" name="weekday" value="6"><span data-day="6">Sat</span></label><label><input type="checkbox" name="weekday" value="7"><span data-day="7">Sun</span></label></div></fieldset>
|
||||
<div class="two"><label><span data-i18n="common.from">From</span><input type="time" name="start_time" value="22:00" required></label><label><span data-i18n="common.to">To</span><input type="time" name="end_time" value="06:00" required></label></div>
|
||||
<label><span data-i18n="common.temperatureC">Temperature °C</span><input type="number" name="setpoint" step="0.1" min="8" max="30" value="23" required></label>
|
||||
<label><span data-i18n="schedules.profile">Profile</span><select name="preset"><option value="comfort" data-i18n="preset.comfort">Comfort</option><option value="sleep" data-i18n="preset.sleep">Sleep</option><option value="away" data-i18n="preset.away">Away</option><option value="custom" data-i18n="preset.custom">Custom temperature</option></select></label>
|
||||
<label id="scheduleSetpointField"><span data-i18n="common.temperatureC">Custom temperature °C</span><input type="number" name="setpoint" step="0.5" min="8" max="30" value="23"></label>
|
||||
<label class="check"><input type="checkbox" name="enabled" checked> <span data-i18n="common.enabled">Enabled</span></label>
|
||||
<div class="form-actions"><button type="button" class="secondary" data-close data-i18n="actions.cancel">Cancel</button><button class="primary" type="submit" data-i18n="actions.save">Save</button></div>
|
||||
</form>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"short_name": "GREE",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f5f7f6",
|
||||
"theme_color": "#0d1715",
|
||||
"background_color": "#151515",
|
||||
"theme_color": "#151515",
|
||||
"icons": [{"src":"/favicon.svg","sizes":"any","type":"image/svg+xml","purpose":"any maskable"}]
|
||||
}
|
||||
|
||||
+528
-193
@@ -1,247 +1,582 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0a1110;
|
||||
--surface: #111d1a;
|
||||
--surface-2: #172622;
|
||||
--surface-muted: #14201d;
|
||||
--input-bg: #0c1614;
|
||||
--nav-bg: #0d1715;
|
||||
--dialog-bg: #101c19;
|
||||
--line: rgba(255,255,255,.10);
|
||||
--text: #f3faf7;
|
||||
--muted: #91aaa0;
|
||||
--accent: #5ee2a0;
|
||||
--accent-strong: #28c779;
|
||||
--accent-text: #092218;
|
||||
--danger: #ff8b8b;
|
||||
--warning: #f6c866;
|
||||
--backdrop: rgba(1,8,6,.72);
|
||||
--grid: rgba(255,255,255,.08);
|
||||
--radius: 22px;
|
||||
--bg: #151515;
|
||||
--canvas: #181818;
|
||||
--surface: #1c1c1c;
|
||||
--surface-2: #222222;
|
||||
--surface-3: #282828;
|
||||
--surface-muted: #202020;
|
||||
--input-bg: #181818;
|
||||
--nav-bg: #171717;
|
||||
--dialog-bg: #1c1c1c;
|
||||
--line: #2c2c2c;
|
||||
--border: #2c2c2c;
|
||||
--line-strong: #3a3a3a;
|
||||
--text: #ededed;
|
||||
--text-soft: #d4d4d4;
|
||||
--muted: #a1a1aa;
|
||||
--muted-2: #737373;
|
||||
--accent: #3ecf8e;
|
||||
--accent-strong: #46d99a;
|
||||
--accent-soft: rgba(62, 207, 142, .10);
|
||||
--accent-border: rgba(62, 207, 142, .36);
|
||||
--accent-text: #06281a;
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, .10);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, .10);
|
||||
--backdrop: rgba(0, 0, 0, .68);
|
||||
--grid: rgba(255, 255, 255, .07);
|
||||
--scroll-thumb: #444444;
|
||||
--scroll-thumb-hover: #575757;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--control-h: 38px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7f6;
|
||||
--bg: #f8faf9;
|
||||
--canvas: #f8faf9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #edf2ef;
|
||||
--surface-muted: #f0f4f2;
|
||||
--surface-2: #f5f6f6;
|
||||
--surface-3: #eef0ef;
|
||||
--surface-muted: #f6f7f7;
|
||||
--input-bg: #ffffff;
|
||||
--nav-bg: #ffffff;
|
||||
--dialog-bg: #ffffff;
|
||||
--line: rgba(19,42,33,.14);
|
||||
--text: #14221d;
|
||||
--muted: #62756d;
|
||||
--accent: #20b86b;
|
||||
--accent-strong: #149557;
|
||||
--line: #e5e7e6;
|
||||
--border: #e5e7e6;
|
||||
--line-strong: #d4d7d5;
|
||||
--text: #1c1c1c;
|
||||
--text-soft: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-2: #a1a1aa;
|
||||
--accent: #24b47e;
|
||||
--accent-strong: #1ea672;
|
||||
--accent-soft: rgba(36, 180, 126, .09);
|
||||
--accent-border: rgba(36, 180, 126, .32);
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c94646;
|
||||
--warning: #b77900;
|
||||
--backdrop: rgba(18,33,27,.28);
|
||||
--grid: rgba(19,42,33,.10);
|
||||
--danger: #dc5f5f;
|
||||
--danger-soft: rgba(220, 95, 95, .08);
|
||||
--warning: #b7791f;
|
||||
--warning-soft: rgba(183, 121, 31, .08);
|
||||
--backdrop: rgba(23, 23, 23, .32);
|
||||
--grid: rgba(24, 24, 27, .08);
|
||||
--scroll-thumb: #c8ccca;
|
||||
--scroll-thumb-hover: #aeb3b0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { background: var(--bg); }
|
||||
body { margin: 0; min-height: 100vh; color: var(--text); }
|
||||
button, input, select { font: inherit; }
|
||||
html { background: var(--bg); }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: wait; }
|
||||
.topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 72px; padding: 12px max(18px, env(safe-area-inset-left)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 94%, transparent); backdrop-filter: blur(14px); }
|
||||
button:disabled { opacity: .48; cursor: wait; }
|
||||
a { color: var(--accent); }
|
||||
::selection { color: var(--text); background: var(--accent-soft); }
|
||||
|
||||
/* Accessible focus treatment in the same restrained style as the rest of the UI. */
|
||||
:where(button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 54px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.brand { display: flex; align-items: center; min-width: 0; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { font-size: 15px; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.brand small { margin-top: 2px; color: var(--muted); font-size: 11px; }
|
||||
.brand.large { margin-bottom: 1rem; }
|
||||
.brand.large strong { font-size: 21px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
|
||||
.toolbar-select { width: auto; min-width: 58px; min-height: 40px; padding: 7px 28px 7px 10px; border-radius: 11px; }
|
||||
.theme-select { min-width: 94px; }
|
||||
button { border: 0; border-radius: 13px; padding: 11px 15px; color: var(--text); background: var(--surface-2); transition: transform .15s ease, background .15s ease, opacity .15s ease; }
|
||||
button:active { transform: scale(.97); }
|
||||
.primary { background: var(--accent); color: var(--accent-text); font-weight: 800; }
|
||||
.primary:hover { background: var(--accent-strong); }
|
||||
.secondary { border: 1px solid var(--line); background: var(--surface-muted); font-weight: 650; }
|
||||
.secondary:hover { background: var(--surface-2); }
|
||||
.brand strong { color: var(--text); font-size: 13px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.brand small { margin-top: 1px; color: var(--muted); font-size: 10px; }
|
||||
.brand.large { margin-bottom: 16px; }
|
||||
.brand.large strong { font-size: 20px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
|
||||
.toolbar-select {
|
||||
width: auto;
|
||||
min-width: 58px;
|
||||
min-height: 34px;
|
||||
padding: 6px 28px 6px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.theme-select { min-width: 92px; }
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 520;
|
||||
transition: border-color .12s ease, background-color .12s ease, color .12s ease, opacity .12s ease;
|
||||
}
|
||||
button:hover { border-color: var(--line-strong); background: var(--surface-2); color: var(--text); }
|
||||
button:active { background: var(--surface-3); }
|
||||
.primary {
|
||||
border-color: color-mix(in srgb, var(--accent) 74%, var(--line));
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.primary:hover { border-color: var(--accent-strong); color: var(--accent-text); background: var(--accent-strong); }
|
||||
.secondary { border-color: var(--line); color: var(--text-soft); background: var(--surface); font-weight: 540; }
|
||||
.secondary:hover { border-color: var(--line-strong); background: var(--surface-2); }
|
||||
.danger { color: var(--danger); }
|
||||
.compact { padding: 10px 14px; }
|
||||
.icon-button { width: 40px; height: 40px; padding: 0; font-size: 22px; background: transparent; }
|
||||
main { width: min(1180px, 100%); margin: 0 auto; padding: 24px 18px 108px; }
|
||||
.view { display: none; animation: reveal .2s ease; }
|
||||
.view.active { display: block; }
|
||||
@keyframes reveal { from { opacity: 0; transform: translateY(5px); } }
|
||||
.danger:hover { border-color: color-mix(in srgb, var(--danger) 38%, var(--line)); background: var(--danger-soft); color: var(--danger); }
|
||||
.compact { min-height: 34px; padding: 7px 11px; }
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
.icon-button:hover { color: var(--text); background: var(--surface-2); }
|
||||
.icon-button svg { width: 16px; height: 16px; }
|
||||
|
||||
main {
|
||||
width: min(1280px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 102px;
|
||||
}
|
||||
.view { display: none; }
|
||||
.view.active { display: block; animation: reveal .12s ease-out; }
|
||||
@keyframes reveal { from { opacity: .65; transform: translateY(2px); } }
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 8px; font-size: clamp(28px, 6vw, 44px); letter-spacing: -.045em; }
|
||||
h2 { margin-bottom: 0; font-size: clamp(23px, 4vw, 31px); letter-spacing: -.035em; }
|
||||
h3 { margin-bottom: 10px; }
|
||||
.eyebrow { color: var(--accent); font-size: 11px; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.lead { max-width: 690px; color: var(--muted); line-height: 1.55; }
|
||||
.hero { position: relative; overflow: hidden; display: flex; justify-content: space-between; align-items: flex-end; gap: 18px; min-height: 210px; padding: clamp(24px, 6vw, 44px); border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line)); border-radius: 30px; background: var(--surface); }
|
||||
.hero::after { content: ""; position: absolute; right: -80px; bottom: -120px; width: 220px; height: 220px; border: 34px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
|
||||
.hero h1 { margin: 5px 0 8px; }
|
||||
.hero p { margin: 0; color: var(--muted); }
|
||||
.hero-temp { z-index: 1; white-space: nowrap; font-size: clamp(48px, 12vw, 88px); font-weight: 250; letter-spacing: -.075em; }
|
||||
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 600; letter-spacing: 0; }
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin: 14px 0 34px; }
|
||||
.metric { padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface); }
|
||||
.metric span { display: block; margin-bottom: 8px; color: var(--muted); font-size: 12px; }
|
||||
.metric strong { font-size: 24px; letter-spacing: -.03em; }
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 16px; margin: 8px 0 18px; }
|
||||
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr)); gap: 14px; }
|
||||
h1 { margin-bottom: 7px; font-size: clamp(25px, 5vw, 34px); line-height: 1.08; font-weight: 620; letter-spacing: -.035em; }
|
||||
h2 { margin-bottom: 0; font-size: clamp(20px, 4vw, 25px); line-height: 1.15; font-weight: 610; letter-spacing: -.025em; }
|
||||
h3 { margin-bottom: 9px; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lead { max-width: 760px; color: var(--muted); font-size: 13px; line-height: 1.6; }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 154px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hero::after { display: none; }
|
||||
.hero h1 { margin: 4px 0 7px; }
|
||||
.hero p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-temp { z-index: 1; white-space: nowrap; color: var(--text); font-size: clamp(48px, 9vw, 68px); font-weight: 310; line-height: 1; letter-spacing: -.065em; }
|
||||
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 550; letter-spacing: 0; }
|
||||
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 10px 0 28px; }
|
||||
.metric { padding: 14px 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.metric span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 11px; }
|
||||
.metric strong { color: var(--text); font-size: 21px; font-weight: 590; letter-spacing: -.025em; }
|
||||
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin: 8px 0 14px; }
|
||||
.section-heading > div { min-width: 0; }
|
||||
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); gap: 10px; }
|
||||
.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.device-card { overflow: hidden; padding: 20px; }
|
||||
.device-card.off { opacity: .86; }
|
||||
.device-card { overflow: hidden; padding: 16px; }
|
||||
.device-card.off { opacity: .78; }
|
||||
.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title { min-width: 0; }
|
||||
.device-title h3 { overflow: hidden; margin: 0 0 4px; text-overflow: ellipsis; white-space: nowrap; font-size: 19px; }
|
||||
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.device-title h3 { overflow: hidden; margin: 0 0 3px; color: var(--text); text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
|
||||
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
|
||||
.status::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--danger); }
|
||||
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
|
||||
.status.online::before { background: var(--accent); }
|
||||
.power-button { display: grid; place-items: center; flex: 0 0 auto; width: 46px; height: 46px; padding: 0; border-radius: 50%; color: var(--muted); font-size: 21px; }
|
||||
.power-button.on { background: var(--accent); color: var(--accent-text); }
|
||||
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 22px; padding: 24px 0 17px; }
|
||||
.temperature-control button { width: 42px; height: 42px; padding: 0; border: 1px solid var(--line); border-radius: 50%; background: transparent; font-size: 22px; }
|
||||
.target-temp { min-width: 106px; text-align: center; font-size: 48px; font-weight: 300; letter-spacing: -.06em; }
|
||||
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 15px; font-weight: 650; }
|
||||
.current-line { display: flex; justify-content: center; gap: 6px; margin: -10px 0 18px; color: var(--muted); font-size: 12px; }
|
||||
.current-line strong { color: var(--text); }
|
||||
.mode-row, .fan-row { display: flex; gap: 7px; overflow-x: auto; padding: 2px 0 10px; scrollbar-width: none; }
|
||||
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar { display: none; }
|
||||
.mode-row button, .fan-row button { flex: 1 0 auto; min-width: 58px; padding: 9px 10px; color: var(--muted); background: var(--surface-muted); font-size: 12px; }
|
||||
.mode-row button.active, .fan-row button.active { color: var(--accent-text); background: var(--accent); font-weight: 800; }
|
||||
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 4px; }
|
||||
.device-toggles button { padding: 9px 5px; color: var(--muted); font-size: 11px; }
|
||||
.device-toggles button.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.card-footer small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-menu { display: flex; gap: 5px; }
|
||||
.card-menu button { padding: 7px 9px; background: transparent; color: var(--muted); font-size: 12px; }
|
||||
.list-card { padding: 19px; }
|
||||
.power-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 17px;
|
||||
}
|
||||
.power-button.on { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 20px 0 14px; }
|
||||
.temperature-control button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 18px;
|
||||
}
|
||||
.target-temp { min-width: 100px; text-align: center; color: var(--text); font-size: 42px; font-weight: 320; line-height: 1; letter-spacing: -.055em; }
|
||||
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 13px; font-weight: 540; }
|
||||
.current-line { display: flex; justify-content: center; gap: 6px; margin: -6px 0 15px; color: var(--muted); font-size: 11px; }
|
||||
.current-line strong { color: var(--text-soft); font-weight: 560; }
|
||||
|
||||
.mode-row, .fan-row, .preset-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar, .preset-row::-webkit-scrollbar { display: none; }
|
||||
.mode-row button, .fan-row button, .preset-row button {
|
||||
flex: 1 0 auto;
|
||||
min-width: 56px;
|
||||
min-height: 32px;
|
||||
padding: 6px 9px;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 11px;
|
||||
font-weight: 540;
|
||||
}
|
||||
.mode-row button.active, .fan-row button.active, .preset-row button.active {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 620;
|
||||
}
|
||||
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 2px; }
|
||||
.device-toggles button { min-height: 32px; padding: 6px 5px; color: var(--muted); background: var(--surface-2); font-size: 10px; }
|
||||
.device-toggles button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||
.card-footer small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-menu { display: flex; gap: 3px; }
|
||||
.card-menu button { min-height: 28px; padding: 5px 7px; border-color: transparent; color: var(--muted); background: transparent; font-size: 10px; }
|
||||
.card-menu button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
|
||||
.list-card { padding: 15px; }
|
||||
.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.list-card h3 { margin: 0 0 5px; font-size: 18px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; padding: 5px 9px; border-radius: 99px; color: var(--muted); background: var(--surface-muted); font-size: 11px; }
|
||||
.badge.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; margin-top: 16px; }
|
||||
.card-stat { padding: 10px; border-radius: 12px; background: var(--surface-muted); text-align: center; }
|
||||
.list-card h3 { margin: 0 0 4px; color: var(--text); font-size: 15px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 23px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface-2); font-size: 10px; font-weight: 550; }
|
||||
.badge.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 13px; }
|
||||
.card-stat { padding: 8px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: center; }
|
||||
.card-stat small, .card-stat strong { display: block; }
|
||||
.card-stat small { margin-bottom: 5px; color: var(--muted); font-size: 10px; }
|
||||
.card-stat strong { font-size: 15px; }
|
||||
.empty { grid-column: 1/-1; padding: 38px 24px; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); text-align: center; }
|
||||
.empty strong { display: block; margin-bottom: 7px; color: var(--text); }
|
||||
.panel { padding: 20px; }
|
||||
.card-stat small { margin-bottom: 4px; color: var(--muted); font-size: 9px; }
|
||||
.card-stat strong { color: var(--text-soft); font-size: 13px; font-weight: 590; }
|
||||
.empty { grid-column: 1/-1; padding: 34px 22px; border: 1px dashed var(--line-strong); border-radius: var(--radius); color: var(--muted); background: color-mix(in srgb, var(--surface) 50%, transparent); text-align: center; }
|
||||
.empty strong { display: block; margin-bottom: 6px; color: var(--text); }
|
||||
.empty.compact { padding: 16px; }
|
||||
|
||||
.panel { padding: 16px; }
|
||||
.chart-panel { overflow: hidden; }
|
||||
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 18px; }
|
||||
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
|
||||
.chart-toolbar select { flex: 1; min-width: 140px; }
|
||||
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; }
|
||||
#historyChart { width: 100%; min-width: 680px; height: 340px; }
|
||||
.legend { display: flex; gap: 18px; margin-top: 10px; color: var(--muted); font-size: 12px; }
|
||||
.legend > span { display: flex; align-items: center; gap: 7px; }
|
||||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
|
||||
.legend { display: flex; gap: 16px; margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.legend > span { display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.dot.target { background: var(--warning); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; }
|
||||
.form-grid hr, .dialog-form hr { width: 100%; border: 0; border-top: 1px solid var(--line); }
|
||||
label { display: grid; gap: 7px; color: var(--muted); font-size: 12px; font-weight: 650; }
|
||||
input, select { width: 100%; min-height: 45px; border: 1px solid var(--line); border-radius: 12px; outline: 0; padding: 10px 12px; color: var(--text); background: var(--input-bg); }
|
||||
input:focus, select:focus { border-color: var(--accent); outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent); outline-offset: 1px; }
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 9px; min-height: 44px; }
|
||||
.check input { width: 19px; min-height: 19px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 6px; }
|
||||
.system-panel { margin-top: 14px; color: var(--muted); line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text); }
|
||||
.log-list { display: grid; gap: 2px; padding: 8px; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 11px 12px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
||||
.form-grid h3 { margin: 2px 0 0; }
|
||||
.form-grid hr, .dialog-form hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid var(--line); }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 550; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
font-size: 13px;
|
||||
transition: border-color .12s ease, background-color .12s ease;
|
||||
}
|
||||
input:hover, select:hover, textarea:hover { border-color: var(--line-strong); }
|
||||
input:focus, select:focus, textarea:focus { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); }
|
||||
input::placeholder, textarea::placeholder { color: var(--muted-2); }
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 8px; min-height: 36px; }
|
||||
.check input { width: 16px; min-height: 16px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 4px; }
|
||||
.system-panel { margin-top: 10px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text-soft); font-weight: 580; }
|
||||
|
||||
.log-list { display: grid; gap: 0; padding: 0; overflow: hidden; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); font-size: 11px; }
|
||||
.log-row:last-child { border-bottom: 0; }
|
||||
.log-row:hover { background: var(--surface-2); }
|
||||
.log-row time, .log-row .kind { color: var(--muted); }
|
||||
.log-row.error .kind { color: var(--danger); }
|
||||
.log-row.warn .kind { color: var(--warning); }
|
||||
.bottom-nav { position: fixed; z-index: 30; right: 0; bottom: 0; left: 0; display: grid; grid-template-columns: repeat(5, 1fr); padding: 8px max(8px, env(safe-area-inset-right)) calc(8px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--nav-bg) 96%, transparent); backdrop-filter: blur(14px); }
|
||||
.bottom-nav button { display: grid; place-items: center; gap: 3px; padding: 7px 2px; border-radius: 12px; color: var(--muted); background: transparent; font-size: 10px; }
|
||||
.bottom-nav button span { font-size: 20px; line-height: 1; }
|
||||
.bottom-nav button b { font-weight: 650; }
|
||||
.bottom-nav button.active { color: var(--accent); }
|
||||
dialog { width: min(540px, calc(100% - 24px)); max-height: min(88vh, 760px); overflow: auto; border: 1px solid var(--line); border-radius: 24px; padding: 0; color: var(--text); background: var(--dialog-bg); }
|
||||
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(4px); }
|
||||
.dialog-form { display: grid; gap: 14px; padding: 20px; }
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding: 6px max(8px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
min-height: 50px;
|
||||
padding: 5px 2px;
|
||||
border-color: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.bottom-nav button:hover { border-color: transparent; color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button span { display: grid; place-items: center; width: 20px; height: 20px; color: currentColor; font-size: 16px; line-height: 1; }
|
||||
.bottom-nav button span svg { width: 18px; height: 18px; stroke-width: 1.6; }
|
||||
.bottom-nav button b { font-weight: 540; }
|
||||
.bottom-nav button.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.desktop-nav-only { display: none !important; }
|
||||
|
||||
/* Dialogs deliberately have no drop shadow: separation comes from border + backdrop. */
|
||||
dialog {
|
||||
width: min(540px, calc(100% - 24px));
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--dialog-bg);
|
||||
}
|
||||
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
|
||||
.dialog-form { display: grid; gap: 12px; padding: 17px; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.dialog-head h2 { font-size: 23px; }
|
||||
.dialog-head button { width: 38px; height: 38px; padding: 0; background: transparent; color: var(--muted); font-size: 25px; }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
#externalSensorFields { display: grid; gap: 12px; }
|
||||
.field-note { margin: -4px 0 0; color: var(--muted); font-size: .8rem; line-height: 1.45; }
|
||||
.sensor-detail { margin-top: 10px; color: var(--muted); font-size: .8rem; line-height: 1.45; }
|
||||
fieldset { border: 1px solid var(--line); border-radius: 14px; padding: 10px; }
|
||||
legend { padding: 0 5px; color: var(--muted); font-size: 11px; }
|
||||
.dialog-head h2 { margin: 0; font-size: 18px; }
|
||||
.dialog-head button { display: grid; place-items: center; width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; border-color: transparent; color: var(--muted); background: transparent; font-size: 21px; }
|
||||
.dialog-head button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
#externalSensorFields { display: grid; gap: 10px; }
|
||||
.field-note { margin: -3px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.sensor-detail { margin-top: 9px; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 9px; }
|
||||
legend { padding: 0 5px; color: var(--muted); font-size: 10px; }
|
||||
.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.days label { display: grid; place-items: center; gap: 5px; padding: 6px 2px; border-radius: 8px; background: var(--surface-muted); font-size: 10px; }
|
||||
.days input { width: 16px; min-height: 16px; accent-color: var(--accent); }
|
||||
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 10px; }
|
||||
.sheet .dialog-head { padding: 18px 18px 5px; }
|
||||
.menu-list { display: grid; padding: 8px 10px 12px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; border-bottom: 1px solid var(--line); border-radius: 0; padding: 16px 9px; background: transparent; text-align: left; }
|
||||
.days label { display: grid; place-items: center; gap: 4px; padding: 6px 2px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 9px; }
|
||||
.days input { width: 14px; min-height: 14px; accent-color: var(--accent); }
|
||||
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 8px; }
|
||||
.sheet .dialog-head { padding: 15px 15px 4px; }
|
||||
.menu-list { display: grid; padding: 7px 9px 10px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; min-height: 42px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 8px; background: transparent; text-align: left; }
|
||||
.menu-list button:hover { background: var(--surface-2); }
|
||||
.menu-list button:last-child { border-bottom: 0; }
|
||||
.auth-dialog { width: min(420px, calc(100% - 30px)); }
|
||||
#toast { position: fixed; z-index: 100; right: 18px; bottom: 92px; max-width: min(380px, calc(100% - 36px)); transform: translateY(15px); opacity: 0; pointer-events: none; padding: 12px 15px; border: 1px solid var(--line); border-radius: 13px; background: var(--surface-2); transition: .2s ease; font-size: 13px; }
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); }
|
||||
@media (min-width: 900px) {
|
||||
.bottom-nav { top: 92px; right: auto; bottom: auto; left: max(16px, calc((100vw - 1380px)/2)); grid-template-columns: 1fr; width: 90px; border: 1px solid var(--line); border-radius: 22px; padding: 9px; background: var(--surface); }
|
||||
.bottom-nav button { padding: 10px 4px; }
|
||||
main { padding-left: 122px; padding-bottom: 50px; }
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
right: 16px;
|
||||
bottom: 82px;
|
||||
max-width: min(380px, calc(100% - 32px));
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface-2);
|
||||
transition: .14s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); background: color-mix(in srgb, var(--danger-soft) 42%, var(--surface-2)); }
|
||||
|
||||
.token-manager { display: grid; gap: 10px; }
|
||||
.token-list { display: grid; gap: 6px; }
|
||||
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.token-row > div { min-width: 0; display: grid; gap: 2px; }
|
||||
.token-row strong { color: var(--text); font-size: 12px; font-weight: 590; }
|
||||
.token-row small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
|
||||
/* Zone quick controls */
|
||||
.zone-quick-control { display: grid; gap: 4px; margin-top: 9px; padding: 8px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.temperature-control.compact { padding: 3px 0 6px; gap: 12px; }
|
||||
.temperature-control.compact button { width: 34px; height: 34px; min-height: 34px; }
|
||||
.target-temp.compact { min-width: 86px; font-size: 29px; }
|
||||
.zone-quick-control .mode-row { padding-bottom: 1px; }
|
||||
|
||||
.discovery-name-list { display: grid; gap: 10px; }
|
||||
.discovery-name-row { display: grid; gap: 7px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.discovery-name-row:last-child { border-bottom: 0; }
|
||||
.discovery-name-row span { display: grid; gap: 2px; }
|
||||
.discovery-name-row small { color: var(--muted); overflow-wrap: anywhere; }
|
||||
|
||||
/* Quiet themed scrollbars. No bright native tracks in dark mode. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--scroll-thumb); background-clip: padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); background-clip: padding-box; }
|
||||
|
||||
.house-climate { display: grid; gap: 13px; margin: -12px 0 28px; }
|
||||
.house-climate-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.house-climate-head h3 { margin: 2px 0 3px; font-size: 15px; }
|
||||
.house-climate-head p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.outside-pill { flex: 0 0 auto; min-width: 90px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: right; }
|
||||
.outside-pill small, .outside-pill strong { display: block; }
|
||||
.outside-pill small { color: var(--muted); font-size: 9px; }
|
||||
.outside-pill strong { margin-top: 2px; color: var(--text); font-size: 16px; font-weight: 590; }
|
||||
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.house-mode-row button { min-height: 36px; color: var(--muted); background: var(--surface-2); }
|
||||
.house-mode-row button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); font-weight: 620; }
|
||||
.mode-indicator { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 999px; vertical-align: 1px; background: var(--muted-2); }
|
||||
.house-mode-row button[data-value="cool"] .mode-indicator { background: #60a5fa; }
|
||||
.house-mode-row button[data-value="heat"] .mode-indicator { background: var(--warning); }
|
||||
.house-mode-row button.active .mode-indicator { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 9%, transparent); }
|
||||
|
||||
.zone-thermostat { overflow: hidden; }
|
||||
.zone-thermostat.demanding { border-color: var(--accent-border); }
|
||||
.thermostat-main { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; margin: 13px 0 8px; }
|
||||
.thermostat-main > div:first-child { text-align: left; }
|
||||
.thermostat-main > div:last-child { text-align: right; }
|
||||
.thermostat-main small, .thermostat-main strong { display: block; }
|
||||
.thermostat-main small { margin-bottom: 3px; color: var(--muted); font-size: 9px; }
|
||||
.thermostat-main strong { color: var(--text-soft); font-size: 14px; font-weight: 590; }
|
||||
.zone-mode-row { padding-bottom: 4px; }
|
||||
.zone-state-line { display: flex; justify-content: space-between; gap: 10px; padding: 7px 1px 0; color: var(--muted); font-size: 10px; }
|
||||
.zone-state-line span:last-child { text-align: right; }
|
||||
.dashboard-zones { margin-bottom: 28px; }
|
||||
|
||||
.schedule-template-panel { display: grid; gap: 11px; margin-bottom: 14px; }
|
||||
.schedule-template-panel h3 { margin-bottom: 3px; }
|
||||
.template-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 6px; }
|
||||
.profile-editor { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 10px; background: var(--surface-2); }
|
||||
.profile-editor summary { cursor: pointer; color: var(--text-soft); font-weight: 590; font-size: 12px; }
|
||||
.profile-editor[open] summary { margin-bottom: 10px; }
|
||||
.profile-grid { display: grid; grid-template-columns: 90px repeat(3, 1fr); align-items: end; gap: 7px; margin-top: 7px; }
|
||||
.profile-grid > strong { align-self: center; font-size: 11px; }
|
||||
.profile-grid label span { font-size: 9px; }
|
||||
.warning-note { color: var(--warning); }
|
||||
.automation-hint { margin-bottom: 14px; }
|
||||
.automation-hint strong { display: block; margin-bottom: 4px; color: var(--text-soft); font-size: 12px; }
|
||||
.automation-hint p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.topbar { padding-left: 14px; }
|
||||
.brand { width: 176px; flex: 0 0 176px; }
|
||||
.bottom-nav {
|
||||
top: 54px;
|
||||
right: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
width: 190px;
|
||||
padding: 10px 8px;
|
||||
border-top: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
grid-template-columns: 22px 1fr;
|
||||
justify-items: start;
|
||||
place-items: center start;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bottom-nav button span { width: 18px; height: 18px; }
|
||||
.bottom-nav button b { font-size: 12px; }
|
||||
.bottom-nav button.active { color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button.active span { color: var(--accent); }
|
||||
.bottom-nav .desktop-nav-only { display: grid !important; }
|
||||
.bottom-nav button[data-nav="more"] { display: none; }
|
||||
main {
|
||||
width: min(1360px, calc(100% - 190px));
|
||||
margin: 0 0 0 190px;
|
||||
padding: 30px 28px 56px;
|
||||
}
|
||||
#toast { bottom: 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar { flex-wrap: wrap; }
|
||||
.topbar { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
||||
.top-actions { width: 100%; }
|
||||
.top-actions .primary { margin-left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
main { padding: 18px 13px 105px; }
|
||||
.hero { min-height: 190px; padding: 23px; }
|
||||
.hero-temp { font-size: 52px; }
|
||||
.metrics { gap: 7px; }
|
||||
.metric { padding: 14px 10px; }
|
||||
.metric strong { font-size: 20px; }
|
||||
main { padding: 15px 11px 92px; }
|
||||
.hero { min-height: 142px; padding: 18px; }
|
||||
.hero-temp { font-size: 48px; }
|
||||
.metrics { gap: 6px; }
|
||||
.metric { padding: 11px 9px; }
|
||||
.metric strong { font-size: 18px; }
|
||||
.section-heading { align-items: center; }
|
||||
.section-heading button { padding: 9px 11px; font-size: 12px; }
|
||||
.section-heading button { min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; }
|
||||
.log-row { grid-template-columns: 62px 1fr; }
|
||||
.log-row .message { grid-column: 1/-1; }
|
||||
.theme-select { min-width: 86px; }
|
||||
}
|
||||
@media (max-width: 410px) {
|
||||
.topbar { padding-inline: 12px; }
|
||||
.brand strong { font-size: 13px; }
|
||||
.hero { align-items: start; flex-direction: column; }
|
||||
.hero-temp { align-self: flex-end; margin-top: -15px; }
|
||||
.two { grid-template-columns: 1fr; }
|
||||
.toolbar-select { min-width: 54px; }
|
||||
.theme-select { min-width: 82px; }
|
||||
}
|
||||
|
||||
.token-manager { display: grid; gap: 12px; }
|
||||
.token-list { display: grid; gap: 8px; }
|
||||
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-muted); }
|
||||
.token-row > div { min-width: 0; display: grid; gap: 3px; }
|
||||
.token-row strong { color: var(--text); font-size: 13px; }
|
||||
.token-row small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
.empty.compact { padding: 18px; }
|
||||
@media (max-width: 620px) {
|
||||
.theme-select { min-width: 84px; }
|
||||
.token-row { align-items: stretch; flex-direction: column; }
|
||||
.token-row button { width: 100%; }
|
||||
.house-climate { margin-top: -18px; }
|
||||
.house-climate-head { align-items: flex-start; }
|
||||
.house-climate-head p { max-width: 230px; }
|
||||
.thermostat-main { grid-template-columns: 1fr auto 1fr; gap: 4px; }
|
||||
.thermostat-main .temperature-control.compact { gap: 7px; }
|
||||
.thermostat-main .target-temp.compact { min-width: 70px; font-size: 26px; }
|
||||
.profile-grid { grid-template-columns: 1fr 1fr; }
|
||||
.profile-grid > strong { grid-column: 1/-1; margin-top: 3px; }
|
||||
.zone-state-line { flex-direction: column; gap: 2px; }
|
||||
.zone-state-line span:last-child { text-align: left; }
|
||||
}
|
||||
|
||||
/* Zone quick controls */
|
||||
.zone-quick-control { display: grid; gap: 4px; margin-top: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-muted); }
|
||||
.temperature-control.compact { padding: 4px 0 7px; gap: 16px; }
|
||||
.temperature-control.compact button { width: 38px; height: 38px; }
|
||||
.target-temp.compact { min-width: 92px; font-size: 32px; }
|
||||
.zone-quick-control .mode-row { padding-bottom: 2px; }
|
||||
|
||||
.discovery-name-list { display:grid; gap:12px; }
|
||||
.discovery-name-row { display:grid; gap:8px; padding:12px 0; border-bottom:1px solid var(--border); }
|
||||
.discovery-name-row:last-child { border-bottom:0; }
|
||||
.discovery-name-row span { display:grid; gap:2px; }
|
||||
.discovery-name-row small { color:var(--muted); overflow-wrap:anywhere; }
|
||||
@media (max-width: 410px) {
|
||||
.brand strong { font-size: 12px; }
|
||||
.hero { align-items: flex-start; flex-direction: column; }
|
||||
.hero-temp { align-self: flex-end; margin-top: -12px; }
|
||||
.two { grid-template-columns: 1fr; }
|
||||
.toolbar-select { min-width: 52px; }
|
||||
.theme-select { min-width: 78px; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'gree-controller-v5';
|
||||
const CACHE = 'gree-controller-v041';
|
||||
const ASSETS = ['/', '/styles.css', '/app.js', '/favicon.svg', '/manifest.webmanifest', '/lang/index.json', '/lang/en.json'];
|
||||
self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting())));
|
||||
self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim())));
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* GREE Controller semantic component layer. This file is the Tailwind build source. */
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #151515;
|
||||
--canvas: #181818;
|
||||
--surface: #1c1c1c;
|
||||
--surface-2: #222222;
|
||||
--surface-3: #282828;
|
||||
--surface-muted: #202020;
|
||||
--input-bg: #181818;
|
||||
--nav-bg: #171717;
|
||||
--dialog-bg: #1c1c1c;
|
||||
--line: #2c2c2c;
|
||||
--border: #2c2c2c;
|
||||
--line-strong: #3a3a3a;
|
||||
--text: #ededed;
|
||||
--text-soft: #d4d4d4;
|
||||
--muted: #a1a1aa;
|
||||
--muted-2: #737373;
|
||||
--accent: #3ecf8e;
|
||||
--accent-strong: #46d99a;
|
||||
--accent-soft: rgba(62, 207, 142, .10);
|
||||
--accent-border: rgba(62, 207, 142, .36);
|
||||
--accent-text: #06281a;
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, .10);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, .10);
|
||||
--backdrop: rgba(0, 0, 0, .68);
|
||||
--grid: rgba(255, 255, 255, .07);
|
||||
--scroll-thumb: #444444;
|
||||
--scroll-thumb-hover: #575757;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--control-h: 38px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--bg: #f8faf9;
|
||||
--canvas: #f8faf9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f5f6f6;
|
||||
--surface-3: #eef0ef;
|
||||
--surface-muted: #f6f7f7;
|
||||
--input-bg: #ffffff;
|
||||
--nav-bg: #ffffff;
|
||||
--dialog-bg: #ffffff;
|
||||
--line: #e5e7e6;
|
||||
--border: #e5e7e6;
|
||||
--line-strong: #d4d7d5;
|
||||
--text: #1c1c1c;
|
||||
--text-soft: #3f3f46;
|
||||
--muted: #71717a;
|
||||
--muted-2: #a1a1aa;
|
||||
--accent: #24b47e;
|
||||
--accent-strong: #1ea672;
|
||||
--accent-soft: rgba(36, 180, 126, .09);
|
||||
--accent-border: rgba(36, 180, 126, .32);
|
||||
--accent-text: #ffffff;
|
||||
--danger: #dc5f5f;
|
||||
--danger-soft: rgba(220, 95, 95, .08);
|
||||
--warning: #b7791f;
|
||||
--warning-soft: rgba(183, 121, 31, .08);
|
||||
--backdrop: rgba(23, 23, 23, .32);
|
||||
--grid: rgba(24, 24, 27, .08);
|
||||
--scroll-thumb: #c8ccca;
|
||||
--scroll-thumb-hover: #aeb3b0;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
html, body { min-height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { opacity: .48; cursor: wait; }
|
||||
a { color: var(--accent); }
|
||||
::selection { color: var(--text); background: var(--accent-soft); }
|
||||
|
||||
/* Accessible focus treatment in the same restrained style as the rest of the UI. */
|
||||
:where(button, input, select, textarea, summary):focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
min-height: 54px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.brand { display: flex; align-items: center; min-width: 0; }
|
||||
.brand strong, .brand small { display: block; }
|
||||
.brand strong { color: var(--text); font-size: 13px; font-weight: 650; letter-spacing: -.01em; white-space: nowrap; }
|
||||
.brand small { margin-top: 1px; color: var(--muted); font-size: 10px; }
|
||||
.brand.large { margin-bottom: 16px; }
|
||||
.brand.large strong { font-size: 20px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
|
||||
.toolbar-select {
|
||||
width: auto;
|
||||
min-width: 58px;
|
||||
min-height: 34px;
|
||||
padding: 6px 28px 6px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.theme-select { min-width: 92px; }
|
||||
|
||||
button {
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
font-weight: 520;
|
||||
transition: border-color .12s ease, background-color .12s ease, color .12s ease, opacity .12s ease;
|
||||
}
|
||||
button:hover { border-color: var(--line-strong); background: var(--surface-2); color: var(--text); }
|
||||
button:active { background: var(--surface-3); }
|
||||
.primary {
|
||||
border-color: color-mix(in srgb, var(--accent) 74%, var(--line));
|
||||
color: var(--accent-text);
|
||||
background: var(--accent);
|
||||
font-weight: 650;
|
||||
}
|
||||
.primary:hover { border-color: var(--accent-strong); color: var(--accent-text); background: var(--accent-strong); }
|
||||
.secondary { border-color: var(--line); color: var(--text-soft); background: var(--surface); font-weight: 540; }
|
||||
.secondary:hover { border-color: var(--line-strong); background: var(--surface-2); }
|
||||
.danger { color: var(--danger); }
|
||||
.danger:hover { border-color: color-mix(in srgb, var(--danger) 38%, var(--line)); background: var(--danger-soft); color: var(--danger); }
|
||||
.compact { min-height: 34px; padding: 7px 11px; }
|
||||
.icon-button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
}
|
||||
.icon-button:hover { color: var(--text); background: var(--surface-2); }
|
||||
.icon-button svg { width: 16px; height: 16px; }
|
||||
|
||||
main {
|
||||
width: min(1280px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 102px;
|
||||
}
|
||||
.view { display: none; }
|
||||
.view.active { display: block; animation: reveal .12s ease-out; }
|
||||
@keyframes reveal { from { opacity: .65; transform: translateY(2px); } }
|
||||
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 7px; font-size: clamp(25px, 5vw, 34px); line-height: 1.08; font-weight: 620; letter-spacing: -.035em; }
|
||||
h2 { margin-bottom: 0; font-size: clamp(20px, 4vw, 25px); line-height: 1.15; font-weight: 610; letter-spacing: -.025em; }
|
||||
h3 { margin-bottom: 9px; font-size: 15px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.lead { max-width: 760px; color: var(--muted); font-size: 13px; line-height: 1.6; }
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 154px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hero::after { display: none; }
|
||||
.hero h1 { margin: 4px 0 7px; }
|
||||
.hero p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-temp { z-index: 1; white-space: nowrap; color: var(--text); font-size: clamp(48px, 9vw, 68px); font-weight: 310; line-height: 1; letter-spacing: -.065em; }
|
||||
.hero-temp small { margin-left: 4px; color: var(--muted); font-size: .28em; font-weight: 550; letter-spacing: 0; }
|
||||
|
||||
.metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 10px 0 28px; }
|
||||
.metric { padding: 14px 15px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.metric span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 11px; }
|
||||
.metric strong { color: var(--text); font-size: 21px; font-weight: 590; letter-spacing: -.025em; }
|
||||
|
||||
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 14px; margin: 8px 0 14px; }
|
||||
.section-heading > div { min-width: 0; }
|
||||
.device-grid, .list-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); gap: 10px; }
|
||||
.device-card, .panel, .list-card { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
||||
.device-card { overflow: hidden; padding: 16px; }
|
||||
.device-card.off { opacity: .78; }
|
||||
.device-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.device-title { min-width: 0; }
|
||||
.device-title h3 { overflow: hidden; margin: 0 0 3px; color: var(--text); text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
|
||||
.device-title p { overflow: hidden; margin: 0; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.status { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); }
|
||||
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
|
||||
.status.online::before { background: var(--accent); }
|
||||
.power-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 17px;
|
||||
}
|
||||
.power-button.on { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
|
||||
.temperature-control { display: flex; align-items: center; justify-content: center; gap: 16px; padding: 20px 0 14px; }
|
||||
.temperature-control button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface);
|
||||
font-size: 18px;
|
||||
}
|
||||
.target-temp { min-width: 100px; text-align: center; color: var(--text); font-size: 42px; font-weight: 320; line-height: 1; letter-spacing: -.055em; }
|
||||
.target-temp small { margin-left: 3px; color: var(--muted); font-size: 13px; font-weight: 540; }
|
||||
.current-line { display: flex; justify-content: center; gap: 6px; margin: -6px 0 15px; color: var(--muted); font-size: 11px; }
|
||||
.current-line strong { color: var(--text-soft); font-weight: 560; }
|
||||
|
||||
.mode-row, .fan-row, .preset-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
overflow-x: auto;
|
||||
padding: 2px 0 8px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.mode-row::-webkit-scrollbar, .fan-row::-webkit-scrollbar, .preset-row::-webkit-scrollbar { display: none; }
|
||||
.mode-row button, .fan-row button, .preset-row button {
|
||||
flex: 1 0 auto;
|
||||
min-width: 56px;
|
||||
min-height: 32px;
|
||||
padding: 6px 9px;
|
||||
border-color: var(--line);
|
||||
color: var(--muted);
|
||||
background: var(--surface-2);
|
||||
font-size: 11px;
|
||||
font-weight: 540;
|
||||
}
|
||||
.mode-row button.active, .fan-row button.active, .preset-row button.active {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 620;
|
||||
}
|
||||
.device-toggles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 2px; }
|
||||
.device-toggles button { min-height: 32px; padding: 6px 5px; color: var(--muted); background: var(--surface-2); font-size: 10px; }
|
||||
.device-toggles button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); }
|
||||
.card-footer small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.card-menu { display: flex; gap: 3px; }
|
||||
.card-menu button { min-height: 28px; padding: 5px 7px; border-color: transparent; color: var(--muted); background: transparent; font-size: 10px; }
|
||||
.card-menu button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
|
||||
.list-card { padding: 15px; }
|
||||
.list-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.list-card h3 { margin: 0 0 4px; color: var(--text); font-size: 15px; }
|
||||
.list-card p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.45; }
|
||||
.badge { display: inline-flex; align-items: center; min-height: 23px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: var(--surface-2); font-size: 10px; font-weight: 550; }
|
||||
.badge.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); }
|
||||
.card-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 13px; }
|
||||
.card-stat { padding: 8px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: center; }
|
||||
.card-stat small, .card-stat strong { display: block; }
|
||||
.card-stat small { margin-bottom: 4px; color: var(--muted); font-size: 9px; }
|
||||
.card-stat strong { color: var(--text-soft); font-size: 13px; font-weight: 590; }
|
||||
.empty { grid-column: 1/-1; padding: 34px 22px; border: 1px dashed var(--line-strong); border-radius: var(--radius); color: var(--muted); background: color-mix(in srgb, var(--surface) 50%, transparent); text-align: center; }
|
||||
.empty strong { display: block; margin-bottom: 6px; color: var(--text); }
|
||||
.empty.compact { padding: 16px; }
|
||||
|
||||
.panel { padding: 16px; }
|
||||
.chart-panel { overflow: hidden; }
|
||||
.chart-toolbar { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 14px; }
|
||||
.chart-toolbar select { flex: 1; min-width: 140px; }
|
||||
.chart-wrap { position: relative; overflow-x: auto; min-height: 310px; }
|
||||
#historyChart { width: 100%; min-width: 680px; height: 340px; }
|
||||
.legend { display: flex; gap: 16px; margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.legend > span { display: flex; align-items: center; gap: 6px; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.dot.target { background: var(--warning); }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: 1/-1; }
|
||||
.form-grid h3 { margin: 2px 0 0; }
|
||||
.form-grid hr, .dialog-form hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid var(--line); }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 550; }
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
background: var(--input-bg);
|
||||
font-size: 13px;
|
||||
transition: border-color .12s ease, background-color .12s ease;
|
||||
}
|
||||
input:hover, select:hover, textarea:hover { border-color: var(--line-strong); }
|
||||
input:focus, select:focus, textarea:focus { border-color: color-mix(in srgb, var(--accent) 62%, var(--line)); }
|
||||
input::placeholder, textarea::placeholder { color: var(--muted-2); }
|
||||
.check { display: flex; grid-auto-flow: column; justify-content: start; align-items: center; gap: 8px; min-height: 36px; }
|
||||
.check input { width: 16px; min-height: 16px; accent-color: var(--accent); }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 4px; }
|
||||
.system-panel { margin-top: 10px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.system-panel strong { color: var(--text-soft); font-weight: 580; }
|
||||
|
||||
.log-list { display: grid; gap: 0; padding: 0; overflow: hidden; }
|
||||
.log-row { display: grid; grid-template-columns: 78px 150px 1fr; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); font-size: 11px; }
|
||||
.log-row:last-child { border-bottom: 0; }
|
||||
.log-row:hover { background: var(--surface-2); }
|
||||
.log-row time, .log-row .kind { color: var(--muted); }
|
||||
.log-row.error .kind { color: var(--danger); }
|
||||
.log-row.warn .kind { color: var(--warning); }
|
||||
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding: 6px max(8px, env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3px;
|
||||
min-height: 50px;
|
||||
padding: 5px 2px;
|
||||
border-color: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
}
|
||||
.bottom-nav button:hover { border-color: transparent; color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button span { display: grid; place-items: center; width: 20px; height: 20px; color: currentColor; font-size: 16px; line-height: 1; }
|
||||
.bottom-nav button span svg { width: 18px; height: 18px; stroke-width: 1.6; }
|
||||
.bottom-nav button b { font-weight: 540; }
|
||||
.bottom-nav button.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.desktop-nav-only { display: none !important; }
|
||||
|
||||
/* Dialogs deliberately have no drop shadow: separation comes from border + backdrop. */
|
||||
dialog {
|
||||
width: min(540px, calc(100% - 24px));
|
||||
max-height: min(88vh, 760px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
background: var(--dialog-bg);
|
||||
}
|
||||
dialog::backdrop { background: var(--backdrop); backdrop-filter: blur(2px); }
|
||||
.dialog-form { display: grid; gap: 12px; padding: 17px; }
|
||||
.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.dialog-head h2 { margin: 0; font-size: 18px; }
|
||||
.dialog-head button { display: grid; place-items: center; width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; border-color: transparent; color: var(--muted); background: transparent; font-size: 21px; }
|
||||
.dialog-head button:hover { border-color: var(--line); color: var(--text); background: var(--surface-2); }
|
||||
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
#externalSensorFields { display: grid; gap: 10px; }
|
||||
.field-note { margin: -3px 0 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.sensor-detail { margin-top: 9px; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
fieldset { border: 1px solid var(--line); border-radius: var(--radius); padding: 9px; }
|
||||
legend { padding: 0 5px; color: var(--muted); font-size: 10px; }
|
||||
.days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.days label { display: grid; place-items: center; gap: 4px; padding: 6px 2px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); font-size: 9px; }
|
||||
.days input { width: 14px; min-height: 14px; accent-color: var(--accent); }
|
||||
.sheet { width: min(500px, calc(100% - 18px)); margin: auto auto 8px; }
|
||||
.sheet .dialog-head { padding: 15px 15px 4px; }
|
||||
.menu-list { display: grid; padding: 7px 9px 10px; }
|
||||
.menu-list button { display: flex; justify-content: space-between; min-height: 42px; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 11px 8px; background: transparent; text-align: left; }
|
||||
.menu-list button:hover { background: var(--surface-2); }
|
||||
.menu-list button:last-child { border-bottom: 0; }
|
||||
.auth-dialog { width: min(420px, calc(100% - 30px)); }
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
right: 16px;
|
||||
bottom: 82px;
|
||||
max-width: min(380px, calc(100% - 32px));
|
||||
transform: translateY(8px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-soft);
|
||||
background: var(--surface-2);
|
||||
transition: .14s ease;
|
||||
font-size: 12px;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.error { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 42%, var(--line)); background: color-mix(in srgb, var(--danger-soft) 42%, var(--surface-2)); }
|
||||
|
||||
.token-manager { display: grid; gap: 10px; }
|
||||
.token-list { display: grid; gap: 6px; }
|
||||
.token-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.token-row > div { min-width: 0; display: grid; gap: 2px; }
|
||||
.token-row strong { color: var(--text); font-size: 12px; font-weight: 590; }
|
||||
.token-row small { overflow: hidden; color: var(--muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
|
||||
/* Zone quick controls */
|
||||
.zone-quick-control { display: grid; gap: 4px; margin-top: 9px; padding: 8px 9px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
|
||||
.temperature-control.compact { padding: 3px 0 6px; gap: 12px; }
|
||||
.temperature-control.compact button { width: 34px; height: 34px; min-height: 34px; }
|
||||
.target-temp.compact { min-width: 86px; font-size: 29px; }
|
||||
.zone-quick-control .mode-row { padding-bottom: 1px; }
|
||||
|
||||
.discovery-name-list { display: grid; gap: 10px; }
|
||||
.discovery-name-row { display: grid; gap: 7px; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.discovery-name-row:last-child { border-bottom: 0; }
|
||||
.discovery-name-row span { display: grid; gap: 2px; }
|
||||
.discovery-name-row small { color: var(--muted); overflow-wrap: anywhere; }
|
||||
|
||||
/* Quiet themed scrollbars. No bright native tracks in dark mode. */
|
||||
* { scrollbar-width: thin; scrollbar-color: var(--scroll-thumb) transparent; }
|
||||
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
*::-webkit-scrollbar-track { background: transparent; }
|
||||
*::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background: var(--scroll-thumb); background-clip: padding-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--scroll-thumb-hover); background-clip: padding-box; }
|
||||
|
||||
.house-climate { display: grid; gap: 13px; margin: -12px 0 28px; }
|
||||
.house-climate-head { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.house-climate-head h3 { margin: 2px 0 3px; font-size: 15px; }
|
||||
.house-climate-head p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.5; }
|
||||
.outside-pill { flex: 0 0 auto; min-width: 90px; padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); text-align: right; }
|
||||
.outside-pill small, .outside-pill strong { display: block; }
|
||||
.outside-pill small { color: var(--muted); font-size: 9px; }
|
||||
.outside-pill strong { margin-top: 2px; color: var(--text); font-size: 16px; font-weight: 590; }
|
||||
.house-mode-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.house-mode-row button { min-height: 36px; color: var(--muted); background: var(--surface-2); }
|
||||
.house-mode-row button.active { border-color: var(--accent-border); color: var(--accent); background: var(--accent-soft); font-weight: 620; }
|
||||
.mode-indicator { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 999px; vertical-align: 1px; background: var(--muted-2); }
|
||||
.house-mode-row button[data-value="cool"] .mode-indicator { background: #60a5fa; }
|
||||
.house-mode-row button[data-value="heat"] .mode-indicator { background: var(--warning); }
|
||||
.house-mode-row button.active .mode-indicator { box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 9%, transparent); }
|
||||
|
||||
.zone-thermostat { overflow: hidden; }
|
||||
.zone-thermostat.demanding { border-color: var(--accent-border); }
|
||||
.thermostat-main { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 8px; margin: 13px 0 8px; }
|
||||
.thermostat-main > div:first-child { text-align: left; }
|
||||
.thermostat-main > div:last-child { text-align: right; }
|
||||
.thermostat-main small, .thermostat-main strong { display: block; }
|
||||
.thermostat-main small { margin-bottom: 3px; color: var(--muted); font-size: 9px; }
|
||||
.thermostat-main strong { color: var(--text-soft); font-size: 14px; font-weight: 590; }
|
||||
.zone-mode-row { padding-bottom: 4px; }
|
||||
.zone-state-line { display: flex; justify-content: space-between; gap: 10px; padding: 7px 1px 0; color: var(--muted); font-size: 10px; }
|
||||
.zone-state-line span:last-child { text-align: right; }
|
||||
.dashboard-zones { margin-bottom: 28px; }
|
||||
|
||||
.schedule-template-panel { display: grid; gap: 11px; margin-bottom: 14px; }
|
||||
.schedule-template-panel h3 { margin-bottom: 3px; }
|
||||
.template-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 6px; }
|
||||
.profile-editor { border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 10px; background: var(--surface-2); }
|
||||
.profile-editor summary { cursor: pointer; color: var(--text-soft); font-weight: 590; font-size: 12px; }
|
||||
.profile-editor[open] summary { margin-bottom: 10px; }
|
||||
.profile-grid { display: grid; grid-template-columns: 90px repeat(3, 1fr); align-items: end; gap: 7px; margin-top: 7px; }
|
||||
.profile-grid > strong { align-self: center; font-size: 11px; }
|
||||
.profile-grid label span { font-size: 9px; }
|
||||
.warning-note { color: var(--warning); }
|
||||
.automation-hint { margin-bottom: 14px; }
|
||||
.automation-hint strong { display: block; margin-bottom: 4px; color: var(--text-soft); font-size: 12px; }
|
||||
.automation-hint p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.topbar { padding-left: 14px; }
|
||||
.brand { width: 176px; flex: 0 0 176px; }
|
||||
.bottom-nav {
|
||||
top: 54px;
|
||||
right: auto;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
align-content: start;
|
||||
grid-template-columns: 1fr;
|
||||
width: 190px;
|
||||
padding: 10px 8px;
|
||||
border-top: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
.bottom-nav button {
|
||||
grid-template-columns: 22px 1fr;
|
||||
justify-items: start;
|
||||
place-items: center start;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bottom-nav button span { width: 18px; height: 18px; }
|
||||
.bottom-nav button b { font-size: 12px; }
|
||||
.bottom-nav button.active { color: var(--text); background: var(--surface-2); }
|
||||
.bottom-nav button.active span { color: var(--accent); }
|
||||
.bottom-nav .desktop-nav-only { display: grid !important; }
|
||||
.bottom-nav button[data-nav="more"] { display: none; }
|
||||
main {
|
||||
width: min(1360px, calc(100% - 190px));
|
||||
margin: 0 0 0 190px;
|
||||
padding: 30px 28px 56px;
|
||||
}
|
||||
#toast { bottom: 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.topbar { flex-wrap: wrap; gap: 6px; padding: 8px 10px; }
|
||||
.top-actions { width: 100%; }
|
||||
.top-actions .primary { margin-left: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
main { padding: 15px 11px 92px; }
|
||||
.hero { min-height: 142px; padding: 18px; }
|
||||
.hero-temp { font-size: 48px; }
|
||||
.metrics { gap: 6px; }
|
||||
.metric { padding: 11px 9px; }
|
||||
.metric strong { font-size: 18px; }
|
||||
.section-heading { align-items: center; }
|
||||
.section-heading button { min-height: 32px; padding: 6px 9px; font-size: 11px; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid h3, .form-grid hr, .form-grid .wide { grid-column: auto; }
|
||||
.log-row { grid-template-columns: 62px 1fr; }
|
||||
.log-row .message { grid-column: 1/-1; }
|
||||
.theme-select { min-width: 84px; }
|
||||
.token-row { align-items: stretch; flex-direction: column; }
|
||||
.token-row button { width: 100%; }
|
||||
.house-climate { margin-top: -18px; }
|
||||
.house-climate-head { align-items: flex-start; }
|
||||
.house-climate-head p { max-width: 230px; }
|
||||
.thermostat-main { grid-template-columns: 1fr auto 1fr; gap: 4px; }
|
||||
.thermostat-main .temperature-control.compact { gap: 7px; }
|
||||
.thermostat-main .target-temp.compact { min-width: 70px; font-size: 26px; }
|
||||
.profile-grid { grid-template-columns: 1fr 1fr; }
|
||||
.profile-grid > strong { grid-column: 1/-1; margin-top: 3px; }
|
||||
.zone-state-line { flex-direction: column; gap: 2px; }
|
||||
.zone-state-line span:last-child { text-align: left; }
|
||||
}
|
||||
|
||||
@media (max-width: 410px) {
|
||||
.brand strong { font-size: 12px; }
|
||||
.hero { align-items: flex-start; flex-direction: column; }
|
||||
.hero-temp { align-self: flex-end; margin-top: -12px; }
|
||||
.two { grid-template-columns: 1fr; }
|
||||
.toolbar-select { min-width: 52px; }
|
||||
.theme-select { min-width: 78px; }
|
||||
}
|
||||
Reference in New Issue
Block a user