diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 1a1ef27..f56a418 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -10,11 +10,11 @@ --surface: #ffffff; --surface-2: #eff1f4; --surface-hover: #f7f8fa; - --border: #e2e5ea; - --border-strong: #cfd3da; + --border: #d9dde4; + --border-strong: #c5cad3; --text: #16181d; --muted: #5b626e; - --faint: #8a909b; + --faint: #676d79; --accent: #2f5bd3; --accent-text: #ffffff; @@ -72,17 +72,21 @@ --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 , 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) { - :root { + :root:not([data-theme="light"]) { --bg: #0f1115; - --surface: #171a20; - --surface-2: #1f232b; - --surface-hover: #1c2027; - --border: #2a2f38; - --border-strong: #394050; + --surface: #1a1e26; + --surface-2: #232834; + --surface-hover: #20252e; + --border: #343b49; + --border-strong: #444d5f; --text: #e7e9ed; --muted: #a0a7b3; - --faint: #737a87; + --faint: #8a92a0; --accent: #6d8ff0; --accent-text: #0b0d12; @@ -108,6 +112,42 @@ --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; } [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::before { background: var(--crit); } .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 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, .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; background: var(--surface); color: var(--text); font-size: 13px; font-weight: 600; cursor: pointer; 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-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); 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; } /* An overlay, not a flex item: absolute against .chips' own (non-scrolling) 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 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 { flex: none; display: grid; place-items: center; 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; } .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; } /* 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. */ @@ -673,25 +715,49 @@ details[open] > summary { margin-bottom: 8px; } 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-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; } -.days { list-style: none; margin: 0 auto; padding: 0; } -.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; } -.day + .day { border-top: 1px solid var(--border); } -.day-name { font-weight: 650; } -.day-date { color: var(--faint); font-size: 13px; } -.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.day-who.nobody { color: var(--faint); font-style: italic; } -/* A run of several days held by the same person (or left empty), replacing - what used to be one identical row per day — see weekRuns() in oncall.js. */ -.day.range { grid-template-columns: 1fr auto; } -.day-range { font-weight: 650; } -.day.today { background: var(--accent-soft); } -.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; } -.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); } -.day.today .day-name, .day.today .day-range { color: var(--accent); } -.day.past { opacity: 0.6; } -/* Highlights whichever row is yours, same soft tint as .today — they already - read fine layered (today's own row is almost always one of yours too). */ -.day.mine { background: var(--accent-soft); } +.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; } +.oncall-title p { margin: 2px 0 0; font-size: 14px; } +.oncall-grid { display: grid; gap: 0; } +.oncall-main, .oncall-side { min-width: 0; } +.hero-list { display: grid; gap: 12px; } + +.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; } +.hero-top { display: flex; align-items: center; gap: 16px; } +.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); } +.hero-avatar { width: 64px; height: 64px; font-size: 24px; } +.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; } +.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; } + +.week-card { margin-top: 16px; padding: 16px 12px 12px; } +.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; } +.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; } +/* Bordered, so the arrows read as buttons. 40px: a touch target without + crowding the date between them. */ +.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 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; } .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 { flex: none; 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) { .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; } +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index ccbe355..5349286 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -14,6 +14,7 @@ + diff --git a/internal/web/static/js/account.js b/internal/web/static/js/account.js index c8f5cba..b91e6a5 100644 --- a/internal/web/static/js/account.js +++ b/internal/web/static/js/account.js @@ -28,6 +28,9 @@ function render() { ...passwordSection(user, hasPassword), + h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })), + themePicker(), + h('div', { class: 'only-desktop' }, h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })), 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 // 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. diff --git a/internal/web/static/js/admin.js b/internal/web/static/js/admin.js index a1c9693..45e3dce 100644 --- a/internal/web/static/js/admin.js +++ b/internal/web/static/js/admin.js @@ -102,13 +102,18 @@ function section() { // buttons, because these are four URLs: app.js intercepts the click, the // browser's Back walks them, and a reload lands where you were. function subnav() { - return h('nav', { class: 'subnav', 'aria-label': 'Administration' }, + const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' }, TABS.map((t) => h('a', { class: 'subnav-link', href: t.path, text: t.label, '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 -------------------------------------------------------------- diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 1f8bdfa..b23aed3 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -202,7 +202,8 @@ function updateBadges() { const pill = $('open-pill'); 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('all-acked', open > 0 && triggered === 0); diff --git a/internal/web/static/js/oncall.js b/internal/web/static/js/oncall.js index 68e3302..72eb1a6 100644 --- a/internal/web/static/js/oncall.js +++ b/internal/web/static/js/oncall.js @@ -1,5 +1,6 @@ // 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 // 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()); return; } + const team = currentTeam(); 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}` }), - nowCard(), - weekCard(), - myShifts(), + h('div', { class: 'oncall-grid' }, + h('div', { class: 'oncall-main' }, nowCard(), weekCard()), + h('div', { class: 'oncall-side' }, myShifts()), + ), ); } @@ -78,75 +85,73 @@ function nowCard() { const entries = data.now || []; const showTeam = entries.length > 1; if (entries.length === 0) { - return h('div', { class: 'card now-card' }, - h('div', { class: 'avatar none', text: '–' }), - h('div', {}, - h('div', { class: 'now-label', text: 'On call now' }), - h('div', { class: 'now-name', text: 'Nobody' }), - ), - ); + return h('div', { class: 'card hero' }, + h('div', { class: 'hero-label', text: 'On call now' }), + h('div', { class: 'hero-top' }, + h('div', { class: 'avatar hero-avatar none', text: '–' }), + h('div', { class: 'hero-name', text: 'Nobody' }))); } - return h('div', {}, ...entries.map((n) => - h('div', { class: 'card now-card' }, - h('div', { class: 'avatar', text: initial(n.username) }), - h('div', {}, - h('div', { - class: 'now-label', - text: showTeam ? `On call now · ${n.team_name}` : 'On call now', - }), - h('div', { class: 'now-name' }, n.username, you(n.user_id)), - ), - ))); + return h('div', { class: 'hero-list' }, ...entries.map((n) => { + const until = shiftEnd(n); + return h('div', { class: 'card hero' }, + h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }), + h('div', { class: 'hero-top' }, + h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), + h('div', {}, + h('div', { class: 'hero-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 -// empty slot) — the week's own version of the consecutive-day grouping -// myShifts does for a single person's own dates, below. Seven identical rows -// for one person all week collapses to the one bar this way. -function weekRuns(byDate) { - const runs = []; - for (let i = 0; i < 7; i++) { - const date = isoDate(addDays(weekStart, i)); - const e = byDate.get(date) || null; - const uid = e ? e.user_id : null; - const last = runs[runs.length - 1]; - if (last && last.uid === uid) last.to = date; - else runs.push({ uid, entry: e, from: date, to: date }); - } - return runs; +// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's +// end is read off the rota (the run of consecutive days from today held by the +// same person), and that is only loaded for one team. The same midnight +// boundary the "Current shift" row below uses. +function shiftEnd(n) { + const team = currentTeam(); + if (!team || n.team_id !== team.id) return null; + const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id])); + let day = new Date(); + if (mine.get(isoDate(day)) !== n.user_id) return null; + while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1); + // Still holding the last day loaded: the shift may run on past it. + if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null; + const end = addDays(parse(isoDate(day)), 1); + 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() { const byDate = new Map(data.week.map((e) => [e.date, e])); const today = isoDate(new Date()); const mine = myID(); - const days = weekRuns(byDate).map((r) => { - const single = r.from === r.to; - const cls = [ - 'day', - single ? '' : 'range', - r.from <= today && today <= r.to ? 'today' : '', - r.to < today ? 'past' : '', - r.uid === mine ? 'mine' : '', - ].filter(Boolean).join(' '); - const label = single - ? [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: 'day-range', - text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`, - })]; - 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 seen = new Map(); + const cells = []; + for (let i = 0; i < 7; i++) { + const d = addDays(weekStart, i); + const date = isoDate(d); + const e = byDate.get(date) || null; + if (e) seen.set(e.user_id, e.username); + cells.push(h('li', { + class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '), + 'aria-current': date === today ? 'date' : null, + }, + h('span', { class: 'strip-name', text: dayName.format(d) }), + h('span', { class: 'strip-date', text: String(d.getDate()) }), + h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }), + h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' }))); + } const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date())); - return [ - h('div', { class: 'page-head' }, + return h('div', { class: 'card week-card' }, + h('div', { class: 'week-head' }, h('h2', { text: thisWeek ? 'This week' : 'Week' }), 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')), h('button', { class: 'btn btn-ghost week-label', @@ -155,12 +160,13 @@ function weekCard() { onclick: () => { weekStart = mondayOf(new Date()); refresh(); }, text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`, }, h('small', { text: ` Week ${isoWeek(weekStart)}` })), - h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) }, - icon('chevronRight')), - ), - ), - h('ul', { class: 'card days' }, days), - ]; + h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) }, + icon('chevronRight')))), + h('ul', { class: 'strip' }, cells), + seen.size > 0 && h('div', { class: 'strip-legend' }, + [...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 diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index a601445..b421064 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -168,13 +168,18 @@ function section() { // buttons, because these are six URLs: app.js intercepts the click, the // browser's Back walks them, and a reload lands where you were. function subnav() { - return h('nav', { class: 'subnav', 'aria-label': 'Team' }, + const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' }, TABS.map((t) => h('a', { class: 'subnav-link', href: t.path, text: t.label, '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 diff --git a/internal/web/static/js/theme.js b/internal/web/static/js/theme.js new file mode 100644 index 0000000..ceb09bb --- /dev/null +++ b/internal/web/static/js/theme.js @@ -0,0 +1,45 @@ +// Theme override: System (no attribute, the OS decides), Light or Dark. +// +// A classic script loaded from , not a module, so the saved choice is on +// 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 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()); +})();