Skip to content

Commit 1ed58af

Browse files
committed
fix(ui): PinGate ref-based in-flight guard, busy-safe delete, preventDefault, focus after error; a11y (compact title aria-hidden, Section aria-expanded/controls, segmented groups aria-pressed); drop dead CSS/imports (codex)
1 parent 5ccb4fa commit 1ed58af

8 files changed

Lines changed: 42 additions & 45 deletions

File tree

‎design/kenney/render.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
const cam = new THREE.OrthographicCamera(-half * W / H, half * W / H, half, -half, 0.1, 100);
5151
const ang = (+(q.get("yaw") || 45)) * Math.PI / 180, el = (+(q.get("pitch") || 32)) * Math.PI / 180;
5252
cam.position.set(ctr.x + 20 * Math.sin(ang) * Math.cos(el), ctr.y + 20 * Math.sin(el), ctr.z + 20 * Math.cos(ang) * Math.cos(el)); cam.lookAt(ctr);
53-
cam.position.y += +(q.get("lift") || 0);
53+
cam.position.y += +(q.get("lift") || 0);
5454
renderer.render(scene, cam);
5555
window.__sizes = group.children.map((c) => { const b = new THREE.Box3().setFromObject(c), z = b.getSize(new THREE.Vector3()); return [c.type, +z.x.toFixed(2), +z.y.toFixed(2), +z.z.toFixed(2)]; });
5656
window.__done = renderer.domElement.toDataURL("image/png");

‎src/components/DeviceView.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
1+
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react";
22
import { getDevice, type Device, type DeviceData, type Latest } from "../lib/api";
33
import { batteryChannels, groupDevice, INVERTER_TYPE_TH } from "../lib/device";
44
import { useSmartPoll } from "../lib/usePoll";
55
import { timeStr } from "../lib/format";
6-
import { IconChevron, IconBack } from "../lib/icons";
7-
import { card, cardP, h2 } from "../lib/ui";
6+
import { IconChevron } from "../lib/icons";
7+
import { card, cardP } from "../lib/ui";
88
import { FlowDiagram } from "./FlowDiagram";
99
import { InsightList } from "./InsightList";
1010
import { analyzeDevice } from "../lib/diagnostics";
@@ -73,13 +73,14 @@ function KVList({ items }: { items: DeviceData[] }) {
7373
}
7474

7575
function Section({ title, count, open, onToggle, children }: { title: string; count?: number; open: boolean; onToggle: () => void; children: ReactNode }) {
76+
const id = useId();
7677
return (
7778
<div className={`${card} mt-3.5 overflow-hidden`}>
78-
<button onClick={onToggle} className="w-full flex items-center justify-between p-5 text-left">
79+
<button onClick={onToggle} aria-expanded={open} aria-controls={id} className="w-full flex items-center justify-between p-5 text-left">
7980
<span className="text-[17px] font-bold">{title}</span>
8081
<span className="flex items-center gap-2 text-muted text-[13px]">{count != null ? `${count} ค่า` : ""}<IconChevron className={`w-5 h-5 transition-transform duration-300 ${open ? "rotate-90" : ""}`} /></span>
8182
</button>
82-
<div className={`grid transition-[grid-template-rows] duration-300 ease-out ${open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`}>
83+
<div id={id} className={`grid transition-[grid-template-rows] duration-300 ease-out ${open ? "grid-rows-[1fr]" : "grid-rows-[0fr]"}`} aria-hidden={!open}>
8384
<div className="overflow-hidden"><div className="px-5 pb-5">{children}</div></div>
8485
</div>
8586
</div>

‎src/components/Header.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function Header({ title, stationName, stations, selectedId, onSwitch, onR
2626
<div className={`ios-bar ${compact ? "on" : ""}`}>
2727
<div className="inner">
2828
<div className="bg" />
29-
<div className="title">{title}</div>
29+
<div className="title" aria-hidden="true">{title}</div>
3030
<button
3131
onClick={() => { if (cooling) return; haptic(); onRefresh(); }}
3232
disabled={cooling}

‎src/components/HistoryView.tsx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getHistory, type HistTotals } from "../lib/api";
33
import { useSmartPoll } from "../lib/usePoll";
44
import { useSettings } from "../lib/settings";
55
import { savingsOf, savingsLabel, co2Of } from "../lib/economics";
6-
import { cardP, cardSm, plate, h2First, h2Mid } from "../lib/ui";
6+
import { cardP, plate, h2Mid } from "../lib/ui";
77
import { IconChevron, IconSun, IconHouse, IconBattery, IconGrid } from "../lib/icons";
88
import { BarChart, LineMini, Legend } from "./Chart";
99
import { MetricSection } from "./MetricSection";
@@ -377,9 +377,9 @@ export function HistoryView({ active, stationId, isPrimary = true, capacity }: {
377377

378378
return (
379379
<>
380-
<div className="ios-seg sticky-seg" role="tablist">
380+
<div className="ios-seg sticky-seg" role="group" aria-label="ช่วงเวลา">
381381
{TABS.map((t) => (
382-
<button key={t.k} onClick={() => changeRange(t.k)} role="tab" aria-selected={range === t.k}
382+
<button key={t.k} onClick={() => changeRange(t.k)} aria-pressed={range === t.k}
383383
className={range === t.k ? "on" : ""}>
384384
{t.label}
385385
</button>
@@ -403,9 +403,9 @@ export function HistoryView({ active, stationId, isPrimary = true, capacity }: {
403403

404404
{/* day-window toggle — same segmented-control language as the range tabs */}
405405
{range === "day" && (
406-
<div className="ios-seg mt-3" role="tablist">
406+
<div className="ios-seg mt-3" role="group" aria-label="ช่วงของวัน">
407407
{([["mid", "เต็มวัน 00–24 น."], ["noon", "รอบแดด เที่ยง→เที่ยง"]] as const).map(([w, lab]) => (
408-
<button key={w} onClick={() => dayWin !== w && changeDayWin(w)} role="tab" aria-selected={dayWin === w}
408+
<button key={w} onClick={() => dayWin !== w && changeDayWin(w)} aria-pressed={dayWin === w}
409409
className={dayWin === w ? "on" : ""}>
410410
{lab}
411411
</button>

‎src/components/PinGate.tsx‎

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from "react";
1+
import { useCallback, useEffect, useRef, useState } from "react";
22
import { postLogin } from "../lib/api";
33
import { IconSun } from "../lib/icons";
44
import { APP_NAME } from "../lib/brand";
@@ -13,25 +13,35 @@ export function PinGate({ onOk }: { onOk: () => void }) {
1313
const [err, setErr] = useState("");
1414
const [busy, setBusy] = useState(false);
1515
const [shake, setShake] = useState(false);
16+
// the in-flight guard is a ref, not state: two Enter presses / taps in the same frame both
17+
// read the same render's `busy`, a ref flips synchronously
18+
const busyRef = useRef(false);
19+
const pinRef = useRef("");
20+
const firstKey = useRef<HTMLButtonElement>(null);
21+
const setPinBoth = (v: string) => { pinRef.current = v; setPin(v); };
1622

1723
const submit = useCallback(async () => {
18-
if (busy || !pin) return; // guard against double-submit
19-
setErr("");
20-
setBusy(true);
21-
const r = await postLogin(pin).catch(() => ({ ok: false, error: "เชื่อมต่อไม่ได้" }));
22-
setBusy(false);
23-
if (r.ok) onOk();
24-
else { setErr(r.error || "รหัสไม่ถูกต้อง"); setPin(""); setShake(true); setTimeout(() => setShake(false), 450); }
25-
}, [busy, pin, onOk]);
24+
const value = pinRef.current;
25+
if (busyRef.current || !value) return;
26+
busyRef.current = true; setBusy(true); setErr("");
27+
const r = await postLogin(value).catch(() => ({ ok: false, error: "เชื่อมต่อไม่ได้" }));
28+
busyRef.current = false; setBusy(false);
29+
if (r.ok) { onOk(); return; }
30+
setErr(r.error || "รหัสไม่ถูกต้อง"); setPinBoth(""); setShake(true); setTimeout(() => setShake(false), 450);
31+
firstKey.current?.focus(); // the submit key is now disabled — keep keyboard/AT focus on the pad
32+
}, [onOk]);
2633

27-
const press = useCallback((d: string) => { if (busy) return; haptic(); setErr(""); setPin((p) => (p.length < MAX ? p + d : p)); }, [busy]);
28-
const del = useCallback(() => { haptic(); setPin((p) => p.slice(0, -1)); }, []);
34+
const press = useCallback((d: string) => {
35+
if (busyRef.current || pinRef.current.length >= MAX) return;
36+
haptic(); setErr(""); setPinBoth(pinRef.current + d);
37+
}, []);
38+
const del = useCallback(() => { if (busyRef.current || !pinRef.current) return; haptic(); setPinBoth(pinRef.current.slice(0, -1)); }, []);
2939

3040
useEffect(() => {
3141
const on = (e: KeyboardEvent) => {
32-
if (/^\d$/.test(e.key)) press(e.key);
33-
else if (e.key === "Backspace") del();
34-
else if (e.key === "Enter") submit();
42+
if (/^\d$/.test(e.key)) { e.preventDefault(); press(e.key); }
43+
else if (e.key === "Backspace") { e.preventDefault(); del(); }
44+
else if (e.key === "Enter") { e.preventDefault(); submit(); }
3545
};
3646
window.addEventListener("keydown", on);
3747
return () => window.removeEventListener("keydown", on);
@@ -52,8 +62,8 @@ export function PinGate({ onOk }: { onOk: () => void }) {
5262
<div className="text-warn text-[15px] font-semibold min-h-6 mt-1" role="alert">{err}</div>
5363

5464
<div className="pin-pad">
55-
{keys.map((k) => <button key={k} onClick={() => press(k)} aria-label={k}>{k}</button>)}
56-
<button className="ghost" onClick={del} aria-label="ลบ" disabled={!pin}>ลบ</button>
65+
{keys.map((k, i) => <button key={k} ref={i === 0 ? firstKey : undefined} onClick={() => press(k)} aria-label={k}>{k}</button>)}
66+
<button className="ghost" onClick={del} aria-label="ลบ" disabled={busy || !pin}>ลบ</button>
5767
<button onClick={() => press("0")} aria-label="0">0</button>
5868
<button className="ok" onClick={submit} disabled={busy || !pin} aria-label="เข้าใช้งาน">
5969
{busy ? "…" : <svg viewBox="0 0 24 24" className="w-8 h-8" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>}

‎src/components/TodayView.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Latest } from "../lib/api";
22
import { fmtKwh } from "../lib/format";
33
import { IconSun, IconHouse, IconGrid, IconBattery } from "../lib/icons";
4-
import { cardP, plateP, h2First, h2Mid } from "../lib/ui";
4+
import { cardP, plateP, h2Mid } from "../lib/ui";
55
import { Tile } from "./Tile";
66
import { AnalysisCard } from "./AnalysisCard";
77
import { SelfConsumption } from "./SelfConsumption";

‎src/components/WeatherView.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type Weather, type WeatherHour } from "../lib/api";
44
import { condText, solarInfo, DAYLBL, shortDate, isNightAt } from "../lib/weather";
55
import { forecast, hourlyKwh } from "../lib/forecast";
66
import { WxIcon } from "../lib/wxicon";
7-
import { card, cardP, h2First, h2Mid } from "../lib/ui";
7+
import { card, cardP, h2Mid } from "../lib/ui";
88
import { IconSun } from "../lib/icons";
99
import { InfoTip } from "./InfoTip";
1010
import { SunPath } from "./SunPath";

‎src/index.css‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,7 @@ textarea {
160160
}
161161
/* Respect the OS "reduce motion" switch — kill the ambient/idle animations. */
162162
@media (prefers-reduced-motion: reduce) {
163-
.view-anim, .hero-illus, .hero-rays, .hero-core,
164-
.wx-float, .wx-star, .wx-glow, .splash-leave, .skeleton::after {
163+
.view-anim, .hero-rays, .wx-glow, .splash-leave, .skeleton::after {
165164
animation: none !important;
166165
}
167166
}
@@ -179,17 +178,10 @@ textarea {
179178
.splash-leave { animation: splashfade 0.45s ease 0.1s forwards; }
180179

181180
/* ----- drawn solar-house hero (Card_Home) ----- */
182-
@keyframes herofloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } }
183-
.hero-illus { animation: herofloat 4.8s ease-in-out infinite; }
184181
.hero-rays { transform-box: fill-box; transform-origin: center; animation: rayspin 22s linear infinite; }
185-
.hero-core { transform-box: fill-box; transform-origin: center; animation: sunpulse 2.6s ease-in-out infinite; }
186182

187183
/* ----- weather immersive page (Solurna purple night sky) ----- */
188-
@keyframes wxfloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-12px); } }
189-
@keyframes wxtwinkle { 0%, 100% { opacity: var(--s, .5); } 50% { opacity: .15; } }
190184
@keyframes wxglow { 0%, 100% { opacity: .55; transform: scale(1); } 50% { opacity: .8; transform: scale(1.06); } }
191-
.wx-float { animation: wxfloat 5.5s ease-in-out infinite; }
192-
.wx-star { animation: wxtwinkle 3.4s ease-in-out infinite; }
193185
.wx-glow { animation: wxglow 6s ease-in-out infinite; }
194186

195187
/* shared ambient keyframes (stars twinkle, live dot pulse) */
@@ -300,14 +292,10 @@ textarea {
300292
/* ── home hero: isometric solar house ───────────────────────────────────────── */
301293
.hscene { position: relative; overflow: hidden; border-radius: 26px; aspect-ratio: 392 / 380; isolation: isolate;
302294
box-shadow: 0 1px 1.5px rgba(0, 0, 0, .05), 0 16px 34px -18px rgba(20, 30, 60, .45); }
303-
.hscene .hs-svg { position: absolute; left: 0; right: 0; bottom: 0; width: 100%; height: auto; }
304295
.hscene .hs-haze { position: absolute; inset: 0; background: linear-gradient(180deg, rgba(150, 160, 175, .45), rgba(200, 205, 215, .1) 70%); }
305296
.hscene .hs-stars { position: absolute; inset: 0 0 45% 0; opacity: .8;
306297
background-image: radial-gradient(1.2px 1.2px at 12% 18%, #fff, transparent), radial-gradient(1px 1px at 28% 8%, #fff, transparent), radial-gradient(1.4px 1.4px at 46% 22%, #fff, transparent),
307298
radial-gradient(1px 1px at 63% 12%, #fff, transparent), radial-gradient(1.3px 1.3px at 78% 26%, #fff, transparent), radial-gradient(1px 1px at 90% 10%, #fff, transparent), radial-gradient(1px 1px at 36% 34%, #fff, transparent); }
308-
@keyframes hs-dash { to { stroke-dashoffset: -26; } }
309-
.hscene .hs-flow { animation: hs-dash 1s linear infinite; }
310-
.hscene .hs-flow.rev { animation-direction: reverse; }
311299
.hscene .hs-top { position: absolute; top: 0; left: 0; right: 0; display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; padding: 16px 16px 0; }
312300
.hscene .hs-name { font-size: 19px; font-weight: 800; color: #0f172a; letter-spacing: -.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
313301
.hscene .hs-wx { font-size: 13px; font-weight: 600; color: #334155; margin-top: 1px; }
@@ -325,8 +313,6 @@ textarea {
325313
.hscene .hs-chip .soc { position: relative; width: 34px; height: 16px; border-radius: 5px; background: rgba(24, 166, 115, .15); overflow: hidden; margin-left: 2px; }
326314
.hscene .hs-chip .soc i { position: absolute; inset: 0 auto 0 0; background: #18a673; border-radius: 4px; }
327315
.hscene .hs-chip .soc b { position: relative; display: block; text-align: center; font-size: 10px; font-weight: 800; color: #063; line-height: 16px; }
328-
@media (prefers-reduced-motion: reduce) { .hscene .hs-flow { animation: none; } }
329-
.hscene.dim .hs-svg { filter: brightness(.74) saturate(.85); }
330316

331317
/* iOS segmented control (large, universal-design sizing) */
332318
.ios-seg { display: flex; gap: 2px; padding: 3px; border-radius: 14px; background: rgba(118, 118, 128, .14); }

0 commit comments

Comments
 (0)