Skip to content

epic 2.6: the viewer chrome reads its brand from config - #54

Merged
kuyazee merged 4 commits into
mainfrom
task/epic-2-6
Aug 29, 2026
Merged

kuyazee merged 4 commits into
mainfrom
task/epic-2-6

Conversation

@kuyazee

@kuyazee kuyazee commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Three items from epic 2.6, plus one commit closing every finding the review gauntlet confirmed.
Viewer-facing chrome now reads its brand from config instead of carrying the product name and the
accent color in the shell files.

Items

  • T2.6.8 - a branding block on the config endpoint (commit 0360ecc)
  • T2.6.2 - the shells read branding from config (commit b9967bb)
  • T2.6.9 - branding on every viewer-facing surface (commit 257d6b8)
  • Review findings from all three lenses, fourteen of them (commit 2ca4a76)

Tests: 118 to 152, no failures. Smoke: 203 to 212 ok-lines, exit 0.

The rule this whole change is built around

Default output is byte-identical to main. Empty string is the neutral value for all five fields
and means "keep the built-in look", which makes the default identical by construction rather than by
care. An existing self-host upgrading sees no visual change at all.

That was verified by serving every shell from this branch and from a git archive checkout of main
with the same BASE_URL, then running cmp:

byte-identical: frame (6306 bytes)
byte-identical: password (5284 bytes)
byte-identical: not-found (2962 bytes)
byte-identical: md (3912 bytes)
byte-identical: jsx (1758 bytes)

The QA lens pointed out that nothing in CI would have caught a regression of this. The old tests
asserted each slot VALUE equalled the literal the shell used to carry, which guards the values and
not the rendered page: move {{LOGO}} to its own line or add a blank line around {{BRAND_STYLE}}
and every test and every smoke line still passed. So test/fixtures/shells-default/ now holds the
five main renders and test/shells-default.test.js fills each current shell with the all-empty slot
table and asserts equality. It was proven to bite by appending one newline to shells/md.html, which
turned that case red.

T2.6.8 - a branding block on the config endpoint

branding: {productName, logoUrl, faviconUrl, accentColor, footerText} on GET/PUT /api/config,
mirroring the existing frame block: same validation shape, same persistence, same precedence
(stored, then env, then empty). New module lib/branding.js keeps parsing and normalizing separate
from the store, so the per-tenant override layer the item describes can merge on top later without
touching a shell. That override half stays out of this repo, as the item says.

PUT /api/config is requireAuth('full'). Checked against live scoped keys: unauthenticated 401,
read 403, publish 403, full 200, bootstrap 200.

Every field is length-capped and validated, and a refused field rolls the whole PUT back rather than
half-applying. normalizeBranding re-validates on read, so a hand-edited config.json cannot smuggle
an unvalidated value into a shell.

T2.6.2 and T2.6.9 - the shells read it

Wired: shells/frame.html, shells/password.html, shells/not-found.html, shells/md.html,
shells/jsx.html, and the share-link tags in lib/social.js. The badge and watermark overlay shell
in T2.6.2's list does not exist yet, since T2.6.1 is unbuilt.

The T2.6.9 grep is clean. No brand accent hex and no viewer-facing product name is left in shells/.
Every hex still in there is the neutral surface palette. The remaining artifact hits are internal
identifiers only: the artifactTheme localStorage key, the #__artifact_error id, and
data-filename="artifact.tsx".

The md render cache keys on branding as well as md config, so a rebrand shows on the next view rather
than serving a stale render.

One behaviour worth knowing. A jsx artifact is built once, at publish time, so a config change
does not rebrand an already-published one; its frame rebrands either way, and republishing rebrands
the body. An html artifact has no shell at all, so it never carries branding beyond its frame.
Documented in docs/api.md and asserted in both directions in smoke.

Review findings

Three lenses ran: adversarial, security, and QA plus UX. Fourteen findings were confirmed with
measured numbers against a live server. All fourteen are fixed in 2ca4a76. Three more were filed
rather than built, listed at the end.

The security lens found no injection path. It set the worst values that pass validation and read
the served bytes across all six sinks: <title>, an attribute value, a URL attribute, a meta
content=, inside <script> (the jsx error label, which goes through JSON.stringify), and inside
<style>. Each is escaped correctly for its own context, not merely escaped. lib/shells.js does one
pass with a function replacement, which kills both slot-stealing and the $& and $` and $'
substitution patterns; all three render verbatim. Prototype pollution attempts returned 200 with no
pollution.

Contrast, three findings, all measured. These were the substance of the review.

One accentColor was filling both the light and the dark slot, but the shipped shells carried a
light/dark pair per surface precisely because one hue cannot serve both grounds. #1d4ed8 gave md
light links 6.70:1 and dark links 2.90:1; #ffe600 gave dark 15.36:1 and light 1.27:1. The *_DARK
slots now derive color-mix(in srgb, <accent> 70%, white) when an accent is set and keep the literal
when it is not. #1d4ed8 dark links go 2.90 to 5.45:1, the 404 dark accent 2.78 to 5.21:1.

The unlock button hardcoded its label at #0b0d0f, so an ordinary brand blue gave 2.90:1 against a
4.5:1 requirement, and a corporate navy gave 1.31:1, an invisible word on a dark rectangle. A BTN_FG
slot now picks #0b0d0f or #ffffff by the accent's relative luminance: #1d4ed8 goes to 6.70:1 and
#16264a to 14.89:1. Separately BTN_BG and BTN_HOVER both returned the accent, so hover lost its
background change entirely; hover now derives its own value.

The accent allowlist was shape-only, so rgb(--), rgb(,,,,), hsl(+) and rgba(1,2) all returned
200 and then failed silently in the browser: one invalid layer voids the whole declaration, so the
unlock button fell back to UA grey and the page lost its dot grid. Color functions are parsed now
rather than pattern-matched, in both the comma and the space-slash form. Alpha below 1 is refused with
a message saying why, which also closes rgba(0,0,0,0) and #0000 computing every link and code rule
to fully transparent, and color-mix multiplying an accent's own alpha so an hsla(...,0.3) glow
rendered at 0.024 opacity and simply was not there.

Layout, two findings. No logo had a max-width, so at 390px a 1200x60 wordmark rendered 400px
wide, pushed scrollWidth to 579, and put the Copy link and Hide buttons off screen; with a product
name too, the artifact title collapsed to 0px. A 600x60 wordmark is 10:1, which is where the frame
starts breaking, so this is an ordinary input. All three logo rules are capped now, and at 390px the
same logo renders 120px with scrollWidth back to 390 and the title unclipped. The frame bar also
showed logo and wordmark side by side with no hierarchy while refusing to shrink, so the viewer's
document lost its name to keep the operator's; the name renders only when there is no logo, and drops
below 560px.

A CSS selector regression. main > p:last-child styled the 404 description, and {{FOOTER}}
appends its paragraph after it, so setting a footer silently restyled the body copy: 14px to 16px,
muted rgb(89,99,110) to full-foreground rgb(31,35,40), margin 0 to 16px 0, rewrapping two
lines into three. The fix goes in BRAND_STYLE rather than on the paragraph, because adding a class
to the shell would change the untouched page's bytes.

Brand assets are same-origin or inline now. The validator accepted any absolute http(s) URL and
lib/branding.js even commented "so a CDN works", but FRAME_CSP carries img-src 'self' data: on
all three chrome pages, so the browser refused the image: a broken-image box on the frame and the
gate, and on the 404 no mark at all, since the built-in anvil is swapped out for the <img>. It was
also inconsistent, because ?raw=1 runs under img-src * and a remote favicon did load there,
leaking viewer IP and UA to the logo host on that one surface.

T2.6.10 already settles the policy: brand assets live in the same storage backend artifacts use, with
no remote hotlinking, because a hotlinked logo means mixed content and a third-party request from
every viewer page. So parseBrandUrl takes a single-slash path or a base64 data: URI, and refuses
absolute URLs with a message naming the reason. data:image/svg+xml gets its own refusal: an SVG
served from our origin is the stored-XSS case T2.2.3 and T2.6.10 both call out, and this branch has no
sanitizer. The cap on those two fields went 2048 to 8192 so a small inline image fits. The docs now say
where a self-host actually puts the file, since .env.example had been suggesting /brand/logo.svg
and there is no static file route on this server, so that 404s on a stock install.

A product name is not a count noun. productName was substituting the noun for a published item,
so a white-label host served <h1>Dropkiln unavailable</h1> on a dead link, which reads as "the
service is down" rather than "this link is wrong", plus "Protected Dropkiln", "This Dropkiln is
password protected." and "Copy link to this Dropkiln." docs/api.md blessed it explicitly. The noun is
the literal artifact again, everywhere, and productName reaches only the four places a brand
belongs: the frame chip, og:site_name, the footer, and the jsx error label. Removing it from the
h1 also dissolved a separate finding, where a 40-character unbroken name overflowed the 404 card by
180px.

The smoke block could not pass on a branded host, and wiped its branding. It asserted the target
renders Artifact unavailable and --link: #c73d1d, both false on exactly the instances this feature
exists for, then PUT all five fields to empty unconditionally. Since fail() exits, any failure in
between left the target branded "Smokebrand" with a blue accent. It now reads the stored branding
first, skips the built-in-look assertions when the host is already branded, and restores what was
there, following the md.width save-and-restore precedent. Proven both ways: on an unbranded host,
212 ok-lines, exit 0; on a host pre-branded Acme Docs with a purple accent and a footer, 211
ok-lines, exit 0, and the config came out exactly as it went in.

Also fixed. The footer was centered on the gate and left-aligned on the 404 while both cards align
everything else left, now left on both. The accentColor error message omitted #rgba while the code
accepted it. docs/api.md grouped html with jsx on build-time staleness, but html carries no branding
at all, so the sentence implied stale branding where there is none. Two coverage gaps closed: the real
upgrade path (a config.json with no branding key, which is the case that decides "zero visual
change after upgrading", and the nearest existing test covered the opposite case), and the BRAND_*
env path, which .env.example documents and nothing pinned.

Filed, not built. Three items appended to the backlog rather than folded in here:

  • The 410 and the plain-text 404 responses stay unbranded and still say "artifact" on a white-labelled
    host. Pre-existing on main, but T2.6.9 says every viewer-facing surface, so the gap is named.
    Turning a plain-text response into an HTML page is a behaviour change that needs its own item.
  • Branding is API-only. The dashboard settings panel writes back frame and md and has no branding
    inputs, the CLI has none, and there is no MCP config tool, so an operator opens Settings, sees every
    other global knob, and has to go find curl for this one. Both done-when clauses are satisfied by a
    PUT, so this is a gap rather than a missed requirement, but it is the gap that decides whether
    anyone uses the feature.
  • The dashboard itself is unbranded.

Left alone deliberately. A light-theme accent can still read poorly against the light ground:
#ffe600 is 1.27:1. The derivation is scoped to the dark slots on purpose, because the light slot is
the operator's literal accent and darkening it means overriding the color they picked in the theme
they picked it for. Same reasoning for an accent near luminance 0.19, where neither black nor white
label clears 4.5:1 and the helper picks the better of the two at about 4.4:1. Both want a product
decision, not a contrast helper.

Verification

  • npm test: 152 pass, 0 fail. Was 118 on main.
  • Smoke on an unbranded host: exit 0, 212 ok-lines. On a pre-branded host: exit 0, 211 ok-lines, with
    the stored branding restored unchanged.
  • Byte-identity: all five shells cmp-equal against main, and now guarded by a fixture test.
  • git diff origin/main..HEAD -U0 | grep "^+" | grep "—\|–" returns nothing.

https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea

GET/PUT /api/config now carry branding: {productName, logoUrl, faviconUrl,
accentColor, footerText}, shaped after the existing frame block. Every field
defaults to an empty string, which means "keep the built-in look", so an
instance that never sets one renders exactly what it rendered before.

Validation lives in lib/branding.js because all five values end up in HTML a
viewer loads. productName and footerText are plain text with a length cap and
no angle brackets. logoUrl and faviconUrl take an absolute http(s) URL or a
path starting with a single slash, and refuse data:, javascript:, //host,
credentials, quotes and spaces. accentColor takes hex or rgb()/rgba()/hsl()/
hsla() and nothing else, because it lands inside a style block where a brace
would end the rule. Each refusal is a 400 naming the field.

BRAND_PRODUCT_NAME, BRAND_LOGO_URL, BRAND_FAVICON_URL, BRAND_ACCENT_COLOR and
BRAND_FOOTER_TEXT supply values until a config is saved, matching how
FRAME_ENABLED works. A bad env value is logged once and ignored rather than
stopping the boot.

The shells do not read any of this yet. That is T2.6.2.
frame.html, password.html, not-found.html and md.html now hold {{SLOT}} markers
where they used to hold a product name and a brand color. lib/branding.js hands
each shell its slot values, and the fallback for every slot is the literal that
shell used to carry, so an instance with nothing configured serves the same
bytes it served before.

Verified by capturing the framed html page, the raw html body, the framed md
page, the md page, the jsx page, the password gate and the 404 page from a
server on the previous commit, then diffing them against the same seven pages
after this change. All seven match.

What a configured value reaches:

- productName replaces the built-in wording ("Protected artifact" becomes
  "Protected Dropkiln", "Artifact unavailable" becomes "Dropkiln unavailable")
  and adds a name chip to the frame bar. It is used exactly as typed, never
  lowercased, because a brand name is not a common noun.
- logoUrl replaces the anvil mark on the 404 page, sits above the password
  card, and rides in the frame bar.
- faviconUrl adds one link tag to every shell.
- accentColor takes over every accent role, including the two translucent
  washes, which color-mix() derives from the same value. The error reds stay
  put: they say "this failed", not "this is our brand".
- footerText adds one muted line to the password and 404 pages.

The md render cache now keys on the branding as well as the md config, so a
rebrand shows on the next view instead of serving a stale render.

The badge/watermark overlay shell is not covered because it does not exist yet.
That is T2.6.1.
The two surfaces T2.6.2 left alone:

- shells/jsx.html now takes the favicon, the error readout color and the error
  label from config. The label sits inside a script, so it is JSON-quoted
  rather than HTML-escaped, and the built-in one keeps the single quotes the
  shell used to write so an unbranded page stays byte for byte the same.
- The share-link tags render og:site_name from the product name, and fall back
  to the brand logo for og:image when the artifact carries no preview image of
  its own. A root-relative logo is resolved against BASE_URL, because the
  unfurler fetches it from its own host. An artifact with its own ogImage keeps
  it.

shells/frame.html picks up the last piece of viewer-visible wording: the
copy-link tooltip.

Two greps over shells/ now come back empty: the accent family (#c73d1d,
#f0502a, #ff7550, #7c2413, #ff9d80, #c4573e, #e08a72, #2ba3cc and the
rgba(240, 80, 42) washes) and the word "Artifact" in anything a viewer reads.
What is left in the shells is the neutral grey and near-black surface palette,
which accentColor is not meant to repaint, and the internal identifiers
(artifactTheme, #__artifact_error, artifact.tsx) that would break the shared
theme if they were renamed.

html and jsx artifacts are built once, at publish time, so they keep the
branding in force when they were published until they are republished. Their
frame rebrands anyway, being rendered per request. docs/api.md says so, and the
smoke suite asserts both halves.
Every fix keeps the all-empty-branding render byte for byte equal to what
origin/main serves, which is now pinned by a fixture test rather than by
value assertions alone.

F1 One accentColor filled both the light and the dark slot, so no value
worked in both themes. The *_DARK slots now lighten the accent with
color-mix(in srgb, <accent> 70%, white). #1d4ed8 md links go from 2.90:1
to 5.45:1 on dark, the 404 accent from 2.78:1 to 5.21:1, and the literal
fallbacks stay put when no accent is set.

F2 The unlock button hardcoded its label at #0b0d0f, which reads 1.00:1
on an accent of #0b0d0f and 2.90:1 on #1d4ed8. New BTN_FG slot returns
that literal when no accent is set, and otherwise picks #0b0d0f or
#ffffff by the accent's luminance: #1d4ed8 now reads 6.70:1 and #16264a
14.89:1. BTN_HOVER no longer returns the same value as BTN_BG, so
button:hover changes its background again.

F3 No max-width on any brand logo. All three logo rules gain a cap and
object-fit: contain. At 390px with a 1200x60 wordmark the frame logo
renders 120px instead of 400px, the page stops scrolling sideways
(scrollWidth 390, was 579), and both cards keep the logo inside the card.

F4 Resolved by F9: the 404 h1 no longer carries any operator text, so
there is no long token to overflow it.

F5 Adding a footer took the 404 description out of p:last-child and
restyled it. The footer's style block now restates the description's
rule, so with a footer set it stays 14px, muted and margin 0.

F6 The accent allowlist was shape-only. rgb(--), rgb(,,,,), hsl(+),
rgba(1,2) and hsl(1/2/3/4/5) all returned 200 and then voided the whole
declaration they landed in. Color functions are parsed now, both the
comma and the space form, and an accent with an alpha below 1 is refused
with a message saying why.

F7 The smoke branding block could not pass on a branded host and wiped
the operator's branding on the way out. It reads the stored block first,
skips the built-in-look assertions when the host is already branded,
puts the saved block back instead of clearing five fields, and asserts
the accent itself is back, not just the wording.

F8 logoUrl and faviconUrl took absolute URLs the page's own CSP then
blocked. They now take a same-origin path or a base64 data: image URI
(png, jpeg, webp, gif), the cap moves to 8192 chars for a small inline
image, and data:image/svg+xml is refused because an SVG runs script and
nothing here sanitizes one. Docs and .env.example say where a self-host
puts the file until T2.6.10 lands an upload.

F9 The product name was substituted as the noun for a published item,
so a viewer read "Dropkiln unavailable" on a dead link. The noun is the
literal artifact everywhere; productName reaches the frame chip,
og:site_name, the footer and the jsx error label only.

F10 The frame bar showed logo and wordmark at the same size with no
separator, and refused to shrink. The name drops when a logo is there to
carry the brand, and drops again under 560px when it is not.

F11 The footer was centered on the gate and left on the 404. Both left.

F12 Nothing guarded the byte-identical claim. test/fixtures/shells-default
holds the origin/main renders and one test fills each current shell with
the empty branding slots and compares.

F13 Two untested paths: a config.json with no branding key at all, which
is the real upgrade case, and the BRAND_ env vars .env.example documents.

F14 The accentColor message now lists #rgba, and the docs stop claiming
html artifacts carry stale branding: there is no html shell, so they
carry none.

Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
@kuyazee
kuyazee merged commit a952a26 into main Aug 29, 2026
7 checks passed
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.

1 participant