From deed444333b82151dde39cecea81c9fafc0ac2d1 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 03:17:38 +0100 Subject: [PATCH 01/29] Fix maintenance time rendering --- app/status/maintenance/page.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/status/maintenance/page.tsx b/app/status/maintenance/page.tsx index 4887ffb1..28cf432f 100644 --- a/app/status/maintenance/page.tsx +++ b/app/status/maintenance/page.tsx @@ -10,6 +10,13 @@ import { StatusActions } from "../status-actions"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Maintenance · Tidelight status", description: "Scheduled maintenance notices for Tidelight services." }; +function formatStatusTime(value: string) { + return new Intl.DateTimeFormat("en-US", { + month: "short", day: "numeric", year: "numeric", + hour: "numeric", minute: "2-digit", timeZone: "UTC", timeZoneName: "short", + }).format(new Date(value)); +} + function noticeLabel(notice: MaintenanceNotice) { if (notice.status === "completed") return "Completed"; if (notice.status === "ongoing") return "In progress"; @@ -40,10 +47,10 @@ export default async function MaintenancePage() { {unavailable ?
Maintenance updates are temporarily unavailable

Please check the live service status while we restore the notice feed.

: currentNotices.length ?
{currentNotices.map(notice =>
{noticeLabel(notice)}

{notice.title}

{noticeLabel(notice)}

{notice.subtitle}

{notice.message}

-
Started {notice.ends_at ? Expected completion : null}
+
Started {notice.ends_at ? Expected completion : null}
{notice.affected_services.length ?
Affected services
    {notice.affected_services.map(service =>
  • {service}
  • )}
: null}
)}
:
No scheduled maintenance

There are no active or upcoming maintenance notices at this time.

} - {pastNotices.length ?

Past maintenance

{pastNotices.map(notice =>
{noticeLabel(notice)}

{notice.title}

Completed

{notice.subtitle}

{notice.message}

Started {notice.ends_at ? Completed : null}
)}
: null} + {pastNotices.length ?

Past maintenance

{pastNotices.map(notice =>
{noticeLabel(notice)}

{notice.title}

Completed

{notice.subtitle}

{notice.message}

Started {notice.ends_at ? Completed : null}
)}
: null} ← Back to current status From f78aae126e9247b713d525480632ad0f83fa2a6d Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 03:17:43 +0100 Subject: [PATCH 02/29] Serialize local maintenance times with browser timezone --- app/operator/communications/editor.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index c9151188..42021822 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -9,6 +9,12 @@ function localDateTime(value: string | null | undefined) { return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16); } +function toIsoTimestamp(value: FormDataEntryValue | undefined) { + if (typeof value !== "string" || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + function FormField({ label, children }: { label: string; children: ReactNode }) { return ; } @@ -29,7 +35,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini const editing = kind === "maintenance" ? editingMaintenance : editingPost; const publishedAt = editingPost?.published_at; const payload = kind === "maintenance" - ? { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, message: raw.message, startsAt: raw.startsAt, endsAt: raw.endsAt || null, status: raw.status, affectedServices: String(raw.affectedServices ?? "").split(",").map(value => value.trim()).filter(Boolean) } + ? { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, message: raw.message, startsAt: toIsoTimestamp(raw.startsAt), endsAt: toIsoTimestamp(raw.endsAt), status: raw.status, affectedServices: String(raw.affectedServices ?? "").split(",").map(value => value.trim()).filter(Boolean) } : { kind, id: editing?.id, title: raw.title, subtitle: raw.subtitle, category: raw.category, slug: raw.slug, body: raw.body, status: raw.status, publishedAt }; setBusy(true); kind === "maintenance" ? setMaintenanceMessage("") : setPostMessage(""); From c20da5d9a9ec4ff215cbb53ac0ad92c883a39e25 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 03:17:52 +0100 Subject: [PATCH 03/29] Fix generated slugs and paginate operator content --- app/api/operator/communications/route.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/api/operator/communications/route.ts b/app/api/operator/communications/route.ts index bf35ffb5..2b9991a2 100644 --- a/app/api/operator/communications/route.ts +++ b/app/api/operator/communications/route.ts @@ -23,7 +23,29 @@ function validDate(value: unknown, optional = false): string | null | undefined } function slugify(value: string) { - return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100); + return value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 100).replace(/-+$/g, ""); +} + +export async function GET(request: Request) { + const user = await getServerUser(); + if (!user) return json({ error: "Sign in to continue." }, 401); + if (!isTidelightOperator(user)) return json({ error: "Operator access is required." }, 403); + const params = new URL(request.url).searchParams; + const kind = params.get("kind"); + const offset = Number(params.get("offset") ?? "0"); + if ((kind !== "maintenance" && kind !== "blog") || !Number.isInteger(offset) || offset < 0 || offset > 1000000) { + return json({ error: "Choose a valid content list and page offset." }, 400); + } + const client = createAdminClient(); + const table = kind === "maintenance" ? "tic_status_maintenance" : "tic_blog_posts"; + const columns = kind === "maintenance" + ? "id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at" + : "id,slug,title,subtitle,category,body,status,published_at,created_at,updated_at"; + const orderColumn = kind === "maintenance" ? "starts_at" : "updated_at"; + const { data, count, error } = await client.from(table).select(columns, { count: "exact" }) + .order(orderColumn, { ascending: false }).range(offset, offset + 49); + if (error) return json({ error: "The content list could not be loaded." }, 500); + return json({ items: data ?? [], hasMore: offset + (data?.length ?? 0) < (count ?? 0) }); } export async function POST(request: Request) { From 99fd9f6127242a0b245da31c42e44845d5b50fd8 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 03:18:03 +0100 Subject: [PATCH 04/29] Allow operators to browse older notices and articles --- app/operator/communications/editor.tsx | 40 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index 42021822..7abd7f11 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -22,12 +22,48 @@ function FormField({ label, children }: { label: string; children: ReactNode }) export function CommunicationsEditor({ initialMaintenance, initialPosts }: { initialMaintenance: MaintenanceNotice[]; initialPosts: EditorialPost[] }) { const [maintenance, setMaintenance] = useState(initialMaintenance); const [posts, setPosts] = useState(initialPosts); + const [maintenanceHasMore, setMaintenanceHasMore] = useState(initialMaintenance.length === 50); + const [postsHaveMore, setPostsHaveMore] = useState(initialPosts.length === 50); + const [maintenanceVisible, setMaintenanceVisible] = useState(12); + const [postsVisible, setPostsVisible] = useState(12); + const [loadingMore, setLoadingMore] = useState<"maintenance" | "blog" | null>(null); const [editingMaintenance, setEditingMaintenance] = useState(null); const [editingPost, setEditingPost] = useState(null); const [maintenanceMessage, setMaintenanceMessage] = useState(""); const [postMessage, setPostMessage] = useState(""); const [busy, setBusy] = useState(false); + async function showMore(kind: "maintenance" | "blog") { + const items = kind === "maintenance" ? maintenance : posts; + const visible = kind === "maintenance" ? maintenanceVisible : postsVisible; + if (visible < items.length) { + kind === "maintenance" ? setMaintenanceVisible(visible + 12) : setPostsVisible(visible + 12); + return; + } + const hasMore = kind === "maintenance" ? maintenanceHasMore : postsHaveMore; + if (!hasMore) return; + setLoadingMore(kind); + try { + const response = await fetch(`/api/operator/communications?kind=${kind}&offset=${items.length}`); + const result = await response.json() as { items?: Array; hasMore?: boolean; error?: string }; + if (!response.ok || !result.items) throw new Error(result.error ?? "Older entries could not be loaded."); + if (kind === "maintenance") { + setMaintenance(current => [...current, ...(result.items as MaintenanceNotice[])]); + setMaintenanceHasMore(Boolean(result.hasMore)); + setMaintenanceVisible(visible + result.items.length); + } else { + setPosts(current => [...current, ...(result.items as EditorialPost[])]); + setPostsHaveMore(Boolean(result.hasMore)); + setPostsVisible(visible + result.items.length); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Older entries could not be loaded."; + kind === "maintenance" ? setMaintenanceMessage(message) : setPostMessage(message); + } finally { + setLoadingMore(null); + } + } + async function submit(event: FormEvent, kind: "maintenance" | "blog") { event.preventDefault(); const form = event.currentTarget; @@ -83,7 +119,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini

{maintenanceMessage}

Recent notices

- {maintenance.length ?
    {maintenance.slice(0, 12).map(item =>
  • {item.title} · {item.status} · {new Date(item.starts_at).toLocaleString()}
  • )}
:

No notices yet.

} + {maintenance.length ?
    {maintenance.slice(0, maintenanceVisible).map(item =>
  • {item.title} · {item.status} · {new Date(item.starts_at).toLocaleString()}
  • )}
:

No notices yet.

}{maintenance.length > maintenanceVisible || maintenanceHasMore ? : null}
@@ -104,7 +140,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini

{postMessage}

Recent articles

- {posts.length ?
    {posts.slice(0, 12).map(item =>
  • {item.title} · {item.status}
  • )}
:

No articles yet.

} + {posts.length ?
    {posts.slice(0, postsVisible).map(item =>
  • {item.title} · {item.status}
  • )}
:

No articles yet.

}{posts.length > postsVisible || postsHaveMore ? : null}
; } From f1d7ccfe66ec74814969b6d07c91982095ba9eea Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:13:24 +0100 Subject: [PATCH 05/29] Use a stable order for communications pagination --- app/api/operator/communications/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/operator/communications/route.ts b/app/api/operator/communications/route.ts index 2b9991a2..f8cfbde2 100644 --- a/app/api/operator/communications/route.ts +++ b/app/api/operator/communications/route.ts @@ -43,7 +43,7 @@ export async function GET(request: Request) { : "id,slug,title,subtitle,category,body,status,published_at,created_at,updated_at"; const orderColumn = kind === "maintenance" ? "starts_at" : "updated_at"; const { data, count, error } = await client.from(table).select(columns, { count: "exact" }) - .order(orderColumn, { ascending: false }).range(offset, offset + 49); + .order(orderColumn, { ascending: false }).order("id", { ascending: true }).range(offset, offset + 49); if (error) return json({ error: "The content list could not be loaded." }, 500); return json({ items: data ?? [], hasMore: offset + (data?.length ?? 0) < (count ?? 0) }); } From 4af6962c3fea3375901bae4f37ad1e2bbce0cd6d Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:13:26 +0100 Subject: [PATCH 06/29] Add stable ordering to editorial content queries --- lib/tic/editorial-content.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/tic/editorial-content.ts b/lib/tic/editorial-content.ts index db909ea7..54f32c0a 100644 --- a/lib/tic/editorial-content.ts +++ b/lib/tic/editorial-content.ts @@ -33,7 +33,7 @@ const admin = () => createAdminClient(); export async function getMaintenanceNotices(): Promise { const { data, error } = await admin().from("tic_status_maintenance") .select("id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at") - .order("starts_at", { ascending: false }).limit(50); + .order("starts_at", { ascending: false }).order("id", { ascending: true }).limit(50); if (error) throw new Error("Maintenance notices could not be loaded."); return (data ?? []) as MaintenanceNotice[]; } @@ -43,10 +43,10 @@ export async function getOperatorEditorialContent(): Promise<{ maintenance: Main const [maintenance, posts] = await Promise.all([ client.from("tic_status_maintenance") .select("id,title,subtitle,message,affected_services,status,starts_at,ends_at,created_at,updated_at") - .order("starts_at", { ascending: false }).limit(50), + .order("starts_at", { ascending: false }).order("id", { ascending: true }).limit(50), client.from("tic_blog_posts") .select("id,slug,title,subtitle,category,body,status,published_at,created_at,updated_at") - .order("updated_at", { ascending: false }).limit(50), + .order("updated_at", { ascending: false }).order("id", { ascending: true }).limit(50), ]); if (maintenance.error || posts.error) throw new Error("Operator content could not be loaded."); return { From 33b7e465d67376902cd65237b31dbace7fcc9406 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:13:27 +0100 Subject: [PATCH 07/29] Track editor pagination independently from list mutations --- app/operator/communications/editor.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index 7abd7f11..26bf61fe 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -21,7 +21,9 @@ function FormField({ label, children }: { label: string; children: ReactNode }) export function CommunicationsEditor({ initialMaintenance, initialPosts }: { initialMaintenance: MaintenanceNotice[]; initialPosts: EditorialPost[] }) { const [maintenance, setMaintenance] = useState(initialMaintenance); + const [maintenanceOffset, setMaintenanceOffset] = useState(initialMaintenance.length); const [posts, setPosts] = useState(initialPosts); + const [postsOffset, setPostsOffset] = useState(initialPosts.length); const [maintenanceHasMore, setMaintenanceHasMore] = useState(initialMaintenance.length === 50); const [postsHaveMore, setPostsHaveMore] = useState(initialPosts.length === 50); const [maintenanceVisible, setMaintenanceVisible] = useState(12); @@ -35,6 +37,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini async function showMore(kind: "maintenance" | "blog") { const items = kind === "maintenance" ? maintenance : posts; + const offset = kind === "maintenance" ? maintenanceOffset : postsOffset; const visible = kind === "maintenance" ? maintenanceVisible : postsVisible; if (visible < items.length) { kind === "maintenance" ? setMaintenanceVisible(visible + 12) : setPostsVisible(visible + 12); @@ -44,15 +47,17 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini if (!hasMore) return; setLoadingMore(kind); try { - const response = await fetch(`/api/operator/communications?kind=${kind}&offset=${items.length}`); + const response = await fetch(`/api/operator/communications?kind=${kind}&offset=${offset}`); const result = await response.json() as { items?: Array; hasMore?: boolean; error?: string }; if (!response.ok || !result.items) throw new Error(result.error ?? "Older entries could not be loaded."); if (kind === "maintenance") { setMaintenance(current => [...current, ...(result.items as MaintenanceNotice[])]); + setMaintenanceOffset(offset + result.items.length); setMaintenanceHasMore(Boolean(result.hasMore)); setMaintenanceVisible(visible + result.items.length); } else { setPosts(current => [...current, ...(result.items as EditorialPost[])]); + setPostsOffset(offset + result.items.length); setPostsHaveMore(Boolean(result.hasMore)); setPostsVisible(visible + result.items.length); } From 10595724cfa10b171073c94c416d5722e3e13602 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:13:47 +0100 Subject: [PATCH 08/29] Refresh paginated lists after editing records --- app/operator/communications/editor.tsx | 39 ++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index 26bf61fe..b140accd 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -69,6 +69,24 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini } } + async function refreshList(kind: "maintenance" | "blog") { + const response = await fetch(`/api/operator/communications?kind=${kind}&offset=0`); + const result = await response.json() as { items?: Array; hasMore?: boolean }; + if (!response.ok || !result.items) return false; + if (kind === "maintenance") { + setMaintenance(result.items as MaintenanceNotice[]); + setMaintenanceOffset(result.items.length); + setMaintenanceHasMore(Boolean(result.hasMore)); + setMaintenanceVisible(12); + } else { + setPosts(result.items as EditorialPost[]); + setPostsOffset(result.items.length); + setPostsHaveMore(Boolean(result.hasMore)); + setPostsVisible(12); + } + return true; + } + async function submit(event: FormEvent, kind: "maintenance" | "blog") { event.preventDefault(); const form = event.currentTarget; @@ -84,16 +102,27 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini const response = await fetch("/api/operator/communications", { method: editing ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const result = await response.json() as { item?: MaintenanceNotice | EditorialPost; error?: string }; if (!response.ok || !result.item) throw new Error(result.error ?? "The update could not be saved."); + const refreshed = await refreshList(kind); if (kind === "maintenance") { - const item = result.item as MaintenanceNotice; - setMaintenance(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.starts_at) - Date.parse(a.starts_at))); + if (!refreshed) { + const item = result.item as MaintenanceNotice; + setMaintenance(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.starts_at) - Date.parse(a.starts_at))); + setMaintenanceHasMore(false); + } setEditingMaintenance(null); - setMaintenanceMessage(editing ? "Maintenance notice updated." : "Maintenance notice published."); + setMaintenanceMessage(refreshed + ? (editing ? "Maintenance notice updated." : "Maintenance notice published.") + : "Saved, but the list could not refresh. Reload this page before loading older notices."); } else { const item = result.item as EditorialPost; - setPosts(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))); + if (!refreshed) { + setPosts(current => [item, ...current.filter(existing => existing.id !== item.id)].sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))); + setPostsHaveMore(false); + } setEditingPost(null); - setPostMessage(item.status === "published" ? "Field Notes article published." : "Draft saved."); + setPostMessage(refreshed + ? (item.status === "published" ? "Field Notes article published." : "Draft saved.") + : "Saved, but the list could not refresh. Reload this page before loading older articles."); } form.reset(); } catch (error) { From b52e9a5bcecea13264afceabb1cc8d2bc04bf18b Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:05 +0100 Subject: [PATCH 09/29] Add automatic, device, and icon theme controls --- app/ui/theme-provider.tsx | 144 +++++++++++++++++++++++++++++--------- 1 file changed, 112 insertions(+), 32 deletions(-) diff --git a/app/ui/theme-provider.tsx b/app/ui/theme-provider.tsx index 621c4481..90e80590 100644 --- a/app/ui/theme-provider.tsx +++ b/app/ui/theme-provider.tsx @@ -3,63 +3,138 @@ import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from "react"; export type Theme = "dark" | "white"; +export type ThemeMode = "auto" | "device" | "manual"; export type ExperienceMode = "mini" | "pro"; + export const themes: { id: Theme; name: string; detail: string; swatches: string[] }[] = [ { id: "dark", name: "Dark", detail: "Graphite, low-glare surfaces", swatches: ["#090a0c", "#d8e1dc", "#202328"] }, { id: "white", name: "White", detail: "Bright white, crisp contrast", swatches: ["#ffffff", "#153b31", "#e8f0eb"] }, ]; + const DEFAULT_THEME: Theme = "dark"; -const ThemeContext = createContext<{ theme: Theme; setTheme: (theme: Theme) => void }>({ theme: DEFAULT_THEME, setTheme: () => {} }); +const ThemeContext = createContext<{ theme: Theme; mode: ThemeMode; setTheme: (theme: Theme) => void; setMode: (mode: ThemeMode) => void }>({ + theme: DEFAULT_THEME, mode: "auto", setTheme: () => {}, setMode: () => {}, +}); const ExperienceContext = createContext<{ mode: ExperienceMode; setMode: (mode: ExperienceMode) => void }>({ mode: "pro", setMode: () => {} }); + const validTheme = (value: string | null): value is Theme => value === "white" || value === "dark"; +const cookieValue = (name: string) => document.cookie.split(";").map(part => part.trim()).find(part => part.startsWith(`${name}=`))?.slice(name.length + 1) ?? null; +const sharedDomain = () => location.hostname === "tidelight.app" || location.hostname.endsWith(".tidelight.app") + ? `; Domain=.tidelight.app${location.protocol === "https:" ? "; Secure" : ""}` + : ""; +const writeCookie = (name: string, value: string) => { + document.cookie = `${name}=${value}; Path=/; SameSite=Lax; Max-Age=31536000${sharedDomain()}`; +}; +const getTimeZone = () => { + try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } + catch { return "UTC"; } +}; +const automaticTheme = (timeZone: string): Theme => { + try { + const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "2-digit", hourCycle: "h23", timeZone }).format(new Date())); + return hour >= 7 && hour < 19 ? "white" : "dark"; + } catch { + return automaticTheme("UTC"); + } +}; +const systemTheme = (): Theme => window.matchMedia?.("(prefers-color-scheme: light)").matches ? "white" : "dark"; +const getThemeMode = (): ThemeMode => { + const value = cookieValue("tidelight-theme-mode"); + return value === "manual" || value === "device" ? value : "auto"; +}; const getTheme = (): Theme => { - const shared = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("tidelight-theme="))?.slice("tidelight-theme=".length) ?? null; - const mode = document.cookie.split(";").map((part) => part.trim()).find((part) => part.startsWith("tidelight-theme-mode="))?.slice("tidelight-theme-mode=".length); - return mode === "manual" && validTheme(shared) ? shared : DEFAULT_THEME; + const mode = getThemeMode(); + if (mode === "device") return systemTheme(); + if (mode === "manual") { + const selected = cookieValue("tidelight-theme"); + if (validTheme(selected)) return selected; + } + return automaticTheme(cookieValue("tidelight-timezone") || getTimeZone()); }; -const saveTheme = (theme: Theme) => { - try { window.localStorage.removeItem("tidelight-theme"); } catch { /* Theme selection still works when storage is blocked. */ } - const sharedDomain = location.hostname === "tidelight.app" || location.hostname.endsWith(".tidelight.app") ? `; Domain=.tidelight.app${location.protocol === "https:" ? "; Secure" : ""}` : ""; - // Keep an explicit choice available across Tidelight pages and subdomains in this browser session. - document.cookie = `tidelight-theme=${theme}; Path=/; SameSite=Lax${sharedDomain}`; - document.cookie = `tidelight-theme-mode=manual; Path=/; SameSite=Lax${sharedDomain}`; +const syncTheme = (theme: Theme) => { + writeCookie("tidelight-timezone", getTimeZone()); + writeCookie("tidelight-theme", theme); }; -const getExperienceMode = (): ExperienceMode => window.localStorage.getItem("tidelight-experience") === "mini" ? "mini" : "pro"; const subscribe = (callback: () => void) => { window.addEventListener("storage", callback); window.addEventListener("tidelight-theme-change", callback); - return () => { window.removeEventListener("storage", callback); window.removeEventListener("tidelight-theme-change", callback); }; + const media = window.matchMedia?.("(prefers-color-scheme: light)"); + media?.addEventListener?.("change", callback); + const interval = window.setInterval(callback, 60_000); + return () => { + window.removeEventListener("storage", callback); + window.removeEventListener("tidelight-theme-change", callback); + media?.removeEventListener?.("change", callback); + window.clearInterval(interval); + }; }; export function ThemeProvider({ children, initialTheme = DEFAULT_THEME }: { children: ReactNode; initialTheme?: Theme }) { const theme = useSyncExternalStore(subscribe, getTheme, (): Theme => initialTheme); - const mode = useSyncExternalStore(subscribe, getExperienceMode, (): ExperienceMode => "pro"); - const setMode = (next: ExperienceMode) => { - window.localStorage.setItem("tidelight-experience", next); - window.dispatchEvent(new Event("tidelight-theme-change")); - }; + const mode = useSyncExternalStore(subscribe, getThemeMode, (): ThemeMode => "auto"); + const experienceMode = useSyncExternalStore( + callback => { + window.addEventListener("storage", callback); + window.addEventListener("tidelight-theme-change", callback); + return () => { + window.removeEventListener("storage", callback); + window.removeEventListener("tidelight-theme-change", callback); + }; + }, + (): ExperienceMode => window.localStorage.getItem("tidelight-experience") === "mini" ? "mini" : "pro", + (): ExperienceMode => "pro", + ); + useEffect(() => { - saveTheme(theme); + syncTheme(theme); document.documentElement.dataset.theme = theme; - document.documentElement.dataset.themeMode = "manual"; - document.documentElement.dataset.experience = mode; + document.documentElement.dataset.themeMode = mode; + document.documentElement.dataset.experience = experienceMode; document.documentElement.style.colorScheme = theme === "white" ? "light" : "dark"; - }, [mode, theme]); + }, [experienceMode, mode, theme]); + const setTheme = (next: Theme) => { - saveTheme(next); + writeCookie("tidelight-theme-mode", "manual"); + writeCookie("tidelight-theme", next); + window.dispatchEvent(new Event("tidelight-theme-change")); + }; + const setMode = (next: ThemeMode) => { + writeCookie("tidelight-theme-mode", next); + if (next === "auto") writeCookie("tidelight-timezone", getTimeZone()); + window.dispatchEvent(new Event("tidelight-theme-change")); + }; + const setExperienceMode = (next: ExperienceMode) => { + window.localStorage.setItem("tidelight-experience", next); window.dispatchEvent(new Event("tidelight-theme-change")); }; - return {children}; + + return + {children} + ; +} + +function ThemeIcon({ name, className }: { name: "sun" | "device" | "moon" | "auto"; className?: string }) { + const shared = { className, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.7, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true as const }; + if (name === "sun") return ; + if (name === "device") return ; + if (name === "moon") return ; + return ; } export function ThemePicker() { - const { theme, setTheme } = useContext(ThemeContext); + const { theme, mode, setTheme, setMode } = useContext(ThemeContext); + const options = [ + { id: "auto", label: "Automatic", detail: "Follows local time", icon: "auto" as const, selected: mode === "auto", choose: () => setMode("auto") }, + { id: "device", label: "Device", detail: "Follows device setting", icon: "device" as const, selected: mode === "device", choose: () => setMode("device") }, + { id: "white", label: "Light", detail: "Bright, crisp contrast", icon: "sun" as const, selected: mode === "manual" && theme === "white", choose: () => setTheme("white") }, + { id: "dark", label: "Dark", detail: "Low-glare surfaces", icon: "moon" as const, selected: mode === "manual" && theme === "dark", choose: () => setTheme("dark") }, + ]; return
Choose a color theme - {themes.map((option) => )}
; } @@ -69,10 +144,15 @@ export function useExperience() { } export function ThemeQuickSwitch() { - const { theme, setTheme } = useContext(ThemeContext); - return
- {themes.map((option) => )}
; } From c380ffadfd02c8f6ee9771bbccaa34e0c302bf06 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:20 +0100 Subject: [PATCH 10/29] Resolve the theme from the saved time zone on the server --- app/layout.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index ee97efa8..3ed2311f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -23,6 +23,16 @@ import "./account-center.css"; import "./notifications/notifications.css"; import { getServerUser } from "@/lib/supabase/auth-user"; +const validTheme = (value: string | undefined): value is "dark" | "white" => value === "dark" || value === "white"; +function automaticTheme(timeZone: string) { + try { + const hour = Number(new Intl.DateTimeFormat("en-US", { hour: "2-digit", hourCycle: "h23", timeZone }).format(new Date())); + return hour >= 7 && hour < 19 ? "white" : "dark"; + } catch { + return "dark"; + } +} + const baseMetadata: Metadata = { metadataBase: new URL("https://tidelight.app"), alternates: { canonical: "https://tidelight.app" }, @@ -66,8 +76,11 @@ export async function generateMetadata(): Promise { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { const cookieStore = await cookies(); const savedTheme = cookieStore.get("tidelight-theme")?.value; - const themeMode = cookieStore.get("tidelight-theme-mode")?.value; - const theme = themeMode === "manual" && (savedTheme === "white" || savedTheme === "dark") ? savedTheme : "dark"; + const themeMode = cookieStore.get("tidelight-theme-mode")?.value ?? "auto"; + const timeZone = cookieStore.get("tidelight-timezone")?.value ?? "UTC"; + const theme = themeMode === "manual" && validTheme(savedTheme) + ? savedTheme + : themeMode === "auto" ? automaticTheme(timeZone) : validTheme(savedTheme) ? savedTheme : automaticTheme(timeZone); const requestHeaders = await headers(); const requestHost = (requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "").split(",")[0].trim().toLowerCase().replace(/:\d+$/, ""); const isPublicPackageHost = requestHost === "package.tidelight.app"; @@ -90,6 +103,6 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac || user?.email?.split("@")[0] || null; - return {isStandalonePublicHost ? children : {children}}{isStandalonePublicHost ? null : <>}; + return {isStandalonePublicHost ? children : {children}}{isStandalonePublicHost ? null : <>}; } From 7f69dcc6b134b82ed5c9ea2f18379036d99c3827 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:21 +0100 Subject: [PATCH 11/29] Explain automatic, device, and manual theme preferences --- app/settings/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/settings/page.tsx b/app/settings/page.tsx index f2f98127..f9a034d9 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -56,7 +56,7 @@ export default async function SettingsPage({ searchParams }: { searchParams: Pro {params.setup ?

Your account is ready. Choose a 4–8 character username to finish setting up your Tidelight profile.

: null}{params.updated ?

Your preference has been saved.

: null}{params.error ?

We couldn’t save that change. Try again.

: null}
文
LANGUAGE / 语言

Choose your language.

Your language choice is saved on this device and used throughout Tidelight.

-
◐
APPEARANCE

Your desk, your light.

Choose Dark or White. Dark is the default theme.

+
◐
APPEARANCE

Your desk, your light.

Choose Light or Dark, follow your device setting, or let Tidelight switch between themes using your local time zone.

◉
ACCOUNT

Your workspace

{user ? <>
{communityProfile?.avatar_url ? : (communityProfile?.display_name ?? user.email ?? "T").slice(0, 1).toUpperCase()}
{communityProfile?.display_name ?? user.email}@{communityProfile?.handle ?? "set up a community handle"} · sign-in protected
PRIVATE
Research lensThis preference is saved to your account.
:

You’re browsing as a guest. Sign in to save preferences and keep your research private across sessions.

Sign in ↗
}
From c9d893939c9093d5e182450ae8dea596038f078b Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:23 +0100 Subject: [PATCH 12/29] Test timezone-based and device theme defaults --- tests/theme-auto.test.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/theme-auto.test.mjs b/tests/theme-auto.test.mjs index 08d4a4f8..74df1ce9 100644 --- a/tests/theme-auto.test.mjs +++ b/tests/theme-auto.test.mjs @@ -2,10 +2,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -test("new visits use Dark rather than resolving a palette from the local clock", async () => { +test("new visits choose a palette from the browser time zone unless a preference is saved", async () => { const provider = await readFile(new URL("../app/ui/theme-provider.tsx", import.meta.url), "utf8"); const layout = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"); - assert.match(provider, /const DEFAULT_THEME: Theme = "dark"/); - assert.doesNotMatch(provider, /automaticTheme|"auto"|daylight|Night Tide|Blue Hour/); - assert.match(layout, /: "dark";/); + assert.match(provider, /export type ThemeMode = "auto" \| "device" \| "manual"/); + assert.match(provider, /automaticTheme\(cookieValue\("tidelight-timezone"\) \|\| getTimeZone\(\)\)/); + assert.match(provider, /window\.matchMedia\?\.\("\(prefers-color-scheme: light\)"\)/); + assert.match(layout, /automaticTheme\(timeZone\)/); + assert.match(layout, /themeMode === "manual" && validTheme\(savedTheme\)/); }); From 24cd1ba30abe7e8627fba5c69339bc0f5adea265 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:24 +0100 Subject: [PATCH 13/29] Update theme preference contract checks --- tests/theme-palette.test.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/theme-palette.test.mjs b/tests/theme-palette.test.mjs index e7f0ce9b..6eb1c095 100644 --- a/tests/theme-palette.test.mjs +++ b/tests/theme-palette.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -test("Dark and White are the only themes, with Dark as the default", async () => { +test("Light and Dark palettes support automatic and device preferences", async () => { const provider = await readFile(new URL("../app/ui/theme-provider.tsx", import.meta.url), "utf8"); const layout = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"); const css = await readFile(new URL("../app/globals.css", import.meta.url), "utf8"); @@ -10,7 +10,11 @@ test("Dark and White are the only themes, with Dark as the default", async () => assert.match(provider, /const DEFAULT_THEME: Theme = "dark"/); assert.match(provider, /id: "dark", name: "Dark"/); assert.match(provider, /id: "white", name: "White"/); - assert.doesNotMatch(provider, /id: "(?:daylight|night|blue)"|automaticTheme|"auto"/); + assert.match(provider, /id: "auto", label: "Automatic"/); + assert.match(provider, /id: "device", label: "Device"/); + assert.match(provider, /name: "sun"/); + assert.match(provider, /name: "device"/); + assert.match(provider, /name: "moon"/); assert.match(provider, /value === "white" \|\| value === "dark"/); assert.match(layout, /savedTheme === "white" \|\| savedTheme === "dark"\) \? savedTheme : "dark"/); assert.match(css, /\.theme-picker\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/); From 2d89e254b88b0e02fa0f8a25330b6880199ec487 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:25 +0100 Subject: [PATCH 14/29] Style recognizable theme control icons --- app/theme-polish.css | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/app/theme-polish.css b/app/theme-polish.css index 4e9f7160..8d90a78a 100644 --- a/app/theme-polish.css +++ b/app/theme-polish.css @@ -458,3 +458,37 @@ .app-shell .station-panel-head { align-items:flex-start; } .app-shell .station-history .station-panel-head > .station-subtle { max-width:100%; white-space:normal; } } + + +/* Match the reference's recognizable sun, device, and moon controls. */ +.theme-quick-option .theme-quick-icon { + width: 14px; + height: 14px; + color: var(--muted); + transition: color .16s ease, transform .16s ease; +} +.theme-quick-option:hover .theme-quick-icon, +.theme-quick-option.selected .theme-quick-icon { + color: var(--brand-aqua); +} +.theme-quick-option:hover .theme-quick-icon { transform: scale(1.08); } +.theme-option-icon { + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex: 0 0 34px; + border: 1px solid var(--line); + border-radius: 10px; + color: var(--brand-teal); + background: color-mix(in srgb, var(--brand-teal) 8%, var(--panel)); +} +.theme-option-icon svg { width: 19px; height: 19px; } +.theme-option.selected .theme-option-icon { + border-color: color-mix(in srgb, var(--brand-teal) 55%, var(--line)); + background: color-mix(in srgb, var(--brand-teal) 15%, var(--panel)); +} +.theme-option-copy small { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; } +@media (max-width: 780px) { + .mobile-utility-controls .theme-quick-icon { width: 12px; height: 12px; } +} From bccfb0e973112a846cb877aa4096337af47864a2 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:44 +0100 Subject: [PATCH 15/29] Use the browser's current time zone for automatic themes --- app/ui/theme-provider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ui/theme-provider.tsx b/app/ui/theme-provider.tsx index 90e80590..92a5152a 100644 --- a/app/ui/theme-provider.tsx +++ b/app/ui/theme-provider.tsx @@ -49,7 +49,7 @@ const getTheme = (): Theme => { const selected = cookieValue("tidelight-theme"); if (validTheme(selected)) return selected; } - return automaticTheme(cookieValue("tidelight-timezone") || getTimeZone()); + return automaticTheme(getTimeZone()); }; const syncTheme = (theme: Theme) => { writeCookie("tidelight-timezone", getTimeZone()); From 21bb91067c5cf25fb2a97c766e98f87b0a9a7763 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:45 +0100 Subject: [PATCH 16/29] Check current timezone-based theme behavior From 95f3bbbd77dd47cb1139cf96b85aa2313a9da6f3 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:14:47 +0100 Subject: [PATCH 17/29] Align theme tests with persisted appearance modes From 1a7784c173142c3f766a7093be18b373cff2358d Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:15:00 +0100 Subject: [PATCH 18/29] Test automatic and device theme selection --- tests/theme-auto.test.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/theme-auto.test.mjs b/tests/theme-auto.test.mjs index 74df1ce9..8d362b1c 100644 --- a/tests/theme-auto.test.mjs +++ b/tests/theme-auto.test.mjs @@ -2,12 +2,12 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; -test("new visits choose a palette from the browser time zone unless a preference is saved", async () => { +test("new visits follow local time and saved preferences can use device or manual themes", async () => { const provider = await readFile(new URL("../app/ui/theme-provider.tsx", import.meta.url), "utf8"); const layout = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"); - assert.match(provider, /export type ThemeMode = "auto" \| "device" \| "manual"/); - assert.match(provider, /automaticTheme\(cookieValue\("tidelight-timezone"\) \|\| getTimeZone\(\)\)/); - assert.match(provider, /window\.matchMedia\?\.\("\(prefers-color-scheme: light\)"\)/); - assert.match(layout, /automaticTheme\(timeZone\)/); - assert.match(layout, /themeMode === "manual" && validTheme\(savedTheme\)/); + assert.match(provider, /export type ThemeMode = "auto" \\| "device" \\| "manual"/); + assert.match(provider, /automaticTheme\\(getTimeZone\\(\\)\\)/); + assert.match(provider, /window\\.matchMedia\\?\\.\\("\\(prefers-color-scheme: light\\)"\\)/); + assert.match(layout, /automaticTheme\\(timeZone\\)/); + assert.match(layout, /themeMode === "manual" && validTheme\\(savedTheme\\)/); }); From 154d2a86e111ae591593c06316e800adcacdfa91 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:15:01 +0100 Subject: [PATCH 19/29] Update palette preference assertions From 6f2976856bf2ccbbb4aaea0326bd59a11a29e28d Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:15:12 +0100 Subject: [PATCH 20/29] Align palette test with saved theme modes --- tests/theme-palette.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/theme-palette.test.mjs b/tests/theme-palette.test.mjs index 6eb1c095..3deee226 100644 --- a/tests/theme-palette.test.mjs +++ b/tests/theme-palette.test.mjs @@ -16,7 +16,7 @@ test("Light and Dark palettes support automatic and device preferences", async ( assert.match(provider, /name: "device"/); assert.match(provider, /name: "moon"/); assert.match(provider, /value === "white" \|\| value === "dark"/); - assert.match(layout, /savedTheme === "white" \|\| savedTheme === "dark"\) \? savedTheme : "dark"/); + assert.match(layout, /themeMode === "manual" && validTheme\\(savedTheme\\)/); assert.match(css, /\.theme-picker\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/); assert.match(css, /:root\[data-theme="white"\]\{color-scheme:light/); assert.match(css, /:root\[data-theme="dark"\]\{color-scheme:dark/); From a90157eb662b27f3020ddfd894e1137efa0cebe4 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:15:29 +0100 Subject: [PATCH 21/29] Correct automatic theme behavior test assertions --- tests/theme-auto.test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/theme-auto.test.mjs b/tests/theme-auto.test.mjs index 8d362b1c..267065a2 100644 --- a/tests/theme-auto.test.mjs +++ b/tests/theme-auto.test.mjs @@ -5,9 +5,9 @@ import { readFile } from "node:fs/promises"; test("new visits follow local time and saved preferences can use device or manual themes", async () => { const provider = await readFile(new URL("../app/ui/theme-provider.tsx", import.meta.url), "utf8"); const layout = await readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"); - assert.match(provider, /export type ThemeMode = "auto" \\| "device" \\| "manual"/); - assert.match(provider, /automaticTheme\\(getTimeZone\\(\\)\\)/); - assert.match(provider, /window\\.matchMedia\\?\\.\\("\\(prefers-color-scheme: light\\)"\\)/); - assert.match(layout, /automaticTheme\\(timeZone\\)/); - assert.match(layout, /themeMode === "manual" && validTheme\\(savedTheme\\)/); + assert.ok(provider.includes('export type ThemeMode = "auto" | "device" | "manual"')); + assert.ok(provider.includes("automaticTheme(getTimeZone())")); + assert.ok(provider.includes('window.matchMedia?.("(prefers-color-scheme: light)")')); + assert.ok(provider.includes("automaticTheme(getTimeZone())")); + assert.ok(layout.includes("automaticTheme(timeZone)")); }); From 24d95d0206ab52e8a147350208d11f06a76de663 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:15:30 +0100 Subject: [PATCH 22/29] Correct theme control tests --- tests/theme-palette.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/theme-palette.test.mjs b/tests/theme-palette.test.mjs index 3deee226..8709222c 100644 --- a/tests/theme-palette.test.mjs +++ b/tests/theme-palette.test.mjs @@ -12,11 +12,11 @@ test("Light and Dark palettes support automatic and device preferences", async ( assert.match(provider, /id: "white", name: "White"/); assert.match(provider, /id: "auto", label: "Automatic"/); assert.match(provider, /id: "device", label: "Device"/); - assert.match(provider, /name: "sun"/); - assert.match(provider, /name: "device"/); - assert.match(provider, /name: "moon"/); + assert.ok(provider.includes('icon: "sun"')); + assert.ok(provider.includes('icon: "device"')); + assert.ok(provider.includes('icon: "moon"')); assert.match(provider, /value === "white" \|\| value === "dark"/); - assert.match(layout, /themeMode === "manual" && validTheme\\(savedTheme\\)/); + assert.ok(layout.includes("themeMode === \"manual\" && validTheme(savedTheme)")); assert.match(css, /\.theme-picker\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/); assert.match(css, /:root\[data-theme="white"\]\{color-scheme:light/); assert.match(css, /:root\[data-theme="dark"\]\{color-scheme:dark/); From 3f9fadb2be54c60f0e767f1d74bd0821699cabd5 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:15:52 +0100 Subject: [PATCH 23/29] Verify automatic and explicit appearance preferences --- tests/theme-auto.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/theme-auto.test.mjs b/tests/theme-auto.test.mjs index 267065a2..c82c9c6d 100644 --- a/tests/theme-auto.test.mjs +++ b/tests/theme-auto.test.mjs @@ -8,6 +8,6 @@ test("new visits follow local time and saved preferences can use device or manua assert.ok(provider.includes('export type ThemeMode = "auto" | "device" | "manual"')); assert.ok(provider.includes("automaticTheme(getTimeZone())")); assert.ok(provider.includes('window.matchMedia?.("(prefers-color-scheme: light)")')); - assert.ok(provider.includes("automaticTheme(getTimeZone())")); assert.ok(layout.includes("automaticTheme(timeZone)")); + assert.ok(layout.includes('themeMode === "manual" && validTheme(savedTheme)')); }); From cdc61d47b9d9f1bae09ac9472d4c6d2b996367ff Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:17:24 +0100 Subject: [PATCH 24/29] Translate automatic theme preference copy --- app/i18n/zh-CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/i18n/zh-CN.json b/app/i18n/zh-CN.json index 3ffd6750..0c6ebdb2 100644 --- a/app/i18n/zh-CN.json +++ b/app/i18n/zh-CN.json @@ -6184,5 +6184,5 @@ "SOURCE-LINKED": "来源可追溯", "PAPER-FIRST CONTROLS": "模拟交易优先控制", "Illustrative workflow · no live activity shown": "示意工作流 · 未显示实时活动", - "Choose Dark or White. Dark is the default theme.": "请选择深色或白色主题。深色为默认主题。" + "Choose Light or Dark, follow your device setting, or let Tidelight switch between themes using your local time zone.": "选择浅色或深色主题、跟随设备设置,或让 Tidelight 根据您所在时区的本地时间自动切换。" } From df98c596bf3d5417613e06a2fe30aa97f8cd2ee2 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:24:14 +0100 Subject: [PATCH 25/29] Handle communications refresh errors after save --- app/operator/communications/editor.tsx | 32 +++++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index b140accd..3222eac4 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -70,21 +70,25 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini } async function refreshList(kind: "maintenance" | "blog") { - const response = await fetch(`/api/operator/communications?kind=${kind}&offset=0`); - const result = await response.json() as { items?: Array; hasMore?: boolean }; - if (!response.ok || !result.items) return false; - if (kind === "maintenance") { - setMaintenance(result.items as MaintenanceNotice[]); - setMaintenanceOffset(result.items.length); - setMaintenanceHasMore(Boolean(result.hasMore)); - setMaintenanceVisible(12); - } else { - setPosts(result.items as EditorialPost[]); - setPostsOffset(result.items.length); - setPostsHaveMore(Boolean(result.hasMore)); - setPostsVisible(12); + try { + const response = await fetch(`/api/operator/communications?kind=${kind}&offset=0`); + const result = await response.json() as { items?: Array; hasMore?: boolean }; + if (!response.ok || !result.items) return false; + if (kind === "maintenance") { + setMaintenance(result.items as MaintenanceNotice[]); + setMaintenanceOffset(result.items.length); + setMaintenanceHasMore(Boolean(result.hasMore)); + setMaintenanceVisible(12); + } else { + setPosts(result.items as EditorialPost[]); + setPostsOffset(result.items.length); + setPostsHaveMore(Boolean(result.hasMore)); + setPostsVisible(12); + } + return true; + } catch { + return false; } - return true; } async function submit(event: FormEvent, kind: "maintenance" | "blog") { From 9eb13e5688e95bdfe18bf7e37bef5427c327e955 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:25:38 +0100 Subject: [PATCH 26/29] Invalidate stale pagination after refresh --- app/operator/communications/editor.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/operator/communications/editor.tsx b/app/operator/communications/editor.tsx index 3222eac4..53d8190e 100644 --- a/app/operator/communications/editor.tsx +++ b/app/operator/communications/editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, type FormEvent, type ReactNode } from "react"; +import { useRef, useState, type FormEvent, type ReactNode } from "react"; import type { EditorialPost, MaintenanceNotice } from "@/lib/tic/editorial-content"; function localDateTime(value: string | null | undefined) { @@ -29,6 +29,7 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini const [maintenanceVisible, setMaintenanceVisible] = useState(12); const [postsVisible, setPostsVisible] = useState(12); const [loadingMore, setLoadingMore] = useState<"maintenance" | "blog" | null>(null); + const paginationGeneration = useRef({ maintenance: 0, blog: 0 }); const [editingMaintenance, setEditingMaintenance] = useState(null); const [editingPost, setEditingPost] = useState(null); const [maintenanceMessage, setMaintenanceMessage] = useState(""); @@ -45,11 +46,13 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini } const hasMore = kind === "maintenance" ? maintenanceHasMore : postsHaveMore; if (!hasMore) return; + const generation = paginationGeneration.current[kind]; setLoadingMore(kind); try { const response = await fetch(`/api/operator/communications?kind=${kind}&offset=${offset}`); const result = await response.json() as { items?: Array; hasMore?: boolean; error?: string }; if (!response.ok || !result.items) throw new Error(result.error ?? "Older entries could not be loaded."); + if (generation !== paginationGeneration.current[kind]) return; if (kind === "maintenance") { setMaintenance(current => [...current, ...(result.items as MaintenanceNotice[])]); setMaintenanceOffset(offset + result.items.length); @@ -62,14 +65,18 @@ export function CommunicationsEditor({ initialMaintenance, initialPosts }: { ini setPostsVisible(visible + result.items.length); } } catch (error) { - const message = error instanceof Error ? error.message : "Older entries could not be loaded."; - kind === "maintenance" ? setMaintenanceMessage(message) : setPostMessage(message); + if (generation === paginationGeneration.current[kind]) { + const message = error instanceof Error ? error.message : "Older entries could not be loaded."; + kind === "maintenance" ? setMaintenanceMessage(message) : setPostMessage(message); + } } finally { - setLoadingMore(null); + if (generation === paginationGeneration.current[kind]) setLoadingMore(null); } } async function refreshList(kind: "maintenance" | "blog") { + paginationGeneration.current[kind] += 1; + setLoadingMore(current => current === kind ? null : current); try { const response = await fetch(`/api/operator/communications?kind=${kind}&offset=0`); const result = await response.json() as { items?: Array; hasMore?: boolean }; From d8df8d71d1b26605aeba947faf78b301c01f4034 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:25:40 +0100 Subject: [PATCH 27/29] Improve selected light theme icon contrast --- app/theme-polish.css | 1 + 1 file changed, 1 insertion(+) diff --git a/app/theme-polish.css b/app/theme-polish.css index 8d90a78a..8d8f186d 100644 --- a/app/theme-polish.css +++ b/app/theme-polish.css @@ -472,6 +472,7 @@ color: var(--brand-aqua); } .theme-quick-option:hover .theme-quick-icon { transform: scale(1.08); } +:root[data-theme="white"] .theme-quick-option.selected .theme-quick-icon { color: #286a4d; } .theme-option-icon { display: grid; place-items: center; From 52e33e3776fbf05172f2a684526ba8bd78f36091 Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:25:41 +0100 Subject: [PATCH 28/29] Render saved theme mode on server --- app/ui/theme-provider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/ui/theme-provider.tsx b/app/ui/theme-provider.tsx index 92a5152a..a60b8b03 100644 --- a/app/ui/theme-provider.tsx +++ b/app/ui/theme-provider.tsx @@ -69,9 +69,9 @@ const subscribe = (callback: () => void) => { }; }; -export function ThemeProvider({ children, initialTheme = DEFAULT_THEME }: { children: ReactNode; initialTheme?: Theme }) { +export function ThemeProvider({ children, initialTheme = DEFAULT_THEME, initialMode = "auto" }: { children: ReactNode; initialTheme?: Theme; initialMode?: ThemeMode }) { const theme = useSyncExternalStore(subscribe, getTheme, (): Theme => initialTheme); - const mode = useSyncExternalStore(subscribe, getThemeMode, (): ThemeMode => "auto"); + const mode = useSyncExternalStore(subscribe, getThemeMode, (): ThemeMode => initialMode); const experienceMode = useSyncExternalStore( callback => { window.addEventListener("storage", callback); From a2d73eef74a7fa0207383b1ee610179080a8e93f Mon Sep 17 00:00:00 2001 From: Asiwaju Date: Wed, 28 Oct 2026 07:25:43 +0100 Subject: [PATCH 29/29] Normalize and pass initial theme mode --- app/layout.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 3ed2311f..3b80f51b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -76,7 +76,8 @@ export async function generateMetadata(): Promise { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { const cookieStore = await cookies(); const savedTheme = cookieStore.get("tidelight-theme")?.value; - const themeMode = cookieStore.get("tidelight-theme-mode")?.value ?? "auto"; + const savedThemeMode = cookieStore.get("tidelight-theme-mode")?.value; + const themeMode = savedThemeMode === "manual" || savedThemeMode === "device" ? savedThemeMode : "auto"; const timeZone = cookieStore.get("tidelight-timezone")?.value ?? "UTC"; const theme = themeMode === "manual" && validTheme(savedTheme) ? savedTheme @@ -103,6 +104,6 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac || user?.email?.split("@")[0] || null; - return {isStandalonePublicHost ? children : {children}}{isStandalonePublicHost ? null : <>}; + return {isStandalonePublicHost ? children : {children}}{isStandalonePublicHost ? null : <>}; }