Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33bb60b165 | ||
|
|
d18bb612df | ||
|
|
49ef914fd2 | ||
|
|
c1a2721fbe | ||
|
|
000a5f11bd | ||
|
|
5dcf1b5e9c |
@@ -67,6 +67,10 @@ html, body {
|
|||||||
font-family: var(--font-mono), ui-monospace, monospace;
|
font-family: var(--font-mono), ui-monospace, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
||||||
|
* readable. Lift both to gray-500 (~4:1). */
|
||||||
|
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
||||||
|
|
||||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
||||||
@@ -84,6 +88,7 @@ html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !
|
|||||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
||||||
|
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||||
@@ -91,16 +96,25 @@ html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
|||||||
/* Borders */
|
/* Borders */
|
||||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||||
|
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
||||||
|
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
||||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||||
|
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
||||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||||
|
|
||||||
|
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||||
|
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
||||||
|
html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
|
||||||
|
.bg-indigo-500, .bg-indigo-600, .bg-indigo-700, .bg-red-600, .bg-red-700,
|
||||||
|
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
||||||
|
|
||||||
/* Hover states */
|
/* Hover states */
|
||||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
@@ -177,6 +191,18 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
|
||||||
|
* padding and let the tile's own theme colours show, so the tile is no bigger
|
||||||
|
* than the video it holds. */
|
||||||
|
.leaflet-tooltip.drb-cam-pin {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type HlsType from "hls.js";
|
||||||
|
import type { Ny511Camera } from "@/lib/types";
|
||||||
|
|
||||||
|
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
|
||||||
|
const SNAPSHOT_REFRESH_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
|
||||||
|
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
|
||||||
|
* a timer. A stream that fails falls back to the snapshot rather than a black box.
|
||||||
|
*/
|
||||||
|
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [videoFailed, setVideoFailed] = useState(false);
|
||||||
|
const [tick, setTick] = useState(() => Date.now());
|
||||||
|
const useVideo = !!camera.video_url && !videoFailed;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useVideo) return;
|
||||||
|
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [useVideo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
const src = camera.video_url;
|
||||||
|
if (!useVideo || !video || !src) return;
|
||||||
|
let hls: HlsType | null = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
video.src = src; // Safari/iOS play HLS natively
|
||||||
|
} else {
|
||||||
|
import("hls.js")
|
||||||
|
.then(({ default: Hls }) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!Hls.isSupported()) { setVideoFailed(true); return; }
|
||||||
|
hls = new Hls({ maxBufferLength: 10, backBufferLength: 0 });
|
||||||
|
hls.on(Hls.Events.ERROR, (_evt, data) => { if (data.fatal) setVideoFailed(true); });
|
||||||
|
hls.loadSource(src);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
})
|
||||||
|
.catch(() => setVideoFailed(true));
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
hls?.destroy();
|
||||||
|
video.removeAttribute("src");
|
||||||
|
video.load(); // stop the download, not just the playback
|
||||||
|
};
|
||||||
|
}, [useVideo, camera.video_url]);
|
||||||
|
|
||||||
|
if (useVideo) {
|
||||||
|
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
|
||||||
|
}
|
||||||
|
if (!camera.image_url) {
|
||||||
|
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ import { useAircraft } from "@/lib/useAircraft";
|
|||||||
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||||
import { useVessels } from "@/lib/useVessels";
|
import { useVessels } from "@/lib/useVessels";
|
||||||
import { use511 } from "@/lib/use511";
|
import { use511 } from "@/lib/use511";
|
||||||
import type { Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||||
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
|
|
||||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
@@ -380,14 +381,28 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pinned cameras: clicking a camera's snapshot pins its live feed as a small
|
||||||
|
// tile anchored at the camera, so several views along one road can be read
|
||||||
|
// against the map. Oldest pin drops past the cap.
|
||||||
|
const MAX_CAMERA_PINS = 6;
|
||||||
|
|
||||||
function DotCameraLayer() {
|
function DotCameraLayer() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
const { data, error } = use511(map, "cameras", shown);
|
const { data, error } = use511(map, "cameras", shown);
|
||||||
|
const [pinned, setPinned] = useState<Ny511Camera[]>([]);
|
||||||
|
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
||||||
|
|
||||||
|
const pin = (c: Ny511Camera) => {
|
||||||
|
map.closePopup();
|
||||||
|
setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS)));
|
||||||
|
};
|
||||||
|
const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.id !== id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||||||
{(data.cameras ?? []).map((c) => (
|
{(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => (
|
||||||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||||
<Popup minWidth={260} maxWidth={340}>
|
<Popup minWidth={260} maxWidth={340}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -395,14 +410,32 @@ function DotCameraLayer() {
|
|||||||
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
||||||
{c.image_url && (
|
{c.image_url && (
|
||||||
// Popup content mounts on open, so the timestamp busts the cache per open.
|
// Popup content mounts on open, so the timestamp busts the cache per open.
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<button type="button" onClick={() => pin(c)} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="text-[10px] text-ink-muted">Snapshot: 511NY / NYSDOT</div>
|
<div className="text-[10px] text-ink-muted">
|
||||||
|
Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
{/* Players only exist while the overlay is on, so hiding it stops every stream. */}
|
||||||
|
{shown && pinned.map((c) => (
|
||||||
|
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
|
||||||
|
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
|
||||||
|
<div className="w-[192px]">
|
||||||
|
<div className="flex items-center gap-1 px-1.5 h-4">
|
||||||
|
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
|
||||||
|
<button type="button" onClick={() => unpin(c.id)} aria-label={`Unpin ${c.name}`} className="text-ink-muted hover:text-ink text-xs leading-none px-0.5">×</button>
|
||||||
|
</div>
|
||||||
|
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,11 @@ const selectClass =
|
|||||||
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
||||||
|
|
||||||
function DeviceSelect({
|
function DeviceSelect({
|
||||||
value, devices, autoLabel, label, disabled, onChange,
|
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
devices: SdrDevice[];
|
devices: SdrDevice[];
|
||||||
|
devicesKnown: boolean;
|
||||||
autoLabel: string;
|
autoLabel: string;
|
||||||
label: string;
|
label: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
@@ -44,7 +45,9 @@ function DeviceSelect({
|
|||||||
SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""}
|
SDR {d.index + 1} · serial {d.serial ?? "unknown"}{d.duplicate_serial ? " (shared serial!)" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{value && !known && <option value={value}>serial {value} (not plugged in)</option>}
|
{value && !known && (
|
||||||
|
<option value={value}>serial {value} ({devicesKnown ? "not plugged in" : "not reported"})</option>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
const reported = node.secondary_sdr_running != null;
|
const reported = node.secondary_sdr_running != null;
|
||||||
const running = node.secondary_sdr_running ?? [];
|
const running = node.secondary_sdr_running ?? [];
|
||||||
const devices = node.sdr_devices ?? [];
|
const devices = node.sdr_devices ?? [];
|
||||||
|
const devicesKnown = reported && node.sdr_devices != null;
|
||||||
|
|
||||||
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority, pins));
|
const [rows, setRows] = useState<Row[]>(() => rowsFrom(priority, pins));
|
||||||
const [op25Pin, setOp25Pin] = useState(pins.op25 ?? "");
|
const [op25Pin, setOp25Pin] = useState(pins.op25 ?? "");
|
||||||
@@ -114,7 +118,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
||||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||||
{reported && node.sdr_devices != null
|
{devicesKnown
|
||||||
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||||
: "This node hasn't reported its SDRs yet."}
|
: "This node hasn't reported its SDRs yet."}
|
||||||
</p>
|
</p>
|
||||||
@@ -134,6 +138,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
<DeviceSelect
|
<DeviceSelect
|
||||||
value={op25Pin}
|
value={op25Pin}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
devicesKnown={devicesKnown}
|
||||||
autoLabel="Automatic (first SDR)"
|
autoLabel="Automatic (first SDR)"
|
||||||
label="OP25 SDR"
|
label="OP25 SDR"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
@@ -143,7 +148,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
|
|
||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const meta = MODES.find((x) => x.mode === row.mode)!;
|
const meta = MODES.find((x) => x.mode === row.mode)!;
|
||||||
const pinMissing = !!row.pin && !devices.some((d) => d.serial === row.pin);
|
const pinMissing = devicesKnown && !!row.pin && !devices.some((d) => d.serial === row.pin);
|
||||||
const state = !row.enabled
|
const state = !row.enabled
|
||||||
? "Off"
|
? "Off"
|
||||||
: dirty
|
: dirty
|
||||||
@@ -173,6 +178,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
<DeviceSelect
|
<DeviceSelect
|
||||||
value={row.pin}
|
value={row.pin}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
devicesKnown={devicesKnown}
|
||||||
autoLabel="Any spare SDR"
|
autoLabel="Any spare SDR"
|
||||||
label={`${meta.name} SDR`}
|
label={`${meta.name} SDR`}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"firebase": "^10.12.0",
|
"firebase": "^10.12.0",
|
||||||
|
"hls.js": "^1.5.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-markdown": "^9.0.1"
|
"react-markdown": "^9.0.1"
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const tok = (name: string) =>
|
||||||
|
`color-mix(in srgb, var(--${name}) calc(<alpha-value> * 100%), transparent)`;
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
@@ -15,24 +18,27 @@ const config: Config = {
|
|||||||
// Semantic tokens — defined as CSS custom properties in app/globals.css on
|
// Semantic tokens — defined as CSS custom properties in app/globals.css on
|
||||||
// :root (light) and .dark (dark). Components must use these names, never a
|
// :root (light) and .dark (dark). Components must use these names, never a
|
||||||
// raw gray-9xx, so a theme is one variable block rather than an override sheet.
|
// raw gray-9xx, so a theme is one variable block rather than an override sheet.
|
||||||
|
// Wrapped in color-mix so opacity modifiers (bg-surface/90, bg-accent/15)
|
||||||
|
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
||||||
|
// class silently generates no rule (map overlays rendered see-through).
|
||||||
colors: {
|
colors: {
|
||||||
page: "var(--page)",
|
page: tok("page"),
|
||||||
surface: "var(--surface)",
|
surface: tok("surface"),
|
||||||
raised: "var(--raised)",
|
raised: tok("raised"),
|
||||||
line: "var(--line)",
|
line: tok("line"),
|
||||||
"line-strong": "var(--line-strong)",
|
"line-strong": tok("line-strong"),
|
||||||
ink: {
|
ink: {
|
||||||
DEFAULT: "var(--ink)",
|
DEFAULT: tok("ink"),
|
||||||
2: "var(--ink-2)",
|
2: tok("ink-2"),
|
||||||
muted: "var(--ink-muted)",
|
muted: tok("ink-muted"),
|
||||||
},
|
},
|
||||||
accent: "var(--accent)",
|
accent: tok("accent"),
|
||||||
"sev-moderate": "var(--sev-moderate)",
|
"sev-moderate": tok("sev-moderate"),
|
||||||
"sev-major": "var(--sev-major)",
|
"sev-major": tok("sev-major"),
|
||||||
"map-bg": "var(--map-bg)",
|
"map-bg": tok("map-bg"),
|
||||||
"map-block": "var(--map-block)",
|
"map-block": tok("map-block"),
|
||||||
"map-road": "var(--map-road)",
|
"map-road": tok("map-road"),
|
||||||
"map-water": "var(--map-water)",
|
"map-water": tok("map-water"),
|
||||||
},
|
},
|
||||||
// Marketing/product type scale — used by the (marketing) surface and
|
// Marketing/product type scale — used by the (marketing) surface and
|
||||||
// settings shell so headings read as a deliberate hierarchy rather than
|
// settings shell so headings read as a deliberate hierarchy rather than
|
||||||
|
|||||||
Reference in New Issue
Block a user