Raise contrast and switch the accents to MCCS colors
All checks were successful
Build and push image / build (push) Successful in 15s

Feedback asked for higher contrast. Every color is now a token in
:root, with text checked for WCAG AA: faint text goes from 2.9:1 to
6:1 and muted from 7:1 to 9:1. The heat scale gets its own stronger
color with a 25% floor so a one-ticket cell is visible, and nothing is
smaller than 12px except the feed's "ago" label.

The accent changes from orange to MCCS green and the heat scale to MCCS
navy, both lifted from the colors sampled from the ITS logo, with a
green stripe under the header. Errors stay red-orange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Skylar Grant 2026-10-19 12:00:38 -04:00
parent dc684fd7fa
commit 0d0bf2b033
3 changed files with 46 additions and 26 deletions

View File

@ -41,6 +41,8 @@ Docs: <https://www.manageengine.com/products/service-desk/sdpod-v3-api/getting-s
- **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open. - **2026-10-15:** **Status rules stay in `.env`** (`STATUS_NAMES`, `UNASSIGNED_EXCLUDED_STATUSES`). They're the help desk's definition of open and closed, not a personal preference. Hiding a status column is per user, but hidden columns still count toward Total open.
- **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest. - **2026-10-19:** **The closed feed's statuses are per user (`feedStatuses`, v2.1.1).** This narrows the decision above: `STATUS_NAMES` still defines open and closed for the status table, Closed today and the unassigned count, and is the default for `feedStatuses`. Our SDP closes a resolved ticket on its own after 2 or 3 days if it isn't reopened, so watching only Closed gave the ding days late. When Resolved is in a user's list, the feed times a ticket by `resolved_time` before `completed_time`, so the automatic close (new `completed_time`, old `resolved_time`) falls outside the check window and doesn't ding again. A reopen and re-resolve gives a new `resolved_time`, which counts as a fresh close. An empty list is refused, because the feed would then take any status. Before this, an empty `STATUS_NAMES` made the feed take any status; now it falls back to `Closed,Resolved,Canceled` like the rest.
- **2026-10-19:** **The technician table sorts by clicking its headers, remembered per browser** (`localStorage` key `sdp-table-sort`, like the sound). This replaced a short-lived `tableSort` setting in the Settings dialog (never released): headers are easier to find and switch, and a wallboard and a desk on the same account can sort differently. The default is name A to Z; a new column starts A to Z for names and highest first for counts, and a second click reverses it. Ties always go A to Z. The arrow points toward the larger end, so ↓ means A to Z or lowest first and ↑ means highest first. Skylar found the usual ↑-for-ascending convention backwards, so don't "fix" it. A saved status sort whose column has no tickets right now just sorts everyone as 0 (A to Z). - **2026-10-19:** **The technician table sorts by clicking its headers, remembered per browser** (`localStorage` key `sdp-table-sort`, like the sound). This replaced a short-lived `tableSort` setting in the Settings dialog (never released): headers are easier to find and switch, and a wallboard and a desk on the same account can sort differently. The default is name A to Z; a new column starts A to Z for names and highest first for counts, and a second click reverses it. Ties always go A to Z. The arrow points toward the larger end, so ↓ means A to Z or lowest first and ↑ means highest first. Skylar found the usual ↑-for-ascending convention backwards, so don't "fix" it. A saved status sort whose column has no tickets right now just sorts everyone as 0 (A to Z).
- **2026-10-19:** **Contrast pass before any theme picker (v2.2.0).** Feedback asked for higher contrast or theme options. We fixed the one dark theme first and will add a small per-browser picker (Dark, Light, High contrast) only if people still ask; a gallery of themes was ruled out because each one has to be rechecked against SDP's status colors, the heat scale and every new panel. Every color is now a token in `:root` in `index.html` (no inline or one-off colors), with text checked against `--card` and `--card-raised` for WCAG AA: `--faint` went from 2.9:1 to 6:1, `--muted` from 7:1 to 9:1. The table heat scale uses its own `--heat-max` (2.9:1 against the card, ink on it 5.2:1) with a floor of 25%, so a cell with one ticket is visible.
- **2026-10-19:** **MCCS colors (v2.2.0).** There's no MCCS brand guide that we know of, so navy `#1a3d6b` and green `#4f763c` were sampled from the ITS logo's pixels (`--mccs-navy`, `--mccs-green`). Both are too dark for text on the dark theme, so the page uses lifted versions of the same hues: the accent (`--signal`, was orange) is MCCS green at 58% lightness, the heat scale is MCCS navy at 44%, and the backgrounds are tinted toward the navy hue. The header stripe is the raw green. Errors keep the red-orange `--alert` so they never look like the green accent. SDP's own status colors (the column swatches) are untouched. Nothing is smaller than 12px except the feed's "ago" label (11px, uppercase). Recheck the ratios when changing a token.
- **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person. - **2026-10-15:** **Sound stays per browser** (`localStorage`), not in the saved settings, so a muted desk browser doesn't mute the wallboard signed in as the same person.
- **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope. - **2026-10-15:** **Sign-in is limited by email domain (`ALLOWED_EMAIL_DOMAINS`).** Any Zoho account can approve our client, and before this, an outside account got a session and `/api/session` handed it the `.env` site, group and technician names (ticket queries failed with their token). Prod v2.0.0 had this gap until v2.1.0 was deployed on 2026-10-16. The callback now refuses other domains and revokes their token, `requireSession` re-checks every call so older cookies die too, and the server won't start without the setting. Students don't have Zoho accounts, so the domain is enough for now. Next step: check the user is an SDP technician at sign-in, which probably needs a users/technicians read scope.
- **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP. - **2026-10-16:** **No ticket subjects, anywhere (FERPA).** Subjects can name students or describe their situation. The closed feed and the unassigned query no longer put `subject` in `fields_required`, and the objects the server returns are built field by field, so a subject SDP sends anyway (if it ever ignores `fields_required`) is dropped before it leaves the server. Feed cards show the ticket number, technician and closing time. Don't add `subject`, `description` or any other free-text field to a query or response without a new review; to identify a ticket, use its number and open it in SDP.

View File

@ -17,6 +17,7 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
- [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain. - [ ] **Finish checking the rebuild live.** Done on 2026-10-15 with a bare-metal test run behind `dash-test`: Zoho sign-in, live SDP data, Sign out, and the NPM access list (403 from mobile data). Still to check: the token renews after an hour without a sign-in prompt, and the first `v*-prod*` image builds, starts healthy and serves the production subdomain.
- [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`. - [ ] **Per-user settings: finish live checks.** Built on 2026-10-15 (branch `v2.1.0`), tested with stubs only. Check against real SDP: the name suggestions query, the setup prompt on first sign-in, and the `sdp-data` volume surviving `docker compose pull && up -d`. Fill in campus satellites in `src/places.js`.
- [ ] **Technician check at sign-in.** Replace or back up `ALLOWED_EMAIL_DOMAINS` by asking SDP whether the user is a technician. Find the scope this needs (users/technicians read); adding it means everyone signs in again. It would also allow real pick lists for technicians. - [ ] **Technician check at sign-in.** Replace or back up `ALLOWED_EMAIL_DOMAINS` by asking SDP whether the user is a technician. Find the scope this needs (users/technicians read); adding it means everyone signs in again. It would also allow real pick lists for technicians.
- [ ] **Theme picker, only if still wanted.** v2.2.0 made the dark theme higher contrast and MCCS colored. If people still ask, add Dark / Light / High contrast, per browser, starting from the OS preference (see `NOTES.md`).
- [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model. - [ ] **Panel modules:** each panel has a server route to fetch its data and page code to draw it. The weather card is the model.
- [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`). - [ ] **Status table width:** it scrolls sideways once there are more statuses than fit (see `NOTES.md`).
@ -37,4 +38,5 @@ A hosted dashboard for MCCS help desk staff. Everyone signs in with their own Zo
- Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM. - Live test on 2026-10-15: per-user sign-in, data, Sign out and the IP access list all work behind NPM.
- Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers. - Per-user settings (2026-10-15, `v2.1.0`): ⚙ Settings and a first-visit setup prompt; sites, groups, technicians with suggestions from recent SDP tickets; table refresh and feed length; weather location from a campus list; hidden panels and status columns. Saved server side by Zoho email, so they follow the user between browsers.
- Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again. - Per-user closed statuses for the feed and ding (2026-10-19, `v2.1.1`), so Resolved can ding without waiting days for SDP's automatic close. Tested with stubs only; check against real SDP that resolved tickets carry `resolved_time` and that the later automatic close doesn't ding again.
- Contrast pass and MCCS colors (2026-10-19, `v2.2.0`): WCAG AA text, stronger heat scale, 12px minimum text, MCCS green accent and navy heat, all colors as tokens.
- Sortable technician table (2026-10-19, `v2.1.1`): click any header to sort, again to reverse. Defaults to name A to Z; remembered per browser. - Sortable technician table (2026-10-19, `v2.1.1`): click any header to sort, again to reverse. Defaults to name A to Z; remembered per browser.

View File

@ -11,16 +11,31 @@
<style> <style>
:root { :root {
--ground: hsl(210, 18%, 8%); /* every color the page uses lives here. Text tokens are checked against --card and
--card: hsl(210, 16%, 13%); --card-raised for WCAG AA (4.5:1): ink 15:1, muted 9:1, faint 6:1 (5.3:1 raised),
--card-raised: hsl(210, 15%, 16%); signal 7.6:1, alert 5.2:1. Recheck when changing one */
/* MCCS colors, sampled from the ITS logo (no brand guide). Both are too dark for text on
this background, so the tokens below use lighter versions of the same hues */
--mccs-navy: #1a3d6b;
--mccs-green: #4f763c;
--ground: hsl(214, 40%, 8%);
--card: hsl(214, 33%, 13%);
--card-raised: hsl(214, 30%, 17%);
--ink: hsl(30, 30%, 96%); --ink: hsl(30, 30%, 96%);
--muted: hsl(215, 12%, 68%); --muted: hsl(214, 16%, 76%);
--faint: hsl(215, 10%, 42%); --faint: hsl(214, 12%, 62%);
--rule: hsl(210, 12%, 24%); --rule: hsl(214, 24%, 30%);
--signal: hsl(28, 95%, 58%); /* the accent: live dot, closes, the feed, toggles and focus; MCCS green, lifted */
--signal-soft: hsl(28, 70%, 22%); --signal: hsl(101, 40%, 58%);
--signal-soft: hsl(101, 33%, 22%);
/* errors stay red-orange so they never read as the green accent */
--alert: hsl(14, 90%, 58%); --alert: hsl(14, 90%, 58%);
/* the busiest table cell, MCCS navy lifted; ink on it stays above 5:1 */
--heat-max: hsl(214, 61%, 44%);
--rain: hsl(205, 70%, 66%);
--backdrop: hsla(214, 40%, 4%, 0.7);
--font-ui: 'Outfit', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-ui: 'Outfit', system-ui, -apple-system, 'Segoe UI', sans-serif;
--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
@ -59,7 +74,7 @@ header {
align-items: center; align-items: center;
gap: 12px 20px; gap: 12px 20px;
padding-bottom: 14px; padding-bottom: 14px;
border-bottom: 2px solid var(--ink); border-bottom: 3px solid var(--mccs-green);
} }
h1 { h1 {
@ -229,7 +244,7 @@ button.quiet:disabled { opacity: 0.5; cursor: default; }
} }
.stat .label { .stat .label {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 12px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
@ -284,7 +299,7 @@ button.quiet:disabled { opacity: 0.5; cursor: default; }
.section-head .note { .section-head .note {
margin-left: auto; margin-left: auto;
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 12px;
color: var(--muted); color: var(--muted);
} }
@ -295,7 +310,7 @@ button.quiet:disabled { opacity: 0.5; cursor: default; }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 14px; font-size: 15px;
} }
th, td { th, td {
@ -306,7 +321,7 @@ th, td {
th { th {
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 12px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--muted); color: var(--muted);
@ -354,9 +369,9 @@ td.num {
--heat: 0; --heat: 0;
font-family: var(--mono); font-family: var(--mono);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), transparent); background: color-mix(in oklab, var(--heat-max) calc(var(--heat) * 100%), transparent);
} }
tbody tr:hover td.num { background: color-mix(in oklab, var(--signal-soft) calc(var(--heat) * 100%), var(--card-raised)); } tbody tr:hover td.num { background: color-mix(in oklab, var(--heat-max) calc(var(--heat) * 100%), var(--card-raised)); }
td.zero { color: var(--faint); } td.zero { color: var(--faint); }
/* totals and closed-today sit apart from the per-status columns */ /* totals and closed-today sit apart from the per-status columns */
@ -369,7 +384,7 @@ tfoot td.closed { color: var(--ink); }
tfoot td { tfoot td {
border-top: 1px solid var(--rule); border-top: 1px solid var(--rule);
font-family: var(--mono); font-family: var(--mono);
font-size: 13px; font-size: 14px;
font-weight: 600; font-weight: 600;
background: none; background: none;
} }
@ -414,7 +429,7 @@ tr.flash td { animation: flash 2.4s ease-out; }
color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%)); color: color-mix(in oklab, var(--signal), var(--muted) calc(var(--age) * 100%));
} }
.rail small { .rail small {
font-size: 10px; font-size: 11px;
font-weight: 400; font-weight: 400;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@ -432,7 +447,7 @@ tr.flash td { animation: flash 2.4s ease-out; }
.byline { .byline {
margin: 2px 0 0; margin: 2px 0 0;
font-size: 12px; font-size: 13px;
color: var(--muted); color: var(--muted);
} }
.byline b { color: var(--ink); font-weight: 500; } .byline b { color: var(--ink); font-weight: 500; }
@ -455,10 +470,11 @@ body > footer {
max-width: 1320px; max-width: 1320px;
margin: auto auto 0; margin: auto auto 0;
padding: 0 24px 28px; padding: 0 24px 28px;
font-size: 11px; font-size: 12px;
} }
body > footer p { margin: 0; } body > footer p { margin: 0; }
body > footer p + p { margin-top: 4px; } body > footer p + p { margin-top: 4px; }
body > footer .credit { color: var(--signal); }
/* ---- weather ---- */ /* ---- weather ---- */
@ -503,10 +519,10 @@ body > footer p + p { margin-top: 4px; }
font-size: 12px; font-size: 12px;
} }
.forecast-day + .forecast-day { border-left: 1px solid color-mix(in oklab, var(--rule), transparent 50%); } .forecast-day + .forecast-day { border-left: 1px solid color-mix(in oklab, var(--rule), transparent 50%); }
.forecast-day .name { color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } .forecast-day .name { color: var(--muted); font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; }
.forecast-day img { display: block; width: 44px; height: 44px; margin: 0 auto; } .forecast-day img { display: block; width: 44px; height: 44px; margin: 0 auto; }
.forecast-day .lo { color: var(--muted); } .forecast-day .lo { color: var(--muted); }
.forecast-day .pop { margin-top: 2px; font-size: 11px; color: hsl(205, 70%, 66%); } .forecast-day .pop { margin-top: 2px; font-size: 12px; color: var(--rain); }
/* ---- temporary: unassigned verification list (not for prod) ---- */ /* ---- temporary: unassigned verification list (not for prod) ---- */
@ -538,7 +554,7 @@ dialog {
color: var(--ink); color: var(--ink);
border: 1px solid var(--rule); border: 1px solid var(--rule);
} }
dialog::backdrop { background: hsla(210, 18%, 4%, 0.7); } dialog::backdrop { background: var(--backdrop); }
.settings-head { padding: 20px 24px 4px; } .settings-head { padding: 20px 24px 4px; }
.settings-head h2 { margin: 0; font-size: 17px; font-weight: 600; } .settings-head h2 { margin: 0; font-size: 17px; font-weight: 600; }
@ -555,7 +571,7 @@ legend, .field > label {
display: block; display: block;
margin-bottom: 6px; margin-bottom: 6px;
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 12px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--muted); color: var(--muted);
@ -1020,7 +1036,7 @@ function renderWorkload(wl) {
} else { } else {
const max = Math.max(1, ...techs.flatMap(t => statuses.map(s => t.counts[s.name] || 0))); const max = Math.max(1, ...techs.flatMap(t => statuses.map(s => t.counts[s.name] || 0)));
const cell = (n, cls) => n const cell = (n, cls) => n
? `<td class="num ${cls || ''}" style="--heat:${cls ? 0 : (n / max * 0.9 + 0.1).toFixed(3)}">${n}</td>` ? `<td class="num ${cls || ''}" style="--heat:${cls ? 0 : (n / max * 0.75 + 0.25).toFixed(3)}">${n}</td>`
: `<td class="num zero ${cls || ''}">·</td>`; : `<td class="num zero ${cls || ''}">·</td>`;
// totals come right after the name so they stay visible when the status columns scroll off // totals come right after the name so they stay visible when the status columns scroll off
@ -1445,8 +1461,8 @@ start();
setInterval(render, 20000); // re-age the rails between checks setInterval(render, 20000); // re-age the rails between checks
</script> </script>
<footer> <footer>
<p>Design and Credits: <span style="color: hsl(28, 95%, 58%);">Isaac Godin</span></p> <p>Design and Credits: <span class="credit">Isaac Godin</span></p>
<p>Dashboard by <span style="color: hsl(28, 95%, 58%);">Skylar Grant</span> with <span style="color: hsl(28, 95%, 58%);">Claude</span> (Anthropic)</p> <p>Dashboard by <span class="credit">Skylar Grant</span> with <span class="credit">Claude</span> (Anthropic)</p>
</footer> </footer>
</body> </body>
</html> </html>