mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
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:
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -255,7 +255,7 @@ function LegendPill({
|
||||
? t('dashboard.runRunning')
|
||||
: next
|
||||
? fmtDT(new Date(next.at))
|
||||
: t('dashboard.routePaused')}
|
||||
: t('dashboard.routeIdle')}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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,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,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 (
|
||||
|
||||
Reference in New Issue
Block a user