diff --git a/apps/admin/src/components/icons.tsx b/apps/admin/src/components/icons.tsx
index 5a772ce..6de6a2d 100644
--- a/apps/admin/src/components/icons.tsx
+++ b/apps/admin/src/components/icons.tsx
@@ -1,4 +1,4 @@
-export interface IconProps {
+interface IconProps {
size?: number
fill?: string
stroke?: string
@@ -50,7 +50,6 @@ export const Icons = {
d="M12 22s-7-7.5-7-13a7 7 0 1 1 14 0c0 5.5-7 13-7 13z M12 11a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"
/>
),
- Map: (p) => ,
Layers: (p) => (
),
@@ -85,15 +84,6 @@ export const Icons = {
BarChart: (p) => (
),
- Settings: (p) => (
-
- ),
Inbox: (p) => (
,
Plus: (p) => ,
- Bell: (p) => (
-
- ),
Check: (p) => ,
X: (p) => ,
ChevronRight: (p) => ,
ChevronLeft: (p) => ,
ChevronDown: (p) => ,
- ChevronUp: (p) => ,
ArrowUp: (p) => ,
ArrowDown: (p) => ,
ArrowRight: (p) => ,
@@ -149,7 +135,6 @@ export const Icons = {
),
Lock: (p) => ,
- Bookmark: (p) => ,
FileText: (p) => (
),
- Phone: (p) => (
-
- ),
- Sidebar: (p) => ,
Trash: (p) => (
(
),
- Filter: (p) => ,
MoreH: (p) => ,
CornerArr: (p) => ,
Globe: (p) => (
diff --git a/apps/admin/src/components/map/boundary-map.tsx b/apps/admin/src/components/map/boundary-map.tsx
index 24abd7d..9035e06 100644
--- a/apps/admin/src/components/map/boundary-map.tsx
+++ b/apps/admin/src/components/map/boundary-map.tsx
@@ -4,6 +4,7 @@ import * as React from "react"
import L from "leaflet"
import { withCartoKey } from "@/lib/carto"
+import { MAP_SETTLE_MS } from "@/lib/timing"
// Derived from Leaflet's own signature, so no direct @types/geojson dependency is needed.
type LeafletGeoJson = Parameters[0]
@@ -17,6 +18,12 @@ const TILE = {
subdomains: "abcd",
}
+const TILE_MAX_ZOOM = 19
+// maxZoom caps how far the fit zooms in, so a tiny place still shows its surroundings.
+const FIT_OPTIONS: L.FitBoundsOptions = { padding: [12, 12], maxZoom: 13 }
+const SHAPE_WEIGHT = 2
+const SHAPE_FILL_OPACITY = 0.18
+
const LAYER_COLOR: Record = {
place: "#5B8C6E",
county: "#3F7CAC",
@@ -25,7 +32,7 @@ const LAYER_COLOR: Record = {
tribal: "#B0593F",
}
-export interface BoundaryMapProps {
+interface BoundaryMapProps {
geometry: { type: string; coordinates: unknown }
/** [west, south, east, north] */
bbox: [number, number, number, number]
@@ -49,17 +56,17 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
L.tileLayer(TILE.url, {
attribution: TILE.attribution,
subdomains: TILE.subdomains,
- maxZoom: 19,
+ maxZoom: TILE_MAX_ZOOM,
detectRetina: true,
}).addTo(map)
- const ro = new ResizeObserver(() => map.invalidateSize())
- ro.observe(elRef.current)
- const t0 = setTimeout(() => map.invalidateSize(), 60)
+ const resizeObserver = new ResizeObserver(() => map.invalidateSize())
+ resizeObserver.observe(elRef.current)
+ const settleTimer = setTimeout(() => map.invalidateSize(), MAP_SETTLE_MS)
return () => {
- ro.disconnect()
- clearTimeout(t0)
+ resizeObserver.disconnect()
+ clearTimeout(settleTimer)
map.remove()
mapRef.current = null
shapeRef.current = null
@@ -76,7 +83,7 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
const color = LAYER_COLOR[layer] ?? LAYER_COLOR.place
// L.geoJSON accepts a bare GeoJSON geometry object.
const shape = L.geoJSON(geometry as unknown as LeafletGeoJson, {
- style: { color, weight: 2, fillColor: color, fillOpacity: 0.18 },
+ style: { color, weight: SHAPE_WEIGHT, fillColor: color, fillOpacity: SHAPE_FILL_OPACITY },
}).addTo(map)
shapeRef.current = shape
@@ -87,7 +94,7 @@ export function BoundaryMap({ geometry, bbox, layer = "place" }: BoundaryMapProp
[south, west],
[north, east],
],
- { padding: [12, 12], maxZoom: 13 },
+ FIT_OPTIONS,
)
}, [geometry, bbox, layer])
diff --git a/apps/admin/src/components/map/leaflet-map.tsx b/apps/admin/src/components/map/leaflet-map.tsx
index 94349c4..f75afed 100644
--- a/apps/admin/src/components/map/leaflet-map.tsx
+++ b/apps/admin/src/components/map/leaflet-map.tsx
@@ -6,6 +6,7 @@ import L from "leaflet"
import { isKeyboardActivationKey } from "@/components/shared/keyboard-activation"
import { withCartoKey } from "@/lib/carto"
import { CATEGORY_GLYPHS } from "@/lib/category"
+import { MAP_SETTLE_MS } from "@/lib/timing"
// Leaflet rather than community-web's MapLibre seam: a deliberate choice for this internal tool.
// Leaflet touches window at import, so this module must be loaded through next/dynamic with ssr:false.
@@ -43,7 +44,25 @@ const MAP_TILES = {
export type MapTint = keyof typeof MAP_TILES
-export const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 }
+const MAP_HOME = { center: [39.5, -98.35] as [number, number], zoom: 4 }
+const BASEMAP_MAX_ZOOM = 20
+
+function addBasemap(map: L.Map, tint: MapTint): L.TileLayer {
+ const tiles = MAP_TILES[tint] ?? MAP_TILES.voyager
+ return L.tileLayer(tiles.url, {
+ attribution: tiles.attribution,
+ subdomains: tiles.subdomains,
+ maxZoom: BASEMAP_MAX_ZOOM,
+ detectRetina: true,
+ }).addTo(map)
+}
+
+const PIN_VIEWBOX_WIDTH = 64
+const PIN_VIEWBOX_HEIGHT = 76
+// Centers the 24-unit glyph (the icon set's grid) in the teardrop's head.
+const PIN_GLYPH_OFFSET = "translate(20 16)"
+const PIN_WIDTH = 31
+const PIN_WIDTH_ACTIVE = 40
// Report pins are gray once routed and red while they still need a routing contact; events take the
// fill of their kind.
@@ -77,21 +96,21 @@ function pinIcon(
// constant own entry of GLYPHS may reach it (an inherited key like "constructor" would not).
const glyph = Object.hasOwn(GLYPHS, glyphKey) ? GLYPHS[glyphKey]! : CATEGORY_GLYPHS.other
const fill = PIN_FILL[state] ?? PIN_FILL.routed
- const w = active ? 40 : 31
- const h = w * (76 / 64)
+ const width = active ? PIN_WIDTH_ACTIVE : PIN_WIDTH
+ const height = width * (PIN_VIEWBOX_HEIGHT / PIN_VIEWBOX_WIDTH)
const html =
- `` +
- `
diff --git a/apps/admin/src/components/providers.tsx b/apps/admin/src/components/providers.tsx
index 218bd78..9a59c5e 100644
--- a/apps/admin/src/components/providers.tsx
+++ b/apps/admin/src/components/providers.tsx
@@ -5,6 +5,7 @@ import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"
import { makeQueryClient } from "@/lib/query"
import { AuthHydrator } from "@/components/auth/auth-hydrator"
+import { BrandBug } from "@/components/shared/brand-bug"
import { ErrorBoundary } from "@/components/shell/error-boundary"
import { OperatorLogin } from "@/features/auth/operator-login"
import { useOperatorSession } from "@/hooks/use-admin-auth"
@@ -47,9 +48,7 @@ function BootScreen({ label }: { label: string }) {
return (
- {/* Tiny static brand SVG:
is appropriate (static export, images.unoptimized). */}
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
+
{label}
diff --git a/apps/admin/src/components/shared/brand-bug.tsx b/apps/admin/src/components/shared/brand-bug.tsx
new file mode 100644
index 0000000..f036306
--- /dev/null
+++ b/apps/admin/src/components/shared/brand-bug.tsx
@@ -0,0 +1,8 @@
+const BRAND_BUG_SRC = "/ds/pinit-bug.svg"
+
+// Decorative: every caller sets the brand name in text beside it or hides it from assistive tech.
+export function BrandBug({ width, height }: { width: number; height: number }) {
+ // A tiny static SVG: next/image adds nothing under the static export's unoptimized images.
+ // eslint-disable-next-line @next/next/no-img-element
+ return

+}
diff --git a/apps/admin/src/components/shared/data-states.tsx b/apps/admin/src/components/shared/data-states.tsx
index da8f2ed..d37f2f3 100644
--- a/apps/admin/src/components/shared/data-states.tsx
+++ b/apps/admin/src/components/shared/data-states.tsx
@@ -49,23 +49,3 @@ export function ErrorState({
)
}
-
-export function Skeleton({
- width = "100%",
- height = 16,
- radius = 8,
- className = "",
-}: {
- width?: number | string
- height?: number | string
- radius?: number
- className?: string
-}) {
- return (
-
- )
-}
diff --git a/apps/admin/src/components/shared/dialog.tsx b/apps/admin/src/components/shared/dialog.tsx
index dc874c9..96f32b4 100644
--- a/apps/admin/src/components/shared/dialog.tsx
+++ b/apps/admin/src/components/shared/dialog.tsx
@@ -69,6 +69,8 @@ export function promptDialog(
})
}
+const PROMPT_FIELD_ROWS = 3
+
const NATIVE_ENTER_TAGS = new Set(["BUTTON", "A", "SELECT"])
// The window listener sees Enter from every focused control. A focused button, link or select keeps
@@ -90,6 +92,71 @@ function enterAccepts({
return modified
}
+function useDialogKeys(
+ current: DialogRequest | null,
+ cancel: () => void,
+ accept: () => void,
+): void {
+ React.useEffect(() => {
+ if (!current) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.preventDefault()
+ cancel()
+ return
+ }
+ if (e.key !== "Enter") return
+ const targetTag = (e.target as HTMLElement | null)?.tagName
+ if (e.repeat) {
+ // A held key's repeats must not answer the dialog its first press opened, neither here nor by
+ // natively clicking whichever button now has focus. A textarea keeps them as newlines.
+ if (targetTag !== "TEXTAREA") e.preventDefault()
+ } else if (enterAccepts({ request: current, targetTag, modified: e.metaKey || e.ctrlKey })) {
+ e.preventDefault()
+ accept()
+ }
+ }
+ window.addEventListener("keydown", onKey)
+ return () => window.removeEventListener("keydown", onKey)
+ }, [current, cancel, accept])
+}
+
+function PromptField({
+ request,
+ fieldId,
+ titleId,
+ fieldRef,
+ value,
+ onChange,
+}: {
+ request: PromptRequest
+ fieldId: string
+ titleId: string
+ fieldRef: React.RefObject
+ value: string
+ onChange: (value: string) => void
+}) {
+ return (
+ <>
+ {request.label && (
+
+ )}
+
)}
{current.kind === "prompt" && (
- <>
- {current.label && (
-
- )}
-