fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m42s
CI / check (push) Successful in 37s

A round of operator-facing fixes on the booth screen, shift views, and the
font-scale control. (Follows the font-scale feature in f706726, which used CSS
`zoom` — reverted here for the rem approach below.)

Font scaling (the A−/A+ control now actually works without breaking layout):
- The control scaled via CSS `zoom`, which also scaled viewport-locked containers
  (h-screen frame, max-h-[90vh] modals) so at 130% modal headers/footers were
  pushed off-screen. Reworked to scale TEXT only: converted every `text-[Npx]`
  font utility to rem across the web app (~230 sites in 25 files + the
  .label/.hint/.btn component classes + body in index.css; 16px root, so 100% is
  visually identical), and applyFontScale now sets the ROOT font-size. vh/h-screen
  layout stays put, so chrome never clips; tall content scrolls its own container.
  Verified at 130%: text 12px→15.6px while the frame stayed viewport-height.

Live feed (event rows):
- Plate, badges and reason now flow inline after the identity and wrap only when
  the row runs out of width — no more forced second line when there's empty space.
- Dropped the redundant TARGË via-badge (the plate chip already conveys it).
- Removed the Direction filter group (Hyrje/Dalje) — it duplicated the entry/exit
  options already in the Type filter.

Active sessions:
- Rebuilt as a real table (Ticket/subscriber · Plate · Entry · Elapsed) so columns
  align and long values (subscriber names, ticket ids) no longer truncate.
- Dropped the status column (an unpaid transient is normal; a subscriber shows ★ +
  name; overstay keeps a row tint). Removed the now-redundant status filter; only
  the Transient/Subscriber filter remains. Plate is now searchable (uses s.plate).

Shift report (close-shift modal + Shift History + printed Z-report slip):
- Removed the confusing `shitje` (subscription-sales) sub-line — Abonime is the
  total; only the out-of-window part is broken out. subscriptionSalesMinor stays in
  the signed payload (audit data), just not displayed/printed.
- Show the inherited opening cash ("Arka fillestare") above the expected drawer, so
  opening + cash-taken = expected reads clearly. Money values no longer line-wrap.

Subscription edit modal:
- Fixed the 2-col grid alignment: a lone "only one version" cell was shifting every
  following row by one column — it now emits a full label+value pair.

Removed orphaned i18n keys (fStatus*, fDir*, srcSubSales) from sq+en (parity kept).
Full workspace build/lint/test green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-28 15:15:09 +02:00
parent f706726eeb
commit cce99aadfd
31 changed files with 410 additions and 401 deletions
+6 -6
View File
@@ -98,7 +98,7 @@ export function DeviceFooter() {
return (
<footer
ref={rootRef}
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[11px]"
className="relative flex shrink-0 items-center gap-2 overflow-visible border-t border-term-border bg-term-panel px-3 py-1.5 text-[0.6875rem]"
>
<span className="shrink-0 font-semibold uppercase tracking-wider text-term-muted">
{t("devices.footerTitle")}
@@ -156,7 +156,7 @@ export function DeviceFooter() {
{open && problems.length > 0 && (
<div className="absolute bottom-full right-2 z-50 mb-1 w-[360px] max-w-[95vw] rounded-term border border-term-border bg-term-panel shadow-2xl">
<div className="flex items-center justify-between border-b border-term-border bg-term-panel-2 px-3 py-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
<span className="text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{t("devices.issuesTitle")}
</span>
<button
@@ -177,13 +177,13 @@ export function DeviceFooter() {
<span className={`mt-1 inline-block h-2 w-2 shrink-0 rounded-full ${DOT[d.state]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className={`text-[12px] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-term-muted">
<span className={`text-[0.75rem] font-semibold ${TEXT[d.state]}`}>{label(d)}</span>
<span className="shrink-0 text-[0.625rem] uppercase tracking-wider text-term-muted">
{t(`devices.state.${d.state}`)}
</span>
</div>
{d.detail && <div className="mt-0.5 break-words text-[11px] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[10px] tabular-nums text-term-muted/70">
{d.detail && <div className="mt-0.5 break-words text-[0.6875rem] text-term-muted">{d.detail}</div>}
<div className="mt-0.5 text-[0.625rem] tabular-nums text-term-muted/70">
{t("devices.checkedAt", { time: new Date(d.checkedAt).toLocaleTimeString() })}
</div>
</div>
+2 -2
View File
@@ -27,7 +27,7 @@ export function SegGroup<V extends string>({
key={v || "all"}
type="button"
onClick={() => onChange(v)}
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
className={`px-2 py-0.5 text-[0.625rem] uppercase tracking-wider transition-colors ${
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
}`}
>
@@ -61,7 +61,7 @@ export function FilterBar({
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder={searchPlaceholder}
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[0.75rem] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
/>
{children}
</div>
+1 -1
View File
@@ -30,7 +30,7 @@ export function Modal({
aria-describedby={undefined}
>
<div className="sticky top-0 flex items-center justify-between border-b border-term-border bg-term-panel-2 px-4 py-2">
<Dialog.Title className="m-0 text-[12px] font-semibold uppercase tracking-wider text-term-amber">
<Dialog.Title className="m-0 text-[0.75rem] font-semibold uppercase tracking-wider text-term-amber">
{title}
</Dialog.Title>
<Dialog.Close className="text-term-muted hover:text-term-text" aria-label="Close">
+1 -1
View File
@@ -21,7 +21,7 @@ export function Panel({
>
{title && (
<header className="flex items-center justify-between px-3 py-1.5 bg-term-panel-2 border-b border-term-border">
<h2 className="m-0 text-[11px] font-semibold uppercase tracking-wider text-term-amber">
<h2 className="m-0 text-[0.6875rem] font-semibold uppercase tracking-wider text-term-amber">
{title}
</h2>
{right}
+8 -8
View File
@@ -37,9 +37,9 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const dirLabel = (dir: "entry" | "exit" | null): string =>
dir === "entry" ? t("pay.snapEntry") : dir === "exit" ? t("pay.snapExit") : "—";
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (isLoading) return <div className="text-[0.6875rem] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0 && failures.length === 0 && plates.length === 0)
return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return <div className="text-[0.6875rem] text-term-muted">{t("pay.noSnapshots")}</div>;
return (
<>
@@ -50,15 +50,15 @@ export function SnapshotStrip({ identity }: { identity: string }) {
{dedupePlates(plates).map((p, i) => (
<span
key={`${p.plate}-${p.direction}-${i}`}
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[11px]"
className="inline-flex items-center gap-1.5 rounded-term border border-term-cyan/40 bg-term-cyan/10 px-2 py-0.5 text-[0.6875rem]"
title={`${dirLabel(p.direction)}${p.region ? ` · ${p.region}` : ""}${
p.at ? ` · ${new Date(p.at).toLocaleString()}` : ""
}`}
>
<span className="text-[9px] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-muted">{t("pay.plate")}</span>
<span className="font-mono font-semibold text-term-cyan">{p.plate}</span>
{typeof p.confidence === "number" && (
<span className="text-[10px] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
<span className="text-[0.625rem] text-term-muted">{(p.confidence * 100).toFixed(0)}%</span>
)}
</span>
))}
@@ -81,7 +81,7 @@ export function SnapshotStrip({ identity }: { identity: string }) {
loading="lazy"
/>
<span
className={`text-[9px] uppercase tracking-wider ${
className={`text-[0.5625rem] uppercase tracking-wider ${
s.direction === "entry" ? "text-term-green" : s.direction === "exit" ? "text-term-red" : "text-term-muted"
}`}
>
@@ -100,8 +100,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
title={`${dirLabel(f.direction)} · ${f.error}${f.occurredAt ? ` · ${new Date(f.occurredAt).toLocaleString()}` : ""}`}
>
<span className="text-lg leading-none text-term-amber">⚠</span>
<span className="text-[9px] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
<span className="px-1 text-[9px] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
<span className="text-[0.5625rem] uppercase tracking-wider text-term-amber">{dirLabel(f.direction)}</span>
<span className="px-1 text-[0.5625rem] leading-tight text-term-muted">{t("pay.snapFailed")}</span>
</div>
))}
</div>
+1 -1
View File
@@ -19,7 +19,7 @@ export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span className="flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
+39 -47
View File
@@ -95,9 +95,9 @@ export function displayIdentity(e: LedgerEvent): string {
return e.subscriberLabel ?? e.identity ?? "—";
}
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps
* the time/label/identity/index columns aligned across rows; the detail line lives in
* its own row, indented under the identity column. */
/** One clickable live-feed / activity row → opens the event-detail modal. A grid keeps the
* time/label/#index columns aligned across rows; the identity, plate, badges and reason flow
* inline in the middle column and wrap there only when they run out of width. */
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
const { t } = useTranslation();
const style = eventStyleFor(e);
@@ -110,52 +110,44 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
const reason = renderReason(p, t);
const amount = paymentSummary(p);
const badges = eventBadges(p);
const via = viaKey(p);
const detail = reason ?? amount ?? (isAnomaly ? t("booth.evtNoReason") : null);
const showDetail = detail != null || badges.length > 0 || via != null;
return (
<button
type="button"
onClick={() => onOpen(e)}
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-start gap-x-3 border-b border-term-border/50 px-1 py-1 text-left text-[0.75rem] tabular-nums hover:bg-term-panel-2 ${
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
}`}
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
<span className="py-px text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`py-px shrink-0 font-semibold ${style.color}`}>{label}</span>
{/* Identity + plate + detail all flow in ONE wrapping line — they fill the available
width and only wrap to a second line when this cell actually runs out of room (no
forced second row). Keeps time/label/#index in their columns. */}
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 py-px">
<span className="break-all text-term-text">{displayIdentity(e)}</span>
{e.plate && (
<span
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
className="shrink-0 rounded border border-term-border px-1 text-[0.6875rem] font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{e.plate}
</span>
)}
{badges.map((k) => (
<span
key={k}
className="shrink-0 rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
{detail && (
<span className={`${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</span>
<span className="text-term-muted">#{e.index}</span>
{showDetail && (
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
))}
{via && (
<span className="rounded-sm bg-term-cyan/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-cyan">
{t(via)}
</span>
)}
{detail && (
<span className={`text-[11px] ${isAnomaly ? "text-term-red/90" : "text-term-muted"}`}>{detail}</span>
)}
</div>
)}
<span className="py-px text-term-muted">#{e.index}</span>
</button>
);
}
@@ -163,8 +155,8 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
/** One label/value line in the event-detail modal. */
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[12px]">
<span className="text-[11px] uppercase tracking-wider text-term-muted">{label}</span>
<div className="grid grid-cols-[8rem_1fr] gap-3 border-b border-term-border/40 py-1.5 text-[0.75rem]">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{label}</span>
<span className="min-w-0 break-words text-term-text">{children}</span>
</div>
);
@@ -203,19 +195,19 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
{(reason || money) && (
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
<div className={`mt-1 text-[0.8125rem] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
{reason ?? money}
</div>
)}
{!reason && !money && isAnomaly && (
<div className="mt-1 text-[13px] text-term-red/90">{t("booth.evtNoReason")}</div>
<div className="mt-1 text-[0.8125rem] text-term-red/90">{t("booth.evtNoReason")}</div>
)}
{badges.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{badges.map((k) => (
<span
key={k}
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[10px] font-semibold uppercase tracking-wide text-term-red"
className="rounded-sm bg-term-red/15 px-1.5 py-px text-[0.625rem] font-semibold uppercase tracking-wide text-term-red"
>
{t(k)}
</span>
@@ -235,7 +227,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
(the SUBSESS-… session key) for traceability against the ledger. */}
{e.subscriberLabel && e.identity && (
<DetailRow label={t("booth.edOccurrence")}>
<code className="text-[11px] text-term-muted">{e.identity}</code>
<code className="text-[0.6875rem] text-term-muted">{e.identity}</code>
</DetailRow>
)}
{money && (
@@ -257,7 +249,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
)}
{tariffVersionId && (
<DetailRow label={t("booth.edTariffVersion")}>
<code className="text-[11px] text-term-muted">{tariffVersionId}</code>
<code className="text-[0.6875rem] text-term-muted">{tariffVersionId}</code>
</DetailRow>
)}
</div>
@@ -265,7 +257,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* The entry/exit evidence images for this session's identity. */}
{e.identity && (
<div>
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<div className="mb-1.5 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("booth.edSnapshots")}</div>
<SnapshotStrip identity={e.identity} />
</div>
)}
@@ -275,28 +267,28 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
operator's; tucking them behind a disclosure keeps the common view clean
while preserving the tamper-evidence trail on demand. */}
<details className="rounded-term border border-term-border bg-term-panel-2">
<summary className="cursor-pointer select-none px-3 py-2 text-[11px] uppercase tracking-wider text-term-muted hover:text-term-text">
<summary className="cursor-pointer select-none px-3 py-2 text-[0.6875rem] uppercase tracking-wider text-term-muted hover:text-term-text">
{t("booth.edAuditData")}
</summary>
<div className="border-t border-term-border px-3 pb-3 pt-1">
<DetailRow label={t("booth.edSignature")}>
<code className="break-all text-[11px] text-term-muted">{e.signature}</code>
<code className="break-all text-[0.6875rem] text-term-muted">{e.signature}</code>
</DetailRow>
<DetailRow label={t("booth.edKeyId")}>
<code className="text-[11px] text-term-muted">{e.keyId}</code>
<code className="text-[0.6875rem] text-term-muted">{e.keyId}</code>
</DetailRow>
<DetailRow label={t("booth.edPrevHash")}>
<code className="break-all text-[11px] text-term-muted">{e.prevHash ?? "—"}</code>
<code className="break-all text-[0.6875rem] text-term-muted">{e.prevHash ?? "—"}</code>
</DetailRow>
<div className="mb-1.5 mt-3 text-[11px] uppercase tracking-wider text-term-muted">
<div className="mb-1.5 mt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("booth.edRawPayload")}
</div>
{p && Object.keys(p).length > 0 ? (
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[11px] text-term-text">
<pre className="overflow-x-auto rounded-term border border-term-border bg-term-bg p-2 text-[0.6875rem] text-term-text">
{JSON.stringify(p, null, 2)}
</pre>
) : (
<div className="text-[12px] text-term-muted">{t("booth.edNoPayload")}</div>
<div className="text-[0.75rem] text-term-muted">{t("booth.edNoPayload")}</div>
)}
</div>
</details>