Skip to content

Hoist a branding anchor out of the promoted navigation - #887

Open
borkweb wants to merge 7 commits into
trunkfrom
fix/nav-brand-hoist
Open

Hoist a branding anchor out of the promoted navigation#887
borkweb wants to merge 7 commits into
trunkfrom
fix/nav-brand-hoist

Conversation

@borkweb

@borkweb borkweb commented Aug 17, 2026

Copy link
Copy Markdown
Member

What

A nav container that authors a branding anchor beside its link cluster is three
elements — the landmark, the brand, and the menu — each carrying its own CSS rule.
The transformer promoted all three into one core/navigation, which made the brand
a menu item. Three consequences followed from that single collapse:

  • The landmark's own box rules and the menu list's rules ended up selecting the
    same element, because the list's className was copied onto the nav
    container. An authored header nav{padding:22px} (specificity 0,0,2) then lost to
    a .navlinks{padding:0} (0,1,0) that was never meant to apply to the landmark.
  • The landmark lost its second flex child, so an authored
    header nav{justify-content:space-between} became a no-op.
  • The brand emitted anchorClassName, which core/navigation-link does not
    register. A consumer that validates comment attributes stops transforming the
    whole part on that attribute, so every other class on the saved wrapper never
    arrives.

The deferral guard that should have prevented the collapse only fires when a
direct-child anchor's class matches a hard-coded vocabulary
(brand|branding|logo|site-title|site-name|home-link|home-logo) and a
direct-child <ul>/<ol> parses as nav items. A brand class outside that
vocabulary, or a link cluster authored as a <div>, slipped straight past it.

How

NavigationPattern::match() now emits the landmark as a core/group carrier
holding the brand block and a core/navigation built from the link cluster alone.

  • Structural detection. The brand is found as a direct-child anchor outside the
    link cluster — exactly one anchor, exactly one cluster, no other significant
    children — so no class vocabulary decides whether it survives. The next designer
    who picks a different class name does not reopen the defect.
  • The cluster owns the navigation block. core/navigation takes its
    presentation from the element it stands in for, so the menu list's className
    and gap stay with the menu instead of being copied onto the landmark. The
    transformer's own hooks — the injected blocks-engine-list-navigation class and
    the blockGap: 0px default for list sources — are unchanged.
  • The brand is converted, not rebuilt. PatternContext gains a nullable
    convertElement callback so the anchor is emitted exactly as the generic
    pipeline emits it anywhere else in the document, keeping its own class and its
    inline-format projection. A brand that would only convert to an HTML fallback
    makes the carrier bail, so the change can never trade a menu item for raw markup.
  • The existing guard still runs first, so every container it already defers
    keeps today's behavior. The carrier covers exactly the containers that would
    otherwise absorb their brand.

With the brand out of the link set, commonNavigationLinkTextAttributes() sees a
menu whose links all share one colour and promotes it to the navigation block as
customTextColor, which core honours — the authored link colour used to be
discarded because the brand, with no colour of its own, was the first link.

Tests

  • tests/unit/navigation-brand-anchor-hoist.php — 17 assertions: the unlisted
    brand class, the <div> link cluster with no list, the className placement, and
    a control proving a menu with no brand still promotes to a single navigation
    block with no carrier group.
  • tests/fixtures/parity/html-nav-brand-anchor-hoist.json — product-neutral header
    shape pinning the emitted topology.

Committed red first: at the test-only commit the unit contract reported 10 failed /
7 passed and the fixture failed at blocks.0.innerBlocks with 4 children instead
of 2.

No existing fixture was modified and no threshold was relaxed.

…ed navigation

A nav landmark that authors a branding anchor beside a menu list collapses into
one core/navigation with the brand as an extra menu item. The deferral guard only
recognises a brand anchor whose class matches a hard-coded vocabulary, so a class
outside that list leaves the collapse in place. Two consequences ride along: the
brand emits `anchorClassName`, which is not a registered core/navigation-link
attribute, and the menu list's className is copied onto the nav container where it
outranks the landmark's own padding rule.

Red first: the unit contract reports 10 failed / 7 passed and the parity fixture
fails at `blocks.0.innerBlocks` with 4 children instead of 2.

Covers gates N3 and N4.
Pattern recognizers could convert an element's children but not an element
itself. A pattern that keeps a sibling out of its own block — a branding anchor
beside a menu, for example — needs that sibling emitted exactly as the generic
pipeline would emit it anywhere else, not rebuilt by hand from inner HTML.

Adds a nullable `convertElement` callback to PatternContext and wires it to the
element conversion entry point. No behavior change: nothing consumes it yet, and
the probe context leaves it null.
A nav container holding a branding anchor beside its link cluster authors three
elements, each with its own CSS rule. Promoting all three into one
core/navigation made the brand a menu item, so the landmark's own box rules and
the menu list's rules competed on a single element and the brand emitted
`anchorClassName` — an attribute core/navigation-link does not register, which
stops a consumer's block transformation on the whole part.

The landmark is now emitted as a core/group carrier holding the brand block and a
core/navigation built from the link cluster alone. The brand is detected
structurally — a direct-child anchor outside the cluster, one anchor and one
cluster, no other significant children — so no class vocabulary decides whether
it survives, and it is converted by the generic pipeline rather than rebuilt from
inner HTML. The cluster owns the navigation block's presentation, so the menu
list's className stays with the menu.

The existing deferral guard still runs first and keeps the shapes it already
recognises unchanged. Any container the carrier cannot fully account for falls
through to the previous behavior.

Closes gates N3 and N4.
`navigationBlocks()` refuses to promote a container whose skipped chrome is a
runtime DOM target, because a promoted navigation would drop an element that
scripts drive. The brand carrier skipped chrome without that check, so it could
have promoted a container the sibling path would have deferred.

Mirrors the existing guard rather than inventing a second policy.
The carrier reused the deferral guard's rule that a branding anchor must hold no
block-level markup. That rule exists because the guard's sibling path flattens the
anchor into a menu item label, where a heading or div would be stripped. The
carrier converts the anchor instead, so the restriction only kept a
`<a class="wordmark"><h1>…</h1></a>` lockup absorbed into the menu — emitting the
unregistered `anchorClassName` and losing the heading.

Verified against the parent commit: both a heading lockup and a two-div lockup went
from one absorbed menu item to a converted brand block, `anchorClassName` 1 -> 0.
…f a menu

Structural position alone does not identify a brand. A nav that authors an
ordinary menu link outside its list — `<nav><a>Home</a><ul>…</ul></nav>`, a
common hand-authored shape — had that link hoisted into a sibling block: it left
the editable menu, lost the promoted shared colour, and stopped counting as a
menu item.

The anchor must now also read as branding: a lockup built from element children,
or an explicit brand/logo cue. Every shape the carrier targets qualifies — a
name-plus-place lockup, a heading lockup, a single-line wordmark — while bare
anchor text stays a menu item. A fixture pins that bare-text shape to the
behavior it has without the carrier.

The carrier is also restricted to a real `<nav>` landmark rather than any
navigation-signalled container, which removes an untestable branch: a consumer's
raw-anchor link resolution scopes by lexical `<nav>` ancestry, so a brand hoisted
into a `div` carrier would fall outside both that pass and the block pass that
rewrites navigation links, losing coverage the folded shape had. The reason now
sits in the comment beside the tag, with a fixture pinning the nested landmark as
harmless: core/navigation is dynamic, so saved content still holds one `<nav>`.

Cheap structural checks now run before either conversion, so a bail no longer
leaves provenance and runtime-island records behind for output never emitted. The
comment no longer claims more coverage than the code has — an anchor holding only
an image with no accessible name is still classified as chrome before it reaches
the brand test.
Semantic parity counts every anchor under a source nav landmark on one side and
only core/navigation children on the other. Hoisting the brand out of the menu
therefore read as content loss: the azure-garden header went from parity `pass` to
`warning` with `navigation_item_count_mismatch` (source 6, block 5) — a false
positive on the exact shape the hoist exists to fix, and one the parity fixture's
`status == "success"` assertion could not catch.

When a navigation block's landmark is a `core/group{tagName:"nav"}` carrier, the
blocks hoisted out beside it now contribute their anchors to that menu's item
list. Counting them here rather than excluding them on the source side keeps a
brand that disappears entirely detectable as real loss.

Two existing parity policies already exclude outside anchors on the source side —
a landmark bearing mobile chrome, and one holding both a brand and a CTA beside
its list. The carrier's items are only added when neither applies, so the same
anchors are never counted on one side while being deliberately dropped on the
other. The fixture now pins `semantic_parity == "pass"`.
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