Shell and navigation contract
This contract describes the v1.2.0 release. Its canonical bilingual sources
are in content/docs/design/.
Authorities and navigation
| Concern | Authority |
|---|---|
| Global navigation | Hugo menus.main |
| Docs / Book sidebar and pager | content tree or data/docs_nav.json |
| Root switcher | resolved top-level content roots |
| Discovery | per-language local search index |
| Page and Palette actions | shared action registry |
No feature introduces another menu or page tree. One menu child level is
interactive; deeper levels warn and flatten beneath linked group headings.
External links use target="_blank" rel="noopener noreferrer"; internal links
remain language- and subpath-aware.
Navbar desktop and drawer views project one tree, and every dropdown panel is
one moderate column of icon-and-title rows — the mega panel and its columns
menu parameter are retired, and a configured columns warns while keeping the
single column. Menu descriptions are configuration data only. The link tree
stays
true-centered at every width: text links from lg, icon links below. Between lg
and md the end edge keeps search, version, language, theme, and GitHub with no
menu button. Below md appearance stays beside search and the drawer entry; version,
language, and GitHub move to the footline dock. Home or
explicit Landing pages add one drawer entry beside search that opens the full
labelled tree. Shell pages with a sidebar open its drawer in that position
instead; no drawer button is shown from md upward. Language
links target the
page translation or that language’s home, stay relative when languages share a
host/base path, and become absolute only for language-specific baseURLs;
hreflang stays absolute and lists only actual translations, never the
language-home fallback offered by the switcher. Paginated blog indexes use
their own canonical URL; later pages omit cross-language alternates because
translated archives need not have matching page boundaries.
navbar_autohide applies to fine pointers from
768px, never touch or drawer widths, and the hidden bar keeps its slot: the
layout reserves the navbar band in both states, a pinned bar occupies exactly
that band with its rule inside it, revealing fades the bar in place without
covering resting content, and hero pages ignore the policy in favour of their
overlay bar. The home page owns the same soft boundary a hero page does: its
navbar carries no bottom rule and no scrolled shadow, resolving into a short
wash below the bar instead.
Sidebar and pager share root and order. manual_link, build.render: link,
dividers, hidden nodes, and placeholders retain their documented semantics.
sidebar_icon_policy is all (default), groups, or none; icons are one
Font Awesome class pair. Invalid policies follow the shared warning/fallback
contract. At sidebar_cache_limit, the two walkers may reuse neutral markup
only for the same language, navigation root, and output-affecting effective
settings. That markup remains visible without JavaScript; the normal shell
runtime adds the active path. A Book page that emits sidebar_headings stays
page-specific and bypasses the shared tree cache.
Root candidates are linkable, non-divider top-level sections followed by sidebar_root_for: self
sections, deduplicated by URL. Both sources honor explicit
sidebar_root_menu: false; absent/true preserves inclusion. The current
resolved root is appended even when excluded from global choices. Zero entries
emit no control; one emits a static link. Language and deployment prefixes stay
on every URL. A divider or a section with build.render: never cannot become a
switcher link.
A sidebar_divider leaf retains its static heading. A divider section retains
its children in both sidebar walkers, with a non-link label and a real
disclosure button when folding is enabled. It never becomes a pager target;
its children retain their positions. Pair it with build.render: never to
omit the section’s own outputs without hiding its descendants. Breadcrumbs
render its label without a link, search omits it, navigation JSON hoists its
children, and Print keeps the child documents. Book TOCs retain the group label
and child links, but omit headings from the unpublished group body. toc_hide still hides the
whole subtree and is not a grouping option. Explicit navigation keys are
language- and deployment-independent paths; rendered links retain both prefixes.
An explicitly empty navigation sections array warns and falls back to the
content tree in every navigation output. Both authorities prune toc_hide
subtrees, and navigation JSON preserves manual_link_relref as an internal
link to its resolved destination rather than a page identity.
Sidebar runtime
OINK 1.1.0 provides this disclosure API and explicit hidden-content isolation. Version 1.0.0 does not provide the API.
window.OinkSidebar owns registered tree disclosures and movable TOC,
backlink, and taxonomy groups, independent of their current DOM parent.
setExpanded(id, boolean, {source}) returns true for a valid target and false
for unknown IDs or non-boolean values. getState(id) returns a fresh
{id, expanded} snapshot or null. IDs are the existing aria-controls region
IDs; arbitrary elements outside the registered OINK groups cannot be changed.
Sources are user, active-path, responsive, and api (default). Every
writer commits aria-expanded, td-is-open, the localized label, and the
region’s inert state before one oink:sidebar-disclosure document event with
detail: {id, expanded, source}. Repeated state writes emit no event. API
restoration keeps current-path ancestors expanded; explicit user disclosure
can still collapse them. Closing a region containing focus returns it to the
toggle before isolation.
ready is a Promise resolving to the API after initial hydration and responsive
placement; isReady and oink:sidebar-ready also expose completion to late
consumers. Optional persistence belongs to the site: await ready, read storage
inside a try/catch, and restore valid region IDs through the setter. OINK owns
whole-column collapse, width, and scroll persistence, not a version/locale
schema for reader-selected branches.
Desktop collapse and a closed mobile drawer make panel content inert and mark
the panel aria-hidden. Focus leaves before isolation; opening clears it
before focus enters. The panel itself remains the 16px pointer sensor, and the
external restore control remains active. Hover, Escape, backdrop dismissal,
breakpoint cleanup, and scroll unlocking retain their existing behavior.
These runtime attributes are not emitted into the no-JavaScript fallback.
Whole-column TOC collapse also isolates its hidden panel. If the collapsing control held focus, focus moves to the visible floating restore button; restoring the column returns focus to its visible column control. When the aside moves into the mobile sidebar, its former column isolation is cleared before the drawer owns interaction. The drawer’s Tab trap includes only rendered, non-inert controls, excluding hidden or collapsed descendants.
Immersive blog presentation
There is no article type or second shell. Immersive reading is four independent keys on the ordinary blog shell, set on a page or section cascade; the section index repeats values it also needs:
The blog shell renders no breadcrumb by default—an article reads as a
standalone piece—so the recipe needs no key for it. breadcrumb remains an
ordinary key a page or cascade may still set either way, on any shell.
hero uses the shared featured image as a decorative full-bleed backdrop on
single pages and section indexes. With no image it renders the normal opening;
banner and wash remain single-page modes. The navbar overlays a hero on a
contrast scrim and scrolls with it.
toc_style is fixed or flow; flow places a wider rail beside the article
and pins it only after scrolling. Its resting place aligns with the article’s
info line, or its description where a page has no info line. docs-shell.js
measures the offset because a title wraps to an unknown number of lines;
without JavaScript the rail starts where the article starts.
toc_taxonomies: false removes term clouds; a rail with neither TOC nor clouds
renders nothing. notoc remains the page-level TOC opt-out. These switches do
not change bylines, tags, series, pager order, feeds, or page-end composition,
and the rail disappears below the xl breakpoint.
Search, actions, and runtime
params.offline_search opts into a local per-language index. When enabled it
also builds under hugo server by default; set offline_search_on_serve: false
for large edit loops. HTML search appears on Home, shell pages, and Landing when
landing_search is enabled. Other non-shell pages and Print omit the dialog,
Lunr, and Palette.
Search metadata is search_keywords, search_boost (default 1), and
search_exclude. The index carries URL, title, taxonomies, excerpt, headings,
description, body/summary, root, section, type, keywords, boost, breadcrumb,
and icon. Fixture budget is 2 MiB raw / 512 KiB gzip. Sites may return extra
strings from hooks/search-keywords-extra.html. Keywords affect matching and
ranking; CJK keyword-only matches display the page description or excerpt, not
the keyword list. Body matches retain their surrounding text as context.
Built-in action IDs are copy_markdown, copy_link, open_chatgpt,
open_claude, view_markdown, view_history, edit_page,
create_child_page, create_issue, create_project_issue, print_section,
print, switch_preset, switch_theme, switch_language, switch_version, and
open_github. copy_link is Palette-only outside the share bar. Site commands
under languages.<lang>.params.ui.command_palette.commands may open a safe URL
or invoke a built-in ID, never inject JavaScript. The legacy clipboard fallback
restores the previous focus and selection, including its direction, without taking focus back if another
control acquired it during the copy operation.
Edit, history, and create-child actions require a repository-relative source
file. Physical filenames and the site working directory use normalized /
separators before containment checks. path_base_for_github_subdir matches
that normalized path: relative to the working directory for local content,
absolute for an external mount. A string regex removes its matches; a
{from, to} mapping may replace it. External sources require an explicit
match. After mapping and path cleanup, empty paths, ., absolute or
drive-qualified paths, and paths starting with .. as a segment suppress
these three actions. Docs and project issue actions remain available under
their existing repository settings. Windows mappings must match / rather
than \; normalization does not change filename case.
The Palette has empty, text-search, and > command modes; quick links derive
from navigation. It has no history, semantic search, personalization, or remote
fallback. Search queries stay in-browser and no default telemetry is sent.
OinkSurfaceCoordinator arbitrates Palette, drawer, root, language, and version
menus. Surfaces own focus restoration and Escape. Keyboard navigation ignores
editable controls and modals; Ctrl/Cmd+K also yields to another open dialog,
including fixed-position ARIA dialogs.
/, \, f, c open search/commands; j/k
move headings; q/e move pages; h changes presentation; l/y, t, and
r open language, theme, and root choices. Sidebar WASD/Arrow navigation uses
real focus without rewriting Tab order.
Non-link divider buttons participate in this tree navigation. Left/a from a
child first focuses its parent group, then a second press folds it; Right/d
opens a closed group or enters its first visible child when already open.
Previous/next page navigation still considers links only, never group buttons.
The outline derives cursor and visible-heading range from one heading model and
the scroller’s computed scroll-padding-top; its SVG line and dot share the
same animated values so they cannot drift. URL fragments are decoded when
valid; malformed percent sequences fall back to the literal heading ID, both
when indexing links and when selecting a requested heading near the page end.
No speculative DOM repair pass is
allowed. This tracking is always owned by the normal shell runtime.
params.ui.scroll_spy and the page key scroll_spy are quiet compatibility
no-ops throughout 1.x, emit no separate runtime, and may be removed only in a
future breaking release.
Search-tail extensions
This API is included in OINK 1.1.0 and absent from version 1.0.0.
Trusted site JavaScript may call
OinkCommandPalette.registerSearchTail({id, rows, activate}); YAML and the
action manifest remain data-only. The bundle stays conditional on local search.
Registration requires a unique ID matching [A-Za-z0-9][A-Za-z0-9_-]* and two
functions; invalid or duplicate registrations throw. The returned unregister
function is idempotent and cannot remove a later registration reusing the ID.
Live changes schedule one owned render; removal cancels that provider’s pending action.
rows(context) synchronously returns descriptors. Context is a frozen snapshot
{query, locale, phase, pageResultCount}: query is trimmed; locale is the HTML
language tag; phase is results, empty, or error; count covers only local
page results after the limit. Providers run only for settled, non-empty text
search, never empty, command, choice, or loading states. Rows follow all native
results and actions in the localized Actions group, in registration order.
Native empty/error messages and input-triggered index retry remain available.
Each descriptor requires a unique per-provider id with the same ID syntax and
a non-empty string title. Optional description, icon, and disabledReason
are strings; available is boolean, default true. OINK copies and freezes these
fields and renders display strings as text. Invalid descriptors, duplicate IDs,
an asynchronous return, or a thrown callback discard that provider for the
render without affecting other providers. No callback-count promise is made.
activate(row, context) runs only through ordinary row activation. It receives
the copied descriptor and its original context plus an AbortSignal and a
handoff() function. Pending activation blocks all other row activations,
including entry into native choice menus. Synchronous throws
and rejected promises release pending state, keep the Palette open, and announce
the localized action-failed message. Fulfillment values are ignored; success
closes the Palette without stealing focus from another surface. Closing,
reopening, changing the rendered query, or unregistering cancels pending work;
late settlement cannot modify a newer session.
Before opening another coordinated surface, call context.handoff(). It closes
the Palette without returning focus or aborting that activation. The consumer
then owns the new surface’s focus and error UI. A later Palette session or
unregistration can still cancel unfinished work; successful completion does not
abort a handed-off operation. OINK imposes no timeout.
rows() must remain pure. This is a trusted-code contract, not a sandbox. The
default query remains local, with no remote provider or telemetry bundled.
Any extension network behavior and provider consent belong to the site.
Share
params.ui.share is empty by default and accepts any ordered subset of 16
targets: x, bluesky, mastodon, facebook, linkedin, reddit,
hackernews, telegram, whatsapp, line, pinterest, weibo, chatgpt,
claude, email, and copy. A page list replaces its inherited list;
share: false opts out. Unknown entries warn and are dropped. Only regular
pages render the bar; print, Markdown, and RSS omit it.
Targets are plain intent links carrying the page permalink/title, plus the
local copy_link button. Pinterest media comes from the shared featured-image
resolver. ChatGPT and Claude receive build-time permalink prompts and are
independent of page-menu assistant actions. Discord has no public intent target
and is deliberately absent.
The bar loads no platform SDK, iframe, script, stylesheet, counter, or campaign
parameter and makes no request until a reader activates a link. It is one
accessible labeled glyph row. share/items.html resolves targets and
share/bar.html renders them.
Annotation
Page annotation resolves descriptors in annotation-items.html and renders
them through page-meta-lastmod.html; either may be overridden narrowly. Lines
appear in this order:
| Line | Condition |
|---|---|
| Last modified | Lastmod is set |
| Upstream | front matter upstream_link is non-empty |
| Translation | configured authoritative language has a translation and this page has authored text |
upstream_link is per-page; a cascade counts, and upstream_link: "" opts out.
Other upstream facts resolve site params → data/upstreams[upstream_source] →
front matter: upstream_name, upstream_copyright, upstream_license,
upstream_notice, optional upstream_ref, and upstream_modified. The first
four are required with a link. Invalid or incomplete attribution warns and
emits no legal notice; unsupported URLs are refused. Publication gates reject
the warning with --panicOnWarning.
upstream_modified changes the credit verb and links commit history; it adds no
line. The notice page carries full license/warranty text. Translation notice is
opt-in through params.ui.translation_notice, cascades as the page key
translation_notice, skips generated or bodyless pages, and can be disabled on
a natively authored page with translation_notice: false.
Authors and series
A blog article head is title, info line, term badges, byline, then the series
strip; the description leads the body below them. The info line
(article-info.html) always carries the date; with reading_time on it adds
the word count and the minutes. Front matter upstream_link—the same per-page
fact the annotation attributes—adds a localized link to the original, gated by
the shared URL policy. Term rows are bare badge runs whose taxonomy name lives
on the group label, not as a visible prefix. At rest a term badge is a pale
neutral chip with muted ink, led by the taxonomy’s term glyph; a linked badge
picks up the current section’s accent wash, border, and ink on hover or focus.
taxonomy-icon.html owns the vocabulary—each taxonomy pairs a whole-taxonomy
glyph with a term glyph (folder-open/folder, tags/tag, cubes/cube,
users/user-pen, book-bookmark/book for series, generic shapes)—and
params.ui.taxonomy_icons overrides a pair with one string for both surfaces
or a taxonomy/term map; unusable input warns and keeps the built-in. The
right-rail cloud wears the whole-taxonomy glyph on its head alone: cloud chips
stay text plus count, because repeating the glyph beside an announced taxonomy
is noise. A standalone taxonomy directory card carries one term glyph; the
byline carries the people
alone—portrait, name, and the profile’s one-line bio—with no label and no date.
List rows, cards, and term archives share one metadata line of the same shape:
date, one localized author-and-section phrase, then word count and minutes
behind the same reading_time switch. Under that sentence sits one wrapping
badge line with every taxonomy’s terms, taxonomies in alphabetical order, each
badge wearing its term glyph; cards leave out authors, whom their sentence
already names.
Authors activate only through taxonomies: {author: authors}. The profile term
page owns display name, summary, body, and featured-image avatar; an absent
profile falls back to link title, initial, and archive. authors-resolve.html
preserves front-matter order for article heads, list rows, and one RSS
dc:creator per author. Legacy author remains unchanged when authors is
absent; when both exist, authors wins without warning. Custom author taxonomy
plurals behave as ordinary taxonomies.
Series activate only through taxonomies: {series: series}. Term pages own the
introduction; no parameter, data file, cover model, or runtime is added. A page
uses series: [name] and optional series_weight. series-pages.html orders
weighted members first by weight, then unweighted members by ascending date,
with Path tie-breaks; strip and term page share it. The first named series gets
one HTML/print strip. The panel is translucent over a blur rather than an opaque
card, because a hero article paints its featured image behind this band and an
opaque ground would punch a hole through the picture; on a plain article the tint
resolves to the page’s own ground, so one treatment serves both. Its summary owns
the full bar and trailing caret, while the series name – its taxonomy icon
included – remains a sibling link laid over a hidden width reservation so the
summary never contains a nested interactive control. Opening the bar rules a
hairline under it and places the reading order in one adaptive grid on the same
surface, preserving DOM order. Every member link owns its ordinal, set at the end
of a fixed square track so the titles hold one edge at any list length; equal
cells stay one column when narrow and add columns only while each title retains a
readable measure, so a desktop panel uses its width without stretching one
selected row across it. Hover and the reader’s own place borrow the two grounds
sidebar navigation already uses for those states, and the current member adds a
filled ordinal and a heavier title, so the cue is never colour alone. Print shows the same list expanded in one column. Singleton
series and non-HTML outputs omit it. Numbering, cross-references, and aggregate
output remain Book concerns.
The default article taxonomy chips omit reserved authors and series because
their dedicated surfaces already carry them. Explicit
params.taxonomy.page_header restores either.
Blog indexes and page composition
Blog section indexes use params.ui.blog_index: list (default) and cards
are one flat run, newest first, sharing blog_index_size pagination—the
metadata line’s dates make year headings redundant; table shows the whole
section as date/title/tag rows without pagination. Cards use the shared lead
image, localized date/author/section metadata, tags, and a three-line summary.
A taxonomy page (/tags/, /authors/) and its term pages share one head,
shell/taxonomy-head.html. The taxonomy page opens with the whole-taxonomy
glyph in a tinted tile, the localized name, and a count of terms. A term page
opens with the term’s title and its page count from ui_taxonomy_pages, using
the current locale’s CLDR plural form; where no breadcrumb is rendered, a kicker above the
title names the taxonomy and links back to it, since an enabled trail already
does both one line higher: a crumb standing for a generated taxonomy page
borrows the same localized label the head renders, not Hugo’s plural title.
Under the head the taxonomy page lays its terms out as a grid of one-line
cards, shell/taxonomy-cards.html, most-used first with alphabetical ties—the
order the rail cloud already uses—filling equal columns by auto-fill so a
short taxonomy never stretches two cards across the page. A card is the term
glyph, the term, and its page count, and the whole card is the link; authors
alone lead with the byline’s small portrait through the same avatar partial.
No card carries a description or a newest page: a term has nothing to say
that its title and count do not, and the extra line only blurred the grid.
There is no filter chip row and no “All” chip: the section root already lives
in the sidebar and the navbar. Term pages stay row lists, and author profiles
keep their own head.
The rail on a taxonomy or term page leads with shell/taxonomy-switcher.html:
one row per declared taxonomy—whole-taxonomy glyph, localized name, term
count—linking to its index page, the current taxonomy on the selected ground.
It is the way from one taxonomy’s pages to another’s, because cloud chips jump
to terms and cloud heads only collapse; a site with one taxonomy renders no
switcher. The group sits behind the same toc_taxonomies switch as the clouds.
A taxonomy page scopes its clouds to the whole site (taxonomy-root.html
returns no root for that kind) and omits its own cloud, whose terms are the
cards beside it; term pages keep the section scope and the full set.
params.ui.blog_index_toggle renders all three forms for the current paginator
slice and lets readers cycle them. The configured form controls first paint and
hidden forms load no images. A reader’s stored choice is scoped to indexes that
publish all three forms: a section whose toggle is off publishes one form and
always shows it. A front-matter value or cascade overrides the site mode per
section. A table published without the toggle remains a complete, unpaginated
archive.
params.logo is always the brand mark; params.wordmark, or the site title, is
the text half hidden at compact widths. Docs, Book, Blog, and Swagger share one
shell model. Page-end order is Share, Feedback, Annotation, Pager, Comments.
Docs/Book pager follows sidebar preorder; Blog uses weight then reverse date;
pager: false opts out. Static outputs omit pager UI.
Every rendered footer style keeps an icon-only utility dock at the end of its
bottom line: version, language, theme, then keyboard help. Its menus open upward;
the version trigger never exposes the current branch or release label. The fat
footer’s collapse chevron follows the dock. Below lg the bottom line gives up
its copyright/center/dock columns and stacks them as three centered full-width
rows, the dock last. These global controls do not render in the sidebar footer,
and footer_style: none removes the whole bottom line.
There is no archive shell, arbitrary-depth flyout, second navigation authority,
query upload, or browser compatibility shim for removed config. Feedback emits
only docs_feedback through an existing gtag, stores the choice locally, and
does not replace Giscus.
Verification
bin/check-navigation-contract.py, bin/check-shell.py, JS tests, output
goldens, and the consumer browser suite cover navigation, language/subpath
links, blog variants, page-end order, keyboard behavior, accessibility, and
responsive layout.
Appearance control
The navbar and footer dock share one click/keyboard disclosure. The Landing
mobile drawer also provides a labeled Appearance row. The panel offers native
Style radios when preset_menu allows a choice and native Light/Dark/System
radios when dark_mode.show_menu is enabled. Selection is immediate and keeps
the panel open. Enter, Space, or ArrowDown opens it; arrow keys select within
a group; Tab moves between groups; Escape closes and returns focus. Sun/moon
trigger icons show the resolved current state: sun for light and moon for
dark, including changes while following the system.
The English group labels are Style and Light. Style options are independent buttons in a two-column grid, with a colored page, layers, pen-nib or terminal icon and the preset name. There are no Aa previews or experiment badges. The site default is identified in its tooltip and accessible name; selecting it clears the saved preset. A tinted background and border show selection, and keyboard focus has a separate outline.
Desktop uses a non-modal dialog anchored to the trigger; outside press or
focus leaving closes it. Below 768 px and inside the Landing drawer,
showModal() opens a bottom sheet in the browser top layer with a close
button and 44 px option targets. Closing the sheet preserves the underlying
drawer. The surface coordinator closes unrelated popovers before opening.
The t shortcut still toggles light/dark through switch_theme;
switch_preset is the separate command-palette choice.
The local Ink/Terminal experiments require explicit configuration;
preset_menu: true continues
to offer Paper/Slate plus the site default. Both reuse the same state, keyboard,
command-palette and bottom-sheet mechanisms. Terminal compacts desktop
navigation rows, while prose and mobile touch targets retain their sizes.