Compare commits

...
Author SHA1 Message Date
Sergey Matyunin ce3b6f210a docs: add light and dark icon comparisons
Issue: #158
User-Visible: no
2026-08-18 00:33:48 +03:00
Sergey Matyunin 1b704c7913 docs: specify device icon redesign
Issue: #158
User-Visible: no
2026-08-17 20:14:26 +03:00
3 changed files with 1382 additions and 0 deletions
+755
View File
@@ -0,0 +1,755 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>#158 · Сравнение состояний маркеров устройств</title>
<style>
:root {
color-scheme: light;
--page: #f4f6f9;
--panel: rgb(255 255 255 / 92%);
--panel-solid: #fff;
--text: #1c2430;
--muted: #657183;
--line: #d8dee8;
--sample-a: #edf2f6;
--sample-b: #f8fafc;
--old-bg: #fff;
--old-line: #cbd3df;
--old-text: #1d2733;
--glass: rgb(255 255 255 / 54%);
--glass-solid: #f8fafc;
--core: #fff;
--core-text: #050608;
--shadow-small: 0 2px 3px rgb(37 40 45 / 12%);
--shadow-large: 0 8px 14px -2px rgb(37 40 45 / 18%);
--accent: #0c82f0;
--working: #f0a00c;
--open: #1dc21d;
--alarm: #f0410c;
--selection: #f3a000;
--focus: #0c82f0;
--s: 56px;
--ripple-scale: 2.8;
}
body[data-theme="dark"] {
color-scheme: dark;
--page: #10141b;
--panel: rgb(28 33 42 / 94%);
--panel-solid: #1c212a;
--text: #f1f4f8;
--muted: #a5afbd;
--line: #3a4351;
--sample-a: #151c26;
--sample-b: #202936;
--old-bg: #16212e;
--old-line: #34485c;
--old-text: #e6edf3;
--glass: rgb(38 43 52 / 62%);
--glass-solid: #2b313b;
--core: #2a3039;
--core-text: #f8fafc;
}
* { box-sizing: border-box; }
html { background: var(--page); }
body {
margin: 0;
min-width: 320px;
background: var(--page);
color: var(--text);
font-family: Inter, Roboto, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
transition: background .2s, color .2s;
}
button, select, input { font: inherit; }
code { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
.page {
width: min(1520px, calc(100% - 32px));
margin: 28px auto 60px;
}
.hero, .controls, .matrix-wrap, .decision {
border: 1px solid var(--line);
background: var(--panel);
box-shadow: 0 18px 45px rgb(15 23 42 / 8%);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.hero {
padding: 26px 28px;
border-radius: 22px 22px 0 0;
}
.eyebrow {
margin: 0 0 7px;
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: .09em;
text-transform: uppercase;
}
h1 { margin: 0; font-size: clamp(25px, 3vw, 42px); line-height: 1.08; }
.lede { max-width: 980px; margin: 13px 0 0; color: var(--muted); line-height: 1.55; }
.prototype {
display: inline-flex;
margin-top: 14px;
padding: 6px 10px;
border: 1px solid color-mix(in srgb, var(--working) 55%, var(--line));
border-radius: 999px;
background: color-mix(in srgb, var(--working) 11%, transparent);
color: color-mix(in srgb, var(--working) 70%, var(--text));
font-size: 12px;
font-weight: 750;
}
.decision {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
margin-top: 12px;
overflow: hidden;
border-radius: 15px;
background: var(--line);
}
.decision > div { padding: 15px 17px; background: var(--panel-solid); }
.decision b { display: block; margin-bottom: 4px; font-size: 13px; }
.decision span { color: var(--muted); font-size: 12px; line-height: 1.4; }
.controls {
position: sticky;
top: 0;
z-index: 30;
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 12px;
margin-top: 12px;
padding: 13px 16px;
border-radius: 15px;
}
.control { display: grid; gap: 5px; }
.control > span { color: var(--muted); font-size: 11px; font-weight: 750; }
select, .segmented, .check {
min-height: 38px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-solid);
color: var(--text);
}
select { padding: 0 34px 0 11px; }
.segmented { display: inline-flex; padding: 3px; }
.segmented button {
min-width: 48px;
padding: 5px 10px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.segmented button.active { background: var(--accent); color: #fff; }
.check { display: inline-flex; align-items: center; gap: 8px; padding: 0 11px; cursor: pointer; }
.check input { accent-color: var(--accent); }
.count { margin-left: auto; align-self: center; color: var(--muted); font-size: 12px; }
.matrix-wrap {
margin-top: 12px;
overflow: auto;
border-radius: 17px;
}
table {
width: 100%;
min-width: max(1180px, calc(880px + var(--s) * 10));
border-collapse: separate;
border-spacing: 0;
}
body[data-variants="both"] table {
min-width: max(2200px, calc(1500px + var(--s) * 20));
}
thead th {
position: sticky;
top: 65px;
z-index: 20;
padding: 13px 15px;
border-bottom: 1px solid var(--line);
background: var(--panel-solid);
color: var(--muted);
font-size: 12px;
letter-spacing: .03em;
text-align: left;
text-transform: uppercase;
}
thead th:nth-child(1) { width: 18%; }
thead th:nth-child(2), thead th:nth-child(3) { width: 29%; text-align: center; }
thead th:nth-child(4) { width: 24%; }
tbody td {
padding: 12px 15px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
tbody tr:last-child td { border-bottom: 0; }
.group-row th {
padding: 11px 15px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--accent) 9%, var(--panel-solid));
color: var(--accent);
font-size: 12px;
letter-spacing: .06em;
text-align: left;
text-transform: uppercase;
}
.state-title { font-weight: 780; line-height: 1.25; }
.state-code { display: block; margin-top: 5px; color: var(--muted); font-size: 11px; }
.contract { color: var(--muted); font-size: 12px; line-height: 1.48; }
.sample {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 8px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent);
border-radius: 13px;
background: color-mix(in srgb, var(--panel-solid) 80%, var(--line));
isolation: isolate;
}
.theme-variant {
position: relative;
display: grid;
min-width: 0;
min-height: clamp(156px, calc(var(--s) * 3.15), 310px);
place-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--line) 62%, transparent);
border-radius: 9px;
background: linear-gradient(135deg, var(--sample-a), var(--sample-b));
isolation: isolate;
}
.theme-light {
color-scheme: light;
--sample-a: #edf2f6;
--sample-b: #f8fafc;
--old-bg: #fff;
--old-line: #cbd3df;
--old-text: #1d2733;
--glass: rgb(255 255 255 / 54%);
--glass-solid: #f8fafc;
--core: #fff;
--core-text: #050608;
--panel-solid: #fff;
color: #1c2430;
}
.theme-dark {
color-scheme: dark;
--sample-a: #151c26;
--sample-b: #202936;
--old-bg: #16212e;
--old-line: #34485c;
--old-text: #e6edf3;
--glass: rgb(38 43 52 / 62%);
--glass-solid: #2b313b;
--core: #2a3039;
--core-text: #f8fafc;
--panel-solid: #1c212a;
color: #f1f4f8;
}
.theme-label {
position: absolute;
top: 7px;
left: 7px;
z-index: 10;
padding: 3px 6px;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--panel-solid) 84%, transparent);
color: inherit;
font-size: 10px;
font-weight: 750;
line-height: 1;
pointer-events: none;
}
body[data-background="color"] .theme-variant {
background:
radial-gradient(circle at 19% 28%, rgb(240 160 12 / 28%), transparent 31%),
radial-gradient(circle at 83% 19%, rgb(12 130 240 / 30%), transparent 34%),
linear-gradient(145deg, #315b65 0%, #84725f 49%, #503d68 100%);
}
body[data-variants="light"] .theme-dark,
body[data-variants="dark"] .theme-light { display: none; }
body[data-variants="light"] .sample,
body[data-variants="dark"] .sample { grid-template-columns: minmax(0, 1fr); }
/* One normalized marker tree, two visual implementations. */
.marker {
--state: var(--core);
--state-text: var(--core-text);
--state-ring: rgb(255 255 255 / 75%);
--state-ring-width: 1.5px;
position: relative;
display: inline-grid;
place-items: center;
color: var(--state-text);
line-height: 1;
transform: translateZ(0);
}
.assembly { position: relative; display: inline-flex; align-items: center; justify-content: center; }
.face-wrap { position: relative; display: grid; place-items: center; flex: 0 0 auto; }
.face {
position: relative;
z-index: 2;
display: grid;
width: var(--s);
height: var(--s);
place-items: center;
color: var(--state-text);
}
.core { display: grid; place-items: center; }
.glyph { width: 62%; height: 62%; overflow: visible; fill: currentColor; }
.old .face {
border: 1px solid var(--old-line);
border-radius: 22%;
background: var(--old-bg);
box-shadow: 0 3px 9px rgb(15 23 42 / 18%);
}
.old .core { width: 100%; height: 100%; border-radius: inherit; }
.old.working .face { border-color: #ffd45c; background: #ffd45c; color: #503c00; }
.old.open .face { border-color: #ff9f43; background: #ff9f43; color: #4a2800; }
.old.locked .face { color: var(--old-text); }
.old.hover .face { border-color: var(--accent); background: var(--accent); color: #fff; }
.old.alarm .face, .old.alarm.hover .face {
border-color: #f25a4a; background: #6f2325; color: #fff;
box-shadow: 0 0 10px rgb(242 90 74 / 65%);
}
.new .face {
border: var(--state-ring-width) solid var(--state-ring);
border-radius: 50%;
background: var(--glass);
box-shadow: var(--shadow-small), var(--shadow-large), inset 0 1px 1px rgb(255 255 255 / 70%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.new .core {
width: 76.923%;
height: 76.923%;
border-radius: 50%;
background: var(--state);
color: var(--state-text);
box-shadow: 0 4px 12px rgb(15 23 42 / 12%);
}
.new .glyph { width: 50%; height: 50%; }
.new.working { --state: var(--working); --state-text: #fff; --state-ring: var(--working); --state-ring-width: 3px; }
.new.open { --state: var(--open); --state-text: #fff; --state-ring: var(--open); --state-ring-width: 2px; }
.new.locked { --state: #07090c; --state-text: #fff; --state-ring: #07090c; --state-ring-width: 2px; }
.new.hover { --state: var(--accent); --state-text: #fff; }
.new.alarm, .new.alarm.hover { --state: var(--alarm); --state-text: #fff; --state-ring: var(--alarm); --state-ring-width: 2px; }
.unavailable { opacity: .35; }
.virtual .face { border-style: dashed; }
.ghost { opacity: .6; }
.ghost .face {
border-style: dashed;
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 25%, var(--old-bg));
color: var(--accent);
box-shadow: none;
}
.new.ghost .core { background: color-mix(in srgb, var(--accent) 18%, var(--core)); color: var(--accent); }
.ha-disabled { opacity: .62; }
.ha-disabled .face { border-color: #9aa0aa; color: #9aa0aa; }
.ha-disabled .core { background: color-mix(in srgb, #9aa0aa 24%, var(--core)); color: #9aa0aa; }
.ha-badge, .new-dot {
position: absolute;
z-index: 8;
display: grid;
width: 32%;
height: 32%;
min-width: 12px;
min-height: 12px;
place-items: center;
border: 1px solid currentColor;
border-radius: 50%;
background: var(--panel-solid);
font-size: calc(var(--s) * .15);
}
.ha-badge { right: -10%; bottom: -10%; }
.new-dot { top: -9%; right: -9%; border: 2px solid var(--panel-solid); background: #f0301f; color: #fff; }
.selected .face-wrap::after, .focused .face-wrap::before {
position: absolute;
z-index: 6;
width: calc(var(--s) + 10px);
height: calc(var(--s) + 10px);
border-radius: 50%;
content: "";
pointer-events: none;
}
.old.selected .face-wrap::after {
width: calc(var(--s) + 6px); height: calc(var(--s) + 6px); border-radius: 26%;
box-shadow: 0 0 0 3px rgb(255 193 77 / 35%);
}
.new.selected .face-wrap::after { border: 2.5px solid var(--selection); box-shadow: 0 0 9px rgb(243 160 0 / 36%); }
.focused .face-wrap::before {
width: calc(var(--s) + 18px); height: calc(var(--s) + 18px);
border: 2.5px solid var(--focus); box-shadow: 0 0 8px rgb(12 130 240 / 26%);
}
.old.focused .face-wrap::before { border-radius: 30%; }
/* Text mode. */
.text .face { width: auto; min-width: var(--s); max-width: calc(var(--s) * 4); padding: 0 calc(var(--s) * .16); }
.text .value-text {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: calc(var(--s) * .42);
}
.old.text .value-text { font-weight: 700; }
.new.text .face { border-radius: calc(var(--s) / 2); }
.new.text .core { width: auto; min-width: calc(var(--s) * .78); height: 76.923%; padding: 0 calc(var(--s) * .22); border-radius: calc(var(--s) / 2); }
.new.text .value-text { font-weight: 400; }
/* Value badge: detached today, joined Double after #158. */
.double .assembly { --gap: calc(var(--s) * .1); gap: var(--gap); }
.pos-left .assembly { flex-direction: row-reverse; }
.pos-top .assembly { flex-direction: column-reverse; }
.pos-bottom .assembly { flex-direction: column; }
.value-badge {
position: relative;
z-index: 3;
display: grid;
min-width: calc(var(--s) * 1.25);
max-width: calc(var(--s) * 4);
min-height: calc(var(--s) * .68);
padding: 0 calc(var(--s) * .16);
place-items: center;
overflow: hidden;
border: 1px solid var(--accent);
border-radius: calc(var(--s) * .18);
background: var(--old-bg);
color: var(--old-text);
font-size: calc(var(--s) * .38);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
box-shadow: 0 3px 9px rgb(15 23 42 / 14%);
}
.new.double .assembly {
--gap: 2px;
padding: 3px;
border: var(--state-ring-width) solid var(--state-ring);
border-radius: calc(var(--s) * .58);
background: var(--glass);
box-shadow: var(--shadow-small), var(--shadow-large), inset 0 1px 1px rgb(255 255 255 / 70%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.new.double .face { border: 0; background: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
.new.double .value-badge {
min-height: calc(var(--s) * .77);
border: 0;
border-radius: calc(var(--s) * .42);
background: var(--core);
color: var(--core-text);
font-weight: 400;
box-shadow: 0 4px 12px rgb(15 23 42 / 12%);
}
.new.double.pos-top .assembly, .new.double.pos-bottom .assembly { border-radius: calc(var(--s) * .5); }
.long .value-badge { width: calc(var(--s) * 3.25); }
.missing-value .value-badge { opacity: .66; }
.lqi {
position: absolute;
z-index: 9;
top: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
color: var(--lqi, #65cc73);
font-size: calc(var(--s) * .3);
font-weight: 700;
line-height: 1;
text-shadow: 0 0 3px #000, 0 0 2px #000;
white-space: nowrap;
}
.pos-bottom .lqi { top: calc(100% + var(--s) * .76); }
.legacy-secondary {
position: absolute;
top: calc(50% + var(--s) * .78);
left: calc(100% + var(--s) * .1);
min-width: 0;
}
/* Motion layers are centred on the face, never the Double assembly. */
.pulse {
position: absolute;
z-index: 0;
left: 50%; top: 50%;
width: calc(var(--s) * var(--ripple-scale));
height: calc(var(--s) * var(--ripple-scale));
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
}
.pulse i, .pulse b {
position: absolute; inset: 0; border: 2px solid var(--pulse-color, var(--accent));
border-radius: 50%; opacity: 0;
}
.old.motion-continuous .pulse i:first-child { animation: old-continuous 2.4s ease-in-out infinite; }
.old.motion-short .pulse i { animation: short-wave 1.1s ease-out 1 both; }
.old.motion-short .pulse i:nth-child(2) { animation-delay: 1.1s; }
.old.motion-short .pulse i:nth-child(3) { animation-delay: 2.2s; }
.old.motion-alarm .pulse i:first-child { border-color: #f25a4a; animation: old-alarm 1s ease-out infinite; }
.new.motion-continuous .pulse i:first-child { animation: future-continuous 3.6s cubic-bezier(.45,.05,.55,.95) infinite; }
.new.motion-continuous .pulse b {
inset: 31%; border: 0; background: var(--pulse-color, var(--accent)); filter: blur(10px);
animation: future-aura 3.6s cubic-bezier(.45,.05,.55,.95) infinite;
}
.new.motion-continuous .core { animation: future-core 3.6s cubic-bezier(.45,.05,.55,.95) infinite; }
.new.motion-short .pulse i { animation: short-wave 1.1s cubic-bezier(.22,.61,.36,1) 1 both; }
.new.motion-short .pulse i:nth-child(2) { animation-delay: 1.1s; }
.new.motion-short .pulse i:nth-child(3) { animation-delay: 2.2s; }
.new.motion-alarm .pulse i { border-width: 3px; border-color: var(--alarm); animation: future-alarm 2.4s cubic-bezier(.22,.61,.36,1) infinite; }
.new.motion-alarm .pulse i:nth-child(2) { animation-delay: 1.2s; }
.new.motion-alarm .pulse b { inset: 25%; border: 0; background: var(--alarm); filter: blur(9px); animation: future-alarm-wave 2.4s cubic-bezier(.22,.61,.36,1) infinite; }
.new.motion-alarm .core { animation: alarm-core 2.4s ease-in-out infinite; }
.activity-dot {
position: absolute; z-index: 8; right: 8%; bottom: 8%; display: none;
width: 18%; height: 18%; min-width: 4px; min-height: 4px;
border: 1px solid var(--panel-solid); border-radius: 50%; background: var(--pulse-color, var(--accent));
}
@keyframes old-continuous { from { transform: scale(.26); opacity: .5; } 65% { opacity: .18; } to { transform: scale(1); opacity: 0; } }
@keyframes old-alarm { from { transform: scale(.3); opacity: .95; } to { transform: scale(1); opacity: 0; } }
@keyframes short-wave { from { transform: scale(.18); opacity: .7; } 70% { opacity: .22; } to { transform: scale(1); opacity: 0; } }
@keyframes future-continuous { 0% { transform: scale(.35); opacity: 0; } 16% { opacity: .34; } 78%,100% { transform: scale(1); opacity: 0; } }
@keyframes future-aura { 0%,100% { transform: scale(.96); opacity: .14; } 50% { transform: scale(1.16); opacity: .34; } }
@keyframes future-core { 0%,100% { transform: scale(1); } 50% { transform: scale(1.03); } }
@keyframes future-alarm { 0% { transform: scale(.34); opacity: 0; } 10% { opacity: .82; } 58% { opacity: .38; } 100% { transform: scale(1); opacity: 0; } }
@keyframes future-alarm-wave { 0% { transform: scale(.4); opacity: .08; } 26% { opacity: .26; } 72% { opacity: .09; } 100% { transform: scale(1); opacity: 0; } }
@keyframes alarm-core { 0%,100% { transform: scale(1); } 48% { transform: scale(1.055); } }
body.reduced .pulse i, body.reduced .pulse b, body.reduced .core { animation: none !important; }
body.reduced .pulse i, body.reduced .pulse b { display: none; }
body.reduced .old.motion-continuous .activity-dot,
body.reduced .old.motion-short .activity-dot,
body.reduced .new.motion-short .activity-dot { display: block; }
body.reduced .new.motion-continuous .pulse b,
body.reduced .new.motion-alarm .pulse b {
display: block; inset: 31%; opacity: .22; transform: scale(1.04);
}
body.paused *, body.paused *::before, body.paused *::after { animation-play-state: paused !important; }
/* Rows that demonstrate reduced motion stay reduced even when the global toggle is off. */
.force-reduced .pulse i { display: none !important; animation: none !important; }
.force-reduced .old.motion-continuous .activity-dot,
.force-reduced .old.motion-short .activity-dot,
.force-reduced .new.motion-short .activity-dot { display: block; }
.force-reduced .new.motion-continuous .pulse b,
.force-reduced .new.motion-alarm .pulse b { display: block; inset: 31%; opacity: .22; transform: scale(1.04); animation: none; }
.force-reduced .core { animation: none !important; }
.lqi-set { display: flex; align-items: center; justify-content: center; gap: calc(var(--s) * .55); }
.lqi-set .marker { transform: scale(.9); }
footer { margin-top: 18px; color: var(--muted); font-size: 12px; line-height: 1.5; text-align: center; }
@media (max-width: 780px) {
.page { width: min(100% - 16px, 1520px); margin-top: 8px; }
.hero { padding: 20px; }
.decision { grid-template-columns: 1fr; }
.controls { position: static; }
thead th { top: 0; }
.count { width: 100%; margin-left: 0; }
}
@media (forced-colors: active) {
.face, .value-badge, .new.double .assembly { border: 2px solid CanvasText !important; background: Canvas !important; color: CanvasText !important; }
.core { background: Canvas !important; color: CanvasText !important; }
.selected .face-wrap::after { border-color: Highlight !important; }
}
</style>
</head>
<body data-theme="light" data-variants="both" data-background="plain">
<svg width="0" height="0" aria-hidden="true" style="position:absolute">
<symbol id="i-bulb" viewBox="0 0 24 24"><path d="M9 21h6v-1H9v1m3-19a7 7 0 0 0-4 12.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26A7 7 0 0 0 12 2m2.85 11.1-.85.65V16h-4v-2.25l-.85-.65A5 5 0 1 1 14.85 13.1Z"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><path d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4m6-9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h1V6a5 5 0 0 1 10 0v2h1m-6-5a3 3 0 0 0-3 3v2h6V6a3 3 0 0 0-3-3Z"/></symbol>
<symbol id="i-unlock" viewBox="0 0 24 24"><path d="M18 8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2h9V6a3 3 0 0 0-5.83-1H7.1A5 5 0 0 1 17 6v2h1m-6 9a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M13 14h-2V9h2v5m0 4h-2v-2h2v2m-1-16A10 10 0 1 0 22 12 10 10 0 0 0 12 2Z"/></symbol>
<symbol id="i-fan" viewBox="0 0 24 24"><path d="M12 11a1 1 0 1 0 0 2 1 1 0 0 0 0-2m.33-9c2.25 0 3.35 2.7 1.76 4.29l-1.11 1.1c-.32.32-.54.72-.64 1.16A3.5 3.5 0 0 0 10 8.78V7.21C10 4.34 10.87 2 12.33 2M4.19 7.5c1.13-1.95 4.02-1.55 4.78.62l.52 1.48c.15.43.42.8.78 1.08a3.5 3.5 0 0 0-.95 2.16l-1.36-.79C5.48 10.62 3.46 8.77 4.19 7.5m1.49 10.78c-1.13-1.95.66-4.25 2.99-3.81l1.54.29c.45.08.91.04 1.33-.13a3.5 3.5 0 0 0 1.39 1.93l-1.36.79c-2.49 1.43-5.16 2.2-5.89.93m12.65.22c-2.25 0-3.35-2.7-1.76-4.29l1.11-1.1c.32-.32.54-.72.64-1.16a3.5 3.5 0 0 0 2.34-.23v1.57c0 2.87-.87 5.21-2.33 5.21Z"/></symbol>
</svg>
<main class="page">
<header class="hero">
<p class="eyebrow">Issue #158 · S3 specification artifact</p>
<h1>Все состояния устройств: сейчас → после реализации</h1>
<p class="lede">Обе колонки строятся из одной state-матрицы. Левая воспроизводит текущий квадратный marker, правая — принятый круглый glass Device Face. По умолчанию каждое состояние одновременно показано в светлой и тёмной теме; переключатели ниже меняют все строки.</p>
<span class="prototype">Это визуальный прототип ТЗ, а не production renderer</span>
</header>
<section class="decision" aria-label="Принятые решения владельца">
<div><b>Continuous Working</b><span>SVG нормативен: 3,6 с, halo/aura + лёгкий breathing core; пользовательские color/size сохраняются.</span></div>
<div><b>Double Open</b><span>Геометрия Active во всех четырёх положениях, semantic green <code>#1DC21D</code>, neutral value.</span></div>
<div><b>Явно не внедряем</b><span>Virtual Active, новый Unavailable и новый LQI из архива. Действуют решения владельца.</span></div>
</section>
<section class="controls" aria-label="Параметры проверки">
<label class="control"><span>Темы в таблице</span><select id="theme"><option value="both">Обе</option><option value="light">Только светлая</option><option value="dark">Только тёмная</option></select></label>
<label class="control"><span>Фон плана</span><select id="background"><option value="plain">Нейтральный</option><option value="color">Цветной</option></select></label>
<div class="control"><span>Размер marker</span><div class="segmented" id="sizes"><button data-size="32">32</button><button class="active" data-size="56">56</button><button data-size="96">96</button></div></div>
<label class="check"><input id="reduced" type="checkbox"> Reduced motion</label>
<label class="check"><input id="paused" type="checkbox"> Пауза анимаций</label>
<span class="count" id="count"></span>
</section>
<section class="matrix-wrap">
<table>
<thead><tr><th>Состояние</th><th>Сейчас</th><th>После #158</th><th>Контракт</th></tr></thead>
<tbody id="matrix"></tbody>
</table>
</section>
<footer>Страница автономна: внешние шрифты, изображения, сеть и Home Assistant не используются. Реальная приёмка выполняется на production bundle через unit, smoke, golden и performance gates.</footer>
</main>
<script>
const icon = (name = 'bulb') => `<svg class="glyph" viewBox="0 0 24 24" aria-hidden="true"><use href="#i-${name}"></use></svg>`;
const pulse = (kind) => kind ? `<span class="pulse" aria-hidden="true"><i></i><i></i><i></i><b></b></span><span class="activity-dot" aria-hidden="true"></span>` : '';
function marker(version, options = {}) {
const {
state = '', iconName = 'bulb', text = '', value = '', position = 'right',
pulseKind = '', virtual = false, unavailable = false, selected = false,
focused = false, ghost = false, disabled = false, newDevice = false,
lqi = '', lqiColor = '', long = false, missingValue = false,
secondary = '', forceReduced = false,
} = options;
const classes = [
'marker', version, state, text ? 'text' : '', value ? 'double' : '',
value ? `pos-${position}` : '', pulseKind ? `motion-${pulseKind}` : '',
virtual ? 'virtual' : '', unavailable ? 'unavailable' : '',
selected ? 'selected' : '', focused ? 'focused' : '', ghost ? 'ghost' : '',
disabled ? 'ha-disabled' : '', long ? 'long' : '', missingValue ? 'missing-value' : '',
forceReduced ? 'force-reduced' : '',
].filter(Boolean).join(' ');
const pulseColor = state === 'working' ? 'var(--working)' : state === 'open' ? 'var(--open)' : state === 'alarm' ? 'var(--alarm)' : 'var(--accent)';
const content = text
? `<span class="value-text" title="${text}">${text}</span>`
: icon(iconName);
const badge = value ? `<span class="value-badge" title="${value}">${value}</span>` : '';
const legacy = secondary ? `<span class="value-badge legacy-secondary">${secondary}</span>` : '';
return `<div class="${classes}" style="--pulse-color:${pulseColor};${lqiColor ? `--lqi:${lqiColor}` : ''}">
<span class="assembly">
<span class="face-wrap">
${pulse(pulseKind)}
<span class="face"><span class="core">${content}</span></span>
${disabled ? '<span class="ha-badge">×</span>' : ''}
${newDevice ? '<span class="new-dot"></span>' : ''}
${lqi ? `<span class="lqi">${lqi}</span>` : ''}
${legacy}
</span>
${badge}
</span>
</div>`;
}
function lqiSet(version) {
return `<div class="lqi-set">
${marker(version, { lqi: '40', lqiColor: '#f0410c' })}
${marker(version, { lqi: '100', lqiColor: '#f0a00c' })}
${marker(version, { lqi: '180', lqiColor: '#1dc21d' })}
</div>`;
}
const rows = [
{ group: 'Базовый Device Face' },
{ title: 'Default / neutral', code: 'available + neutral', options: {}, note: 'Новый круглый glass shell и внутренний core. MDI и anchor не меняются.' },
{ title: 'Hover', code: 'neutral + fine pointer hover', options: { state: 'hover' }, note: 'Blue core #0C82F0. Touch/pen не должны фиксировать hover.' },
{ title: 'Working / Active', code: 'status=working', options: { state: 'working', iconName: 'fan' }, note: 'Жёлтый #F0A00C и 3 px semantic ring.' },
{ title: 'Open / Unlocked', code: 'status=open', options: { state: 'open', iconName: 'unlock' }, note: 'Зелёный #1DC21D; generic open сохраняет собственный MDI.' },
{ title: 'Locked', code: 'lock=locked', options: { state: 'locked', iconName: 'lock' }, note: 'Парный Lock reference: чёрный core/ring, белый glyph.' },
{ title: 'Alarm / Alert', code: 'status=alarm', options: { state: 'alarm', iconName: 'alert' }, note: 'Красный #F0410C имеет максимальный semantic priority.' },
{ title: 'Alarm + hover', code: 'alarm + pointer hover', options: { state: 'alarm hover', iconName: 'alert' }, note: 'Hover не превращает тревогу в синюю.' },
{ group: 'Availability и virtual' },
{ title: 'Unavailable', code: 'availability=unavailable', options: { unavailable: true }, note: 'Оба варианта используют прежнюю общую opacity 0.35; отдельный новый asset не внедряется.' },
{ title: 'Virtual idle', code: 'binding=virtual', options: { virtual: true }, note: 'Default face + постоянный dashed outer shell.' },
{ title: 'Virtual hover', code: 'virtual + fine pointer hover', options: { virtual: true, state: 'hover' }, note: 'Blue hover core, dashed shell остаётся. Pulse невозможен.' },
{ title: 'Static icon', code: 'display=static_icon', options: {}, note: 'Всегда neutral: без live state, value, LQI и pulse.' },
{ group: 'Редактор и accessibility overlays' },
{ title: 'Selected', code: 'editor selection', options: { selected: true }, note: 'Amber overlay не подменяет face.' },
{ title: 'Selected + hover', code: 'selected + pointer hover', options: { selected: true, state: 'hover' }, note: 'Hover core и selection ring видны одновременно.' },
{ title: 'Selected + Working', code: 'selected + working', options: { selected: true, state: 'working', iconName: 'fan' }, note: 'Working остаётся жёлтым под amber overlay.' },
{ title: 'Selected + Open', code: 'selected + open', options: { selected: true, state: 'open', iconName: 'unlock' }, note: 'Open остаётся зелёным под amber overlay.' },
{ title: 'Selected + Alarm', code: 'selected + alarm', options: { selected: true, state: 'alarm', iconName: 'alert' }, note: 'Alarm остаётся красным.' },
{ title: 'Keyboard focus', code: ':focus-visible', options: { focused: true }, note: 'Новый отдельный синий focus ring; pointer focus его не показывает.' },
{ title: 'Selected + Focus + Alarm', code: 'all overlays', options: { selected: true, focused: true, state: 'alarm', iconName: 'alert' }, note: 'Оба overlays различимы, focus снаружи, face остаётся красным.' },
{ title: 'Hidden ghost', code: 'editor ghost', options: { ghost: true }, note: 'Существующий editor chrome сохраняется поверх новой геометрии.' },
{ title: 'HA-disabled ghost', code: 'ghost + ha-disabled', options: { ghost: true, disabled: true }, note: 'Neutral grey и plug-off diagnostic badge не меняют смысл.' },
{ title: 'New device dot', code: 'new inbox marker', options: { newDevice: true }, note: 'Красная точка остаётся отдельным badge.' },
{ group: 'Text и Double' },
{ title: 'Text Default', code: 'display=value', options: { text: '43%' }, note: 'Regular 400, круглая Text pill, максимум 4× marker.' },
{ title: 'Text Hover', code: 'value + hover', options: { text: '43%', state: 'hover' }, note: 'Blue semantic surface и полный accessible text.' },
{ title: 'Text Working', code: 'value + working', options: { text: '43%', state: 'working' }, note: 'Value-only не теряет live-state semantics.' },
{ title: 'Text Open', code: 'value + open', options: { text: '43%', state: 'open' }, note: 'Green variant строится общими semantic tokens.' },
{ title: 'Text Locked', code: 'value + authoritative lock=locked', options: { text: 'locked', state: 'locked' }, note: 'Black Lock token приходит из transient lock facet, не из текста или MDI.' },
{ title: 'Text Alarm', code: 'value + alarm', options: { text: '43%', state: 'alarm' }, note: 'Alarm остаётся красным и без MDI.' },
{ title: 'Text long value', code: 'max-width + ellipsis', options: { text: 'very long unavailable value', long: true }, note: 'В production полный текст остаётся в title/aria-label.' },
{ title: 'Double Right', code: 'value_badge.position=right', options: { value: '23.5 °C', position: 'right' }, note: 'Сегодня badge отделён; target — одна композиция.' },
{ title: 'Double Left + Hover', code: 'position=left + hover', options: { value: '23.5 °C', position: 'left', state: 'hover', iconName: 'fan' }, note: 'Value section theme-neutral и текст горизонтален.' },
{ title: 'Double Up + Working', code: 'position=top + working', options: { value: '23.5 °C', position: 'top', state: 'working', iconName: 'fan' }, note: 'Исправленный vertical layout из итогового пакета.' },
{ title: 'Double Down + Open', code: 'position=bottom + open', options: { value: '100 %', position: 'bottom', state: 'open', iconName: 'unlock' }, note: 'Принятый default: Active geometry + green #1DC21D.' },
{ title: 'Double Left + Locked', code: 'position=left + lock=locked', options: { value: '100 %', position: 'left', state: 'locked', iconName: 'lock' }, note: 'Black Lock token; neutral value section; glyph сам не является resolver evidence.' },
{ title: 'Double Right + Alarm', code: 'position=right + alarm', options: { value: '23 %', position: 'right', state: 'alarm', iconName: 'alert' }, note: 'Value neutral; icon section и outer ring красные.' },
{ title: 'Double long value', code: 'right + max-width', options: { value: 'very long unavailable value', position: 'right', long: true, missingValue: true }, note: 'Ellipsis и существующая unavailable opacity badge.' },
{ title: 'Legacy secondary metric', code: 'legacy temp + humidity', options: { value: '23 °C', position: 'right', secondary: '43 %' }, note: 'Вторая legacy metric остаётся satellite и не создаёт тройную pill.' },
{ group: 'Motion' },
{ title: 'Continuous Default', code: 'running + neutral', options: { pulseKind: 'continuous' }, note: 'Сейчас 2,4 с; target SVG — 3,6 с, ring + aura + breathing core.' },
{ title: 'Continuous Working', code: 'running + working', options: { pulseKind: 'continuous', state: 'working', iconName: 'fan' }, note: 'Default color наследует yellow; ripple_color может переопределить.' },
{ title: 'Continuous Open', code: 'running + open', options: { pulseKind: 'continuous', state: 'open', iconName: 'unlock' }, note: 'Green motion variant; hover не перезапускает цикл.' },
{ title: 'Short Activity', code: 'event/transition edge', options: { pulseKind: 'short' }, note: 'Три кольца 1,1 с, total 3,3 с; retrigger сохраняется.' },
{ title: 'Alarm pulse', code: 'alarm dynamic display', options: { pulseKind: 'alarm', state: 'alarm', iconName: 'alert' }, note: 'Сейчас одна волна 1 с; target — две волны 2,4 с + red aura/core.' },
{ title: 'Reduced Continuous', code: 'prefers-reduced-motion', options: { pulseKind: 'continuous', state: 'working', forceReduced: true }, note: 'Target: static halo без transform; обычная activity не мигает.' },
{ title: 'Reduced Short', code: 'short + reduced motion', options: { pulseKind: 'short', forceReduced: true }, note: 'Сохраняется существующая semantic dot.' },
{ group: 'Намеренно без редизайна' },
{ title: 'Zigbee LQI Low / Mid / High', code: '40 / 100 / 180', special: 'lqi', note: 'Значения, градиент, шрифт, позиция и thresholds пиксельно не меняются.' },
{ title: 'Bottom Double + system LQI', code: 'badge bottom stacks LQI', options: { value: '23.5 °C', position: 'bottom', lqi: '180', lqiColor: '#1dc21d' }, note: 'LQI сдвигается ниже полной Double composition, источник не меняется.' },
{ title: 'Unavailable value source', code: 'badge availability=unavailable', options: { value: '—', missingValue: true }, note: 'Локальная opacity 0.66 сохраняется; wrapper unavailable при необходимости умножается отдельно.' },
];
const tbody = document.querySelector('#matrix');
const themedSamples = (content) => `<div class="sample">
<div class="theme-variant theme-light"><span class="theme-label">Светлая</span>${content}</div>
<div class="theme-variant theme-dark"><span class="theme-label">Тёмная</span>${content}</div>
</div>`;
let count = 0;
tbody.innerHTML = rows.map((row) => {
if (row.group) return `<tr class="group-row"><th colspan="4">${row.group}</th></tr>`;
count += 1;
const before = row.special === 'lqi' ? lqiSet('old') : marker('old', row.options);
const after = row.special === 'lqi' ? lqiSet('new') : marker('new', row.options);
return `<tr data-state="${row.title}">
<td><span class="state-title">${row.title}</span><code class="state-code">${row.code}</code></td>
<td>${themedSamples(before)}</td>
<td>${themedSamples(after)}</td>
<td class="contract">${row.note}</td>
</tr>`;
}).join('');
document.querySelector('#count').textContent = `${count} проверяемых строк`;
document.querySelector('#theme').addEventListener('change', (event) => {
const mode = event.target.value;
document.body.dataset.variants = mode;
document.body.dataset.theme = mode === 'dark' ? 'dark' : 'light';
});
document.querySelector('#background').addEventListener('change', (event) => { document.body.dataset.background = event.target.value; });
document.querySelector('#reduced').addEventListener('change', (event) => { document.body.classList.toggle('reduced', event.target.checked); });
document.querySelector('#paused').addEventListener('change', (event) => { document.body.classList.toggle('paused', event.target.checked); });
document.querySelector('#sizes').addEventListener('click', (event) => {
const button = event.target.closest('button[data-size]');
if (!button) return;
document.documentElement.style.setProperty('--s', `${button.dataset.size}px`);
document.querySelectorAll('#sizes button').forEach((item) => item.classList.toggle('active', item === button));
});
</script>
</body>
</html>
+626
View File
@@ -0,0 +1,626 @@
# Issue #158 — обновление внешнего вида маркеров устройств
- Issue: [#158](https://github.com/Matysh/houseplan-card/issues/158)
- Приоритет: P1
- Ветка: `issue/158-device-icons`
- Статус документа: готово к реализации; issue намеренно остаётся в `S3-spec`
- Сравнительный прототип:
[158-device-icons-comparison.html](158-device-icons-comparison.html)
- Основание: исходный пакет дизайна, аналитика от 2026-08-15, решения владельца
от 2026-08-15 и 2026-08-17, итоговый архив дизайнера
[`House plan Icons.zip`](https://github.com/user-attachments/files/31141782/House.plan.Icons.zip)
## 1. Пользовательская проблема и результат
Маркеры устройств — главный интерактивный слой House Plan, но текущая квадратная
плашка, отдельный value badge и разнородные кольца активности визуально тяжелее
плана и хуже читаются поверх сложного фона. Состояния существуют, однако их
оформление формировалось по частям: обычное устройство, тревога, виртуальное
устройство, значение, LQI, selected и pulse используют разные визуальные правила.
После #158 все поверхности используют один круглый glass Device Face:
- neutral, hover, working, open/unlocked, locked и alarm имеют единый язык;
- `value` становится круглой Text pill, а icon + внешний value badge — цельной
Double-композицией;
- short, continuous и alarm pulse исходят из той же круглой геометрии;
- virtual, unavailable, editor selection и keyboard focus остаются различимыми;
- светлая и тёмная тема, цветной фон, отсутствие blur и размеры 32/56/96 px
входят в проверяемый контракт;
- MDI glyph, state resolver, HA bindings, click/tap actions, координаты и
сохранённые настройки не меняются.
Это визуальный redesign существующего поведения, а не новая система состояний.
## 2. Источники истины и их приоритет
При расхождении материалов применяется такой порядок:
1. решения владельца в issue;
2. это ТЗ;
3. действующие продуктовые контракты #98 и #90;
4. фактические SVG из итогового архива как visual/motion reference;
5. markdown-файлы внутри архива;
6. более ранние архивы и комментарии дизайнера.
Следствия:
- Continuous Working использует фактический animated SVG с циклом **3,6 с**,
halo/aura и лёгким breathing core; строка `2,4 s` в
`ACTIVE_ANIMATION_SPEC.md` итогового архива не нормативна;
- `Virtual Device Active.svg` не используется: у virtual нет semantic active,
open/alarm и pulse;
- отдельные новые `Unavailable*.svg` не используются: действует текущая общая
opacity `0.35`;
- новые SVG Zigbee LQI не используются: LQI остаётся без изменений;
- отсутствующий Double Open/Unlock строится из геометрии Double Active и
зелёного semantic token `#1DC21D`;
- SVG — референсы для общего динамического DOM/CSS-компонента. Production не
подменяет целиком SVG при изменении HA state и не встраивает Roboto data URI.
## 3. Подтверждённая техническая база
На момент подготовки ТЗ:
- `resolveDevicePresentation()` уже единожды проецирует availability, status,
display, value badge, LQI и pulse для Full, Static и preview;
- `renderDeviceFace()` в `src/device-face.ts` является общей DOM-точкой для
`interactive-plan`, `static-card` и `preview`;
- semantic classes `on`, `open`, `alarm`, `unavail`, `virtual`, `ghost`,
`ha-disabled`, `sel` навешиваются owning surface;
- marker сейчас квадратный: `border-radius: 22%`, `1px` border,
`--hp-bg/--hp-line`, glyph `62%` от `--dev-size`;
- hover заменяет ordinary `on/open` на accent, а alarm сохраняет красный;
- unavailable — opacity `0.35` на всей `.dev`;
- virtual — dashed border во всех состояниях;
- value-only ограничен шириной `4 × --dev-size`, использует ellipsis;
- внешний value badge поддерживает right/bottom/left/top и при bottom сдвигает
системный LQI ниже;
- short — три волны по 1,1 с, continuous — одна бесконечная волна, alarm —
отдельный красный pulse; reduced motion обычной активности использует dot;
- `ripple_color` и `ripple_size` уже сохранены в config и санитизируются перед
передачей в CSS custom properties;
- `static_icon` намеренно запрещает live state, value и pulse;
- MDI glyph остаётся HTML overlay и не получает perspective transform в
скрытой изометрии.
Текущий `DeviceStatus` не отличает `lock=locked` от ordinary neutral. Поэтому
чёрный Lock reference нельзя реализовать CSS-проверкой glyph. Presentation
должен получить неперсистентную lock-фасету (конкретное имя типа не нормативно),
вычисленную из выбранного доступного HA lock source:
- `locked`, только если authoritative lock state равен `locked`;
- `unlocked`, если state равен `unlocked/open` — он продолжает давать общий
semantic status `open`;
- `null` для не-lock устройств, unknown/unavailable и неоднозначного источника.
Alarm/working/open имеют приоритет над locked-фасетой. Пользовательский MDI
`mdi:lock` на произвольном switch не создаёт locked state. Фасета не сохраняется
в config, не меняет actions и не добавляет новый источник активности.
## 4. Scope
### 4.1. Общий Device Face
- новый layered DOM/CSS face для icon и value-only;
- light/dark tokens, `backdrop-filter: blur(20px)` и `-webkit-` вариант;
- no-blur fallback без изменения semantic state;
- пропорциональное масштабирование от существующего `--dev-size`;
- единая геометрия Full, `houseplan-space-card`, `hp-device-preview` и preview
диалога устройства;
- screen-parallel marker в flat и скрытой изометрии.
### 4.2. Состояния и overlays
- neutral/default, hover, working/active, open/unlocked, locked, alarm/alert;
- virtual default/hover;
- unavailable;
- editor selected, ghost и HA-disabled;
- `:focus-visible` для реально фокусируемого интерактивного marker;
- static icon;
- alert/continuous/short и reduced-motion варианты.
### 4.3. Значения
- Text для `display: value`;
- Double для icon + одного внешнего value badge;
- right/bottom/left/top;
- короткое, обычное и длинное значение, ellipsis и title/accessible text;
- сохранение legacy secondary metric и существующего LQI stacking.
### 4.4. Проверки и документация
- unit, browser smoke, golden и performance coverage;
- сравнительная страница из этого ТЗ как review aid, не production surface;
- RU/EN changelog и пользовательская документация в implementation commit.
## 5. Non-scope
- изменение MDI picker, auto-icon rules или fallback glyph;
- изменение resolver приоритетов HA state/activity из #98;
- новые HA bindings, service calls, действия click/tap или more-info;
- изменение источника, форматирования или сохранённой позиции value badge #90;
- редизайн Zigbee LQI, его градиента, шрифта, позиции или thresholds;
- отдельный unavailable visual помимо opacity `0.35`;
- virtual active/open/alarm или virtual pulse;
- low-zoom LOD, clustering, collision avoidance и автоматическое перемещение;
- touch-hover/pressed fixes из #22/#154;
- изменение vacuum puck, Glow, room fills, солнца, decor или backdrop;
- публичное включение скрытой изометрии;
- новые persisted поля, schema version или migration.
## 6. Нормативная геометрия и tokens
`--dev-size` продолжает означать пользовательский размер marker. В новой
геометрии это диаметр внешней основной оболочки без overflow shadow/pulse.
| Элемент | Сейчас | После #158 |
|---|---|---|
| Marker shell | квадрат, radius `22%` | круг, `100%` diameter |
| Core | отсутствует | круг `80/104 = 76.923%` shell |
| Glyph | `62%` marker | ориентир `40/104 = 38.462%` shell |
| Glass blur | нет | `20px`, без зависимости от marker size |
| Neutral outer border | `1px --hp-line` | `1.5px rgb(255 255 255 / 75%)` |
| Neutral core | `--hp-bg` | light `#fff`, dark theme-neutral surface |
| Neutral glyph | `--hp-txt` | light `#000`, dark контрастный token из reference |
| Hover | `--hp-accent` | core `#0C82F0`, белый glyph |
| Working | `--hp-on` | core `#F0A00C`, белый glyph, `3px` ring |
| Open/Unlock | `--hp-open` | core/ring `#1DC21D`, белый glyph, `2px` ring |
| Locked | neutral face | black core/ring по Lock reference, белый lock glyph |
| Alarm | `#6f2325/#f25a4a` | core/ring `#F0410C`, белый glyph, `2px` ring |
| Shadow | `--shadow-1` | `0 2px 3px rgb(37 40 45 / 12%)`, `0 8px 14px -2px rgb(37 40 45 / 18%)`, inner highlight `0 1px 1px rgb(255 255 255 / 70%)` |
| Text | rectangle, bold `45%` | round glass pill, Roboto/system regular `400` |
| Double | detached badge with gap | one glass composition with two inner sections |
Implementation may express the same pixels through equivalent relative CSS
tokens. Hardcoded reference-canvas coordinates `127/130/176px` are not copied
into production; ratios are normalized against `--dev-size`.
Supported visual checkpoints are 32, 56 and 96 px. Existing saved scale values
outside these checkpoints remain readable and are not clamped. Pointer/touch hit
area remains governed by the owning surface; this task does not silently move
the saved marker or expand collision geometry.
## 7. State and overlay matrix
### 7.1. Real device
| Resolver result / interaction | Face | Pulse | Overlay |
|---|---|---|---|
| neutral | Default | none | none |
| neutral + true mouse hover | Hover blue | unchanged | raised z-index |
| working | Active yellow | per #98/display | none |
| working + hover | Hover blue | cycle continues, not restarted | none |
| open/unlocked | Open green | per #98/display | none |
| open/unlocked + hover | Hover blue | cycle continues, not restarted | none |
| locked | Lock black reference | per existing activity | none |
| alarm | Alert red | alarm | none |
| alarm + hover | Alert red | alarm continues | hover may adjust shadow only |
| unavailable | resolved face × `opacity: 0.35` | none | no false active hover |
| selected | underlying face unchanged | unchanged | amber selection ring/glow |
| focus-visible | underlying face unchanged | unchanged | separate blue focus ring |
| selected + focus | underlying face unchanged | unchanged | both rings remain distinguishable; focus outermost |
Hover applies only for `@media (hover: hover) and (pointer: fine)` or the
equivalent existing pointer policy. Touch/pen must not latch a hover state.
### 7.2. Virtual
- shell is dashed in idle and hover;
- idle uses Default core/glyph;
- hover uses the same blue core/white glyph as a real neutral device, shell
stays dashed;
- presentation is forced neutral and activity `none`;
- virtual never displays working/open/alarm face and never renders short,
continuous or alarm pulse, even if an upstream source is malformed;
- selection/focus may wrap a virtual marker in an editor just like Default.
### 7.3. Unavailable, ghost and HA-disabled
- unavailable keeps exactly the existing wrapper opacity `0.35`; no separate
fill, border, glyph or hover asset is used;
- source-level unavailable/missing value badge keeps its existing `0.66` local
opacity in addition to wrapper state where applicable;
- hidden-device ghost and HA-disabled ghost keep current editor semantics,
colors, plug-off badge and no-live-state rule; their chrome is layered over
the new Default geometry rather than reclassified as a product state;
- unavailable/ghost/disabled never start pulse.
### 7.4. State precedence
Layers are composed rather than collapsed into one class:
1. availability/ghost visibility gate;
2. pulse behind marker;
3. virtual dashed-shell variant;
4. resolved semantic face;
5. hover face override for non-alarm real devices;
6. selected ring;
7. focus-visible ring;
8. new-device/HA-disabled badges, value badge and LQI.
Alarm always keeps the red semantic face. Focus and selection do not erase it.
Locked применяется только из нормативной lock-фасеты §3, а не из имени glyph.
## 8. Display modes and values
| Display / supplement | Target contract |
|---|---|
| `badge` | dynamic icon face plus eligible live state, alarm and external value |
| `icon_ripple` | same face plus short/continuous activity |
| `value` | Text pill; same availability/status/hover projection, no MDI |
| `static_icon` | Default face only; no live state, value, LQI or pulse |
| external value badge | Double composition when exactly one configured/legacy primary badge exists |
| legacy secondary metric | remains a second satellite; it is not merged into a third Double section |
| system LQI | current text, color, position and thresholds unchanged |
### 8.1. Text
- height equals the marker shell diameter;
- minimum width is one marker diameter, maximum stays `4 × --dev-size`;
- horizontal padding scales with marker size;
- text is one line, regular weight `400`, vertically and horizontally centred;
- overflow uses ellipsis; full text remains in `title`/accessible label;
- semantic background follows the same Default/Hover/Working/Open/Locked/Alarm tokens
as icon core so current live-state information is not lost.
### 8.2. Double
- icon section keeps the exact marker anchor and `--dev-size`;
- value section joins it inside one outer glass outline;
- right/left extend horizontally; top/bottom extend vertically;
- text remains horizontal for every position;
- value section stays theme-neutral; semantic color affects the icon section
and outer state ring;
- Default/Hover/Working/Alarm follow the provided four-position references;
- Open/Unlock uses Double Active geometry with `#1DC21D` ring/core;
- Locked uses the same Double geometry with the black Lock token;
- long value keeps the existing maximum width and ellipsis contract;
- no coordinate, badge source, formatting or persistence change is allowed;
- bottom Double still reserves the first satellite row and moves system LQI
below the complete composition.
## 9. Motion contract
### 9.1. Alarm
- two red outline waves, `3px`, duration `2.4s`, second delay `1.2s`;
- easing `cubic-bezier(.22,.61,.36,1)`, maximum scale `1.5`;
- red soft wave and core breathing `1 → 1.055 → 1` follow the SVG;
- alarm is available for every dynamic display, independent of ordinary
live-state styling, and absent for `static_icon`/unavailable/virtual;
- hover, selected and focus do not restart or recolor it;
- reduced motion: no looping transforms; one static red halo remains.
### 9.2. Continuous Working
- reasons and gating remain exactly #98: `presence`, `transition`, `running`,
only for `display: icon_ripple`;
- one ring plus aura and subtle core breathing follow actual light/dark SVG;
- duration **3.6s**, easing `cubic-bezier(.45,.05,.55,.95)`, infinite;
- Default, Yellow and Green base variants are supported;
- default ring/aura color inherits semantic face; saved `ripple_color` overrides
color and saved `ripple_size` overrides maximum diameter/scale;
- RGB light fallback and safe color sanitization remain unchanged;
- hover does not restart the timeline;
- reduced motion: ring hidden, static semantic aura/halo remains; no transform.
### 9.3. Short Activity
- reasons, edge generation and retrigger remain #98;
- three rings, each `1.1s`, delays `0/1.1/2.2s`, total `3.3s`, one-shot;
- a retrigger restarts at ring one through the existing generation identity;
- saved `ripple_color` and `ripple_size` keep priority;
- only centre/outline geometry changes to the circular shell;
- reduced motion keeps the existing static activity dot and no ring animation.
### 9.4. Runtime ownership
No timers or state are added to `renderDeviceFace()`. Existing activity runtime
owns short expiry/retrigger. CSS owns continuous/alarm animation. Re-rendering an
unchanged HA state must not reset infinite timelines.
## 10. Themes, background and fallback
- theme follows current HA light/dark context; no per-marker theme field;
- backdrop blur is `20px` for the glass shell and joined value surface;
- `-webkit-backdrop-filter` is included for supported WebViews;
- when backdrop-filter is unsupported, use an opaque theme-neutral surface with
the same outline, state color and shadow; functionality never depends on blur;
- light, dark and non-uniform/colorful plan backgrounds are golden scenarios;
- `forced-colors` keeps a visible boundary and uses system colors where the
glass/color design would disappear;
- no surface may sample or rewrite the underlying plan color.
## 11. Surfaces and parity
The following consume the same state resolver, shared face markup and tokens:
| Surface | Live state | Hover/action | Values/pulse | Notes |
|---|---:|---:|---:|---|
| Full View | yes | yes | yes | canonical interactive surface |
| Device editor View | yes | drag/select | yes | editor overlays compose |
| `houseplan-space-card` | yes | no device action/hover | yes | read-only parity |
| `hp-device-preview` | yes/demo | preview controls | yes | exact saved settings |
| marker dialog preview | yes/demo | no production service call | yes | safe area includes Double/pulse |
| hidden isometric View | yes | yes | yes | face stays screen-parallel |
`static_icon` is a display mode, not the same thing as the static space card.
## 12. Architecture contract
### 12.1. One renderer
- extend `renderDeviceFace()` with stable semantic layers (`shell`, `core`,
glyph/value, overlays) rather than duplicating markup per surface;
- keep owning wrapper responsible for coordinates, pointer handlers, tooltip,
selection and surface-specific accessibility;
- keep `resolveDevicePresentation()` and `resolveDevicePulse()` as the only
semantic projection; CSS must not infer HA state from glyph names;
- Double geometry is a layout variant of the same face/value badge, not a new
value resolver;
- raw designer SVG may be used in tests/reference only, not as runtime state
components.
### 12.2. CSS tokens
New CSS custom properties must have one definition point and safe fallbacks.
Expected groups:
- geometry: face/core ratio, outline widths, joined badge gap/overlap;
- theme: glass/core/glyph/shadow;
- semantic: hover/working/open/locked/alarm;
- overlay: selected/focus;
- motion: color and maximum scale.
Inline user colors continue through `safeRenderColor()`. No unsanitized config
string is interpolated into CSS, class or SVG markup.
### 12.3. Stable DOM
- unchanged state frames reuse the same node structure;
- animation layers exist only when the resolver returns a pulse;
- `will-change` is limited to actively animated layers and removed otherwise;
- no canvas snapshot, data-URI font or one-SVG-per-state download is introduced.
## 13. Data, compatibility and migration
- config schema and storage are unchanged;
- `display`, `ripple_color`, `ripple_size`, marker scale/angle, value badge source
and position round-trip byte-for-byte when not edited;
- old configs receive the new visual automatically;
- future/unknown config fields remain lossless;
- no import/export version bump;
- rollback to the previous bundle restores the old visual without data loss;
- cached/restored snapshots need no migration.
## 14. Accessibility, keyboard and touch
- glyph/state remains represented by existing accessible device name/state;
- values expose the full untruncated string;
- a focusable interactive marker gains a `:focus-visible` ring; programmatic or
pointer focus must not create persistent keyboard chrome;
- selected and focus are not color-only aliases of working/alarm;
- reduced motion rules in §9 are mandatory;
- marker touch behaviour, gesture arbitration and action targets do not change;
- static/read-only surfaces do not become focusable solely for appearance;
- true hover is never synthesized from touch/pen.
## 15. Performance, security and observability
### 15.1. Performance
The main risk is a dense plan with many blur/shadow and simultaneous motion
layers. Requirements:
- no JS work per animation frame;
- no new geometry/resolver computation on unchanged HA frames;
- blur is confined to marker/value bounds, not a plan-wide layer;
- hidden/static/unavailable/virtual markers allocate no pulse layers;
- existing absolute performance budgets remain green;
- add a dense-device browser scenario containing neutral, Double and concurrent
continuous markers in both themes; its accepted budget is explicit and may
not weaken an existing profile;
- compare GPU/compositor behaviour on the Windows Chromium harness and in Linux
CI before beta.
### 15.2. Security
- only sanitized colors enter CSS variables;
- values remain text nodes, never `unsafeHTML`;
- no external font/network resource is loaded;
- designer archive is not served to HA users;
- pointer/click/service-call policy is unchanged.
### 15.3. Diagnostics
No user-facing telemetry is added. Existing smoke/golden names and issue-linked
test evidence are the diagnostic contract.
## 16. Сравнительная тестовая страница
`docs/specs/158-device-icons-comparison.html` is a self-contained review aid.
It must:
- render columns «Сейчас» and «После #158» from one row matrix;
- cover every row in §7–§9 plus Text, four Double positions, LQI unchanged,
ghost/disabled and static icon;
- show light and dark variants side by side in every «Сейчас»/«После #158» cell,
with a control to filter the matrix to either theme;
- offer plain/colored background, 32/56/96 px and reduced-motion controls;
- require no build, server, HA session, font or network request;
- show the accepted owner overrides prominently;
- never be imported by production code or included as a runtime asset contract.
The page is illustrative at S3: the target column is a CSS rendering of the
normative design, not proof that production is implemented. Golden evidence
must later capture the real card bundle.
## 17. Acceptance criteria
1. All supported surfaces use one shared circular Device Face and match the
state matrix without duplicated semantic logic.
2. Default/Hover/Working/Open/Locked/Alarm match target tokens in light/dark,
on plain and colored backgrounds, at 32/56/96 px.
3. Hover replaces ordinary neutral/working/open face with blue but never hides
alarm; touch/pen does not latch hover.
4. Virtual is dashed in idle/hover and can never become semantic or animated.
5. Unavailable is exactly the current `0.35` wrapper opacity and never pulses.
6. Selected and focus-visible compose over every semantic face; alarm remains
red and focus remains visible.
7. Text and Double preserve source/format/position, all four positions, maximum
width, ellipsis, full accessible text and bottom-LQI stacking.
8. Double Open uses green Active geometry; value section remains neutral.
9. Locked face is derived only from an available authoritative lock state and
works for icon, Text and Double without inspecting the selected glyph.
10. LQI pixels/thresholds/position remain unchanged except unavoidable common
movement caused by the joined Double footprint.
11. Alarm pulse matches the 2.4s two-wave contract; Continuous matches the 3.6s
SVG contract; Short remains 3.3s/retriggerable.
12. `ripple_color`/`ripple_size` override ordinary motion without affecting
alarm and survive round-trip.
13. Reduced motion has no looping transforms: alarm/continuous retain static
halo and short retains the dot.
14. `static_icon` remains neutral and pulse/value-free on every surface.
15. No-blur fallback retains state readability and focus boundary.
16. Existing actions, bindings, positions, MDI, Glow, vacuum and configuration
remain unchanged.
17. Typecheck, unit and build are green in implementation cycle; named smokes,
golden, mutation guards and performance gates are green before beta.
## 18. Test plan
### 18.1. Unit
Extend/add tests for:
- `device-face`: stable layer DOM, class/style projection, Text/Double positions,
full value text, LQI stacking;
- `device-presentation`: real vs virtual/unavailable/static gates;
- `device-visual`/presentation: authoritative lock source produces the transient
locked facet; custom lock glyph and unrelated entities do not;
- `device-pulse`: 3.6s presentation contract metadata if represented in TS,
saved color/scale priority, reduced motion;
- source/token contract tests that prevent reintroduction of Virtual Active,
redesigned unavailable or redesigned LQI;
- theme/fallback CSS anchors and safe user color interpolation.
### 18.2. Browser smoke
Add `demo/smoke_device_face_redesign.mjs` and extend
`demo/smoke_device_preview_parity.mjs`:
- compare semantic classes and bounding boxes across Full, Static and preview;
- measure 32/56/96, Text and all Double positions rather than only counting DOM;
- assert alarm remains red through hover/selected/focus;
- assert virtual is dashed and has zero pulse nodes;
- assert unavailable opacity and zero pulse nodes;
- assert long values fit/ellipsis and title contains full value;
- assert no uncaught errors on theme/background/motion switches;
- sample at least two distinct animation frames for Continuous and a complete
Short retrigger instead of checking only animation-name.
### 18.3. Golden
Add real-card scenarios, at minimum:
- state matrix light and dark;
- colored/non-uniform plan background;
- Text + Double right/left/top/bottom;
- virtual/unavailable/selected/focus;
- alarm/continuous fixed frames and reduced motion;
- Full/Static/preview parity;
- 32/56/96 checkpoints.
Every issue-specific golden carries a semantic pixel/geometry assertion and is
proven red by a mutation before acceptance.
### 18.4. Mutation gate
Add 2–5 meaningful guards, for example:
1. remove virtual dashed shell;
2. allow pulse for virtual/unavailable;
3. change unavailable opacity from `0.35`;
4. let hover override alarm;
5. detach one Double position or change Continuous duration.
Each mutation must make its named unit/smoke/golden check fail.
### 18.5. Commands and stage
Implementation cycle:
```text
npm run typecheck
npm test
npm run build
```
Before moving implementation to code review, run the AC-named local browser
smokes. Before beta, run golden verify, mutation gate, relevant performance
profiles and the full Linux CI HA harness according to `AGENTS.md`/`PROCESS.md`.
## 19. Implementation plan
1. Introduce normalized visual tokens and layered shared face markup.
2. Implement Default/semantic/hover/virtual/unavailable and overlays.
3. Implement Text and joined Double while preserving the #90 resolver.
4. Restyle alarm/continuous/short without changing #98 ownership/gates.
5. Apply parity to Full, Static, preview and hidden isometry.
6. Add fallback/accessibility/forced-colors/reduced-motion rules.
7. Add unit, smoke, golden, mutation and dense performance evidence.
8. Update documentation, both changelogs and generated bundles in the same
user-visible implementation commit.
## 20. Release artifacts
The implementation commit is `User-Visible: yes` and must include:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` marker states/display documentation;
- `docs/TESTING.md` checklist and exact automated evidence;
- updated screenshot/golden baselines and baseline index;
- production bundle copies required by repository policy;
- release notes wording when the containing beta is prepared.
No separate config compatibility note is required beyond documenting that data
and saved settings are unchanged.
## 21. Risks and mitigations
| Risk | Mitigation |
|---|---|
| blur/shadow cost on dense plans | confined layers, dense benchmark, no JS frame loop |
| semantic state lost under hover | explicit matrix and alarm-hover smoke |
| package contradicts owner decisions | source precedence §2 plus contract tests |
| Full/Static/preview drift | shared face + parity smoke/goldens |
| value clipping after joining | four-position geometry smoke at max scale/value |
| sticky hover on touch | fine-pointer media/pointer policy smoke |
| animation restarts on HA frames | stable DOM/runtime ownership and frame sampling |
| user customization silently removed | round-trip and override tests |
| glass invisible without blur | opaque fallback and forced-colors checks |
## 22. Rollback
Rollback is a revert of the implementation commit and rebuilt bundle. No config,
backend or migration rollback is needed. Golden baselines and new visual tests
are reverted with the feature; #98/#90 behavioural tests remain.
## 23. Принятые предположения
- `--dev-size` maps to the new outer shell diameter; shadow overflow is not part
of anchoring.
- Exact reference-canvas offsets are normalized to relative ratios.
- Double Open/Unlock is Active geometry recolored to `#1DC21D`.
- Locked Text/Double reuse the black Lock token through the transient lock
facet; no persisted status or glyph heuristic is introduced.
- Text semantic variants absent from exports are produced from the same shared
tokens so existing live-state behaviour is not lost.
- Static editor ghost/disabled chrome is retained over the new Default face.
- The comparison HTML is a specification aid and does not authorize product
code at `S3-spec`.
- Any conflict not resolved by §2 returns to the owner instead of being inferred
during implementation.
+1
View File
@@ -49,6 +49,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#138](https://github.com/Matysh/houseplan-card/issues/138) Автозамыкание комнаты по существующей стене | [138-adjacent-room-autoclose.md](138-adjacent-room-autoclose.md) |
| [#146](https://github.com/Matysh/houseplan-card/issues/146) Четырёхфазный фон «Следует за Солнцем» | [146-four-phase-sun-background.md](146-four-phase-sun-background.md) |
| [#156](https://github.com/Matysh/houseplan-card/issues/156) Регрессии Full Performance перед v1.64.0 stable | [156-full-performance-regressions.md](156-full-performance-regressions.md) |
| [#158](https://github.com/Matysh/houseplan-card/issues/158) Обновление внешнего вида маркеров устройств | [158-device-icons-redesign.md](158-device-icons-redesign.md) |
| [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) |
| [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) |
| [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) |