Make the web UI easier to read, and add a theme toggle
Secondary text was too dim to read: --faint sat at about 4.0:1 in the dark theme and 3.3:1 in the light one, and it carries row ages, hints and labels. It now clears 4.5:1 in both. The dark surfaces and borders are a step further apart so cards stand out from the page, and the light borders a touch stronger. Account has an Appearance section with System, Light and Dark. System is the old behaviour. The choice is per browser, kept in localStorage, and is applied by a small js/theme.js loaded from <head> so there is no flash of the other theme; the CSP allows no inline script, hence a file of its own. On-call is redesigned: a hero card for who is on call now, with when the shift ends for the selected team, and the week as seven day cells instead of grouped rows. Today is marked with a neutral tint and a bar rather than the accent colour, which now means only things you can act on; your own days are marked by the "you" badge, not a fill. On a wide screen the hero and week sit beside your shifts under a page title. The page stays read-only: shifts are still edited in the TUI. Smaller fixes: the team switcher no longer appears in the phone's bottom bar as well as the top bar (a shared rule overrode the one that hides it); the Team and Admin tab strips fade at the edge and scroll the open section into view, so "Sources" is no longer cut off; and "All clear" is a green status badge with a check instead of a grey pill that looked like a button. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
This commit is contained in:
+113
-35
@@ -10,11 +10,11 @@
|
|||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--surface-2: #eff1f4;
|
--surface-2: #eff1f4;
|
||||||
--surface-hover: #f7f8fa;
|
--surface-hover: #f7f8fa;
|
||||||
--border: #e2e5ea;
|
--border: #d9dde4;
|
||||||
--border-strong: #cfd3da;
|
--border-strong: #c5cad3;
|
||||||
--text: #16181d;
|
--text: #16181d;
|
||||||
--muted: #5b626e;
|
--muted: #5b626e;
|
||||||
--faint: #8a909b;
|
--faint: #676d79;
|
||||||
|
|
||||||
--accent: #2f5bd3;
|
--accent: #2f5bd3;
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
@@ -72,17 +72,21 @@
|
|||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark palette. Applied by the OS preference unless the user chose Light in
|
||||||
|
Account (data-theme="light" on <html>, set by js/theme.js), and always when
|
||||||
|
they chose Dark. The two blocks must stay identical: CSS has no way to share
|
||||||
|
a declaration list between a media query and an attribute selector. */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not([data-theme="light"]) {
|
||||||
--bg: #0f1115;
|
--bg: #0f1115;
|
||||||
--surface: #171a20;
|
--surface: #1a1e26;
|
||||||
--surface-2: #1f232b;
|
--surface-2: #232834;
|
||||||
--surface-hover: #1c2027;
|
--surface-hover: #20252e;
|
||||||
--border: #2a2f38;
|
--border: #343b49;
|
||||||
--border-strong: #394050;
|
--border-strong: #444d5f;
|
||||||
--text: #e7e9ed;
|
--text: #e7e9ed;
|
||||||
--muted: #a0a7b3;
|
--muted: #a0a7b3;
|
||||||
--faint: #737a87;
|
--faint: #8a92a0;
|
||||||
|
|
||||||
--accent: #6d8ff0;
|
--accent: #6d8ff0;
|
||||||
--accent-text: #0b0d12;
|
--accent-text: #0b0d12;
|
||||||
@@ -108,6 +112,42 @@
|
|||||||
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--surface: #1a1e26;
|
||||||
|
--surface-2: #232834;
|
||||||
|
--surface-hover: #20252e;
|
||||||
|
--border: #343b49;
|
||||||
|
--border-strong: #444d5f;
|
||||||
|
--text: #e7e9ed;
|
||||||
|
--muted: #a0a7b3;
|
||||||
|
--faint: #8a92a0;
|
||||||
|
|
||||||
|
--accent: #6d8ff0;
|
||||||
|
--accent-text: #0b0d12;
|
||||||
|
--accent-soft: #1d2640;
|
||||||
|
|
||||||
|
--crit: #ff6b61;
|
||||||
|
--crit-soft: #3a1c1b;
|
||||||
|
--warn: #f0b140;
|
||||||
|
--warn-soft: #362a14;
|
||||||
|
--info: #74a3ff;
|
||||||
|
--info-soft: #1a2640;
|
||||||
|
--ok: #4cc488;
|
||||||
|
--ok-soft: #15301f;
|
||||||
|
--snooze: #a89bff;
|
||||||
|
--snooze-soft: #262245;
|
||||||
|
|
||||||
|
--teal: #4fc2d4;
|
||||||
|
--teal-soft: #0f2e33;
|
||||||
|
--pink: #f07fb8;
|
||||||
|
--pink-soft: #3a1c2d;
|
||||||
|
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||||
|
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
|
||||||
|
}
|
||||||
|
:root[data-theme="light"] { color-scheme: light; }
|
||||||
|
:root[data-theme="dark"] { color-scheme: dark; }
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
|
||||||
.open-pill.has-triggered::before { background: var(--crit); }
|
.open-pill.has-triggered::before { background: var(--crit); }
|
||||||
.open-pill.all-acked::before { background: var(--warn); }
|
.open-pill.all-acked::before { background: var(--warn); }
|
||||||
|
/* "All clear" is a status, not an action: green with a check, no dot. */
|
||||||
|
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
|
||||||
|
.open-pill.all-clear::before { content: none; }
|
||||||
|
.open-pill .icon { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||||
left sidebar from 900px, where the desktop block below redeclares display
|
left sidebar from 900px, where the desktop block below redeclares display
|
||||||
@@ -290,13 +334,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
|
|
||||||
.nav-team-selector,
|
.nav-team-selector,
|
||||||
.team-selector-mobile {
|
.team-selector-mobile {
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
align-items: center; gap: 8px;
|
||||||
border: 1px solid var(--border-strong); border-radius: 999px;
|
border: 1px solid var(--border-strong); border-radius: 999px;
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
padding: 4px 12px; max-width: 100%;
|
padding: 4px 12px; max-width: 100%;
|
||||||
}
|
}
|
||||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
.team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
|
||||||
|
|
||||||
@@ -340,7 +384,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
background: var(--surface); color: var(--muted);
|
background: var(--surface); color: var(--muted);
|
||||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
}
|
}
|
||||||
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||||
|
.theme-picker { padding: 0 0 8px; }
|
||||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||||
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
||||||
box stays flush with its real right edge regardless of scroll position,
|
box stays flush with its real right edge regardless of scroll position,
|
||||||
@@ -650,7 +695,6 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
|
||||||
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
|
||||||
|
|
||||||
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
|
|
||||||
.avatar {
|
.avatar {
|
||||||
flex: none; display: grid; place-items: center;
|
flex: none; display: grid; place-items: center;
|
||||||
width: 44px; height: 44px; border-radius: 50%;
|
width: 44px; height: 44px; border-radius: 50%;
|
||||||
@@ -658,8 +702,6 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
||||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
|
||||||
.now-name { font-size: 20px; font-weight: 750; }
|
|
||||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||||
is easier to spot there than this plain accent-coloured text. */
|
is easier to spot there than this plain accent-coloured text. */
|
||||||
@@ -673,25 +715,49 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
caught by the unrelated .label > span styling meant for label chips. */
|
caught by the unrelated .label > span styling meant for label chips. */
|
||||||
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
|
||||||
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
|
||||||
.days { list-style: none; margin: 0 auto; padding: 0; }
|
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
|
||||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
|
||||||
.day + .day { border-top: 1px solid var(--border); }
|
.oncall-grid { display: grid; gap: 0; }
|
||||||
.day-name { font-weight: 650; }
|
.oncall-main, .oncall-side { min-width: 0; }
|
||||||
.day-date { color: var(--faint); font-size: 13px; }
|
.hero-list { display: grid; gap: 12px; }
|
||||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
|
||||||
/* A run of several days held by the same person (or left empty), replacing
|
.hero-top { display: flex; align-items: center; gap: 16px; }
|
||||||
what used to be one identical row per day — see weekRuns() in oncall.js. */
|
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
|
||||||
.day.range { grid-template-columns: 1fr auto; }
|
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
|
||||||
.day-range { font-weight: 650; }
|
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
.day.today { background: var(--accent-soft); }
|
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
|
||||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
|
||||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
|
||||||
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
|
||||||
.day.past { opacity: 0.6; }
|
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||||
/* Highlights whichever row is yours, same soft tint as .today — they already
|
/* Bordered, so the arrows read as buttons. 40px: a touch target without
|
||||||
read fine layered (today's own row is almost always one of yours too). */
|
crowding the date between them. */
|
||||||
.day.mine { background: var(--accent-soft); }
|
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
|
||||||
|
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
|
||||||
|
.strip-day {
|
||||||
|
position: relative; min-width: 0;
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
|
||||||
|
.strip-date { font-size: 15px; font-weight: 650; }
|
||||||
|
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||||
|
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.strip-who.nobody { font-style: italic; color: var(--faint); }
|
||||||
|
.strip-day.past { opacity: 0.6; }
|
||||||
|
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
|
||||||
|
reads as a marker and not as a pressed button. The accent stays for things
|
||||||
|
you can act on. */
|
||||||
|
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
|
||||||
|
.strip-day.today::before {
|
||||||
|
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
|
||||||
|
border-radius: 0 0 3px 3px; background: var(--text);
|
||||||
|
}
|
||||||
|
.strip-day.today .strip-name { color: var(--text); }
|
||||||
|
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
|
||||||
|
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||||
|
|
||||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||||
@@ -1038,6 +1104,11 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
overflow-x: auto; scrollbar-width: none;
|
overflow-x: auto; scrollbar-width: none;
|
||||||
}
|
}
|
||||||
.subnav::-webkit-scrollbar { display: none; }
|
.subnav::-webkit-scrollbar { display: none; }
|
||||||
|
/* A fade on the right edge says "there is more" where the strip overflows;
|
||||||
|
the desktop width fits every entry, so it is phone-only. */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
|
||||||
|
}
|
||||||
.subnav-link {
|
.subnav-link {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 8px 12px; margin-bottom: -1px;
|
padding: 8px 12px; margin-bottom: -1px;
|
||||||
@@ -1097,3 +1168,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* On-call, wide: the hero and the week on the left, your shifts on the right.
|
||||||
|
.view-page > * caps its children at 760px, so the grid lifts that. */
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||||
|
#view-oncall > .oncall-title { max-width: 1080px; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
<link rel="stylesheet" href="/app.css">
|
<link rel="stylesheet" href="/app.css">
|
||||||
|
<script src="/js/theme.js"></script>
|
||||||
<script type="module" src="/js/app.js"></script>
|
<script type="module" src="/js/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ function render() {
|
|||||||
|
|
||||||
...passwordSection(user, hasPassword),
|
...passwordSection(user, hasPassword),
|
||||||
|
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
|
||||||
|
themePicker(),
|
||||||
|
|
||||||
h('div', { class: 'only-desktop' },
|
h('div', { class: 'only-desktop' },
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
|
||||||
h('div', { class: 'card' }, shortcuts())),
|
h('div', { class: 'card' }, shortcuts())),
|
||||||
@@ -39,6 +42,23 @@ function render() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System / Light / Dark. Per browser, not per account: it lives in
|
||||||
|
// localStorage (see js/theme.js), the same place the selected team does.
|
||||||
|
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
|
||||||
|
|
||||||
|
function themePicker() {
|
||||||
|
const theme = window.terdutTheme;
|
||||||
|
const buttons = THEMES.map(([value, text]) => h('button', {
|
||||||
|
class: 'chip', type: 'button', role: 'radio', text,
|
||||||
|
onclick: () => { theme.set(value); sync(); },
|
||||||
|
}));
|
||||||
|
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
|
||||||
|
sync();
|
||||||
|
return h('div', { class: 'card card-pad' },
|
||||||
|
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
|
||||||
|
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
|
||||||
|
}
|
||||||
|
|
||||||
// Where this user's pages go. The onboarding checklist's first step sends
|
// Where this user's pages go. The onboarding checklist's first step sends
|
||||||
// people here for it, and until now there was nothing here to send them to:
|
// people here for it, and until now there was nothing here to send them to:
|
||||||
// the topic could only be set with curl or by an administrator.
|
// the topic could only be set with curl or by an administrator.
|
||||||
|
|||||||
@@ -102,13 +102,18 @@ function section() {
|
|||||||
// buttons, because these are four URLs: app.js intercepts the click, the
|
// buttons, because these are four URLs: app.js intercepts the click, the
|
||||||
// browser's Back walks them, and a reload lands where you were.
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
function subnav() {
|
function subnav() {
|
||||||
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
|
||||||
TABS.map((t) => h('a', {
|
TABS.map((t) => h('a', {
|
||||||
class: 'subnav-link',
|
class: 'subnav-link',
|
||||||
href: t.path,
|
href: t.path,
|
||||||
text: t.label,
|
text: t.label,
|
||||||
'aria-current': t.tab === tab ? 'page' : null,
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
})));
|
})));
|
||||||
|
// On a phone the strip overflows; bring the open section into view so a
|
||||||
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||||
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||||
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||||
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- overview --------------------------------------------------------------
|
// --- overview --------------------------------------------------------------
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ function updateBadges() {
|
|||||||
|
|
||||||
const pill = $('open-pill');
|
const pill = $('open-pill');
|
||||||
pill.hidden = false;
|
pill.hidden = false;
|
||||||
pill.textContent = open ? `${open} open` : 'All clear';
|
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
|
||||||
|
pill.classList.toggle('all-clear', open === 0);
|
||||||
pill.classList.toggle('has-triggered', triggered > 0);
|
pill.classList.toggle('has-triggered', triggered > 0);
|
||||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// On-call: who is on duty now, the week around it, and your own next shifts.
|
// On-call: who is on duty now, the week around it, and your own next shifts.
|
||||||
// Read-only for now; the TUI edits the schedule.
|
// Read-only for now; the TUI edits the schedule, so there is no add or swap
|
||||||
|
// button here.
|
||||||
//
|
//
|
||||||
// One team's rota at a time — the viewer's first team, since a viewer in one
|
// One team's rota at a time — the viewer's first team, since a viewer in one
|
||||||
// team has nothing to choose between. "On call now" is the exception and shows
|
// team has nothing to choose between. "On call now" is the exception and shows
|
||||||
@@ -59,11 +60,17 @@ function render() {
|
|||||||
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const team = currentTeam();
|
||||||
clear(view(),
|
clear(view(),
|
||||||
|
// The top bar carries the title on a phone; the desktop has none.
|
||||||
|
h('div', { class: 'only-desktop oncall-title' },
|
||||||
|
h('h1', { text: 'On-call' }),
|
||||||
|
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
nowCard(),
|
h('div', { class: 'oncall-grid' },
|
||||||
weekCard(),
|
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
|
||||||
myShifts(),
|
h('div', { class: 'oncall-side' }, myShifts()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,75 +85,73 @@ function nowCard() {
|
|||||||
const entries = data.now || [];
|
const entries = data.now || [];
|
||||||
const showTeam = entries.length > 1;
|
const showTeam = entries.length > 1;
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return h('div', { class: 'card now-card' },
|
return h('div', { class: 'card hero' },
|
||||||
h('div', { class: 'avatar none', text: '–' }),
|
h('div', { class: 'hero-label', text: 'On call now' }),
|
||||||
h('div', {},
|
h('div', { class: 'hero-top' },
|
||||||
h('div', { class: 'now-label', text: 'On call now' }),
|
h('div', { class: 'avatar hero-avatar none', text: '–' }),
|
||||||
h('div', { class: 'now-name', text: 'Nobody' }),
|
h('div', { class: 'hero-name', text: 'Nobody' })));
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return h('div', {}, ...entries.map((n) =>
|
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
|
||||||
h('div', { class: 'card now-card' },
|
const until = shiftEnd(n);
|
||||||
h('div', { class: 'avatar', text: initial(n.username) }),
|
return h('div', { class: 'card hero' },
|
||||||
h('div', {},
|
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||||
h('div', {
|
h('div', { class: 'hero-top' },
|
||||||
class: 'now-label',
|
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
|
||||||
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
|
h('div', {},
|
||||||
}),
|
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||||
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
|
until && h('div', { class: 'hero-until', text: until }))));
|
||||||
),
|
}));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups the week's 7 days into runs held by the same person (or the same
|
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
|
||||||
// empty slot) — the week's own version of the consecutive-day grouping
|
// end is read off the rota (the run of consecutive days from today held by the
|
||||||
// myShifts does for a single person's own dates, below. Seven identical rows
|
// same person), and that is only loaded for one team. The same midnight
|
||||||
// for one person all week collapses to the one bar this way.
|
// boundary the "Current shift" row below uses.
|
||||||
function weekRuns(byDate) {
|
function shiftEnd(n) {
|
||||||
const runs = [];
|
const team = currentTeam();
|
||||||
for (let i = 0; i < 7; i++) {
|
if (!team || n.team_id !== team.id) return null;
|
||||||
const date = isoDate(addDays(weekStart, i));
|
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
|
||||||
const e = byDate.get(date) || null;
|
let day = new Date();
|
||||||
const uid = e ? e.user_id : null;
|
if (mine.get(isoDate(day)) !== n.user_id) return null;
|
||||||
const last = runs[runs.length - 1];
|
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
|
||||||
if (last && last.uid === uid) last.to = date;
|
// Still holding the last day loaded: the shift may run on past it.
|
||||||
else runs.push({ uid, entry: e, from: date, to: date });
|
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
|
||||||
}
|
const end = addDays(parse(isoDate(day)), 1);
|
||||||
return runs;
|
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A stable colour per person from the six-colour rcN palette. The week page
|
||||||
|
// has no member list to take an index from (team.js does), so the id decides.
|
||||||
|
const personClass = (userID) => `rc${(userID % 6) + 1}`;
|
||||||
|
|
||||||
|
// The week as seven cells, Monday to Sunday, each showing who holds that day.
|
||||||
|
// A hand-over in the middle of the week is visible without reading anything.
|
||||||
function weekCard() {
|
function weekCard() {
|
||||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||||
const today = isoDate(new Date());
|
const today = isoDate(new Date());
|
||||||
const mine = myID();
|
const mine = myID();
|
||||||
const days = weekRuns(byDate).map((r) => {
|
const seen = new Map();
|
||||||
const single = r.from === r.to;
|
const cells = [];
|
||||||
const cls = [
|
for (let i = 0; i < 7; i++) {
|
||||||
'day',
|
const d = addDays(weekStart, i);
|
||||||
single ? '' : 'range',
|
const date = isoDate(d);
|
||||||
r.from <= today && today <= r.to ? 'today' : '',
|
const e = byDate.get(date) || null;
|
||||||
r.to < today ? 'past' : '',
|
if (e) seen.set(e.user_id, e.username);
|
||||||
r.uid === mine ? 'mine' : '',
|
cells.push(h('li', {
|
||||||
].filter(Boolean).join(' ');
|
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
|
||||||
const label = single
|
'aria-current': date === today ? 'date' : null,
|
||||||
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
|
},
|
||||||
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
|
h('span', { class: 'strip-name', text: dayName.format(d) }),
|
||||||
: [h('span', {
|
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
||||||
class: 'day-range',
|
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
|
||||||
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
|
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
|
||||||
})];
|
}
|
||||||
return h('li', { class: cls },
|
|
||||||
...label,
|
|
||||||
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||||
return [
|
return h('div', { class: 'card week-card' },
|
||||||
h('div', { class: 'page-head' },
|
h('div', { class: 'week-head' },
|
||||||
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
|
||||||
h('div', { class: 'week-nav' },
|
h('div', { class: 'week-nav' },
|
||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||||
icon('chevronLeft')),
|
icon('chevronLeft')),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn btn-ghost week-label',
|
class: 'btn btn-ghost week-label',
|
||||||
@@ -155,12 +160,13 @@ function weekCard() {
|
|||||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||||
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
|
||||||
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
|
||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||||
icon('chevronRight')),
|
icon('chevronRight')))),
|
||||||
),
|
h('ul', { class: 'strip' }, cells),
|
||||||
),
|
seen.size > 0 && h('div', { class: 'strip-legend' },
|
||||||
h('ul', { class: 'card days' }, days),
|
[...seen].map(([id, name]) => h('span', {},
|
||||||
];
|
h('i', { class: `strip-dot ${personClass(id)}` }),
|
||||||
|
id === mine ? `${name} (you)` : name))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// myShifts groups your upcoming dates into runs of consecutive days, then
|
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||||
|
|||||||
@@ -168,13 +168,18 @@ function section() {
|
|||||||
// buttons, because these are six URLs: app.js intercepts the click, the
|
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||||
// browser's Back walks them, and a reload lands where you were.
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
function subnav() {
|
function subnav() {
|
||||||
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||||
TABS.map((t) => h('a', {
|
TABS.map((t) => h('a', {
|
||||||
class: 'subnav-link',
|
class: 'subnav-link',
|
||||||
href: t.path,
|
href: t.path,
|
||||||
text: t.label,
|
text: t.label,
|
||||||
'aria-current': t.tab === tab ? 'page' : null,
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
})));
|
})));
|
||||||
|
// On a phone the strip overflows; bring the open section into view so a
|
||||||
|
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||||
|
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||||
|
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||||
|
return nav;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Names which team's settings the six sections below belong to. It used to be
|
// Names which team's settings the six sections below belong to. It used to be
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Theme override: System (no attribute, the OS decides), Light or Dark.
|
||||||
|
//
|
||||||
|
// A classic script loaded from <head>, not a module, so the saved choice is on
|
||||||
|
// <html> before the first paint and a Dark user on a light OS never sees a
|
||||||
|
// flash. The CSP allows no inline script, hence a file of its own. Account
|
||||||
|
// (account.js) reads and writes the choice through window.terdutTheme.
|
||||||
|
(function () {
|
||||||
|
var KEY = 'terdut.theme';
|
||||||
|
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
|
||||||
|
|
||||||
|
function get() {
|
||||||
|
try {
|
||||||
|
var v = localStorage.getItem(KEY);
|
||||||
|
return v === 'light' || v === 'dark' ? v : 'system';
|
||||||
|
} catch (e) {
|
||||||
|
return 'system'; // storage unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The browser chrome colour follows the choice too; the two <meta>s are
|
||||||
|
// media-keyed to the OS, so a forced theme sets both to the same colour.
|
||||||
|
function apply(theme) {
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (theme === 'system') root.removeAttribute('data-theme');
|
||||||
|
else root.setAttribute('data-theme', theme);
|
||||||
|
var metas = document.querySelectorAll('meta[name="theme-color"]');
|
||||||
|
for (var i = 0; i < metas.length; i++) {
|
||||||
|
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
|
||||||
|
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(theme) {
|
||||||
|
try {
|
||||||
|
if (theme === 'system') localStorage.removeItem(KEY);
|
||||||
|
else localStorage.setItem(KEY, theme);
|
||||||
|
} catch (e) {
|
||||||
|
/* storage unavailable: applies for this page view only */
|
||||||
|
}
|
||||||
|
apply(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.terdutTheme = { get: get, set: set };
|
||||||
|
apply(get());
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user