fix(nav+digest): the sun icon stops lying, and the digest spinner deadlock dies #5542
3 changed files+154−39
Addedsrc/__tests__/digest-nav-honesty.test.ts+75−0View fileUnifiedSplit
@@ -0,0 +1,75 @@
1/**
2 * Pins two fixes from 2026-08-27 (owner report: "how do i change the screen
3 * from black to white" → "it activates morning digest which doesnt work
4 * either"):
5 *
6 * 1. The signed-in nav's Morning Digest link wore the ☀ sun — the universal
7 * light-mode symbol — while the actual theme toggle hid inside the avatar
8 * dropdown. Anyone escaping dark mode clicked into /digest. The digest
9 * link must never wear a sun, and BOTH nav branches (signed-in and
10 * signed-out) must render a visible /theme/toggle link.
11 *
12 * 2. /digest deadlocked into an eternal spinner: the page judged staleness
13 * by calendar day while sendSmartDigest() enforced a 20h cooldown, so a
14 * yesterday-afternoon digest made the page fire a generation the sender
15 * silently refused, forever. The page path must not call
16 * sendSmartDigest() and its spinner must be poll-bounded.
17 */
18
19import { describe, expect, test } from "bun:test";
20import { readFileSync } from "fs";
21import { join } from "path";
22
23const layoutSrc = readFileSync(
24 join(import.meta.dir, "../views/layout.tsx"),
25 "utf8"
26);
27const digestSrc = readFileSync(
28 join(import.meta.dir, "../routes/digest.tsx"),
29 "utf8"
30);
31
32describe("nav: theme toggle vs digest link", () => {
33 test("the digest nav link never wears the sun glyph", () => {
34 const digestAnchors = layoutSrc.match(
35 /<a[^>]*href="\/digest"[\s\S]{0,600}?<\/a>/g
36 );
37 expect(digestAnchors?.length).toBeGreaterThan(0);
38 for (const anchor of digestAnchors!) {
39 expect(anchor).not.toContain("☀"); // ☀
40 expect(anchor).not.toContain("☾"); // ☾
41 }
42 });
43
44 test("a visible /theme/toggle exists outside the user-menu dropdown", () => {
45 // The dropdown item has role="menuitem"; at least one toggle link must
46 // exist without it (the always-visible nav icon), and there must be
47 // toggles in more than one nav branch (signed-in AND signed-out).
48 const toggleAnchors =
49 layoutSrc.match(/<a[^>]*href="\/theme\/toggle"[^>]*>/g) || [];
50 const visibleToggles = toggleAnchors.filter(
51 (a) => !a.includes('role="menuitem"')
52 );
53 expect(visibleToggles.length).toBeGreaterThanOrEqual(2);
54 });
55});
56
57describe("digest page: no cooldown deadlock, no eternal spinner", () => {
58 test("the page never routes generation through the cooldown-gated sender", () => {
59 // The name may appear in comments explaining WHY it's banned; what must
60 // not exist is an import of it (and therefore any call).
61 const importLines = digestSrc
62 .split("\n")
63 .filter((l) => l.trimStart().startsWith("import"));
64 for (const line of importLines) {
65 expect(line).not.toContain("sendSmartDigest");
66 }
67 });
68
69 test("the spinner is poll-bounded and threads its wait counter", () => {
70 expect(digestSrc).toContain("MAX_GENERATION_WAITS");
71 // The reload must carry the incremented counter, not restart at zero —
72 // a bare '/digest' reload re-fires generation and loops forever.
73 expect(digestSrc).toContain("/digest?w=${wait + 1}");
74 });
75});
Modifiedsrc/routes/digest.tsx+62−36View fileUnifiedSplit
@@ -20,7 +20,7 @@ import { notifications, users } from "../db/schema";
2020import { Layout } from "../views/layout";
2121import { requireAuth, softAuth } from "../middleware/auth";
2222import type { AuthEnv } from "../middleware/auth";
23import { composeSmartDigest, sendSmartDigest, type SmartDigest, type DigestItem } from "../lib/smart-digest";
23import { composeSmartDigest, type SmartDigest, type DigestItem } from "../lib/smart-digest";
2424import { formatRelative } from "../views/ui";
2525
2626const digest = new Hono<AuthEnv>();
@@ -296,9 +296,46 @@ function itemIcon(t: ItemType): string {
296296// GET /digest
297297// ---------------------------------------------------------------------------
298298
299/**
300 * Page-initiated generation: compose + store, stamping the cooldown clock.
301 *
302 * Deliberately NOT sendSmartDigest(). That function enforces the autopilot's
303 * 20h cooldown — correct for the push channel, deadly here: the page decides
304 * staleness by *calendar day* while the sender decides by *hours elapsed*, so
305 * a digest generated yesterday afternoon left a morning window where the page
306 * demanded a refresh the sender silently refused. The result was an infinite
307 * "Generating your digest..." spinner (page → fire-and-forget no-op →
308 * ?generating=1 → reload → repeat). A user standing on the page asking for a
309 * digest IS the authorization — no cooldown applies.
310 */
311async function generateDigestNow(userId: string): Promise<void> {
312 try {
313 const fresh = await composeSmartDigest(userId);
314 if (!fresh) return;
315 await db.insert(notifications).values({
316 userId,
317 kind: "digest",
318 title: fresh.headline,
319 body: JSON.stringify(fresh),
320 url: "/digest",
321 });
322 await db
323 .update(users)
324 .set({ lastSmartDigestSentAt: new Date() })
325 .where(eq(users.id, userId));
326 } catch (err) {
327 console.error("[digest] generateDigestNow error:", err);
328 }
329}
330
331/** Spinner poll cap: ~3s per hop; past this the page renders what it has. */
332const MAX_GENERATION_WAITS = 8;
333
299334digest.get("/digest", softAuth, requireAuth, async (c) => {
300335 const user = c.get("user")!;
301 const generating = c.req.query("generating") === "1";
336 // w = which spinner poll we're on. 0/absent = fresh visit (may fire a
337 // generation), 1..MAX = waiting on one already fired, beyond = give up.
338 const wait = Math.max(0, Number(c.req.query("w")) || 0);
302339
303340 // Look up the most recent digest notification
304341 const [latestDigestNotif] = await db
@@ -331,21 +368,18 @@ digest.get("/digest", softAuth, requireAuth, async (c) => {
331368 }
332369 }
333370
334 // Auto-generate if no digest today and not already generating
335 if (!isToday && !generating) {
336 // Fire-and-forget then redirect with ?generating=1 to show spinner
337 void (async () => {
338 try {
339 await sendSmartDigest(user.id);
340 } catch {
341 /* swallow */
342 }
343 })();
344 return c.redirect("/digest?generating=1");
371 // Stale digest handling. Fire generation exactly ONCE (the w=0 visit) —
372 // the old code re-fired on every spinner reload, spawning a duplicate
373 // Claude call per 3-second hop while the first was still composing.
374 if (!isToday && wait === 0) {
375 void generateDigestNow(user.id);
376 return c.redirect("/digest?w=1");
345377 }
346378
347 // If generating, show spinner page that polls
348 if (generating && !isToday) {
379 // Waiting on that generation: poll a bounded number of times, then fall
380 // through and render whatever exists (yesterday's digest, or the empty
381 // state with its Regenerate button) — never an eternal spinner.
382 if (!isToday && wait <= MAX_GENERATION_WAITS) {
349383 return c.html(
350384 <Layout title="Morning Digest" user={user}>
351385 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
@@ -363,7 +397,7 @@ digest.get("/digest", softAuth, requireAuth, async (c) => {
363397 dangerouslySetInnerHTML={{
364398 __html: `
365399 setTimeout(function() {
366 window.location.href = '/digest';
400 window.location.href = '/digest?w=${wait + 1}';
367401 }, 3000);
368402 `,
369403 }}
@@ -373,6 +407,9 @@ digest.get("/digest", softAuth, requireAuth, async (c) => {
373407 );
374408 }
375409
410 // Past the poll cap with nothing new: be honest about it on the page.
411 const generationTimedOut = !isToday && wait > MAX_GENERATION_WAITS;
412
376413 return c.html(
377414 <Layout title="Morning Digest" user={user}>
378415 <style dangerouslySetInnerHTML={{ __html: DIGEST_STYLES }} />
@@ -389,6 +426,14 @@ digest.get("/digest", softAuth, requireAuth, async (c) => {
389426 Generated {formatRelative(digestData.generatedAt)}
390427 </p>
391428 )}
429 {generationTimedOut && (
430 <p class="digest-meta" style="margin-top:6px">
431 A fresh digest is taking longer than expected —{" "}
432 {digestData
433 ? "showing your previous one below. Regenerate to retry."
434 : "use Regenerate to retry."}
435 </p>
436 )}
392437 <div class="digest-actions">
393438 <form method="post" action="/digest/refresh" style="display:inline">
394439 <button type="submit" class="digest-regenerate-btn">
@@ -486,26 +531,7 @@ digest.get("/digest", softAuth, requireAuth, async (c) => {
486531
487532digest.post("/digest/refresh", softAuth, requireAuth, async (c) => {
488533 const user = c.get("user")!;
489 // Force a fresh digest by clearing cooldown temporarily — just compose + insert
490 try {
491 const freshDigest = await composeSmartDigest(user.id);
492 if (freshDigest) {
493 await db.insert(notifications).values({
494 userId: user.id,
495 kind: "digest",
496 title: freshDigest.headline,
497 body: JSON.stringify(freshDigest),
498 url: "/digest",
499 });
500 // Update last sent timestamp
501 await db
502 .update(users)
503 .set({ lastSmartDigestSentAt: new Date() })
504 .where(eq(users.id, user.id));
505 }
506 } catch (err) {
507 console.error("[digest] refresh error:", err);
508 }
534 await generateDigestNow(user.id);
509535 return c.redirect("/digest");
510536});
511537
Modifiedsrc/views/layout.tsx+17−3View fileUnifiedSplit
@@ -246,9 +246,21 @@ export const Layout: FC<
246246 </a>
247247 </div>
248248 </div>
249 {/* Smart morning digest link */}
250 <a href="/digest" class="nav-link" title="Morning Digest" aria-label="Morning Digest">
251 {"☀"}
249 {/* Theme toggle — visible for signed-in users too. It used to
250 live only inside the avatar dropdown while the digest link
251 below wore the ☀ sun — the universal light-mode symbol — so
252 anyone trying to leave dark mode clicked straight into the
253 digest page. The icon a control wears is a promise. */}
254 <a href="/theme/toggle" class="nav-link nav-theme" title="Toggle theme" aria-label="Toggle theme">
255 <span class="theme-icon-dark">{"☾"}</span>
256 <span class="theme-icon-light">{"☀"}</span>
257 </a>
258 {/* Smart morning digest link — newspaper glyph, never a sun */}
259 <a href="/digest" class="nav-link nav-digest" title="Morning Digest" aria-label="Morning Digest">
260 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
261 <path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/>
262 <path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/>
263 </svg>
252264 </a>
253265 {/* Inbox bell with unread badge */}
254266 <a
@@ -1095,6 +1107,8 @@ ${designTokensCss}
10951107 /* Theme toggle — show the icon for the *opposite* theme so users see what they'll switch to. */
10961108 .nav-theme { display: inline-flex; align-items: center; font-size: 15px; line-height: 1; opacity: 0.85; }
10971109 .nav-theme:hover { opacity: 1; }
1110 .nav-digest { display: inline-flex; align-items: center; opacity: 0.85; }
1111 .nav-digest:hover { opacity: 1; }
10981112 :root[data-theme='dark'] .theme-icon-dark { display: none; }
10991113 :root[data-theme='light'] .theme-icon-light { display: none; }
11001114
11011115
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts