feat(design): apply NTMS design system tokens

Adopt the handoff design system: navy-slate surfaces with indigo-violet
accent (#6366F1 dark / #5458E3 light), Inter as the UI font, hairline
1px borders, softer corner radii. Add propagation-scale and amateur-band
CSS vars so JS hooks can pull theme-aware colors via getComputedStyle.

Polish: drop table zebra striping for hover-on-base-200 rows with
uppercase small-caps headers, bump .header to text-2xl, tighten nav
spacing with a hairline divider, fix broken text-brand -> text-primary
on the auth screens.

Wire --band-* into contacts_map_hook: resolve once at mount, re-resolve
on data-theme mutation so polylines recolor when the theme toggles.
This commit is contained in:
Graham McIntire 2026-04-17 17:32:59 -05:00
parent 2464e6fb1c
commit b8b78f7e57
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 199 additions and 74 deletions

View file

@ -1,6 +1,10 @@
/* Leaflet map CSS (vendored) */ /* Leaflet map CSS (vendored) */
@import "../vendor/leaflet/leaflet.css"; @import "../vendor/leaflet/leaflet.css";
/* Inter primary UI font. Design spec calls for weights 400/500/600/700.
JetBrains Mono kept as escape hatch for code blocks. */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
/* See the Tailwind configuration guide for advanced usage /* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */ https://tailwindcss.com/docs/configuration */
@ -33,38 +37,44 @@
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */ on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
/* NTMS Propagation Prediction dark theme (primary).
Navy-slate surfaces, indigo-violet accent. Values derived from the
design-system handoff: bg-2 (#1E2230) is the elevated surface class
(cards, nav), bg-1 (#161922) is the app body, bg-0 (#0F1218) is the
deepest page fill. Kept in this codebase's base-100/200/300 convention
where base-100 is the *lightest* dark surface (elevated). */
@plugin "../vendor/daisyui-theme" { @plugin "../vendor/daisyui-theme" {
name: "dark"; name: "dark";
default: false; default: false;
prefersdark: true; prefersdark: true;
color-scheme: "dark"; color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42); --color-base-100: #1E2230;
--color-base-200: oklch(25.26% 0.014 253.1); --color-base-200: #161922;
--color-base-300: oklch(20.15% 0.012 254.09); --color-base-300: #0F1218;
--color-base-content: oklch(97.807% 0.029 256.847); --color-base-content: #F5F6FA;
--color-primary: oklch(58% 0.233 277.117); --color-primary: #6366F1;
--color-primary-content: oklch(96% 0.018 272.314); --color-primary-content: #FFFFFF;
--color-secondary: oklch(58% 0.233 277.117); --color-secondary: #7376F4;
--color-secondary-content: oklch(96% 0.018 272.314); --color-secondary-content: #FFFFFF;
--color-accent: oklch(60% 0.25 292.717); --color-accent: #6366F1;
--color-accent-content: oklch(96% 0.016 293.756); --color-accent-content: #FFFFFF;
--color-neutral: oklch(37% 0.044 257.287); --color-neutral: #262B3B;
--color-neutral-content: oklch(98% 0.003 247.858); --color-neutral-content: #F5F6FA;
--color-info: oklch(58% 0.158 241.966); --color-info: #60A5FA;
--color-info-content: oklch(97% 0.013 236.62); --color-info-content: #0A1A2B;
--color-success: oklch(60% 0.118 184.704); --color-success: #2BD4A4;
--color-success-content: oklch(98% 0.014 180.72); --color-success-content: #052A1E;
--color-warning: oklch(66% 0.179 58.318); --color-warning: #F5A524;
--color-warning-content: oklch(98% 0.022 95.277); --color-warning-content: #2A1A00;
--color-error: oklch(58% 0.253 17.585); --color-error: #F05674;
--color-error-content: oklch(96% 0.015 12.422); --color-error-content: #FFFFFF;
--radius-selector: 0.25rem; --radius-selector: 0.375rem;
--radius-field: 0.25rem; --radius-field: 0.375rem;
--radius-box: 0.5rem; --radius-box: 0.75rem;
--size-selector: 0.21875rem; --size-selector: 0.21875rem;
--size-field: 0.21875rem; --size-field: 0.21875rem;
--border: 1.5px; --border: 1px;
--depth: 1; --depth: 0;
--noise: 0; --noise: 0;
} }
@ -73,33 +83,33 @@
default: true; default: true;
prefersdark: false; prefersdark: false;
color-scheme: "light"; color-scheme: "light";
--color-base-100: oklch(98% 0 0); --color-base-100: #FFFFFF;
--color-base-200: oklch(96% 0.001 286.375); --color-base-200: #F7F8FB;
--color-base-300: oklch(92% 0.004 286.32); --color-base-300: #F1F3F8;
--color-base-content: oklch(21% 0.006 285.885); --color-base-content: #10131C;
--color-primary: oklch(70% 0.213 47.604); --color-primary: #5458E3;
--color-primary-content: oklch(98% 0.016 73.684); --color-primary-content: #FFFFFF;
--color-secondary: oklch(55% 0.027 264.364); --color-secondary: #4A4ED1;
--color-secondary-content: oklch(98% 0.002 247.839); --color-secondary-content: #FFFFFF;
--color-accent: oklch(0% 0 0); --color-accent: #5458E3;
--color-accent-content: oklch(100% 0 0); --color-accent-content: #FFFFFF;
--color-neutral: oklch(44% 0.017 285.786); --color-neutral: #323849;
--color-neutral-content: oklch(98% 0 0); --color-neutral-content: #FFFFFF;
--color-info: oklch(62% 0.214 259.815); --color-info: #2563EB;
--color-info-content: oklch(97% 0.014 254.604); --color-info-content: #FFFFFF;
--color-success: oklch(70% 0.14 182.503); --color-success: #149E74;
--color-success-content: oklch(98% 0.014 180.72); --color-success-content: #FFFFFF;
--color-warning: oklch(66% 0.179 58.318); --color-warning: #C77700;
--color-warning-content: oklch(98% 0.022 95.277); --color-warning-content: #FFFFFF;
--color-error: oklch(58% 0.253 17.585); --color-error: #D0324D;
--color-error-content: oklch(96% 0.015 12.422); --color-error-content: #FFFFFF;
--radius-selector: 0.25rem; --radius-selector: 0.375rem;
--radius-field: 0.25rem; --radius-field: 0.375rem;
--radius-box: 0.5rem; --radius-box: 0.75rem;
--size-selector: 0.21875rem; --size-selector: 0.21875rem;
--size-field: 0.21875rem; --size-field: 0.21875rem;
--border: 1.5px; --border: 1px;
--depth: 1; --depth: 0;
--noise: 0; --noise: 0;
} }
@ -114,6 +124,65 @@
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents } [data-phx-session], [data-phx-teleported-src] { display: contents }
/* ==========================================================================
NTMS design tokens propagation quality + band palette.
Held outside daisyUI's theme plugin so they can be referenced directly
from HEEx templates and JS hooks (e.g. the heatmap canvas).
========================================================================== */
:root,
:root[data-theme="dark"] {
--prop-excellent: #2BD4A4;
--prop-good: #9FE6C8;
--prop-marginal: #F5D968;
--prop-poor: #F59E4B;
--prop-negligible: #EF6B6B;
--band-2m: #60A5FA;
--band-70cm: #A78BFA;
--band-23cm: #F472B6;
--band-13cm: #FB7185;
--band-9cm: #FB923C;
--band-6cm: #FBBF24;
--band-3cm: #A3E635;
--band-1-25cm: #34D399;
--band-6mm: #22D3EE;
}
:root[data-theme="light"] {
--prop-excellent: #149E74;
--prop-good: #5FC79D;
--prop-marginal: #E5B83B;
--prop-poor: #E58A3B;
--prop-negligible: #D45757;
--band-2m: #2563EB;
--band-70cm: #7C3AED;
--band-23cm: #DB2777;
--band-13cm: #E11D48;
--band-9cm: #EA580C;
--band-6cm: #B45309;
--band-3cm: #65A30D;
--band-1-25cm: #059669;
--band-6mm: #0891B2;
}
/* Inter as the default UI font. JetBrains Mono reserved for code blocks.
Callsigns and grid squares are intentionally sans + uppercase (not mono)
to match product convention. */
html, body {
font-family:
"Inter", ui-sans-serif, system-ui, -apple-system,
"Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-variant-numeric: tabular-nums;
}
/* Callsign / grid styling helpers. Sans with tight tracking in upper
case; tabular digits so columns align. */
.callsign { font-weight: 600; letter-spacing: 0.02em; text-transform: uppercase; }
.grid-square { font-weight: 500; color: var(--color-base-content); opacity: 0.85; }
/* Fix daisyUI select text alignment when select class is on the <select> /* Fix daisyUI select text alignment when select class is on the <select>
element directly, the nested .select select rules cause negative margins element directly, the nested .select select rules cause negative margins
and misaligned text. Reset them so text sits centered like <input>. */ and misaligned text. Reset them so text sits centered like <input>. */

View file

@ -30,6 +30,7 @@ interface ContactsMapHook extends ViewHook {
map: L.Map map: L.Map
delegatedChange: (e: Event) => void delegatedChange: (e: Event) => void
delegatedClick: (e: Event) => void delegatedClick: (e: Event) => void
themeObserver: MutationObserver | null
initMap(this: ContactsMapHook): void initMap(this: ContactsMapHook): void
buildLines(this: ContactsMapHook): void buildLines(this: ContactsMapHook): void
syncBandLayers(this: ContactsMapHook): void syncBandLayers(this: ContactsMapHook): void
@ -38,16 +39,26 @@ interface ContactsMapHook extends ViewHook {
updateCount(this: ContactsMapHook, count: number): void updateCount(this: ContactsMapHook, count: number): void
wireControls(this: ContactsMapHook): void wireControls(this: ContactsMapHook): void
unwireControls(this: ContactsMapHook): void unwireControls(this: ContactsMapHook): void
restyleLines(this: ContactsMapHook): void
} }
const BAND_COLORS: Record<number, string> = { // Amateur-band palette. Nine of these map to the design system's --band-*
1296: "#475569", // CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
2304: "#7c3aed", // 47 GHz fall back to hard-coded hues since the design set only spans up
3400: "#4f46e5", // to 6mm. CSS vars are resolved once per page via getComputedStyle because
5760: "#2563eb", // Leaflet writes the color onto the SVG `stroke` attribute, which does not
10000: "#059669", // evaluate `var()` references.
24000: "#d97706", const BAND_VAR: Record<number, string> = {
47000: "#ea580c", 1296: "--band-23cm",
2304: "--band-13cm",
3400: "--band-9cm",
5760: "--band-6cm",
10000: "--band-3cm",
24000: "--band-1-25cm",
47000: "--band-6mm",
}
const BAND_FALLBACK: Record<number, string> = {
68000: "#dc2626", 68000: "#dc2626",
75000: "#c026d3", 75000: "#c026d3",
122000: "#db2777", 122000: "#db2777",
@ -55,6 +66,24 @@ const BAND_COLORS: Record<number, string> = {
241000: "#b91c1c", 241000: "#b91c1c",
} }
let BAND_COLORS: Record<number, string> | null = null
function resolveBandColors(): Record<number, string> {
if (BAND_COLORS) return BAND_COLORS
const styles = getComputedStyle(document.documentElement)
const resolved: Record<number, string> = { ...BAND_FALLBACK }
for (const [band, cssVar] of Object.entries(BAND_VAR)) {
const value = styles.getPropertyValue(cssVar).trim()
if (value) resolved[Number(band)] = value
}
BAND_COLORS = resolved
return resolved
}
function invalidateBandColors(): void {
BAND_COLORS = null
}
function bandLabel(mhz: number): string { function bandLabel(mhz: number): string {
if (mhz >= 1000) { if (mhz >= 1000) {
const ghz = mhz / 1000 const ghz = mhz / 1000
@ -64,7 +93,7 @@ function bandLabel(mhz: number): string {
} }
function bandColor(band: number): string { function bandColor(band: number): string {
return BAND_COLORS[band] || "#64748b" return resolveBandColors()[band] || "#64748b"
} }
export const ContactsMap = { export const ContactsMap = {
@ -76,6 +105,7 @@ export const ContactsMap = {
this.bandGroups = new Map() this.bandGroups = new Map()
this.lines = [] this.lines = []
this.dotLayer = L.layerGroup() this.dotLayer = L.layerGroup()
this.themeObserver = null
// Callsign filter still roundtrips through LiveView (debounced) — it's // Callsign filter still roundtrips through LiveView (debounced) — it's
// cross-cutting and fires rarely. Band toggles are purely client-side. // cross-cutting and fires rarely. Band toggles are purely client-side.
@ -87,6 +117,18 @@ export const ContactsMap = {
this.wireControls() this.wireControls()
// Re-color polylines when the theme toggles. Leaflet writes `stroke` as
// an SVG attribute (which does not resolve CSS vars), so we re-read the
// resolved --band-* values and call setStyle on each line.
this.themeObserver = new MutationObserver(() => {
invalidateBandColors()
this.restyleLines()
})
this.themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"]
})
requestAnimationFrame(() => this.initMap()) requestAnimationFrame(() => this.initMap())
fetch(fetchUrl) fetch(fetchUrl)
@ -105,6 +147,16 @@ export const ContactsMap = {
destroyed(this: ContactsMapHook) { destroyed(this: ContactsMapHook) {
this.unwireControls() this.unwireControls()
if (this.themeObserver) {
this.themeObserver.disconnect()
this.themeObserver = null
}
},
restyleLines(this: ContactsMapHook) {
for (const entry of this.lines) {
entry.line.setStyle({ color: bandColor(entry.band) })
}
}, },
wireControls(this: ContactsMapHook) { wireControls(this: ContactsMapHook) {

View file

@ -322,10 +322,10 @@ defmodule MicrowavepropWeb.CoreComponents do
~H""" ~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}> <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div> <div>
<h1 class="text-lg font-semibold leading-8"> <h1 class="text-2xl font-semibold leading-snug tracking-tight">
{render_slot(@inner_block)} {render_slot(@inner_block)}
</h1> </h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70"> <p :if={@subtitle != []} class="text-sm text-base-content/70 mt-0.5">
{render_slot(@subtitle)} {render_slot(@subtitle)}
</p> </p>
</div> </div>
@ -371,13 +371,13 @@ defmodule MicrowavepropWeb.CoreComponents do
end end
~H""" ~H"""
<table class="table table-zebra"> <table class="table">
<thead> <thead class="text-xs uppercase tracking-wide text-base-content/60">
<tr> <tr>
<th :for={col <- @col}> <th :for={col <- @col} class="font-medium">
<span <span
:if={col[:sort_field]} :if={col[:sort_field]}
class="cursor-pointer select-none inline-flex items-center gap-1" class="cursor-pointer select-none inline-flex items-center gap-1 hover:text-base-content"
phx-click="sort" phx-click="sort"
phx-value-field={col[:sort_field]} phx-value-field={col[:sort_field]}
phx-value-table={@sort_target} phx-value-table={@sort_target}
@ -396,13 +396,17 @@ defmodule MicrowavepropWeb.CoreComponents do
</span> </span>
<span :if={!col[:sort_field]}>{col[:label]}</span> <span :if={!col[:sort_field]}>{col[:label]}</span>
</th> </th>
<th :if={@action != []}> <th :if={@action != []} class="font-medium">
<span class="sr-only">{gettext("Actions")}</span> <span class="sr-only">{gettext("Actions")}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}> <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}> <tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="hover:bg-base-200 transition-colors"
>
<td <td
:for={col <- @col} :for={col <- @col}
phx-click={@row_click && @row_click.(row)} phx-click={@row_click && @row_click.(row)}
@ -410,8 +414,8 @@ defmodule MicrowavepropWeb.CoreComponents do
> >
{render_slot(col, @row_item.(row))} {render_slot(col, @row_item.(row))}
</td> </td>
<td :if={@action != []} class="w-0 font-semibold"> <td :if={@action != []} class="w-0 font-medium text-right">
<div class="flex gap-4"> <div class="flex gap-4 justify-end">
<%= for action <- @action do %> <%= for action <- @action do %>
{render_slot(action, @row_item.(row))} {render_slot(action, @row_item.(row))}
<% end %> <% end %>

View file

@ -36,10 +36,10 @@ defmodule MicrowavepropWeb.Layouts do
def app(assigns) do def app(assigns) do
~H""" ~H"""
<header class="navbar px-4 sm:px-6 lg:px-8"> <header class="navbar px-4 sm:px-6 lg:px-8 border-b border-base-200">
<div class="flex-1 gap-4"> <div class="flex-1 gap-4">
<a href="/" class="font-semibold">NTMS Propagation Prediction</a> <a href="/" class="font-semibold tracking-tight">NTMS Propagation Prediction</a>
<nav class="flex gap-2"> <nav class="flex gap-1">
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link> <.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link> <.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link> <.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>

View file

@ -5,7 +5,7 @@
Register for an account Register for an account
<:subtitle> <:subtitle>
Already registered? Already registered?
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline"> <.link navigate={~p"/users/log-in"} class="font-semibold text-primary hover:underline">
Log in Log in
</.link> </.link>
to your account now. to your account now.

View file

@ -9,7 +9,7 @@
<% else %> <% else %>
Don't have an account? <.link Don't have an account? <.link
navigate={~p"/users/register"} navigate={~p"/users/register"}
class="font-semibold text-brand hover:underline" class="font-semibold text-primary hover:underline"
phx-no-format phx-no-format
>Sign up</.link> for an account now. >Sign up</.link> for an account now.
<% end %> <% end %>