diff --git a/AGENTS.md b/AGENTS.md
index a7be8cff..2b2e050c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,9 +17,9 @@ gp-sphinx (`gp_sphinx`) is a shared Sphinx documentation platform for Python pro
Key features:
- `merge_sphinx_config()` API for shared defaults with per-project overrides
-- Shared extension list (autodoc, intersphinx, myst_parser, sphinx_design, etc.)
+- Shared extension list (autodoc, intersphinx, myst_parser, sphinx_ux_*, etc.)
- Shared Furo theme configuration (CSS variables, fonts, sidebar, footer)
-- Bundled workarounds (tabs.js removal, spa-nav.js injection)
+- Bundled workarounds (spa-nav.js injection)
- Shared font configuration (IBM Plex via Fontsource)
## Development Environment
diff --git a/docs/_ext/package_reference.py b/docs/_ext/package_reference.py
index 16bfe3ca..017c5ff7 100644
--- a/docs/_ext/package_reference.py
+++ b/docs/_ext/package_reference.py
@@ -200,6 +200,9 @@ class PackageDocsRecord:
"@gp-sphinx/serene-tokens": "tokens",
"sphinx-fonts": "tokens",
"sphinx-ux-badges": "ux",
+ "sphinx-ux-octicons": "ux",
+ "sphinx-ux-grid": "ux",
+ "sphinx-ux-tabs": "ux",
"sphinx-ux-autodoc-layout": "ux",
"sphinx-vite-builder": "build-seo",
"sphinx-gp-opengraph": "build-seo",
@@ -902,7 +905,7 @@ def package_reference_markdown(package_name: str) -> str:
def maturity_badge(maturity: str) -> str:
- """Return a sphinx-design badge role for use in grid markdown output.
+ """Return a badge role (MyST ``{bdg-*}`` syntax) for use in grid markdown output.
Used only in :func:`workspace_package_grid_markdown` which produces raw
MyST markdown strings. Per-page package headers use the ``gp-sphinx-package-meta``
diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css
index 5a30f20e..ec28567f 100644
--- a/docs/_static/css/custom.css
+++ b/docs/_static/css/custom.css
@@ -286,251 +286,6 @@ img[src*="codecov.io"] {
line-height: 1.5;
}
-/* ── Package page metadata strip ────────────────────────────
- * Selects the first
inside the top-level section that
- * contains ONLY sphinx-design badges (no non-badge children).
- *
- * MyST renders {bdg-*} roles as inside
- * a bare
that is a direct child of , which is a
- * direct child of .
- *
- * :has() requires CSS Level 4 (all evergreen browsers ≥ 2024).
- * Falls back gracefully: badges render inline without the flex strip.
- *
- * Overrides `article h1 { margin-bottom: 0.75rem }` (line 38)
- * via equal specificity (0,0,2) and later source order.
- * ────────────────────────────────────────────────────────── */
-
-/* Tighten h1 → badge strip gap so they read as a unit */
-article > section > h1 {
- margin-bottom: 0.2rem;
-}
-
-/* Convert badge-only paragraph into a flex metadata strip */
-article > section > p:first-of-type:has(> .sd-badge:first-child):not(:has(*:not(.sd-badge))) {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- margin-top: 0;
- margin-bottom: 1.5rem;
- padding: 0;
- line-height: 1;
-}
-
-/* ── Base badge reset ────────────────────────────────────── */
-.sd-badge {
- display: inline-flex !important;
- align-items: center;
- vertical-align: middle;
- font-size: 0.67rem;
- font-weight: 600;
- line-height: 1;
- letter-spacing: 0.02em;
- padding: 0.16rem 0.4rem;
- border-radius: 0.22rem;
- user-select: none;
- -webkit-user-select: none;
-}
-
-/* ── Maturity palette ────────────────────────────────────────
- * Subtle fill: Radix steps 12 (text), 11 (border), 3 (bg).
- * Step-12 text passes WCAG AAA on step-3 tinted backgrounds.
- * Step-11 border passes WCAG 1.4.11 (non-text contrast ≥ 3:1).
- *
- * !important matches sphinx-design's own !important on
- * .sd-text-warning / .sd-text-success and .sd-outline-*.
- * Without it, color and border-color are silently overridden.
- * background-color does not need !important — sphinx-design
- * does not set background on these badge variants.
- * ─────────────────────────────────────────────────────────── */
-:root {
- --badge-alpha-color: #4e2009; /* Radix amber-12 — 11.62:1 on amber-3 (AAA) */
- --badge-alpha-border: #ab6400; /* Radix amber-11 — 4.11:1 on card footer (WCAG 1.4.11 ✓) */
- --badge-alpha-bg: #ffedc6; /* Radix amber-3 — opaque tint, no color-mix */
-
- --badge-beta-color: #193b2d; /* Radix green-12 — 10.55:1 on green-3 (AAA) */
- --badge-beta-border: #218358; /* Radix green-11 — 4.22:1 on card footer (WCAG 1.4.11 ✓) */
- --badge-beta-bg: #ddf3e4; /* Radix green-3 */
-}
-
-/* Furo explicit dark theme */
-body[data-theme="dark"] {
- --badge-alpha-color: #ffca16; /* Radix amber-11 dark — 9.13:1 on #3f2700 (AAA) */
- --badge-alpha-border: #8f6424; /* Radix amber-8 dark */
- --badge-alpha-bg: #3f2700; /* Radix amber-3 dark */
-
- --badge-beta-color: #3dd68c; /* Radix green-11 dark — 6.66:1 on #113b29 (AA) */
- --badge-beta-border: #2f7c57; /* Radix green-8 dark */
- --badge-beta-bg: #113b29; /* Radix green-3 dark */
-}
-
-/* Furo auto mode: system dark when not explicitly set to light */
-@media (prefers-color-scheme: dark) {
- body:not([data-theme="light"]) {
- --badge-alpha-color: #ffca16;
- --badge-alpha-border: #8f6424;
- --badge-alpha-bg: #3f2700;
- --badge-beta-color: #3dd68c;
- --badge-beta-border: #2f7c57;
- --badge-beta-bg: #113b29;
- }
-}
-
-/* {bdg-warning-line} → .sd-badge.sd-outline-warning.sd-text-warning */
-.sd-badge.sd-outline-warning {
- color: var(--badge-alpha-color) !important;
- border-color: var(--badge-alpha-border) !important;
- background-color: var(--badge-alpha-bg);
-}
-
-/* {bdg-success-line} → .sd-badge.sd-outline-success.sd-text-success */
-.sd-badge.sd-outline-success {
- color: var(--badge-beta-color) !important;
- border-color: var(--badge-beta-border) !important;
- background-color: var(--badge-beta-bg);
-}
-
-/* ── Safety badge compatibility ─────────────────────────────
- * Downstream projects like libtmux-mcp emit safety badges via
- * a custom extension. Keep the docs-site copy aligned with the
- * packaged theme CSS so previews match downstream builds.
- * ────────────────────────────────────────────────────────── */
-:root {
- --gp-sphinx-fastmcp-safety-readonly-bg: #1f7a3f;
- --gp-sphinx-fastmcp-safety-readonly-border: #2a8d4d;
- --gp-sphinx-fastmcp-safety-readonly-text: #f3fff7;
- --gp-sphinx-fastmcp-safety-mutating-bg: #b96a1a;
- --gp-sphinx-fastmcp-safety-mutating-border: #cf7a23;
- --gp-sphinx-fastmcp-safety-mutating-text: #fff8ef;
- --gp-sphinx-fastmcp-safety-destructive-bg: #b4232c;
- --gp-sphinx-fastmcp-safety-destructive-border: #cb3640;
- --gp-sphinx-fastmcp-safety-destructive-text: #fff5f5;
-}
-
-h2:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]),
-h3:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]),
-h4:has(> .sd-badge[role="note"][aria-label^="Safety tier:"]) {
- display: inline-flex;
- align-items: center;
- gap: 0.45rem;
-}
-
-.sd-badge[role="note"][aria-label^="Safety tier:"] {
- gap: 0.28rem;
- font-weight: 700;
- letter-spacing: 0.01em;
- border: 1px solid transparent;
-}
-
-.sd-badge[role="note"][aria-label^="Safety tier:"]::before {
- font-style: normal;
- font-weight: normal;
- font-size: 1em;
- line-height: 1;
- flex-shrink: 0;
-}
-
-.sd-badge.sd-bg-success[role="note"][aria-label^="Safety tier:"] {
- background-color: var(--gp-sphinx-fastmcp-safety-readonly-bg) !important;
- color: var(--gp-sphinx-fastmcp-safety-readonly-text) !important;
- border-color: var(--gp-sphinx-fastmcp-safety-readonly-border);
-}
-
-.sd-badge.sd-bg-warning[role="note"][aria-label^="Safety tier:"] {
- background-color: var(--gp-sphinx-fastmcp-safety-mutating-bg) !important;
- color: var(--gp-sphinx-fastmcp-safety-mutating-text) !important;
- border-color: var(--gp-sphinx-fastmcp-safety-mutating-border);
-}
-
-.sd-badge.sd-bg-danger[role="note"][aria-label^="Safety tier:"] {
- background-color: var(--gp-sphinx-fastmcp-safety-destructive-bg) !important;
- color: var(--gp-sphinx-fastmcp-safety-destructive-text) !important;
- border-color: var(--gp-sphinx-fastmcp-safety-destructive-border);
-}
-
-.sd-badge.sd-bg-success[role="note"][aria-label^="Safety tier:"]::before {
- content: "🔍";
-}
-
-.sd-badge.sd-bg-warning[role="note"][aria-label^="Safety tier:"]::before {
- content: "✏️";
-}
-
-.sd-badge.sd-bg-danger[role="note"][aria-label^="Safety tier:"]::before {
- content: "💣";
-}
-
-h2 .sd-badge[role="note"][aria-label^="Safety tier:"],
-h3 .sd-badge[role="note"][aria-label^="Safety tier:"] {
- font-size: 0.68rem;
- padding: 0.17rem 0.4rem;
-}
-
-p .sd-badge[role="note"][aria-label^="Safety tier:"],
-li .sd-badge[role="note"][aria-label^="Safety tier:"],
-td .sd-badge[role="note"][aria-label^="Safety tier:"],
-a .sd-badge[role="note"][aria-label^="Safety tier:"] {
- font-size: 0.62rem;
- padding: 0.12rem 0.32rem;
-}
-
-code.docutils + .sd-badge[role="note"][aria-label^="Safety tier:"],
-.sd-badge[role="note"][aria-label^="Safety tier:"] + code.docutils {
- margin-left: 0.4em;
-}
-
-/* ── Safety badge link behavior ────────────────────────────
- * Keep docs-site previews aligned with the packaged theme:
- * tool links underline only the code token, not the gap or
- * the attached safety badge.
- * ────────────────────────────────────────────────────────── */
-a.reference:has(.sd-badge[role="note"][aria-label^="Safety tier:"]) {
- text-decoration: none;
-}
-
-a.reference:has(.sd-badge[role="note"][aria-label^="Safety tier:"])
- .sd-badge[role="note"][aria-label^="Safety tier:"] {
- text-decoration: none;
- vertical-align: middle;
-}
-
-a.reference:has(.sd-badge[role="note"][aria-label^="Safety tier:"]) code {
- text-decoration: none;
- vertical-align: middle;
-}
-
-a.reference:has(.sd-badge[role="note"][aria-label^="Safety tier:"]):hover code {
- text-decoration: underline;
-}
-
-/* Type badges (solid fills) — muted gray, scoped to metadata strip only.
- * extension: {bdg-primary} → .sd-badge.sd-bg-primary
- * coordinator: {bdg-success} → .sd-badge.sd-bg-success (solid fill)
- * theme: {bdg-info} → .sd-badge.sd-bg-info
- * .sd-bg-success and .sd-outline-success are mutually exclusive in sphinx-design. */
-article > section > p:first-of-type > .sd-badge.sd-bg-primary,
-article > section > p:first-of-type > .sd-badge.sd-bg-success,
-article > section > p:first-of-type > .sd-badge.sd-bg-info {
- font-size: 0.6rem;
- font-weight: 500;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- opacity: 0.65;
- background-color: var(--color-background-border) !important;
- color: var(--color-foreground-secondary) !important;
- border: 1px solid var(--color-foreground-border);
-}
-
-/* Card footer badges — compact and scannable in the grid index */
-.sd-card-footer .sd-badge {
- font-size: 0.6rem;
- font-weight: 500;
- padding: 0.13rem 0.35rem;
- letter-spacing: 0.03em;
- text-transform: uppercase;
- vertical-align: middle;
-}
-
/* Per-package landing layout — rendered by PackageLandingDirective.
*
* The directive emits :class-container: gp-sphinx-package__landing-grid
diff --git a/docs/conf.py b/docs/conf.py
index 833a1b3b..79530502 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -46,6 +46,18 @@
0,
str(project_root / "packages" / "sphinx-ux-badges" / "src"),
)
+sys.path.insert(
+ 0,
+ str(project_root / "packages" / "sphinx-ux-octicons" / "src"),
+)
+sys.path.insert(
+ 0,
+ str(project_root / "packages" / "sphinx-ux-grid" / "src"),
+)
+sys.path.insert(
+ 0,
+ str(project_root / "packages" / "sphinx-ux-tabs" / "src"),
+)
sys.path.insert(
0,
str(project_root / "packages" / "sphinx-autodoc-fastmcp" / "src"),
@@ -92,7 +104,6 @@
"package_reference",
"sab_demo",
"sab_meta",
- "sphinx_ux_badges",
"sphinx_autodoc_api_style",
"sphinx_autodoc_pytest_fixtures",
"sphinx_autodoc_docutils",
diff --git a/docs/configuration.md b/docs/configuration.md
index 697b901c..cc8d1175 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -83,12 +83,15 @@ already present.
## Injected `setup(app)`
The returned config includes a `setup(app)` function from
-{py:func}`gp_sphinx.config.setup`. It does two things:
+{py:func}`gp_sphinx.config.setup`. It wires the runtime hooks that the
+shared config depends on:
| Action | Effect |
| --- | --- |
| `app.add_js_file("js/spa-nav.js", loading_method="defer")` | Registers the bundled SPA navigation script from `sphinx-gp-theme` |
-| `app.connect("build-finished", remove_tabs_js)` | Removes `_static/tabs.js` after HTML builds as a `sphinx-inline-tabs` workaround |
+| `app.connect("html-page-context", _inject_copybutton_bridge)` | Exposes `copybutton_selector` as a window global for `spa-nav.js` |
+| `app.connect("html-page-context", _inject_fowt_prevention)` | Injects the FOWT-prevention head snippet to suppress flash-of-wrong-theme |
+| `app.add_lexer("myst", MystLexer)` / `("myst-md", MystLexer)` | Registers the MyST source-block lexer aliases |
## Always-set coordinator values
@@ -111,7 +114,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants:
| Constant | Value |
| --- | --- |
-| `DEFAULT_EXTENSIONS` | `["sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints_gp", "sphinx.ext.todo", "sphinx_inline_tabs", "sphinx_copybutton", "sphinx_gp_opengraph", "sphinx_gp_sitemap", "sphinxext.rediraffe", "sphinx_design", "myst_parser", "linkify_issues"]` |
+| `DEFAULT_EXTENSIONS` | `["sphinx.ext.autodoc", "sphinx_fonts", "sphinx.ext.intersphinx", "sphinx_autodoc_typehints_gp", "sphinx.ext.todo", "sphinx_ux_tabs", "sphinx_copybutton", "sphinx_gp_opengraph", "sphinx_gp_sitemap", "sphinxext.rediraffe", "sphinx_ux_grid", "sphinx_ux_octicons", "sphinx_ux_badges", "myst_parser", "linkify_issues"]` |
| `DEFAULT_SOURCE_SUFFIX` | `{".rst": "restructuredtext", ".md": "markdown"}` |
| `DEFAULT_MYST_EXTENSIONS` | `["colon_fence", "substitution", "replacements", "strikethrough", "linkify"]` |
| `DEFAULT_MYST_HEADING_ANCHORS` | `4` |
@@ -138,7 +141,7 @@ These are injected even though they are not exposed as `DEFAULT_*` constants:
| Constant | Value |
| --- | --- |
-| `DEFAULT_PYGMENTS_STYLE` | `"monokai"` |
+| `DEFAULT_PYGMENTS_STYLE` | `"gp-sphinx-light"` |
| `DEFAULT_PYGMENTS_DARK_STYLE` | `"monokai"` |
| `DEFAULT_COPYBUTTON_PROMPT_TEXT` | regex matching Python, shell, and IPython prompts |
| `DEFAULT_COPYBUTTON_PROMPT_IS_REGEXP` | `True` |
diff --git a/docs/packages/index.md b/docs/packages/index.md
index 170e8631..6865cea2 100644
--- a/docs/packages/index.md
+++ b/docs/packages/index.md
@@ -15,6 +15,9 @@ and independently installable.
The rendering pipeline every autodoc extension consumes:
- [`sphinx-ux-badges`](sphinx-ux-badges/index.md) — badge primitives and colour palette
+- [`sphinx-ux-octicons`](sphinx-ux-octicons/index.md) — curated GitHub Octicons as a Sphinx `{octicon}` role
+- [`sphinx-ux-grid`](sphinx-ux-grid/index.md) — CSS-Grid `{grid}` and `{grid-item-card}` directives
+- [`sphinx-ux-tabs`](sphinx-ux-tabs/index.md) — drop-in tabs replacement for sphinx-inline-tabs and sphinx-design
- [`sphinx-ux-autodoc-layout`](sphinx-ux-autodoc-layout/index.md) — structural presenter for `api-*` entry components
- [`sphinx-autodoc-typehints-gp`](sphinx-autodoc-typehints-gp/index.md) — annotation normalization and type rendering
- [`sphinx-fonts`](sphinx-fonts/index.md) — IBM Plex font preloading
diff --git a/docs/packages/sphinx-ux-badges/reference.md b/docs/packages/sphinx-ux-badges/reference.md
index 9cf3f9f6..9f286a59 100644
--- a/docs/packages/sphinx-ux-badges/reference.md
+++ b/docs/packages/sphinx-ux-badges/reference.md
@@ -2,6 +2,72 @@
# API Reference
+## `{bdg-*}` MyST roles
+
+The extension registers a family of 22 MyST roles for inline badges
+using semantic colour names. Each colour comes in a filled variant
+(`{bdg-}`) and an outline variant (`{bdg--line}`).
+
+```{list-table}
+:header-rows: 1
+:widths: 22 14 32 32
+
+* - Colour
+ - Filled
+ - Filled example
+ - Outline example
+* - `primary`
+ - `` {bdg-primary}`text` ``
+ - {bdg-primary}`primary`
+ - {bdg-primary-line}`primary`
+* - `secondary`
+ - `` {bdg-secondary}`text` ``
+ - {bdg-secondary}`secondary`
+ - {bdg-secondary-line}`secondary`
+* - `success`
+ - `` {bdg-success}`text` ``
+ - {bdg-success}`success`
+ - {bdg-success-line}`success`
+* - `info`
+ - `` {bdg-info}`text` ``
+ - {bdg-info}`info`
+ - {bdg-info-line}`info`
+* - `warning`
+ - `` {bdg-warning}`text` ``
+ - {bdg-warning}`warning`
+ - {bdg-warning-line}`warning`
+* - `danger`
+ - `` {bdg-danger}`text` ``
+ - {bdg-danger}`danger`
+ - {bdg-danger-line}`danger`
+* - `light`
+ - `` {bdg-light}`text` ``
+ - {bdg-light}`light`
+ - {bdg-light-line}`light`
+* - `muted`
+ - `` {bdg-muted}`text` ``
+ - {bdg-muted}`muted`
+ - {bdg-muted-line}`muted`
+* - `dark`
+ - `` {bdg-dark}`text` ``
+ - {bdg-dark}`dark`
+ - {bdg-dark-line}`dark`
+* - `white`
+ - `` {bdg-white}`text` ``
+ - {bdg-white}`white`
+ - {bdg-white-line}`white`
+* - `black`
+ - `` {bdg-black}`text` ``
+ - {bdg-black}`black`
+ - {bdg-black-line}`black`
+```
+
+The roles emit a `BadgeNode` carrying `gp-sphinx-badge`,
+`gp-sphinx-badge--color-`, and either `gp-sphinx-badge--filled`
+or `gp-sphinx-badge--outline`. Colour values are defined as CSS
+custom-property triples (`-bg`, `-fg`, `-border`) in
+`sab_palettes.css` and have dedicated dark-mode overrides.
+
## Colour palette
All semantic badge colours live in `sab_palettes.css` (registered by
diff --git a/docs/packages/sphinx-ux-grid/dependents.md b/docs/packages/sphinx-ux-grid/dependents.md
new file mode 100644
index 00000000..19d03865
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/dependents.md
@@ -0,0 +1,6 @@
+(sphinx-ux-grid-dependents)=
+
+# Dependents
+
+```{package-dependents} sphinx-ux-grid
+```
diff --git a/docs/packages/sphinx-ux-grid/examples.md b/docs/packages/sphinx-ux-grid/examples.md
new file mode 100644
index 00000000..f6de0ac5
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/examples.md
@@ -0,0 +1,128 @@
+(sphinx-ux-grid-examples)=
+
+# Examples
+
+## Four-breakpoint responsive grid
+
+Resize the browser to see the column count change.
+
+::::{grid} 1 2 3 4
+:gutter: 3
+
+:::{grid-item-card} {octicon}`rocket` Tutorial
+:link: tutorial
+:link-type: doc
+Working usage examples for `{grid}` and `{grid-item-card}`.
+:::
+
+:::{grid-item-card} {octicon}`tools` How to
+:link: how-to
+:link-type: doc
+Recipes for sync groups, links, images, and overrides.
+:::
+
+:::{grid-item-card} {octicon}`book` Reference
+:link: reference
+:link-type: doc
+Option reference for every directive.
+:::
+
+:::{grid-item-card} {octicon}`light-bulb` Explanation
+:link: explanation
+:link-type: doc
+Why CSS Grid, why custom properties, why one node.
+:::
+::::
+
+## Cards with header / body / footer
+
+Markers `^^^` and `+++` split the card body into three regions.
+
+::::{grid} 1 1 2 2
+:gutter: 3
+
+:::{grid-item-card} Three regions
+{bdg-primary}`Header`
+^^^
+Body text describing the card's main content. Renders inside
+`.gp-sphinx-grid-card__body`.
++++
+{octicon}`info` Footer with a different background tint.
+:::
+
+:::{grid-item-card} Two regions (body + footer)
+Body-only opening — no `^^^` marker, so the card has no header.
++++
+{bdg-success}`Stable` · {bdg-info-line}`v0.0.1`
+:::
+::::
+
+## Mixed-span items inside one grid
+
+`{grid-item}` accepts its own `:columns:` to override the parent
+grid's breakpoint defaults.
+
+::::{grid} 1 2 4 4
+:gutter: 3
+
+:::{grid-item}
+:columns: 4 4 4 4
+:class: gp-sphinx-grid-card gp-sphinx-grid-card--outline
+Full-width across mobile, then quarter-width on tablet and desktop.
+:::
+
+:::{grid-item-card}
+Standard card.
+:::
+
+:::{grid-item-card}
+Standard card.
+:::
+
+:::{grid-item-card}
+Standard card.
+:::
+::::
+
+## Outline cards (no shadow)
+
+::::{grid} 1 2 2 2
+:gutter: 2
+
+:::{grid-item-card} Subtle framing
+:outline:
+Low-density framing without a drop shadow. Useful for content
+indexes.
+:::
+
+:::{grid-item-card} {octicon}`star` Highlight
+:outline:
+Compose with `{octicon}` for an icon next to the title.
+:::
+::::
+
+## RST authoring
+
+The directives also work in reStructuredText. The rendered HTML is
+identical to the MyST forms above.
+
+```{eval-rst}
+.. grid:: 1 2 3 3
+ :gutter: 3
+
+ .. grid-item-card:: First
+ :shadow: sm
+
+ Authored in reStructuredText.
+
+ .. grid-item-card:: Second
+ :shadow: sm
+
+ Same ``gp-sphinx-grid-card`` HTML as MyST source.
+
+ .. grid-item-card:: Third
+ :shadow: sm
+
+ Mix RST and MyST in the same project — both authoring
+ surfaces produce the same DOM.
+```
diff --git a/docs/packages/sphinx-ux-grid/explanation.md b/docs/packages/sphinx-ux-grid/explanation.md
new file mode 100644
index 00000000..6b6b73c8
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/explanation.md
@@ -0,0 +1,93 @@
+(sphinx-ux-grid-explanation)=
+
+# Explanation
+
+## CSS Grid, not Bootstrap floats
+
+Older grid systems (Bootstrap 4 and earlier) built columns out of
+`float: left` with negative margins. `sphinx-ux-grid` uses CSS Grid
+directly: every grid is `display: grid` with
+`grid-template-columns: repeat(N, minmax(0, 1fr))`. The breakpoint
+column count is set per-grid via inline custom properties, and the
+static CSS reads the property at each media query.
+
+The upshot:
+
+- Items size proportionally without `width: percentage` arithmetic.
+- Gutter is a single `gap` declaration, not nested padding.
+- A single rule set in the CSS file handles every grid invocation,
+ regardless of how many appear on a page.
+
+## Plain `nodes.container`, not custom nodes
+
+The directives emit
+{py:class}`docutils.nodes.container` with class names. There is no
+custom node subclass for the grid itself.
+
+This is deliberate. The grid is presentation-only — every behaviour
+worth describing (column count, gutter, alignment, links) is encoded
+in a CSS class or an inline custom property. Non-HTML builders
+(LaTeX, text, man) descend into the container's children and render
+the content; the layout disappears without breaking the doctree.
+
+The exception is the card's link target. To preserve a Sphinx
+cross-reference resolution path for `:link-type: doc` and `:ref`, the
+directive emits a thin `LinkPassthrough(nodes.TextElement)` that
+hosts an `addnodes.pending_xref` or `nodes.reference`. This sidesteps
+Sphinx's `HTML5Translator.visit_reference` assertion that a reference
+node containing more than one child must wrap a single image — a
+constraint that prevents wrapping the entire card container in a
+reference.
+
+## Breakpoint values as inline custom properties
+
+The straightforward way to make a grid responsive is to write one CSS
+rule per (grid × breakpoint) combination — but that produces an
+unbounded number of selectors as the docs grow. Instead, the
+directive emits one `style="..."` declaration per grid:
+
+```html
+
+```
+
+The static CSS file has a finite set of rules consuming these
+properties via `var()`:
+
+```css
+.gp-sphinx-grid {
+ grid-template-columns: repeat(var(--gp-sphinx-grid-cols-xs, 1), minmax(0, 1fr));
+}
+@media (min-width: 576px) {
+ .gp-sphinx-grid {
+ grid-template-columns: repeat(var(--gp-sphinx-grid-cols-sm, 1), minmax(0, 1fr));
+ }
+}
+```
+
+This pattern is repeated for margin and padding. The rendered CSS
+stays a constant size; per-grid variation lives entirely in inline
+styles.
+
+## Comparison to sphinx-design
+
+`sphinx-design` ships a much larger surface: dropdowns, plain cards,
+buttons, article-info, Material and FontAwesome icons,
+card-carousels, tab-set-code. `sphinx-ux-grid` is intentionally
+narrow — just the grid and card directives gp-sphinx actually uses.
+
+The MyST authoring surface is unchanged: `{grid}`, `{grid-item}`,
+`{grid-item-card}`, and their option names, all match sphinx-design.
+Source written against sphinx-design's grid works against this
+package without edits.
+
+## Comparison to Furo's `sd-card`
+
+Furo's bundled CSS includes overrides for sphinx-design's
+`.sd-card`/`.sd-row` classes. This package uses its own
+`gp-sphinx-grid*` namespace under `@layer gp-sphinx`, which sits
+between Furo's `components` and `utilities` layers. Project-level
+overrides win because they target the package's namespaced classes
+directly.
diff --git a/docs/packages/sphinx-ux-grid/how-to.md b/docs/packages/sphinx-ux-grid/how-to.md
new file mode 100644
index 00000000..dc116e82
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/how-to.md
@@ -0,0 +1,120 @@
+(sphinx-ux-grid-how-to)=
+
+# How to
+
+## Mix columns within a grid
+
+`{grid-item}` accepts its own `:columns:` argument that overrides the
+parent grid's breakpoint defaults for that one item:
+
+````markdown
+:::{grid} 1 2 3 4
+
+:::{grid-item}
+:columns: 12 12 12 12
+Full-width hero spanning every breakpoint.
+:::
+
+:::{grid-item-card}
+Normal-width sibling.
+:::
+
+:::
+````
+
+`:columns:` takes the same single-int or four-int values as the
+parent's column count.
+
+## Compose with icons and badges
+
+Cards compose with [`sphinx-ux-octicons`](../sphinx-ux-octicons/index.md)
+and [`sphinx-ux-badges`](../sphinx-ux-badges/index.md):
+
+````markdown
+:::{grid-item-card} {octicon}`rocket` Quickstart
+:link: quickstart
+:link-type: doc
+{bdg-success}`Stable`
+^^^
+Install and get started in minutes.
+:::
+````
+
+## Outline cards
+
+Use `:outline:` to swap the shadow for a border. Common for cards in
+documentation indexes where you want low-density framing:
+
+```markdown
+:::{grid-item-card} Reference
+:outline:
+:link: reference
+:link-type: doc
+API reference for every directive and option.
+:::
+```
+
+## Image-only cards
+
+`:img-background:` puts an image behind the card body. `:img-top:` /
+`:img-bottom:` places an image above or below the body:
+
+```markdown
+:::{grid-item-card} Gallery
+:img-top: _static/gallery-hero.png
+:img-alt: A wide shot of the gallery
+A walkthrough of every component.
+:::
+```
+
+## Reverse a grid's visual order
+
+`:reverse:` flips the grid items' visual order without changing
+source order. Useful when you want the most recent item to appear
+first visually but you author it last:
+
+```markdown
+:::{grid} 1 1 2 2
+:reverse:
+:gutter: 3
+
+:::{grid-item-card} Older item
+:::
+:::{grid-item-card} Newer item
+:::
+
+:::
+```
+
+## Apply custom margins via the spacing scale
+
+The `:margin:` and `:padding:` options accept the integer scale
+`0..5` or the keyword `auto`. Each integer maps to a CSS length
+(`0` → `0`, `1` → `0.25rem`, ..., `5` → `3rem`). Pass one value to
+apply to every breakpoint, or four values for `xs sm md lg`:
+
+```markdown
+:::{grid} 1 2 3 4
+:gutter: 3
+:margin: 0 0 4 4
+
+Spacious only on tablet and desktop.
+:::
+```
+
+## Override classes from MyST source
+
+Every card section accepts a class override via the relevant
+`:class-*:` option (`:class-container:`, `:class-row:`,
+`:class-item:`, `:class-card:`, `:class-body:`, `:class-title:`,
+`:class-header:`, `:class-footer:`):
+
+```markdown
+:::{grid-item-card} Custom card
+:class-card: my-project-callout
+Body text.
+:::
+```
+
+The class names are appended to the rendered element. The package's
+own `gp-sphinx-grid*` classes are always present.
diff --git a/docs/packages/sphinx-ux-grid/index.md b/docs/packages/sphinx-ux-grid/index.md
new file mode 100644
index 00000000..c8ef0a5d
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/index.md
@@ -0,0 +1,6 @@
+(sphinx-ux-grid)=
+
+# sphinx-ux-grid
+
+```{package-landing} sphinx-ux-grid
+```
diff --git a/docs/packages/sphinx-ux-grid/reference.md b/docs/packages/sphinx-ux-grid/reference.md
new file mode 100644
index 00000000..7b80dfb5
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/reference.md
@@ -0,0 +1,236 @@
+(sphinx-ux-grid-reference)=
+
+# API Reference
+
+## Directives
+
+### `{grid}`
+
+Container for grid items. Lays out children in a CSS-Grid template
+whose column count varies by breakpoint.
+
+```{list-table}
+:header-rows: 1
+:widths: 20 20 60
+
+* - Option
+ - Value
+ - Description
+* - argument
+ - `` or ``
+ - Column counts. Single integer applies to every breakpoint; four
+ integers map to `xs sm md lg` (defaults: 1, 1, 2, 2).
+* - `:gutter:`
+ - `0`–`5` or four such values
+ - Spacing scale. `0` → `0`, `1` → `0.25rem`, ..., `5` → `3rem`.
+ Defaults to `3`.
+* - `:margin:`
+ - `0`–`5`, `auto`, or four such values
+ - Margin scale, per breakpoint.
+* - `:padding:`
+ - `0`–`5` or four such values
+ - Padding scale, per breakpoint.
+* - `:outline:`
+ - flag
+ - Render a faint outline around the grid container (debugging
+ helper).
+* - `:reverse:`
+ - flag
+ - Reverse the visual order of grid items without changing source
+ order.
+* - `:class-container:`
+ - string
+ - Append classes to the grid container.
+* - `:class-row:`
+ - string
+ - Append classes to the inner row wrapper.
+```
+
+### `{grid-item}`
+
+Child of `{grid}`. Carries column-span overrides for itself.
+
+```{list-table}
+:header-rows: 1
+:widths: 20 20 60
+
+* - Option
+ - Value
+ - Description
+* - `:columns:`
+ - `` or four ints (1..12)
+ - Column span, per breakpoint. Default inherits the parent grid's
+ breakpoint columns.
+* - `:child-direction:`
+ - `column` or `row`
+ - Direction the item's children flow.
+* - `:child-align:`
+ - `start`, `end`, `center`, `justify`, `spaced`
+ - Alignment of the item's children.
+* - `:margin:`
+ - `0`–`5`, `auto`, or four such values
+ - Margin scale, per breakpoint.
+* - `:padding:`
+ - `0`–`5` or four such values
+ - Padding scale, per breakpoint.
+* - `:outline:`
+ - flag
+ - Debug outline.
+* - `:class:`
+ - string
+ - Append classes to the item.
+```
+
+### `{grid-item-card}`
+
+Composite: a `{grid-item}` wrapping a card. Accepts every `{grid-item}`
+option plus card-specific options below.
+
+```{list-table}
+:header-rows: 1
+:widths: 20 20 60
+
+* - Option
+ - Value
+ - Description
+* - argument
+ - inline text
+ - Card title.
+* - `:link:`
+ - target string
+ - Make the card clickable. Combined with `:link-type:`.
+* - `:link-type:`
+ - `url`, `any`, `ref`, `doc`
+ - How `:link:` is resolved. Default `any` (tries `doc` then `ref`).
+* - `:link-alt:`
+ - string
+ - Accessible label for the link.
+* - `:shadow:`
+ - `none`, `sm`, `md`, `lg`
+ - Card shadow strength.
+* - `:width:`
+ - `25%`, `50%`, `75%`, `100%`, `auto`
+ - Card width override.
+* - `:text-align:`
+ - `left`, `center`, `right`, `justify`
+ - Body text alignment.
+* - `:img-top:`
+ - image path
+ - Image rendered above the body.
+* - `:img-bottom:`
+ - image path
+ - Image rendered below the body.
+* - `:img-background:`
+ - image path
+ - Image rendered behind the body.
+* - `:img-alt:`
+ - string
+ - Alt text for the image.
+* - `:class-item:`
+ - string
+ - Classes appended to the outer grid-item wrapper.
+* - `:class-card:`
+ - string
+ - Classes appended to the card container.
+* - `:class-body:`
+ - string
+ - Classes appended to the card body.
+* - `:class-title:`
+ - string
+ - Classes appended to the card title.
+* - `:class-header:`
+ - string
+ - Classes appended to the card header.
+* - `:class-footer:`
+ - string
+ - Classes appended to the card footer.
+```
+
+#### Card content splitters
+
+Inside a `{grid-item-card}`, two markers split the body:
+
+- `^^^` — separator between header and body.
+- `+++` — separator between body and footer.
+
+The header, body, and footer each receive their own
+`.gp-sphinx-grid-card__header` / `__body` / `__footer` container.
+
+## CSS classes
+
+```{list-table}
+:header-rows: 1
+:widths: 35 65
+
+* - Class
+ - Applied to
+* - `gp-sphinx-grid`
+ - The grid container.
+* - `gp-sphinx-grid__item`
+ - Each grid item wrapper.
+* - `gp-sphinx-grid-card`
+ - The card element inside `{grid-item-card}`.
+* - `gp-sphinx-grid-card__body`
+ - Card body region.
+* - `gp-sphinx-grid-card__title`
+ - Card title.
+* - `gp-sphinx-grid-card__header`
+ - Header region (above `^^^`).
+* - `gp-sphinx-grid-card__footer`
+ - Footer region (below `+++`).
+* - `gp-sphinx-grid-card__img-top`
+ - Top-positioned image.
+* - `gp-sphinx-grid-card__img-bottom`
+ - Bottom-positioned image.
+* - `gp-sphinx-grid-card__link`
+ - Stretched-link anchor that makes the whole card clickable.
+* - `gp-sphinx-grid-card--shadow-sm`
+ - Modifier — small shadow.
+* - `gp-sphinx-grid-card--shadow-md`
+ - Modifier — medium shadow.
+* - `gp-sphinx-grid-card--shadow-lg`
+ - Modifier — large shadow.
+* - `gp-sphinx-grid-card--outline`
+ - Modifier — render with an outline instead of a shadow.
+* - `gp-sphinx-grid--reverse`
+ - Modifier — reverse visual order.
+```
+
+## CSS custom properties
+
+The grid container reads breakpoint values from inline custom
+properties; the package's CSS file declares the rules that consume
+them. Override defaults via your project's `custom.css`.
+
+```{list-table}
+:header-rows: 1
+:widths: 40 60
+
+* - Property
+ - Purpose
+* - `--gp-sphinx-grid-cols-xs`
+ - Column count at extra-small width.
+* - `--gp-sphinx-grid-cols-sm`
+ - Column count at small width (≥ 576px).
+* - `--gp-sphinx-grid-cols-md`
+ - Column count at medium width (≥ 768px).
+* - `--gp-sphinx-grid-cols-lg`
+ - Column count at large width (≥ 992px).
+* - `--gp-sphinx-grid-gutter`
+ - Gap between grid items.
+* - `--gp-sphinx-grid-margin-{xs,sm,md,lg}`
+ - Margin per breakpoint.
+* - `--gp-sphinx-grid-padding-{xs,sm,md,lg}`
+ - Padding per breakpoint.
+```
+
+The directive emits these as inline `style="..."` overrides on the
+grid container. The static CSS rules consume them with the
+established cascade (each breakpoint's rule reads the matching
+property and falls back to the previous breakpoint).
+
+## Python API
+
+```{eval-rst}
+.. autofunction:: sphinx_ux_grid.setup
+```
diff --git a/docs/packages/sphinx-ux-grid/tutorial.md b/docs/packages/sphinx-ux-grid/tutorial.md
new file mode 100644
index 00000000..aba9be72
--- /dev/null
+++ b/docs/packages/sphinx-ux-grid/tutorial.md
@@ -0,0 +1,84 @@
+(sphinx-ux-grid-tutorial)=
+
+# Tutorial
+
+## Add the extension
+
+`sphinx-ux-grid` is loaded automatically by
+{py:func}`~gp_sphinx.config.merge_sphinx_config`. To use it in a
+standalone Sphinx project:
+
+```python
+extensions = ["sphinx_ux_grid"]
+```
+
+## A responsive grid
+
+The `{grid}` directive accepts a breakpoint argument of either a
+single integer or four space-separated integers (`xs sm md lg`):
+
+````markdown
+:::{grid} 1 2 3 4
+:gutter: 3
+
+:::{grid-item-card} Quickstart
+Install and get started in minutes.
+:::
+
+:::{grid-item-card} Reference
+Full API documentation.
+:::
+
+:::{grid-item-card} Examples
+Live demos.
+:::
+
+:::{grid-item-card} Source
+GitHub source for every package.
+:::
+
+:::
+````
+
+The grid renders 1 column on phones, 2 on small tablets, 3 on
+tablets, and 4 on desktops.
+
+## A card with a link
+
+Cards become clickable when `:link:` is set. `:link-type:` controls
+how the link is resolved:
+
+- `url` — bare HTML link
+- `doc` — Sphinx docname (e.g. `packages/sphinx-ux-tabs/index`)
+- `ref` — `:ref:` label
+- `any` — try `doc` first, then `ref` (the default)
+
+```markdown
+:::{grid-item-card} Quickstart
+:link: quickstart
+:link-type: doc
+:shadow: md
+Install and get started in minutes.
+:::
+```
+
+The whole card is clickable. The internal `.gp-sphinx-grid-card__link`
+covers the card via `position: absolute; inset: 0`.
+
+## Header and footer markers
+
+Inside `{grid-item-card}`, two markers split the card body into
+sections:
+
+- `^^^` — everything above is the header.
+- `+++` — everything below is the footer.
+
+````markdown
+:::{grid-item-card} Card with sections
+header text
+^^^
+body text
++++
+footer text
+:::
+````
diff --git a/docs/packages/sphinx-ux-octicons/dependents.md b/docs/packages/sphinx-ux-octicons/dependents.md
new file mode 100644
index 00000000..a01b093c
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/dependents.md
@@ -0,0 +1,6 @@
+(sphinx-ux-octicons-dependents)=
+
+# Dependents
+
+```{package-dependents} sphinx-ux-octicons
+```
diff --git a/docs/packages/sphinx-ux-octicons/examples.md b/docs/packages/sphinx-ux-octicons/examples.md
new file mode 100644
index 00000000..2adf39f2
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/examples.md
@@ -0,0 +1,90 @@
+(sphinx-ux-octicons-examples)=
+
+# Examples
+
+## Every bundled icon
+
+Each icon below is rendered by the real `{octicon}` role at `1.5rem`:
+
+```{list-table}
+:header-rows: 1
+:widths: 25 25 50
+
+* - Name
+ - Rendered
+ - Role
+* - `rocket`
+ - {octicon}`rocket;1.5rem`
+ - `` {octicon}`rocket;1.5rem` ``
+* - `tools`
+ - {octicon}`tools;1.5rem`
+ - `` {octicon}`tools;1.5rem` ``
+* - `book`
+ - {octicon}`book;1.5rem`
+ - `` {octicon}`book;1.5rem` ``
+* - `light-bulb`
+ - {octicon}`light-bulb;1.5rem`
+ - `` {octicon}`light-bulb;1.5rem` ``
+* - `star`
+ - {octicon}`star;1.5rem`
+ - `` {octicon}`star;1.5rem` ``
+* - `alert`
+ - {octicon}`alert;1.5rem`
+ - `` {octicon}`alert;1.5rem` ``
+* - `terminal`
+ - {octicon}`terminal;1.5rem`
+ - `` {octicon}`terminal;1.5rem` ``
+* - `paintbrush`
+ - {octicon}`paintbrush;1.5rem`
+ - `` {octicon}`paintbrush;1.5rem` ``
+* - `code`
+ - {octicon}`code;1.5rem`
+ - `` {octicon}`code;1.5rem` ``
+* - `device-camera`
+ - {octicon}`device-camera;1.5rem`
+ - `` {octicon}`device-camera;1.5rem` ``
+* - `diff`
+ - {octicon}`diff;1.5rem`
+ - `` {octicon}`diff;1.5rem` ``
+* - `link`
+ - {octicon}`link;1.5rem`
+ - `` {octicon}`link;1.5rem` ``
+* - `home`
+ - {octicon}`home;1.5rem`
+ - `` {octicon}`home;1.5rem` ``
+* - `gear`
+ - {octicon}`gear;1.5rem`
+ - `` {octicon}`gear;1.5rem` ``
+* - `package`
+ - {octicon}`package;1.5rem`
+ - `` {octicon}`package;1.5rem` ``
+* - `info`
+ - {octicon}`info;1.5rem`
+ - `` {octicon}`info;1.5rem` ``
+* - `check-circle`
+ - {octicon}`check-circle;1.5rem`
+ - `` {octicon}`check-circle;1.5rem` ``
+* - `x-circle`
+ - {octicon}`x-circle;1.5rem`
+ - `` {octicon}`x-circle;1.5rem` ``
+```
+
+## Icons inherit text colour
+
+Wrap the role in a span with a colour class to tint the icon:
+
+{octicon}`rocket` Ship it
+
+{octicon}`alert` Heads up
+
+{octicon}`check-circle` Looks good
+
+## RST authoring
+
+The role is also available as a reStructuredText role. Both syntaxes
+emit the same SVG markup.
+
+```{eval-rst}
+Build it with :octicon:`rocket;1.5rem`, document it with :octicon:`book;1.5rem`,
+ship it with :octicon:`check-circle;1.5rem`.
+```
diff --git a/docs/packages/sphinx-ux-octicons/explanation.md b/docs/packages/sphinx-ux-octicons/explanation.md
new file mode 100644
index 00000000..719a035d
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/explanation.md
@@ -0,0 +1,47 @@
+(sphinx-ux-octicons-explanation)=
+
+# Explanation
+
+## Curated bundle, not the full set
+
+Upstream Octicons ships ~200 icons across 16px and 24px variants. The
+full JSON is roughly 1 MB. The gp-sphinx docs use about a dozen icons
+in practice, so the bundle ships only what is used — under 15 KB —
+plus a handful of common headroom names (`home`, `gear`, `info`).
+
+The bundle is regenerated by `scripts/sync_octicons.py` from the
+upstream `@primer/octicons` npm package. The script is a maintainer
+one-shot; consumers never run it.
+
+## Inline SVG, not icon fonts
+
+`{octicon}` emits inline SVG markup with `fill: currentColor`. This
+trades one network request (an icon font) for slightly larger HTML,
+and in return: icons inherit text colour without per-icon CSS, scale
+crisply at any zoom, and remain accessible (the SVG carries
+`aria-hidden="true"` so screen readers skip decorative icons).
+
+## Two nodes, not one
+
+The directive emits an `OcticonNode` subclass of
+{py:class}`docutils.nodes.inline` carrying:
+
+- `svg_markup` — the pre-rendered SVG string consumed by the HTML
+ visitor.
+- A child {py:class}`docutils.nodes.Text` carrying the icon name,
+ reached only by non-HTML builders via MRO fallback.
+
+This two-payload shape means HTML emits SVG and text emits the icon
+name, with no per-builder branching at directive-run time.
+
+## Comparison to sphinx-design
+
+`sphinx-design` shipped a similar `{octicon}` role with the full
+upstream icon set plus support for FontAwesome and Material variants.
+This package keeps just the Octicons role and just the icons gp-sphinx
+docs use, so the rendered HTML is smaller and consumers get a single
+icon library with a consistent visual style.
+
+The role syntax is intentionally identical (`name`, `name;height`,
+`name;height;classes`), so MyST source written against sphinx-design's
+`{octicon}` works unchanged.
diff --git a/docs/packages/sphinx-ux-octicons/how-to.md b/docs/packages/sphinx-ux-octicons/how-to.md
new file mode 100644
index 00000000..858fa13a
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/how-to.md
@@ -0,0 +1,59 @@
+(sphinx-ux-octicons-how-to)=
+
+# How to
+
+## Icon in a heading
+
+Inline `{octicon}` works anywhere a role is allowed, including
+headings:
+
+```markdown
+## {octicon}`book` Reference
+```
+
+The icon inherits the heading's colour and scales with the heading's
+font size when the role's height argument is `1em` (the default).
+
+## Icon inside a grid card title
+
+`{octicon}` composes with [`sphinx-ux-grid`](../sphinx-ux-grid/index.md)
+to put an icon next to a card title:
+
+```markdown
+:::{grid-item-card} {octicon}`rocket` Quickstart
+:link: quickstart
+:link-type: doc
+Install and get started in minutes.
+:::
+```
+
+## Match the rendered SVG to a colour
+
+`gp-sphinx-octicon` uses `fill: currentColor`. Override the colour by
+wrapping the role in a span carrying a colour class, or by setting
+`color` on the parent element via your project's `custom.css`:
+
+```css
+.my-warning {
+ color: var(--color-attention-foreground, #b08800);
+}
+```
+
+```markdown
+{octicon}`alert` Heads up
+```
+
+## Add a new icon to the bundle
+
+The bundle is hand-curated to keep the wheel small. Add a name to
+`_data/octicons_curated.txt` and regenerate `_data/octicons.json` from
+upstream `@primer/octicons`:
+
+```console
+$ cd packages/sphinx-ux-octicons
+$ pnpm install @primer/octicons
+$ python scripts/sync_octicons.py node_modules/@primer/octicons/build/svg
+```
+
+Commit both files together so the JSON and the audit-source-of-truth
+text file stay in sync.
diff --git a/docs/packages/sphinx-ux-octicons/index.md b/docs/packages/sphinx-ux-octicons/index.md
new file mode 100644
index 00000000..a8c2207b
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/index.md
@@ -0,0 +1,6 @@
+(sphinx-ux-octicons)=
+
+# sphinx-ux-octicons
+
+```{package-landing} sphinx-ux-octicons
+```
diff --git a/docs/packages/sphinx-ux-octicons/reference.md b/docs/packages/sphinx-ux-octicons/reference.md
new file mode 100644
index 00000000..5b56302f
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/reference.md
@@ -0,0 +1,110 @@
+(sphinx-ux-octicons-reference)=
+
+# API Reference
+
+## Role syntax
+
+The `{octicon}` role accepts up to three `;`-separated arguments:
+
+| Form | Example |
+|---|---|
+| `name` | `` {octicon}`rocket` `` |
+| `name;height` | `` {octicon}`rocket;1.5rem` `` |
+| `name;height;classes` | `` {octicon}`rocket;1.5rem;text-success` `` |
+
+- `name` — bundled icon name (see {ref}`bundled-icons` below).
+- `height` — CSS length (`em`, `rem`, `px`). Default `1em`. Width
+ scales to preserve the icon's 1:1 aspect ratio.
+- `classes` — space-separated extra classes appended to the SVG.
+
+Unknown icon names emit a docutils error pointing at the source line.
+
+(bundled-icons)=
+## Bundled icons
+
+```{list-table}
+:header-rows: 1
+:widths: 30 70
+
+* - Name
+ - Used for
+* - `rocket`
+ - Tutorials and getting-started entry points
+* - `tools`
+ - How-to guides and configuration recipes
+* - `book`
+ - Reference / API pages
+* - `light-bulb`
+ - Explanation and design rationale
+* - `star`
+ - Example showcases and highlights
+* - `alert`
+ - Errors, warnings, breaking-change callouts
+* - `terminal`
+ - CLI documentation and command reference
+* - `paintbrush`
+ - Theme tokens and styling content
+* - `code`
+ - Signature and code-example pages
+* - `device-camera`
+ - Gallery / kitchen-sink content
+* - `diff`
+ - Surface-diff and migration content
+* - `link`
+ - Dependents and cross-package references
+* - `home`
+ - Landing pages
+* - `gear`
+ - Settings and configuration
+* - `package`
+ - Package-level content
+* - `info`
+ - Informational notes
+* - `check-circle`
+ - Validation passes and success states
+* - `x-circle`
+ - Validation failures and error states
+```
+
+Need an icon that isn't in the bundle? Add it via the recipe in
+{doc}`how-to`.
+
+## CSS class
+
+`gp-sphinx-octicon` is the only class shipped by this extension. The
+rendered SVG receives both `gp-sphinx-octicon` and a per-icon modifier
+`gp-sphinx-octicon--`:
+
+```html
+
+```
+
+The CSS rule lives in `_static/css/sphinx_ux_octicons.css`:
+
+```css
+@layer gp-sphinx {
+ .gp-sphinx-octicon {
+ display: inline-block;
+ vertical-align: text-top;
+ fill: currentColor;
+ }
+}
+```
+
+`fill: currentColor` lets the icon inherit its colour from the
+surrounding text — no per-role styling required.
+
+## Non-HTML builders
+
+`OcticonNode` subclasses {py:class}`docutils.nodes.inline`, so non-HTML
+builders (text, man, LaTeX) fall back via Sphinx MRO dispatch to
+`visit_inline` and render the icon name as visible text. Documents
+build cleanly across every Sphinx builder.
+
+## Python API
+
+```{eval-rst}
+.. autofunction:: sphinx_ux_octicons.setup
+```
diff --git a/docs/packages/sphinx-ux-octicons/tutorial.md b/docs/packages/sphinx-ux-octicons/tutorial.md
new file mode 100644
index 00000000..08bf69c4
--- /dev/null
+++ b/docs/packages/sphinx-ux-octicons/tutorial.md
@@ -0,0 +1,47 @@
+(sphinx-ux-octicons-tutorial)=
+
+# Tutorial
+
+## Add the extension
+
+`sphinx-ux-octicons` is loaded automatically by
+{py:func}`~gp_sphinx.config.merge_sphinx_config`. To use it in a
+standalone Sphinx project, list it in `conf.py`:
+
+```python
+extensions = ["sphinx_ux_octicons"]
+```
+
+## Use the role
+
+The `{octicon}` role accepts an icon name and emits an inline SVG.
+
+```markdown
+Welcome {octicon}`rocket`!
+```
+
+Welcome {octicon}`rocket`!
+
+## Size an icon
+
+Pass a CSS length as the second argument, separated by `;`.
+
+```markdown
+{octicon}`book;1.5rem` Documentation
+```
+
+{octicon}`book;1.5rem` Documentation
+
+## Add extra classes
+
+Pass a space-separated list of class names as the third argument.
+
+```markdown
+{octicon}`alert;1em;text-warning` heads up
+```
+
+{octicon}`alert;1em;text-warning` heads up
+
+The rendered SVG inherits its colour from `currentColor`, so wrapping
+the role in a coloured container (a heading, an admonition, a span
+with a colour utility) tints the icon without per-role styling.
diff --git a/docs/packages/sphinx-ux-tabs/dependents.md b/docs/packages/sphinx-ux-tabs/dependents.md
new file mode 100644
index 00000000..36c86fb1
--- /dev/null
+++ b/docs/packages/sphinx-ux-tabs/dependents.md
@@ -0,0 +1,6 @@
+(sphinx-ux-tabs-dependents)=
+
+# Dependents
+
+```{package-dependents} sphinx-ux-tabs
+```
diff --git a/docs/packages/sphinx-ux-tabs/examples.md b/docs/packages/sphinx-ux-tabs/examples.md
new file mode 100644
index 00000000..58e86be4
--- /dev/null
+++ b/docs/packages/sphinx-ux-tabs/examples.md
@@ -0,0 +1,242 @@
+(sphinx-ux-tabs-examples)=
+
+# Examples
+
+## Inline-tabs style (consecutive `.. tab::`)
+
+```{eval-rst}
+.. tab:: Python
+
+ .. code-block:: python
+
+ def greet(name: str) -> str:
+ return f"Hello, {name}!"
+
+.. tab:: Rust
+
+ .. code-block:: rust
+
+ fn greet(name: &str) -> String {
+ format!("Hello, {}!", name)
+ }
+
+.. tab:: Go
+
+ .. code-block:: go
+
+ func Greet(name string) string {
+ return fmt.Sprintf("Hello, %s!", name)
+ }
+```
+
+## Tab-set style ({tab-set} / {tab-item})
+
+::::{tab-set}
+
+:::{tab-item} pip
+```console
+$ pip install gp-sphinx
+```
+:::
+
+:::{tab-item} uv
+```console
+$ uv add gp-sphinx
+```
+:::
+
+:::{tab-item} pipx
+```console
+$ pipx install gp-sphinx
+```
+:::
+
+::::
+
+## Pre-selected tab
+
+::::{tab-set}
+
+:::{tab-item} Linux
+Linux setup instructions.
+:::
+
+:::{tab-item} macOS
+:selected:
+macOS setup instructions — selected on page load.
+:::
+
+:::{tab-item} Windows
+Windows setup instructions.
+:::
+
+::::
+
+## Synchronized tab-sets
+
+Pick a shell here:
+
+::::{tab-set}
+:sync-group: shell
+
+:::{tab-item} bash
+:sync: bash
+```bash
+export PATH="$HOME/.local/bin:$PATH"
+```
+:::
+
+:::{tab-item} zsh
+:sync: zsh
+```zsh
+typeset -U path PATH
+path=("$HOME/.local/bin" $path)
+```
+:::
+
+:::{tab-item} fish
+:sync: fish
+```fish
+fish_add_path "$HOME/.local/bin"
+```
+:::
+
+::::
+
+The same shell stays selected here:
+
+::::{tab-set}
+:sync-group: shell
+
+:::{tab-item} bash
+:sync: bash
+```bash
+source ~/.bashrc
+```
+:::
+
+:::{tab-item} zsh
+:sync: zsh
+```zsh
+source ~/.zshrc
+```
+:::
+
+:::{tab-item} fish
+:sync: fish
+```fish
+source ~/.config/fish/config.fish
+```
+:::
+
+::::
+
+Click a tab in either tab-set; the other follows.
+
+## Size variants
+
+Default size — compact labels at `0.95em`:
+
+::::{tab-set}
+
+:::{tab-item} pip
+```console
+$ pip install gp-sphinx
+```
+:::
+
+:::{tab-item} uv
+```console
+$ uv add gp-sphinx
+```
+:::
+
+:::{tab-item} pipx
+```console
+$ pipx install gp-sphinx
+```
+:::
+
+::::
+
+`:class: gp-sphinx-tabs--large` — body-size labels with roomier
+padding:
+
+::::{tab-set}
+:class: gp-sphinx-tabs--large
+
+:::{tab-item} pip
+```console
+$ pip install gp-sphinx
+```
+:::
+
+:::{tab-item} uv
+```console
+$ uv add gp-sphinx
+```
+:::
+
+:::{tab-item} pipx
+```console
+$ pipx install gp-sphinx
+```
+:::
+
+::::
+
+## Deep-link to a tab
+
+::::{tab-set}
+:sync-group: example
+
+:::{tab-item} Python
+:sync: python
+```python
+print("hello world")
+```
+:::
+
+:::{tab-item} Rust
+:sync: rust
+```rust
+println!("hello world");
+```
+:::
+
+:::{tab-item} Go
+:sync: go
+```go
+fmt.Println("hello world")
+```
+:::
+
+::::
+
+Open this page with ?example=python
+to pre-select the Python tab via the sphinx-design URL form. The
+legacy sphinx-inline-tabs form is supported too:
+?tabs=Python. Either form
+writes through to `localStorage`, so the choice persists on subsequent
+visits.
+
+## `:new-set:` breaks a consecutive run
+
+```{eval-rst}
+.. tab:: Set A, tab 1
+
+ First tab of the first set.
+
+.. tab:: Set A, tab 2
+
+ Second tab of the first set — auto-grouped with the previous.
+
+.. tab:: Set B, tab 1
+ :new-set:
+
+ ``:new-set:`` forces a fresh tab-set break.
+
+.. tab:: Set B, tab 2
+
+ Second tab of the second set.
+```
diff --git a/docs/packages/sphinx-ux-tabs/explanation.md b/docs/packages/sphinx-ux-tabs/explanation.md
new file mode 100644
index 00000000..e09122ee
--- /dev/null
+++ b/docs/packages/sphinx-ux-tabs/explanation.md
@@ -0,0 +1,97 @@
+(sphinx-ux-tabs-explanation)=
+
+# Explanation
+
+## Radio inputs and CSS-only switching
+
+Tabs are rendered as a group of `` elements
+sharing a `name` attribute, each followed by a `