Skip to content

feat(ui): add Totman/Classic P favicon style switcher - #1325

Merged
backnotprop merged 8 commits into
backnotprop:mainfrom
FNDEVVE:feat/favicon-switcher
Aug 17, 2026
Merged

backnotprop merged 8 commits into
backnotprop:mainfrom
FNDEVVE:feat/favicon-switcher

Conversation

@FNDEVVE

@FNDEVVE FNDEVVE commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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.json settings path — surviving random ports, browsers, and remote hostnames — while /favicon.png remains 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 — new FaviconStyle type ('totman' | 'classic'), isFaviconStyle() validator, the exact historical CLASSIC_FAVICON_SVG, a base64 CLASSIC_FAVICON_DATA_URL, and faviconDataUrl(style). Existing PNG/SVG exports untouched.

Persistence

  • packages/shared/config.tsfavicon?: FaviconStyle on PlannotatorConfig; getServerConfig() includes favicon only when the stored value is exactly totman or classic.
  • packages/ui/config/settings.ts — registers faviconStyle (cookie key plannotator-favicon, server key favicon, default totman).
  • POST /api/config — accepts and validates favicon across all Bun servers (index, review, annotate, goal-setup) and Pi servers (serverPlan, serverReview, serverAnnotate).

UI

  • packages/ui/components/ThemeProvider.tsx — one effect keyed on faviconStyle that selects/creates link[rel="icon"] and updates type, sizes, and href immediately (before the debounced server write completes).
  • packages/ui/components/ThemeTab.tsx — a Favicon segmented 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, and isFaviconStyle validation.
  • packages/ui/components/ThemeProvider.favicon.test.tsx (new) — asserts the observable link[rel="icon"] href/type/sizes update on configStore.set() in both directions.
  • packages/shared/config.test.ts + packages/server/annotate.test.ts — sandboxed persistence and POST /api/config round-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 Favicon control beneath Mode:

Mode:  [ Light ] [ Dark ] [ System ]
Favicon: [ Totman ] [ Classic P ]
System follows your OS and switches between the two themes below.

FNDEVVE and others added 5 commits August 16, 2026 08:56
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.
@backnotprop

Copy link
Copy Markdown
Owner

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 @plannotator/ui cannot hijack a host page's icon, the server serves your persisted style so classic users get no Totman flash, the classic SVG is pinned to its digest, and the duplicated control JSX is extracted. Nothing was rewritten or squashed, your commit is still the base.

AI-assisted review and fixes.

What's good here

Your favicon archaeology is exact. I verified CLASSIC_FAVICON_SVG against git show 5b91c543^:packages/core/favicon.ts and it is byte-identical to the FAVICON_SVG that shipped before the Totman swap, sha256 27d33cff3d4515801f48e1cbaceec777ba802a7d341b22b2c0444d82b303cb49. You did not eyeball a lookalike and retype it, you recovered the real asset. That is the part of this PR I could not have done faster myself.

The two-runtime mirroring was disciplined too. Every /api/config site got the same isFaviconStyle(body.favicon) guard in the same position, across all three Bun servers, all three Pi servers, and goal-setup.ts, which people miss constantly. And you validated on the way in rather than trusting the client, so an unknown value never reaches config.json.

The four commits

5dd8b180 fix(ui): make ThemeProvider favicon ownership opt-in. This was the blocker. @plannotator/ui is published and installed into host apps with their own branding, so an unconditional effect that find-or-creates link[rel=icon] means merely mounting our provider repaints someone else's tab icon. Added a manageFavicon prop defaulting to false, published on the theme context so the Settings control gates on the same value, and opted in at all four first-party mount points in packages/editor/App.tsx and packages/review-editor/App.tsx. Plannotator behaves exactly as your version did.

One thing worth knowing for next time: ThemeProvider.favicon.test.tsx was never registered in the DOM lane in .github/workflows/test.yml, so every case in it was skipping in CI. It guards on typeof document, and the plain bun test lane has no DOM. Added it to the list, so it runs for real now.

f9828d49 refactor(ui): extract the favicon control and gate it on manageFavicon. The two segmented controls were the same 20 lines twice. One local FaviconStyleControl now. The compact layout drops the visible "Favicon" heading, so the group carries aria-label="Favicon style"; without it that row was two buttons whose only content is a decorative img and a short word. Both call sites render only when manageFavicon is on, since a knob that changes nothing is worse than no knob. Your unrelated type="button" addition is untouched, it was a good catch.

09997abf fix(server): serve the persisted favicon style so classic users see no flash. handleFavicon() still answered the PNG unconditionally, so a classic user got a painted Totman tile every load and only reached their own icon once React mounted. Both runtimes now read getServerConfig(null).favicon. No caching layer, loadConfig() is a small JSON read once per page load.

The interesting part was the static <link>. /favicon.png now has two possible bodies, and the entry HTML declared type="image/png" sizes="64x64", which is a lie for classic users. Rather than teach six server files to template HTML, I dropped those two advisory attributes from the two server-served entry points and left the response Content-Type as the single source of truth. They only ever mattered as hints for picking among several declared icons and there is exactly one, so the Totman default is unaffected. apps/portal/index.html keeps its typed link because it is a static site with no Plannotator server, so its favicon really is always the PNG. Cache-Control also drops to no-cache, since a day-long cache on a preference-dependent URL would re-paint the old icon on the next session, which is the same flash all over again.

On your annotate.test.ts favicon test: you are right that it fails under a manual DOM_TESTS=1 run, since happy-dom replaces global fetch. It is fine in CI, because the DOM lanes list individual files and include no server tests, so I left it alone rather than add a guard it does not need. I put the new coverage in shared-handlers.test.ts as a handler unit test that never calls fetch, and extended api-404-guard.test.ts to cover the classic path across all six servers, which is where Bun and Pi parity actually gets proven. That suite also gained a temp PLANNOTATOR_DATA_DIR, since it now reads config.json and must not depend on the real ~/.plannotator of whoever runs it.

dc4d8f85 test(core): pin the classic favicon SVG to its archival digest. favicon.ts already carries a Source SHA-256 comment for the production PNG and favicon.test.ts already asserts it, so the classic asset now gets the same treatment in both places plus tests/favicon-surfaces.test.ts. The failure this catches is a reformat: it is a template literal, so re-indenting it silently ships a different icon under a name claiming to be the archival one.

Gates

bun test on server, shared, pi-extension, core and tests matches the pre-existing baseline exactly, 12 environmental failures on my machine from a missing sem sidecar and GitButler CLI, none new. DOM_TESTS=1 bun test packages/ui packages/core is clean apart from the live paste-service E2E, which needs network. bun run typecheck passes, and bun run --cwd apps/review build && bun run build:hook both succeed.

Please review the four commits and push back on anything you disagree with, particularly the dropped type/sizes attributes, which is the one judgement call with a real alternative. Thanks for a genuinely careful PR.

@FNDEVVE

FNDEVVE commented Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed all four commits — agreed on every one, no pushback.

  • manageFavicon opt-in is the right call for the published @plannotator/ui: the unconditional effect would have hijacked a host app's favicon. That seam was the real gap in the PR, and the CI-lane registration for the skipped test is a good catch.
  • Extracted control + aria-label fixes both the layout duplication and the compact group's missing accessible name.
  • Dropped type/sizes is correct — they're advisory hints for picking among multiple declared icons, and with exactly one icon the response Content-Type is the truthful single source of truth. no-cache is the right trade once one URL has two possible bodies.
  • Digest pin brings the classic asset to parity with the production PNG.

Thanks for the archaeology check (git show 5b91c543^ vs CLASSIC_FAVICON_SVG) and for the careful seam review.

@backnotprop
backnotprop merged commit e1ce7da into backnotprop:main Aug 17, 2026
24 checks passed
@backnotprop

Copy link
Copy Markdown
Owner

Merged, thanks @FNDEVVE. The byte-exact archaeology made this an easy yes. Ships in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants