fix: frontend accessibility and design audit pass

- palette: AA contrast for muted/faint text, a light-theme accent-as-text
  token, 3:1 input borders, ink-on-red danger buttons in dark
- semantics: html lang follows the active language, main landmark and a
  hidden h1, labeled header buttons, app-wide keyboard focus outline
- dialogs: focus starts on the first field, validation banners scroll
  into view instead of mounting off-screen
- type scale collapsed to five steps (11/12.5/13.5/16/19); panel titles
  step up to 16px
- mobile: run history renders as cards, the topology stacks vertically,
  route and guest rows wrap instead of clipping at 320px
- language and timezone pickers are native selects
- activity log timestamps carry the date; an idle route no longer reads
  as paused
This commit is contained in:
Catubba
2026-08-08 15:54:51 +02:00
parent a79f0191a3
commit 233c46d91b
24 changed files with 326 additions and 223 deletions
+5 -5
View File
@@ -75,14 +75,14 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
>
{state.icon}
</div>
<span id={titleId} style={{ fontSize: 17, fontWeight: 700 }}>{state.title}</span>
<span id={titleId} style={{ fontSize: 16, fontWeight: 700 }}>{state.title}</span>
</div>
{/* pre-line so a message can list what it is warning about, one item per line. */}
<p
id={msgId}
style={{
margin: '0 0 20px',
fontSize: 14,
fontSize: 13.5,
lineHeight: 1.55,
color: c.textMid,
whiteSpace: 'pre-line',
@@ -97,7 +97,7 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
alignItems: 'center',
gap: 10,
margin: '0 0 18px',
fontSize: 13,
fontSize: 13.5,
color: c.textMid,
cursor: 'pointer',
}}
@@ -116,7 +116,7 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
border: `1px solid ${c.ghostBorder}`,
borderRadius: 8,
padding: '9px 18px',
fontSize: 13,
fontSize: 13.5,
fontWeight: 600,
cursor: 'pointer',
}}
@@ -134,7 +134,7 @@ export function ConfirmModal({ state, onCancel }: { state: ConfirmState | null;
border: 'none',
borderRadius: 8,
padding: '9px 18px',
fontSize: 13,
fontSize: 13.5,
fontWeight: 600,
cursor: 'pointer',
}}
+26 -73
View File
@@ -1,4 +1,3 @@
import { useState } from 'react'
import { c, mono } from '../theme'
export interface Option {
@@ -14,79 +13,33 @@ interface Props {
mono?: boolean
}
/**
* A styled native `<select>`. It used to be a hand-rolled button+menu, which had none of the
* keyboard/ARIA behavior a select gets for free (Escape, arrow keys, type-ahead, aria-expanded)
* — the menu itself now renders as the OS one, which is the trade.
*/
export function Dropdown({ value, options, onChange, width = '100%', mono: useMono }: Props) {
const [open, setOpen] = useState(false)
const current = options.find((o) => o.value === value)
return (
<div style={{ position: 'relative', width }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
width: '100%',
background: c.inputBg,
border: `1px solid ${c.inputBorder}`,
borderRadius: 7,
color: c.text,
padding: '10px 12px',
fontFamily: useMono ? mono : "'IBM Plex Sans', sans-serif",
fontSize: 14,
cursor: 'pointer',
}}
>
<span>{current?.label ?? value ?? '—'}</span>
<span style={{ color: c.textFaint, fontSize: 9 }}></span>
</button>
{open && (
<>
<div onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 30 }} />
<div
style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 31,
background: c.menuBg,
border: `1px solid ${c.inputBorder}`,
borderRadius: 7,
overflowY: 'auto',
overflowX: 'hidden',
maxHeight: '40vh',
boxShadow: '0 12px 30px rgba(0,0,0,.45)',
}}
>
{options.map((o) => (
<button
key={o.value}
type="button"
onClick={() => {
onChange(o.value)
setOpen(false)
}}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: o.value === value ? 'rgba(232,131,15,.12)' : 'transparent',
color: o.value === value ? c.text : c.textMid,
border: 'none',
padding: '9px 12px',
fontSize: 14,
cursor: 'pointer',
}}
>
{o.label}
</button>
))}
</div>
</>
)}
</div>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
width,
background: c.inputBg,
border: `1px solid ${c.inputBorder}`,
borderRadius: 7,
color: c.text,
padding: '10px 12px',
fontFamily: useMono ? mono : "'IBM Plex Sans', sans-serif",
fontSize: 13.5,
cursor: 'pointer',
}}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
)
}
+5 -2
View File
@@ -10,6 +10,9 @@ interface ModalProps {
footer: ReactNode
/** `lg` is the 700px route editor; the default 420px is the small action dialogs. */
size?: 'sm' | 'lg'
/** Where focus starts. Without it, useDialogKeys falls back to the first focusable —
* which is the ✕, a poor introduction for a screen reader. Point it at the first field. */
initialFocusRef?: React.RefObject<HTMLElement | null>
children: ReactNode
}
@@ -23,12 +26,12 @@ interface ModalProps {
* backdrop is the same `onClose`, so a guarded surface (the route editor's unsaved-changes
* prompt) intercepts every exit at one point.
*/
export function Modal({ title, onClose, footer, size = 'sm', children }: ModalProps) {
export function Modal({ title, onClose, footer, size = 'sm', initialFocusRef, children }: ModalProps) {
const { t } = useTranslation()
const titleId = useId()
const dialogRef = useRef<HTMLDivElement>(null)
useDialogKeys(true, dialogRef, onClose)
useDialogKeys(true, dialogRef, onClose, initialFocusRef)
// Stop the page behind the dialog from scrolling under it.
useEffect(() => {
+1
View File
@@ -3,6 +3,7 @@ import { c, tint } from '../theme'
export function Spinner({ size = 16, color = c.accent }: { size?: number; color?: string }) {
return (
<div
className="jn-spin"
style={{
width: size,
height: size,
@@ -0,0 +1,13 @@
import { useEffect } from 'react'
/**
* Scrolls the dialog's error banner into view when it appears. The banner mounts at the top
* of the modal's scroll container while the user is at the bottom where the Next/Save button
* is — without this, a failed validation looks like a dead click.
*/
export function useRevealBanner(active: boolean) {
useEffect(() => {
if (!active) return
document.querySelector('.modal-scroll .form-banner')?.scrollIntoView({ block: 'nearest' })
}, [active])
}
+141 -54
View File
@@ -92,9 +92,10 @@
flex-wrap: wrap;
}
.panel-hd h2 {
font-size: 13px;
/* A real step above the 13.5px body — titles used to sit at 13px over 13px rows. */
font-size: 16px;
font-weight: 700;
letter-spacing: 0.04em;
letter-spacing: 0.02em;
margin: 0;
}
.panel-hd .count {
@@ -107,7 +108,7 @@
.panel-empty {
padding: 26px 16px;
text-align: center;
font-size: 13px;
font-size: 13.5px;
color: var(--jn-text-dim);
}
@@ -129,7 +130,7 @@
border: 1px solid var(--jn-border);
border-radius: 999px;
padding: 4px 12px 4px 9px;
font-size: 12px;
font-size: 12.5px;
cursor: default;
color: var(--jn-text-mid);
font-family: inherit;
@@ -145,10 +146,10 @@
}
.rpill .rwhen {
color: var(--jn-text-faint);
font-size: 11.5px;
font-size: 12.5px;
}
.rpill .rwhen.run {
color: var(--jn-accent);
color: var(--jn-accent-text);
font-weight: 600;
}
@@ -248,7 +249,7 @@
white-space: nowrap;
}
.device-host {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-dim);
margin-top: 2px;
}
@@ -256,7 +257,7 @@
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-size: 12.5px;
margin-top: 6px;
}
.dot {
@@ -299,7 +300,7 @@
background: transparent;
border: 1px solid var(--jn-input-border);
color: var(--jn-text-dim);
font-size: 13px;
font-size: 13.5px;
line-height: 1;
cursor: pointer;
display: inline-flex;
@@ -327,14 +328,14 @@
.dbar > div {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #e8830f, #f5a83a);
background: linear-gradient(90deg, var(--jn-accent), var(--jn-accent-hover));
}
.dbar-lab {
display: flex;
justify-content: space-between;
margin-top: 4px;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 10.5px;
font-size: 11px;
color: var(--jn-text-faint);
font-variant-numeric: tabular-nums;
}
@@ -383,7 +384,7 @@
font-weight: 600;
}
.badge {
font-size: 10.5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
@@ -413,7 +414,7 @@
}
.chip-s {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 11.5px;
font-size: 12.5px;
background: var(--jn-input-bg);
border: 1px solid var(--jn-input-border);
border-radius: 6px;
@@ -429,7 +430,7 @@
}
.route-mid .cron {
color: var(--jn-text-mid);
font-size: 12px;
font-size: 12.5px;
}
.route-mid div + div {
margin-top: 3px;
@@ -469,7 +470,7 @@
flex: 1 0 auto;
padding: 9px 16px;
border-bottom: 1px solid var(--jn-divider);
font-size: 13px;
font-size: 13.5px;
}
.uprow:last-child {
border-bottom: none;
@@ -490,12 +491,12 @@
}
.uprow .uwhen {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-dim);
font-variant-numeric: tabular-nums;
}
.uprow.now .uwhen {
color: var(--jn-accent);
color: var(--jn-accent-text);
font-weight: 600;
}
@@ -511,7 +512,7 @@
color: var(--jn-text-mid);
border-radius: 8px;
padding: 8px 14px;
font-size: 13px;
font-size: 13.5px;
cursor: pointer;
font-family: inherit;
white-space: nowrap;
@@ -542,9 +543,13 @@
.btn-danger-solid {
background: var(--jn-red);
border-color: var(--jn-red);
color: #fff;
/* Ink like the accent button: white on the dark theme's red is only 3.3:1. */
color: var(--jn-accent-ink);
font-weight: 600;
}
:root[data-theme='light'] .btn-danger-solid {
color: #fff;
}
.btn-danger-solid:hover {
background: color-mix(in srgb, var(--jn-red) 85%, black);
border-color: var(--jn-red);
@@ -567,7 +572,7 @@
color: var(--jn-text-dim);
border-radius: 999px;
padding: 3px 11px;
font-size: 12px;
font-size: 12.5px;
cursor: pointer;
font-family: inherit;
display: inline-flex;
@@ -602,11 +607,11 @@
.htable {
width: 100%;
border-collapse: collapse;
font-size: 13px;
font-size: 13.5px;
}
.htable th {
text-align: left;
font-size: 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -639,7 +644,7 @@
.htable .when,
.htable .dur {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -652,7 +657,7 @@
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-size: 12.5px;
font-weight: 500;
border-radius: 999px;
padding: 2px 9px;
@@ -671,7 +676,7 @@
width: 14px;
margin-right: 4px;
color: var(--jn-text-faint);
font-size: 10px;
font-size: 11px;
transition: transform 0.15s ease;
}
.hrow.open .xbtn {
@@ -694,7 +699,7 @@
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-dim);
}
.step .sname {
@@ -714,7 +719,7 @@
/* task-log tail */
.tasklog {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
font-size: 12.5px;
line-height: 1.75;
color: var(--jn-text-mid);
overflow-x: auto;
@@ -728,7 +733,7 @@
color: var(--jn-amber);
}
.tasklog-empty {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-muted);
}
.runbar {
@@ -779,7 +784,7 @@
}
.gdiv {
padding: 7px 16px 5px;
font-size: 10.5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
@@ -796,7 +801,7 @@
flex: none;
width: 26px;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 10px;
font-size: 11px;
font-weight: 600;
padding: 2px 0;
border-radius: 5px;
@@ -819,7 +824,7 @@
.gname {
flex: 1;
min-width: 0;
font-size: 13px;
font-size: 13.5px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
@@ -827,14 +832,14 @@
}
.gwhen {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 11.5px;
font-size: 12.5px;
color: var(--jn-text-dim);
flex: none;
font-variant-numeric: tabular-nums;
}
.pbschip {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 10.5px;
font-size: 11px;
border: 1px solid var(--jn-input-border);
color: var(--jn-text-dim);
border-radius: 5px;
@@ -846,7 +851,7 @@
color: var(--jn-amber);
}
.gerr {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-red);
}
.gsearch {
@@ -863,6 +868,11 @@
outline: none;
border-color: var(--jn-accent);
}
/* Wins the specificity tie against the outline:none above when focus came from the keyboard. */
.gsearch:focus-visible {
outline: 2px solid var(--jn-accent);
outline-offset: 2px;
}
.gsearch::placeholder {
color: var(--jn-text-muted);
}
@@ -916,7 +926,7 @@
border-bottom: 1px solid var(--jn-border-soft);
}
.modal-hd h2 {
font-size: 14px;
font-size: 13.5px;
font-weight: 700;
margin: 0;
}
@@ -968,7 +978,7 @@
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-size: 13.5px;
color: var(--jn-text-mid);
cursor: pointer;
}
@@ -991,7 +1001,7 @@
}
.modal input[type='time'] {
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 15px;
font-size: 16px;
font-weight: 500;
}
.modal input:focus,
@@ -1008,7 +1018,7 @@
color: var(--jn-text);
border-radius: 8px;
padding: 7px 10px;
font-size: 13px;
font-size: 13.5px;
font-family: inherit;
width: 100%;
}
@@ -1038,11 +1048,11 @@
color: var(--jn-text-mid);
}
.field .help {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-faint);
}
.field .err {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-red);
}
.form-banner {
@@ -1055,7 +1065,7 @@
color: var(--jn-red);
}
.form-warn {
font-size: 12px;
font-size: 12.5px;
line-height: 1.5;
color: var(--jn-amber);
}
@@ -1087,7 +1097,7 @@
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.65);
font-size: 12px;
font-size: 12.5px;
font-weight: 700;
}
/* Custom colour: a native <input type="color"> disguised as a rainbow swatch — clicking
@@ -1121,14 +1131,14 @@
color: var(--jn-text-mid);
border-radius: 8px;
padding: 6px 11px;
font-size: 13px;
font-size: 13.5px;
cursor: pointer;
user-select: none;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
}
.chip .k {
font-family: 'IBM Plex Sans', system-ui, sans-serif;
font-size: 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
color: var(--jn-text-muted);
@@ -1143,7 +1153,7 @@
background: color-mix(in srgb, var(--jn-accent) 9%, transparent);
}
.chip.on .k {
color: var(--jn-accent);
color: var(--jn-accent-text);
}
.chip[aria-disabled='true'] {
opacity: 0.35;
@@ -1169,11 +1179,11 @@
}
.preview-note {
color: var(--jn-text-faint);
font-size: 12px;
font-size: 12.5px;
}
.preview-err {
color: var(--jn-red);
font-size: 12px;
font-size: 12.5px;
}
.sched-row {
display: flex;
@@ -1194,7 +1204,7 @@
border-radius: 7px;
padding: 8px 0;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.04em;
cursor: pointer;
@@ -1224,7 +1234,7 @@
border: none;
border-radius: 6px;
padding: 7px 4px;
font-size: 12px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
@@ -1241,7 +1251,7 @@
}
.guest-group {
padding: 7px 14px 5px;
font-size: 10.5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
@@ -1266,7 +1276,7 @@
.guest-empty {
padding: 16px;
text-align: center;
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-muted);
}
.retention-row {
@@ -1281,7 +1291,7 @@
width: 92px;
}
.retention-row .r span {
font-size: 11.5px;
font-size: 12.5px;
color: var(--jn-text-faint);
}
.retention-row input {
@@ -1292,7 +1302,7 @@
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 13px;
font-size: 13.5px;
color: var(--jn-text-mid);
}
.radio-row label {
@@ -1322,7 +1332,7 @@ details.adv summary::-webkit-details-marker {
}
details.adv summary::before {
content: '▸';
font-size: 10px;
font-size: 11px;
color: var(--jn-text-faint);
transition: transform 0.15s ease;
}
@@ -1348,6 +1358,83 @@ details.adv[open] summary::before {
.days {
grid-template-columns: repeat(7, 1fr);
}
/* The status pill + toggle + Edit no longer fit on one line beside the card padding at
phone widths — without this they clip off the card's right edge. */
.route-right {
flex-wrap: wrap;
}
/* Keep the guest name readable: it is the one identifying column, so it must not be the
column that collapses. The date + target chip wrap to a second line instead. */
.grow {
flex-wrap: wrap;
}
.gname {
min-width: 120px;
}
/* Run history becomes cards: on a phone, "did it work" must not be the column that ends
up behind a horizontal scrollbar. Row 1: route + result pill. Row 2: kind. Row 3:
started + duration. The expanded detail row keeps its block flow. */
.htable thead {
display: none;
}
.htable,
.htable tbody {
display: block;
}
.htable tr.hrow {
display: grid;
grid-template-columns: 1fr auto;
row-gap: 2px;
padding: 8px 16px;
border-bottom: 1px solid var(--jn-divider);
}
.htable .hrow td {
display: block;
padding: 0;
border-bottom: none;
}
.htable .hrow td:nth-child(1) {
grid-column: 1;
grid-row: 1;
}
.htable .hrow td:nth-child(5) {
grid-column: 2;
grid-row: 1;
justify-self: end;
}
.htable .hrow td.kind {
grid-column: 1 / -1;
grid-row: 2;
}
.htable .hrow td.when {
grid-column: 1;
grid-row: 3;
}
.htable .hrow td.dur {
grid-column: 2;
grid-row: 3;
justify-self: end;
}
.htable tr.drow {
display: block;
}
.htable .drow td {
display: block;
padding: 10px 12px;
}
/* The topology stacks: PVEs above, backup servers below. The wires go — the sleeping/awake
states and route pills carry the story, and off-screen backup servers carried nothing. */
.diagram {
min-width: 0;
grid-template-columns: 1fr;
padding: 8px;
gap: 20px;
}
.wires {
display: none;
}
}
/* Keyboard focus, matching the mockup's single rule for every interactive element here. */
+5 -3
View File
@@ -45,7 +45,8 @@
"settings": "Settings",
"theme": "Theme",
"logout": "Logout",
"signOut": "Sign out"
"signOut": "Sign out",
"backToDashboard": "Back to dashboard"
},
"status": {
"running": "Backup running",
@@ -130,7 +131,6 @@
"backupMap": "Backup map",
"colPve": "Proxmox VE",
"colPbs": "Proxmox Backup Server",
"routePaused": "paused",
"pveCluster_one": "Cluster · {{nodes}} nodes · {{count}} guest",
"pveCluster_other": "Cluster · {{nodes}} nodes · {{count}} guests",
"pveStandalone_one": "Standalone · {{count}} guest",
@@ -257,7 +257,9 @@
"keepYearly": "Keep yearly",
"guestIncludeLabel": "Include {{name}} (VMID {{vmid}})"
},
"routeEnabledLabel": "Route {{name}} enabled"
"routeEnabledLabel": "Route {{name}} enabled",
"title": "Dashboard",
"routeIdle": "no run scheduled"
},
"wizard": {
"pve": {
+5
View File
@@ -15,4 +15,9 @@ i18n.use(initReactI18next).init({
interpolation: { escapeValue: false },
})
// Keep <html lang> in step with the active language so screen readers switch pronunciation.
i18n.on('languageChanged', (lng) => {
document.documentElement.lang = lng
})
export default i18n
+5 -3
View File
@@ -45,7 +45,8 @@
"settings": "Impostazioni",
"theme": "Tema",
"logout": "Esci",
"signOut": "Disconnetti"
"signOut": "Disconnetti",
"backToDashboard": "Torna alla dashboard"
},
"status": {
"running": "Backup in corso",
@@ -130,7 +131,6 @@
"backupMap": "Mappa dei backup",
"colPve": "Proxmox VE",
"colPbs": "Proxmox Backup Server",
"routePaused": "in pausa",
"pveCluster_one": "Cluster · {{nodes}} nodi · {{count}} guest",
"pveCluster_other": "Cluster · {{nodes}} nodi · {{count}} guest",
"pveStandalone_one": "Standalone · {{count}} guest",
@@ -257,7 +257,9 @@
"keepYearly": "Conserva gli annuali",
"guestIncludeLabel": "Includi {{name}} (VMID {{vmid}})"
},
"routeEnabledLabel": "Route {{name}} attiva"
"routeEnabledLabel": "Route {{name}} attiva",
"title": "Dashboard",
"routeIdle": "nessuna run programmata"
},
"wizard": {
"pve": {
+35 -16
View File
@@ -62,14 +62,18 @@
--jn-border: #232a32;
--jn-border-soft: #1f262e;
--jn-input-bg: #0f1216;
--jn-input-border: #2b333c;
/* 3:1 against both the field fill and the panel behind it (WCAG 1.4.11). */
--jn-input-border: #606a77;
--jn-text: #e7eaee;
--jn-text-mid: #c4cad1;
--jn-text-dim: #8a929c;
--jn-text-faint: #7f8893;
--jn-text-muted: #5b636d;
--jn-text-muted: #7a838e;
--jn-accent: #e8830f;
--jn-accent-hover: #f5953a;
/* Accent used AS TEXT (active tab, "running" times). Same hue as --jn-accent, but dark
enough in the light palette to clear 4.5:1 at small sizes fills stay on --jn-accent. */
--jn-accent-text: #e8830f;
--jn-accent-ink: #1a1206;
--jn-green: #3fb27f;
--jn-red: #e5675b;
@@ -100,19 +104,20 @@
--jn-border: #dfe3e8;
--jn-border-soft: #e7ebef;
--jn-input-bg: #fbfcfd;
--jn-input-border: #cdd4db;
--jn-input-border: #85909c;
--jn-text: #1c2229;
--jn-text-mid: #3d454f;
--jn-text-dim: #5d6570;
--jn-text-faint: #6b747f;
--jn-text-muted: #98a1ab;
--jn-text-faint: #656e79;
--jn-text-muted: #697380;
--jn-accent: #c86f06;
--jn-accent-hover: #b26305;
--jn-accent-text: #a85d04;
--jn-accent-ink: #1a1206;
--jn-green: #1e8a5c;
--jn-green: #158054;
--jn-red: #c93f32;
--jn-blue: #2563eb;
--jn-amber: #9a7410;
--jn-amber: #886609;
--jn-btn-bg: #e9edf1;
--jn-btn-border: #d5dbe2;
--jn-ghost-border: #c3cad2;
@@ -148,6 +153,10 @@ html.theme-fade *::after {
html.theme-fade *::after {
transition: none;
}
/* Spinners are inline-styled; !important is the only override that reaches them. */
.jn-spin {
animation: none !important;
}
}
body {
margin: 0;
@@ -160,15 +169,6 @@ body {
transform: rotate(360deg);
}
}
@keyframes pulseBar {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.55;
}
}
input[type='time']::-webkit-calendar-picker-indicator {
filter: invert(0.7);
cursor: pointer;
@@ -183,6 +183,25 @@ input:focus {
outline: none;
border-color: var(--jn-accent);
}
/* Keyboard focus is always visible, app-wide inline-styled inputs (Login) and the guest
search included. Pointer focus keeps the quieter border-color change above. */
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--jn-accent);
outline-offset: 2px;
}
/* Screen-reader-only text (the dashboard's h1). */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
button {
font-family: 'IBM Plex Sans', system-ui, sans-serif;
}
+1
View File
@@ -138,6 +138,7 @@ export function Dashboard({ status, refreshStatus }: DashboardProps) {
return (
<div className="jn-home">
<h1 className="sr-only">{t('dashboard.title')}</h1>
{actionError && (
<div className="form-banner" role="alert">
{actionError}
+5 -5
View File
@@ -89,13 +89,13 @@ export function Login() {
padding: 26,
}}
>
<span style={{ display: 'block', fontSize: 17, fontWeight: 700, marginBottom: 5 }}>
<span style={{ display: 'block', fontSize: 16, fontWeight: 700, marginBottom: 5 }}>
{register ? t('auth.registerTitle') : t('auth.signInTitle')}
</span>
<span
style={{
display: 'block',
fontSize: 13,
fontSize: 13.5,
color: c.textDim,
lineHeight: 1.5,
marginBottom: 22,
@@ -116,7 +116,7 @@ export function Login() {
borderRadius: 7,
padding: '9px 12px',
marginBottom: 14,
fontSize: 12,
fontSize: 12.5,
color: c.textMid,
}}
>
@@ -199,7 +199,7 @@ export function Login() {
borderRadius: 7,
padding: '9px 12px',
marginBottom: 14,
fontSize: 12,
fontSize: 12.5,
color: c.red,
}}
>
@@ -210,7 +210,7 @@ export function Login() {
<button
type="submit"
disabled={busy}
style={{ ...primaryBtn, width: '100%', padding: 12, marginTop: 6, fontSize: 14 }}
style={{ ...primaryBtn, width: '100%', padding: 12, marginTop: 6, fontSize: 13.5 }}
>
{busy
? t('common.loading')
+9 -6
View File
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import type { LogLine } from '../../api/types'
import { c, mono } from '../../theme'
import { fmtClock } from '../../utils/format'
import { fmtShort } from '../../utils/format'
export const LEVELS: Record<string, { color: string; bg: string }> = {
INFO: { color: c.info, bg: 'rgba(138,166,192,.13)' },
@@ -11,7 +11,7 @@ export const LEVELS: Record<string, { color: string; bg: string }> = {
}
export const colHead: React.CSSProperties = {
fontSize: 10,
fontSize: 11,
fontWeight: 600,
letterSpacing: '.06em',
textTransform: 'uppercase',
@@ -36,7 +36,7 @@ export function ActivityLog({ logs }: { logs: LogLine[] }) {
</div>
<div className="jn-log-list">
{logs.length === 0 && (
<div style={{ padding: '14px 18px', fontSize: 13, color: c.textFaint }}>{t('dashboard.noLogs')}</div>
<div style={{ padding: '14px 18px', fontSize: 13.5, color: c.textFaint }}>{t('dashboard.noLogs')}</div>
)}
{logs.map((l) => {
const lvl = LEVELS[l.level] ?? LEVELS.INFO
@@ -50,13 +50,16 @@ export function ActivityLog({ logs }: { logs: LogLine[] }) {
borderBottom: `1px solid ${c.divider}`,
}}
>
<span style={{ fontFamily: mono, fontSize: 12, color: c.textFaint }}>{fmtClock(new Date(l.ts))}</span>
{/* Date + time, not time-of-day alone: the log spans days, and "which night
did GC fail" is the question it answers. Same format family as the
history table. */}
<span style={{ fontFamily: mono, fontSize: 12.5, color: c.textFaint }}>{fmtShort(new Date(l.ts))}</span>
<span>
<span
style={{
display: 'inline-block',
fontFamily: mono,
fontSize: 10,
fontSize: 11,
fontWeight: 600,
padding: '2px 7px',
borderRadius: 5,
@@ -67,7 +70,7 @@ export function ActivityLog({ logs }: { logs: LogLine[] }) {
{l.level}
</span>
</span>
<span style={{ fontSize: 13, color: c.textMid, minWidth: 0, overflowWrap: 'anywhere' }}>
<span style={{ fontSize: 13.5, color: c.textMid, minWidth: 0, overflowWrap: 'anywhere' }}>
{l.message}
</span>
</div>
@@ -5,6 +5,7 @@ import type { PbsDevice, PveDevice, Route } from '../../api/types'
import { ConfirmModal, type ConfirmState } from '../../components/ConfirmModal'
import { Modal } from '../../components/Modal'
import { Toggle } from '../../components/Toggle'
import { useRevealBanner } from '../../components/useRevealBanner'
import { useRegisterDirty, useUnsavedGuard } from '../../shell/UnsavedGuard'
import { guestTypeLabel, type PveGuests } from '../../utils/guestPanel'
import { deviceId, pbsNodeId, pveNodeId, routeKindBadge } from '../../utils/routes'
@@ -71,6 +72,7 @@ export function RouteModal({ route, routes, pves, pbss, groups, onClose, onSaved
const { t } = useTranslation()
const { guard } = useUnsavedGuard()
const initial = useRef(draftFromRoute(route, pbss))
const nameRef = useRef<HTMLInputElement>(null)
const [draft, setDraft] = useState<RouteDraft>(initial.current)
// Nothing is flagged until the first Save: complaining that a brand-new form has no name is
// noise. After that the client-side rules re-run on every keystroke, so a fix clears its own
@@ -105,6 +107,7 @@ export function RouteModal({ route, routes, pves, pbss, groups, onClose, onSaved
const errorsFor = (field: RouteField) => errors.filter((e) => e.field === field)
const formErrors = errors.filter((e) => !e.field)
useRevealBanner(formErrors.length > 0)
const text = (e: FieldError) => e.message ?? t(e.key ?? '', e.params ?? {})
// Keyed `pve:<id>` / `pbs:<id>`: a PVE and a PBS may share an id, and a bare one made the
@@ -188,6 +191,7 @@ export function RouteModal({ route, routes, pves, pbss, groups, onClose, onSaved
}
onClose={close}
footer={footer}
initialFocusRef={nameRef}
>
<div className="rm-bd">
{formErrors.length > 0 && (
@@ -204,6 +208,7 @@ export function RouteModal({ route, routes, pves, pbss, groups, onClose, onSaved
<label htmlFor="rm-name">{t('dashboard.routeModal.name')}</label>
<input
id="rm-name"
ref={nameRef}
type="text"
autoComplete="off"
value={draft.name}
+1 -1
View File
@@ -255,7 +255,7 @@ function LegendPill({
? t('dashboard.runRunning')
: next
? fmtDT(new Date(next.at))
: t('dashboard.routePaused')}
: t('dashboard.routeIdle')}
</span>
</span>
)
+1 -1
View File
@@ -214,7 +214,7 @@ export function YamlEditor() {
{err && (
<pre
role="alert"
style={{ fontSize: 12, color: c.red, fontFamily: mono, whiteSpace: 'pre-wrap', margin: 0 }}
style={{ fontSize: 12.5, color: c.red, fontFamily: mono, whiteSpace: 'pre-wrap', margin: 0 }}
>
{err}
</pre>
+3 -3
View File
@@ -57,11 +57,11 @@
/* Activity log */
.jn-log-head {
display: grid;
grid-template-columns: 84px 96px 1fr;
grid-template-columns: 100px 96px 1fr;
}
.jn-log-row {
display: grid;
grid-template-columns: 84px 96px 1fr;
grid-template-columns: 100px 96px 1fr;
}
/* 60 events is a tall page; scroll them. 12 rows of 34.5px (7+7 padding + 13px text at
the shell's 1.5 line-height + 1px divider) keep in step if the row padding changes. */
@@ -112,7 +112,7 @@
display: none;
}
.jn-log-row {
grid-template-columns: 60px 1fr;
grid-template-columns: 84px 1fr;
row-gap: 4px;
}
.jn-log-row > :nth-child(3) {
+12 -12
View File
@@ -44,7 +44,7 @@
color: var(--jn-text);
}
.tab.on {
color: var(--jn-accent);
color: var(--jn-accent-text);
border-bottom-color: var(--jn-accent);
}
.tab:focus-visible {
@@ -67,7 +67,7 @@
/* dashboard.css scopes `.help` to `.field`, but Settings hangs explanatory lines directly off
a panel body or a modal section too same look, one rule less specific. */
.help {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-faint);
line-height: 1.5;
}
@@ -131,7 +131,7 @@
white-space: nowrap;
}
.dev-badge {
font-size: 10.5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.5px;
text-transform: uppercase;
@@ -143,13 +143,13 @@
flex: none;
}
.dev-host {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-dim);
margin-top: 3px;
word-break: break-all;
}
.dev-meta {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-faint);
margin-top: 5px;
}
@@ -157,7 +157,7 @@
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-size: 12.5px;
margin: 8px 0 11px;
}
.dev-actions {
@@ -193,7 +193,7 @@
flex: 1;
}
.chan-hint {
font-size: 11.5px;
font-size: 12.5px;
color: var(--jn-text-faint);
}
.chan-fields {
@@ -221,7 +221,7 @@
flex-wrap: wrap;
}
.save-row .help {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-text-faint);
}
.save-row.bare {
@@ -229,11 +229,11 @@
padding-top: 0;
}
.ok-note {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-green);
}
.err-note {
font-size: 12px;
font-size: 12.5px;
color: var(--jn-red);
}
@@ -244,7 +244,7 @@
border-radius: 8px;
padding: 12px 14px;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
font-size: 12.5px;
line-height: 1.7;
color: var(--jn-text-mid);
overflow-x: auto;
@@ -319,7 +319,7 @@
.jn-settings textarea {
resize: vertical;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 12px;
font-size: 12.5px;
line-height: 1.6;
}
.jn-settings input:focus,
+21 -19
View File
@@ -7,7 +7,7 @@ import { ConfigProvider, useConfig } from '../config/ConfigContext'
import { Dashboard } from '../pages/Dashboard'
import { Settings, type Tab } from '../pages/Settings'
import { useStatus } from '../hooks/useStatus'
import { c } from '../theme'
import { c, tint } from '../theme'
import { AddPveWizard } from '../wizard/AddPveWizard'
import { Header } from './Header'
import { UnsavedGuardProvider, useUnsavedGuard } from './UnsavedGuard'
@@ -20,13 +20,13 @@ function Banner({ tone, children }: { tone: 'red' | 'amber'; children: ReactNode
const red = tone === 'red'
return (
<div
role="status"
role={red ? 'alert' : 'status'}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: red ? 'rgba(229,103,91,.12)' : 'rgba(232,131,15,.1)',
border: `1px solid ${red ? c.red : 'rgba(232,131,15,.4)'}`,
background: red ? tint(c.red, 12) : tint(c.accent, 10),
border: `1px solid ${red ? c.red : tint(c.accent, 40)}`,
borderRadius: 8,
padding: '10px 14px',
marginBottom: 12,
@@ -103,25 +103,27 @@ function ShellInner() {
{status?.state === 'paused' && (
<Banner tone="amber"> {t('common.schedulerPaused')}</Banner>
)}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 60 }}>
<Spinner size={26} />
</div>
) : view === 'main' ? (
<Dashboard status={status} refreshStatus={refresh} />
) : (
<Settings
onClose={() => setView('main')}
initialTab={settingsTab}
status={status}
refreshStatus={refresh}
/>
)}
<main>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 60 }}>
<Spinner size={26} />
</div>
) : view === 'main' ? (
<Dashboard status={status} refreshStatus={refresh} />
) : (
<Settings
onClose={() => setView('main')}
initialTab={settingsTab}
status={status}
refreshStatus={refresh}
/>
)}
</main>
<footer
style={{
textAlign: 'center',
marginTop: 28,
fontSize: 12,
fontSize: 12.5,
color: c.textFaint,
}}
>
+8 -6
View File
@@ -69,6 +69,7 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
>
{p.busy ? (
<div
className="jn-spin"
style={{
width: 13,
height: 13,
@@ -83,7 +84,7 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
style={{ width: 9, height: 9, borderRadius: '50%', background: color, boxShadow: `0 0 0 3px ${tint(color, 13)}` }}
/>
)}
<span style={{ fontSize: 13, fontWeight: 600 }}>
<span style={{ fontSize: 13.5, fontWeight: 600 }}>
{t(p.labelKey, {
route: p.route,
when: p.nextAt ? fmtDT(new Date(p.nextAt)) : '',
@@ -91,7 +92,7 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
</span>
</div>
<div style={{ fontFamily: mono, fontSize: 15, fontWeight: 500, color: c.textMid, minWidth: 78, textAlign: 'right' }}>
<div style={{ fontFamily: mono, fontSize: 16, fontWeight: 500, color: c.textMid, minWidth: 78, textAlign: 'right' }}>
{fmtClock(now)}
</div>
</div>
@@ -99,7 +100,8 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
<div className="jn-header-actions">
<button
onClick={onToggleView}
title={t('header.settings')}
title={view === 'settings' ? t('header.backToDashboard') : t('header.settings')}
aria-label={view === 'settings' ? t('header.backToDashboard') : t('header.settings')}
style={{
display: 'flex',
alignItems: 'center',
@@ -109,7 +111,7 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
borderRadius: 8,
padding: '8px 12px',
color: c.textMid,
fontSize: 15,
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
@@ -129,7 +131,7 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
border: `1px solid ${c.inputBorder}`,
borderRadius: 8,
padding: '8px 12px',
fontSize: 15,
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
@@ -148,7 +150,7 @@ export function Header({ status, view, onToggleView, onLogout }: HeaderProps) {
borderRadius: 8,
color: c.textMid,
cursor: 'pointer',
fontSize: 13,
fontSize: 13.5,
padding: '8px 12px',
fontWeight: 600,
}}
+5 -4
View File
@@ -19,6 +19,7 @@ export const c = {
textMuted: 'var(--jn-text-muted)',
accent: 'var(--jn-accent)',
accentHover: 'var(--jn-accent-hover)',
accentText: 'var(--jn-accent-text)',
accentInk: 'var(--jn-accent-ink)',
green: 'var(--jn-green)',
red: 'var(--jn-red)',
@@ -71,7 +72,7 @@ export const mono = "'IBM Plex Mono', monospace"
// Uppercase field-label style used throughout the design.
export const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: 10,
fontSize: 11,
fontWeight: 600,
letterSpacing: '.08em',
textTransform: 'uppercase',
@@ -87,7 +88,7 @@ export const inputStyle: React.CSSProperties = {
color: c.text,
padding: '10px 12px',
fontFamily: "'IBM Plex Sans', sans-serif",
fontSize: 14,
fontSize: 13.5,
}
export const primaryBtn: React.CSSProperties = {
@@ -100,7 +101,7 @@ export const primaryBtn: React.CSSProperties = {
border: 'none',
borderRadius: 8,
padding: '11px',
fontSize: 13,
fontSize: 13.5,
fontWeight: 600,
cursor: 'pointer',
}
@@ -115,7 +116,7 @@ export const ghostBtn: React.CSSProperties = {
border: `1px solid ${c.ghostBorder}`,
borderRadius: 8,
padding: '11px',
fontSize: 13,
fontSize: 13.5,
fontWeight: 600,
cursor: 'pointer',
}
+10 -10
View File
@@ -30,7 +30,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11.5px;
font-size: 12.5px;
font-weight: 700;
border: 1px solid var(--jn-input-border);
color: var(--jn-text-faint);
@@ -38,7 +38,7 @@
flex: none;
}
.wstep .sl {
font-size: 12px;
font-size: 12.5px;
font-weight: 600;
color: var(--jn-text-faint);
white-space: nowrap;
@@ -97,12 +97,12 @@
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-size: 13.5px;
color: var(--jn-green);
}
.okline .mono {
color: var(--jn-text-dim);
font-size: 12px;
font-size: 12.5px;
}
/* What the wizard discovered about the device, for the user to read rather than type. */
@@ -114,7 +114,7 @@
display: flex;
flex-direction: column;
gap: 5px;
font-size: 13px;
font-size: 13.5px;
}
.detected .t {
font-weight: 600;
@@ -147,17 +147,17 @@
}
.found .t .mono {
font-weight: 400;
font-size: 11.5px;
font-size: 12.5px;
color: var(--jn-text-faint);
}
.found .m {
color: var(--jn-text-faint);
font-size: 12px;
font-size: 12.5px;
margin-top: 2px;
}
.found .linked {
color: var(--jn-green);
font-size: 12px;
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
}
@@ -170,7 +170,7 @@
border-radius: 8px;
padding: 10px 12px;
font-family: 'IBM Plex Mono', ui-monospace, monospace;
font-size: 11.5px;
font-size: 12.5px;
color: var(--jn-text-mid);
overflow-x: auto;
white-space: pre;
@@ -181,7 +181,7 @@
display: flex;
align-items: flex-start;
gap: 9px;
font-size: 13px;
font-size: 13.5px;
padding: 4px 0;
line-height: 1.5;
}
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiError, api } from '../api/client'
import { ConfirmModal, type ConfirmState } from '../components/ConfirmModal'
import { useRevealBanner } from '../components/useRevealBanner'
import { useConfig } from '../config/ConfigContext'
import type { DeviceError } from '../utils/deviceForm'
import {
@@ -193,6 +194,7 @@ export function AddPbsWizard({ onClose }: { onClose: () => void }) {
}
const banners = (errors ?? []).filter((e) => !e.field)
useRevealBanner(Boolean(failed || setup.error || banners.length))
return (
<WizardShell
+2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiError, api } from '../api/client'
import { ConfirmModal, type ConfirmState } from '../components/ConfirmModal'
import { useRevealBanner } from '../components/useRevealBanner'
import type { PveConnectResult, WizardStorage } from '../api/types'
import { useConfig } from '../config/ConfigContext'
import type { DeviceError } from '../utils/deviceForm'
@@ -298,6 +299,7 @@ export function AddPveWizard({ onClose }: { onClose: () => void }) {
}
const banners = (errors ?? []).filter((e) => !e.field)
useRevealBanner(Boolean(failed || setup.error || banners.length))
const nodes = connected?.nodes ?? []
return (