epic 2.6: the viewer chrome reads its brand from config - #54
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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 archivecheckout of mainwith the same
BASE_URL, then runningcmp: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 thefive main renders and
test/shells-default.test.jsfills each current shell with the all-empty slottable and asserts equality. It was proven to bite by appending one newline to
shells/md.html, whichturned that case red.
T2.6.8 - a branding block on the config endpoint
branding: {productName, logoUrl, faviconUrl, accentColor, footerText}onGET/PUT /api/config,mirroring the existing
frameblock: same validation shape, same persistence, same precedence(stored, then env, then empty). New module
lib/branding.jskeeps parsing and normalizing separatefrom 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/configisrequireAuth('full'). Checked against live scoped keys: unauthenticated 401,read403,publish403,full200, bootstrap 200.Every field is length-capped and validated, and a refused field rolls the whole PUT back rather than
half-applying.
normalizeBrandingre-validates on read, so a hand-editedconfig.jsoncannot smugglean 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 inlib/social.js. The badge and watermark overlay shellin 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
artifacthits are internalidentifiers only: the
artifactThemelocalStorage key, the#__artifact_errorid, anddata-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.mdand 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 metacontent=, inside<script>(the jsx error label, which goes throughJSON.stringify), and inside<style>. Each is escaped correctly for its own context, not merely escaped.lib/shells.jsdoes onepass 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
accentColorwas filling both the light and the dark slot, but the shipped shells carried alight/dark pair per surface precisely because one hue cannot serve both grounds.
#1d4ed8gave mdlight links 6.70:1 and dark links 2.90:1;
#ffe600gave dark 15.36:1 and light 1.27:1. The*_DARKslots now derive
color-mix(in srgb, <accent> 70%, white)when an accent is set and keep the literalwhen it is not.
#1d4ed8dark 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 a4.5:1 requirement, and a corporate navy gave 1.31:1, an invisible word on a dark rectangle. A
BTN_FGslot now picks
#0b0d0for#ffffffby the accent's relative luminance:#1d4ed8goes to 6.70:1 and#16264ato 14.89:1. SeparatelyBTN_BGandBTN_HOVERboth returned the accent, so hover lost itsbackground change entirely; hover now derives its own value.
The accent allowlist was shape-only, so
rgb(--),rgb(,,,,),hsl(+)andrgba(1,2)all returned200 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#0000computing every link and code ruleto fully transparent, and
color-mixmultiplying an accent's own alpha so anhsla(...,0.3)glowrendered 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 400pxwide, pushed
scrollWidthto 579, and put the Copy link and Hide buttons off screen; with a productname 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
scrollWidthback to 390 and the title unclipped. The frame bar alsoshowed 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-childstyled 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-foregroundrgb(31,35,40), margin0to16px 0, rewrapping twolines into three. The fix goes in
BRAND_STYLErather than on the paragraph, because adding a classto 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 andlib/branding.jseven commented "so a CDN works", butFRAME_CSPcarriesimg-src 'self' data:onall 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 wasalso inconsistent, because
?raw=1runs underimg-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
parseBrandUrltakes a single-slash path or a base64data:URI, and refusesabsolute URLs with a message naming the reason.
data:image/svg+xmlgets its own refusal: an SVGserved 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.examplehad been suggesting/brand/logo.svgand there is no static file route on this server, so that 404s on a stock install.
A product name is not a count noun.
productNamewas substituting the noun for a published item,so a white-label host served
<h1>Dropkiln unavailable</h1>on a dead link, which reads as "theservice is down" rather than "this link is wrong", plus "Protected Dropkiln", "This Dropkiln is
password protected." and "Copy link to this Dropkiln."
docs/api.mdblessed it explicitly. The noun isthe literal
artifactagain, everywhere, andproductNamereaches only the four places a brandbelongs: the frame chip,
og:site_name, the footer, and the jsx error label. Removing it from theh1also dissolved a separate finding, where a 40-character unbroken name overflowed the 404 card by180px.
The smoke block could not pass on a branded host, and wiped its branding. It asserted the target
renders
Artifact unavailableand--link: #c73d1d, both false on exactly the instances this featureexists for, then PUT all five fields to empty unconditionally. Since
fail()exits, any failure inbetween 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.widthsave-and-restore precedent. Proven both ways: on an unbranded host,212 ok-lines, exit 0; on a host pre-branded
Acme Docswith a purple accent and a footer, 211ok-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
accentColorerror message omitted#rgbawhile the codeaccepted it.
docs/api.mdgrouped html with jsx on build-time staleness, but html carries no brandingat all, so the sentence implied stale branding where there is none. Two coverage gaps closed: the real
upgrade path (a
config.jsonwith nobrandingkey, which is the case that decides "zero visualchange after upgrading", and the nearest existing test covered the opposite case), and the
BRAND_*env path, which
.env.exampledocuments and nothing pinned.Filed, not built. Three items appended to the backlog rather than folded in here:
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.
frameandmdand has no brandinginputs, 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 whetheranyone uses the feature.
Left alone deliberately. A light-theme accent can still read poorly against the light ground:
#ffe600is 1.27:1. The derivation is scoped to the dark slots on purpose, because the light slot isthe 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.the stored branding restored unchanged.
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