feat(shell): config-driven brand mark — c.brand_logo + c.topbar_brand dedup

Merged
MH
3mo ago
+773-40

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 — a DocsKit::BrandLogo value object accepting exactly one of five forms, rendered by the new shared DocsUI::Logo component inside both brand anchors (topbar h-6, sidebar h-7), falling back to the text c.brand. Unset config renders byte-identical markup.

    FormRenders as
    svg: path-d (landing-compat)inline <svg fill="currentColor">, theme-adaptive
    paths: [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> via image_url (not theme-adaptive, documented)
  • c.topbar_brand — :always (default, strict byte-compat) or :mobile_only, which adds lg:hidden to 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#logo now renders through the same DocsUI::Logo; LandingConfig#hero_logo normalizes through DocsKit::BrandLogo (LandingConfig::Logo stays as a constant alias), so c.landing.logo gains 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 + invalidation
  • spec/docs_kit/configuration_spec.rb — brand_logo nil default, memoized normalization, loud failure on malformed values; topbar_brand default/validation
  • spec/docs_ui/logo_spec.rb — per-form rendering, path-d attribute escaping, role="img"/aria-label/<title>, sized embed wrapper, raw-src degrade off-request
  • spec/docs_ui/shell_spec.rb / sidebar_spec.rb — byte-compat when unset (no <svg> in the brand anchor, no lg:hidden), mark inside the anchor, c.brand aria fallback, lg:hidden only on :mobile_only, version badge kept
  • spec/docs_ui/landing_spec.rb — hero-logo semantics pinned across the unification
  • spec/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::Logo as an alias if anything references it" — it IS referenced (landing_config_spec asserts the type), so the nested Data class was replaced with Logo = DocsKit::BrandLogo. That required brand_logo.rb to be eagerly required from landing_config.rb and loader.ignored in lib/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_section and Landing#logo were unchanged, so the plan held.
  • Landing's src: image form previously raised NoMethodError in a view-context-less render (image_url on a nil view_context). The shared renderer degrades to the raw src off-request — the DocsUI::MetaTags posture — so that latent bug is fixed as a side effect.
  • Landing's inline hero svg emitted a lowercase viewbox attribute; the shared renderer emits the correct viewBox casing (the BrandMark posture). Browsers normalize both identically, but the emitted bytes differ for sites using c.landing.logo's inline form (semantics pinned by spec).
  • The landing hero now passes h-9 w-auto text-primary to BOTH forms; the old code gave the <img> form h-9 w-auto only. text-primary on an <img> is inert.

Judgment calls

  • BrandLogo.from raises ArgumentError on an empty/ambiguous Hash (no form key, or several mixed). The old LandingConfig::Logo.from silently built an all-nil value that rendered a broken <img src="">. Loud-at-config-time matches the on_page/openapi_document posture but is stricter than the old landing behavior.
  • label/alt fall back to each other so a site setting either gets an accessible name in every render form; both unset falls back to c.brand at render time.
  • Configuration#brand_logo memoizes its normalized value (invalidated on reassignment), unlike #app_link's rebuild-per-read — a file: 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 a span wrapper carrying the caller's sizing plus literal inline-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, .svg extension, <svg shape) eagerly at normalize time and re-reads on mtime change — the openapi_document posture.