feat(ui): add Totman/Classic P favicon style switcher - #1325
Conversation
Adds a two-choice favicon preference beside the Light/Dark/System mode selector so users can switch between the production Totman mascot and the historical dark-navy "P" tile. The choice persists through the existing cookie + ~/.plannotator/config.json settings path, surviving random ports, browsers, and remote hostnames, while /favicon.png stays the fast static default before React mounts. - packages/core/favicon.ts: add FaviconStyle type, CLASSIC_FAVICON_SVG, CLASSIC_FAVICON_DATA_URL, and faviconDataUrl() selector - packages/shared/config.ts: persist favicon in PlannotatorConfig and getServerConfig(), validating only totman|classic - packages/ui/config/settings.ts: register faviconStyle with cookie key plannotator-favicon and serverKey favicon - packages/ui/components/ThemeProvider.tsx: sync link[rel="icon"] type, sizes, and href whenever the style changes - packages/ui/components/ThemeTab.tsx: add Favicon segmented control beside the Mode row in both normal and compact layouts - server POST /api/config: accept favicon across Bun and Pi servers
The favicon effect ran unconditionally on every ThemeProvider mount. That is fine inside Plannotator, but @plannotator/ui is a published package installed into host applications with their own branding, so a mounted provider would find-or-create link[rel=icon] and silently replace the host page's favicon with Plannotator's. Per packages/ui/README.md, behavior a host might not want belongs behind a seam whose default reproduces the host's current behavior. Adds a `manageFavicon` prop defaulting to false. The effect returns early when it is off, and the resolved value is published on the theme context so the Settings control can be gated identically. Plannotator's own apps opt in at all four first-party mount points (packages/editor/App.tsx and packages/review-editor/App.tsx, main plus loading branch), so nothing changes for Plannotator. Also registers ThemeProvider.favicon.test.tsx in the DOM lane of .github/workflows/test.yml. It was never listed, so every case in it skipped in CI: the file guards on `typeof document !== 'undefined'` and the plain `bun test` lane has no DOM. Tests: the default mount now must leave document.head alone, including a host's pre-existing link[rel=icon], and must keep ignoring later preference changes; the opt-in mount still takes over.
The two favicon segmented controls in ThemeTab were the same ~20 lines of JSX twice, one per layout, which is two places to drift. Extracts them into one local FaviconStyleControl. The compact layout drops the visible "Favicon" heading the full layout has, so the extracted group carries aria-label="Favicon style". Without it that layout offered two buttons whose only distinguishing content is a decorative img plus a short word, with nothing naming what the group sets. Both call sites now render only when the theme context reports manageFavicon. A host that has not opted into favicon ownership no longer gets a switch that changes nothing, which is worse than no switch at all. The author's unrelated type="button" addition on the Mode buttons is left as is.
…o flash handleFavicon() answered FAVICON_PNG_BYTES unconditionally, so a user who had chosen Classic P still got a painted Totman tile on every load and only reached their own icon once React mounted and the ThemeProvider effect ran. The preference is already persisted in config.json; the static route just never read it. Both runtimes now read it. getServerConfig(null).favicon is the same validated accessor the config API uses, and loadConfig() is a small unmodified JSON read that happens once per page load, so no caching layer was added. On the static link tag and content type -------------------------------------- /favicon.png is now one URL with two possible bodies. The entry HTML declared `type="image/png" sizes="64x64"`, which becomes a lie for classic users. Rather than teach six server files to template HTML, the two server-served entry points (apps/hook/index.html, apps/review/index.html) drop those two advisory attributes, leaving the response Content-Type as the single source of truth. The attributes only ever mattered as hints for choosing among several declared icons, and there is exactly one; nothing regresses for the Totman default, whose response still says image/png. apps/portal/index.html keeps the fully typed link because it is a static site with no Plannotator server, so its favicon really is always the 64px PNG the vite plugin emits. Cache-Control drops from `public, max-age=86400` to `no-cache` for the same reason: with two possible bodies behind one URL, a day-long cache would re-paint the previous icon on the next session after a switch, which is the exact flash this commit removes. Tests ----- handleFavicon gets unit coverage in shared-handlers.test.ts: default serves the PNG, classic serves the SVG typed image/svg+xml, an unknown persisted value falls back, and neither payload is cacheable. It is a handler unit test, not an HTTP round trip, so it never touches global fetch and is correct in either CI lane. The DOM lanes in .github/workflows/test.yml list individual files and include no server tests, which is why the annotate.test.ts favicon test the author added is green in CI despite failing under a manual DOM_TESTS=1 run; it is left alone rather than given a guard it does not need. api-404-guard.test.ts covers the classic path across all six servers, which is where Bun and Pi parity is actually proven. That suite also gains a temp PLANNOTATOR_DATA_DIR: it now reads config.json, and per the testing rules it must never depend on the real ~/.plannotator of whoever runs it.
packages/core/favicon.ts already records a Source SHA-256 for the production Totman PNG, and packages/core/favicon.test.ts already asserts it. The classic style is the same kind of asset, an exact historical artifact, and had neither. Records the digest on CLASSIC_FAVICON_SVG following the file's existing convention and asserts it in both places that enumerate favicon assets. Verified byte-identical to the FAVICON_SVG that shipped at 5b91c54^: 27d33cff3d4515801f48e1cbaceec777ba802a7d341b22b2c0444d82b303cb49 The failure this catches is a reformat. The value is a template literal, so re-indenting it or normalizing its quotes silently ships a different icon under a name that claims to be the archival one.
|
TLDR: Merging this. I pushed four fix commits straight onto your branch (maintainerCanModify) rather than sending you round again: favicon ownership is now opt-in so the published AI-assisted review and fixes. What's good hereYour favicon archaeology is exact. I verified The two-runtime mirroring was disciplined too. Every The four commits
One thing worth knowing for next time:
The interesting part was the static On your
Gates
Please review the four commits and push back on anything you disagree with, particularly the dropped |
|
Reviewed all four commits — agreed on every one, no pushback.
Thanks for the archaeology check ( |
|
Merged, thanks @FNDEVVE. The byte-exact archaeology made this an easy yes. Ships in the next release. |
Summary
Adds a two-choice favicon switcher beside the Light/Dark/System mode selector, so users can flip between the current Totman production mascot and the historical dark-navy "P" tile on the spot. The choice persists through the existing cookie +
~/.plannotator/config.jsonsettings path — surviving random ports, browsers, and remote hostnames — while/favicon.pngremains the fast static default before React mounts.Motivation
Plannotator hard-codes the production Totman PNG favicon, with the earlier dark-navy tile living only in Git history. This restores the historical asset behind one small core interface and exposes it as a first-class preference, exactly where appearance settings already live.
What changed
Core asset + selection interface
packages/core/favicon.ts— newFaviconStyletype ('totman' | 'classic'),isFaviconStyle()validator, the exact historicalCLASSIC_FAVICON_SVG, a base64CLASSIC_FAVICON_DATA_URL, andfaviconDataUrl(style). Existing PNG/SVG exports untouched.Persistence
packages/shared/config.ts—favicon?: FaviconStyleonPlannotatorConfig;getServerConfig()includesfavicononly when the stored value is exactlytotmanorclassic.packages/ui/config/settings.ts— registersfaviconStyle(cookie keyplannotator-favicon, server keyfavicon, defaulttotman).POST /api/config— accepts and validatesfaviconacross all Bun servers (index,review,annotate,goal-setup) and Pi servers (serverPlan,serverReview,serverAnnotate).UI
packages/ui/components/ThemeProvider.tsx— one effect keyed onfaviconStylethat selects/createslink[rel="icon"]and updatestype,sizes, andhrefimmediately (before the debounced server write completes).packages/ui/components/ThemeTab.tsx— aFaviconsegmented control (Totman/Classic P) with inline 20px previews, rendered directly below the Mode buttons in both normal and compact layouts.The marketing/docs site is deliberately untouched — it has no per-user Settings store and keeps the production brand favicon.
Testing
packages/core/favicon.test.ts— data-URL encoding, historical SVG payload, andisFaviconStylevalidation.packages/ui/components/ThemeProvider.favicon.test.tsx(new) — asserts the observablelink[rel="icon"]href/type/sizesupdate onconfigStore.set()in both directions.packages/shared/config.test.ts+packages/server/annotate.test.ts— sandboxed persistence andPOST /api/configround-trips, including invalid-value rejection.tests/favicon-surfaces.test.ts— static production Totman fallback assertions unchanged.bun run typecheck— clean across all seven tsconfigs.Browser-verified: plan UI switches tab icon immediately, persists across new random ports, inherits into the review UI and back, and falls back cleanly to Totman on a hand-edited invalid config value.
Screenshot
Settings → Theme, with the new
Faviconcontrol beneathMode: