feat(shell): config-driven brand mark — c.brand_logo + c.topbar_brand dedup
Closes #64. Implements the plan in https://github.com/mhenrixon/docs-kit/issues/64#issuecomment-5172789782.
Summary
Sites wanting their own mark in the shell chrome had to prepend copies of Shell#topbar / Sidebar#header_section — copies that silently went stale on upgrades (a 1.0.6-era copy swallowed 1.0.7's app_home_link). This PR makes the mark a config knob:
-
c.brand_logo— aDocsKit::BrandLogovalue object accepting exactly one of five forms, rendered by the new sharedDocsUI::Logocomponent inside both brand anchors (topbarh-6, sidebarh-7), falling back to the textc.brand. Unset config renders byte-identical markup.Form Renders as svg:path-d (landing-compat)inline <svg fill="currentColor">, theme-adaptivepaths:[d1, d2, …]same, multi-path wordmark markup:full<svg>stringembedded verbatim in a sized wrapper file:path to a.svgfile content embedded inline, re-read on mtime change src:asset path/URL<img>viaimage_url(not theme-adaptive, documented) -
c.topbar_brand—:always(default, strict byte-compat) or:mobile_only, which addslg:hiddento the topbar brand anchor: at the drawer-pinned breakpoint the sidebar brand is already visible, so the topbar copy is a duplicate. -
Landing unification —
Landing#logonow renders through the sameDocsUI::Logo;LandingConfig#hero_logonormalizes throughDocsKit::BrandLogo(LandingConfig::Logostays as a constant alias), soc.landing.logogains all five forms for free.
Escaping posture: svg:/paths: render every path-d as an ordinary Phlex-escaped attribute (covered by a spec asserting an injected " cannot break out). markup:/file: deliberately embed site-authored markup — the site's own initializer/asset, the same trust domain as its own views — and both are shape-checked to be an <svg> element at config time with a loud ArgumentError otherwise.
Generator: the initializer template documents both knobs (commented, opt-in); docs-kit new runs the same generator. README gains a "The brand mark" section.
Test plan
spec/docs_kit/brand_logo_spec.rb— the five forms, exactly-one-form +<svg>shape guards, label/alt fallback, file mtime memoization + invalidationspec/docs_kit/configuration_spec.rb—brand_logonil default, memoized normalization, loud failure on malformed values;topbar_branddefault/validationspec/docs_ui/logo_spec.rb— per-form rendering, path-d attribute escaping,role="img"/aria-label/<title>, sized embed wrapper, raw-src degrade off-requestspec/docs_ui/shell_spec.rb/sidebar_spec.rb— byte-compat when unset (no<svg>in the brand anchor, nolg:hidden), mark inside the anchor,c.brandaria fallback,lg:hiddenonly on:mobile_only, version badge keptspec/docs_ui/landing_spec.rb— hero-logo semantics pinned across the unificationspec/generators/install_generator_spec.rb— the generated initializer documents both knobs
bundle exec rake: 855 examples green, RuboCop clean, coverage above the 80% floor. All sizing classes (h-6/h-7/w-auto/lg:hidden, the [&>svg]:* wrapper variants) are literals in scanned gem .rb files — no @source inline addition needed.
Deviations & judgment calls
Deviations
- The plan said "keep
LandingConfig::Logoas an alias if anything references it" — it IS referenced (landing_config_specasserts the type), so the nested Data class was replaced withLogo = DocsKit::BrandLogo. That requiredbrand_logo.rbto be eagerly required fromlanding_config.rbandloader.ignored inlib/docs_kit.rb(the alias resolves at require time, before zeitwerk is set up) — the existing seo_config/landing_config pattern.
Discoveries (behavior deltas folded into the unification)
- The worktree base (
f942145, PR #66 sidebar flattening) is newer than the plan's investigation snapshot;Sidebar#header_sectionandLanding#logowere unchanged, so the plan held. - Landing's
src:image form previously raisedNoMethodErrorin a view-context-less render (image_urlon a nil view_context). The shared renderer degrades to the raw src off-request — theDocsUI::MetaTagsposture — so that latent bug is fixed as a side effect. - Landing's inline hero svg emitted a lowercase
viewboxattribute; the shared renderer emits the correctviewBoxcasing (theBrandMarkposture). Browsers normalize both identically, but the emitted bytes differ for sites usingc.landing.logo's inline form (semantics pinned by spec). - The landing hero now passes
h-9 w-auto text-primaryto BOTH forms; the old code gave the<img>formh-9 w-autoonly.text-primaryon an<img>is inert.
Judgment calls
BrandLogo.fromraisesArgumentErroron an empty/ambiguous Hash (no form key, or several mixed). The oldLandingConfig::Logo.fromsilently built an all-nil value that rendered a broken<img src="">. Loud-at-config-time matches theon_page/openapi_documentposture but is stricter than the old landing behavior.label/altfall back to each other so a site setting either gets an accessible name in every render form; both unset falls back toc.brandat render time.Configuration#brand_logomemoizes its normalized value (invalidated on reassignment), unlike#app_link's rebuild-per-read — afile:mark reads and shape-checks its SVG on build, so per-render re-normalization would repeat IO. RuboCop's memoized-ivar-name cop forced the@brand_logo_raw(writer) /@brand_logo(memo) naming.- The
markup:/file:embed renders inside aspanwrapper carrying the caller's sizing plus literalinline-flex [&>svg]:h-full [&>svg]:w-auto, so the site's own<svg>fills the surface without its markup being rewritten. - A
file:mark validates (exists,.svgextension,<svgshape) eagerly at normalize time and re-reads on mtime change — theopenapi_documentposture.