Skip to content

feat(portal): separate favicon upload + brand-color theming#41

Merged
keithfawcett merged 4 commits into
mainfrom
feat/brand-favicon-theming
Jul 9, 2026
Merged

feat(portal): separate favicon upload + brand-color theming#41
keithfawcett merged 4 commits into
mainfrom
feat/brand-favicon-theming

Conversation

@keithfawcett

Copy link
Copy Markdown
Contributor

Follow-up to #39 — this commit just missed its merge window and has been re-homed onto current main (incl. the #40 conflict resolution; verified: typecheck clean, api 192/192, portal 12/12).

Separate favicon (logos are lockups; favicons are square marks):

  • Tenant.faviconUrl migration + POST /uploads/favicon (PNG/JPEG/WebP only — no SVG/ICO; SVG is a script vector and PNG favicons render everywhere)
  • Second uploader in Settings → Brand info (LogoUploader generalized to BrandImageUploader); /branding + /config/program carry faviconUrl
  • Tab-icon preference: favicon → logo → brand-colored monogram

Brand-color theming (spec §5.4 — brandColor existed but was never applied):

  • Every accent token in theme.ts is now a CSS custom property with the platform teal as fallback; BrandDocument derives + injects --op-accent* from the tenant's color (hover shade, WCAG-luminance ink so button text stays readable, precomputed alpha tints — inline styles can't color-math a var())
  • Buttons, links, and the active nav re-tint app-wide; tenants without a color keep the teal
  • Swept all ${theme.accent}NN hex-suffix concats (which break against var() values) to explicit accentA* tokens, with a theme.ts comment forbidding the pattern

🤖 Generated with Claude Code

Favicon (distinct from logo — square mark vs lockup):
- Tenant.faviconUrl migration + /uploads/favicon (PNG/JPEG/WebP only; no
  SVG/ICO — SVG is a script vector and PNG favicons render everywhere).
- Settings → Brand info gains a second uploader (LogoUploader generalized
  to BrandImageUploader); /branding + /config/program carry faviconUrl.
- BrandDocument favicon preference: faviconUrl → logoUrl → monogram.

Brand-color theming (spec §5.4, finally applied):
- Every accent token in theme.ts is now a CSS custom property with the
  platform teal as fallback; BrandDocument derives and injects
  --op-accent* from Tenant.brandColor (hover = shaded, ink = black/white
  by WCAG luminance, soft + alpha tints precomputed — inline styles can't
  color-math a var()). Buttons, links, and active nav re-tint app-wide;
  no brandColor → properties removed → teal default.
- Swept all ${theme.accent}NN hex-suffix concatenations (which would
  break against var() values) to explicit accentA* tokens; theme.ts
  comment forbids reintroducing the pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/api/src/routes/uploads.ts Fixed
CodeQL (js/type-confusion-through-parameter-tampering) flags reading
req.body?.length before the type is proven — with express.raw a
mismatched Content-Type leaves req.body as {}. Behavior-identical
reorder: the Buffer guard now precedes validateImageUpload at all three
upload sites (logo, favicon, brand assets), which resolves the recurring
alert structurally instead of dismissing each new copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/api/src/routes/brand-resources.ts Fixed
Comment thread apps/api/src/routes/uploads.ts Fixed
Comment thread apps/api/src/routes/uploads.ts Fixed
…n't narrow req.body

The reorder in the previous commit wasn't enough: CodeQL treats every
fresh req.body access as a new tainted read and doesn't narrow it
through Buffer.isBuffer(req.body). Its own suggested changeset shows the
requirement — bind to a local after the guard and use only that. Done at
all three upload sites (logo, favicon, brand assets); behavior identical,
and TypeScript narrows the unknown-typed local through the same guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/api/src/routes/brand-resources.ts Dismissed
Comment thread apps/api/src/routes/uploads.ts Dismissed
Comment thread apps/api/src/routes/uploads.ts Dismissed
…ImageUpload

Third and final round with js/type-confusion: after the guarded-local
body fix, the query's remaining tainted argument was
req.header('content-type'). validateImageUpload now accepts the raw
string | string[] | undefined union and normalizes with an explicit
Array.isArray branch — the sanitizer shape the query recognizes, and
genuinely more robust against duplicate Content-Type headers. One place,
covers all three upload sites. If the rule fires again after this, the
remaining step is dismissal with justification, not more contortion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@keithfawcett keithfawcett merged commit a43491f into main Jul 9, 2026
9 checks passed
@keithfawcett keithfawcett deleted the feat/brand-favicon-theming branch July 9, 2026 17:49
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