diff --git a/web/assets/icons.svg b/web/assets/icons.svg new file mode 100644 index 0000000..926d1e0 --- /dev/null +++ b/web/assets/icons.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/assets/mountain-lake-dark.jpg b/web/assets/mountain-lake-dark.jpg new file mode 100644 index 0000000..5200d87 Binary files /dev/null and b/web/assets/mountain-lake-dark.jpg differ diff --git a/web/assets/mountain-lake-light.jpg b/web/assets/mountain-lake-light.jpg new file mode 100644 index 0000000..a44c5f0 Binary files /dev/null and b/web/assets/mountain-lake-light.jpg differ diff --git a/web/css/base.css b/web/css/base.css new file mode 100644 index 0000000..d7da39c --- /dev/null +++ b/web/css/base.css @@ -0,0 +1,293 @@ +:root { + color-scheme: light; + --page: #dcecf2; + --page-deep: #b7d4df; + --glass: rgba(244, 251, 253, 0.58); + --glass-strong: rgba(248, 253, 255, 0.76); + --glass-soft: rgba(255, 255, 255, 0.34); + --line: rgba(255, 255, 255, 0.72); + --line-soft: rgba(63, 104, 121, 0.14); + --text: #17313e; + --muted: #617b87; + --faint: #8ca0a9; + --blue: #198ed1; + --blue-deep: #096ca7; + --blue-soft: rgba(25, 142, 209, 0.14); + --green: #20a66a; + --green-soft: rgba(32, 166, 106, 0.14); + --orange: #e98a2c; + --orange-soft: rgba(233, 138, 44, 0.16); + --red: #d95665; + --red-soft: rgba(217, 86, 101, 0.14); + --yellow: #d7a823; + --shadow: 0 24px 70px rgba(42, 86, 105, 0.2), 0 2px 10px rgba(43, 82, 98, 0.08); + --shadow-soft: 0 10px 32px rgba(39, 80, 96, 0.12); + --radius-xl: 28px; + --radius-lg: 21px; + --radius-md: 16px; + --blur: 26px; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --page: #0d161d; + --page-deep: #172b39; + --glass: rgba(24, 38, 49, 0.68); + --glass-strong: rgba(28, 44, 56, 0.86); + --glass-soft: rgba(255, 255, 255, 0.045); + --line: rgba(193, 226, 240, 0.16); + --line-soft: rgba(188, 220, 234, 0.1); + --text: #eff8fb; + --muted: #9cb4bf; + --faint: #6e8793; + --blue: #56b9f0; + --blue-deep: #178ccf; + --blue-soft: rgba(69, 168, 225, 0.14); + --green: #52d394; + --green-soft: rgba(48, 193, 119, 0.14); + --orange: #f2a24c; + --orange-soft: rgba(242, 162, 76, 0.15); + --red: #ff7280; + --red-soft: rgba(255, 99, 116, 0.14); + --yellow: #f2c84b; + --shadow: 0 28px 80px rgba(0, 0, 0, 0.36), 0 2px 12px rgba(0, 0, 0, 0.28); + --shadow-soft: 0 12px 34px rgba(0, 0, 0, 0.22); +} + +* { box-sizing: border-box; } + +html, body { min-height: 100%; } + +body { + margin: 0; + color: var(--text); + background: + radial-gradient(circle at 10% 5%, rgba(64, 176, 219, 0.32), transparent 32rem), + radial-gradient(circle at 88% 16%, rgba(73, 144, 225, 0.23), transparent 29rem), + radial-gradient(circle at 58% 92%, rgba(75, 191, 171, 0.2), transparent 32rem), + linear-gradient(145deg, var(--page), var(--page-deep)); + background-attachment: fixed; + -webkit-font-smoothing: antialiased; +} + +button, input { font: inherit; } +button { color: inherit; } + +.ambient { + position: fixed; + inset: 0; + z-index: -1; + overflow: hidden; + pointer-events: none; +} + +.ambient::before, +.ambient::after { + content: ""; + position: absolute; + width: 28rem; + height: 28rem; + border-radius: 50%; + filter: blur(34px); + opacity: 0.34; +} + +.ambient::before { left: -10rem; top: -12rem; background: #62d7e3; } +.ambient::after { right: -12rem; bottom: -15rem; background: #58a0ef; } + +.glass { + background: linear-gradient(145deg, var(--glass-strong), var(--glass)); + border: 1px solid var(--line); + box-shadow: var(--shadow); + backdrop-filter: blur(var(--blur)) saturate(135%); + -webkit-backdrop-filter: blur(var(--blur)) saturate(135%); +} + +.glass-inner { + background: linear-gradient(145deg, var(--glass-soft), rgba(255,255,255,0.015)); + border: 1px solid var(--line-soft); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), var(--shadow-soft); +} + +.eyebrow { + color: var(--muted); + font-size: 0.69rem; + font-weight: 750; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.brand { display: flex; align-items: center; gap: 0.78rem; min-width: 0; } +.brand-mark { + display: grid; + place-items: center; + width: 2.65rem; + height: 2.65rem; + flex: 0 0 auto; + border-radius: 0.9rem; + color: white; + background: linear-gradient(145deg, #45b9ed, #147cba); + box-shadow: 0 8px 20px rgba(18, 126, 184, 0.26), inset 0 1px 0 rgba(255,255,255,0.35); +} +.brand-mark svg { width: 1.42rem; height: 1.42rem; } +.brand-copy { min-width: 0; } +.brand-title { margin: 0; font-size: 1.05rem; line-height: 1.15; letter-spacing: -0.015em; } +.brand-subtitle { margin: 0.18rem 0 0; color: var(--muted); font-size: 0.72rem; } + +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.43rem; + min-height: 1.75rem; + padding: 0.3rem 0.66rem; + border: 1px solid transparent; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.055em; + white-space: nowrap; + text-transform: uppercase; +} +.pill::before { content: ""; width: 0.43rem; height: 0.43rem; border-radius: 50%; background: currentColor; box-shadow: 0 0 0 4px currentColor; opacity: 0.9; } +.pill.good { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 24%, transparent); } +.pill.info { color: var(--blue); background: var(--blue-soft); border-color: color-mix(in srgb, var(--blue) 22%, transparent); } +.pill.warn { color: var(--orange); background: var(--orange-soft); border-color: color-mix(in srgb, var(--orange) 24%, transparent); } +.pill.bad { color: var(--red); background: var(--red-soft); border-color: color-mix(in srgb, var(--red) 24%, transparent); } +.pill.muted { color: var(--muted); background: var(--glass-soft); border-color: var(--line-soft); } +.pill.no-dot::before { display: none; } + +.icon-button, .soft-button, .primary-button, .step-button, .toggle, .segment { + border: 0; + cursor: pointer; + transition: transform 150ms ease, background 150ms ease, border-color 150ms ease, opacity 150ms ease, box-shadow 150ms ease; +} +.icon-button:hover, .soft-button:hover, .primary-button:hover, .step-button:hover, .toggle:hover, .segment:hover { transform: translateY(-1px); } +.icon-button:active, .soft-button:active, .primary-button:active, .step-button:active, .toggle:active, .segment:active { transform: translateY(0) scale(0.98); } +button:focus-visible, input:focus-visible { outline: 3px solid color-mix(in srgb, var(--blue) 32%, transparent); outline-offset: 2px; } +button:disabled { cursor: wait; opacity: 0.55; transform: none !important; } + +.icon-button { + display: grid; + place-items: center; + width: 2.45rem; + height: 2.45rem; + border: 1px solid var(--line-soft); + border-radius: 0.82rem; + background: var(--glass-soft); +} +.icon-button svg { width: 1.12rem; height: 1.12rem; } + +.soft-button, .primary-button { + min-height: 2.55rem; + padding: 0.65rem 1rem; + border-radius: 0.88rem; + font-size: 0.77rem; + font-weight: 750; +} +.soft-button { color: var(--text); background: var(--glass-soft); border: 1px solid var(--line-soft); } +.primary-button { color: white; background: linear-gradient(145deg, #35aae3, #157dbb); box-shadow: 0 9px 24px rgba(14, 119, 177, 0.25); } +.soft-button.danger { color: var(--red); background: var(--red-soft); border-color: color-mix(in srgb, var(--red) 22%, transparent); } + +.stepper { display: flex; align-items: center; justify-content: center; gap: 0.5rem; } +.step-button { + display: grid; + place-items: center; + width: 2.28rem; + height: 2.28rem; + border-radius: 0.78rem; + color: var(--blue); + background: var(--blue-soft); + border: 1px solid color-mix(in srgb, var(--blue) 22%, transparent); + font-size: 1.25rem; + font-weight: 500; + line-height: 1; +} + +.toggle { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + min-width: 5.5rem; + padding: 0.48rem 0.58rem 0.48rem 0.7rem; + border: 1px solid var(--line-soft); + border-radius: 999px; + background: var(--glass-soft); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.04em; +} +.toggle::after { + content: ""; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background: var(--faint); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.3); +} +.toggle[aria-pressed="true"] { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 26%, transparent); } +.toggle[aria-pressed="true"]::after { background: var(--green); box-shadow: 0 0 14px color-mix(in srgb, var(--green) 52%, transparent); } +.toggle.orange[aria-pressed="true"] { color: var(--orange); background: var(--orange-soft); border-color: color-mix(in srgb, var(--orange) 28%, transparent); } +.toggle.orange[aria-pressed="true"]::after { background: var(--orange); box-shadow: 0 0 14px color-mix(in srgb, var(--orange) 48%, transparent); } + +.value { font-variant-numeric: tabular-nums; } +.muted { color: var(--muted); } + +.range { + --progress: 50%; + width: 100%; + height: 1.35rem; + margin: 0; + appearance: none; + background: transparent; + cursor: pointer; +} +.range::-webkit-slider-runnable-track { + height: 0.42rem; + border-radius: 99px; + background: linear-gradient(90deg, var(--blue) var(--progress), var(--line-soft) var(--progress)); +} +.range::-moz-range-track { height: 0.42rem; border-radius: 99px; background: var(--line-soft); } +.range::-moz-range-progress { height: 0.42rem; border-radius: 99px; background: var(--blue); } +.range::-webkit-slider-thumb { + width: 1.35rem; + height: 1.35rem; + margin-top: -0.465rem; + appearance: none; + border: 0.26rem solid var(--glass-strong); + border-radius: 50%; + background: var(--blue); + box-shadow: 0 3px 12px rgba(14, 118, 177, 0.36); +} +.range::-moz-range-thumb { + width: 0.92rem; + height: 0.92rem; + border: 0.25rem solid var(--glass-strong); + border-radius: 50%; + background: var(--blue); + box-shadow: 0 3px 12px rgba(14, 118, 177, 0.36); +} + +.toast-region { position: fixed; right: 1rem; bottom: 1rem; z-index: 50; display: grid; gap: 0.55rem; } +.toast { + max-width: min(23rem, calc(100vw - 2rem)); + padding: 0.78rem 1rem; + border: 1px solid var(--line); + border-radius: 0.9rem; + background: var(--glass-strong); + box-shadow: var(--shadow-soft); + backdrop-filter: blur(18px); + font-size: 0.78rem; + animation: toast-in 180ms ease both; +} +.toast.error { color: var(--red); border-color: color-mix(in srgb, var(--red) 28%, transparent); } +@keyframes toast-in { from { transform: translateY(0.55rem); opacity: 0; } } + +.skeleton { color: transparent !important; border-radius: 0.4rem; background: linear-gradient(100deg, var(--line-soft) 35%, var(--glass-soft) 50%, var(--line-soft) 65%); background-size: 200% 100%; animation: shimmer 1.3s infinite; } +@keyframes shimmer { to { background-position-x: -200%; } } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } +} diff --git a/web/css/panel.css b/web/css/panel.css new file mode 100644 index 0000000..7bbe5d7 --- /dev/null +++ b/web/css/panel.css @@ -0,0 +1,799 @@ +:root { + --panel-ink: #08265d; + --panel-muted: #315d91; + --tile: rgba(219, 241, 253, 0.22); + --tile-strong: rgba(244, 251, 255, 0.4); + --tile-line: rgba(255, 255, 255, 0.9); + --tile-line-soft: rgba(193, 226, 244, 0.5); + --tile-shadow: 0 14px 32px rgba(23, 75, 111, 0.16), inset 0 1px 0 rgba(255,255,255,0.92), inset 1px 0 0 rgba(255,255,255,0.36); + --panel-glass: rgba(211, 235, 248, 0.35); + --panel-edge: rgba(255, 255, 255, 0.92); + --blue-halo: rgba(25, 139, 255, 0.34); + --green-halo: rgba(30, 205, 120, 0.34); + --orange-halo: rgba(255, 126, 46, 0.3); +} + +:root[data-theme="dark"] { + --panel-ink: #edf8ff; + --panel-muted: #9fc5df; + --tile: rgba(28, 48, 65, 0.46); + --tile-strong: rgba(45, 71, 89, 0.54); + --tile-line: rgba(194, 229, 247, 0.3); + --tile-line-soft: rgba(114, 176, 207, 0.22); + --tile-shadow: 0 16px 38px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(235,249,255,0.14), inset 1px 0 0 rgba(190,228,247,0.08); + --panel-glass: rgba(13, 29, 41, 0.58); + --panel-edge: rgba(187, 225, 244, 0.32); + --blue-halo: rgba(30, 161, 255, 0.42); + --green-halo: rgba(25, 225, 129, 0.35); + --orange-halo: rgba(255, 126, 39, 0.34); +} + +html { min-width: 320px; } +body { + min-height: 100vh; + padding: clamp(0.7rem, 2vw, 2rem); + color: var(--panel-ink); + background: + radial-gradient(ellipse at 8% 10%, rgba(244,253,255,.95) 0 7%, transparent 24%), + radial-gradient(ellipse at 73% -8%, rgba(255,249,239,.85) 0 10%, transparent 29%), + radial-gradient(ellipse at 93% 32%, rgba(90,152,125,.52) 0 10%, transparent 28%), + radial-gradient(ellipse at 12% 80%, rgba(65,132,170,.46) 0 12%, transparent 31%), + linear-gradient(115deg, rgba(184,222,239,.94), rgba(213,236,247,.82) 37%, rgba(239,232,217,.82) 66%, rgba(129,181,183,.88)); + background-attachment: fixed; + overflow-x: hidden; + isolation: isolate; +} + +:root[data-theme="dark"] body { + background: + radial-gradient(ellipse at 6% 2%, rgba(54,112,145,.44) 0 8%, transparent 28%), + radial-gradient(ellipse at 78% -5%, rgba(138,103,60,.31) 0 9%, transparent 28%), + radial-gradient(ellipse at 94% 36%, rgba(28,91,78,.44) 0 12%, transparent 31%), + radial-gradient(ellipse at 15% 87%, rgba(17,84,122,.42) 0 13%, transparent 33%), + linear-gradient(118deg, #081723, #122d3d 40%, #292c30 68%, #0c262c); +} + +body::before { + content: ""; + position: fixed; + inset: -7rem; + z-index: -3; + background: + linear-gradient(90deg, transparent 0 13%, rgba(255,255,255,.55) 13% 16%, transparent 16% 34%, rgba(255,255,255,.35) 34% 37%, transparent 37% 68%, rgba(255,255,255,.42) 68% 71%, transparent 71%), + linear-gradient(0deg, transparent 0 58%, rgba(228,244,251,.48) 58% 61%, transparent 61%), + radial-gradient(ellipse at 50% 114%, rgba(36,111,68,.48) 0 17%, transparent 47%); + filter: blur(26px); + transform: scale(1.05); +} + +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; + background: + radial-gradient(circle at 4% 22%, rgba(255,255,255,.88) 0 .8%, transparent 5%), + radial-gradient(circle at 19% 74%, rgba(246,253,255,.68) 0 1.2%, transparent 7%), + radial-gradient(circle at 62% 34%, rgba(255,241,210,.63) 0 1.3%, transparent 8%), + radial-gradient(circle at 89% 13%, rgba(244,253,255,.66) 0 1%, transparent 6%), + radial-gradient(circle at 82% 82%, rgba(209,238,228,.48) 0 1.5%, transparent 8%); + filter: blur(8px); + opacity: .86; +} + +.ambient::before { width: 42rem; height: 42rem; left: -14rem; top: -18rem; background: #c9f2ff; opacity: .58; filter: blur(54px); } +.ambient::after { width: 46rem; height: 46rem; right: -14rem; bottom: -21rem; background: #ebd5a7; opacity: .38; filter: blur(64px); } +:root[data-theme="dark"] .ambient::before { background: #147db5; opacity: .24; } +:root[data-theme="dark"] .ambient::after { background: #846630; opacity: .2; } + +.climate-panel { + position: relative; + isolation: isolate; + overflow: hidden; + width: min(100%, 104rem); + min-height: calc(100vh - clamp(1.4rem, 4vw, 4rem)); + margin: 0 auto; + padding: clamp(0.65rem, 1.25vw, 1.25rem); + border: 1px solid var(--panel-edge); + outline: 1px solid rgba(255,255,255,.5); + outline-offset: -5px; + border-radius: 1.5rem; + background: + linear-gradient(125deg, rgba(255,255,255,.34), transparent 29%, rgba(255,255,255,.11) 67%, rgba(214,235,247,.2)), + var(--panel-glass); + box-shadow: 0 32px 90px rgba(20,60,89,.3), 0 2px 10px rgba(255,255,255,.35), inset 0 1px 0 rgba(255,255,255,.95), inset 0 -1px 0 rgba(114,162,192,.23); + backdrop-filter: blur(34px) saturate(148%); + -webkit-backdrop-filter: blur(34px) saturate(148%); +} + +:root[data-theme="dark"] .climate-panel { outline-color: rgba(203,235,250,.12); box-shadow: 0 38px 100px rgba(0,0,0,.48), 0 0 42px rgba(21,120,171,.1), inset 0 1px 0 rgba(224,246,255,.2), inset 0 -1px 0 rgba(0,0,0,.35); } + +.climate-panel::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + border-radius: inherit; + background: + linear-gradient(112deg, rgba(255,255,255,.44) 0, rgba(255,255,255,.08) 17%, transparent 38%), + radial-gradient(ellipse at 52% -16%, rgba(255,255,255,.54), transparent 48%), + radial-gradient(ellipse at 100% 100%, rgba(102,178,219,.13), transparent 42%); +} + +.climate-panel::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: .2; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); + mix-blend-mode: soft-light; +} + +.climate-panel > * { position: relative; z-index: 1; } + +.glass-tile, .glass-section { + position: relative; + isolation: isolate; + overflow: hidden; + border: 1px solid var(--tile-line); + outline: 1px solid var(--tile-line-soft); + outline-offset: -3px; + background: + linear-gradient(132deg, rgba(255,255,255,.38), rgba(255,255,255,.06) 35%, transparent 63%), + linear-gradient(145deg, var(--tile-strong), var(--tile)); + box-shadow: var(--tile-shadow); + backdrop-filter: blur(23px) saturate(145%); + -webkit-backdrop-filter: blur(23px) saturate(145%); +} + +.glass-tile::before, .glass-section::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + border-radius: inherit; + background: linear-gradient(115deg, rgba(255,255,255,.35), transparent 25%, transparent 72%, rgba(255,255,255,.08)); +} +.glass-tile > *, .glass-section > * { position: relative; z-index: 1; } + +.speed-card::after, .temperature-card::after, .power-card::after, .heater-card::after { + content: ""; + position: absolute; + z-index: 0; + width: 13rem; + height: 13rem; + right: -5rem; + bottom: -7rem; + border-radius: 50%; + pointer-events: none; + filter: blur(17px); +} +.speed-card::after { background: var(--blue-halo); } +.temperature-card::after { width: 18rem; background: linear-gradient(90deg, var(--blue-halo), var(--orange-halo)); } +.power-card::after { background: var(--green-halo); opacity: .64; } +.heater-card::after { background: var(--orange-halo); opacity: .58; } + +.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0 0.65rem 0.7rem; } +.identity, .header-tools { display: flex; align-items: center; gap: 0.65rem; } +.identity h1 { margin: 0 0.35rem 0 0; font-size: clamp(1.8rem, 3.1vw, 3rem); line-height: 1; letter-spacing: -0.045em; } +.climate-panel .pill { min-height: 2.05rem; padding: .38rem .82rem; border-color: rgba(255,255,255,.62); box-shadow: inset 0 1px 0 rgba(255,255,255,.72), 0 6px 18px rgba(26,75,108,.1); backdrop-filter: blur(14px); } +.climate-panel .pill.good { box-shadow: 0 0 21px rgba(28,204,117,.18), inset 0 1px 0 rgba(255,255,255,.74); } +.climate-panel .pill.warn { box-shadow: 0 0 21px rgba(242,145,56,.17), inset 0 1px 0 rgba(255,255,255,.74); } +.climate-panel .pill.info { box-shadow: 0 0 21px rgba(31,145,236,.16), inset 0 1px 0 rgba(255,255,255,.74); } +.climate-panel .icon-button { border-color: var(--tile-line); background: linear-gradient(145deg, var(--tile-strong), var(--tile)); box-shadow: var(--tile-shadow); backdrop-filter: blur(16px); } +.header-tools { justify-content: flex-end; } +.ventilation-badge { display: flex; align-items: center; gap: 0.7rem; min-width: 22rem; padding: 0.55rem 1rem; border-radius: 1rem; } +.ventilation-badge svg { width: 2.5rem; color: #1977d2; filter: drop-shadow(0 0 9px rgba(26,128,226,.34)); } +.ventilation-badge strong, .ventilation-badge small { display: block; } +.ventilation-badge strong { font-size: 0.9rem; } +.ventilation-badge small { margin-top: 0.1rem; color: var(--panel-muted); font-size: 0.68rem; } + +.primary-controls { display: grid; grid-template-columns: 2.05fr 2.05fr .95fr .95fr; gap: 0.75rem; } +.control-card, .switch-card { min-height: 17.6rem; border-radius: 1.15rem; padding: 1rem; } +.control-card { display: flex; flex-direction: column; } +.card-title { display: flex; align-items: flex-start; gap: 0.72rem; } +.card-title.compact { align-items: center; } +.card-title h2 { margin: 0; font-size: clamp(1rem, 1.55vw, 1.28rem); letter-spacing: -0.02em; } +.card-title p { margin: 0.18rem 0 0; color: var(--panel-muted); font-size: 0.72rem; } +.feature-icon { display: grid; place-items: center; flex: 0 0 auto; width: 2.1rem; height: 2.1rem; color: #1389f4; font-size: 1.75rem; font-weight: 800; line-height: 1; text-shadow: 0 0 16px rgba(20,139,244,.46); } +.feature-icon.green { color: #08a95a; text-shadow: 0 0 20px rgba(20,210,112,.62); } +.feature-icon.orange { color: #f06419; text-shadow: 0 0 18px rgba(255,105,29,.46); } +.fan-icon { font-size: 2.2rem; } +.hero-control { display: grid; grid-template-columns: 4.3rem 1fr 4.3rem; align-items: center; gap: 1rem; margin: auto 0 0.75rem; } +.hero-value { text-align: center; font-size: clamp(3.2rem, 5vw, 4.35rem); line-height: 1; letter-spacing: -0.06em; font-variant-numeric: tabular-nums; } +.hero-step { + display: grid; + place-items: center; + width: 4.3rem; + height: 4.3rem; + border: 1.5px solid rgba(255,255,255,.85); + border-radius: 1rem; + color: #176dbb; + background: linear-gradient(145deg, rgba(245,252,255,.42), rgba(191,224,244,.2)); + box-shadow: 0 10px 24px rgba(35,92,132,.15), inset 0 1px 0 rgba(255,255,255,.9), 0 0 0 1px rgba(185,226,248,.24); + cursor: pointer; + font-size: 2.3rem; + font-weight: 500; + transition: transform .15s ease, filter .15s ease; +} +.hero-step.primary { color: white; background: linear-gradient(145deg, #57b5ff, #1376e4 76%); box-shadow: 0 12px 28px rgba(16,103,220,.35), 0 0 26px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.8), inset 0 -2px 5px rgba(0,67,156,.24); } +.hero-step:hover { transform: translateY(-2px); filter: brightness(1.08); box-shadow: 0 14px 32px rgba(25,112,219,.35), 0 0 32px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.78); } +.hero-step:active { transform: scale(.98); } +.speed-range { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); clip-path: inset(50%); white-space: nowrap; } +.speed-segments { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.16rem; } +.speed-segments button { height: 1.12rem; padding: 0; border: 1px solid rgba(255,255,255,.88); border-radius: .55rem; background: linear-gradient(180deg, rgba(173,207,229,.32), rgba(98,151,190,.2)); box-shadow: inset 0 1px 2px rgba(27,70,105,.08), 0 2px 5px rgba(32,86,126,.08); cursor: pointer; transition: background .12s ease, box-shadow .12s ease, transform .12s ease; } +.speed-segments button:hover { transform: translateY(-1px); } +.speed-segments button.active { background: linear-gradient(90deg, #0e82ed, #37b8ff); box-shadow: 0 0 17px rgba(25,143,247,.56), inset 0 1px 0 rgba(255,255,255,.74), inset 0 -2px 4px rgba(0,84,182,.22); } +.speed-labels, .temperature-labels { display: flex; justify-content: space-between; margin-top: 0.45rem; padding: 0 .55rem; color: var(--panel-muted); font-size: 0.68rem; } +.temperature-range { height: 1.45rem; } +.temperature-range::-webkit-slider-runnable-track { height: .78rem; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); border: 1px solid rgba(255,255,255,.92); box-shadow: 0 0 16px rgba(37,153,239,.24), 0 0 14px rgba(255,114,41,.14), inset 0 1px 2px rgba(255,255,255,.5); } +.temperature-range::-webkit-slider-thumb { width: 1.9rem; height: 1.9rem; margin-top: -.58rem; border-width: .28rem; box-shadow: 0 0 0 2px rgba(255,255,255,.55), 0 0 22px rgba(29,139,241,.58), 0 5px 13px rgba(17,79,143,.3); } +.temperature-range::-moz-range-track { height: .78rem; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); } +.temperature-range::-moz-range-progress { background: transparent; } +.switch-card { display: flex; flex-direction: column; justify-content: space-between; } +.glass-switch { + width: 100%; min-height: 6rem; border: 1.5px solid rgba(255,255,255,.9); border-radius: 1rem; color: var(--panel-ink); background: rgba(199,222,235,.28); box-shadow: var(--tile-shadow); cursor: pointer; font-size: 1.75rem; font-weight: 850; + text-shadow: 0 1px 0 rgba(255,255,255,.35); + transition: transform .15s ease, background .2s ease, box-shadow .2s ease, filter .2s ease; +} +.glass-switch:hover { transform: translateY(-2px); filter: brightness(1.06); } +.glass-switch[aria-pressed="true"].green { background: linear-gradient(145deg, rgba(145,255,198,.9), rgba(38,194,113,.62)); border-color: rgba(213,255,232,.96); box-shadow: 0 14px 32px rgba(11,153,81,.3), 0 0 32px var(--green-halo), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 10px rgba(0,125,62,.18); } +.glass-switch[aria-pressed="true"].orange { background: linear-gradient(145deg, rgba(255,202,130,.88), rgba(245,112,40,.58)); border-color: rgba(255,236,213,.95); box-shadow: 0 14px 32px rgba(210,91,25,.3), 0 0 30px var(--orange-halo), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -4px 10px rgba(168,54,0,.17); } + +.content-section { margin-top: 0.75rem; padding: 0.7rem; border-radius: 1.15rem; } +.section-title { display: flex; align-items: center; gap: 0.65rem; min-height: 2.35rem; padding: 0 0.35rem 0.5rem; } +.section-title > div { display: flex; align-items: center; gap: 0.55rem; } +.section-title h2 { margin: 0; font-size: clamp(1rem, 1.55vw, 1.28rem); } +.section-icon { display: grid; place-items: center; width: 1.8rem; height: 1.8rem; color: #207bcc; font-size: 1.55rem; text-shadow: 0 0 14px rgba(26,124,210,.38); } +.section-icon.leaf { color: #069c51; transform: rotate(-18deg); text-shadow: 0 0 15px rgba(8,173,88,.42); } +.section-title .updated { margin-left: auto; color: var(--panel-muted); font-size: 0.7rem; } +.air-grid { display: grid; grid-template-columns: 1.55fr 1fr .9fr 1fr 1fr 1.28fr; gap: 0.55rem; } +.metric-card { min-width: 0; min-height: 9.4rem; padding: 0.85rem; border-radius: 1rem; } +.metric-head, .metric-heading { display: flex; align-items: center; gap: 0.55rem; color: var(--panel-muted); font-size: 0.76rem; } +.metric-head { justify-content: space-between; } +.metric-icon { color: #137edc; font-size: 1.7rem; line-height: 1; text-shadow: 0 0 14px rgba(20,124,221,.34); } +.metric-icon.dots { letter-spacing: -.2rem; } +.metric-card > strong { display: block; margin-top: 0.75rem; font-size: clamp(1.55rem, 2.4vw, 2.2rem); line-height: 1; font-variant-numeric: tabular-nums; white-space: nowrap; } +.metric-card strong small { color: var(--panel-muted); font-size: .63rem; font-weight: 600; } +.quality-chip { display: inline-flex; justify-content: center; padding: .35rem .72rem; border: 1px solid rgba(109,237,167,.82); border-radius: 999px; color: #076d3d; background: linear-gradient(145deg, rgba(179,255,214,.7), rgba(92,218,151,.48)); box-shadow: 0 0 17px rgba(33,203,116,.24), inset 0 1px 0 rgba(255,255,255,.88); font-size: .66rem; font-weight: 750; } +:root[data-theme="dark"] .quality-chip { color: #b9ffda; } +.quality-chip.small { margin-top: .8rem; min-width: 5.5rem; } +.co2-card > p { height: 1rem; margin: .2rem 0 .55rem; color: var(--panel-muted); font-size: .58rem; overflow: hidden; } +.meter { height: .82rem; border: 1px solid rgba(255,255,255,.8); border-radius: 999px; background: rgba(91,145,181,.18); overflow: hidden; } +.meter i { display: block; height: 100%; width: 0; border-radius: inherit; background: linear-gradient(90deg, #08a350, #36dc83); box-shadow: 0 0 18px rgba(18,199,104,.58), inset 0 1px 0 rgba(255,255,255,.64); transition: width .35s ease; } +.meter-labels { display: flex; justify-content: space-between; margin-top: .25rem; color: var(--panel-muted); font-size: .55rem; } +.filter-line { display: flex; align-items: center; gap: .45rem; margin-top: 1rem; } +.filter-meter { flex: 1; } +.filter-meter i { background: linear-gradient(90deg, #176dec, #36c1ff); box-shadow: 0 0 18px rgba(31,144,247,.55), inset 0 1px 0 rgba(255,255,255,.58); } +.filter-line > span { font-size: .65rem; font-weight: 750; } + +.bottom-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 0.75rem; } +.schedule-layout { display: grid; grid-template-columns: 1.35fr 1fr; gap: 0.7rem; } +.schedule-times { display: grid; grid-template-columns: 1fr 1px 1fr; gap: 1rem; align-items: center; min-height: 8.2rem; padding: 1rem 1.3rem; border-radius: 1rem; } +.schedule-times > i { width: 1px; height: 80%; background: rgba(49,93,145,.25); } +.schedule-times div { display: grid; gap: .22rem; } +.schedule-times span, .schedule-times small { color: var(--panel-muted); font-size: .68rem; } +.schedule-times strong { font-size: clamp(1.65rem, 2.8vw, 2.3rem); font-variant-numeric: tabular-nums; } +.schedule-buttons { display: grid; gap: .55rem; } +.schedule-button { display: grid; grid-template-columns: 2.4rem 1fr; align-items: center; gap: .65rem; min-height: 3.8rem; padding: .55rem .8rem; border: 1.5px solid; border-radius: .9rem; text-align: left; cursor: pointer; } +.schedule-button strong, .schedule-button small { display: block; } +.schedule-button strong { font-size: .84rem; } +.schedule-button small { margin-top: .12rem; font-size: .62rem; } +.schedule-button.auto { color: #096bda; border-color: rgba(83,176,255,.9); background: linear-gradient(145deg, rgba(224,244,255,.67), rgba(133,201,250,.26)); box-shadow: 0 9px 24px rgba(23,113,212,.19), 0 0 20px rgba(42,153,243,.13), inset 0 1px 0 rgba(255,255,255,.92); } +.schedule-button.pause { color: #a73512; border-color: rgba(250,174,102,.9); background: linear-gradient(145deg, rgba(255,240,217,.72), rgba(246,177,107,.28)); box-shadow: 0 9px 24px rgba(202,104,44,.17), 0 0 20px rgba(244,131,50,.12), inset 0 1px 0 rgba(255,255,255,.92); } +.schedule-button:hover { filter: brightness(1.06); transform: translateY(-1px); } +.action-symbol { font-size: 2rem; font-weight: 800; text-align: center; } +.mode-banner { display: flex; gap: .4rem; align-items: center; margin-top: .5rem; padding: .45rem .7rem; border-radius: .65rem; color: var(--panel-muted); background: rgba(255,255,255,.14); font-size: .62rem; } +.mode-banner small::before { content: "· "; } +.extras-grid { display: grid; grid-template-columns: .9fr 1.3fr; gap: .55rem; } +.extra-card { display: flex; align-items: center; gap: .8rem; min-height: 8.2rem; padding: 1rem; border-radius: 1rem; } +.extra-icon { color: #267fd3; font-size: 3.4rem; line-height: 1; text-shadow: 0 0 19px rgba(33,126,211,.32); } +.extra-icon.wind { font-size: 3.8rem; transform: rotate(90deg); } +.extra-card small, .extra-card strong, .extra-card em { display: block; } +.extra-card small { color: var(--panel-muted); font-size: .7rem; } +.extra-card strong { margin-top: .18rem; font-size: clamp(1.3rem, 2.2vw, 2rem); font-style: normal; } +.extra-card em { margin-top: .18rem; color: var(--panel-muted); font-size: .62rem; font-style: normal; } +.mode-card { display: grid; grid-template-columns: auto 1fr; } +.mode-card strong { font-size: .85rem; } +.segments { grid-column: 1 / -1; display: flex; gap: .3rem; } +.segment { flex: 1; padding: .42rem; border: 1px solid rgba(255,255,255,.55); border-radius: .55rem; color: var(--panel-muted); background: rgba(255,255,255,.14); cursor: pointer; font-size: .58rem; } +.segment.active { color: white; background: linear-gradient(145deg, #45a7f4, #1976d2); } +.micro-settings { display: flex; align-items: center; gap: .65rem; margin-top: .55rem; padding: 0 .25rem; color: var(--panel-muted); font-size: .58rem; } +.micro-settings > span:nth-child(2) { margin-right: auto; } +.micro-toggle { padding: .32rem .55rem; border: 1px solid var(--tile-line); border-radius: 999px; color: var(--panel-muted); background: var(--tile); cursor: pointer; font-size: .56rem; } +.micro-toggle[aria-pressed="true"] { color: #087848; background: rgba(105,222,160,.38); } +.demo-note { display: block; margin-top: .55rem; color: var(--panel-muted); text-align: center; font-size: .62rem; } + +/* Dark aeroglass keeps the same optical depth, with colder edges and + brighter local halos so active controls do not disappear into the glass. */ +:root[data-theme="dark"] body::after { opacity: .42; mix-blend-mode: screen; } +:root[data-theme="dark"] .climate-panel::before { + background: + linear-gradient(112deg, rgba(205,239,255,.14), rgba(255,255,255,.025) 21%, transparent 42%), + radial-gradient(ellipse at 52% -16%, rgba(112,202,244,.13), transparent 50%), + radial-gradient(ellipse at 100% 100%, rgba(23,139,186,.12), transparent 44%); +} +:root[data-theme="dark"] .glass-tile, +:root[data-theme="dark"] .glass-section { + background: + linear-gradient(132deg, rgba(211,240,253,.105), rgba(255,255,255,.018) 38%, transparent 65%), + linear-gradient(145deg, var(--tile-strong), var(--tile)); +} +:root[data-theme="dark"] .glass-tile::before, +:root[data-theme="dark"] .glass-section::before { + background: linear-gradient(115deg, rgba(218,243,255,.12), transparent 26%, transparent 74%, rgba(80,181,222,.045)); +} +:root[data-theme="dark"] .hero-step { + color: #8ed3ff; + border-color: rgba(184,226,247,.28); + background: linear-gradient(145deg, rgba(89,137,166,.2), rgba(20,48,67,.23)); + box-shadow: 0 12px 28px rgba(0,0,0,.28), inset 0 1px 0 rgba(224,246,255,.14), 0 0 0 1px rgba(60,157,205,.08); +} +:root[data-theme="dark"] .hero-step.primary { + color: white; + background: linear-gradient(145deg, #42b8ff, #096bd3 78%); + box-shadow: 0 13px 32px rgba(0,65,139,.5), 0 0 30px var(--blue-halo), inset 0 1px 0 rgba(220,248,255,.52), inset 0 -3px 7px rgba(0,27,91,.38); +} +:root[data-theme="dark"] .speed-segments button { border-color: rgba(192,226,244,.25); background: linear-gradient(180deg, rgba(91,136,162,.23), rgba(24,55,74,.28)); } +:root[data-theme="dark"] .speed-segments button.active { border-color: rgba(117,207,255,.55); background: linear-gradient(90deg, #0877df, #20b7ff); box-shadow: 0 0 20px rgba(24,156,255,.66), inset 0 1px 0 rgba(223,248,255,.42); } +:root[data-theme="dark"] .glass-switch { color: #ddecf4; border-color: rgba(193,228,245,.25); background: linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); text-shadow: 0 1px 2px rgba(0,0,0,.4); } +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green { color: #eafff4; border-color: rgba(118,255,185,.54); background: linear-gradient(145deg, rgba(56,221,137,.76), rgba(8,120,69,.67)); box-shadow: 0 15px 36px rgba(0,0,0,.3), 0 0 38px var(--green-halo), inset 0 1px 0 rgba(218,255,236,.48); } +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { color: #fff5e9; border-color: rgba(255,185,118,.53); background: linear-gradient(145deg, rgba(244,145,61,.76), rgba(143,58,14,.68)); box-shadow: 0 15px 36px rgba(0,0,0,.3), 0 0 36px var(--orange-halo), inset 0 1px 0 rgba(255,237,216,.48); } +:root[data-theme="dark"] .quality-chip { color: #caffdf; border-color: rgba(89,232,158,.42); background: linear-gradient(145deg, rgba(45,186,113,.35), rgba(11,101,60,.3)); box-shadow: 0 0 20px rgba(20,218,118,.18), inset 0 1px 0 rgba(210,255,232,.16); } +:root[data-theme="dark"] .schedule-button.auto { color: #a8ddff; border-color: rgba(76,176,241,.48); background: linear-gradient(145deg, rgba(38,125,187,.29), rgba(15,61,91,.28)); box-shadow: 0 10px 27px rgba(0,0,0,.23), 0 0 24px rgba(27,150,237,.13), inset 0 1px 0 rgba(211,241,255,.13); } +:root[data-theme="dark"] .schedule-button.pause { color: #ffc18c; border-color: rgba(242,147,70,.48); background: linear-gradient(145deg, rgba(167,82,27,.3), rgba(73,40,25,.27)); box-shadow: 0 10px 27px rgba(0,0,0,.23), 0 0 24px rgba(244,117,35,.12), inset 0 1px 0 rgba(255,230,207,.12); } +:root[data-theme="dark"] .meter { border-color: rgba(200,232,247,.24); background: rgba(3,22,34,.34); box-shadow: inset 0 2px 5px rgba(0,0,0,.2); } +:root[data-theme="dark"] .micro-toggle { border-color: rgba(190,226,244,.2); background: rgba(76,117,141,.15); } +:root[data-theme="dark"] .micro-toggle[aria-pressed="true"] { color: #b8ffda; border-color: rgba(67,213,139,.35); background: rgba(21,149,85,.26); box-shadow: 0 0 15px rgba(20,208,114,.12); } + +/* Photographic aeroglass refinement. The backdrop already contains optical + blur, so the UI only adds a restrained haze instead of whitening the scene. */ +:root { + --tile: rgba(214, 238, 251, 0.14); + --tile-strong: rgba(247, 252, 255, 0.27); + --tile-line: rgba(255, 255, 255, 0.82); + --tile-line-soft: rgba(206, 235, 249, 0.43); + --panel-glass: rgba(200, 228, 244, 0.2); + --blue-halo: rgba(23, 137, 248, 0.16); + --green-halo: rgba(28, 196, 113, 0.16); + --orange-halo: rgba(246, 126, 50, 0.14); +} +:root[data-theme="dark"] { + --tile: rgba(16, 37, 51, 0.28); + --tile-strong: rgba(52, 78, 96, 0.31); + --tile-line: rgba(203, 234, 248, 0.25); + --tile-line-soft: rgba(114, 176, 207, 0.16); + --panel-glass: rgba(7, 22, 33, 0.48); + --blue-halo: rgba(29, 153, 245, 0.22); + --green-halo: rgba(27, 210, 121, 0.19); + --orange-halo: rgba(246, 125, 46, 0.18); +} +body { + background-color: #b7d5e3; + background-image: + linear-gradient(118deg, rgba(180,220,239,.16), rgba(238,247,251,.04) 45%, rgba(247,226,204,.1)), + url("../assets/mountain-lake-light.jpg"); + background-position: center; + background-size: cover; + background-repeat: no-repeat; +} +:root[data-theme="dark"] body { + background-color: #091722; + background-image: + linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)), + url("../assets/mountain-lake-dark.jpg"); + background-position: center; + background-size: cover; +} +body::before { + inset: 0; + background: linear-gradient(110deg, rgba(214,242,253,.16), transparent 35%, rgba(255,240,218,.1) 76%, transparent); + filter: none; + transform: none; + opacity: .75; +} +body::after { + background: + radial-gradient(circle at 11% 18%, rgba(255,255,255,.33) 0 .5%, transparent 5%), + radial-gradient(circle at 64% 23%, rgba(255,244,218,.28) 0 .6%, transparent 6%), + radial-gradient(circle at 91% 79%, rgba(225,250,237,.22) 0 .8%, transparent 7%); + filter: blur(11px); + opacity: .5; +} +:root[data-theme="dark"] body::before { background: linear-gradient(115deg, rgba(29,104,142,.1), transparent 40%, rgba(110,76,42,.08)); opacity: .7; } +:root[data-theme="dark"] body::after { opacity: .26; } +.ambient::before { opacity: .14; filter: blur(76px); } +.ambient::after { opacity: .11; filter: blur(82px); } +:root[data-theme="dark"] .ambient::before { opacity: .12; } +:root[data-theme="dark"] .ambient::after { opacity: .08; } +.climate-panel { + background: + linear-gradient(125deg, rgba(255,255,255,.22), transparent 31%, rgba(255,255,255,.055) 69%, rgba(196,224,240,.1)), + var(--panel-glass); + box-shadow: 0 27px 68px rgba(22,66,95,.24), 0 2px 8px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -1px 0 rgba(113,163,192,.17); + backdrop-filter: blur(13px) saturate(124%); + -webkit-backdrop-filter: blur(13px) saturate(124%); +} +.climate-panel::before { + background: + linear-gradient(112deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 18%, transparent 39%), + radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.25), transparent 47%); +} +.climate-panel::after { opacity: .1; } +:root[data-theme="dark"] .climate-panel { box-shadow: 0 33px 78px rgba(0,0,0,.43), 0 0 28px rgba(19,112,158,.06), inset 0 1px 0 rgba(221,243,252,.17), inset 0 -1px 0 rgba(0,0,0,.28); } +:root[data-theme="dark"] .climate-panel::before { background: linear-gradient(112deg, rgba(203,238,253,.1), transparent 24%, transparent 72%, rgba(71,163,202,.035)); } +.glass-tile, .glass-section { + background: + linear-gradient(132deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 35%, transparent 64%), + linear-gradient(145deg, var(--tile-strong), var(--tile)); + box-shadow: 0 10px 25px rgba(24,72,105,.12), inset 0 1px 0 rgba(255,255,255,.78), inset 1px 0 0 rgba(255,255,255,.24); + backdrop-filter: blur(16px) saturate(127%); + -webkit-backdrop-filter: blur(16px) saturate(127%); +} +.glass-tile::before, .glass-section::before { opacity: .62; } +:root[data-theme="dark"] .glass-tile, +:root[data-theme="dark"] .glass-section { + background: + linear-gradient(132deg, rgba(213,240,252,.075), rgba(255,255,255,.012) 38%, transparent 65%), + linear-gradient(145deg, var(--tile-strong), var(--tile)); + box-shadow: 0 13px 29px rgba(0,0,0,.26), inset 0 1px 0 rgba(226,246,255,.11), inset 1px 0 0 rgba(186,226,245,.055); +} +.speed-card::after, .temperature-card::after, .power-card::after, .heater-card::after { filter: blur(32px); opacity: .48; } +.power-card::after { opacity: .38; } +.heater-card::after { opacity: .34; } +.hero-step.primary { box-shadow: 0 10px 23px rgba(16,103,220,.25), 0 0 15px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.76), inset 0 -2px 5px rgba(0,67,156,.2); } +.hero-step:hover { box-shadow: 0 12px 27px rgba(25,112,219,.27), 0 0 19px var(--blue-halo), inset 0 1px 0 rgba(255,255,255,.75); } +.speed-segments button.active { box-shadow: 0 0 9px rgba(25,143,247,.34), inset 0 1px 0 rgba(255,255,255,.66), inset 0 -2px 4px rgba(0,84,182,.18); } +.temperature-range::-webkit-slider-runnable-track { box-shadow: 0 0 8px rgba(37,153,239,.15), 0 0 7px rgba(255,114,41,.09), inset 0 1px 2px rgba(255,255,255,.46); } +.temperature-range::-webkit-slider-thumb { box-shadow: 0 0 0 2px rgba(255,255,255,.48), 0 0 12px rgba(29,139,241,.34), 0 4px 10px rgba(17,79,143,.24); } +.glass-switch[aria-pressed="true"].green { box-shadow: 0 12px 27px rgba(11,153,81,.22), 0 0 19px var(--green-halo), inset 0 1px 0 rgba(255,255,255,.84), inset 0 -4px 10px rgba(0,125,62,.14); } +.glass-switch[aria-pressed="true"].orange { box-shadow: 0 12px 27px rgba(210,91,25,.2), 0 0 18px var(--orange-halo), inset 0 1px 0 rgba(255,255,255,.83), inset 0 -4px 10px rgba(168,54,0,.13); } +.quality-chip { box-shadow: 0 0 10px rgba(33,203,116,.13), inset 0 1px 0 rgba(255,255,255,.83); } +.meter i { box-shadow: 0 0 10px rgba(18,199,104,.35), inset 0 1px 0 rgba(255,255,255,.58); } +.filter-meter i { box-shadow: 0 0 10px rgba(31,144,247,.32), inset 0 1px 0 rgba(255,255,255,.54); } +.schedule-button.auto, .schedule-button.pause { box-shadow: 0 7px 18px rgba(31,83,119,.12), inset 0 1px 0 rgba(255,255,255,.82); } +:root[data-theme="dark"] .hero-step.primary { box-shadow: 0 11px 27px rgba(0,65,139,.4), 0 0 18px var(--blue-halo), inset 0 1px 0 rgba(220,248,255,.42), inset 0 -3px 7px rgba(0,27,91,.3); } +:root[data-theme="dark"] .speed-segments button.active { box-shadow: 0 0 12px rgba(24,156,255,.4), inset 0 1px 0 rgba(223,248,255,.36); } +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green { box-shadow: 0 12px 28px rgba(0,0,0,.28), 0 0 20px var(--green-halo), inset 0 1px 0 rgba(218,255,236,.4); } +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { box-shadow: 0 12px 28px rgba(0,0,0,.28), 0 0 20px var(--orange-halo), inset 0 1px 0 rgba(255,237,216,.4); } + +/* Natural highlights: state colors tint the glass, but do not paint opaque + neon blocks over the photographic background. Heater ON is a healthy green + state too; its orange icon still communicates heat. */ +.speed-card::after, .temperature-card::after { opacity: .18; filter: blur(42px); } +.power-card::after, .heater-card::after { background: var(--green-halo); opacity: .16; filter: blur(44px); } +.hero-step.primary { box-shadow: 0 9px 20px rgba(15,92,183,.22), 0 0 9px rgba(48,151,239,.14), inset 0 1px 0 rgba(255,255,255,.78), inset 0 -2px 5px rgba(0,67,156,.18); } +.hero-step:hover { box-shadow: 0 11px 23px rgba(20,101,190,.24), 0 0 12px rgba(48,151,239,.16), inset 0 1px 0 rgba(255,255,255,.76); } +.speed-segments button.active { box-shadow: 0 0 6px rgba(25,143,247,.24), inset 0 1px 0 rgba(255,255,255,.68), inset 0 -2px 4px rgba(0,84,182,.16); } +.glass-switch[aria-pressed="true"].green, +.glass-switch[aria-pressed="true"].orange { + background: linear-gradient(145deg, rgba(139,244,190,.72), rgba(55,194,122,.48)); + border-color: rgba(218,255,235,.84); + box-shadow: 0 10px 23px rgba(18,143,80,.18), 0 0 11px rgba(38,199,116,.13), inset 0 1px 0 rgba(255,255,255,.82), inset 0 -3px 8px rgba(0,114,57,.1); +} +:root[data-theme="dark"] .speed-card::after, +:root[data-theme="dark"] .temperature-card::after { opacity: .2; } +:root[data-theme="dark"] .power-card::after, +:root[data-theme="dark"] .heater-card::after { opacity: .18; } +:root[data-theme="dark"] .hero-step.primary { box-shadow: 0 10px 24px rgba(0,53,119,.38), 0 0 11px rgba(41,162,244,.19), inset 0 1px 0 rgba(220,248,255,.4), inset 0 -3px 7px rgba(0,27,91,.27); } +:root[data-theme="dark"] .speed-segments button.active { box-shadow: 0 0 7px rgba(24,156,255,.27), inset 0 1px 0 rgba(223,248,255,.34); } +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green, +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { + color: #effff6; + border-color: rgba(127,242,182,.4); + background: linear-gradient(145deg, rgba(51,190,119,.58), rgba(9,102,59,.55)); + box-shadow: 0 11px 25px rgba(0,0,0,.25), 0 0 12px rgba(37,207,121,.15), inset 0 1px 0 rgba(218,255,236,.34); +} + +/* Final material and icon polish. */ +.ui-icon { + display: block; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} +.feature-icon { padding: .08rem; filter: drop-shadow(0 0 6px rgba(20,139,244,.2)); text-shadow: none; } +.feature-icon.green { filter: drop-shadow(0 0 6px rgba(20,190,104,.24)); } +.feature-icon.orange { filter: drop-shadow(0 0 6px rgba(244,105,31,.22)); } +.fan-icon { font-size: inherit; } +.section-icon { padding: .06rem; filter: drop-shadow(0 0 5px rgba(26,124,210,.17)); text-shadow: none; } +.section-icon.leaf { filter: drop-shadow(0 0 5px rgba(8,160,81,.2)); } +.metric-icon { width: 1.7rem; height: 1.7rem; flex: 0 0 auto; filter: drop-shadow(0 0 4px rgba(20,124,221,.15)); text-shadow: none; } +.metric-icon.particles { padding: .05rem; } +.action-symbol { width: 2rem; height: 2rem; font-size: inherit; } +.extra-icon { width: 3.4rem; height: 3.4rem; flex: 0 0 auto; filter: drop-shadow(0 0 7px rgba(33,126,211,.17)); text-shadow: none; } +.extra-icon.wind { width: 3.8rem; height: 3.8rem; font-size: inherit; transform: none; } + +:root[data-theme="light"] { + --tile: rgba(104, 126, 139, 0.16); + --tile-strong: rgba(214, 226, 232, 0.2); + --tile-line-soft: rgba(189, 211, 221, 0.38); +} +:root[data-theme="light"] .glass-tile, +:root[data-theme="light"] .glass-section { + background: + linear-gradient(132deg, rgba(244,248,250,.22), rgba(159,178,188,.055) 38%, transparent 66%), + linear-gradient(145deg, var(--tile-strong), var(--tile)); + backdrop-filter: blur(14px) saturate(116%); + -webkit-backdrop-filter: blur(14px) saturate(116%); +} + +.metric-card:not(.co2-card) { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.metric-card:not(.co2-card) .metric-heading { width: 100%; justify-content: center; text-align: left; } +.metric-card:not(.co2-card) > strong { margin-top: .7rem; } +.metric-card:not(.co2-card) .quality-chip { align-self: center; } +.filter-card .filter-line { width: 100%; } + +.hero-step.primary { + background: + radial-gradient(circle at 24% 5%, rgba(255,255,255,.55), transparent 38%), + linear-gradient(135deg, rgba(255,255,255,.16), transparent 42%, rgba(0,73,170,.1)), + linear-gradient(145deg, #55b4ff, #1479e8 72%); + box-shadow: 0 8px 18px rgba(15,92,183,.18), 0 0 7px rgba(48,151,239,.1), inset 0 1px 0 rgba(255,255,255,.86), inset 0 -3px 7px rgba(0,67,156,.16); +} +.hero-step.primary:hover { box-shadow: 0 10px 21px rgba(20,101,190,.21), 0 0 9px rgba(48,151,239,.12), inset 0 1px 0 rgba(255,255,255,.88); } +.speed-segments button.active { + background: + linear-gradient(180deg, rgba(255,255,255,.32), transparent 48%), + linear-gradient(90deg, #1685ec, #35afff); + box-shadow: 0 0 5px rgba(25,143,247,.19), inset 0 1px 0 rgba(255,255,255,.75), inset 0 -2px 4px rgba(0,84,182,.13); +} +.glass-switch { overflow: hidden; } +.glass-switch[aria-pressed="true"].green, +.glass-switch[aria-pressed="true"].orange { + background: + radial-gradient(circle at 25% -8%, rgba(255,255,255,.67), transparent 42%), + linear-gradient(132deg, rgba(255,255,255,.15), transparent 40%, rgba(0,125,62,.06)), + linear-gradient(145deg, rgba(139,244,190,.67), rgba(55,194,122,.43)); + box-shadow: 0 8px 19px rgba(18,143,80,.14), 0 0 8px rgba(38,199,116,.09), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 9px rgba(0,114,57,.08); +} +.schedule-button.auto { + background: + radial-gradient(circle at 19% -30%, rgba(255,255,255,.55), transparent 44%), + linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%), + linear-gradient(145deg, rgba(211,239,255,.52), rgba(100,179,235,.18)); +} +.schedule-button.pause { + background: + radial-gradient(circle at 19% -30%, rgba(255,255,255,.58), transparent 44%), + linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%), + linear-gradient(145deg, rgba(255,231,199,.55), rgba(235,151,76,.18)); +} +:root[data-theme="dark"] .hero-step.primary { + background: + radial-gradient(circle at 24% 5%, rgba(214,246,255,.32), transparent 38%), + linear-gradient(135deg, rgba(255,255,255,.08), transparent 42%, rgba(0,32,95,.17)), + linear-gradient(145deg, #33aaf4, #0869d0 74%); +} +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green, +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { + background: + radial-gradient(circle at 25% -8%, rgba(218,255,236,.3), transparent 42%), + linear-gradient(132deg, rgba(255,255,255,.07), transparent 40%, rgba(0,45,23,.12)), + linear-gradient(145deg, rgba(51,190,119,.52), rgba(9,102,59,.49)); + box-shadow: 0 9px 21px rgba(0,0,0,.22), 0 0 9px rgba(37,207,121,.1), inset 0 1px 0 rgba(218,255,236,.32), inset 0 -3px 8px rgba(0,31,16,.16); +} +:root[data-theme="dark"] .schedule-button.auto { + background: + radial-gradient(circle at 19% -30%, rgba(208,243,255,.17), transparent 45%), + linear-gradient(145deg, rgba(38,125,187,.26), rgba(15,61,91,.25)); +} +:root[data-theme="dark"] .schedule-button.pause { + background: + radial-gradient(circle at 19% -30%, rgba(255,226,199,.16), transparent 45%), + linear-gradient(145deg, rgba(167,82,27,.27), rgba(73,40,25,.25)); +} + +@media (min-width: 1300px) and (min-height: 760px) { + .climate-panel { + width: 100%; + max-width: none; + display: grid; + grid-template-rows: auto minmax(18rem, 1.4fr) minmax(11rem, .78fr) minmax(13rem, .9fr) auto; + gap: .75rem; + } + .panel-header { padding-bottom: 0; } + .primary-controls, .bottom-grid { min-height: 0; height: 100%; } + .control-card, .switch-card { min-height: 0; height: 100%; } + .content-section { min-height: 0; height: 100%; margin-top: 0; display: flex; flex-direction: column; } + .air-grid { min-height: 0; flex: 1; } + .metric-card { min-height: 0; height: 100%; } + .schedule-layout, .extras-grid { min-height: 0; flex: 1; } + .schedule-times, .extra-card { min-height: 0; height: 100%; } +} + +@media (min-width: 1700px) { + html { font-size: 18px; } + .climate-panel { padding: 1.2rem; } +} + +@media (max-width: 1120px) { + .primary-controls { grid-template-columns: 1fr 1fr; } + .switch-card { min-height: 9rem; } + .glass-switch { min-height: 3.8rem; } + .air-grid { grid-template-columns: repeat(3, 1fr); } + .bottom-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + body { padding: .5rem; } + .climate-panel { border-radius: 1.1rem; } + .panel-header, .identity, .header-tools { flex-wrap: wrap; } + .panel-header { align-items: flex-start; } + .header-tools { width: 100%; justify-content: flex-start; } + .ventilation-badge { min-width: 0; flex: 1; } + .primary-controls { grid-template-columns: 1fr; } + .control-card, .switch-card { min-height: auto; } + .hero-control { margin: 1.4rem 0 .75rem; } + .air-grid { grid-template-columns: 1fr 1fr; } + .co2-card, .filter-card { grid-column: 1 / -1; } + .schedule-layout { grid-template-columns: 1fr; } +} + +@media (max-width: 480px) { + .identity h1 { width: 100%; } + .ventilation-badge small { display: none; } + .air-grid, .extras-grid { grid-template-columns: 1fr; } + .co2-card, .filter-card { grid-column: auto; } + .schedule-times { grid-template-columns: 1fr; } + .schedule-times > i { width: 100%; height: 1px; } + .micro-settings { flex-wrap: wrap; } +} + +@media (prefers-reduced-motion: reduce) { .hero-step:hover { transform: none; } } + +/* Schedule controls and editor. */ +.schedule-title-tools { margin-left: auto; } +.schedule-edit-button { + display: inline-flex; + align-items: center; + gap: .4rem; + min-height: 2.05rem; + padding: .36rem .68rem; + border: 1px solid var(--tile-line); + border-radius: .7rem; + color: #126fc4; + background: + radial-gradient(circle at 20% -35%, rgba(255,255,255,.48), transparent 48%), + linear-gradient(145deg, rgba(220,241,253,.42), rgba(100,173,220,.13)); + box-shadow: 0 6px 15px rgba(27,91,135,.1), inset 0 1px 0 rgba(255,255,255,.76); + cursor: pointer; + font-size: .65rem; + font-weight: 750; + transition: transform .15s ease, filter .15s ease; +} +.schedule-edit-button svg { width: 1rem; height: 1rem; } +.schedule-edit-button:hover { transform: translateY(-1px); filter: brightness(1.06); } +.schedule-button:disabled { cursor: not-allowed; opacity: .5; filter: saturate(.38); box-shadow: none; } +.schedule-button:disabled:hover { transform: none; filter: saturate(.38); } +.schedule-button[aria-busy="true"] { cursor: wait; } + +#schedule-state.warn { + color: #8d3b00; + border-color: rgba(247,162,73,.78); + background: linear-gradient(145deg, rgba(255,232,190,.82), rgba(240,157,69,.42)); + box-shadow: 0 0 12px rgba(237,130,39,.13), inset 0 1px 0 rgba(255,255,255,.86); + font-size: .66rem; + letter-spacing: .07em; +} +:root[data-theme="dark"] #schedule-state.warn { + color: #ffe2b8; + border-color: rgba(255,166,83,.57); + background: linear-gradient(145deg, rgba(177,87,24,.52), rgba(95,45,17,.48)); + box-shadow: 0 0 13px rgba(242,129,38,.15), inset 0 1px 0 rgba(255,228,198,.18); +} + +.schedule-dialog { + width: min(72rem, calc(100vw - 2rem)); + max-width: none; + max-height: calc(100vh - 2rem); + padding: 0; + border: 1px solid var(--panel-edge); + border-radius: 1.35rem; + color: var(--panel-ink); + background: + linear-gradient(125deg, rgba(255,255,255,.28), transparent 32%, rgba(255,255,255,.06) 72%), + rgba(201,229,244,.72); + box-shadow: 0 28px 90px rgba(8,45,70,.42), inset 0 1px 0 rgba(255,255,255,.9); + backdrop-filter: blur(28px) saturate(135%); + -webkit-backdrop-filter: blur(28px) saturate(135%); + overflow: hidden; +} +.schedule-dialog::backdrop { background: rgba(5,20,31,.46); backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px); } +:root[data-theme="dark"] .schedule-dialog { background: linear-gradient(125deg, rgba(205,239,255,.1), transparent 34%), rgba(7,24,36,.88); box-shadow: 0 30px 100px rgba(0,0,0,.64), inset 0 1px 0 rgba(221,243,252,.15); } +.schedule-editor { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: calc(100vh - 2rem); } +.editor-header, .editor-footer { display: flex; align-items: center; gap: .7rem; padding: .8rem 1rem; } +.editor-header { justify-content: space-between; border-bottom: 1px solid var(--tile-line-soft); } +.editor-heading { display: flex; align-items: center; gap: .65rem; } +.editor-heading > svg { width: 2rem; height: 2rem; color: #1883df; filter: drop-shadow(0 0 5px rgba(24,131,223,.2)); } +.editor-heading h2 { margin: 0; font-size: 1.15rem; } +.editor-heading small { display: block; margin-top: .15rem; color: var(--panel-muted); font-size: .63rem; } +.editor-close { width: 2.2rem; height: 2.2rem; padding: 0; border: 1px solid var(--tile-line); border-radius: .75rem; color: var(--panel-muted); background: var(--tile); cursor: pointer; font-size: 1.45rem; line-height: 1; } +.editor-scroll { display: grid; gap: .7rem; padding: .85rem 1rem 1rem; overflow: auto; } +.schedule-enabled { display: flex; align-items: center; gap: .7rem; padding: .72rem .82rem; border: 1px solid var(--tile-line); border-radius: .9rem; background: linear-gradient(145deg, rgba(255,255,255,.22), rgba(120,169,195,.08)); cursor: pointer; } +.schedule-enabled input { width: 1.1rem; height: 1.1rem; accent-color: var(--blue); } +.schedule-enabled span { display: grid; gap: .12rem; } +.schedule-enabled strong { font-size: .76rem; } +.schedule-enabled small { color: var(--panel-muted); font-size: .61rem; } +.editor-section { padding: .8rem; border: 1px solid var(--tile-line); border-radius: 1rem; background: linear-gradient(145deg, rgba(255,255,255,.19), rgba(94,148,179,.075)); box-shadow: inset 0 1px 0 rgba(255,255,255,.48); } +:root[data-theme="dark"] .editor-section, :root[data-theme="dark"] .schedule-enabled { background: linear-gradient(145deg, rgba(205,236,249,.055), rgba(2,19,30,.12)); box-shadow: inset 0 1px 0 rgba(224,246,255,.07); } +.editor-section-title { display: flex; align-items: end; justify-content: space-between; gap: .75rem; margin-bottom: .65rem; } +.editor-section-title h3 { margin: 0; font-size: .86rem; } +.editor-section-title small { display: block; margin-top: .13rem; color: var(--panel-muted); font-size: .6rem; } +.day-assignments { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .42rem; } +.day-assignment { display: grid; gap: .3rem; color: var(--panel-muted); font-size: .58rem; font-weight: 700; } +.day-assignment select, .template-toolbar select, .point-editor input, .point-editor select { + min-width: 0; + height: 2.25rem; + padding: .38rem .48rem; + border: 1px solid var(--tile-line); + border-radius: .62rem; + color: var(--panel-ink); + background: rgba(245,251,253,.48); + outline: none; + font-size: .66rem; +} +:root[data-theme="dark"] .day-assignment select, +:root[data-theme="dark"] .template-toolbar select, +:root[data-theme="dark"] .point-editor input, +:root[data-theme="dark"] .point-editor select { background: rgba(16,38,52,.72); } +.day-assignment select:focus, .template-toolbar select:focus, .point-editor input:focus, .point-editor select:focus { border-color: rgba(45,151,230,.7); box-shadow: 0 0 0 3px rgba(34,145,226,.11); } +.template-toolbar > label { display: grid; gap: .25rem; min-width: 10rem; color: var(--panel-muted); font-size: .57rem; font-weight: 700; } +.template-actions { display: flex; align-items: end; gap: .35rem; } +.template-actions button { min-height: 2.25rem; padding: .4rem .65rem; border: 1px solid var(--tile-line); border-radius: .62rem; color: #126fc4; background: rgba(52,150,220,.08); cursor: pointer; font-size: .61rem; font-weight: 700; } +.template-actions #delete-template { color: var(--red); border-color: rgba(223,91,105,.28); background: var(--red-soft); } +.template-actions button:disabled { cursor: not-allowed; opacity: .45; } +.schedule-points { display: grid; gap: .55rem; } +.point-editor { padding: .68rem; border: 1px solid var(--tile-line); border-radius: .85rem; background: linear-gradient(145deg, rgba(255,255,255,.22), rgba(116,160,183,.065)); box-shadow: inset 0 1px 0 rgba(255,255,255,.45); } +:root[data-theme="dark"] .point-editor { background: linear-gradient(145deg, rgba(197,231,247,.055), rgba(2,18,29,.13)); box-shadow: inset 0 1px 0 rgba(224,246,255,.06); } +.point-header { display: grid; grid-template-columns: auto minmax(7rem, .7fr) minmax(9rem, 1fr) auto; align-items: end; gap: .55rem; } +.point-number { align-self: center; display: grid; place-items: center; width: 1.85rem; height: 1.85rem; border-radius: 50%; color: #1478cc; background: var(--blue-soft); font-size: .62rem; font-weight: 800; } +.point-header label, .point-setting { display: grid; gap: .25rem; color: var(--panel-muted); font-size: .56rem; font-weight: 700; } +.remove-point { width: 2.25rem; height: 2.25rem; padding: 0; border: 1px solid rgba(223,91,105,.32); border-radius: .62rem; color: var(--red); background: var(--red-soft); cursor: pointer; font-size: 1.1rem; } +.point-settings { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: .48rem; margin-top: .55rem; } +.point-editor.auto [data-set-only] { display: none; } +.point-editor.auto .point-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.empty-points { padding: 1.4rem; border: 1px dashed var(--tile-line); border-radius: .8rem; color: var(--panel-muted); text-align: center; font-size: .68rem; } +.add-schedule-point { width: 100%; min-height: 2.55rem; margin-top: .6rem; border: 1px dashed rgba(37,131,205,.55); border-radius: .75rem; color: #126fc4; background: rgba(52,150,220,.08); cursor: pointer; font-size: .68rem; font-weight: 750; } +.editor-footer { border-top: 1px solid var(--tile-line-soft); justify-content: flex-end; } +.editor-status { margin-right: auto; color: var(--panel-muted); font-size: .63rem; } +.editor-status.error { color: var(--red); } +.editor-button { min-height: 2.5rem; padding: .58rem .9rem; border-radius: .75rem; cursor: pointer; font-size: .69rem; font-weight: 750; } +.editor-button.secondary { border: 1px solid var(--tile-line); color: var(--panel-ink); background: var(--tile); } +.editor-button.primary { border: 1px solid rgba(182,226,255,.76); color: white; background: radial-gradient(circle at 20% -30%, rgba(255,255,255,.5), transparent 48%), linear-gradient(145deg, #3aa9eb, #1476c9); box-shadow: 0 7px 17px rgba(18,102,170,.2), inset 0 1px 0 rgba(255,255,255,.75); } + +@media (max-width: 900px) { + .day-assignments { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .point-settings { grid-template-columns: repeat(3, minmax(0, 1fr)); } +} + +@media (max-width: 600px) { + .schedule-dialog { width: calc(100vw - .7rem); max-height: calc(100vh - .7rem); border-radius: 1rem; } + .schedule-editor { max-height: calc(100vh - .7rem); } + .editor-header, .editor-footer { padding: .7rem; } + .editor-scroll { padding: .7rem; } + .day-assignments { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .editor-section-title { align-items: stretch; flex-direction: column; } + .template-toolbar > label { min-width: 0; } + .template-actions { align-items: stretch; } + .template-actions button { flex: 1; } + .point-header { grid-template-columns: auto 1fr auto; } + .point-header .point-action { grid-column: 2 / -1; } + .point-settings, .point-editor.auto .point-settings { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .editor-footer { flex-wrap: wrap; } + .editor-status { width: 100%; } + .schedule-edit-button span { display: none; } +} diff --git a/web/css/widget.css b/web/css/widget.css new file mode 100644 index 0000000..b0e0ce3 --- /dev/null +++ b/web/css/widget.css @@ -0,0 +1,466 @@ +:root { + --widget-ink: #08265d; + --widget-muted: #315d91; + --widget-glass: rgba(200, 228, 244, 0.21); + --widget-edge: rgba(255, 255, 255, 0.9); + --tile: rgba(104, 126, 139, 0.16); + --tile-strong: rgba(214, 226, 232, 0.2); + --tile-edge: rgba(255, 255, 255, 0.82); + --tile-edge-soft: rgba(189, 211, 221, 0.38); + --tile-shadow: 0 10px 25px rgba(24, 72, 105, 0.12), inset 0 1px 0 rgba(255,255,255,.78), inset 1px 0 0 rgba(255,255,255,.24); + --blue-halo: rgba(23, 137, 248, 0.16); + --green-halo: rgba(28, 196, 113, 0.16); + --orange-halo: rgba(246, 126, 50, 0.14); +} + +:root[data-theme="dark"] { + --widget-ink: #edf8ff; + --widget-muted: #9fc5df; + --widget-glass: rgba(7, 22, 33, 0.49); + --widget-edge: rgba(187, 225, 244, 0.3); + --tile: rgba(16, 37, 51, 0.3); + --tile-strong: rgba(52, 78, 96, 0.33); + --tile-edge: rgba(203, 234, 248, 0.25); + --tile-edge-soft: rgba(114, 176, 207, 0.16); + --tile-shadow: 0 13px 29px rgba(0,0,0,.26), inset 0 1px 0 rgba(226,246,255,.11), inset 1px 0 0 rgba(186,226,245,.055); + --blue-halo: rgba(29, 153, 245, 0.22); + --green-halo: rgba(27, 210, 121, 0.19); + --orange-halo: rgba(246, 125, 46, 0.18); +} + +html { min-width: 280px; } + +body { + min-height: 100vh; + margin: 0; + padding: clamp(.5rem, 2.2vw, 1rem); + display: flex; + align-items: flex-start; + justify-content: center; + color: var(--widget-ink); + background-color: #b7d5e3; + background-image: + linear-gradient(118deg, rgba(180,220,239,.15), rgba(238,247,251,.025) 45%, rgba(247,226,204,.09)), + url("../assets/mountain-lake-light.jpg"); + background-position: center; + background-size: cover; + background-repeat: no-repeat; + background-attachment: fixed; + overflow-x: hidden; + isolation: isolate; +} + +:root[data-theme="dark"] body { + background-color: #091722; + background-image: + linear-gradient(118deg, rgba(2,15,25,.66), rgba(8,27,40,.57) 48%, rgba(20,25,29,.64)), + url("../assets/mountain-lake-dark.jpg"); +} + +body::before, +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + pointer-events: none; +} + +body::before { + background: linear-gradient(110deg, rgba(214,242,253,.16), transparent 35%, rgba(255,240,218,.1) 76%, transparent); + opacity: .74; +} + +body::after { + background: + radial-gradient(circle at 11% 18%, rgba(255,255,255,.32) 0 .5%, transparent 5%), + radial-gradient(circle at 64% 23%, rgba(255,244,218,.27) 0 .6%, transparent 6%), + radial-gradient(circle at 91% 79%, rgba(225,250,237,.21) 0 .8%, transparent 7%); + filter: blur(11px); + opacity: .5; +} + +:root[data-theme="dark"] body::before { background: linear-gradient(115deg, rgba(29,104,142,.1), transparent 40%, rgba(110,76,42,.08)); opacity: .7; } +:root[data-theme="dark"] body::after { opacity: .25; } + +.ambient::before { width: 32rem; height: 32rem; left: -13rem; top: -14rem; background: #c9f2ff; opacity: .14; filter: blur(76px); } +.ambient::after { width: 34rem; height: 34rem; right: -14rem; bottom: -16rem; background: #ebd5a7; opacity: .11; filter: blur(82px); } +:root[data-theme="dark"] .ambient::before { background: #147db5; opacity: .12; } +:root[data-theme="dark"] .ambient::after { background: #846630; opacity: .08; } + +.widget { + position: relative; + isolation: isolate; + width: min(100%, 31.5rem); + display: grid; + gap: .65rem; + padding: clamp(.65rem, 2.2vw, .9rem); + overflow: hidden; + border: 1px solid var(--widget-edge); + outline: 1px solid rgba(255,255,255,.48); + outline-offset: -5px; + border-radius: 1.55rem; + background: + linear-gradient(125deg, rgba(255,255,255,.22), transparent 31%, rgba(255,255,255,.055) 69%, rgba(196,224,240,.1)), + var(--widget-glass); + box-shadow: 0 27px 68px rgba(22,66,95,.24), 0 2px 8px rgba(255,255,255,.28), inset 0 1px 0 rgba(255,255,255,.88), inset 0 -1px 0 rgba(113,163,192,.17); + backdrop-filter: blur(13px) saturate(124%); + -webkit-backdrop-filter: blur(13px) saturate(124%); +} + +.widget::before, +.widget::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + pointer-events: none; +} + +.widget::before { + background: + linear-gradient(112deg, rgba(255,255,255,.25), rgba(255,255,255,.035) 18%, transparent 39%), + radial-gradient(ellipse at 50% -20%, rgba(255,255,255,.25), transparent 47%); +} + +.widget::after { + opacity: .1; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.16'/%3E%3C/svg%3E"); + mix-blend-mode: soft-light; +} + +.widget > * { position: relative; z-index: 1; } +:root[data-theme="dark"] .widget { outline-color: rgba(203,235,250,.11); box-shadow: 0 33px 78px rgba(0,0,0,.43), 0 0 28px rgba(19,112,158,.06), inset 0 1px 0 rgba(221,243,252,.17), inset 0 -1px 0 rgba(0,0,0,.28); } +:root[data-theme="dark"] .widget::before { background: linear-gradient(112deg, rgba(203,238,253,.1), transparent 24%, transparent 72%, rgba(71,163,202,.035)); } + +.widget-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: .55rem .7rem; + padding: .15rem .25rem .05rem; +} + +.identity { min-width: 0; display: flex; align-items: center; gap: .7rem; } +.brand-icon { + display: grid; + place-items: center; + width: 2.9rem; + height: 2.9rem; + flex: 0 0 auto; + border: 1px solid rgba(255,255,255,.82); + border-radius: 1rem; + color: #147bd5; + background: + radial-gradient(circle at 25% 2%, rgba(255,255,255,.62), transparent 43%), + linear-gradient(145deg, rgba(217,242,255,.58), rgba(73,159,222,.2)); + box-shadow: 0 8px 19px rgba(19,101,160,.13), inset 0 1px 0 rgba(255,255,255,.88); +} +.brand-icon svg { width: 1.75rem; height: 1.75rem; filter: drop-shadow(0 0 5px rgba(20,123,213,.18)); } +.identity-copy { min-width: 0; display: block; } +.title-row { display: flex; align-items: center; gap: .55rem; min-width: 0; } +.identity h1 { margin: 0; font-size: 1.62rem; line-height: 1; letter-spacing: -.045em; white-space: nowrap; } +.identity-copy > small { display: block; margin-top: .28rem; color: var(--widget-muted); font-size: .7rem; } +.widget .pill { min-height: 1.75rem; padding: .27rem .58rem; border-color: rgba(255,255,255,.56); font-size: .59rem; box-shadow: inset 0 1px 0 rgba(255,255,255,.62), 0 5px 14px rgba(26,75,108,.08); backdrop-filter: blur(12px); } +.widget .pill::before { width: .36rem; height: .36rem; box-shadow: 0 0 7px currentColor; } + +.theme-button, +.open-section { + display: grid; + place-items: center; + border: 1px solid var(--tile-edge); + color: var(--widget-ink); + background: linear-gradient(145deg, var(--tile-strong), var(--tile)); + box-shadow: var(--tile-shadow); + cursor: pointer; + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + transition: transform .15s ease, filter .15s ease; +} +.theme-button { width: 2.55rem; height: 2.55rem; border-radius: .9rem; } +.theme-button svg { width: 1.18rem; height: 1.18rem; } +.theme-button:hover, .open-section:hover { transform: translateY(-1px); filter: brightness(1.07); } +.theme-button:active, .open-section:active { transform: scale(.97); } +.status-row { grid-column: 1 / -1; display: flex; align-items: center; gap: .55rem; min-width: 0; } +.mode-detail { min-width: 0; color: var(--widget-muted); font-size: .66rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.widget-tile { + position: relative; + isolation: isolate; + overflow: hidden; + border: 1px solid var(--tile-edge); + outline: 1px solid var(--tile-edge-soft); + outline-offset: -3px; + border-radius: 1.15rem; + background: + linear-gradient(132deg, rgba(244,248,250,.22), rgba(159,178,188,.055) 38%, transparent 66%), + linear-gradient(145deg, var(--tile-strong), var(--tile)); + box-shadow: var(--tile-shadow); + backdrop-filter: blur(14px) saturate(116%); + -webkit-backdrop-filter: blur(14px) saturate(116%); +} + +.widget-tile::before { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + border-radius: inherit; + pointer-events: none; + opacity: .62; + background: linear-gradient(115deg, rgba(255,255,255,.35), transparent 25%, transparent 72%, rgba(255,255,255,.08)); +} +.widget-tile > * { position: relative; z-index: 1; } +:root[data-theme="dark"] .widget-tile { background: linear-gradient(132deg, rgba(213,240,252,.075), rgba(255,255,255,.012) 38%, transparent 65%), linear-gradient(145deg, var(--tile-strong), var(--tile)); backdrop-filter: blur(16px) saturate(127%); -webkit-backdrop-filter: blur(16px) saturate(127%); } + +.control-card { min-height: 11.4rem; padding: .9rem 1rem .85rem; display: flex; flex-direction: column; } +.control-card::after, +.switch-card::after { + content: ""; + position: absolute; + z-index: 0; + width: 12rem; + height: 12rem; + right: -5rem; + bottom: -7rem; + border-radius: 50%; + pointer-events: none; + filter: blur(42px); + opacity: .18; +} +.speed-card::after { background: var(--blue-halo); } +.temperature-card::after { width: 17rem; background: linear-gradient(90deg, var(--blue-halo), var(--orange-halo)); } +.power-card::after, .heater-card::after { background: var(--green-halo); opacity: .16; } + +.card-title { display: flex; align-items: flex-start; gap: .65rem; } +.card-title h2, .switch-title h2, .section-title h2 { margin: 0; font-size: .96rem; line-height: 1.2; letter-spacing: -.015em; } +.card-title p { margin: .16rem 0 0; color: var(--widget-muted); font-size: .66rem; } +.ui-icon { display: block; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.feature-icon { width: 1.95rem; height: 1.95rem; flex: 0 0 auto; padding: .06rem; color: #1389f4; filter: drop-shadow(0 0 5px rgba(20,139,244,.2)); } + +.hero-control { display: grid; grid-template-columns: 3.45rem 1fr 3.45rem; align-items: center; gap: .8rem; margin: 1rem 0 .72rem; } +.hero-value { text-align: center; font-size: 3rem; line-height: 1; letter-spacing: -.065em; font-variant-numeric: tabular-nums; } +.hero-step { + display: grid; + place-items: center; + width: 3.45rem; + height: 3.45rem; + padding: 0; + border: 1.5px solid rgba(255,255,255,.84); + border-radius: .95rem; + color: #176dbb; + background: + radial-gradient(circle at 24% 5%, rgba(255,255,255,.48), transparent 40%), + linear-gradient(145deg, rgba(245,252,255,.4), rgba(191,224,244,.18)); + box-shadow: 0 8px 19px rgba(35,92,132,.13), inset 0 1px 0 rgba(255,255,255,.88), 0 0 0 1px rgba(185,226,248,.2); + cursor: pointer; + font-size: 1.8rem; + line-height: 1; + transition: transform .15s ease, filter .15s ease, box-shadow .15s ease; +} +.hero-step.primary { + color: white; + background: + radial-gradient(circle at 24% 5%, rgba(255,255,255,.55), transparent 38%), + linear-gradient(135deg, rgba(255,255,255,.16), transparent 42%, rgba(0,73,170,.1)), + linear-gradient(145deg, #55b4ff, #1479e8 72%); + box-shadow: 0 8px 18px rgba(15,92,183,.18), 0 0 7px rgba(48,151,239,.1), inset 0 1px 0 rgba(255,255,255,.86), inset 0 -3px 7px rgba(0,67,156,.16); +} +.hero-step:hover { transform: translateY(-1px); filter: brightness(1.07); box-shadow: 0 10px 21px rgba(20,101,190,.2), 0 0 9px rgba(48,151,239,.12), inset 0 1px 0 rgba(255,255,255,.88); } +.hero-step:active { transform: scale(.97); } + +.speed-segments { display: grid; grid-template-columns: repeat(6, 1fr); gap: .18rem; } +.speed-segments button { + height: .9rem; + padding: 0; + border: 1px solid rgba(255,255,255,.82); + border-radius: .5rem; + background: linear-gradient(180deg, rgba(173,207,229,.3), rgba(98,151,190,.18)); + box-shadow: inset 0 1px 2px rgba(27,70,105,.07), 0 2px 5px rgba(32,86,126,.07); + cursor: pointer; + transition: background .12s ease, box-shadow .12s ease, transform .12s ease; +} +.speed-segments button:hover { transform: translateY(-1px); } +.speed-segments button.active { + background: linear-gradient(180deg, rgba(255,255,255,.32), transparent 48%), linear-gradient(90deg, #1685ec, #35afff); + box-shadow: 0 0 5px rgba(25,143,247,.19), inset 0 1px 0 rgba(255,255,255,.75), inset 0 -2px 4px rgba(0,84,182,.13); +} +.scale-labels, .temperature-labels { display: flex; justify-content: space-between; margin-top: .34rem; padding: 0 .3rem; color: var(--widget-muted); font-size: .62rem; } + +.temperature-range { width: 100%; height: 1.45rem; margin: 0; appearance: none; background: transparent; cursor: pointer; } +.temperature-range::-webkit-slider-runnable-track { height: .68rem; border: 1px solid rgba(255,255,255,.88); border-radius: 999px; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); box-shadow: 0 0 8px rgba(37,153,239,.15), 0 0 7px rgba(255,114,41,.09), inset 0 1px 2px rgba(255,255,255,.46); } +.temperature-range::-moz-range-track { height: .68rem; border: 1px solid rgba(255,255,255,.88); border-radius: 999px; background: linear-gradient(90deg, #0878ed, #53c7f5 38%, #f3c26e 72%, #ff6b31); } +.temperature-range::-moz-range-progress { background: transparent; } +.temperature-range::-webkit-slider-thumb { width: 1.6rem; height: 1.6rem; margin-top: -.49rem; appearance: none; border: .25rem solid rgba(244,252,255,.9); border-radius: 50%; background: #238feb; box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 0 11px rgba(29,139,241,.31), 0 4px 9px rgba(17,79,143,.22); } +.temperature-range::-moz-range-thumb { width: 1.08rem; height: 1.08rem; border: .25rem solid rgba(244,252,255,.9); border-radius: 50%; background: #238feb; box-shadow: 0 0 0 2px rgba(255,255,255,.45), 0 0 11px rgba(29,139,241,.31), 0 4px 9px rgba(17,79,143,.22); } + +.switch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .65rem; } +.switch-card { min-width: 0; min-height: 8.15rem; padding: .82rem; display: flex; flex-direction: column; gap: .72rem; } +.switch-title { display: flex; align-items: center; gap: .5rem; min-width: 0; } +.switch-title > span { min-width: 0; } +.switch-title small { display: block; margin-top: .12rem; color: var(--widget-muted); font-size: .58rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.switch-icon { width: 1.75rem; height: 1.75rem; flex: 0 0 auto; } +.switch-icon.green { color: #08a95a; filter: drop-shadow(0 0 5px rgba(20,190,104,.23)); } +.switch-icon.orange { color: #f06419; filter: drop-shadow(0 0 5px rgba(244,105,31,.2)); } +.glass-switch { + position: relative; + overflow: hidden; + width: 100%; + min-height: 3.35rem; + margin-top: auto; + border: 1.5px solid rgba(255,255,255,.86); + border-radius: .95rem; + color: var(--widget-ink); + background: rgba(199,222,235,.24); + box-shadow: var(--tile-shadow); + cursor: pointer; + font-size: 1.25rem; + font-weight: 850; + text-shadow: 0 1px 0 rgba(255,255,255,.3); + transition: transform .15s ease, background .2s ease, box-shadow .2s ease, filter .2s ease; +} +.glass-switch:hover { transform: translateY(-1px); filter: brightness(1.06); } +.glass-switch:active { transform: scale(.98); } +.glass-switch[aria-pressed="true"].green, +.glass-switch[aria-pressed="true"].orange { + background: + radial-gradient(circle at 25% -8%, rgba(255,255,255,.67), transparent 42%), + linear-gradient(132deg, rgba(255,255,255,.15), transparent 40%, rgba(0,125,62,.06)), + linear-gradient(145deg, rgba(139,244,190,.67), rgba(55,194,122,.43)); + border-color: rgba(218,255,235,.84); + box-shadow: 0 8px 19px rgba(18,143,80,.14), 0 0 8px rgba(38,199,116,.09), inset 0 1px 0 rgba(255,255,255,.9), inset 0 -4px 9px rgba(0,114,57,.08); +} + +.info-card { padding: .82rem; } +.section-title { display: flex; align-items: center; justify-content: space-between; gap: .65rem; } +.section-title > span { display: flex; align-items: center; gap: .5rem; } +.section-icon { width: 1.75rem; height: 1.75rem; color: #207bcc; filter: drop-shadow(0 0 5px rgba(26,124,210,.17)); } +.section-icon.leaf { color: #069c51; transform: rotate(-18deg); filter: drop-shadow(0 0 5px rgba(8,160,81,.2)); } +.open-section { width: 2rem; height: 2rem; padding: 0; border-radius: .7rem; color: #147bd5; font-size: .96rem; } + +.co2-summary { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: .6rem; margin: .8rem .1rem .52rem; } +.co2-label { color: var(--widget-muted); font-size: .74rem; font-weight: 750; } +.co2-summary strong { text-align: center; font-size: 2rem; line-height: 1; letter-spacing: -.045em; } +.co2-summary strong small { color: var(--widget-muted); font-size: .62rem; font-weight: 650; letter-spacing: 0; } +.quality-chip { display: inline-flex; justify-content: center; padding: .32rem .62rem; border: 1px solid var(--tile-edge-soft); border-radius: 999px; background: rgba(199,222,235,.2); color: var(--widget-muted); font-size: .6rem; font-weight: 750; white-space: nowrap; } +.quality-chip.good { color: #076d3d; border-color: rgba(109,237,167,.65); background: linear-gradient(145deg, rgba(179,255,214,.58), rgba(92,218,151,.39)); box-shadow: 0 0 9px rgba(33,203,116,.12), inset 0 1px 0 rgba(255,255,255,.82); } +.quality-chip.warn { color: #9c5513; border-color: rgba(244,183,106,.67); background: linear-gradient(145deg, rgba(255,232,188,.56), rgba(239,168,80,.34)); } +.quality-chip.bad { color: #a62f3d; border-color: rgba(242,135,147,.62); background: linear-gradient(145deg, rgba(255,209,216,.53), rgba(225,105,120,.3)); } +:root[data-theme="dark"] .quality-chip.good { color: #b9ffda; } +:root[data-theme="dark"] .quality-chip.warn { color: #ffd59e; } +:root[data-theme="dark"] .quality-chip.bad { color: #ffc0c8; } + +.co2-meter { height: .68rem; border: 1px solid rgba(255,255,255,.78); border-radius: 999px; background: rgba(91,145,181,.17); box-shadow: inset 0 1px 3px rgba(25,68,98,.09); overflow: hidden; } +.co2-meter i { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #08a350, #36dc83); box-shadow: 0 0 10px rgba(18,199,104,.34), inset 0 1px 0 rgba(255,255,255,.55); transition: width .3s ease; } +.meter-labels { display: flex; justify-content: space-between; margin-top: .26rem; padding: 0 .2rem; color: var(--widget-muted); font-size: .54rem; } +.mini-metrics { display: grid; grid-template-columns: 1fr 1fr; gap: .52rem; margin-top: .68rem; } +.mini-metric { min-width: 0; min-height: 4.35rem; display: flex; align-items: center; justify-content: center; gap: .5rem; padding: .62rem .5rem; border: 1px solid var(--tile-edge-soft); border-radius: .9rem; background: linear-gradient(145deg, rgba(255,255,255,.13), rgba(107,151,177,.055)); box-shadow: inset 0 1px 0 rgba(255,255,255,.33); text-align: center; } +.metric-icon { width: 1.55rem; height: 1.55rem; flex: 0 0 auto; color: #137edc; filter: drop-shadow(0 0 4px rgba(20,124,221,.15)); } +.mini-metric span { min-width: 0; } +.mini-metric small { display: block; color: var(--widget-muted); font-size: .58rem; } +.mini-metric strong { display: block; margin-top: .2rem; font-size: 1.35rem; line-height: 1; letter-spacing: -.035em; } + +.schedule-times { display: grid; grid-template-columns: 1fr 1px 1fr; align-items: center; gap: .7rem; margin-top: .75rem; padding: .15rem .25rem .25rem; } +.schedule-times > span { display: grid; justify-items: center; gap: .18rem; text-align: center; } +.schedule-times small { color: var(--widget-muted); font-size: .6rem; } +.schedule-times strong { font-size: 1.35rem; letter-spacing: -.035em; } +.schedule-times > i { width: 1px; height: 2.75rem; background: var(--tile-edge-soft); } + +.open-panel { + min-height: 3.25rem; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: .65rem; + padding: .65rem .9rem; + border: 1px solid rgba(194,232,255,.84); + border-radius: 1rem; + color: white; + background: + radial-gradient(circle at 18% -35%, rgba(255,255,255,.52), transparent 47%), + linear-gradient(135deg, rgba(255,255,255,.13), transparent 43%), + linear-gradient(145deg, rgba(60,174,239,.92), rgba(17,113,194,.88)); + box-shadow: 0 9px 20px rgba(15,92,183,.2), 0 0 9px rgba(48,151,239,.11), inset 0 1px 0 rgba(255,255,255,.84), inset 0 -3px 7px rgba(0,67,156,.15); + cursor: pointer; + font-size: .78rem; + font-weight: 760; + transition: transform .15s ease, filter .15s ease, box-shadow .15s ease; +} +.open-panel svg { width: 1.45rem; height: 1.45rem; } +.open-panel b { font-size: 1rem; } +.open-panel:hover { transform: translateY(-1px); filter: brightness(1.05); box-shadow: 0 11px 23px rgba(20,101,190,.23), 0 0 11px rgba(48,151,239,.13), inset 0 1px 0 rgba(255,255,255,.86); } +.open-panel:active { transform: scale(.99); } +.demo-note { color: var(--widget-muted); text-align: center; font-size: .58rem; } + +:root[data-theme="dark"] .brand-icon { border-color: rgba(193,228,245,.25); background: radial-gradient(circle at 25% 2%, rgba(208,243,255,.16), transparent 43%), linear-gradient(145deg, rgba(38,125,187,.27), rgba(15,61,91,.26)); } +:root[data-theme="dark"] .hero-step { color: #76c7fa; border-color: rgba(192,226,244,.25); background: radial-gradient(circle at 24% 5%, rgba(213,240,252,.11), transparent 40%), linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); box-shadow: 0 9px 21px rgba(0,0,0,.23), inset 0 1px 0 rgba(226,246,255,.12); } +:root[data-theme="dark"] .hero-step.primary { color: white; background: radial-gradient(circle at 24% 5%, rgba(214,246,255,.32), transparent 38%), linear-gradient(135deg, rgba(255,255,255,.08), transparent 42%, rgba(0,32,95,.17)), linear-gradient(145deg, #33aaf4, #0869d0 74%); box-shadow: 0 10px 24px rgba(0,53,119,.38), 0 0 11px rgba(41,162,244,.19), inset 0 1px 0 rgba(220,248,255,.4), inset 0 -3px 7px rgba(0,27,91,.27); } +:root[data-theme="dark"] .speed-segments button { border-color: rgba(192,226,244,.25); background: linear-gradient(180deg, rgba(91,136,162,.23), rgba(24,55,74,.28)); } +:root[data-theme="dark"] .speed-segments button.active { border-color: rgba(117,207,255,.48); background: linear-gradient(180deg, rgba(216,246,255,.19), transparent 48%), linear-gradient(90deg, #0877df, #20b7ff); box-shadow: 0 0 7px rgba(24,156,255,.27), inset 0 1px 0 rgba(223,248,255,.34); } +:root[data-theme="dark"] .glass-switch { color: #ddecf4; border-color: rgba(193,228,245,.25); background: linear-gradient(145deg, rgba(77,112,133,.23), rgba(20,44,59,.28)); text-shadow: 0 1px 2px rgba(0,0,0,.4); } +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].green, +:root[data-theme="dark"] .glass-switch[aria-pressed="true"].orange { color: #effff6; border-color: rgba(127,242,182,.4); background: radial-gradient(circle at 25% -8%, rgba(218,255,236,.3), transparent 42%), linear-gradient(132deg, rgba(255,255,255,.07), transparent 40%, rgba(0,45,23,.12)), linear-gradient(145deg, rgba(51,190,119,.52), rgba(9,102,59,.49)); box-shadow: 0 9px 21px rgba(0,0,0,.22), 0 0 9px rgba(37,207,121,.1), inset 0 1px 0 rgba(218,255,236,.32), inset 0 -3px 8px rgba(0,31,16,.16); } +:root[data-theme="dark"] .co2-meter { border-color: rgba(200,232,247,.24); background: rgba(3,22,34,.34); box-shadow: inset 0 2px 5px rgba(0,0,0,.2); } +:root[data-theme="dark"] .mini-metric { background: linear-gradient(145deg, rgba(196,231,247,.055), rgba(3,24,37,.08)); box-shadow: inset 0 1px 0 rgba(224,246,255,.07); } + +/* Wide monitor layout. The same markup stays vertical on narrow screens, so + controls and API behaviour cannot drift between two widget versions. */ +:root[data-widget-layout="horizontal"] .widget { + width: min(100%, 76rem); + max-width: none; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, .8fr) minmax(0, .8fr); + grid-template-areas: + "header header header header" + "speed temperature power heater" + "air air schedule schedule" + "open open open open" + "demo demo demo demo"; + align-items: stretch; +} +:root[data-widget-layout="horizontal"] .widget-header { grid-area: header; grid-template-columns: minmax(0, 1fr) auto auto; } +:root[data-widget-layout="horizontal"] .widget-header .identity { grid-column: 1; grid-row: 1; } +:root[data-widget-layout="horizontal"] .widget-header .status-row { grid-column: 2; grid-row: 1; } +:root[data-widget-layout="horizontal"] .widget-header .theme-button { grid-column: 3; grid-row: 1; } +:root[data-widget-layout="horizontal"] .speed-card { grid-area: speed; } +:root[data-widget-layout="horizontal"] .temperature-card { grid-area: temperature; } +:root[data-widget-layout="horizontal"] .switch-grid { display: contents; } +:root[data-widget-layout="horizontal"] .switch-grid > * { z-index: 1; } +:root[data-widget-layout="horizontal"] .power-card { grid-area: power; } +:root[data-widget-layout="horizontal"] .heater-card { grid-area: heater; } +:root[data-widget-layout="horizontal"] .switch-card { min-height: 11.4rem; } +:root[data-widget-layout="horizontal"] .air-card { grid-area: air; } +:root[data-widget-layout="horizontal"] .schedule-card { grid-area: schedule; } +:root[data-widget-layout="horizontal"] .air-card, +:root[data-widget-layout="horizontal"] .schedule-card { min-height: 9.6rem; } +:root[data-widget-layout="horizontal"] .open-panel { grid-area: open; } +:root[data-widget-layout="horizontal"] .demo-note { grid-area: demo; } + +button:disabled { opacity: .58; cursor: wait; transform: none !important; } + +@media (min-width: 560px) and (min-height: 900px) { + html { font-size: 17px; } + :root[data-widget-layout="vertical"] .widget { width: min(100%, 33rem); } +} + +@media (max-width: 380px) { + body { padding: .36rem; } + .widget { padding: .55rem; gap: .52rem; border-radius: 1.2rem; } + .brand-icon { width: 2.55rem; height: 2.55rem; } + .identity h1 { font-size: 1.38rem; } + .title-row { gap: .38rem; } + .widget .pill { padding-inline: .46rem; font-size: .54rem; } + .hero-control { grid-template-columns: 3.15rem 1fr 3.15rem; gap: .55rem; } + .hero-step { width: 3.15rem; height: 3.15rem; } + .hero-value { font-size: 2.65rem; } + .switch-grid { gap: .5rem; } + .switch-card { padding: .7rem; } + .switch-title { align-items: flex-start; } + .switch-title small { display: none; } + .co2-summary { grid-template-columns: auto 1fr; } + .co2-summary .quality-chip { grid-column: 1 / -1; justify-self: center; } +} + +@media (prefers-reduced-motion: reduce) { + .hero-step:hover, .theme-button:hover, .open-section:hover, .glass-switch:hover, .open-panel:hover { transform: none; } +} diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..ae0c8c1 --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,261 @@ +const query = new URLSearchParams(window.location.search); +export const isDemo = query.get("demo") === "1"; + +const demoState = { + online: true, + running: true, + last_seen: new Date().toISOString(), + last_error: null, + tion: { + power: true, + heater: true, + heating: false, + sound: true, + mode: "outside", + out_temp: 21, + in_temp: 20, + target_temp: 21, + fan_speed: 3, + filter_remain: 136.7, + device_time: "22:45", + request_error_code: 0, + model: "S4", + light: true, + }, + auto: { + available: true, + state: "inactive", + reason: null, + target_speed: null, + auto_speed: null, + target_heater: null, + auto_heater: null, + temperature: 25.9, + temperature_source: "qingping", + last_error: null, + config_error: null, + }, + qingping: { + online: true, + temperature: 25.9, + humidity: 69.4, + co2: 780, + pm25: 0, + pm10: 0, + battery: 100, + last_error: null, + }, + schedule: { + available: true, + enabled: true, + running: true, + paused: false, + current_action: "set", + current_time: "22:45", + next_time: "10:00", + next_action: "set", + override_until_time: "10:00", + auto_active: false, + auto_fallback_speed: 2, + auto_target_temp: 21, + scheduled_settings: { speed: 2, target_temp: 21, heater: true }, + override_active: true, + override_until: new Date(Date.now() + 8 * 3600_000).toISOString(), + override_settings: { speed: 3 }, + last_error: null, + }, +}; + +const demoScheduleConfig = { + version: 1, + enabled: true, + timezone: "local", + templates: { + workday: [ + { time: "07:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } }, + { time: "09:00", action: { type: "auto", speed: 2, target_temp: 21 } }, + { time: "22:45", action: { type: "set", speed: 1 } }, + ], + weekend: [ + { time: "09:30", action: { type: "set", power: true, speed: 3, heater: true, target_temp: 21 } }, + { time: "11:00", action: { type: "auto", speed: 2, target_temp: 21 } }, + { time: "23:00", action: { type: "set", speed: 1 } }, + ], + }, + days: { mon: "workday", tue: "workday", wed: "workday", thu: "workday", fri: "workday", sat: "weekend", sun: "weekend" }, +}; + +const copy = value => JSON.parse(JSON.stringify(value)); +const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + +function mutateDemo(path) { + const tion = demoState.tion; + const schedule = demoState.schedule; + let match; + + if ((match = path.match(/^\/api\/tion\/speed\/(\d)$/))) { + tion.fan_speed = Number(match[1]); + schedule.override_active = true; + schedule.override_until_time ||= schedule.next_time; + schedule.override_settings.speed = tion.fan_speed; + } else if ((match = path.match(/^\/api\/tion\/temperature\/(\d+)$/))) { + tion.target_temp = Number(match[1]); + schedule.override_active = true; + schedule.override_until_time ||= schedule.next_time; + schedule.override_settings.target_temp = tion.target_temp; + } else if ((match = path.match(/^\/api\/tion\/(power|heater|sound|light)\/(on|off)$/))) { + tion[match[1]] = match[2] === "on"; + schedule.override_active = true; + schedule.override_settings[match[1]] = tion[match[1]]; + } else if ((match = path.match(/^\/api\/tion\/mode\/(outside|recirculation)$/))) { + tion.mode = match[1]; + schedule.override_active = true; + schedule.override_settings.mode = match[1]; + } else if (path === "/api/schedule/override/clear") { + schedule.override_active = false; + schedule.override_until = null; + schedule.override_until_time = null; + schedule.override_settings = {}; + } else if (path === "/api/schedule/pause") { + schedule.paused = true; + schedule.override_active = false; + schedule.override_until_time = null; + } else if (path === "/api/schedule/resume") { + schedule.paused = false; + } + + demoState.last_seen = new Date().toISOString(); +} + +async function request(path, options = {}) { + if (isDemo) { + await wait(options.method === "POST" ? 220 : 80); + if (path === "/api/schedule/config") { + if (options.method === "PUT") { + const replacement = JSON.parse(options.body); + Object.keys(demoScheduleConfig).forEach(key => delete demoScheduleConfig[key]); + Object.assign(demoScheduleConfig, replacement); + return { ok: true, config: copy(demoScheduleConfig) }; + } + return copy(demoScheduleConfig); + } + if (options.method === "POST") mutateDemo(path); + return copy(demoState); + } + + const response = await fetch(path, { + cache: "no-store", + headers: { Accept: "application/json" }, + ...options, + }); + + if (!response.ok) { + let message = `Ошибка ${response.status}`; + try { + const body = await response.json(); + if (body.detail && typeof body.detail === "object") { + message = [body.detail.message, body.detail.error].filter(Boolean).join(": ") || message; + } else { + message = body.detail || body.message || message; + } + } catch (_) {} + throw new Error(typeof message === "string" ? message : JSON.stringify(message)); + } + + return response.json(); +} + +export const api = { + status: () => request("/api/status"), + post: path => request(path, { method: "POST" }), + scheduleConfig: () => request("/api/schedule/config"), + saveScheduleConfig: config => request("/api/schedule/config", { + method: "PUT", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(config), + }), +}; + +export function configureTheme(button) { + const allowed = ["auto", "light", "dark"]; + const explicit = query.get("theme"); + let mode = allowed.includes(explicit) ? explicit : (localStorage.getItem("tion-theme") || "auto"); + + const apply = () => { + const dark = mode === "dark" || (mode === "auto" && matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.dataset.theme = dark ? "dark" : "light"; + document.documentElement.dataset.themeMode = mode; + if (button) { + button.title = `Тема: ${mode === "auto" ? "системная" : mode === "dark" ? "тёмная" : "светлая"}`; + button.setAttribute("aria-label", button.title); + } + }; + + apply(); + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => mode === "auto" && apply()); + + if (button) button.addEventListener("click", () => { + mode = allowed[(allowed.indexOf(mode) + 1) % allowed.length]; + localStorage.setItem("tion-theme", mode); + apply(); + }); + + return () => mode; +} + +export function setRangeProgress(input) { + const min = Number(input.min); + const max = Number(input.max); + const value = Number(input.value); + input.style.setProperty("--progress", `${((value - min) / (max - min)) * 100}%`); +} + +export function airQuality(co2) { + if (!Number.isFinite(Number(co2))) return { label: "Нет данных", tone: "muted", pct: 0 }; + const value = Number(co2); + if (value < 700) return { label: "Отличный воздух", tone: "good", pct: value / 20 }; + if (value < 900) return { label: "Хороший воздух", tone: "good", pct: value / 20 }; + if (value < 1300) return { label: "Повышенный CO₂", tone: "warn", pct: value / 20 }; + if (value < 1600) return { label: "Душно", tone: "warn", pct: value / 20 }; + return { label: "Нужна вентиляция", tone: "bad", pct: Math.min(100, value / 20) }; +} + +export function modeInfo(status) { + const schedule = status?.schedule || {}; + if (!schedule.available) return { label: "Без расписания", detail: "Недоступно", tone: "muted" }; + if (schedule.paused) return { label: "Ручной режим", detail: "Расписание на паузе", tone: "warn" }; + if (schedule.override_active) return { + label: "Ручное управление", + detail: schedule.override_until_time ? `До ${schedule.override_until_time}` : "До следующей точки", + tone: "warn", + }; + if (schedule.auto_active) return { label: "AUTO", detail: "По качеству воздуха", tone: "info" }; + return { label: "Расписание", detail: "Активно", tone: "info" }; +} + +export const numberOrDash = (value, digits = 0) => { + const number = Number(value); + return Number.isFinite(number) ? number.toFixed(digits) : "—"; +}; + +export function showToast(message, error = false) { + let region = document.querySelector(".toast-region"); + if (!region) { + region = document.createElement("div"); + region.className = "toast-region"; + document.body.append(region); + } + const toast = document.createElement("div"); + toast.className = `toast${error ? " error" : ""}`; + toast.textContent = message; + region.append(toast); + setTimeout(() => toast.remove(), 3200); +} + +export function panelUrl(themeMode = "auto", hash = "") { + const url = new URL("/ui/panel", window.location.origin); + url.searchParams.set("theme", themeMode); + if (isDemo) url.searchParams.set("demo", "1"); + url.hash = hash; + return url.toString(); +} diff --git a/web/js/panel.js b/web/js/panel.js new file mode 100644 index 0000000..c2bc4ee --- /dev/null +++ b/web/js/panel.js @@ -0,0 +1,472 @@ +import { api, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, setRangeProgress, showToast } from "./api.js"; + +const $ = selector => document.querySelector(selector); +configureTheme($("#theme-button")); +let state = null; +let busy = false; +let scheduleDraft = null; +let activeTemplate = null; + +const weekdays = [ + ["mon", "Понедельник"], + ["tue", "Вторник"], + ["wed", "Среда"], + ["thu", "Четверг"], + ["fri", "Пятница"], + ["sat", "Суббота"], + ["sun", "Воскресенье"], +]; +const templateNames = { workday: "Будний", weekend: "Выходной" }; +const clone = value => JSON.parse(JSON.stringify(value)); + +function templateLabel(name) { + return templateNames[name] || name; +} + +if (isDemo) $("#demo-note").hidden = false; + +function text(selector, value) { $(selector).textContent = value; } +function setPill(selector, label, tone) { const element = $(selector); element.textContent = label; element.className = `pill ${tone}`; } +function toneColor(tone) { return `var(--${tone === "bad" ? "red" : tone === "warn" ? "orange" : tone === "good" ? "green" : tone === "info" ? "blue" : "muted"})`; } + +function toggle(selector, active, label = "") { + const button = $(selector); + button.setAttribute("aria-pressed", String(Boolean(active))); + button.textContent = `${label}${label ? " " : ""}${active ? "ON" : "OFF"}`; +} + +function setRange(selector, value) { + const input = $(selector); + if (Number.isFinite(Number(value))) input.value = value; + setRangeProgress(input); +} + +function updateSpeedVisual(value) { + const speed = Math.max(1, Math.min(6, Number(value) || 1)); + text("#speed-value", speed); + setRange("#speed-range", speed); + document.querySelectorAll(".speed-segments button").forEach((segment, index) => { + segment.classList.toggle("active", index < speed); + segment.setAttribute("aria-pressed", String(index + 1 === speed)); + }); +} + +function updateTemperatureVisual(value) { + const temperature = Math.max(5, Math.min(30, Number(value) || 5)); + text("#temperature-value", temperature); + setRange("#temperature-range", temperature); +} + +function actionLabel(value) { + return ({ set: "Настройка режима", auto: "Автоматический режим", off: "Выключение" })[value] || "Точка расписания"; +} + +function renderAir(sensor, tion) { + const quality = airQuality(sensor.co2); + text("#co2", numberOrDash(sensor.co2)); + text("#air-label", quality.label); + text("#air-advice", quality.tone === "good" ? "Проветривание работает нормально" : quality.tone === "warn" ? "Автоматика при необходимости повысит скорость" : quality.tone === "bad" ? "Рекомендуется усилить вентиляцию" : "Ожидаем показания датчика"); + $("#air-label").style.color = toneColor(quality.tone); + $("#air-label").style.background = `var(--${quality.tone === "bad" ? "red" : quality.tone === "warn" ? "orange" : quality.tone === "good" ? "green" : "glass"}-soft)`; + $("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`; + setPill("#sensor-status", sensor.online ? "Online" : "Offline", sensor.online ? "good" : "bad"); + text("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1)); + text("#humidity", numberOrDash(sensor.humidity)); + text("#pm25", numberOrDash(sensor.pm25)); + text("#pm10", numberOrDash(sensor.pm10)); + const pm25 = Number(sensor.pm25); + const pm10 = Number(sensor.pm10); + text("#pm25-quality", Number.isFinite(pm25) ? (pm25 <= 15 ? "Отлично" : pm25 <= 35 ? "Норма" : "Повышено") : "—"); + text("#pm10-quality", Number.isFinite(pm10) ? (pm10 <= 30 ? "Отлично" : pm10 <= 60 ? "Норма" : "Повышено") : "—"); +} + +function renderSchedule(status) { + const schedule = status.schedule || {}; + const mode = modeInfo(status); + text("#current-time", schedule.current_time || status.tion?.device_time || "—"); + text("#next-time", schedule.next_time || "—"); + text("#current-action", actionLabel(schedule.current_action)); + text("#next-action", actionLabel(schedule.next_action)); + text("#mode-title", mode.label); + text("#mode-detail", mode.detail); + setPill("#schedule-state", schedule.paused ? "Пауза" : schedule.enabled ? "Активно" : "Выкл", schedule.paused ? "warn" : schedule.enabled ? "info" : "muted"); + const headerMode = schedule.override_active && schedule.override_until_time + ? `Ручной до ${schedule.override_until_time}` + : mode.label; + setPill("#mode-pill", headerMode, mode.tone); + $("#mode-banner").className = `mode-banner ${mode.tone === "info" ? "info" : mode.tone === "muted" ? "muted" : "warn"}`; + $("#auto-button").disabled = !schedule.available || (!schedule.override_active && !schedule.paused); + $("#pause-button strong").textContent = schedule.paused ? "Возобновить расписание" : "Пауза расписания"; + $("#pause-button small").textContent = schedule.paused ? "Продолжить по расписанию" : "Временно приостановить"; +} + +function render(status) { + state = status; + const tion = status.tion || {}; + const sensor = status.qingping || {}; + setPill("#connection", status.online ? "Online" : "Offline", status.online ? "good" : "bad"); + updateSpeedVisual(tion.fan_speed); + updateTemperatureVisual(tion.target_temp); + text("#speed-note", status.auto?.state === "active" && status.auto?.target_speed ? `AUTO выбрал скорость ${status.auto.target_speed}` : `Ступень ${numberOrDash(tion.fan_speed)} из 6`); + text("#temperature-note", tion.heating ? "Сейчас нагревает входящий воздух" : "Желаемая температура в помещении"); + toggle("#power-toggle", tion.power); + toggle("#heater-toggle", tion.heater); + toggle("#sound-toggle", tion.sound, "Звук"); + toggle("#light-toggle", tion.light, "Свет"); + text("#power-caption", tion.power ? "Устройство включено" : "Устройство выключено"); + text("#heater-caption", tion.heating ? "Сейчас нагревает воздух" : "Поддержание температуры"); + $("#mode-outside").classList.toggle("active", tion.mode === "outside"); + $("#mode-recirculation").classList.toggle("active", tion.mode === "recirculation"); + text("#mode-name", tion.mode === "recirculation" ? "Рециркуляция" : "Приточная вентиляция"); + text("#outside-temp", numberOrDash(tion.out_temp)); + text("#filter-remain", numberOrDash(tion.filter_remain)); + const filterPercent = Math.max(0, Math.min(100, Number(tion.filter_remain || 0) / 2)); + $("#filter-progress").style.width = `${filterPercent}%`; + text("#filter-percent", `${Math.round(filterPercent)}%`); + text("#model", tion.model || "—"); + text("#battery", numberOrDash(sensor.battery)); + renderAir(sensor, tion); + renderSchedule(status); + text("#updated-at", `Обновлено ${new Date().toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`); +} + +async function refresh(silent = false) { + try { render(await api.status()); } + catch (error) { + setPill("#connection", "Ошибка связи", "bad"); + if (!silent) showToast(error.message, true); + } +} + +async function act(path, source) { + if (busy) return; + busy = true; + source?.setAttribute("disabled", ""); + source?.setAttribute("aria-busy", "true"); + try { await api.post(path); await refresh(true); } + catch (error) { showToast(error.message, true); await refresh(true); } + finally { busy = false; source?.removeAttribute("disabled"); source?.removeAttribute("aria-busy"); } +} + +function stepped(kind, delta, source) { + if (busy) return; + const isSpeed = kind === "speed"; + const current = Number(isSpeed ? state?.tion?.fan_speed : state?.tion?.target_temp); + const min = isSpeed ? 1 : 5; + const max = isSpeed ? 6 : 30; + const value = Math.min(max, Math.max(min, current + delta)); + if (Number.isFinite(value) && value !== current) { + if (isSpeed) { + state.tion.fan_speed = value; + updateSpeedVisual(value); + } else { + state.tion.target_temp = value; + updateTemperatureVisual(value); + } + act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source); + } +} + +function optionList(values, current, emptyLabel = "Не менять") { + const items = emptyLabel === null ? [] : [["", emptyLabel]]; + items.push(...values); + return items.map(([value, label]) => ``).join(""); +} + +function booleanSetting(value) { + return value === true ? "on" : value === false ? "off" : ""; +} + +function pointMarkup(point, index) { + const action = point?.action || {}; + const type = action.type === "auto" ? "auto" : "set"; + const speedOptions = Array.from({ length: 6 }, (_, item) => [String(item + 1), String(item + 1)]); + const booleanOptions = [["on", "Включить"], ["off", "Выключить"]]; + return ` +
+
+ ${index + 1} + + + +
+
+ + + + + + + +
+
`; +} + +function readRenderedPoints(strict = false) { + const points = [...document.querySelectorAll(".point-editor")].map(card => { + const time = card.querySelector('[data-field="time"]').value; + const type = card.querySelector('[data-field="type"]').value; + const action = { type }; + + card.querySelectorAll("[data-setting]").forEach(input => { + const field = input.dataset.setting; + const raw = input.value; + if (raw === "" || (type === "auto" && !["speed", "target_temp"].includes(field))) return; + if (["speed", "target_temp"].includes(field)) action[field] = Number(raw); + else if (["power", "heater", "sound", "light"].includes(field)) action[field] = raw === "on"; + else action[field] = raw; + }); + + return { time, action }; + }); + + if (strict) validateTemplate(activeTemplate, points); + return points; +} + +function validateTemplate(name, points) { + const label = templateLabel(name); + if (!Array.isArray(points) || !points.length) throw new Error(`Шаблон «${label}» не может быть пустым`); + const times = new Set(); + points.forEach((point, index) => { + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(point.time || "")) throw new Error(`Проверьте время в точке ${index + 1} шаблона «${label}»`); + if (times.has(point.time)) throw new Error(`В шаблоне «${label}» время ${point.time} указано дважды`); + times.add(point.time); + const action = point.action || {}; + if (!["set", "auto"].includes(action.type)) throw new Error(`Неизвестное действие в шаблоне «${label}»`); + if (action.speed !== undefined && (!Number.isInteger(action.speed) || action.speed < 1 || action.speed > 6)) throw new Error(`Скорость в шаблоне «${label}» должна быть от 1 до 6`); + if (action.target_temp !== undefined && (!Number.isInteger(action.target_temp) || action.target_temp < 5 || action.target_temp > 30)) throw new Error(`Температура в шаблоне «${label}» должна быть от 5 до 30°`); + if (action.type === "auto" && action.speed === undefined) throw new Error(`Для AUTO в шаблоне «${label}» укажите базовую скорость`); + if (action.type === "set" && Object.keys(action).length === 1) throw new Error(`В точке ${point.time} шаблона «${label}» не выбран ни один параметр`); + }); +} + +function validateScheduleDraft() { + const names = Object.keys(scheduleDraft?.templates || {}); + if (!names.length) throw new Error("В расписании должен быть хотя бы один шаблон"); + names.forEach(name => validateTemplate(name, scheduleDraft.templates[name])); + weekdays.forEach(([day, label]) => { + if (!names.includes(scheduleDraft.days?.[day])) throw new Error(`Для дня «${label}» не выбран шаблон`); + }); +} + +function commitActiveTemplate(strict = false) { + if (scheduleDraft && activeTemplate) scheduleDraft.templates[activeTemplate] = readRenderedPoints(strict); +} + +function renderTemplateUsage() { + const usedBy = weekdays.filter(([day]) => scheduleDraft.days?.[day] === activeTemplate).map(([, label]) => label.toLowerCase()); + text("#template-usage", usedBy.length ? `Используют: ${usedBy.join(", ")}` : "Пока не назначен ни одному дню"); +} + +function renderTemplateSelector() { + const select = $("#template-select"); + select.replaceChildren(...Object.keys(scheduleDraft.templates).map(name => new Option(templateLabel(name), name, false, name === activeTemplate))); + $("#delete-template").disabled = Object.keys(scheduleDraft.templates).length <= 1; +} + +function renderDayAssignments() { + const host = $("#day-assignments"); + const templateNames = Object.keys(scheduleDraft.templates); + host.replaceChildren(...weekdays.map(([day, label]) => { + const wrapper = document.createElement("label"); + wrapper.className = "day-assignment"; + wrapper.textContent = label; + const select = document.createElement("select"); + select.dataset.day = day; + select.replaceChildren(...templateNames.map(name => new Option(templateLabel(name), name, false, scheduleDraft.days?.[day] === name))); + wrapper.append(select); + return wrapper; + })); +} + +function renderSchedulePoints() { + const points = scheduleDraft.templates[activeTemplate] || []; + $("#schedule-points").innerHTML = points.length + ? points.map(pointMarkup).join("") + : '
В этом шаблоне пока нет точек
'; + renderTemplateUsage(); +} + +function setEditorStatus(message = "", error = false) { + const status = $("#schedule-editor-status"); + status.textContent = message; + status.classList.toggle("error", error); +} + +async function openScheduleEditor() { + const source = $("#schedule-edit-button"); + source.disabled = true; + source.setAttribute("aria-busy", "true"); + try { + scheduleDraft = clone(await api.scheduleConfig()); + const names = Object.keys(scheduleDraft.templates || {}); + if (!names.length) throw new Error("В расписании нет шаблонов"); + const dayKeys = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]; + activeTemplate = scheduleDraft.days?.[dayKeys[new Date().getDay()]] || names[0]; + $("#schedule-enabled").checked = scheduleDraft.enabled !== false; + renderTemplateSelector(); + renderDayAssignments(); + renderSchedulePoints(); + setEditorStatus(); + $("#schedule-dialog").showModal(); + } catch (error) { + showToast(`Не удалось открыть редактор: ${error.message}`, true); + } finally { + source.disabled = false; + source.removeAttribute("aria-busy"); + } +} + +function closeScheduleEditor() { + $("#schedule-dialog").close(); + scheduleDraft = null; + activeTemplate = null; +} + +async function saveScheduleEditor(event) { + event.preventDefault(); + if (!scheduleDraft) return; + const saveButton = $("#schedule-save-button"); + try { + commitActiveTemplate(true); + scheduleDraft.enabled = $("#schedule-enabled").checked; + validateScheduleDraft(); + Object.values(scheduleDraft.templates).forEach(points => points.sort((left, right) => left.time.localeCompare(right.time))); + setEditorStatus("Сохраняем…"); + saveButton.disabled = true; + saveButton.setAttribute("aria-busy", "true"); + const result = await api.saveScheduleConfig(scheduleDraft); + $("#schedule-dialog").close(); + scheduleDraft = null; + activeTemplate = null; + showToast(result.restart_required ? "Расписание сохранено. Перезапустите сервис для применения" : "Расписание сохранено и применено"); + await refresh(true); + } catch (error) { + setEditorStatus(error.message, true); + } finally { + saveButton.disabled = false; + saveButton.removeAttribute("aria-busy"); + } +} + +document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => stepped(button.dataset.step, Number(button.dataset.delta), button))); + +document.querySelectorAll(".speed-segments button").forEach(button => button.addEventListener("click", () => { + if (busy || !state?.tion) return; + const value = Number(button.dataset.speed); + state.tion.fan_speed = value; + updateSpeedVisual(value); + act(`/api/tion/speed/${value}`, button); +})); + +$("#speed-range").addEventListener("input", event => updateSpeedVisual(event.target.value)); +$("#speed-range").addEventListener("change", event => act(`/api/tion/speed/${event.target.value}`, event.target)); +$("#temperature-range").addEventListener("input", event => { setRangeProgress(event.target); text("#temperature-value", event.target.value); }); +$("#temperature-range").addEventListener("change", event => act(`/api/tion/temperature/${event.target.value}`, event.target)); + +$("#power-toggle").addEventListener("click", event => act(`/api/tion/power/${state?.tion?.power ? "off" : "on"}`, event.currentTarget)); +$("#heater-toggle").addEventListener("click", event => act(`/api/tion/heater/${state?.tion?.heater ? "off" : "on"}`, event.currentTarget)); +$("#sound-toggle").addEventListener("click", event => act(`/api/tion/sound/${state?.tion?.sound ? "off" : "on"}`, event.currentTarget)); +$("#light-toggle").addEventListener("click", event => act(`/api/tion/light/${state?.tion?.light ? "off" : "on"}`, event.currentTarget)); +$("#mode-outside").addEventListener("click", event => act("/api/tion/mode/outside", event.currentTarget)); +$("#mode-recirculation").addEventListener("click", event => act("/api/tion/mode/recirculation", event.currentTarget)); +$("#auto-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/override/clear", event.currentTarget)); +$("#pause-button").addEventListener("click", event => act(state?.schedule?.paused ? "/api/schedule/resume" : "/api/schedule/pause", event.currentTarget)); +$("#refresh-button").addEventListener("click", event => { event.currentTarget.animate([{ transform: "rotate(0)" }, { transform: "rotate(360deg)" }], { duration: 450 }); refresh(); }); + +$("#schedule-edit-button").addEventListener("click", openScheduleEditor); +$("#schedule-close-button").addEventListener("click", closeScheduleEditor); +$("#schedule-cancel-button").addEventListener("click", closeScheduleEditor); +$("#schedule-form").addEventListener("submit", saveScheduleEditor); +$("#schedule-dialog").addEventListener("close", () => { scheduleDraft = null; activeTemplate = null; }); + +$("#template-select").addEventListener("change", event => { + if (!scheduleDraft) return; + commitActiveTemplate(false); + activeTemplate = event.target.value; + renderSchedulePoints(); +}); + +$("#duplicate-template").addEventListener("click", () => { + if (!scheduleDraft || !activeTemplate) return; + commitActiveTemplate(false); + const base = `${activeTemplate}-copy`; + let name = base; + let suffix = 2; + while (scheduleDraft.templates[name]) name = `${base}-${suffix++}`; + scheduleDraft.templates[name] = clone(scheduleDraft.templates[activeTemplate]); + activeTemplate = name; + renderTemplateSelector(); + renderDayAssignments(); + renderSchedulePoints(); + setEditorStatus(`Создан шаблон «${templateLabel(name)}». Назначьте его нужным дням`); +}); + +$("#delete-template").addEventListener("click", () => { + if (!scheduleDraft || Object.keys(scheduleDraft.templates).length <= 1) return; + if (!window.confirm(`Удалить шаблон «${templateLabel(activeTemplate)}»?`)) return; + const removed = activeTemplate; + delete scheduleDraft.templates[removed]; + activeTemplate = Object.keys(scheduleDraft.templates)[0]; + weekdays.forEach(([day]) => { + if (scheduleDraft.days[day] === removed) scheduleDraft.days[day] = activeTemplate; + }); + renderTemplateSelector(); + renderDayAssignments(); + renderSchedulePoints(); + setEditorStatus(`Шаблон «${templateLabel(removed)}» удалён`); +}); + +$("#day-assignments").addEventListener("change", event => { + const select = event.target.closest("[data-day]"); + if (!select || !scheduleDraft) return; + scheduleDraft.days[select.dataset.day] = select.value; + renderTemplateUsage(); +}); + +$("#schedule-points").addEventListener("change", event => { + if (event.target.dataset.field !== "type") return; + const card = event.target.closest(".point-editor"); + const isAuto = event.target.value === "auto"; + card.classList.toggle("auto", isAuto); + card.classList.toggle("set", !isAuto); + const speed = card.querySelector('[data-setting="speed"]'); + if (isAuto && !speed.value) speed.value = "2"; +}); + +$("#schedule-points").addEventListener("click", event => { + const button = event.target.closest("[data-remove-point]"); + if (!button || !scheduleDraft) return; + commitActiveTemplate(false); + scheduleDraft.templates[activeTemplate].splice(Number(button.dataset.removePoint), 1); + renderSchedulePoints(); +}); + +$("#add-schedule-point").addEventListener("click", () => { + if (!scheduleDraft) return; + commitActiveTemplate(false); + const points = scheduleDraft.templates[activeTemplate]; + const used = new Set(points.map(point => point.time)); + const existingTimes = points.map(point => { + const [hours, minute] = String(point.time || "").split(":").map(Number); + return hours * 60 + minute; + }).filter(Number.isFinite); + let minutes = existingTimes.length ? (Math.max(...existingTimes) + 60) % 1440 : 8 * 60; + for (let attempt = 0; attempt < 48; attempt += 1) { + const candidate = `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; + if (!used.has(candidate)) { + points.push({ time: candidate, action: { type: "set", speed: 2 } }); + break; + } + minutes = (minutes + 30) % 1440; + } + points.sort((left, right) => left.time.localeCompare(right.time)); + renderSchedulePoints(); + $("#schedule-points .point-editor:last-of-type")?.scrollIntoView({ behavior: "smooth", block: "nearest" }); +}); + +setRangeProgress($("#speed-range")); +setRangeProgress($("#temperature-range")); +refresh(); +setInterval(() => !document.hidden && !busy && refresh(true), 3000); diff --git a/web/js/widget.js b/web/js/widget.js new file mode 100644 index 0000000..425d029 --- /dev/null +++ b/web/js/widget.js @@ -0,0 +1,166 @@ +import { api, airQuality, configureTheme, isDemo, modeInfo, numberOrDash, panelUrl, showToast } from "./api.js"; + +const requestedLayout = new URLSearchParams(window.location.search).get("layout"); +const layoutMode = ["horizontal", "vertical"].includes(requestedLayout) ? requestedLayout : null; +const wideLayout = window.matchMedia("(min-width: 800px)"); + +function applyLayout() { + document.documentElement.dataset.widgetLayout = layoutMode || (wideLayout.matches ? "horizontal" : "vertical"); +} + +applyLayout(); +wideLayout.addEventListener("change", () => !layoutMode && applyLayout()); + +const $ = selector => document.querySelector(selector); +const themeMode = configureTheme($("#theme-button")); +let state = null; +let busy = false; + +if (isDemo) $("#demo-note").hidden = false; + +function setText(selector, value) { $(selector).textContent = value; } +function setPill(element, label, tone) { element.textContent = label; element.className = `pill ${tone}`; } +function toneClass(tone) { return ["good", "warn", "bad"].includes(tone) ? tone : "muted"; } + +function updateSpeedVisual(value) { + const speed = Math.max(1, Math.min(6, Number(value) || 1)); + setText("#speed", speed); + $("#speed").classList.remove("skeleton"); + document.querySelectorAll("[data-speed]").forEach(button => { + const segment = Number(button.dataset.speed); + button.classList.toggle("active", segment <= speed); + button.setAttribute("aria-pressed", String(segment === speed)); + }); +} + +function updateTemperatureVisual(value) { + const temperature = Math.max(5, Math.min(30, Number(value) || 5)); + setText("#target-temp", temperature); + $("#target-temp").classList.remove("skeleton"); + $("#temperature-range").value = temperature; +} + +function drawToggle(selector, active) { + const button = $(selector); + button.setAttribute("aria-pressed", String(Boolean(active))); + button.textContent = active ? "ON" : "OFF"; +} + +function render(status) { + state = status; + const tion = status.tion || {}; + const sensor = status.qingping || {}; + const schedule = status.schedule || {}; + const mode = modeInfo(status); + const quality = airQuality(sensor.co2); + const headerMode = schedule.override_active && schedule.override_until_time + ? `Ручной до ${schedule.override_until_time}` + : mode.label; + + setPill($("#connection"), status.online ? "Online" : "Offline", status.online ? "good" : "bad"); + setPill($("#mode-pill"), headerMode, mode.tone); + setText("#mode-detail", mode.detail); + + updateSpeedVisual(tion.fan_speed); + setText("#speed-note", status.auto?.state === "active" && status.auto?.target_speed + ? `AUTO выбрал скорость ${status.auto.target_speed}` + : `Ступень ${numberOrDash(tion.fan_speed)} из 6`); + updateTemperatureVisual(tion.target_temp); + setText("#heating-note", tion.heating ? "Сейчас нагревает входящий воздух" : tion.heater ? "Обогрев разрешён" : "Желаемая температура воздуха"); + + drawToggle("#power-toggle", tion.power); + drawToggle("#heater-toggle", tion.heater); + setText("#power-detail", tion.power ? "Устройство включено" : "Устройство выключено"); + setText("#heater-detail", tion.heating ? "Сейчас нагревает" : tion.heater ? "Автоматически" : "Выключен"); + + setText("#co2", numberOrDash(sensor.co2)); + setText("#air-label", quality.label); + $("#air-label").className = `quality-chip ${toneClass(quality.tone)}`; + $("#co2-progress").style.width = `${Math.max(0, Math.min(100, quality.pct))}%`; + setText("#room-temp", numberOrDash(sensor.temperature ?? tion.in_temp, 1)); + setText("#humidity", numberOrDash(sensor.humidity)); + setText("#current-time", schedule.current_time || tion.device_time || "—"); + setText("#next-time", schedule.next_time || "—"); +} + +async function refresh(silent = false) { + try { render(await api.status()); } + catch (error) { + setPill($("#connection"), "Ошибка", "bad"); + if (!silent) showToast(error.message, true); + } +} + +async function act(path, source) { + if (busy) return; + busy = true; + source?.setAttribute("disabled", ""); + try { await api.post(path); await refresh(true); } + catch (error) { showToast(error.message, true); await refresh(true); } + finally { busy = false; source?.removeAttribute("disabled"); } +} + +function stepped(kind, delta, source) { + if (busy || !state?.tion) return; + const isSpeed = kind === "speed"; + const current = Number(isSpeed ? state.tion.fan_speed : state.tion.target_temp); + const min = isSpeed ? 1 : 5; + const max = isSpeed ? 6 : 30; + const value = Math.min(max, Math.max(min, current + delta)); + if (!Number.isFinite(value) || value === current) return; + + if (isSpeed) { + state.tion.fan_speed = value; + updateSpeedVisual(value); + } else { + state.tion.target_temp = value; + updateTemperatureVisual(value); + } + act(`/api/tion/${isSpeed ? "speed" : "temperature"}/${value}`, source); +} + +document.querySelectorAll("[data-step]").forEach(button => button.addEventListener("click", () => { + stepped(button.dataset.step, Number(button.dataset.delta), button); +})); + +document.querySelectorAll("[data-speed]").forEach(button => button.addEventListener("click", () => { + if (busy || !state?.tion) return; + const value = Number(button.dataset.speed); + state.tion.fan_speed = value; + updateSpeedVisual(value); + act(`/api/tion/speed/${value}`, button); +})); + +$("#temperature-range").addEventListener("input", event => setText("#target-temp", event.target.value)); +$("#temperature-range").addEventListener("change", event => { + if (busy || !state?.tion) return; + const value = Number(event.target.value); + state.tion.target_temp = value; + updateTemperatureVisual(value); + act(`/api/tion/temperature/${value}`, event.target); +}); + +$("#power-toggle").addEventListener("click", event => { + if (busy || !state?.tion) return; + const value = !state.tion.power; + state.tion.power = value; + drawToggle("#power-toggle", value); + setText("#power-detail", value ? "Устройство включено" : "Устройство выключено"); + act(`/api/tion/power/${value ? "on" : "off"}`, event.currentTarget); +}); + +$("#heater-toggle").addEventListener("click", event => { + if (busy || !state?.tion) return; + const value = !state.tion.heater; + state.tion.heater = value; + drawToggle("#heater-toggle", value); + setText("#heater-detail", value ? "Автоматически" : "Выключен"); + act(`/api/tion/heater/${value ? "on" : "off"}`, event.currentTarget); +}); + +$("#open-panel").addEventListener("click", () => window.open(panelUrl(themeMode()), "_blank", "noopener")); +$("#air-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "air"), "_blank", "noopener")); +$("#schedule-link").addEventListener("click", () => window.open(panelUrl(themeMode(), "schedule"), "_blank", "noopener")); + +refresh(); +setInterval(() => !document.hidden && !busy && refresh(true), 3000); diff --git a/web/panel.html b/web/panel.html new file mode 100644 index 0000000..dae673e --- /dev/null +++ b/web/panel.html @@ -0,0 +1,163 @@ + + + + + + + Tion 4S — панель управления + + + + +
+
+
+
+

Tion 4S

+ Загрузка + +
+
+
+ + Приточная вентиляцияЧистый воздух для вашего дома +
+ + +
+
+ +
+
+

Скорость вентиляции

Текущая ступень вентилятора

+
+ + + +
+ +
+ + + + + + +
+ +
+ +
+

Цель (температура)

Желаемая температура воздуха

+
+ + ° + +
+ + +
+ +
+

Питание

Устройство выключено

+ +
+ +
+

Обогрев

Поддержание температуры

+ +
+
+ +
+
+

Показатели воздуха

+ Датчик + Обновление… +
+
+
+
CO₂Нет данных
+ ppm +

Ожидаем показания датчика

+
+
40080012002000
+
+
Температура
в помещении
°
+
Влажность
%
+
PM2.5
мкг/м³
+
PM10
мкг/м³
+
Фильтр
ч осталось
+
+
+ +
+
+

Расписание

+
+
+
Текущая точкаНет данных
Следующая точкаНет данных
+
+
+ + +
+
+
Загрузка состоянияПожалуйста, подождите
+
+ +
+

Дополнительно

+
+
На улице°
+
+ Режим работыПриточная вентиляцияПодача и очистка воздуха +
+
+
+
+ Модель Батарея Qingping % + +
+
+
+ +
+ +
+
+

Редактор расписания

Изменения сохраняются в config/schedule.yaml
+ +
+
+ + +
+

Дни недели

Назначьте каждому дню один из шаблонов
+
+
+ +
+
+

Точки расписания

Выберите шаблон
+ + +
+
+ +
+
+ +
+
+ + + diff --git a/web/widget.html b/web/widget.html new file mode 100644 index 0000000..76f0263 --- /dev/null +++ b/web/widget.html @@ -0,0 +1,129 @@ + + + + + + + Tion 4S — виджет + + + + + +
+
+
+ + +

Tion 4S

Загрузка
+ Приточная вентиляция +
+
+ +
+ + Загрузка состояния +
+
+ +
+
+ +

Скорость вентиляции

Ступень — из 6

+
+
+ + 0 + +
+
+ + + + + + +
+ +
+ +
+
+ +

Целевая температура

Желаемая температура воздуха

+
+
+ + 00° + +
+ + +
+ +
+
+
+ +

Питание

Устройство выключено
+
+ +
+
+
+ +

Обогрев

Выключен
+
+ +
+
+ +
+
+

Воздух в комнате

+ +
+
+ CO₂ + ppm + Нет данных +
+ + +
+
+ + Температура° +
+
+ + Влажность% +
+
+
+ +
+
+

Расписание

+ +
+
+ Сейчас + + Следующая точка +
+
+ + + +
+ + +