fix(web): booth UI/UX pass — readable font scaling + booth layout/report clarity
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:
@@ -206,8 +206,8 @@ function CategorySection({
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<div className="mb-3 rounded-term border border-term-amber/60 bg-term-amber/10 px-3 py-2">
|
||||
<strong className="text-[12px] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[12px] text-term-amber">
|
||||
<strong className="text-[0.75rem] text-term-amber">{t("setup.warnTitle")}</strong>
|
||||
<ul className="mt-1 list-disc pl-5 text-[0.75rem] text-term-amber">
|
||||
{warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
@@ -233,7 +233,7 @@ function CategorySection({
|
||||
)}
|
||||
|
||||
{blockedNoController ? (
|
||||
<p className="m-0 text-[12px] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
<p className="m-0 text-[0.75rem] text-term-amber">{t("setup.needControllerFirst", { noun })}</p>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setFormFor("new")}>
|
||||
{assignments.length === 0 ? t("setup.add", { noun }) : t("setup.addAnother", { noun })}
|
||||
@@ -301,7 +301,7 @@ function AssignmentRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[12px]">
|
||||
<li className="flex items-center gap-2 border-b border-term-border/60 px-1 py-2 text-[0.75rem]">
|
||||
<strong className="text-term-text">{assignment.driverId}</strong>
|
||||
{host && <span className="tabular-nums text-term-muted">{host}</span>}
|
||||
<DeviceSummary assignment={assignment} controllers={controllers} />
|
||||
@@ -690,19 +690,19 @@ function DeviceForm({
|
||||
|
||||
{selected && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-2 text-[12px] text-term-muted">{selected.description}</p>
|
||||
<p className="mb-2 text-[0.75rem] text-term-muted">{selected.description}</p>
|
||||
|
||||
{canDiscover && (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<button type="button" className="btn btn-sm" onClick={scan} disabled={scanning}>
|
||||
{scanning ? t("setup.scanning") : t("setup.scan")}
|
||||
</button>
|
||||
{scanError && <span className="ml-2 text-[12px] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[12px] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{scanError && <span className="ml-2 text-[0.75rem] text-term-red">{scanError}</span>}
|
||||
{found && found.length === 0 && <p className="mt-2 text-[0.75rem] text-term-muted">{t("setup.noControllersFound")}</p>}
|
||||
{found && found.length > 0 && (
|
||||
<ul className="mt-2 list-none p-0">
|
||||
{found.map((d) => (
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[12px]">
|
||||
<li key={d.id} className="my-1 flex items-center gap-2 text-[0.75rem]">
|
||||
<button type="button" className="btn btn-sm" onClick={() => applyDiscovered(d)}>
|
||||
{t("setup.use")}
|
||||
</button>
|
||||
@@ -734,7 +734,7 @@ function DeviceForm({
|
||||
f.type === "boolean" ? (
|
||||
// Boolean config field → a real checkbox (stores a true/false boolean, not
|
||||
// the string "true"). The label sits beside the box, with the help below.
|
||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label key={f.key} className="my-2 flex max-w-sm items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -859,7 +859,7 @@ function DeviceForm({
|
||||
|
||||
{/* CAMERA: opt this camera into ANPR (the VisionReader polls it for plates). */}
|
||||
{isCamera && (
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -877,7 +877,7 @@ function DeviceForm({
|
||||
(evidence) but does NOT auto-open the barrier — for a shared entry/exit lane where
|
||||
the exit cam's back-plate read would phantom-exit a car that just entered. */}
|
||||
{isCamera && anpr && (
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<label className="my-2 flex items-start gap-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -901,7 +901,7 @@ function DeviceForm({
|
||||
a Test connection first. We surface each field separately, matching the
|
||||
camera's Alarm Settings form (Destination IP / URL / Protocol / Port). */}
|
||||
{isCamera && Boolean(config.alarmPushEnabled) && (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[12px]">
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2 text-[0.75rem]">
|
||||
<div className="font-semibold text-term-text">{t("setup.alarmUrlTitle")}</div>
|
||||
{!editing?.id ? (
|
||||
<p className="hint mt-1">{t("setup.alarmUrlSaveFirst")}</p>
|
||||
@@ -964,9 +964,9 @@ function DeviceForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{testError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: testError })}</p>}
|
||||
{tested && (
|
||||
<div className="mt-2 text-[12px]">
|
||||
<div className="mt-2 text-[0.75rem]">
|
||||
<div className="text-term-text">
|
||||
{t("setup.deviceLabel")} <HealthBadge status={tested.health.status} />
|
||||
{tested.health.detail && <span className="text-term-muted"> — {tested.health.detail}</span>}
|
||||
@@ -993,10 +993,10 @@ function DeviceForm({
|
||||
</button>
|
||||
<p className="hint mt-1">{t("setup.testAnprHint")}</p>
|
||||
|
||||
{anprError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||
{anprError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: anprError })}</p>}
|
||||
{anprResult &&
|
||||
(anprResult.ok ? (
|
||||
<div className="mt-2 text-[12px] text-term-green">
|
||||
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||
{t("setup.anprOk", {
|
||||
plate: anprResult.plate,
|
||||
confidence: Math.round(anprResult.confidence * 100),
|
||||
@@ -1007,7 +1007,7 @@ function DeviceForm({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[12px] text-term-amber">
|
||||
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||
⚠ {t(`setup.anprFail.${anprResult.reason}`, { defaultValue: anprResult.reason })}
|
||||
{anprResult.detail && <span className="text-term-muted"> — {anprResult.detail}</span>}
|
||||
{anprResult.tookMs != null && (
|
||||
@@ -1027,14 +1027,14 @@ function DeviceForm({
|
||||
</button>
|
||||
<p className="hint mt-1">{t("setup.testPrintHint")}</p>
|
||||
|
||||
{printError && <p className="mt-2 text-[12px] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||
{printError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.testFailed", { error: printError })}</p>}
|
||||
{printResult &&
|
||||
(printResult.ok ? (
|
||||
<div className="mt-2 text-[12px] text-term-green">
|
||||
<div className="mt-2 text-[0.75rem] text-term-green">
|
||||
{t("setup.printOk", { ms: printResult.tookMs })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-[12px] text-term-amber">
|
||||
<div className="mt-2 text-[0.75rem] text-term-amber">
|
||||
⚠ {t(`setup.printFail.${printResult.reason}`, { defaultValue: printResult.reason })}
|
||||
{printResult.detail && <span className="text-term-muted"> — {printResult.detail}</span>}
|
||||
</div>
|
||||
@@ -1060,12 +1060,12 @@ function DeviceForm({
|
||||
</select>
|
||||
</div>
|
||||
{!backendIps.some((c) => c.onDeviceSubnet) && (
|
||||
<span className="text-[12px] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
<span className="text-[0.75rem] text-term-amber">{t("setup.noNicOnSubnet")}</span>
|
||||
)}
|
||||
<p className="hint mt-1">{t("setup.backendIpHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
{saveError && <p className="mt-2 text-[12px] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
{saveError && <p className="mt-2 text-[0.75rem] text-term-red">{t("setup.saveFailed", { error: saveError })}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1105,11 +1105,11 @@ function OutputEditor({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.outputsTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.outputsHint")}</p>
|
||||
|
||||
{/* Pulse-open time applies to every barrier relay (how long it's held open). */}
|
||||
<label className="my-1 inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||
<label className="my-1 inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.pulseOpenHint")}>
|
||||
{t("setup.pulseOpenMs")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1125,7 +1125,7 @@ function OutputEditor({
|
||||
barriers pulse (their button/presence terminals are in the Inputs section). */}
|
||||
{relays.map((r, i) => (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1150,7 +1150,7 @@ function OutputEditor({
|
||||
{/* Alert relay: which input fires the blink + the blink cadence. */}
|
||||
{r.direction === "radarAlert" && (
|
||||
<>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.triggerInputHint")}>
|
||||
{t("setup.triggerInput")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1161,7 +1161,7 @@ function OutputEditor({
|
||||
onChange={(e) => update(i, { triggerInput: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.lockLaneHint")}>
|
||||
{t("setup.lockLane")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -1172,7 +1172,7 @@ function OutputEditor({
|
||||
<option value="exit">{t("setup.lockLaneExit")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.blinkOnMs")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1183,7 +1183,7 @@ function OutputEditor({
|
||||
onChange={(e) => update(i, { blinkOnMs: e.target.value === "" ? undefined : Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.blinkOffMs")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1251,11 +1251,11 @@ function InputEditor({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.inputsTitle")}</strong>
|
||||
<p className="hint mt-0.5 mb-2">{t("setup.inputsHint")}</p>
|
||||
|
||||
{/* Board-wide resting level (idle HIGH vs LOW) — an input property. */}
|
||||
<label className="my-1 inline-flex items-start gap-2 text-[12px] text-term-muted">
|
||||
<label className="my-1 inline-flex items-start gap-2 text-[0.75rem] text-term-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
@@ -1274,7 +1274,7 @@ function InputEditor({
|
||||
const isButton = row.role === "button";
|
||||
return (
|
||||
<div key={i} className="my-1 flex flex-wrap items-center gap-2 border-t border-term-border pt-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.inputTerminal")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1298,7 +1298,7 @@ function InputEditor({
|
||||
|
||||
{/* Which barrier this input serves — button/presence only (alert triggers a lamp). */}
|
||||
{row.role !== "alertTrigger" && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.inputServesRelay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -1319,7 +1319,7 @@ function InputEditor({
|
||||
|
||||
{/* Presence: active-low (a radar wired opposite the button). */}
|
||||
{isPresence && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.activeLowHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.activeLowHint")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!row.activeLow}
|
||||
@@ -1331,7 +1331,7 @@ function InputEditor({
|
||||
|
||||
{/* Button cooldown fallback — only when no presence sensor serves this relay. */}
|
||||
{isButton && !hasPresenceFor(row.relay) && (
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted" title={t("setup.entryCooldownHint")}>
|
||||
{t("setup.entryCooldown")}
|
||||
<input
|
||||
type="number"
|
||||
@@ -1381,9 +1381,9 @@ function BindingPicker({
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-term border border-term-border bg-term-bg p-2">
|
||||
<strong className="text-[12px] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<strong className="text-[0.75rem] uppercase tracking-wider text-term-text">{t("setup.whichBarrier")}</strong>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.controller")}
|
||||
<select className="select input-sm w-auto" value={controllerId} onChange={(e) => onControllerChange(e.target.value)}>
|
||||
<option value="" disabled>
|
||||
@@ -1400,7 +1400,7 @@ function BindingPicker({
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-muted">
|
||||
<label className="inline-flex items-center gap-1.5 text-[0.75rem] text-term-muted">
|
||||
{t("setup.relay")}
|
||||
<select
|
||||
className="select input-sm w-auto"
|
||||
@@ -1424,7 +1424,7 @@ function BindingPicker({
|
||||
{chosen && <DirectionBadge direction={chosen.direction} label={t("setup.inherits", { direction: t(DIRECTION_KEYS[chosen.direction]) })} />}
|
||||
</div>
|
||||
{controller && relays.length === 0 && (
|
||||
<p className="mt-1.5 text-[12px] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
<p className="mt-1.5 text-[0.75rem] text-term-amber">{t("setup.noRelaysConfigured")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -1441,7 +1441,7 @@ function DirectionBadge({ direction, label }: { direction: RelayEvent; label?: s
|
||||
? "border-term-red text-term-red"
|
||||
: "border-term-muted text-term-muted";
|
||||
return (
|
||||
<span className={`rounded-term border px-1.5 text-[10px] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
<span className={`rounded-term border px-1.5 text-[0.625rem] font-semibold uppercase tracking-wider ${cls}`}>
|
||||
{label ?? direction}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user