Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ next-env.d.ts
# misc
.DS_Store
*.pem
/LNSITE/

# debug
npm-debug.log*
Expand Down
50 changes: 38 additions & 12 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,44 @@ normal terminal.

Defined in `tailwind.config.ts` + `app/globals.css`.

- **Palettes**: `ink` (neutrals), `gulf` (primary blue), `sand` (accent warm),
`coral` (alert/accent).
- **Theme variables**: `--bg`, `--bg-elevated`, `--fg`, `--muted`, `--line`,
`--accent`. Light + dark modes are real, not just a class swap — every
surface reads from these vars.
- **Background layers**: `bg-canvas` (radial wash) + `bg-grid` (subtle grid)
+ `bg-noise` (svg grain) stacked behind everything.
- **Components**: `surface`, `shell` (glass panel), `btn-primary` /
`btn-ghost`, `pill`, `hairline`. Custom animations: `fade-up`, `pulse-ring`,
`drift`, `shimmer`.
- **Typography**: `font-display` for headings (Space Grotesk), `font-sans`
body, `font-mono` for kickers/labels.
The site is a non-profit public-information site first. Structure, type and
data carry the page; ornament is deliberate and scoped. The 2026 redesign
removed the glass/gradient/glow layer the site had accumulated, then added a
single technical texture back in two places on purpose.

- **Palettes are sampled from `public/logo.svg`.** `gulf` runs from the logo's
bright cyan-teal (`#49c7cc`) and mint (`#6ed7c8`) down to its deep teal
(`#0d6070`) and hull navies. `ink` is a navy-tinted neutral built on the same
logo navies, so the greys read as brand rather than as grey. `sand` is
**status only** (advisories, "planned"); `coral` is errors.
- **Three accent tokens, because one value cannot do all three jobs at brand
brightness:**
- `--accent` — small text (links, 11px labels). The brightest point on the
logo's hue that still clears AA 4.5:1 on white.
- `--accent-bright` — display type only, which needs just 3:1, so headline
highlights go brighter than body copy is allowed to.
- `--accent-solid` — button fills. Always pairs with `ink-950` text (6.3:1);
white on a fill this bright would fail. Hover goes brighter, not darker.
- **Other theme variables**: `--bg`, `--bg-elevated`, `--bg-sunken`, `--fg`,
`--fg-muted`, `--line`, `--line-strong`. Light mode uses a pure `#ffffff` canvas
with `#f8fafc` sunken tones and `#e2e8f0` crisp slate borders; dark mode uses
deep obsidian slate `#0b0f19` canvas, `#111827` elevated surfaces, and `#1f2937` borders.
- **Background & Surfaces**: The site uses clean, natural contrast without artificial
mathematical grid lines (`.grid-tech`) or radial glow blobs (`.wash-brand`).
Surfaces (`surface`, `tile`) feature crisp 1px borders, solid fills, and no synthetic glass/backdrop blur.
- **Buttons & Badges**:
- `btn-primary` (brand fill, dark ink) and `btn-ghost` (bordered).
- `.badge` and `.badge-accent` for technical hardware and frequency specifications (`915 MHz`, `MeshCore`, `Open Source`).
- Steady status indicators instead of distracting pulsating radar dots.
- **Homepage Structure**:
- Authoritative hero copy focused on community and storm resilience.
- Live network telemetry and framed live explorer embed.
- "How It Actually Works" explaining 915 MHz ISM radio, multi-hop mesh routing, and hurricane resilience.
- Real hardware pathways (Companion Radio, Solar Rooftop Repeater, Room Server & Base Station).
- Community spotlight on the weekly Monday Voice Net (8:00 PM CST on Discord).
- Regional coverage directory with node counts and repeater hosting invitations.
- **Type**: `font-display` (Space Grotesk) headings, `font-sans` body,
`font-mono` for data, badges, and technical specs. Figures use `.tabular`.

## Pages

Expand Down
32 changes: 16 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ Other scripts:
```
app/
layout.tsx Root layout: fonts, theme bootstrap, header, footer
page.tsx Homepage (hero with embedded Analyzer, live stats, network bento, Meshcore stack snapshot, how-it-works, regions)
page.tsx Homepage (mission, live network panel with map + stats, what we do, get started, coverage, get involved)
meshmap/page.tsx Live maps (Meshcore Analyzer + Meshtastic Meshview) and MQTT instructions
links/page.tsx Curated guides, community, and upstream resources
emailsignup/page.tsx Newsletter signup (Listmonk-backed)
mesh-monitor/page.tsx Mesh monitoring: reserve, duplicates, network reports
globals.css Design tokens, surface/utility classes
icon.svg Browser tab icon
components/
site-header.tsx Floating glass nav with GitHub + theme toggle
site-footer.tsx Multi-column footer with supporters/partners
site-header.tsx Sticky solid nav with GitHub + theme toggle
site-footer.tsx Four-column footer with supporters/partners
theme-toggle.tsx Light/dark switcher button
theme-script.tsx Inline pre-paint script (no FOUC)
live-map.tsx iframe wrapper for embedded maps (skeleton + error fallback)
Expand All @@ -53,17 +53,17 @@ eslint.config.mjs Flat ESLint config wrapping next/core-web-vitals + next/

## Design language

- **Type**: Inter (UI), Space Grotesk (display), JetBrains Mono (eyebrows / monospace details).
- **Color**: deep ink navy, vibrant gulf teal/cyan, warm sand amber, coral accents.
- **Surfaces**: subtle glass cards (`.surface`, `.surface-strong`, `.tile`, `.tile-accent`).
- **Motion**: pre-paint theme bootstrap (no flash), gentle hover lifts, animated skeleton on map embeds.
- **Accessibility**: focus rings, `prefers-reduced-motion` honored, semantic landmarks.
- **Type**: Inter (UI), Space Grotesk (display), JetBrains Mono (data + labels).
- **Color**: sampled from `public/logo.svg` — bright gulf teal on navy-tinted
neutrals. Warm sand is reserved for status, never decoration.
- **Contrast**: three accent tokens split the small-text / display / button-fill
jobs so brand brightness never costs legibility. All pairs clear WCAG AA.
- **Surfaces**: flat 1px-bordered cards (`.surface`, `.tile`) — no glass,
no glow.
- **Texture**: one masked grid + wash band, declared once in the root layout.
- **Motion**: pre-paint theme bootstrap (no flash), `fade-up`, and a live dot
on genuinely live data. `prefers-reduced-motion` honored.
- **Accessibility**: skip link, focus rings, semantic landmarks, tabular
numerals on data.

## Replacing content

- **Newsletter** — list IDs and Listmonk endpoint live at the top of `app/emailsignup/page.tsx` (`ALERTS_LIST_ID`, `NEWS_LIST_ID`, and the `fetch()` URL).
- **Supporters / partners** — individual supporters in `components/site-footer.tsx`; partner logos in `lib/partners.ts` with host logos in `public/supporters/`.
- **Hero copy, stats, regions, how-it-works** — `app/page.tsx`. Regions support a `forceLive: true` flag to show as live even when the upstream API count is zero (currently used for Mississippi).
- **GitHub org link** — `components/site-header.tsx` and `components/site-footer.tsx` both point at [`github.com/GulfCoastMesh`](https://github.com/GulfCoastMesh).
- **Mesh Monitor API** — [`API.md`](./API.md) and [`openapi.yaml`](./openapi.yaml) document the API. `/mesh-monitor` hosts reserve, duplicates, and network reports (proxied via `app/api/mesh-monitor/*` and `lib/mesh-monitor.ts`).
- **Setup wizard** — repeater flow in `app/setup/page.tsx` reserves MeshBuddy prefixes via `/api/meshbuddy/*` (proxies to `MESHBUDDY_API_BASE_URL`, default `https://meshbuddy.gulfcoastmesh.org`). Email is required for reservation; firmware versions come from MeshCore GitHub releases.
See [`NOTES.md`](./NOTES.md) for the full token list and the reasoning.
169 changes: 169 additions & 0 deletions app/api/mesh-replay/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { NextResponse } from "next/server";

type AnalyzerPacket = {
payload_type?: unknown;
timestamp?: unknown;
first_seen?: unknown;
};

type ReplayEvent = {
offsetMs: number;
payloadType: number;
};

type ReplayPayload = {
durationMs: number;
events: ReplayEvent[];
};

const ANALYZER_URL =
process.env.ANALYZER_API_URL || "https://analyzer.gulfcoastmesh.org";
const WINDOW_MS = 5 * 60 * 1000;
const REPLAY_DURATION_MS = 20 * 1000;
const MAX_REPLAY_EVENTS = 500;
const CACHE_DURATION_MS = 5 * 60 * 1000;

let cachedReplay: { expiresAt: number; payload: ReplayPayload } | null = null;

const asTimestamp = (value: unknown): number | null => {
if (typeof value !== "string") return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
};

const busiestWindow = (timestamps: number[]): [number, number] | null => {
if (timestamps.length === 0) return null;

let left = 0;
let bestLeft = 0;
let bestRight = 0;

for (let right = 0; right < timestamps.length; right += 1) {
while (timestamps[right] - timestamps[left] > WINDOW_MS) left += 1;
if (right - left > bestRight - bestLeft) {
bestLeft = left;
bestRight = right;
}
}

return [timestamps[bestLeft], timestamps[bestLeft] + WINDOW_MS];
};

const sampleEvents = (events: ReplayEvent[]): ReplayEvent[] => {
if (events.length <= MAX_REPLAY_EVENTS) return events;

return Array.from({ length: MAX_REPLAY_EVENTS }, (_, index) => {
const sourceIndex = Math.floor(
(index * (events.length - 1)) / (MAX_REPLAY_EVENTS - 1)
);
return events[sourceIndex];
});
};

const loadReplay = async (): Promise<ReplayPayload> => {
const now = Date.now();
const since = new Date(now - 24 * 60 * 60 * 1000).toISOString();
const timestampsUrl = new URL("/api/packets/timestamps", ANALYZER_URL);
timestampsUrl.searchParams.set("since", since);

const timestampsResponse = await fetch(timestampsUrl, {
cache: "no-store",
signal: AbortSignal.timeout(10000),
});
if (!timestampsResponse.ok) {
throw new Error(
`Analyzer timestamps returned ${timestampsResponse.status}`
);
}

const rawTimestamps: unknown = await timestampsResponse.json();
if (!Array.isArray(rawTimestamps)) throw new Error("Invalid timestamp list");

const timestamps = rawTimestamps
.map(asTimestamp)
.filter((timestamp): timestamp is number => timestamp !== null)
.sort((first, second) => first - second);
const window = busiestWindow(timestamps);
if (!window) return { durationMs: REPLAY_DURATION_MS, events: [] };

const packetsUrl = new URL("/api/packets", ANALYZER_URL);
packetsUrl.searchParams.set("since", new Date(window[0]).toISOString());
packetsUrl.searchParams.set("until", new Date(window[1]).toISOString());
packetsUrl.searchParams.set("limit", "10000");
packetsUrl.searchParams.set("order", "asc");

const packetsResponse = await fetch(packetsUrl, {
cache: "no-store",
signal: AbortSignal.timeout(10000),
});
if (!packetsResponse.ok) {
throw new Error(`Analyzer packets returned ${packetsResponse.status}`);
}

const rawPackets: unknown = await packetsResponse.json();
if (
typeof rawPackets !== "object" ||
rawPackets === null ||
!Array.isArray((rawPackets as { packets?: unknown }).packets)
) {
throw new Error("Invalid packet list");
}

const packets = (rawPackets as { packets: AnalyzerPacket[] }).packets;
const events = packets
.map((packet): { timestamp: number; payloadType: number } | null => {
const timestamp = asTimestamp(packet.timestamp ?? packet.first_seen);
const payloadType =
typeof packet.payload_type === "number"
? packet.payload_type
: Number.parseInt(String(packet.payload_type), 10);
if (timestamp === null || !Number.isFinite(payloadType)) return null;
return { timestamp, payloadType };
})
.filter(
(event): event is { timestamp: number; payloadType: number } =>
event !== null
)
.sort((first, second) => first.timestamp - second.timestamp);

if (events.length === 0) {
return { durationMs: REPLAY_DURATION_MS, events: [] };
}

const sourceStart = events[0].timestamp;
const sourceDuration = Math.max(
events[events.length - 1].timestamp - sourceStart,
1
);
const replayEvents = events.map((event) => ({
offsetMs: Math.round(
((event.timestamp - sourceStart) / sourceDuration) * REPLAY_DURATION_MS
),
payloadType: event.payloadType,
}));

return {
durationMs: REPLAY_DURATION_MS,
events: sampleEvents(replayEvents),
};
};

export async function GET() {
try {
if (cachedReplay && cachedReplay.expiresAt > Date.now()) {
return NextResponse.json(cachedReplay.payload);
}

const payload = await loadReplay();
cachedReplay = {
expiresAt: Date.now() + CACHE_DURATION_MS,
payload,
};
return NextResponse.json(payload);
} catch {
return NextResponse.json(
{ durationMs: REPLAY_DURATION_MS, events: [] },
{ status: 200 }
);
}
}
13 changes: 5 additions & 8 deletions app/docs/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ export const revalidate = 3600;
// deploy still renders on first visit (and gets cached per `revalidate`
// above), so new docs pages don't need a redeploy to go live.

const DOCS_REPO_RAW_HTML =
"https://github.com/GulfCoastMesh/louisianameshcommunity.github.io/blob/main/docs";

export async function generateStaticParams() {
return (await getAllSlugs()).map((slug) => ({ slug }));
}
Expand Down Expand Up @@ -63,13 +60,13 @@ export default async function DocsSlugPage({
<nav
aria-label="Docs pagination"
className="mt-16 grid gap-4 border-t pt-8 sm:grid-cols-2"
style={{ borderColor: "rgb(var(--line) / 0.6)" }}
style={{ borderColor: "rgb(var(--line))" }}
>
{prev ? (
<Link
href={prev.slug === "index" ? "/docs" : `/docs/${prev.slug}`}
className="group flex flex-col rounded-2xl border bg-white/60 px-5 py-4 transition hover:-translate-y-0.5 hover:bg-white dark:border-white/10 dark:bg-ink-900/60 dark:hover:bg-ink-900/80"
style={{ borderColor: "rgb(var(--line) / 0.6)" }}
className="group flex flex-col rounded-lg border bg-[rgb(var(--bg-elevated))] px-5 py-4 transition hover:bg-[rgb(var(--bg-sunken))]"
style={{ borderColor: "rgb(var(--line))" }}
>
<span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.18em] text-ink-500 dark:text-ink-400">
<ArrowLeft className="h-3 w-3" aria-hidden />
Expand All @@ -85,8 +82,8 @@ export default async function DocsSlugPage({
{next ? (
<Link
href={`/docs/${next.slug}`}
className="group flex flex-col rounded-2xl border bg-white/60 px-5 py-4 text-right transition hover:-translate-y-0.5 hover:bg-white sm:items-end dark:border-white/10 dark:bg-ink-900/60 dark:hover:bg-ink-900/80"
style={{ borderColor: "rgb(var(--line) / 0.6)" }}
className="group flex flex-col rounded-lg border bg-[rgb(var(--bg-elevated))] px-5 py-4 text-right transition hover:bg-[rgb(var(--bg-sunken))] sm:items-end"
style={{ borderColor: "rgb(var(--line))" }}
>
<span className="inline-flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.18em] text-ink-500 dark:text-ink-400">
Next
Expand Down
2 changes: 0 additions & 2 deletions app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ export const metadata = {
"Field guides, hardware recommendations, and setup walkthroughs for the Gulf Coast Mesh.",
};

const DOCS_REPO_HTML =
"https://github.com/GulfCoastMesh/louisianameshcommunity.github.io";
const LEGACY_DOCS_URL = "https://docs.gulfcoastmesh.org";

export default async function DocsIndexPage() {
Expand Down
20 changes: 6 additions & 14 deletions app/emailsignup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,8 @@ export default function EmailSignupPage() {
<div className="container pb-24">
<div className="mx-auto grid max-w-5xl gap-10 lg:grid-cols-[1fr_1.1fr] lg:items-center">
<div>
<span className="eyebrow">
<Mail className="h-3.5 w-3.5" aria-hidden />
Newsletter
</span>
<h1 className="mt-5 font-display text-display-xl font-semibold tracking-tight text-balance text-ink-900 dark:text-white">
Network signal,
<br />
<span className="gradient-text">delivered to your inbox.</span>
<h1 className="font-display text-display-xl font-semibold tracking-tight text-balance text-ink-900 dark:text-white">
Network signal, delivered to your inbox.
</h1>
<p className="mt-5 text-pretty text-lg text-ink-600 dark:text-ink-300">
Two clean lists — pick what you want. No spam, no auto-enrollment, no surprises.
Expand Down Expand Up @@ -87,22 +81,20 @@ export default function EmailSignupPage() {
Summaries from community meetings and other news. Up to one email a week.
</p>
</div>
<span className="ml-auto inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-ink-700 dark:border-white/10 dark:text-ink-100" style={{ borderColor: "rgb(var(--line) / 0.7)" }}>
<span className="ml-auto inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.18em] text-ink-700 dark:text-ink-100" style={{ borderColor: "rgb(var(--line))" }}>
Optional
</span>
</li>
</ul>
</div>

<form onSubmit={handleSubmit} className="surface-strong relative overflow-hidden p-8 sm:p-10">
<div className="pointer-events-none absolute -right-12 -top-12 h-44 w-44 rounded-full bg-gulf-400/20 blur-3xl" />
<div className="pointer-events-none absolute -bottom-12 -left-12 h-44 w-44 rounded-full bg-sand-400/15 blur-3xl" />

<div className="relative">
<label htmlFor="email" className="block font-display text-sm font-semibold text-ink-900 dark:text-white">
Email address
</label>
<div className="mt-2 flex items-center gap-2 rounded-2xl border bg-white px-3 py-1 focus-within:ring-2 focus-within:ring-gulf-400 dark:border-white/10 dark:bg-ink-900" style={{ borderColor: "rgb(var(--line) / 0.7)" }}>
<div className="mt-2 flex items-center gap-2 rounded-lg border bg-white px-3 py-1 focus-within:ring-2 focus-within:ring-gulf-400 dark:bg-ink-900" style={{ borderColor: "rgb(var(--line))" }}>
<Mail className="h-4 w-4 text-ink-400" />
<input
type="email"
Expand All @@ -115,7 +107,7 @@ export default function EmailSignupPage() {
/>
</div>

<label className="mt-5 flex cursor-pointer items-start gap-3 rounded-2xl border p-4 transition hover:border-gulf-400/50 dark:border-white/10" style={{ borderColor: "rgb(var(--line) / 0.7)" }}>
<label className="mt-5 flex cursor-pointer items-start gap-3 rounded-lg border p-4 transition hover:border-gulf-400/50 " style={{ borderColor: "rgb(var(--line))" }}>
<input
type="checkbox"
name="weekly"
Expand All @@ -142,7 +134,7 @@ export default function EmailSignupPage() {
<div
role="status"
className={
"mt-5 rounded-2xl border p-4 text-sm font-medium " +
"mt-5 rounded-lg border p-4 text-sm font-medium " +
(status.type === "success"
? "border-gulf-500/30 bg-gulf-500/10 text-gulf-800 dark:text-gulf-100"
: "border-coral-500/30 bg-coral-500/10 text-coral-500")
Expand Down
Loading