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) */
@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
https://tailwindcss.com/docs/configuration */
@ -33,38 +37,44 @@
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
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" {
name: "dark";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1);
--color-base-300: oklch(20.15% 0.012 254.09);
--color-base-content: oklch(97.807% 0.029 256.847);
--color-primary: oklch(58% 0.233 277.117);
--color-primary-content: oklch(96% 0.018 272.314);
--color-secondary: oklch(58% 0.233 277.117);
--color-secondary-content: oklch(96% 0.018 272.314);
--color-accent: oklch(60% 0.25 292.717);
--color-accent-content: oklch(96% 0.016 293.756);
--color-neutral: oklch(37% 0.044 257.287);
--color-neutral-content: oklch(98% 0.003 247.858);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--color-base-100: #1E2230;
--color-base-200: #161922;
--color-base-300: #0F1218;
--color-base-content: #F5F6FA;
--color-primary: #6366F1;
--color-primary-content: #FFFFFF;
--color-secondary: #7376F4;
--color-secondary-content: #FFFFFF;
--color-accent: #6366F1;
--color-accent-content: #FFFFFF;
--color-neutral: #262B3B;
--color-neutral-content: #F5F6FA;
--color-info: #60A5FA;
--color-info-content: #0A1A2B;
--color-success: #2BD4A4;
--color-success-content: #052A1E;
--color-warning: #F5A524;
--color-warning-content: #2A1A00;
--color-error: #F05674;
--color-error-content: #FFFFFF;
--radius-selector: 0.375rem;
--radius-field: 0.375rem;
--radius-box: 0.75rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--border: 1px;
--depth: 0;
--noise: 0;
}
@ -73,33 +83,33 @@
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--color-base-100: #FFFFFF;
--color-base-200: #F7F8FB;
--color-base-300: #F1F3F8;
--color-base-content: #10131C;
--color-primary: #5458E3;
--color-primary-content: #FFFFFF;
--color-secondary: #4A4ED1;
--color-secondary-content: #FFFFFF;
--color-accent: #5458E3;
--color-accent-content: #FFFFFF;
--color-neutral: #323849;
--color-neutral-content: #FFFFFF;
--color-info: #2563EB;
--color-info-content: #FFFFFF;
--color-success: #149E74;
--color-success-content: #FFFFFF;
--color-warning: #C77700;
--color-warning-content: #FFFFFF;
--color-error: #D0324D;
--color-error-content: #FFFFFF;
--radius-selector: 0.375rem;
--radius-field: 0.375rem;
--radius-box: 0.75rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--border: 1px;
--depth: 0;
--noise: 0;
}
@ -114,6 +124,65 @@
/* Make LiveView wrapper divs transparent for layout */
[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>
element directly, the nested .select select rules cause negative margins
and misaligned text. Reset them so text sits centered like <input>. */

View file

@ -30,6 +30,7 @@ interface ContactsMapHook extends ViewHook {
map: L.Map
delegatedChange: (e: Event) => void
delegatedClick: (e: Event) => void
themeObserver: MutationObserver | null
initMap(this: ContactsMapHook): void
buildLines(this: ContactsMapHook): void
syncBandLayers(this: ContactsMapHook): void
@ -38,16 +39,26 @@ interface ContactsMapHook extends ViewHook {
updateCount(this: ContactsMapHook, count: number): void
wireControls(this: ContactsMapHook): void
unwireControls(this: ContactsMapHook): void
restyleLines(this: ContactsMapHook): void
}
const BAND_COLORS: Record<number, string> = {
1296: "#475569",
2304: "#7c3aed",
3400: "#4f46e5",
5760: "#2563eb",
10000: "#059669",
24000: "#d97706",
47000: "#ea580c",
// Amateur-band palette. Nine of these map to the design system's --band-*
// CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
// 47 GHz fall back to hard-coded hues since the design set only spans up
// to 6mm. CSS vars are resolved once per page via getComputedStyle because
// Leaflet writes the color onto the SVG `stroke` attribute, which does not
// evaluate `var()` references.
const BAND_VAR: Record<number, string> = {
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",
75000: "#c026d3",
122000: "#db2777",
@ -55,6 +66,24 @@ const BAND_COLORS: Record<number, string> = {
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 {
if (mhz >= 1000) {
const ghz = mhz / 1000
@ -64,7 +93,7 @@ function bandLabel(mhz: number): string {
}
function bandColor(band: number): string {
return BAND_COLORS[band] || "#64748b"
return resolveBandColors()[band] || "#64748b"
}
export const ContactsMap = {
@ -76,6 +105,7 @@ export const ContactsMap = {
this.bandGroups = new Map()
this.lines = []
this.dotLayer = L.layerGroup()
this.themeObserver = null
// Callsign filter still roundtrips through LiveView (debounced) — it's
// cross-cutting and fires rarely. Band toggles are purely client-side.
@ -87,6 +117,18 @@ export const ContactsMap = {
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())
fetch(fetchUrl)
@ -105,6 +147,16 @@ export const ContactsMap = {
destroyed(this: ContactsMapHook) {
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) {

View file

@ -322,10 +322,10 @@ defmodule MicrowavepropWeb.CoreComponents do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
<h1 class="text-2xl font-semibold leading-snug tracking-tight">
{render_slot(@inner_block)}
</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)}
</p>
</div>
@ -371,13 +371,13 @@ defmodule MicrowavepropWeb.CoreComponents do
end
~H"""
<table class="table table-zebra">
<thead>
<table class="table">
<thead class="text-xs uppercase tracking-wide text-base-content/60">
<tr>
<th :for={col <- @col}>
<th :for={col <- @col} class="font-medium">
<span
: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-value-field={col[:sort_field]}
phx-value-table={@sort_target}
@ -396,13 +396,17 @@ defmodule MicrowavepropWeb.CoreComponents do
</span>
<span :if={!col[:sort_field]}>{col[:label]}</span>
</th>
<th :if={@action != []}>
<th :if={@action != []} class="font-medium">
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<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
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
@ -410,8 +414,8 @@ defmodule MicrowavepropWeb.CoreComponents do
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<td :if={@action != []} class="w-0 font-medium text-right">
<div class="flex gap-4 justify-end">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>

View file

@ -36,10 +36,10 @@ defmodule MicrowavepropWeb.Layouts do
def app(assigns) do
~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">
<a href="/" class="font-semibold">NTMS Propagation Prediction</a>
<nav class="flex gap-2">
<a href="/" class="font-semibold tracking-tight">NTMS Propagation Prediction</a>
<nav class="flex gap-1">
<.link navigate="/map" class="btn btn-ghost btn-sm">Map</.link>
<.link navigate="/path" class="btn btn-ghost btn-sm">Path</.link>
<.link navigate="/beacons" class="btn btn-ghost btn-sm">Beacons</.link>

View file

@ -5,7 +5,7 @@
Register for an account
<:subtitle>
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
</.link>
to your account now.

View file

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