7 Commits
Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 b7cdb4452f Releases: tag each healthy deploy vMAJOR.MINOR.PATCH; /health and node checkin report versions (node-26#49)
Build & Deploy / Build & push images (push) Successful in 4m35s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 1m34s
Build & Deploy / Deploy to VM (push) Successful in 1m54s
Build & Deploy / Report a failed deploy (push) Skipped
Build & Deploy / Tag release (push) Successful in 5s
VERSION holds MAJOR.MINOR (1.1). The build job works out the next patch
(scripts/next-version.sh, integer count) and stamps it into c2-core as
DRB_VERSION, which /health returns. The new "Tag release" job tags the
commit only after the deploy job succeeds, so a failed deploy's number is
reused. Node checkins carry `version`, stored on the node doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:34:51 -05:00
Logan CusanoandClaude Opus 5.5 5cdde85d55 Calls: store every radio heard on a call; call_end writes srcaddr (server-26#304)
Build & Deploy / Build & push images (push) Successful in 4m36s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped
P1 of the radio-ID plan. The node now reports `radios`, one
{radio, start, end} per transmission, on /upload and on call_end. Both go
through app/internal/radios.py into `radios` + `radio_ids` on the call doc.
/upload writes them before the pipeline starts, because call_end arrives
after it.

call_end also writes srcaddr now. Only call_start used to, and the node
usually learns the talker after call_start, so most calls kept None and the
correlator's srcaddr match rarely fired. /upload fills srcaddr from the
latest radio when the doc has none.

Older nodes send neither field; nothing is blanked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:03:08 -05:00
logan cbf45ff004 Merge pull request 'Frontend: finish Iris — accent-role blue/sky to indigo' (#306) from feat/palette-ui-pass-3 into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m48s
Build & Deploy / Report a failed deploy (push) Skipped
2026-11-08 22:54:05 -05:00
Logan CusanoandClaude Opus 5.5 cf232e313f frontend: move accent-role blue/sky to Iris (popup links, play glyph, admin info banners)
Category colours (police tag, trips) stay as-is: they're semantic, not accent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:53:40 -05:00
logan 92c9b245f6 Merge pull request 'Frontend: Iris palette + map load/legend fixes' (#305) from feat/palette-ui-pass-2 into main
Build & Deploy / Build & push images (push) Successful in 4m21s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 26s
Build & Deploy / Deploy to VM (push) Successful in 1m32s
Build & Deploy / Report a failed deploy (push) Skipped
Merge PR #305: Iris palette + map load/legend fixes
2026-11-08 21:50:48 -05:00
Logan CusanoandClaude Opus 5.5 cc41d1badb map: mount without waiting on the 500-call window; legend collapsible, hidden under open layers list
The Live map was held behind useCalls(500), measured at ~9.2s to first map
paint on prod; it now mounts as soon as the saved view is known (nodes are
awaited only when there is no view). The legend painted over the expanded
Leaflet layers list (z-1001 vs the map's z-0 stacking context): it is now a
<details> that hides while the list is open and starts folded on short
viewports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:42:02 -05:00
Logan CusanoandClaude Opus 5.5 df837d7d81 frontend: Iris palette — graphite neutrals + indigo-violet accent
Tokens in globals.css, Tailwind gray/indigo scales remapped so the ~350
legacy raw-class uses follow the palette, light-mode override sheet moved
off slate, map accent hexes updated. Severity hues retuned for AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:41:55 -05:00
17 changed files with 390 additions and 81 deletions
+42
View File
@@ -12,9 +12,27 @@ jobs:
build: build:
name: Build & push images name: Build & push images
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
fetch-depth: 0
# node-26#49: this build's release, vMAJOR.MINOR.PATCH. Tagged only once
# the deploy is healthy (job "tag" below), so a failed deploy's number is
# reused by the next push.
- name: Work out the release version
id: version
run: |
git fetch --force --tags origin
TAG="$(bash scripts/next-version.sh)"
if [ -z "$TAG" ]; then
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k3,3n | tail -1)"
fi
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "release ${TAG}"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -33,6 +51,7 @@ jobs:
push: true push: true
build-args: | build-args: |
GIT_SHA=${{ gitea.sha }} GIT_SHA=${{ gitea.sha }}
DRB_VERSION=${{ steps.version.outputs.version }}
tags: | tags: |
${{ env.REGISTRY }}/c2-core:latest ${{ env.REGISTRY }}/c2-core:latest
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }} ${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
@@ -459,3 +478,26 @@ jobs:
PY PY
curl -sS -X POST -H "Content-Type: application/json" \ curl -sS -X POST -H "Content-Type: application/json" \
--data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed" --data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed"
# node-26#49: a healthy deploy is a release. Tag it vMAJOR.MINOR.PATCH with
# the number the build stamped into /health.
tag:
name: Tag release
needs: [build, deploy]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Tag this commit
env:
VERSION: ${{ needs.build.outputs.version }}
run: |
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "no release version from build ('$VERSION')"; exit 1; }
git fetch --force --tags origin
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
echo "v$VERSION already exists"; exit 0
fi
git tag "v$VERSION"
git push origin "refs/tags/v$VERSION"
echo "released v$VERSION"
+1
View File
@@ -0,0 +1 @@
1.1
+3
View File
@@ -13,5 +13,8 @@ COPY tests/ ./tests/
# -- exactly how production served two-day-old code for two days. # -- exactly how production served two-day-old code for two days.
ARG GIT_SHA=unknown ARG GIT_SHA=unknown
ENV GIT_SHA=$GIT_SHA ENV GIT_SHA=$GIT_SHA
# The release tag this build becomes (node-26#49), e.g. 1.1.4.
ARG DRB_VERSION=untagged
ENV DRB_VERSION=$DRB_VERSION
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+9 -1
View File
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
from app.config import settings from app.config import settings
from app.internal.logger import logger from app.internal.logger import logger
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import radios
from app.internal import talkgroups from app.internal import talkgroups
from app.internal.tenancy import FOUNDING_ORG_ID from app.internal.tenancy import FOUNDING_ORG_ID
@@ -130,6 +131,7 @@ class MQTTHandler:
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1 "sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"disk_free_bytes": payload.get("disk_free_bytes"), "disk_free_bytes": payload.get("disk_free_bytes"),
"disk_total_bytes": payload.get("disk_total_bytes"), "disk_total_bytes": payload.get("disk_total_bytes"),
"version": payload.get("version"), # installed release (node-26#49)
"enforce_override_timeout": payload.get("enforce_override_timeout", True), "enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False, "is_overridden": False,
"override_system_id": None, "override_system_id": None,
@@ -167,7 +169,7 @@ class MQTTHandler:
# records to — a full disk silently stopped node-002 for 17 days. # records to — a full disk silently stopped node-002 for 17 days.
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running", for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels", "sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
"disk_free_bytes", "disk_total_bytes"): "disk_free_bytes", "disk_total_bytes", "version"):
if key in payload: if key in payload:
updates[key] = payload[key] updates[key] = payload[key]
if "sdr_count" in payload: if "sdr_count" in payload:
@@ -307,6 +309,12 @@ class MQTTHandler:
updates["org_id"] = node["org_id"] updates["org_id"] = node["org_id"]
if payload.get("audio_url"): if payload.get("audio_url"):
updates["audio_url"] = payload["audio_url"] updates["audio_url"] = payload["audio_url"]
# The node usually learns the talker after call_start went out; only
# call_start used to write srcaddr, so most calls kept None
# (server-26#304).
if payload.get("srcaddr") is not None:
updates["srcaddr"] = payload["srcaddr"]
updates.update(radios.call_fields(payload.get("radios")))
await fstore.doc_set("calls", call_id, updates) await fstore.doc_set("calls", call_id, updates)
logger.info(f"Call end: {call_id}") logger.info(f"Call end: {call_id}")
+64
View File
@@ -0,0 +1,64 @@
"""
Every radio heard on a call, as the edge node reports it (server-26#304).
The node sends `radios`: a list of {radio, start, end}, one entry per
transmission, offsets in seconds into the uploaded audio file, so a talker's
wall-clock time is audio_start_epoch + offset (the same mapping the node uses
for the audio itself). It arrives twice — on
/upload (as a JSON string form field, because the pipeline starts there) and
on the MQTT call_end — and both go through `call_fields` so the call doc gets
the same shape either way.
`radio_ids` is the distinct IDs in first-heard order, kept alongside so a
correlator or query can ask "was radio X on this call" without walking the list.
"""
import json
from typing import Any, Dict, List, Optional
from app.internal.logger import logger
# A 600 s recording polled every 0.5 s cannot honestly hold more than this.
MAX_TRANSMISSIONS = 200
def _num(value: Any) -> Optional[float]:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number >= 0 else None
def parse(value: Any) -> List[Dict[str, Any]]:
"""The node's radio list, validated. Accepts a list or its JSON string; junk entries are dropped."""
if value is None or value == "":
return []
if isinstance(value, str):
try:
value = json.loads(value)
except ValueError:
logger.warning(f"radios: not JSON, ignored ({value[:80]!r})")
return []
if not isinstance(value, list):
return []
out: List[Dict[str, Any]] = []
for item in value[:MAX_TRANSMISSIONS]:
if not isinstance(item, dict):
continue
try:
radio = int(item.get("radio"))
except (TypeError, ValueError):
continue
start, end = _num(item.get("start")), _num(item.get("end"))
if radio <= 0 or start is None or end is None:
continue
out.append({"radio": radio, "start": start, "end": max(start, end)})
return out
def call_fields(value: Any) -> Dict[str, Any]:
"""Call-doc fields for a radio list; empty when the node sent none (older nodes, analog)."""
radios = parse(value)
if not radios:
return {}
return {"radios": radios, "radio_ids": list(dict.fromkeys(r["radio"] for r in radios))}
+2
View File
@@ -187,6 +187,7 @@ app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signe
# build stamp baked in by the Dockerfile, not configuration anyone sets or # build stamp baked in by the Dockerfile, not configuration anyone sets or
# tunes, and keeping it out of Settings avoids implying it can be changed. # tunes, and keeping it out of Settings avoids implying it can be changed.
_GIT_SHA = os.getenv("GIT_SHA", "unknown") _GIT_SHA = os.getenv("GIT_SHA", "unknown")
_VERSION = os.getenv("DRB_VERSION", "untagged")
@app.get("/health") @app.get("/health")
@@ -197,6 +198,7 @@ async def health():
# CI asserts this equals the commit it just deployed. Without it a # CI asserts this equals the commit it just deployed. Without it a
# deploy can "succeed" while the previous container is still serving. # deploy can "succeed" while the previous container is still serving.
"git_sha": _GIT_SHA, "git_sha": _GIT_SHA,
"version": _VERSION,
} }
+10
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPExce
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.internal.storage import upload_audio from app.internal.storage import upload_audio
from app.internal import dedup from app.internal import dedup
from app.internal import radios
from app.internal import firestore as fstore from app.internal import firestore as fstore
from app.internal import clock from app.internal import clock
from app.internal.logger import logger from app.internal.logger import logger
@@ -24,6 +25,7 @@ async def upload_call_audio(
talkgroup_id: Optional[int] = Form(None), talkgroup_id: Optional[int] = Form(None),
talkgroup_name: Optional[str] = Form(None), talkgroup_name: Optional[str] = Form(None),
system_id: Optional[str] = Form(None), system_id: Optional[str] = Form(None),
radios_json: Optional[str] = Form(None, alias="radios"),
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer), credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
): ):
""" """
@@ -101,6 +103,14 @@ async def upload_call_audio(
updates = {"audio_gcs_uri": gcs_uri} updates = {"audio_gcs_uri": gcs_uri}
if node and node.get("org_id"): if node and node.get("org_id"):
updates["org_id"] = node["org_id"] updates["org_id"] = node["org_id"]
# Every radio heard on the call (server-26#304). Written here, not
# only on call_end, because the pipeline below starts before the
# node publishes call_end.
radio_fields = radios.call_fields(radios_json)
updates.update(radio_fields)
if radio_fields and existing.get("srcaddr") is None:
# Same meaning call_end gives it: the latest talker.
updates["srcaddr"] = radio_fields["radios"][-1]["radio"]
await fstore.doc_set("calls", call_id, updates) await fstore.doc_set("calls", call_id, updates)
except Exception as e: except Exception as e:
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}") logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
+100
View File
@@ -0,0 +1,100 @@
"""
Every radio on a call reaches the call doc (server-26#304) — from /upload,
which starts the pipeline, and from call_end, which used to drop srcaddr.
"""
import json
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.internal import radios
from app.internal.mqtt_handler import MQTTHandler
from app.main import app
from app.routers import upload
LIST = [{"radio": 555, "start": 0.0, "end": 1.0}, {"radio": 777, "start": 2.0, "end": 2.5},
{"radio": 555, "start": 3.5, "end": 4.0}]
def test_parse_accepts_json_and_drops_junk():
raw = json.dumps(LIST + [{"radio": 0, "start": 1, "end": 2}, {"radio": "x"}, "junk",
{"radio": 9, "start": -1, "end": 2}, {"radio": 8, "start": 5, "end": 4}])
assert radios.parse(raw) == LIST + [{"radio": 8, "start": 5.0, "end": 5.0}]
assert radios.parse("not json") == []
assert radios.parse({"radio": 1}) == []
assert radios.parse(None) == []
def test_call_fields_keeps_distinct_ids_in_first_heard_order():
assert radios.call_fields(LIST)["radio_ids"] == [555, 777]
assert radios.call_fields([]) == {}
@pytest.mark.asyncio
async def test_call_end_writes_srcaddr_and_radios():
"""call_start used to be the only writer of srcaddr; the talker is usually learned later."""
handler = MQTTHandler()
with patch("app.internal.mqtt_handler.fstore") as fs:
fs.doc_get_cached = AsyncMock(return_value=None)
fs.doc_set = AsyncMock()
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": 555, "radios": LIST})
updates = fs.doc_set.call_args[0][2]
assert updates["srcaddr"] == 555
assert updates["radios"] == LIST
assert updates["radio_ids"] == [555, 777]
@pytest.mark.asyncio
async def test_call_end_without_radios_leaves_them_alone():
"""An older node sends neither field; a merge write must not blank what /upload stored."""
handler = MQTTHandler()
with patch("app.internal.mqtt_handler.fstore") as fs:
fs.doc_get_cached = AsyncMock(return_value=None)
fs.doc_set = AsyncMock()
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": None})
updates = fs.doc_set.call_args[0][2]
assert "srcaddr" not in updates and "radios" not in updates
def _upload(existing_call, form_radios):
store = {
"node_keys": {"api_key": "k"},
"nodes": {"approval_status": "approved", "org_id": "org"},
"calls": existing_call,
}
writes = []
async def doc_get(c, i):
return store.get(c)
async def doc_set(c, i, data, merge=True):
writes.append((c, data))
data = {"call_id": "c1", "node_id": "n1"}
if form_radios is not None:
data["radios"] = form_radios
with patch.object(upload.fstore, "doc_get", doc_get), \
patch.object(upload.fstore, "doc_set", doc_set), \
patch.object(upload, "upload_audio", AsyncMock(return_value="gs://b/c1.mp3")), \
patch.object(upload.dedup, "find_duplicate_of", AsyncMock(return_value="other")):
resp = TestClient(app).post(
"/upload", data=data,
files={"file": ("a.mp3", b"\x00\x01", "audio/mpeg")},
headers={"Authorization": "Bearer k"},
)
assert resp.status_code == 200
return writes[0][1]
def test_upload_stores_the_radio_list_before_the_pipeline_runs():
first = _upload({}, json.dumps(LIST))
assert first["radios"] == LIST
assert first["radio_ids"] == [555, 777]
# call_start had no talker yet: take the latest one, as call_end would.
assert first["srcaddr"] == 555
def test_upload_keeps_an_existing_srcaddr_and_tolerates_no_list():
assert "srcaddr" not in _upload({"srcaddr": 999}, json.dumps(LIST))
assert "radios" not in _upload({}, None)
+24
View File
@@ -0,0 +1,24 @@
"""node-26#49 — releases are visible: /health reports the server's, checkin carries the node's."""
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from app.internal.mqtt_handler import MQTTHandler
from app.main import app
def test_health_reports_the_release():
with patch("app.main._VERSION", "1.1.4"):
body = TestClient(app).get("/health").json()
assert body["version"] == "1.1.4"
assert "git_sha" in body
@pytest.mark.asyncio
async def test_checkin_stores_the_node_release():
with patch("app.internal.mqtt_handler.fstore") as fs:
fs.doc_get = AsyncMock(return_value=None)
fs.doc_set = AsyncMock()
await MQTTHandler()._handle_checkin("n1", {"name": "Pi", "version": "1.1.10"})
assert fs.doc_set.call_args[0][2]["version"] == "1.1.10"
+77 -68
View File
@@ -4,6 +4,8 @@
@import 'leaflet/dist/leaflet.css'; @import 'leaflet/dist/leaflet.css';
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
/* ── Design tokens ──────────────────────────────────────────────────────────── /* ── Design tokens ────────────────────────────────────────────────────────────
* Single source of truth for surface, ink and encoding colour. `:root` is the * Single source of truth for surface, ink and encoding colour. `:root` is the
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"]) * LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
@@ -16,43 +18,43 @@
* Do not add a hue here for a category; add a glyph. * Do not add a hue here for a category; add a glyph.
*/ */
:root { :root {
--page: #F4F6F9; --page: #F7F7F8;
--surface: #FFFFFF; --surface: #FFFFFF;
--raised: #F7F9FB; --raised: #F2F2F5;
--line: rgba(11,17,27,.11); --line: rgba(24,24,27,.10);
--line-strong: rgba(11,17,27,.22); --line-strong: rgba(24,24,27,.20);
--ink: #101620; --ink: #18181B;
--ink-2: #46536A; --ink-2: #52525B;
--ink-muted: #6B788C; --ink-muted: #6B6B76;
--accent: #2A78D6; --accent: #5E6AD2;
--sev-moderate: #B07800; --sev-moderate: #B54708;
--sev-major: #C0281F; --sev-major: #D92D20;
--map-bg: #E8ECF1; --map-bg: #ECECF0;
--map-block: #DFE4EB; --map-block: #E1E1E8;
--map-road: #FFFFFF; --map-road: #FFFFFF;
--map-water: #D3E2EE; --map-water: #DADDF2;
} }
.dark { .dark {
--page: #0B0E13; --page: #08090A;
--surface: #141922; --surface: #111214;
--raised: #1B2230; --raised: #1A1B1F;
--line: rgba(255,255,255,.09); --line: rgba(255,255,255,.08);
--line-strong: rgba(255,255,255,.17); --line-strong: rgba(255,255,255,.16);
--ink: #E8EBF0; --ink: #EDEDEF;
--ink-2: #A5B0C2; --ink-2: #A9A9B3;
--ink-muted: #8C98AE; /* was #77839A: 4.2:1 on raised, failed AA at the 10-12px it's used at (#136) */ --ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
--accent: #3987E5; --accent: #8B93F0;
--sev-moderate: #C98500; --sev-moderate: #FDB022;
--sev-major: #F05252; /* was #D03B3B: 3.7:1 as text on surface (#136) */ --sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
--map-bg: #0E131B; --map-bg: #0D0E11;
--map-block: #161C26; --map-block: #16171B;
--map-road: #232C3A; --map-road: #26272D;
--map-water: #12202E; --map-water: #141729;
} }
/* ── Base ─────────────────────────────────────────────────────────────────── */ /* ── Base ─────────────────────────────────────────────────────────────────── */
@@ -69,7 +71,7 @@ html, body {
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below /* 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). */ * readable. Lift both to gray-500 (~4:1). */
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; } .dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
/* ── Light mode overrides ─────────────────────────────────────────────────── */ /* ── Light mode overrides ─────────────────────────────────────────────────── */
/* /*
@@ -80,34 +82,34 @@ html, body {
/* Structural backgrounds */ /* Structural backgrounds */
html:not(.dark), html:not(.dark),
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; } html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; } html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; } html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; } html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; } html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
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: #f2f2f5 !important; }
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; } html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,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(242,242,245,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(242,242,245,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; } html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
/* Borders */ /* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; } html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; } html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; } html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; } html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
/* Text */ /* Text */
html:not(.dark) .text-white { color: #0f172a !important; } html:not(.dark) .text-white { color: #18181b !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !important; } html:not(.dark) .text-gray-100 { color: #27272a !important; }
html:not(.dark) .text-gray-200 { color: #1e293b !important; } html:not(.dark) .text-gray-200 { color: #27272a !important; }
html:not(.dark) .text-gray-300 { color: #334155 !important; } html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; } html:not(.dark) .text-gray-400 { color: #52525b !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; } html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; } html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
/* …except on saturated fills (primary/danger/success buttons), where the fill /* …except on saturated fills (primary/danger/success buttons), where the fill
* stays dark in both themes — remapping to navy made their labels unreadable. */ * stays dark in both themes — remapping to navy made their labels unreadable. */
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; } .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: #fafafa !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; }
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; } html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; } html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; } html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
/* Hover text */ /* Hover text */
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; } html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; } html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */ /* ── Accent badge palette (dark → light) ─────────────────────────────────── */
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
html:not(.dark) .border-green-800 { border-color: #86efac !important; } html:not(.dark) .border-green-800 { border-color: #86efac !important; }
/* Unconfigured / Info */ /* Unconfigured / Info */
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; } html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; } html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
html:not(.dark) .text-indigo-300 { color: #4338ca !important; } html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; } html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; } html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */ /* ── Pulsing ring for recording nodes ────────────────────────────────────── */
@keyframes pulse-ring { @keyframes pulse-ring {
@@ -191,6 +193,13 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
z-index: 0; z-index: 0;
} }
/* The legend floats above the map's stacking context, so it would cover the
* expanded layers list (both live in the right-hand column). Hide it while the
* list is open. */
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
visibility: hidden;
}
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme /* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
* overlay cards sat on a bright, busy map. Inverting the raster keeps the * overlay cards sat on a bright, busy map. Inverting the raster keeps the
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving * keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
@@ -203,7 +212,7 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on * gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
* white — is pinned to a grey that holds AA on white (~6:1). */ * white — is pinned to a grey that holds AA on white (~6:1). */
.leaflet-popup-content .text-ink-muted { .leaflet-popup-content .text-ink-muted {
color: #5B6577; color: #5F5F69;
} }
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip /* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
@@ -224,17 +233,17 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */ * "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
.dark input::placeholder, .dark input::placeholder,
.dark textarea::placeholder { .dark textarea::placeholder {
color: #5F6B80; color: #6E6E78;
font-style: italic; font-style: italic;
} }
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,
html:not(.dark) textarea { html:not(.dark) textarea {
color: #0f172a; color: #18181b;
} }
html:not(.dark) input::placeholder, html:not(.dark) input::placeholder,
html:not(.dark) textarea::placeholder { html:not(.dark) textarea::placeholder {
color: #94a3b8; color: #a1a1aa;
} }
/* ── Marketing/product surface additions (2026-08 overhaul) ───────────────── /* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
@@ -245,8 +254,8 @@ html:not(.dark) textarea::placeholder {
*/ */
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */ /* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; } html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; } html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; } html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; } html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; } html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
@@ -255,10 +264,10 @@ html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !imp
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */ /* Marketing hero background — subtle radial glow, brand-neutral in both themes */
.marketing-hero-bg { .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
} }
html:not(.dark) .marketing-hero-bg { html:not(.dark) .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%); background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
} }
/* Skeleton loading shimmer */ /* Skeleton loading shimmer */
+1 -1
View File
@@ -115,7 +115,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
</td> </td>
<td className="px-4 py-2 text-xs"> <td className="px-4 py-2 text-xs">
{hasAudio ? ( {hasAudio ? (
<span className="text-blue-400">▶</span> <span className="text-accent">▶</span>
) : ( ) : (
<span className="text-ink-muted">—</span> <span className="text-ink-muted">—</span>
)} )}
+5 -1
View File
@@ -560,7 +560,11 @@ export function LiveView() {
{/* Degraded → grey the map and its incident rail (the rail lives inside {/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */} MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}> <div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading || !mapState ? ( {/* Mount the map as soon as its starting view is known. The 500-call
window took ~9s on prod and used to hold the whole map back;
calls and paths now stream in once it lands. Nodes are only
awaited when there's no saved view, since they pick the centre. */}
{!mapState || (!mapState.view && nodesLoading) ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div> <div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : ( ) : (
<MapView <MapView
+17 -6
View File
@@ -887,7 +887,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
const hot = highlightIds.has(c.id); const hot = highlightIds.has(c.id);
return ( return (
<Polygon key={`vs-${c.id}`} positions={wedge} <Polygon key={`vs-${c.id}`} positions={wedge}
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}> pathOptions={{ color: hot ? "#8B93F0" : "#A9A9B3", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
<Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip> <Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip>
</Polygon> </Polygon>
); );
@@ -902,7 +902,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
function nearCameraIcon(): L.DivIcon { function nearCameraIcon(): L.DivIcon {
return L.divIcon({ return L.divIcon({
className: "", className: "",
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`, html: `<div style="width:20px;height:16px;border-radius:4px;background:#1C1F42;border:2px solid #8B93F0;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
iconSize: [20, 16], iconSize: [20, 16],
iconAnchor: [10, 8], iconAnchor: [10, 8],
}); });
@@ -917,7 +917,7 @@ function NearCameraLayer({ cameras, head, onOpenCamera }: {
<> <>
{head && ( {head && (
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false} <Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} /> pathOptions={{ color: "#8B93F0", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
)} )}
{cameras.map(({ cam, km: d }) => ( {cameras.map(({ cam, km: d }) => (
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600} <Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
@@ -1351,7 +1351,7 @@ function FanIncidentLayer({
<a <a
href={`/incidents/${inc.incident_id}`} href={`/incidents/${inc.incident_id}`}
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }} onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
className="text-xs text-blue-600 hover:underline block mt-0.5" className="text-xs text-indigo-600 hover:underline block mt-0.5"
> >
View incident → View incident →
</a> </a>
@@ -1505,7 +1505,7 @@ function IncidentPathLayer({
<span className="text-xs text-ink-muted">Play this call</span> <span className="text-xs text-ink-muted">Play this call</span>
</div> </div>
)} )}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1"> <a href={`/incidents/${inc.incident_id}`} className="text-xs text-indigo-600 hover:underline block mt-1">
View incident → View incident →
</a> </a>
{/* Gate A / A2 (server-26#46) — the stop location and its {/* Gate A / A2 (server-26#46) — the stop location and its
@@ -1620,6 +1620,9 @@ function MapView({
const [wxShown, setWxShown] = useState(() => startLayers.has("wx")); const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
const loopClock = useMemo(createLoopClock, []); const loopClock = useMemo(createLoopClock, []);
// Short viewports (laptops, the 352px incident-page map) start with the
// legend folded to a chip so it doesn't cover the right side of the map.
const [legendOpenAtStart] = useState(() => typeof window === "undefined" || window.innerHeight >= 760);
// The altitude key only belongs in the legend while the opt-in Aircraft // The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185); same for the outage key (#184). // overlay is actually on (server-26#185); same for the outage key (#184).
@@ -1931,7 +1934,14 @@ function MapView({
</div> </div>
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */} {/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto"> {/* Collapsible (one click to a "Legend" chip) and hidden while the Leaflet
layers list is expanded: the map sits in a z-0 stacking context, so the
legend at z-1001 used to paint over the bottom of that list. */}
<details open={legendOpenAtStart} className="drb-legend group/legend absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg text-xs pointer-events-none max-h-[calc(100%-4rem)] overflow-y-auto">
<summary className="pointer-events-auto cursor-pointer list-none select-none px-3 py-1.5 text-ink-muted font-medium text-[10px] uppercase tracking-wide flex items-center gap-1.5">
<span className="group-open/legend:hidden">▸</span><span className="hidden group-open/legend:inline">▾</span> Legend
</summary>
<div className="px-3 pb-2.5 space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p> <p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => ( {(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
@@ -2071,6 +2081,7 @@ function MapView({
))} ))}
</div> </div>
</div> </div>
</details>
{/* ── Incident overlay panel ───────────────────────────────────────────── */} {/* ── Incident overlay panel ───────────────────────────────────────────── */}
{incidents.length > 0 && ( {incidents.length > 0 && (
@@ -14,7 +14,7 @@ import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = { const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" }, commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" }, attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" }, agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" }, non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" }, unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
@@ -15,7 +15,7 @@ import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = { const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" }, commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" }, attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" }, agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" }, non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" }, unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
+12 -1
View File
@@ -22,6 +22,17 @@ const config: Config = {
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the // work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
// class silently generates no rule (map overlays rendered see-through). // class silently generates no rule (map overlays rendered see-through).
colors: { colors: {
// "Iris" palette: the legacy raw gray-*/indigo-* classes (~350 uses)
// resolve to graphite + the brand accent instead of Tailwind's slate-blue
// defaults, so un-migrated components match the tokens above.
gray: {
50: "#F7F7F8", 100: "#EDEDEF", 200: "#E4E4E7", 300: "#C9C9D0", 400: "#A1A1AA",
500: "#7A7A85", 600: "#4A4B53", 700: "#2A2B31", 800: "#1A1B1F", 900: "#111214", 950: "#08090A",
},
indigo: {
50: "#F0F1FD", 100: "#E2E4FB", 200: "#C8CCF6", 300: "#A8AEF1", 400: "#8B93F0",
500: "#6F79E0", 600: "#5E6AD2", 700: "#4E58B8", 800: "#3F4794", 900: "#313770", 950: "#1C1F42",
},
page: tok("page"), page: tok("page"),
surface: tok("surface"), surface: tok("surface"),
raised: tok("raised"), raised: tok("raised"),
@@ -51,7 +62,7 @@ const config: Config = {
boxShadow: { boxShadow: {
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)", card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)", "card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)", glow: "0 0 0 1px rgb(94 106 210 / 0.4), 0 0 24px 0 rgb(94 106 210 / 0.25)",
}, },
animation: { animation: {
"fade-in": "fade-in 0.4s ease-out", "fade-in": "fade-in 0.4s ease-out",
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# node-26#49 (versioning, shared with Client/scripts) — the release tag this commit should get, or nothing if it has one.
#
# VERSION holds MAJOR.MINOR and is edited by hand. PATCH is automatic: one
# more than the highest existing vMAJOR.MINOR.PATCH tag, compared as an
# integer (so .10 follows .9), starting at 0 for a new MAJOR.MINOR.
#
# Usage: scripts/next-version.sh (from the repo root, tags fetched)
set -euo pipefail
base="$(tr -d ' \r\n' < VERSION)"
[[ "$base" =~ ^[0-9]+\.[0-9]+$ ]] || { echo "VERSION must be MAJOR.MINOR, got '$base'" >&2; exit 1; }
pattern="^v${base//./\\.}\\.[0-9]+$"
if git tag --points-at HEAD | grep -qE "$pattern"; then
exit 0 # this commit is already released
fi
last="$(git tag -l "v${base}.*" | grep -E "$pattern" | sed "s/^v${base}\.//" | sort -n | tail -1 || true)"
echo "v${base}.$(( ${last:--1} + 1 ))"