= {
+ info: 'Note',
+ success: 'Success',
+ warning: 'Warning',
+ problem: 'Important',
+};
+---
+
+
+
+
+
+
+
{title ?? labels[intent]}
+
+
+
+
+
diff --git a/src/ink/core/Card.astro b/src/ink/core/Card.astro
new file mode 100644
index 0000000000..b7f3f52749
--- /dev/null
+++ b/src/ink/core/Card.astro
@@ -0,0 +1,103 @@
+---
+// Content image card: thumbnail + title + description, linking out. `not-prose`
+// on the root so prose typography never touches it; API is identical to the
+// legacy card it replaces. Frame + hover language come from the shared
+// .ink-tile recipe (components.css); image zoom and the title arrow are local.
+import Icon from './Icon.astro';
+
+interface Props {
+ description?: string;
+ imgAlt: string;
+ imgSrc: string;
+ link: string;
+ title: string;
+ variant?: 'padded' | 'related-topics';
+}
+const { description, imgAlt, imgSrc, link, title, variant } = Astro.props;
+
+const isYouTube = link.includes('youtube.com') || link.includes('youtu.be');
+const isRelated = variant === 'related-topics';
+const isPadded = variant === 'padded';
+const labelText = isYouTube ? 'Watch Video' : 'Learn More';
+const FALLBACK = 'https://i.octopus.com/library/step-templates/other.png';
+---
+
+
+
+
+
+ {
+ !isRelated && (
+
+ )
+ }
+
+
+
+
+
+ {title}
+
+ {
+ !isRelated && (
+
+ )
+ }
+
+ {
+ description && (
+
{description}
+ )
+ }
+ {
+ isRelated && (
+
+
+ {labelText}
+
+
+
+
+ )
+ }
+
+
+
diff --git a/src/ink/core/CodeBlock.astro b/src/ink/core/CodeBlock.astro
new file mode 100644
index 0000000000..43b7da6a80
--- /dev/null
+++ b/src/ink/core/CodeBlock.astro
@@ -0,0 +1,74 @@
+---
+// Calm bordered code surface with a slim label bar (filename/language) and an
+// optional copy affordance. Styles live in styles/code.css (.ink-code); the
+// copy button is a tiny is:inline vanilla script (no framework runtime) - it
+// reads textContent from the sibling .
+import Icon from './Icon.astro';
+
+type Lang = 'bash' | 'shell' | 'json' | 'yaml' | 'powershell' | 'text';
+
+interface Props {
+ label?: string;
+ lang?: Lang;
+ copy?: boolean;
+}
+
+const { label, lang = 'text', copy = true } = Astro.props;
+
+const icons = {
+ bash: 'terminal',
+ shell: 'terminal',
+ powershell: 'terminal',
+ json: 'braces',
+ yaml: 'braces',
+ text: 'file-text',
+} as const satisfies Record;
+---
+
+
+
+
+
+ {label ?? lang}
+
+ {
+ copy && (
+
+
+
+ Copy
+
+
+
+ Copied
+
+
+ )
+ }
+
+
+
+
+
diff --git a/src/ink/core/Icon.astro b/src/ink/core/Icon.astro
new file mode 100644
index 0000000000..e4bfd65634
--- /dev/null
+++ b/src/ink/core/Icon.astro
@@ -0,0 +1,64 @@
+---
+// The single inline-SVG icon registry for Ink. Every component and shell draws
+// from this set instead of pasting raw markup, so the 24x24 stroke
+// language (width 2, round caps/joins) is declared once and stays consistent.
+// Icons inherit color via currentColor and size via the `class` prop.
+const STROKE_PATHS = {
+ 'arrow-right': ' ',
+ 'arrow-up': ' ',
+ 'chevron-down': ' ',
+ 'chevron-right': ' ',
+ menu: ' ',
+ list: ' ',
+ outline: ' ',
+ sun: ' ',
+ moon: ' ',
+ copy: ' ',
+ check: ' ',
+ close:
+ ' ',
+ terminal: ' ',
+ braces:
+ ' ',
+ 'file-text':
+ ' ',
+ info: ' ',
+ success: ' ',
+ warning:
+ ' ',
+ problem:
+ ' ',
+} as const;
+
+// Filled (no stroke) icons - brand marks that don't follow the stroke language.
+const FILL_PATHS = {
+ youtube:
+ ' ',
+} as const;
+
+export type IconName = keyof typeof STROKE_PATHS | keyof typeof FILL_PATHS;
+
+interface Props {
+ name: IconName;
+ class?: string;
+ strokeWidth?: number | string;
+}
+
+const { name, class: cls, strokeWidth = 2 } = Astro.props;
+const filled = name in FILL_PATHS;
+const html = filled
+ ? FILL_PATHS[name as keyof typeof FILL_PATHS]
+ : STROKE_PATHS[name as keyof typeof STROKE_PATHS];
+---
+
+
diff --git a/src/ink/core/Image.astro b/src/ink/core/Image.astro
new file mode 100644
index 0000000000..4e80deea94
--- /dev/null
+++ b/src/ink/core/Image.astro
@@ -0,0 +1,16 @@
+---
+// Content image with optional caption. Renders a semantic ; the visual styling
+// (`.image__img` / `.image__caption`) is the design system's shared figure/image styling
+// in styles/prose.css (the same rules markdown images get), so this stays part of prose.
+interface Props {
+ src: string;
+ alt: string;
+ caption?: string;
+}
+const { src, alt, caption } = Astro.props;
+---
+
+
+
+ {caption && {caption} }
+
diff --git a/src/ink/core/LinkCard.astro b/src/ink/core/LinkCard.astro
new file mode 100644
index 0000000000..5747927027
--- /dev/null
+++ b/src/ink/core/LinkCard.astro
@@ -0,0 +1,40 @@
+---
+// Generic link card: a titled panel that optionally links out with a hover arrow.
+// (Was ink/core/Card.astro; renamed so `Card` can be the content image-card.)
+import Icon from './Icon.astro';
+
+interface Props {
+ href?: string;
+ title?: string;
+ class?: string;
+}
+
+const { href, title, class: extra = '' } = Astro.props;
+// Frame + hover language come from the shared .ink-tile recipe (components.css).
+const interactive = href
+ ? 'group ink-tile--link focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2'
+ : '';
+const cls = ['ink-tile bg-card/70 p-5 backdrop-blur-sm', interactive, extra]
+ .filter(Boolean)
+ .join(' ');
+const Tag = href ? 'a' : 'div';
+// Title may carry a trailing arrow glyph in content; strip it so we render our own.
+const cleanTitle = title?.replace(/\s*→\s*$/, '');
+---
+
+
+ {
+ cleanTitle && (
+
+ {cleanTitle}
+ {href && (
+
+ )}
+
+ )
+ }
+
+
diff --git a/src/ink/core/behavior/external-links.ts b/src/ink/core/behavior/external-links.ts
new file mode 100644
index 0000000000..c618bd14ad
--- /dev/null
+++ b/src/ink/core/behavior/external-links.ts
@@ -0,0 +1,18 @@
+// Security: open off-host links in a new tab with rel=noopener, without clobbering
+// links that already declare their own target/rel. Self-contained (no external deps)
+// so the Ink package works in any Astro repo.
+export function setExternalLinkAttributes(root: ParentNode = document): void {
+ root
+ .querySelectorAll('a[href^="http"]')
+ .forEach((link) => {
+ let destination: URL;
+ try {
+ destination = new URL(link.href);
+ } catch {
+ return;
+ }
+ if (destination.hostname === window.location.hostname) return;
+ if (!link.target) link.setAttribute('target', '_blank');
+ if (!link.rel) link.setAttribute('rel', 'noopener');
+ });
+}
diff --git a/src/ink/core/behavior/figures.ts b/src/ink/core/behavior/figures.ts
new file mode 100644
index 0000000000..9f359fe38b
--- /dev/null
+++ b/src/ink/core/behavior/figures.ts
@@ -0,0 +1,153 @@
+// Content image lightbox. Enhances content images with an accessible "enlarge"
+// affordance that opens a native lightbox: the image scales up on a dimmed,
+// blurred backdrop, can be toggled to its actual size (with pan), and is dismissed via
+// Escape / backdrop / close button. Self-contained (native , no deps, no
+// icon-font), a no-op without matching images, and idempotent. The gives a real
+// focus trap, background inert, top-layer paint and focus-restore for free. Styling lives in
+// styles/components.css (`.ink-lightbox*`, `.magnify-*`); icons are masked-SVG tokens.
+
+let dialog: HTMLDialogElement | null = null;
+let lbImg: HTMLImageElement;
+let lbCaption: HTMLElement;
+let isClosing = false;
+
+const prefersReducedMotion = (): boolean =>
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+function build(): HTMLDialogElement {
+ const d = document.createElement('dialog');
+ d.className = 'ink-lightbox';
+ d.setAttribute('aria-label', 'Image viewer');
+
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'ink-lightbox__close';
+ close.setAttribute('aria-label', 'Close image viewer');
+
+ const figure = document.createElement('figure');
+ figure.className = 'ink-lightbox__figure';
+
+ lbImg = document.createElement('img');
+ lbImg.className = 'ink-lightbox__img';
+ lbImg.alt = '';
+
+ lbCaption = document.createElement('figcaption');
+ lbCaption.className = 'ink-lightbox__caption';
+
+ figure.append(lbImg, lbCaption);
+ d.append(close, figure);
+ document.body.appendChild(d);
+
+ close.addEventListener('click', requestClose);
+ // Click on the backdrop (the dialog element itself, or the figure's padding around the
+ // image) closes; clicking the image toggles actual-size zoom instead.
+ d.addEventListener('click', (e) => {
+ if (e.target === d || e.target === figure) requestClose();
+ });
+ // Escape fires the dialog's `cancel` event; run our animated close instead of the
+ // instant native one.
+ d.addEventListener('cancel', (e) => {
+ e.preventDefault();
+ requestClose();
+ });
+ lbImg.addEventListener('click', (e) => {
+ e.stopPropagation();
+ d.classList.toggle('is-zoomed');
+ });
+
+ return (dialog = d);
+}
+
+function open(src: string, caption: string, alt: string): void {
+ const d = dialog ?? build();
+ isClosing = false;
+ lbImg.src = src;
+ lbImg.alt = alt;
+ lbCaption.textContent = caption;
+ lbCaption.hidden = caption === '';
+ d.classList.remove('is-zoomed');
+ document.documentElement.style.overflow = 'hidden';
+ d.showModal();
+ if (prefersReducedMotion()) {
+ d.classList.add('is-open');
+ } else {
+ requestAnimationFrame(() => d.classList.add('is-open'));
+ }
+}
+
+function requestClose(): void {
+ const d = dialog;
+ if (!d || isClosing) return;
+
+ const finish = (): void => {
+ d.classList.remove('is-open', 'is-zoomed');
+ d.close();
+ document.documentElement.style.overflow = '';
+ lbImg.removeAttribute('src');
+ isClosing = false;
+ };
+
+ if (prefersReducedMotion()) {
+ finish();
+ return;
+ }
+
+ isClosing = true;
+ d.classList.remove('is-open');
+ let done = false;
+ const settle = (): void => {
+ if (done) return;
+ done = true;
+ d.removeEventListener('transitionend', onEnd);
+ finish();
+ };
+ const onEnd = (e: TransitionEvent): void => {
+ if (e.target === d.querySelector('.ink-lightbox__figure')) settle();
+ };
+ d.addEventListener('transitionend', onEnd);
+ window.setTimeout(settle, 400); // fallback if transitionend is missed
+}
+
+function captionFor(img: HTMLImageElement): string {
+ const fig = img.closest('figure');
+ const cap = fig?.querySelector('figcaption, .image__caption');
+ return cap?.textContent?.trim() || img.alt || '';
+}
+
+export function enhanceFigures(root: ParentNode = document): void {
+ root
+ .querySelectorAll(
+ 'figure > p > img, [data-image] > .image__img'
+ )
+ .forEach((img) => {
+ if (img.dataset.inkLightbox) return; // idempotent
+ img.dataset.inkLightbox = 'true';
+
+ const activate = (): void =>
+ open(img.currentSrc || img.src, captionFor(img), img.alt || '');
+
+ // Mouse convenience: the image itself is clickable (not a tab stop, to avoid a
+ // duplicate - keyboard/AT users use the button below).
+ img.style.cursor = 'zoom-in';
+ img.addEventListener('click', activate);
+
+ // Accessible, focusable trigger for keyboard/touch/discoverability.
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'magnify-icon';
+ button.title = 'Enlarge';
+ button.setAttribute(
+ 'aria-label',
+ img.alt ? `Enlarge image: ${img.alt}` : 'Enlarge image'
+ );
+ button.addEventListener('click', (e) => {
+ e.stopPropagation();
+ activate();
+ });
+
+ const container = document.createElement('div');
+ container.className = 'magnify-container';
+ container.appendChild(button);
+ img.insertAdjacentElement('beforebegin', container);
+ });
+}
diff --git a/src/ink/core/behavior/heading-anchors.ts b/src/ink/core/behavior/heading-anchors.ts
new file mode 100644
index 0000000000..26aa2654a3
--- /dev/null
+++ b/src/ink/core/behavior/heading-anchors.ts
@@ -0,0 +1,25 @@
+// Append a permalink anchor to every h2-h6 that has an id, so readers can grab a
+// deep link to a section. The icon is drawn purely in CSS (.bookmark-link, see
+// styles/components.css) via a masked SVG, so this carries no icon-font dependency. Clicking it
+// copies the section URL to the clipboard and shows a toast, rather than jump-scrolling.
+import { showToast, copyToClipboard } from './toast';
+
+export function enhanceHeadingAnchors(root: ParentNode = document): void {
+ root
+ .querySelectorAll('h2[id], h3[id], h4[id], h5[id], h6[id]')
+ .forEach((heading) => {
+ if (heading.querySelector('.bookmark-link')) return; // idempotent
+ const link = document.createElement('a');
+ link.href = `#${heading.id}`;
+ link.className = 'bookmark-link';
+ link.setAttribute('aria-label', 'Copy link to this section');
+ link.addEventListener('click', async (e) => {
+ e.preventDefault();
+ // Reflect the section in the address bar without a scroll jump.
+ history.pushState(null, '', `#${heading.id}`);
+ const ok = await copyToClipboard(link.href);
+ showToast(ok ? 'Link copied' : 'Press ⌘/Ctrl+C to copy', link);
+ });
+ heading.appendChild(link);
+ });
+}
diff --git a/src/ink/core/behavior/input-type.ts b/src/ink/core/behavior/input-type.ts
new file mode 100644
index 0000000000..3207ede130
--- /dev/null
+++ b/src/ink/core/behavior/input-type.ts
@@ -0,0 +1,18 @@
+// a11y: reflect the active input modality as a class on (input-keyboard /
+// input-mouse / input-touch) so focus styling can adapt. Self-contained.
+let current = 'input-none';
+
+function apply(next: string): void {
+ if (next === current) return;
+ document.body.classList.remove(current);
+ document.body.classList.add(next);
+ current = next;
+}
+
+export function monitorInputType(): void {
+ window.addEventListener('keydown', () => apply('input-keyboard'));
+ window.addEventListener('mousemove', () => apply('input-mouse'));
+ window.addEventListener('touchstart', () => apply('input-touch'), {
+ passive: true,
+ });
+}
diff --git a/src/ink/core/behavior/scroll-shade.ts b/src/ink/core/behavior/scroll-shade.ts
new file mode 100644
index 0000000000..22d77e1146
--- /dev/null
+++ b/src/ink/core/behavior/scroll-shade.ts
@@ -0,0 +1,41 @@
+// Scroll-edge shades. ONE implementation for every container that shows fade
+// scrims when content is scrolled out of view (the sidebar nav, the search
+// results dropdown, ...). Contract: mark the container with [data-scroll-shade];
+// the scrolling element is a [data-scroll-shade-viewport] child, or the
+// container itself. The behavior keeps data-at-top / data-at-bottom current;
+// CSS reveals the matching scrim when the value is "false". No-op without the
+// markup.
+
+type ShadeViewport = HTMLElement & { __inkShadeInit?: boolean };
+
+export function observeScrollShades(): void {
+ document
+ .querySelectorAll('[data-scroll-shade]')
+ .forEach((host) => {
+ const viewport = (host.querySelector(
+ '[data-scroll-shade-viewport]'
+ ) ?? host) as ShadeViewport;
+ if (viewport.__inkShadeInit) return;
+ viewport.__inkShadeInit = true;
+
+ const update = () => {
+ host.setAttribute(
+ 'data-at-top',
+ viewport.scrollTop <= 1 ? 'true' : 'false'
+ );
+ host.setAttribute(
+ 'data-at-bottom',
+ viewport.scrollTop + viewport.clientHeight >=
+ viewport.scrollHeight - 1
+ ? 'true'
+ : 'false'
+ );
+ };
+
+ viewport.addEventListener('scroll', update, { passive: true });
+ window.addEventListener('resize', update);
+ // Content can change under the scroller (e.g. search results rendering).
+ new MutationObserver(update).observe(viewport, { childList: true });
+ update();
+ });
+}
diff --git a/src/ink/core/behavior/tabs.ts b/src/ink/core/behavior/tabs.ts
new file mode 100644
index 0000000000..a84be0b4f0
--- /dev/null
+++ b/src/ink/core/behavior/tabs.ts
@@ -0,0 +1,135 @@
+// Convert authored `` siblings into an accessible ARIA
+// tablist (WAI-ARIA manual-activation tabs pattern). Self-contained, no external deps
+// so the Ink package works in any Astro repo. A no-op when no `details[data-group]`
+// exist, and naturally idempotent (it consumes the source elements), so it is
+// safe to run unconditionally. Styling lives in styles/components.css (`.tab-list` / `[role=tab]` /
+// `[role=tabpanel]`); the markup contract is a shared agreement with the content author.
+
+class TabsManual {
+ private tabs: HTMLElement[];
+ private panels: (HTMLElement | null)[] = [];
+ private firstTab: HTMLElement | null = null;
+ private lastTab: HTMLElement | null = null;
+
+ constructor(tablist: HTMLElement) {
+ this.tabs = Array.from(tablist.querySelectorAll('[role=tab]'));
+ this.tabs.forEach((tab) => {
+ const panel = document.getElementById(
+ tab.getAttribute('aria-controls') ?? ''
+ );
+ tab.tabIndex = -1;
+ tab.setAttribute('aria-selected', 'false');
+ this.panels.push(panel);
+ tab.addEventListener('keydown', (e) => this.onKeydown(e, tab));
+ tab.addEventListener('click', () => this.setSelected(tab));
+ if (!this.firstTab) this.firstTab = tab;
+ this.lastTab = tab;
+ });
+ if (this.firstTab) this.setSelected(this.firstTab);
+ }
+
+ private setSelected(current: HTMLElement): void {
+ this.tabs.forEach((tab, i) => {
+ const selected = tab === current;
+ tab.setAttribute('aria-selected', String(selected));
+ if (selected) tab.removeAttribute('tabindex');
+ else tab.tabIndex = -1;
+ this.panels[i]?.classList.toggle('is-hidden', !selected);
+ });
+ }
+
+ private moveFocus(tab: HTMLElement): void {
+ tab.focus();
+ this.setSelected(tab);
+ }
+
+ private onKeydown(event: KeyboardEvent, tab: HTMLElement): void {
+ const index = this.tabs.indexOf(tab);
+ let handled = true;
+ switch (event.key) {
+ case 'ArrowLeft':
+ this.moveFocus(
+ tab === this.firstTab ? this.lastTab! : this.tabs[index - 1]
+ );
+ break;
+ case 'ArrowRight':
+ this.moveFocus(
+ tab === this.lastTab ? this.firstTab! : this.tabs[index + 1]
+ );
+ break;
+ case 'Home':
+ this.moveFocus(this.firstTab!);
+ break;
+ case 'End':
+ this.moveFocus(this.lastTab!);
+ break;
+ default:
+ handled = false;
+ }
+ if (handled) {
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ }
+}
+
+export function enhanceDetailGroups(root: ParentNode = document): void {
+ const groups = new Set();
+ root.querySelectorAll('details[data-group]').forEach((d) => {
+ if (d.dataset.group) groups.add(d.dataset.group);
+ });
+
+ groups.forEach((group) => {
+ const participants = Array.from(
+ root.querySelectorAll(`details[data-group='${group}']`)
+ );
+ if (participants.length === 0) return;
+
+ const parent = participants[0].parentNode;
+ if (!parent) return;
+
+ const tablist = document.createElement('div');
+ tablist.setAttribute('role', 'tablist');
+ tablist.className = 'tab-list';
+ parent.insertBefore(tablist, participants[0]);
+
+ participants.forEach((detail, i) => {
+ const summary = detail.querySelector('summary');
+ const id = `ink-tab-${group}-${i}`;
+ const panelId = `ink-tabpanel-${group}-${i}`;
+
+ const panel = document.createElement('div');
+ panel.setAttribute('tabindex', '0');
+ panel.setAttribute('role', 'tabpanel');
+ panel.setAttribute('aria-labelledby', id);
+ panel.id = panelId;
+
+ // Move the authored nodes (everything except ) into the panel rather
+ // than round-tripping through innerHTML - no HTML reparse, no injection sink, and
+ // nested nodes/state are preserved. The source is removed afterwards.
+ const content = document.createElement('div');
+ Array.from(detail.childNodes).forEach((node) => {
+ if (node.nodeName === 'SUMMARY') return;
+ content.appendChild(node);
+ });
+ panel.appendChild(content);
+ parent.insertBefore(panel, participants[0]);
+
+ const tab = document.createElement('button');
+ tab.id = id;
+ tab.type = 'button';
+ tab.setAttribute('role', 'tab');
+ tab.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
+ tab.setAttribute('aria-controls', panelId);
+
+ const label = document.createElement('span');
+ label.className = 'focus';
+ label.textContent = summary?.textContent ?? '';
+ tab.appendChild(label);
+ tablist.appendChild(tab);
+ });
+
+ new TabsManual(tablist);
+ participants.forEach((detail) => detail.parentNode?.removeChild(detail));
+ });
+}
diff --git a/src/ink/core/behavior/toast.ts b/src/ink/core/behavior/toast.ts
new file mode 100644
index 0000000000..2d3778af3a
--- /dev/null
+++ b/src/ink/core/behavior/toast.ts
@@ -0,0 +1,66 @@
+// Minimal, reusable toast. A single shared element announced via role="status" (screen
+// readers get it), auto-dismissed. Self-contained, no deps; styling is `.ink-toast` in
+// styles/components.css. Pass an `anchor` element to show it right next to where the user acted
+// (e.g. the clicked bookmark) instead of the bottom-center fallback.
+let el: HTMLElement | null = null;
+let label: HTMLElement;
+let timer: number | undefined;
+
+function ensure(): void {
+ if (el) return;
+ el = document.createElement('div');
+ el.className = 'ink-toast';
+ el.setAttribute('role', 'status');
+ el.setAttribute('aria-live', 'polite');
+ const icon = document.createElement('span');
+ icon.className = 'ink-toast__icon';
+ icon.setAttribute('aria-hidden', 'true');
+ label = document.createElement('span');
+ el.append(icon, label);
+ document.body.appendChild(el);
+}
+
+export function showToast(message: string, anchor?: Element | null): void {
+ ensure();
+ const node = el!;
+ label.textContent = message;
+ node.classList.remove('ink-toast--anchored', 'ink-toast--below');
+ node.style.left = '';
+ node.style.top = '';
+ if (anchor) {
+ const r = anchor.getBoundingClientRect();
+ const below = r.top < 72; // too close to the top / under the sticky header
+ node.classList.add('ink-toast--anchored');
+ if (below) node.classList.add('ink-toast--below');
+ node.style.left = `${Math.round(r.left + r.width / 2)}px`;
+ node.style.top = `${Math.round(below ? r.bottom : r.top)}px`;
+ }
+ requestAnimationFrame(() => node.classList.add('is-visible'));
+ window.clearTimeout(timer);
+ timer = window.setTimeout(() => node.classList.remove('is-visible'), 1600);
+}
+
+// Copy text to the clipboard, with a legacy fallback for non-secure contexts.
+export async function copyToClipboard(text: string): Promise {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {
+ // fall through to the legacy path
+ }
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ ta.remove();
+ return ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/src/ink/styles/base.css b/src/ink/styles/base.css
new file mode 100644
index 0000000000..1b486ed5ae
--- /dev/null
+++ b/src/ink/styles/base.css
@@ -0,0 +1,104 @@
+/*
+ Ink base layer: document-level defaults (scrolling, scrollbars, selection) and
+ the decorative page atmosphere (aurora mesh, grid, grain, reveal animation,
+ gradient text). Everything here is markup-agnostic and portable.
+*/
+
+/* Reserve the scrollbar gutter so a late-appearing scrollbar can't shift the
+ centered layout sideways. Smooth in-page scrolling for the TOC anchors. */
+html {
+ scrollbar-gutter: stable;
+ scroll-behavior: smooth;
+ -webkit-text-size-adjust: 100%;
+}
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+}
+
+/* Tinted, thin scrollbars that match the abyss. */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent)
+ transparent;
+}
+*::-webkit-scrollbar {
+ width: 9px;
+ height: 9px;
+}
+*::-webkit-scrollbar-thumb {
+ background: color-mix(in oklab, var(--muted-foreground) 32%, transparent);
+ border-radius: 9999px;
+ border: 2px solid transparent;
+ background-clip: padding-box;
+}
+*::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in oklab, var(--muted-foreground) 55%, transparent);
+ background-clip: padding-box;
+}
+
+::selection {
+ background: color-mix(in oklab, var(--primary) 24%, transparent);
+ color: var(--foreground);
+}
+
+/* Atmosphere: fixed aurora mesh + grid + grain, behind everything (z-index:-1).
+ The layout renders the .ink-atmosphere / .ink-grain divs. */
+.ink-atmosphere {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ background:
+ radial-gradient(52rem 38rem at 12% -8%, var(--aurora-1), transparent 60%),
+ radial-gradient(46rem 40rem at 100% 0%, var(--aurora-2), transparent 58%);
+ background-color: var(--background);
+}
+.ink-atmosphere::before {
+ /* Engineering grid, masked to fade inward. */
+ content: '';
+ position: absolute;
+ inset: 0;
+ background-image:
+ linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
+ linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
+ background-size: 56px 56px;
+ background-position: center top;
+ -webkit-mask-image: radial-gradient(
+ 120% 80% at 50% 0%,
+ #000 0%,
+ transparent 75%
+ );
+ mask-image: radial-gradient(120% 80% at 50% 0%, #000 0%, transparent 75%);
+}
+.ink-grain {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ opacity: var(--grain-opacity);
+ mix-blend-mode: soft-light;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
+}
+
+/* Staggered page-load reveal: [data-rise] fades/lifts, --rise sets the delay. */
+@keyframes ink-rise {
+ from {
+ opacity: 0;
+ transform: translateY(14px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+[data-rise] {
+ animation: ink-rise 0.7s var(--ease-out) both;
+ animation-delay: var(--rise, 0ms);
+}
+@media (prefers-reduced-motion: reduce) {
+ [data-rise] {
+ animation: none;
+ }
+}
diff --git a/src/ink/styles/code.css b/src/ink/styles/code.css
new file mode 100644
index 0000000000..0b239f328e
--- /dev/null
+++ b/src/ink/styles/code.css
@@ -0,0 +1,152 @@
+/*
+ Ink code layer. ONE code surface shared by every form fenced code can take:
+ a bare `` in prose, a Shiki-highlighted ``, and
+ the `` inside the CodeBlock component frame (`.ink-code`, a `not-prose`
+ figure, so the prose selector alone can't reach it). Unlayered - see prose.css.
+*/
+
+.prose :where(pre):not(:where([class~='not-prose'] *)),
+.ink-code > pre {
+ position: relative;
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+ padding: 1.2rem 1.3rem;
+ background: var(--code-bg);
+ color: var(--code-foreground);
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-md);
+ font-family: var(--font-mono);
+ font-size: var(--text-sm);
+ /* 24px line box: code wants more air than the sm copy box. */
+ line-height: var(--text-base--line-height);
+ text-align: left;
+ overflow-x: auto;
+}
+.prose :where(pre code):not(:where([class~='not-prose'] *)),
+.ink-code > pre code {
+ background: transparent;
+ padding: 0;
+ font-weight: 400;
+ font-size: inherit;
+ color: inherit;
+}
+
+/* Dual-theme Shiki tokens. The global shikiConfig emits `light-plus` as the base inline
+ color plus a per-token `--shiki-dark` var (`dark-plus`). Ink renders code on an
+ always-dark surface (`--code-bg` is dark in BOTH page themes), so it uses the dark
+ palette everywhere: force the Ink surface over Shiki's inline light background and
+ read each token's `--shiki-dark` value (falling back to the code foreground for
+ text without a token color). `!important` author rules override Shiki's inline styles. */
+.prose :where(pre.astro-code):not(:where([class~='not-prose'] *)),
+.ink-code > pre.astro-code {
+ background: var(--code-bg) !important;
+ color: var(--code-foreground) !important;
+}
+.prose :where(pre.astro-code span):not(:where([class~='not-prose'] *)),
+.ink-code > pre.astro-code span {
+ color: var(--shiki-dark, var(--code-foreground)) !important;
+ background-color: transparent !important;
+}
+
+/* CodeBlock frame (``): label bar + body in one bordered
+ surface with a lit top edge; the frame carries the border/radius/shadow, so the
+ inner drops its own. */
+.ink-code {
+ position: relative;
+ text-align: left;
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+ border: 1px solid var(--code-border);
+ border-radius: var(--radius-lg);
+ background: var(--code-bg);
+ box-shadow: var(--shadow-md);
+ overflow: hidden;
+}
+.ink-code::before {
+ content: '';
+ position: absolute;
+ inset: -1px -1px auto -1px;
+ height: 1px;
+ background: var(--hairline-brand);
+}
+.ink-code > pre {
+ margin: 0;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+}
+.ink-code__bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 2.6rem;
+ padding: 0 0.5rem 0 1rem;
+ background: var(--code-chrome);
+ border-bottom: 1px solid var(--code-border);
+}
+.ink-code__label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-family: var(--font-mono);
+ font-size: var(--text-xs);
+ letter-spacing: 0.03em;
+ font-weight: 400;
+ color: color-mix(in oklab, var(--code-foreground) 64%, transparent);
+}
+.ink-code__label svg {
+ width: 0.9rem;
+ height: 0.9rem;
+ color: color-mix(in oklab, var(--octo-blue-bright) 70%, transparent);
+}
+/* Copy affordance: a quiet button that brightens to brand on interaction. */
+.ink-code__copy {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ height: 1.8rem;
+ padding: 0 0.6rem;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: color-mix(in oklab, var(--code-foreground) 55%, transparent);
+ font-family: var(--font-sans);
+ font-size: var(--text-xs);
+ font-weight: 400;
+ cursor: pointer;
+ transition:
+ color var(--duration-fast) ease,
+ background-color var(--duration-fast) ease,
+ border-color var(--duration-fast) ease;
+}
+.ink-code__copy svg {
+ width: 0.85rem;
+ height: 0.85rem;
+}
+.ink-code__copy:hover {
+ color: var(--code-foreground);
+ background: color-mix(in oklab, var(--code-foreground) 10%, transparent);
+}
+.ink-code__copy:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+.ink-code__copy[data-copied] {
+ color: var(--accent);
+}
+.ink-code__copy-idle,
+.ink-code__copy-done {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+.ink-code__copy-done {
+ display: none;
+}
+.ink-code__copy[data-copied] .ink-code__copy-idle {
+ display: none;
+}
+.ink-code__copy[data-copied] .ink-code__copy-done {
+ display: inline-flex;
+}
diff --git a/src/ink/styles/components.css b/src/ink/styles/components.css
new file mode 100644
index 0000000000..ddae7e5b4a
--- /dev/null
+++ b/src/ink/styles/components.css
@@ -0,0 +1,835 @@
+/*
+ Ink component layer: the CSS for core components and for the content markup
+ contracts their behaviors enhance (tabs, figures, heading anchors, toast).
+ Rule of the file: every visual recipe is declared ONCE and shared by selector
+ grouping - the component class first, the content/markup contract second.
+*/
+
+/* ── Buttons ─────────────────────────────────────────────────────────────────
+ ONE declaration for both surfaces: the `.ink-btn` component (Button.astro)
+ and content buttons authored in markdown as `.button.button--primary` /
+ `--secondary` (marketing-site class names). Unlayered so both beat the
+ typography plugin's prose-link styling; sizing overrides therefore can't come
+ from utilities - use the `--sm` modifier (or add one), not `h-9 px-4`.
+ --hover-x/--hover-y are set by the pointer-tracking script in Button.astro. */
+.ink-btn,
+.prose a.button {
+ position: relative;
+ z-index: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ max-width: fit-content;
+ overflow: hidden;
+ padding: var(--space-12) var(--space-24);
+ border-radius: var(--radius-md);
+ border: 0.0625rem solid transparent;
+ font-family: var(--font-sans);
+ font-size: var(--button-font-size);
+ font-weight: var(--button-font-weight);
+ line-height: 1.2;
+ white-space: nowrap;
+ text-decoration: none;
+ cursor: pointer;
+ appearance: none;
+ transition-property: background, box-shadow, border-color, color;
+ transition-duration: var(--duration-base);
+ transition-timing-function: ease-in-out;
+}
+.ink-btn--sm {
+ height: 2.25rem;
+ padding: 0 1rem;
+ gap: 0.375rem;
+}
+
+/* Primary: purple fill with a cursor-tracked pink glow. */
+.ink-btn--primary,
+.prose a.button--primary {
+ color: var(--white);
+ background: var(--purple-300);
+}
+.ink-btn--primary::before,
+.prose a.button--primary::before {
+ content: '';
+ position: absolute;
+ left: var(--hover-x, 50%);
+ top: var(--hover-y, 50%);
+ width: 16rem;
+ height: 16rem;
+ background: radial-gradient(
+ circle closest-side,
+ var(--pink-100),
+ transparent
+ );
+ transform: translate(-50%, -50%) scale(0);
+ transition: transform var(--duration-slow) ease;
+ z-index: -1;
+ pointer-events: none;
+}
+.ink-btn--primary:hover,
+.prose a.button--primary:hover {
+ box-shadow: inset 0 0 0 0.0625rem rgba(255, 255, 255, 0.25);
+}
+.ink-btn--primary:hover::before,
+.prose a.button--primary:hover::before {
+ transform: translate(-50%, -50%) scale(1);
+}
+.ink-btn--primary:active,
+.prose a.button--primary:active {
+ box-shadow: var(--button-shadow-inner);
+}
+/* Brand focus (deliberate exception to the canonical 2px ring outline): a wide
+ purple halo that reads on the purple fill, matching the marketing site. */
+.ink-btn--primary:focus-visible,
+.prose a.button--primary:focus-visible {
+ outline: 0.25rem solid color-mix(in oklab, var(--purple-300) 50%, transparent);
+ box-shadow: none;
+}
+.ink-btn--primary:focus-visible::before,
+.prose a.button--primary:focus-visible::before {
+ transform: translate(-50%, -50%) scale(0);
+}
+
+/* Secondary: gradient-border ring (masked padding box) + cursor-tracked glow. */
+.ink-btn--secondary,
+.prose a.button--secondary {
+ color: var(--secondary-button-text);
+ background: var(--secondary-button-bg);
+}
+.ink-btn--secondary::before,
+.prose a.button--secondary::before {
+ content: '';
+ position: absolute;
+ left: var(--hover-x, 50%);
+ top: var(--hover-y, 50%);
+ width: 14rem;
+ height: 14rem;
+ background: radial-gradient(
+ circle closest-side,
+ var(--secondary-button-hover-glow),
+ transparent
+ );
+ transform: translate(-50%, -50%) scale(0);
+ transition: transform var(--duration-slow) ease;
+ z-index: -1;
+ pointer-events: none;
+}
+.ink-btn--secondary::after,
+.prose a.button--secondary::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ padding: 0.0625rem;
+ background: var(--purple-300);
+ -webkit-mask-image: linear-gradient(#fff 0 0), linear-gradient(#fff 0 0);
+ -webkit-mask-clip: content-box, border-box;
+ -webkit-mask-composite: xor;
+ mask-image: linear-gradient(#fff 0 0), linear-gradient(#fff 0 0);
+ mask-clip: content-box, border-box;
+ mask-composite: exclude;
+ pointer-events: none;
+ z-index: 2;
+}
+.ink-btn--secondary svg path {
+ transition: stroke var(--duration-base) ease-in-out;
+}
+.ink-btn--secondary:hover,
+.prose a.button--secondary:hover {
+ color: var(--secondary-button-hover-text);
+ background: var(--secondary-button-hover-bg);
+}
+.ink-btn--secondary:hover svg path {
+ stroke: var(--secondary-button-hover-text);
+}
+.ink-btn--secondary:hover::before,
+.prose a.button--secondary:hover::before {
+ transform: translate(-50%, -50%) scale(1);
+}
+.ink-btn--secondary:hover::after,
+.prose a.button--secondary:hover::after {
+ background: radial-gradient(
+ circle 7rem at var(--hover-x, 50%) var(--hover-y, 50%),
+ var(--secondary-button-hover-border),
+ var(--purple-300)
+ );
+}
+.ink-btn--secondary:active,
+.prose a.button--secondary:active {
+ box-shadow: var(--button-shadow-inner);
+}
+.ink-btn--secondary:active::before,
+.prose a.button--secondary:active::before {
+ transform: translate(-50%, -50%) scale(0);
+}
+.ink-btn--secondary:active::after,
+.prose a.button--secondary:active::after {
+ background: var(--purple-400);
+}
+.ink-btn--secondary:focus-visible,
+.prose a.button--secondary:focus-visible {
+ background: var(--secondary-button-bg);
+ outline: 0.25rem solid var(--purple-300);
+ box-shadow: none;
+}
+.ink-btn--secondary:focus-visible::before,
+.prose a.button--secondary:focus-visible::before {
+ transform: translate(-50%, -50%) scale(0);
+}
+
+/* Low-emphasis text button. */
+.ink-btn--ghost {
+ color: var(--foreground);
+ background: transparent;
+}
+.ink-btn--ghost:hover {
+ background: var(--surface-hover);
+}
+
+/* The CTA's wrapper is inline, so a margin on the inline-flex button
+ can't space it from the next block. Give the wrapper block rhythm instead. */
+.prose span:has(> a.button) {
+ display: block;
+ margin-bottom: 1.25rem;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ink-btn--primary::before,
+ .ink-btn--secondary::before,
+ .prose a.button--primary::before,
+ .prose a.button--secondary::before {
+ transition: none;
+ }
+}
+
+/* ── Eyebrow ─────────────────────────────────────────────────────────────────
+ THE small uppercase micro-label (section kickers, table headers, card
+ labels). One recipe; consumers add their own color/margins. Content contract
+ consumers (thead) are grouped in; the article-header kicker is the one
+ deliberate hero variant (mono, wider tracking) and lives in the shell. */
+.ink-eyebrow,
+.prose :where(thead th):not(:where([class~='not-prose'] *)) {
+ font-size: var(--text-xs);
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+/* ── Callouts ────────────────────────────────────────────────────────────────
+ ONE declaration for both surfaces: the `.ink-callout` component
+ (Callout.astro) and the remark-directive output authored as
+ `:::div{.hint|.info|.success|.warning|.problem|.question}` (raw divs inside
+ .prose). Intent modifiers only set the three custom properties. */
+.ink-callout,
+.prose
+ :where(
+ div.hint,
+ div.info,
+ div.success,
+ div.warning,
+ div.problem,
+ div.question
+ ):not(:where([class~='not-prose'] *)) {
+ position: relative;
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+ padding: 1rem 1.15rem 1rem 1.2rem;
+ border: 1px solid color-mix(in oklab, var(--callout-bd) 32%, transparent);
+ border-left: 3px solid var(--callout-bd);
+ border-radius: var(--radius-lg);
+ background: var(--callout-bg);
+ color: var(--callout-c);
+ font-size: var(--text-base);
+ line-height: var(--leading-prose);
+ box-shadow: var(--shadow-sm);
+}
+.ink-callout--info,
+.prose :where(div.hint, div.info):not(:where([class~='not-prose'] *)) {
+ --callout-c: var(--info);
+ --callout-bg: var(--info-bg);
+ --callout-bd: var(--info-border);
+}
+.ink-callout--success,
+.prose :where(div.success):not(:where([class~='not-prose'] *)) {
+ --callout-c: var(--success);
+ --callout-bg: var(--success-bg);
+ --callout-bd: var(--success-border);
+}
+.ink-callout--warning,
+.prose :where(div.warning):not(:where([class~='not-prose'] *)) {
+ --callout-c: var(--warning);
+ --callout-bg: var(--warning-bg);
+ --callout-bd: var(--warning-border);
+}
+.ink-callout--problem,
+.prose :where(div.problem):not(:where([class~='not-prose'] *)) {
+ --callout-c: var(--problem);
+ --callout-bg: var(--problem-bg);
+ --callout-bd: var(--problem-border);
+}
+.ink-callout--question,
+.prose :where(div.question):not(:where([class~='not-prose'] *)) {
+ --callout-c: var(--question);
+ --callout-bg: var(--question-bg);
+ --callout-bd: var(--question-border);
+}
+/* Paragraph rhythm inside the remark-authored callouts. */
+.prose
+ :where(
+ div.hint,
+ div.info,
+ div.success,
+ div.warning,
+ div.problem,
+ div.question
+ ):not(:where([class~='not-prose'] *)) {
+ & :where(p) {
+ margin-top: 0.5rem;
+ margin-bottom: 0.5rem;
+ color: inherit;
+ }
+ & :where(p:first-child) {
+ margin-top: 0;
+ }
+ & :where(p:last-child) {
+ margin-bottom: 0;
+ }
+}
+/* Icon chip in the component variant. */
+.ink-callout__icon {
+ display: grid;
+ place-items: center;
+ flex: none;
+ width: 1.75rem;
+ height: 1.75rem;
+ margin-top: 0.125rem;
+ border-radius: var(--radius-md);
+ color: var(--callout-bd);
+ background: color-mix(in oklab, var(--callout-bd) 12%, transparent);
+ box-shadow: 0 0 10px 1px
+ color-mix(in oklab, var(--callout-bd) 45%, transparent);
+}
+.ink-callout__icon svg {
+ width: 17px;
+ height: 17px;
+}
+
+/* ── Masked-SVG icons ────────────────────────────────────────────────────────
+ ONE recipe for every icon drawn from the --ink-icon-* tokens: a box filled
+ with currentColor, cut by the mask in `--mask`. Consumers set `--mask`, a
+ size, and (optionally) a tint. Works as a real class (.ink-mask) and for the
+ grouped pseudo-element consumers below. */
+.ink-mask,
+.ink-toast__icon,
+.magnify-icon::before,
+.bookmark-link::before,
+.ink-lightbox__close::before {
+ content: '';
+ display: inline-block;
+ flex: none;
+ width: 1em;
+ height: 1em;
+ background-color: currentColor;
+ -webkit-mask: var(--mask) center / contain no-repeat;
+ mask: var(--mask) center / contain no-repeat;
+}
+
+/* ── Interactive tile ────────────────────────────────────────────────────────
+ The shared card language: bordered surface; linking tiles lift, light their
+ border, glow, and fade in a brand hairline on the top edge. Used by
+ Card.astro / LinkCard.astro; the docs shell reuses the same tokens for its
+ own tiles (journey buttons, search results). In the components layer so
+ layout utilities (padding, background) can override. */
+@layer components {
+ .ink-tile {
+ position: relative;
+ display: flex;
+ height: 100%;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ background: var(--card);
+ color: var(--card-foreground);
+ transition-property: translate, box-shadow, border-color, background-color;
+ transition-duration: var(--duration-slow);
+ transition-timing-function: var(--ease-out);
+ }
+ .ink-tile--link::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 1.25rem;
+ right: 1.25rem;
+ height: 1px;
+ background: var(--hairline-primary);
+ opacity: 0;
+ transition: opacity var(--duration-slow) ease;
+ }
+ .ink-tile--link:hover {
+ translate: 0 -2px;
+ border-color: var(--border-hover);
+ box-shadow: var(--shadow-glow);
+ }
+ .ink-tile--link:hover::before {
+ opacity: 1;
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .ink-tile,
+ .ink-tile--link::before {
+ transition: none;
+ }
+ .ink-tile--link:hover {
+ translate: none;
+ }
+ }
+}
+
+/* ── Status pills (used in content tables to render state cells) ────────────
+ The content-table sibling of Badge.astro: same pill idea, but authored in
+ markdown and carrying the live status dot. Kept separate on purpose - Badge
+ is UI chrome, this is a content contract. */
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.15rem 0.6rem 0.15rem 0.5rem;
+ border-radius: 9999px;
+ font-size: var(--text-xs);
+ font-weight: 700;
+ line-height: var(--text-xs--line-height);
+ white-space: nowrap;
+ border: 1px solid transparent;
+}
+.status-dot {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 9999px;
+ background: currentColor;
+ box-shadow: 0 0 0 3px color-mix(in oklab, currentColor 22%, transparent);
+ animation: ink-pulse 2.4s ease-in-out infinite;
+}
+@keyframes ink-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 color-mix(in oklab, currentColor 35%, transparent);
+ }
+ 50% {
+ box-shadow: 0 0 0 4px color-mix(in oklab, currentColor 8%, transparent);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .status-dot {
+ animation: none;
+ }
+}
+.status-pill--ok {
+ color: var(--success-border);
+ background: color-mix(in oklab, var(--success-border) 12%, var(--background));
+ border-color: color-mix(in oklab, var(--success-border) 30%, transparent);
+}
+.status-pill--wait {
+ color: var(--warning-border);
+ background: color-mix(in oklab, var(--warning-border) 12%, var(--background));
+ border-color: color-mix(in oklab, var(--warning-border) 30%, transparent);
+}
+
+/* ── Detail tabs ─────────────────────────────────────────────────────────────
+ core/behavior/tabs.ts converts authored siblings
+ into an ARIA tablist. Semantic tokens only, so it themes automatically. */
+.tab-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.15rem;
+ margin: 1.5rem 0 0;
+ padding: 0;
+ border-bottom: 1px solid var(--border);
+}
+.tab-list button[role='tab'] {
+ appearance: none;
+ margin: 0 0 -1px;
+ padding: 0;
+ border: 0;
+ background: none;
+ font: inherit;
+ font-weight: 700;
+ color: var(--muted-foreground);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition:
+ color var(--duration-fast) ease,
+ border-color var(--duration-fast) ease;
+}
+.tab-list button[role='tab'] span.focus {
+ display: inline-block;
+ padding: 0.55rem 0.9rem;
+ border-radius: var(--radius-lg) var(--radius-lg) 0 0;
+}
+.tab-list button[role='tab']:hover {
+ color: var(--foreground);
+}
+.tab-list button[role='tab'][aria-selected='true'] {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+}
+.tab-list button[role='tab']:focus-visible span.focus {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+[role='tabpanel'] {
+ padding: 1.15rem 0.15rem;
+}
+[role='tabpanel'].is-hidden {
+ display: none;
+}
+[role='tabpanel'] > div > :first-child {
+ margin-top: 0;
+}
+
+/* ── Image magnify + lightbox ────────────────────────────────────────────────
+ core/behavior/figures.ts adds an "enlarge" button over content images and
+ opens a native lightbox. */
+
+/* Trigger: a floating button in the image's top-right, revealed on figure hover/focus
+ and always shown on touch (via the input-type body class). Zero-height overlay row so
+ it doesn't shift the image. */
+.magnify-container {
+ position: relative;
+ top: 0.65rem;
+ z-index: 1;
+ width: 100%;
+ max-height: 0;
+ margin: 0;
+ padding-inline-end: 0.65rem;
+ text-align: end;
+ pointer-events: none;
+}
+.magnify-icon {
+ pointer-events: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.4rem;
+ height: 2.4rem;
+ border: 1px solid var(--border);
+ border-radius: 9999px;
+ background: color-mix(in oklab, var(--background) 82%, transparent);
+ -webkit-backdrop-filter: blur(6px) saturate(1.2);
+ backdrop-filter: blur(6px) saturate(1.2);
+ color: var(--primary);
+ cursor: zoom-in;
+ opacity: 0;
+ box-shadow: var(--shadow-md);
+ transition:
+ opacity var(--duration-base) ease,
+ transform var(--duration-base) ease,
+ background var(--duration-base) ease;
+}
+.magnify-icon::before {
+ --mask: var(--ink-icon-magnify);
+ width: 1.2rem;
+ height: 1.2rem;
+}
+figure:hover .magnify-icon,
+figure:focus-within .magnify-icon,
+.magnify-icon:focus-visible {
+ opacity: 1;
+}
+.input-touch .magnify-icon {
+ opacity: 1;
+}
+.magnify-icon:hover {
+ transform: scale(1.08);
+ background: var(--background);
+}
+.magnify-icon:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+/* Lightbox: native in the top layer. Fills the viewport, centers the figure,
+ dims + blurs everything behind. Enter/leave animate opacity+scale on the figure and
+ opacity on the backdrop; `.is-open` is toggled by the behavior a frame after showModal. */
+.ink-lightbox {
+ width: 100vw;
+ height: 100dvh;
+ max-width: 100vw;
+ max-height: 100dvh;
+ margin: 0;
+ padding: clamp(1rem, 4vw, 3rem);
+ border: 0;
+ background: transparent;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+.ink-lightbox[open] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.ink-lightbox::backdrop {
+ background: rgba(4, 12, 20, 0.8);
+ -webkit-backdrop-filter: blur(7px);
+ backdrop-filter: blur(7px);
+ opacity: 0;
+ transition: opacity var(--duration-slow) ease;
+}
+.ink-lightbox.is-open::backdrop {
+ opacity: 1;
+}
+.ink-lightbox__figure {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.85rem;
+ max-width: 100%;
+ max-height: 100%;
+ margin: 0;
+ opacity: 0;
+ transform: scale(0.94);
+ transition:
+ opacity var(--duration-slow) ease,
+ transform var(--duration-slow) var(--ease-out);
+}
+.ink-lightbox.is-open .ink-lightbox__figure {
+ opacity: 1;
+ transform: none;
+}
+.ink-lightbox__img {
+ display: block;
+ max-width: 92vw;
+ max-height: 82dvh;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ border-radius: var(--radius-lg);
+ background: var(--background);
+ box-shadow: 0 30px 90px -25px rgba(0, 0, 0, 0.75);
+ cursor: zoom-in;
+}
+.ink-lightbox__caption {
+ max-width: min(92vw, 60ch);
+ margin: 0;
+ color: rgba(255, 255, 255, 0.86);
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+ text-align: center;
+}
+.ink-lightbox__caption[hidden] {
+ display: none;
+}
+/* Actual-size mode: image renders at its natural dimensions; the dialog scrolls to pan. */
+.ink-lightbox.is-zoomed {
+ overflow: auto;
+ overscroll-behavior: contain;
+}
+.ink-lightbox.is-zoomed .ink-lightbox__figure {
+ max-width: none;
+ max-height: none;
+ margin: auto;
+}
+.ink-lightbox.is-zoomed .ink-lightbox__img {
+ max-width: none;
+ max-height: none;
+ cursor: zoom-out;
+}
+.ink-lightbox__close {
+ position: fixed;
+ top: clamp(0.75rem, 2vw, 1.35rem);
+ right: clamp(0.75rem, 2vw, 1.35rem);
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.75rem;
+ height: 2.75rem;
+ border: 1px solid rgba(255, 255, 255, 0.28);
+ border-radius: 9999px;
+ background: rgba(255, 255, 255, 0.12);
+ -webkit-backdrop-filter: blur(4px);
+ backdrop-filter: blur(4px);
+ color: #fff;
+ cursor: pointer;
+ transition:
+ background var(--duration-fast) ease,
+ transform var(--duration-fast) ease;
+}
+.ink-lightbox__close:hover {
+ background: rgba(255, 255, 255, 0.24);
+ transform: scale(1.06);
+}
+/* White focus (deliberate exception): the ring token vanishes on photo overlays. */
+.ink-lightbox__close:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+}
+.ink-lightbox__close::before {
+ --mask: var(--ink-icon-close);
+ width: 1.2rem;
+ height: 1.2rem;
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-lightbox__figure,
+ .ink-lightbox::backdrop {
+ transition: none;
+ }
+ .ink-lightbox__figure {
+ transform: none;
+ }
+}
+
+/* ── Toast (core/behavior/toast.ts) ──────────────────────────────────────────
+ A brief confirmation pill, bottom-center, that fades up and auto-dismisses. */
+.ink-toast {
+ position: fixed;
+ z-index: 300;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.85rem;
+ border-radius: 9999px;
+ border: 1px solid var(--border);
+ background: var(--card);
+ color: var(--foreground);
+ font-size: var(--text-sm);
+ font-weight: 400;
+ white-space: nowrap;
+ box-shadow: var(--shadow-lg);
+ pointer-events: none;
+ opacity: 0;
+ transition:
+ opacity var(--duration-fast) ease,
+ transform var(--duration-fast) ease;
+}
+.ink-toast.is-visible {
+ opacity: 1;
+}
+.ink-toast__icon {
+ --mask: var(--ink-icon-check);
+ width: 1rem;
+ height: 1rem;
+ background-color: var(--success-border);
+}
+
+/* Default placement: bottom-center (fallback when no anchor is given). */
+.ink-toast:not(.ink-toast--anchored) {
+ left: 50%;
+ bottom: 1.5rem;
+ transform: translate(-50%, 0.75rem);
+}
+.ink-toast:not(.ink-toast--anchored).is-visible {
+ transform: translate(-50%, 0);
+}
+
+/* Anchored placement: `left`/`top` set inline to the clicked element; sits just above it
+ (or below when the element is near the top), with a small pop-in. */
+.ink-toast--anchored {
+ bottom: auto;
+ transform-origin: center bottom;
+ transform: translate(-50%, calc(-100% - 8px)) scale(0.94);
+}
+.ink-toast--anchored.is-visible {
+ transform: translate(-50%, calc(-100% - 8px)) scale(1);
+}
+.ink-toast--anchored.ink-toast--below {
+ transform-origin: center top;
+ transform: translate(-50%, 8px) scale(0.94);
+}
+.ink-toast--anchored.ink-toast--below.is-visible {
+ transform: translate(-50%, 8px) scale(1);
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-toast:not(.ink-toast--anchored),
+ .ink-toast:not(.ink-toast--anchored).is-visible {
+ transform: translate(-50%, 0);
+ }
+ .ink-toast--anchored,
+ .ink-toast--anchored.is-visible {
+ transform: translate(-50%, calc(-100% - 8px));
+ }
+ .ink-toast--anchored.ink-toast--below,
+ .ink-toast--anchored.ink-toast--below.is-visible {
+ transform: translate(-50%, 8px);
+ }
+}
+
+/* ── Skip links (WCAG bypass blocks) ─────────────────────────────────────────
+ Off-screen until a link receives focus, then it slides into the top-left. */
+.ink-skip-links a {
+ position: fixed;
+ top: 0.5rem;
+ left: -9999px;
+ z-index: 200;
+ padding: 0.6rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--background);
+ color: var(--primary);
+ font-weight: 700;
+ text-decoration: none;
+ box-shadow: var(--shadow-md);
+}
+.ink-skip-links a:focus,
+.ink-skip-links a:focus-visible {
+ left: 0.5rem;
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+/* ── Heading anchor links ────────────────────────────────────────────────────
+ core/behavior/heading-anchors.ts appends to every
+ h2-h6[id]; revealed on heading hover/focus. Base rule is global so an anchor
+ added outside .prose still styles correctly; the .prose override cancels the
+ animated-underline link treatment. */
+.bookmark-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.4em;
+ height: 1.4em;
+ margin-inline-start: 0.15rem;
+ vertical-align: middle;
+ /* em-relative radius on purpose: the anchor scales with its heading. */
+ border-radius: 0.4em;
+ color: var(--primary);
+ text-decoration: none;
+ opacity: 0;
+ transform: translateX(-0.15em);
+ transition:
+ opacity var(--duration-base) ease,
+ transform var(--duration-base) ease,
+ background-color var(--duration-base) ease;
+}
+.bookmark-link::before {
+ --mask: var(--ink-icon-link);
+ width: 0.82em;
+ height: 0.82em;
+}
+/* Revealed on heading hover or keyboard focus, with a subtle slide-in. */
+:is(h2, h3, h4, h5, h6):hover .bookmark-link,
+.bookmark-link:focus-visible {
+ opacity: 1;
+ transform: translateX(0);
+}
+/* Hovering the icon itself gives a soft ghost-button backdrop. */
+.bookmark-link:hover {
+ background-color: var(--wash-primary);
+}
+.bookmark-link:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+/* Touch / no-hover devices can't reveal on hover, so keep it gently visible. */
+@media (hover: none) {
+ .bookmark-link {
+ opacity: 0.55;
+ transform: none;
+ }
+}
+.prose a.bookmark-link:not(.button) {
+ background-image: none;
+ padding-bottom: 0;
+ font-weight: 400;
+ color: var(--primary);
+}
diff --git a/src/ink/styles/prose.css b/src/ink/styles/prose.css
new file mode 100644
index 0000000000..3d07fa92fe
--- /dev/null
+++ b/src/ink/styles/prose.css
@@ -0,0 +1,345 @@
+/*
+ Ink prose layer: brand-tuned typography for markdown content rendered inside
+ `.prose`, plus the content constructs authored via directives (grids, figures,
+ tables). Intentionally UNLAYERED to beat the typography plugin's utility-layer
+ defaults (keeps brand colors + dark mode). Don't move these into a layer or the
+ brand colors will lose to the plugin (and dark-mode body text will go dark).
+*/
+
+.prose {
+ --tw-prose-body: var(--foreground);
+ --tw-prose-headings: var(--foreground);
+ --tw-prose-links: var(--primary);
+ --tw-prose-bold: var(--foreground);
+ --tw-prose-counters: var(--muted-foreground);
+ --tw-prose-bullets: var(--octo-navy-300);
+ --tw-prose-hr: var(--border);
+ --tw-prose-quotes: var(--foreground);
+ --tw-prose-quote-borders: var(--primary);
+ --tw-prose-captions: var(--muted-foreground);
+ --tw-prose-code: var(--foreground);
+ --tw-prose-th-borders: var(--border);
+ --tw-prose-td-borders: var(--border);
+ --tw-prose-pre-bg: var(--code-bg);
+ --tw-prose-pre-code: var(--code-foreground);
+ max-width: 46rem;
+ font-size: var(--text-base);
+ line-height: var(--leading-prose);
+}
+
+/* Confident display headings with a luminous anchored accent. */
+.prose :where(h2):not(:where([class~='not-prose'] *)) {
+ position: relative;
+ margin-top: 3rem;
+ margin-bottom: 1rem;
+ font-family: var(--font-display);
+ font-size: var(--text-h2);
+ line-height: var(--text-h2--line-height);
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ scroll-margin-top: 6rem;
+}
+/* Glowing tick to the left of every H2, centered on the first line. */
+.prose :where(h2):not(:where([class~='not-prose'] *))::before {
+ content: '';
+ position: absolute;
+ left: -1.15rem;
+ top: 50%;
+ transform: translateY(-50%);
+ height: 0.82em;
+ width: 3px;
+ border-radius: 9999px;
+ background: var(--gradient-brand);
+ box-shadow: var(--glow-dot);
+}
+.prose :where(h3):not(:where([class~='not-prose'] *)) {
+ margin-top: 2.1rem;
+ margin-bottom: 0.6rem;
+ font-family: var(--font-display);
+ font-size: var(--text-h3);
+ line-height: var(--text-h3--line-height);
+ font-weight: 700;
+ letter-spacing: -0.014em;
+ scroll-margin-top: 6rem;
+}
+/* Deep headings: same voice, tapered scale steps. */
+.prose :where(h4, h5, h6):not(:where([class~='not-prose'] *)) {
+ margin-top: 1.75rem;
+ margin-bottom: 0.5rem;
+ font-family: var(--font-display);
+ font-weight: 700;
+ letter-spacing: -0.01em;
+ scroll-margin-top: 6rem;
+}
+.prose :where(h4):not(:where([class~='not-prose'] *)) {
+ font-size: var(--text-h4);
+ line-height: var(--text-h4--line-height);
+}
+.prose :where(h5):not(:where([class~='not-prose'] *)) {
+ font-size: var(--text-h5);
+ line-height: var(--text-h5--line-height);
+}
+.prose :where(h6):not(:where([class~='not-prose'] *)) {
+ font-size: var(--text-h6);
+ line-height: var(--text-h6--line-height);
+}
+.prose :where(h2, h3):not(:where([class~='not-prose'] *)):first-child {
+ margin-top: 0;
+}
+.prose :where(p, ul, ol):not(:where([class~='not-prose'] *)) {
+ margin-top: 0;
+ margin-bottom: 1.25rem;
+}
+.prose :where(strong):not(:where([class~='not-prose'] *)) {
+ font-weight: 700;
+ color: var(--foreground);
+}
+
+/* Standard content link: no underline at rest; on hover it draws in from the left
+ (matches the marketing site). Uses background-size width (0 -> 100%) rather than an
+ absolute ::after, so it works on links that wrap across lines. Buttons opt out.
+ SINGLE SOURCE OF TRUTH - every prose link gets it automatically, and any in-content
+ link OUTSIDE `.prose` opts in with `class="ink-link"` (e.g. Edit-on-GitHub in the
+ article footer). Change the treatment here and it updates everywhere. */
+.prose :where(a):not(.button):not(:where([class~='not-prose'] *)),
+.ink-link {
+ font-weight: 400;
+ text-decoration: none;
+ color: var(--primary);
+ background-image: linear-gradient(currentColor, currentColor);
+ background-position: 0 100%;
+ background-repeat: no-repeat;
+ background-size: 0% 1px;
+ padding-bottom: 1px;
+ transition:
+ background-size var(--duration-base) ease-in-out,
+ color var(--duration-base) ease-in-out;
+}
+.prose :where(a):not(.button):not(:where([class~='not-prose'] *)):hover,
+.prose :where(a):not(.button):not(:where([class~='not-prose'] *)):focus-visible,
+.ink-link:hover,
+.ink-link:focus-visible {
+ background-size: 100% 1px;
+}
+
+/* Lists: tidy markers and spacing. */
+.prose :where(ol > li, ul > li):not(:where([class~='not-prose'] *)) {
+ margin-top: 0.4rem;
+ margin-bottom: 0.4rem;
+ padding-left: 0.4em;
+}
+.prose :where(ol > li)::marker:not(:where([class~='not-prose'] *)) {
+ font-weight: 700;
+ color: var(--primary);
+}
+.prose :where(ul > li)::marker:not(:where([class~='not-prose'] *)) {
+ color: var(--primary);
+}
+
+/* Blockquote: glassy panel with a luminous edge. */
+.prose :where(blockquote):not(:where([class~='not-prose'] *)) {
+ margin: 1.5rem 0;
+ padding: 0.9rem 1.25rem;
+ border: 0;
+ border-left: 3px solid transparent;
+ border-image: var(--gradient-brand) 1;
+ border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
+ background: color-mix(in oklab, var(--primary) 6%, transparent);
+ font-style: normal;
+ font-weight: 400;
+ color: var(--foreground);
+}
+.prose :where(blockquote p:first-of-type)::before,
+.prose :where(blockquote p:last-of-type)::after {
+ content: '';
+}
+
+/* Horizontal rule: a faded brand line. */
+.prose :where(hr):not(:where([class~='not-prose'] *)) {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+ border: 0;
+ height: 1px;
+ background: linear-gradient(
+ to right,
+ transparent,
+ var(--border) 18%,
+ var(--border) 82%,
+ transparent
+ );
+}
+
+/* --- Tables: zebra + hover, token-aligned borders, refined header --- */
+.prose :where(table):not(:where([class~='not-prose'] *)) {
+ width: 100%;
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ border-collapse: separate;
+ border-spacing: 0;
+ overflow: hidden;
+ font-size: var(--text-sm);
+ background: color-mix(in oklab, var(--card) 60%, transparent);
+}
+.prose :where(thead):not(:where([class~='not-prose'] *)) {
+ background: var(--muted);
+ border-bottom: 1px solid var(--border);
+}
+/* Header cells take the shared eyebrow type recipe (components.css). */
+.prose :where(thead th):not(:where([class~='not-prose'] *)) {
+ padding: 0.72rem 1rem;
+ color: var(--muted-foreground);
+ border-bottom: 1px solid var(--border);
+}
+.prose :where(tbody td):not(:where([class~='not-prose'] *)) {
+ padding: 0.72rem 1rem;
+ border-bottom: 1px solid var(--border);
+ vertical-align: middle;
+}
+.prose :where(tbody tr:last-child td):not(:where([class~='not-prose'] *)) {
+ border-bottom: 0;
+}
+.prose :where(tbody tr):not(:where([class~='not-prose'] *)):nth-child(even) {
+ background: color-mix(in oklab, var(--muted) 45%, transparent);
+}
+.prose :where(tbody tr):not(:where([class~='not-prose'] *)):hover {
+ background: color-mix(in oklab, var(--primary) 7%, transparent);
+}
+
+/* --- Table wrapper (wrapTables -> ) --- */
+.prose :where(.table-wrap):not(:where([class~='not-prose'] *)) {
+ margin-top: 1.5rem;
+ margin-bottom: 1.5rem;
+ overflow-x: auto;
+}
+.prose :where(.table-wrap > table):not(:where([class~='not-prose'] *)) {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+/* Inline code chips, brand-tinted */
+.prose :not(pre) > code {
+ background: color-mix(in oklab, var(--primary) 10%, var(--background));
+ color: color-mix(in oklab, var(--primary) 72%, var(--foreground));
+ border: 1px solid color-mix(in oklab, var(--primary) 20%, transparent);
+ padding: 0.12em 0.42em;
+ border-radius: var(--radius-sm);
+ font-family: var(--font-mono);
+ font-weight: 400;
+ font-size: 0.875em;
+ /* Long unbroken tokens (URLs, paths) must wrap inside the container, not overflow it. */
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+.prose :not(pre) > code::before,
+.prose :not(pre) > code::after {
+ content: none;
+}
+
+/* Content grids: `
` / `.simple-grid-3` (often inside `.docs-home`),
+ authored on landing/index pages. 2- or 3-column grid that collapses to one column on narrow
+ screens. */
+.simple-grid,
+.simple-grid-3 {
+ display: grid;
+ gap: 1.25rem;
+}
+.simple-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+.simple-grid-3 {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+.simple-grid > * > * {
+ width: 100%;
+}
+.simple-grid .astro-code {
+ text-align: left;
+}
+.docs-home {
+ margin-block: 3rem;
+ text-align: center;
+}
+@media (max-width: 860px) {
+ .simple-grid,
+ .simple-grid-3 {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
+/* Inline note: a footnote marker inside table cells; absolutely positioned so it doesn't
+ widen the cell. */
+.inline-note {
+ position: absolute;
+ padding-left: 0.5rem;
+}
+
+/* In-content image "glass" effect (from the marketing site), scoped to .prose
+ so chrome imagery is untouched. Frosted panel + blurred bloom (::before)
+ behind; image/caption above via z-index. Tokens: --glass-* in tokens.css. */
+.prose :where(figure, .image, [data-image]):not(:where([class~='not-prose'] *)),
+.prose p:has(> img.resp-img:only-child):not(:where([class~='not-prose'] *)) {
+ position: relative;
+ isolation: isolate;
+ width: fit-content;
+ max-width: 100%;
+ margin: 2.25rem auto;
+ padding: var(--glass-pad);
+ border-radius: var(--radius-lg);
+ background: var(--glass-background);
+ border: 1px solid var(--glass-border-color);
+ box-shadow: var(--glass-shadow);
+ overflow: visible;
+ text-align: center;
+}
+.prose
+ :where(figure, .image, [data-image]):not(
+ :where([class~='not-prose'] *)
+ )::before,
+.prose
+ p:has(> img.resp-img:only-child):not(:where([class~='not-prose'] *))::before {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ filter: blur(60px);
+ z-index: 0;
+ pointer-events: none;
+ background: var(--glass-blur-glow);
+}
+/* Inner image stacks above the bloom. */
+.prose
+ :where(figure, .image, [data-image]):not(:where([class~='not-prose'] *))
+ :where(img, .image__img),
+.prose
+ p:has(> img.resp-img:only-child):not(:where([class~='not-prose'] *))
+ > img {
+ position: relative;
+ z-index: 1;
+ display: block;
+ max-width: 100%;
+ height: auto;
+ /* Zero the plugin's block margins so glass padding is uniform on all sides. */
+ margin: 0 auto;
+ border-radius: var(--radius-md);
+}
+/* Strip prose margin from a wrapping
inside the figure. */
+.prose
+ :where(figure, .image, [data-image]):not(:where([class~='not-prose'] *))
+ :where(p):not(:where([class~='not-prose'] *)) {
+ margin: 0;
+}
+.prose :where(.image__caption, figcaption):not(:where([class~='not-prose'] *)) {
+ position: relative;
+ z-index: 1;
+ display: block;
+ margin-top: 0.7rem;
+ text-align: center;
+ color: var(--muted-foreground);
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+}
diff --git a/src/ink/theme.css b/src/ink/theme.css
new file mode 100644
index 0000000000..07f44f3282
--- /dev/null
+++ b/src/ink/theme.css
@@ -0,0 +1,103 @@
+/*
+ Ink entry point. Importing this opts a page in: Tailwind v4 + typography
+ plugin + brand tokens mapped into the theme + the package style layers.
+ Web fonts load in the layout
. Nothing here leaks onto pages that
+ don't import it.
+
+ Layer map (all package-owned; the docs shell's chrome CSS lives in the host
+ at src/layouts/ink-docs/shell.css):
+ - tokens.css / tokens.decorative.css the customization surface
+ - styles/base.css document defaults + atmosphere
+ - styles/prose.css markdown typography + content constructs
+ - styles/code.css the one code surface (pre/Shiki/CodeBlock)
+ - styles/components.css core components + enhanced-markup contracts
+*/
+
+@import 'tailwindcss';
+@import './tokens.css';
+@import './tokens.decorative.css';
+@import './styles/base.css';
+@import './styles/prose.css';
+@import './styles/code.css';
+@import './styles/components.css';
+@plugin '@tailwindcss/typography';
+
+/* Dark mode is driven by the existing [data-theme="dark"] attribute, not a class. */
+@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
+
+/* Enforce the Ink type scale: wipe Tailwind's default font-size utilities so a
+ size outside tokens.css simply doesn't compile, then map ONLY the scale back
+ in (utilities: text-xs/sm/base, text-title, text-h1..h6). */
+@theme {
+ --text-*: initial;
+ /* Two weights only: regular and bold. Off-scale weight utilities
+ (font-medium, font-semibold, ...) don't compile. */
+ --font-weight-*: initial;
+ --font-weight-normal: 400;
+ --font-weight-bold: 700;
+ /* Radius: only the Ink steps compile (rounded-sm/md/lg/xl/full). */
+ --radius-*: initial;
+}
+
+@theme inline {
+ --text-xs: var(--text-xs);
+ --text-xs--line-height: var(--text-xs--line-height);
+ --text-sm: var(--text-sm);
+ --text-sm--line-height: var(--text-sm--line-height);
+ --text-base: var(--text-base);
+ --text-base--line-height: var(--text-base--line-height);
+ --text-title: var(--text-title);
+ --text-title--line-height: var(--text-title--line-height);
+ --text-h1: var(--text-h1);
+ --text-h1--line-height: var(--text-h1--line-height);
+ --text-h2: var(--text-h2);
+ --text-h2--line-height: var(--text-h2--line-height);
+ --text-h3: var(--text-h3);
+ --text-h3--line-height: var(--text-h3--line-height);
+ --text-h4: var(--text-h4);
+ --text-h4--line-height: var(--text-h4--line-height);
+ --text-h5: var(--text-h5);
+ --text-h5--line-height: var(--text-h5--line-height);
+ --text-h6: var(--text-h6);
+ --text-h6--line-height: var(--text-h6--line-height);
+
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-surface: var(--surface);
+ --color-surface-2: var(--surface-2);
+
+ --font-display: var(--font-display);
+ --font-sans: var(--font-sans);
+ --font-mono: var(--font-mono);
+
+ --radius-sm: var(--radius-sm);
+ --radius-md: var(--radius-md);
+ --radius-lg: var(--radius-lg);
+ --radius-xl: var(--radius-xl);
+
+ --shadow-sm: var(--shadow-sm);
+ --shadow-md: var(--shadow-md);
+ --shadow-lg: var(--shadow-lg);
+ --shadow-glow: var(--shadow-glow);
+
+ --ease-out: var(--ease-out);
+}
diff --git a/src/ink/tokens.css b/src/ink/tokens.css
new file mode 100644
index 0000000000..3638ba5bd2
--- /dev/null
+++ b/src/ink/tokens.css
@@ -0,0 +1,325 @@
+/*
+ Ink token contract: brand primitives + shadcn-style semantic tokens + the
+ core-required decorative tokens (glow-primary/shadow-glow, glass, button palette,
+ icons, fonts). This is the REQUIRED foundation every Ink shell needs; it ports
+ 1:1 unchanged. The tunable "abyssal/bioluminescent" brand MOOD (aurora, grid,
+ grain, gradients, glow-brand) is split into tokens.decorative.css, imported by
+ theme.css right after this file. Dark mode keys off [data-theme="dark"].
+ Brand source: public/docs/css/vars.css.
+*/
+
+:root {
+ /* --- Brand primitives (from vars.css) --- */
+ --octo-blue: #1a77ca;
+ --octo-blue-light: #2f95e3;
+ --octo-blue-bright: #1fc0ff;
+ --octo-green: #00e693;
+ --octo-green-bright: #00ffa3;
+ --octo-navy-900: #10212f;
+ --octo-navy-800: #152b3d;
+ --octo-navy-700: #2e475d;
+ --octo-navy-600: #3e607d;
+ --octo-navy-400: #7c98b4;
+ --octo-navy-300: #a9bbcb;
+ --octo-navy-200: #dae2e9;
+ --octo-navy-100: #f4f6f8;
+ --octo-blue-100: #f2f8fd;
+
+ /* --- Semantic tokens (shadcn shape) --- */
+ --background: #f7fafc;
+ --foreground: var(--octo-navy-900);
+
+ --card: #ffffff;
+ --card-foreground: var(--octo-navy-900);
+
+ --popover: #ffffff;
+ --popover-foreground: var(--octo-navy-900);
+
+ --primary: var(--octo-blue);
+ --primary-foreground: #ffffff;
+
+ /* Octopus CTA green is the brand "accent" action color */
+ --accent: #00b86f;
+ --accent-foreground: #04241a;
+
+ --secondary: var(--octo-navy-100);
+ --secondary-foreground: var(--octo-navy-700);
+
+ --muted: var(--octo-navy-100);
+ --muted-foreground: #587088;
+
+ --border: #dde6ee;
+ --input: var(--octo-navy-200);
+ --ring: var(--octo-blue);
+
+ --destructive: #d63d3d;
+ --destructive-foreground: #ffffff;
+
+ /* Callout intents (map to existing hint/info/success/warning/problem) */
+ --info: #124164;
+ --info-bg: #eaf5ff;
+ --info-border: var(--octo-blue);
+ --success: #04502f;
+ --success-bg: #e6fbef;
+ --success-border: #00b065;
+ --warning: #a23623;
+ --warning-bg: #fef7e7;
+ --warning-border: #fc8431;
+ --problem: #931919;
+ --problem-bg: #fff1ed;
+ --problem-border: #ff4848;
+ /* question -> neutral/muted treatment */
+ --question: var(--octo-navy-700);
+ --question-bg: var(--octo-navy-100);
+ --question-border: var(--octo-navy-400);
+
+ --sidebar: #fbfdff;
+ --sidebar-foreground: #4a6076;
+
+ /* Surfaces for layered UI (code blocks, window chrome, hover states) */
+ --surface: #f2f6fa;
+ --surface-2: #ffffff;
+ --code-bg: #0b1a26;
+ --code-foreground: #e6f0f7;
+ --code-chrome: #112637;
+ --code-border: #1d384d;
+
+ /* Primary blue glow - feeds --shadow-glow (card hover) so it belongs with the
+ contract. The decorative brand mood (glow-brand, gradients, aurora, grid,
+ grain) lives in tokens.decorative.css - shared brand, imported by theme.css
+ right after this file. */
+ --glow-primary: color-mix(in oklab, var(--octo-blue) 38%, transparent);
+
+ /* Glass effect for in-content images (mirrors the marketing site). The
+ composed tokens below are light-adapted (the white marketing originals
+ vanish on the pale bg); dark mode overrides them with the exact values. */
+ --glass-navy-fade: rgba(10, 35, 54, 0);
+ --glass-pad: 0.75rem;
+
+ /* Docs-calm glass: same structure as the marketing site, much softer (faint
+ tint, neutral lift, whisper of a bloom) so screenshots feel framed not hero. */
+ --glass-background: linear-gradient(
+ 263.36deg,
+ rgba(255, 255, 255, 0.6) -46.77%,
+ rgba(147, 225, 255, 0.12) 86.43%
+ );
+ --glass-border-color: rgba(31, 192, 255, 0.18);
+ --glass-shadow: 0 8px 26px -16px rgba(16, 33, 47, 0.18);
+ --glass-blur-glow: radial-gradient(
+ 101.55% 101.55% at 50% -1.55%,
+ rgba(255, 255, 255, 0.35) 0%,
+ rgba(31, 192, 255, 0.14) 18.63%,
+ rgba(204, 60, 255, 0.09) 73.48%,
+ var(--glass-navy-fade) 100%
+ );
+
+ /* Marketing-site button palette + base (purple primary, gradient-border
+ secondary). Primary/palette are theme-independent; the --secondary-button-*
+ set below is light, overridden for dark. */
+ --white: #ffffff;
+ --purple-300: #6950ff;
+ --purple-400: #9747ff;
+ --pink-100: #cc3cff;
+ --space-12: 0.75rem;
+ --space-24: 1.5rem;
+ --button-font-size: var(--text-base);
+ --button-font-weight: 700;
+ --button-shadow-inner: inset 0 2px 6px rgba(0, 0, 0, 0.25);
+
+ --secondary-button-text: var(--purple-300);
+ --secondary-button-bg: transparent;
+ --secondary-button-hover-text: var(--pink-100);
+ --secondary-button-hover-bg: transparent;
+ --secondary-button-hover-glow: rgba(204, 60, 255, 0.15);
+ --secondary-button-hover-border: var(--pink-100);
+
+ /* Elevation - layered, slightly tinted toward the brand navy */
+ --shadow-sm: 0 1px 2px 0 rgba(16, 33, 47, 0.06);
+ --shadow-md:
+ 0 4px 14px -3px rgba(16, 33, 47, 0.12),
+ 0 2px 6px -2px rgba(16, 33, 47, 0.07);
+ --shadow-lg:
+ 0 18px 40px -12px rgba(16, 33, 47, 0.22),
+ 0 6px 14px -6px rgba(16, 33, 47, 0.12);
+ --shadow-glow: 0 10px 40px -10px var(--glow-primary);
+
+ /* Radius scale - one knob (--radius = lg = 8px) with ratio-derived steps on
+ the px grid: sm 4 / md 6 / lg 8 / xl 12. Components reference the steps,
+ never --radius directly; pills use 9999px. The bookmark anchor's 0.4em is
+ the one documented exception (scales with its heading). */
+ --radius: 0.5rem;
+ --radius-sm: calc(var(--radius) / 2);
+ --radius-md: calc(var(--radius) * 0.75);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.5);
+
+ /* Motion - one easing + three speeds for every transition in the system. */
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --duration-fast: 150ms;
+ --duration-base: 200ms;
+ --duration-slow: 300ms;
+
+ /* Interaction states - the single recipe for hover surfaces, hover borders,
+ primary washes and the input focus ring. Derived from semantic tokens, so
+ they adapt per theme. The canonical FOCUS treatment is
+ `outline: 2px solid var(--ring); outline-offset: 2px` - --focus-ring is the
+ box-shadow variant reserved for text inputs (where the outline would fight
+ the border treatment). */
+ --surface-hover: color-mix(in oklab, var(--muted) 55%, transparent);
+ --card-hover: color-mix(in oklab, var(--primary) 5%, var(--card));
+ --border-hover: color-mix(in oklab, var(--primary) 50%, var(--border));
+ --wash-primary: color-mix(in oklab, var(--primary) 10%, transparent);
+ --focus-ring: 0 0 0 2px color-mix(in oklab, var(--ring) 60%, transparent);
+
+ /* The one small-halo glow for dots, rails and accent ticks. */
+ --glow-dot: 0 0 10px 1px var(--glow-primary);
+
+ /* Inline SVG icons (masked, tinted via currentColor) so components stay free of
+ any icon-font dependency and remain portable to any repo. */
+ --ink-icon-link: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'/%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'/%3E%3C/svg%3E");
+ --ink-icon-magnify: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3Cline x1='11' y1='8' x2='11' y2='14'/%3E%3Cline x1='8' y1='11' x2='14' y2='11'/%3E%3C/svg%3E");
+ --ink-icon-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'/%3E%3Cline x1='6' y1='6' x2='18' y2='18'/%3E%3C/svg%3E");
+ --ink-icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
+
+ /* Type scale - THE complete font-size set; nothing in Ink may use any other
+ size. Three copy sizes (base for all main copy, sm, xs for breadcrumbs/
+ eyebrows/badges) + six heading steps on a px-grid tapered scale
+ (32/28/24/20/18/17px - 4px steps through the display range, halving toward
+ the base so H6 stays just above body copy). Inline code uses 0.875em - the
+ sm step expressed relative to its context. --text-title is the tile-title
+ ROLE (Card/LinkCard/search results/journey): base size, weight carries the
+ emphasis. */
+ --text-xs: 0.8125rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-h6: 1.0625rem;
+ --text-h5: 1.125rem;
+ --text-h4: 1.25rem;
+ --text-h3: 1.5rem;
+ --text-h2: 1.75rem;
+ --text-h1: 2rem;
+ --text-title: var(--text-base);
+
+ /* Line heights - every size carries ONE canonical line box. Copy sits on the
+ px grid (20px under xs/sm, 24px under base); headings get a constant 0.5rem
+ (8px) of leading, so the boxes are 40/36/32/28/26/25px and the ratios taper
+ monotonically (1.25 -> 1.47) as sizes approach the base. Long-form prose
+ (and callouts) read at --leading-prose (28px). Documented exceptions:
+ single-line controls (button 1.2, CopyMarkdown trigger 1). */
+ --text-xs--line-height: 1.25rem;
+ --text-sm--line-height: 1.25rem;
+ --text-base--line-height: 1.5rem;
+ --text-h6--line-height: calc(var(--text-h6) + 0.5rem);
+ --text-h5--line-height: calc(var(--text-h5) + 0.5rem);
+ --text-h4--line-height: calc(var(--text-h4) + 0.5rem);
+ --text-h3--line-height: calc(var(--text-h3) + 0.5rem);
+ --text-h2--line-height: calc(var(--text-h2) + 0.5rem);
+ --text-h1--line-height: calc(var(--text-h1) + 0.5rem);
+ --text-title--line-height: var(--text-base--line-height);
+ --leading-prose: 1.75;
+
+ /* Fonts - system stacks only (zero web-font requests, instant first paint,
+ native rendering on every platform). --font-display aliases the sans stack;
+ it stays a separate ROLE so a shell can re-point it at a display face
+ without touching components. */
+ --font-display: var(--font-sans);
+ --font-sans:
+ system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
+ 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
+ 'Segoe UI Symbol';
+ --font-mono:
+ ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Consolas, 'Liberation Mono',
+ 'Courier New', monospace;
+}
+
+[data-theme='dark'] {
+ /* The abyss: deep navy with a faint blue cast, luminous accents. */
+ --background: #081521;
+ --foreground: #dde7ef;
+
+ --card: #0e2231;
+ --card-foreground: #dde7ef;
+
+ --popover: #0b1d2a;
+ --popover-foreground: #dde7ef;
+
+ --primary: var(--octo-blue-bright);
+ --primary-foreground: #04121c;
+
+ --accent: var(--octo-green-bright);
+ --accent-foreground: #04241a;
+
+ --secondary: #122a3c;
+ --secondary-foreground: var(--octo-navy-200);
+
+ --muted: #102636;
+ --muted-foreground: #8aa4bd;
+
+ --border: #1e394e;
+ --input: #284459;
+ --ring: var(--octo-blue-bright);
+
+ /* Callout text colors must lighten for legibility on the dark surfaces. */
+ --info: #cfe6fb;
+ --info-bg: rgba(31, 192, 255, 0.08);
+ --info-border: var(--octo-blue-bright);
+ --success: #6fe6b4;
+ --success-bg: rgba(0, 230, 147, 0.09);
+ --success-border: var(--octo-green);
+ --warning: #ffb27a;
+ --warning-bg: rgba(252, 132, 49, 0.1);
+ --warning-border: #fc8431;
+ --problem: #ff9b9b;
+ --problem-bg: rgba(255, 72, 72, 0.1);
+ --problem-border: #ff4848;
+ --question: var(--octo-navy-200);
+ --question-bg: rgba(124, 152, 180, 0.1);
+ --question-border: var(--octo-navy-400);
+
+ --sidebar: #091a27;
+ --sidebar-foreground: #a9c0d4;
+
+ --surface: #0c1f2d;
+ --surface-2: #112a3b;
+ --code-bg: #06121c;
+ --code-foreground: #e6f0f7;
+ --code-chrome: #0c2031;
+ --code-border: #16344a;
+
+ --glow-primary: color-mix(in oklab, var(--octo-blue-bright) 50%, transparent);
+ /* Decorative dark overrides (glow-brand, gradients, aurora, grid, grain) live in
+ tokens.decorative.css. */
+
+ /* Glass effect: the exact marketing-site values (tuned for dark backgrounds). */
+ /* Docs-calm glass (dark): a gentle frost + faint bloom, not the bright
+ marketing panel. */
+ --glass-background: linear-gradient(
+ 263.36deg,
+ rgba(255, 255, 255, 0.08) -46.77%,
+ rgba(147, 225, 255, 0.09) 86.43%
+ );
+ --glass-border-color: rgba(255, 255, 255, 0.1);
+ --glass-shadow: 0 14px 30px -18px rgba(0, 0, 0, 0.5);
+ --glass-blur-glow: radial-gradient(
+ 101.55% 101.55% at 50% -1.55%,
+ rgba(255, 255, 255, 0.18) 0%,
+ rgba(31, 192, 255, 0.18) 18.63%,
+ rgba(204, 60, 255, 0.14) 73.48%,
+ var(--glass-navy-fade) 100%
+ );
+
+ /* Secondary button (dark): purple-tinted fill instead of transparent. */
+ --secondary-button-text: var(--white);
+ --secondary-button-bg: rgba(105, 80, 255, 0.3);
+ --secondary-button-hover-text: var(--white);
+ --secondary-button-hover-bg: rgba(105, 80, 255, 0.3);
+ --secondary-button-hover-glow: rgba(204, 60, 255, 0.5);
+ --secondary-button-hover-border: var(--pink-100);
+
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.45);
+ --shadow-md:
+ 0 4px 14px -3px rgba(0, 0, 0, 0.55), 0 2px 6px -2px rgba(0, 0, 0, 0.45);
+ --shadow-lg:
+ 0 18px 44px -12px rgba(0, 0, 0, 0.65), 0 6px 16px -6px rgba(0, 0, 0, 0.5);
+ --shadow-glow: 0 10px 44px -8px var(--glow-primary);
+}
diff --git a/src/ink/tokens.decorative.css b/src/ink/tokens.decorative.css
new file mode 100644
index 0000000000..881f251521
--- /dev/null
+++ b/src/ink/tokens.decorative.css
@@ -0,0 +1,66 @@
+/*
+ Ink decorative layer - the "Abyssal / bioluminescent" brand mood.
+
+ SHARED OCTOPUS BRAND (not docs-specific): the cyan->purple->pink glow language
+ every Octopus surface shares. Ports 1:1 like tokens.css. Split out from the
+ required semantic contract (tokens.css) so a shell can dial the mood - or swap
+ this one file - for a flatter skin without touching the contract.
+
+ Consumed only by the decoration layer (.ink-atmosphere / .ink-grain in
+ styles/base.css, prose accents) and shell chrome; core components never
+ reference these directly. Imported by theme.css right after
+ tokens.css, so any site loading theme.css gets the full mood. Tune the whole
+ look here: drop the aurora / grain / glow alphas toward zero to flatten.
+*/
+
+:root {
+ /* Brand glow (cyan->purple->pink; green stays semantic-success only). */
+ --glow-brand: color-mix(in oklab, var(--purple-300) 50%, transparent);
+
+ /* Accent gradient: decorative accents, rule/border images. */
+ --gradient-brand: linear-gradient(
+ 100deg,
+ var(--octo-blue-bright) 0%,
+ var(--purple-300) 52%,
+ var(--pink-100) 108%
+ );
+
+ /* Lit-edge hairlines: 1px gradient edges on chrome and cards. Two recipes
+ cover every use - the primary fade (header, tile hover) and the brand duo
+ (code frames, hero surfaces). */
+ --hairline-primary: linear-gradient(
+ to right,
+ transparent,
+ color-mix(in oklab, var(--primary) 50%, transparent),
+ transparent
+ );
+ --hairline-brand: linear-gradient(
+ to right,
+ transparent,
+ color-mix(in oklab, var(--octo-blue-bright) 55%, transparent),
+ color-mix(in oklab, var(--pink-100) 45%, transparent),
+ transparent
+ );
+
+ /* Atmosphere: fixed background mesh + grid + film grain. */
+ --aurora-1: color-mix(in oklab, var(--octo-blue-bright) 11%, transparent);
+ --aurora-2: color-mix(in oklab, var(--pink-100) 7%, transparent);
+ --aurora-3: color-mix(in oklab, var(--purple-300) 9%, transparent);
+ --grid-line: color-mix(in oklab, var(--octo-navy-700) 3%, transparent);
+ --grain-opacity: 0.035;
+}
+
+[data-theme='dark'] {
+ --glow-brand: color-mix(in oklab, var(--pink-100) 55%, transparent);
+ --gradient-brand: linear-gradient(
+ 100deg,
+ var(--octo-blue-bright) 0%,
+ var(--purple-400) 50%,
+ var(--pink-100) 115%
+ );
+ --aurora-1: color-mix(in oklab, var(--octo-blue-bright) 9%, transparent);
+ --aurora-2: color-mix(in oklab, var(--pink-100) 7%, transparent);
+ --aurora-3: color-mix(in oklab, var(--purple-300) 10%, transparent);
+ --grid-line: color-mix(in oklab, var(--octo-blue-bright) 3%, transparent);
+ --grain-opacity: 0.05;
+}
diff --git a/src/layouts/Default.astro b/src/layouts/Default.astro
index 8669d5df3e..d1acd76abb 100644
--- a/src/layouts/Default.astro
+++ b/src/layouts/Default.astro
@@ -1,150 +1,21 @@
---
-import { Accelerator, PostFiltering } from 'astro-accelerator-utils';
-import type { Frontmatter as OriginalFrontmatter } from 'astro-accelerator-utils/types/Frontmatter';
-import { SITE } from '@config';
-import { getImageInfo } from '@util/custom-markdown.mjs';
-
-// Theme components
-import Head from '@components/HtmlHead.astro';
-import SkipLinks from '@components/SkipLinks.astro';
-import Breadcrumbs from '@components/Breadcrumbs.astro';
-import Navigation from '@components/Navigation.astro';
-import Authors from '@components/Authors.astro';
-import Taxonomy from '@components/Taxonomy.astro';
-
-// Custom components
-import ArticleHeader from '../components/ArticleHeader.astro';
-import ArticleNav from '../components/ArticleNav.astro';
-import Header from '../components/Header.astro';
-import Related from '../components/Related.astro';
-import ArticleJourney from '../components/ArticleJourney.astro';
-import Feedback from '../components/Feedback.astro';
-import EditOnGithub from '../components/EditOnGithub.astro';
-import LastUpdated from '../components/LastUpdated.astro';
-import CopyMarkdown from '../components/CopyMarkdown.astro';
-import Plausible from 'src/components/Plausible.astro';
-import SharedFooter from 'src/components/SharedFooter.astro';
-import SharedScripts from 'src/components/SharedScripts.astro';
-
-// Extend Frontmatter with icon
-type Frontmatter = OriginalFrontmatter & {
- icon?: string; // Add the icon property
-};
-
-type Props = {
- frontmatter: Frontmatter;
- headings: { depth: number; slug: string; text: string }[];
- breadcrumbs?: { url: string; title: string; ariaCurrent?: string }[] | null;
-};
-const { frontmatter, headings, breadcrumbs } = Astro.props satisfies Props;
-
-const lang = frontmatter.lang ?? SITE.default.lang;
-const textDirection = frontmatter.dir ?? SITE.default.dir;
-
-// Logic
-const accelerator = new Accelerator(SITE);
-
-const metaImage = frontmatter.bannerImage
- ? getImageInfo(frontmatter.bannerImage.src, '', SITE.images.contentSize)
- : null;
-
-const title = await accelerator.markdown.getInlineHtmlFrom(
- frontmatter.title ?? SITE.title
-);
-
-const subtitle = frontmatter.subtitle
- ? await accelerator.markdown.getInlineHtmlFrom(frontmatter.subtitle)
- : null;
-
-const icon = frontmatter.icon
- ? await accelerator.markdown.getInlineHtmlFrom(frontmatter.icon)
- : null;
-
-const site_url = SITE.url;
-const site_features = SITE.featureFlags;
-const search =
- accelerator.posts.all().filter(PostFiltering.isSearch).shift() ?? null;
-const searchUrl = search && accelerator.urlFormatter.formatAddress(search.url);
-const isSearchPage =
- accelerator.urlFormatter.formatAddress(Astro.url.pathname) === searchUrl;
-
-const showSearch = !isSearchPage;
+// Layout delegator. Every content page references `layout: src/layouts/Default.astro`
+// in its frontmatter, so this single file decides what the whole site renders -
+// no per-page frontmatter changes needed to switch designs.
+//
+// Default: the host's Ink-based docs shell (layouts/ink-docs/InkLayout), which
+// consumes the standalone Ink package (src/ink/ = core + tokens + theme).
+// Escape hatch: set `legacyLayout: true` in a page's frontmatter to render the
+// original layout (preserved verbatim in DefaultLegacy.astro).
+// Global revert: swap the `Layout` default back to LegacyLayout, or restore this
+// file from DefaultLegacy.astro.
+import NewLayout from './ink-docs/InkLayout.astro';
+import LegacyLayout from './DefaultLegacy.astro';
+
+const { frontmatter, headings, breadcrumbs } = Astro.props;
+const Layout = frontmatter?.legacyLayout ? LegacyLayout : NewLayout;
---
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {metaImage &&
}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/src/layouts/DefaultLegacy.astro b/src/layouts/DefaultLegacy.astro
new file mode 100644
index 0000000000..8669d5df3e
--- /dev/null
+++ b/src/layouts/DefaultLegacy.astro
@@ -0,0 +1,150 @@
+---
+import { Accelerator, PostFiltering } from 'astro-accelerator-utils';
+import type { Frontmatter as OriginalFrontmatter } from 'astro-accelerator-utils/types/Frontmatter';
+import { SITE } from '@config';
+import { getImageInfo } from '@util/custom-markdown.mjs';
+
+// Theme components
+import Head from '@components/HtmlHead.astro';
+import SkipLinks from '@components/SkipLinks.astro';
+import Breadcrumbs from '@components/Breadcrumbs.astro';
+import Navigation from '@components/Navigation.astro';
+import Authors from '@components/Authors.astro';
+import Taxonomy from '@components/Taxonomy.astro';
+
+// Custom components
+import ArticleHeader from '../components/ArticleHeader.astro';
+import ArticleNav from '../components/ArticleNav.astro';
+import Header from '../components/Header.astro';
+import Related from '../components/Related.astro';
+import ArticleJourney from '../components/ArticleJourney.astro';
+import Feedback from '../components/Feedback.astro';
+import EditOnGithub from '../components/EditOnGithub.astro';
+import LastUpdated from '../components/LastUpdated.astro';
+import CopyMarkdown from '../components/CopyMarkdown.astro';
+import Plausible from 'src/components/Plausible.astro';
+import SharedFooter from 'src/components/SharedFooter.astro';
+import SharedScripts from 'src/components/SharedScripts.astro';
+
+// Extend Frontmatter with icon
+type Frontmatter = OriginalFrontmatter & {
+ icon?: string; // Add the icon property
+};
+
+type Props = {
+ frontmatter: Frontmatter;
+ headings: { depth: number; slug: string; text: string }[];
+ breadcrumbs?: { url: string; title: string; ariaCurrent?: string }[] | null;
+};
+const { frontmatter, headings, breadcrumbs } = Astro.props satisfies Props;
+
+const lang = frontmatter.lang ?? SITE.default.lang;
+const textDirection = frontmatter.dir ?? SITE.default.dir;
+
+// Logic
+const accelerator = new Accelerator(SITE);
+
+const metaImage = frontmatter.bannerImage
+ ? getImageInfo(frontmatter.bannerImage.src, '', SITE.images.contentSize)
+ : null;
+
+const title = await accelerator.markdown.getInlineHtmlFrom(
+ frontmatter.title ?? SITE.title
+);
+
+const subtitle = frontmatter.subtitle
+ ? await accelerator.markdown.getInlineHtmlFrom(frontmatter.subtitle)
+ : null;
+
+const icon = frontmatter.icon
+ ? await accelerator.markdown.getInlineHtmlFrom(frontmatter.icon)
+ : null;
+
+const site_url = SITE.url;
+const site_features = SITE.featureFlags;
+const search =
+ accelerator.posts.all().filter(PostFiltering.isSearch).shift() ?? null;
+const searchUrl = search && accelerator.urlFormatter.formatAddress(search.url);
+const isSearchPage =
+ accelerator.urlFormatter.formatAddress(Astro.url.pathname) === searchUrl;
+
+const showSearch = !isSearchPage;
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {metaImage &&
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layouts/ink-docs/ArticleFooter.astro b/src/layouts/ink-docs/ArticleFooter.astro
new file mode 100644
index 0000000000..fae79fa442
--- /dev/null
+++ b/src/layouts/ink-docs/ArticleFooter.astro
@@ -0,0 +1,42 @@
+---
+// Docs-shell article footer. Composes the existing end-of-article components (which own
+// their data logic via astro-accelerator) and gives their output Ink styling (see
+// `.ink-article-footer` in ./shell.css). The feedback CTA is rendered with the shared Ink
+// `Button` so it stays visually coherent with every other button on the site. These are
+// docs-specific and NOT part of portable Ink core - hence they live in the host shell.
+import LastUpdated from '../../components/LastUpdated.astro';
+import EditOnGithub from '../../components/EditOnGithub.astro';
+import ArticleJourney from '../../components/ArticleJourney.astro';
+import Button from '../../ink/core/Button.astro';
+import { Accelerator } from 'astro-accelerator-utils';
+import { SITE } from '@config';
+
+interface Props {
+ frontmatter: Record;
+ headings: { depth: number; slug: string; text: string }[];
+ lang: string;
+}
+const { frontmatter, headings, lang } = Astro.props;
+
+// Feedback form URL (mirrors the legacy Feedback component: prefill the page title).
+const accelerator = new Accelerator(SITE);
+const feedbackHeading = await accelerator.markdown.getTextFrom(
+ frontmatter.title
+);
+const feedbackUrl = `https://docs.google.com/forms/d/e/1FAIpQLSehVdN2w6tgSvp5QX7lHGnHDmgKi2Yfvko7bM2izgWQaqg-Wg/viewform?usp=pp_url&entry.336432709=${encodeURIComponent(feedbackHeading)}`;
+---
+
+
diff --git a/src/layouts/ink-docs/Behavior.astro b/src/layouts/ink-docs/Behavior.astro
new file mode 100644
index 0000000000..427075986a
--- /dev/null
+++ b/src/layouts/ink-docs/Behavior.astro
@@ -0,0 +1,37 @@
+---
+// Client behavior for the Ink docs shell. The imported modules are bundled by Astro
+// (hashed, self-contained) rather than loaded from a host-specific /public URL, so the
+// package is portable to any Astro repo. Universal behaviors always run; the optional
+// heading anchors are gated by a prop (surfaced as a data-attribute the bundled script
+// reads) so this component never imports host config.
+interface Props {
+ headingAnchors?: boolean;
+}
+const { headingAnchors = true } = Astro.props;
+---
+
+
+
+
+
diff --git a/src/layouts/ink-docs/Breadcrumbs.astro b/src/layouts/ink-docs/Breadcrumbs.astro
new file mode 100644
index 0000000000..98e825281d
--- /dev/null
+++ b/src/layouts/ink-docs/Breadcrumbs.astro
@@ -0,0 +1,54 @@
+---
+interface Crumb {
+ label: string;
+ href?: string;
+}
+interface Props {
+ items: Crumb[];
+}
+const { items } = Astro.props;
+---
+
+
+
+
+ {
+ items.map((c, i) => (
+
+ {c.href ? (
+
+ {c.label}
+
+ ) : (
+
+ {c.label}
+
+ )}
+
+ {i < items.length - 1 && (
+
+ /
+
+ )}
+
+ ))
+ }
+
+
diff --git a/src/layouts/ink-docs/Head.astro b/src/layouts/ink-docs/Head.astro
new file mode 100644
index 0000000000..1aedf2df61
--- /dev/null
+++ b/src/layouts/ink-docs/Head.astro
@@ -0,0 +1,27 @@
+---
+// Ink docs : charset + web fonts + the shared (non-main.css) stylesheets, then the
+// shared HeadMeta (SEO/OG/JsonLd/HEADER_SCRIPTS - single source of truth with HtmlHead).
+// main.css is intentionally omitted so legacy prose CSS doesn't bleed onto Ink pages.
+import type { Frontmatter } from 'astro-accelerator-utils/types/Frontmatter';
+import { SITE } from '@config';
+import { ASSETS, ASSETS_ORIGIN } from 'src/lib/sharedNavbarFooter';
+import HeadMeta from '@components/HeadMeta.astro';
+
+type Props = {
+ lang: string;
+ frontmatter: Frontmatter;
+ headings: { depth: number; slug: string; text: string }[];
+};
+const { frontmatter, headings, lang } = Astro.props;
+---
+
+
+
+ {/* Ink is system-font only - no web-font requests. */}
+
+
+
+
+
+
+
diff --git a/src/layouts/ink-docs/Header.astro b/src/layouts/ink-docs/Header.astro
new file mode 100644
index 0000000000..b7bd4c8234
--- /dev/null
+++ b/src/layouts/ink-docs/Header.astro
@@ -0,0 +1,127 @@
+---
+// Top navigation bar. The only JS is a tiny theme toggle (vanilla, no framework
+// runtime) to demonstrate dark mode - consistent with the zero-JS approach.
+// Search renders the REAL site search component + engine (search.js), restyled
+// via tokens in shell.css. The engine is unchanged; we only provide its markup.
+import OctopusLogo from '../../../public/docs/img/OctopusLogo.astro';
+import Button from '../../ink/core/Button.astro';
+import Icon from '../../ink/core/Icon.astro';
+import Search from '../../themes/octopus/components/Search.astro';
+import { SITE } from '@config';
+
+type Props = {
+ lang?: string;
+};
+const { lang = SITE.default.lang } = Astro.props satisfies Props;
+---
+
+
+
+
+
+
+
+
diff --git a/src/layouts/ink-docs/InkLayout.astro b/src/layouts/ink-docs/InkLayout.astro
new file mode 100644
index 0000000000..97c6115b25
--- /dev/null
+++ b/src/layouts/ink-docs/InkLayout.astro
@@ -0,0 +1,357 @@
+---
+// Phase A adapter layout: markdown-compatible entrypoint that lets any real
+// content page adopt the new design system by switching its `layout:`
+// frontmatter line. Reuses the site's existing data sources (nav tree,
+// breadcrumbs, SEO head via HtmlHead) and renders them through our chrome.
+//
+// ⚠️ MVP / PREVIEW - not yet the production default. Most plumbing is now in place:
+// SkipLinks, real search, Marketo, GTM analytics + Plausible search hook, unified theme
+// system, article footer, and a selective client-behavior layer (see core/behavior/).
+// Remaining gaps before rollout are tracked in
+// .claude/docs/engineering/design-system-migration-status.md.
+import '../../ink/theme.css';
+import './shell.css';
+import { Accelerator } from 'astro-accelerator-utils';
+import { SITE } from '@config';
+import { menu } from '@data/navigation';
+import Head from './Head.astro';
+
+import Header from './Header.astro';
+import Toc from './Toc.astro';
+import Breadcrumbs from './Breadcrumbs.astro';
+import SidebarNav from './SidebarNav.astro';
+import SidebarBehavior from './SidebarBehavior.astro';
+import Behavior from './Behavior.astro';
+import SkipLinks from './SkipLinks.astro';
+import Icon from '../../ink/core/Icon.astro';
+import SharedFooter from '../../components/SharedFooter.astro';
+import CopyMarkdown from '../../components/CopyMarkdown.astro';
+import ArticleFooter from './ArticleFooter.astro';
+import Plausible from '../../components/Plausible.astro';
+import SharedScripts from '../../components/SharedScripts.astro';
+
+// This repo honors its own `headers` feature flag; the flag lookup stays in the shell
+// so the portable Behavior/Ink components never import host config.
+const headingAnchors = (SITE.featureFlags?.headers ?? []).includes('link');
+
+type Props = {
+ frontmatter: Record;
+ headings: { depth: number; slug: string; text: string }[];
+ breadcrumbs?: { url: string; title: string; ariaCurrent?: string }[] | null;
+};
+const { frontmatter, headings, breadcrumbs } = Astro.props satisfies Props;
+
+const accelerator = new Accelerator(SITE);
+const lang = frontmatter.lang ?? SITE.default.lang;
+const currentUrl = new URL(Astro.request.url);
+
+// Real sidebar nav tree (NavPage[]).
+const pages = accelerator.navigation.menu(currentUrl, SITE.subfolder, menu);
+
+// Lightweight nav (mirrors src/themes/octopus/components/Navigation.astro from
+// main): keep only the active section's subtree expanded and collapse every
+// other top-level section to a single labelled link, so the rendered DOM stays
+// small for AI-crawler token budgets. Collapsed sections render as links with a
+// right-chevron 'navigate' affordance (see SidebarNav); the active section
+// stays an expandable dropdown.
+const currentPath = currentUrl.pathname.replace(/\/$/, '');
+const isOnActivePath = (page: any): boolean => {
+ const pUrl = (page.url ?? '').replace(/\/$/, '');
+ if (!pUrl) return false;
+ return currentPath === pUrl || currentPath.startsWith(pUrl + '/');
+};
+const markActiveBranchOpen = (items: any[]): void => {
+ for (const item of items) {
+ if (item.children && item.children.length > 0 && isOnActivePath(item)) {
+ item.isOpen = true;
+ markActiveBranchOpen(item.children);
+ }
+ }
+};
+for (const top of pages) {
+ if (top.children && top.children.length > 0) {
+ if (isOnActivePath(top)) {
+ top.isOpen = true;
+ markActiveBranchOpen(top.children);
+ } else {
+ if (top.section) top.title = top.section;
+ top.children = [];
+ }
+ }
+}
+
+// Real breadcrumbs from accelerator nav data.
+const crumbs = accelerator.navigation
+ .breadcrumbs(currentUrl, SITE.subfolder, breadcrumbs?.length ?? 0)
+ .map((crumb: any) => ({
+ label: crumb.section || crumb.title,
+ href: accelerator.urlFormatter.formatAddress(crumb.url),
+ }));
+
+// TOC from the rendered markdown headings (h2/h3 only).
+const toc = headings
+ .filter((h) => h.depth >= 2 && h.depth <= 3)
+ .map((h) => ({
+ label: h.text,
+ href: '#' + h.slug,
+ depth: h.depth >= 3 ? 2 : 1,
+ }));
+
+const sortedTopLevel = pages
+ .slice()
+ .sort((a: any, b: any) => a.order - b.order);
+---
+
+
+
+
+ {
+ /* Theme is set before paint by HEADER_SCRIPTS (in ) from the shared `theme`
+ localStorage key; no separate anti-flash script needed here. */
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ /* Shared button hover effect for the shared footer/navbar chrome (host asset). */
+ }
+
+
+
+ {
+ /* Analytics: GTM (page views) is loaded pre-body by HEADER_SCRIPTS on the prod host.
+ This hook forwards the search.js `searched` event to Plausible if present. */
+ }
+
+
+
+
diff --git a/src/layouts/ink-docs/SidebarBehavior.astro b/src/layouts/ink-docs/SidebarBehavior.astro
new file mode 100644
index 0000000000..1d69aa2f79
--- /dev/null
+++ b/src/layouts/ink-docs/SidebarBehavior.astro
@@ -0,0 +1,68 @@
+---
+// Sidebar behaviour for [data-navscroll] viewports: auto-reveal of the active
+// item and a tooltip for truncated labels. Vanilla, zero deps. The edge fade
+// shades are handled by the shared scroll-shade behavior
+// (src/ink/core/behavior/scroll-shade.ts, wired via Behavior.astro).
+---
+
+
diff --git a/src/layouts/ink-docs/SidebarNav.astro b/src/layouts/ink-docs/SidebarNav.astro
new file mode 100644
index 0000000000..fb00f57f4b
--- /dev/null
+++ b/src/layouts/ink-docs/SidebarNav.astro
@@ -0,0 +1,89 @@
+---
+// Recursive nav over the Octopus nav tree (NavPage[]). Three shapes: leaf link,
+// collapsed top-level link (right-chevron), expandable native .
+// Zero-JS. Styling lives in shell.css (.ink-nav__*).
+import { Accelerator } from 'astro-accelerator-utils';
+import type { NavPage } from 'astro-accelerator-utils/types/NavPage';
+import { SITE } from '@config';
+import Icon from '../../ink/core/Icon.astro';
+
+const accelerator = new Accelerator(SITE);
+
+type Props = {
+ page: NavPage;
+ depth?: number;
+};
+const { page, depth = 0 } = Astro.props satisfies Props;
+
+const displayLink = page.title != null;
+const linkTitle = page.title === page.fullTitle ? null : page.fullTitle;
+const children = page.children.slice().sort((a, b) => a.order - b.order);
+
+const href = accelerator.urlFormatter.formatAddress(page.url);
+
+// page.ariaCurrent is the source of truth; fall back to a normalized path
+// compare because the upstream exact-match misses on trailing-slash diffs.
+const normalize = (p: string) => p.replace(/\/+$/, '') || '/';
+const currentPath = normalize(new URL(Astro.url).pathname);
+const isCurrent = Boolean(page.ariaCurrent) || normalize(href) === currentPath;
+const ariaCurrent = isCurrent ? page.ariaCurrent || 'page' : undefined;
+
+// Open section on the active branch lights its rail (the trail to the page).
+const isCurrentPage = (p: NavPage): boolean =>
+ Boolean(p.ariaCurrent) ||
+ normalize(accelerator.urlFormatter.formatAddress(p.url)) === currentPath;
+const onActiveTrail =
+ page.children.length > 0 &&
+ page.isOpen &&
+ page.children.some(function hasCurrent(c: NavPage): boolean {
+ return isCurrentPage(c) || c.children.some(hasCurrent);
+ });
+---
+
+{
+ displayLink && page.children.length === 0 && (
+
+
+ {depth > 0 && }
+ {page.title}
+ {depth === 0 && (
+
+ )}
+
+
+ )
+}
+{
+ displayLink && page.children.length > 0 && (
+
+
+
+ {depth > 0 && }
+ {page.section}
+
+
+
+ {children.map((child) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/layouts/ink-docs/SkipLinks.astro b/src/layouts/ink-docs/SkipLinks.astro
new file mode 100644
index 0000000000..c0555cc995
--- /dev/null
+++ b/src/layouts/ink-docs/SkipLinks.astro
@@ -0,0 +1,27 @@
+---
+// Skip links (WCAG 2.4.1 "Bypass Blocks"): the first focusable elements on the page,
+// visually hidden until focused. Self-contained and prop-driven - labels default to
+// English but a host can pass translated strings, so the component imports no i18n
+// system and stays portable. Targets must exist in the layout with matching ids
+// (InkLayout gives id="ink-main-content" tabindex="-1" and the sidebar nav
+// id="ink-main-nav").
+interface Props {
+ contentHref?: string;
+ navHref?: string;
+ contentLabel?: string;
+ navLabel?: string;
+ ariaLabel?: string;
+}
+const {
+ contentHref = '#ink-main-content',
+ navHref = '#ink-main-nav',
+ contentLabel = 'Skip to content',
+ navLabel = 'Skip to navigation',
+ ariaLabel = 'Skip links',
+} = Astro.props;
+---
+
+
+ {contentLabel}
+ {navLabel}
+
diff --git a/src/layouts/ink-docs/Toc.astro b/src/layouts/ink-docs/Toc.astro
new file mode 100644
index 0000000000..c4197fb31a
--- /dev/null
+++ b/src/layouts/ink-docs/Toc.astro
@@ -0,0 +1,395 @@
+---
+import Icon from '../../ink/core/Icon.astro';
+
+interface TocItem {
+ label: string;
+ href: string;
+ depth?: number;
+}
+interface Props {
+ items: TocItem[];
+}
+const { items } = Astro.props;
+---
+
+
+
+{
+ /* is:global (namespaced classes): scoped styles can't reach the Icon child's svg. */
+}
+
+
+
diff --git a/src/layouts/ink-docs/shell.css b/src/layouts/ink-docs/shell.css
new file mode 100644
index 0000000000..184d862090
--- /dev/null
+++ b/src/layouts/ink-docs/shell.css
@@ -0,0 +1,1073 @@
+/*
+ Docs-shell chrome styles (HOST-OWNED, imported by InkLayout - NOT part of the
+ portable src/ink/ package). Everything here styles docs-specific chrome or
+ re-skins host markup contracts the shell composes:
+
+ - `.ink-nav*` / `.ink-navscroll*` / `.ink-nav-tip` - the sidebar (SidebarNav)
+ - `.ink-search .site-search*` - the real site search engine markup (unchanged)
+ - `.octo-copy-md*` - the CopyMarkdown component (also styled by legacy main.css)
+ - `.ink-article-footer .article-journey*` etc. - reused end-of-article components
+ - FontAwesome tints for author-embedded `` icons
+
+ It consumes only Ink tokens (tokens.css), so it themes with the package.
+*/
+
+/* Header height - the ONE source of truth. Referenced by Header.astro (bar
+ height), Toc.astro and .ink-navscroll below (sticky offsets that must sit
+ flush under the sticky header). Change it here and everything tracks. */
+:root {
+ --header-h: 4.5rem;
+}
+
+/* ── Article header icon badge ──────────────────────────────────────────────
+ InkLayout renders it from frontmatter `icon` (a FontAwesome class or an image
+ path). FA is a host/content dependency, not an Ink core dep. */
+.ink-article-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 3rem;
+ height: 3rem;
+ border-radius: var(--radius-lg);
+ border: 1px solid color-mix(in oklab, var(--primary) 20%, transparent);
+ background: color-mix(in oklab, var(--primary) 12%, transparent);
+ color: var(--primary);
+ font-size: var(--text-h4);
+}
+.ink-article-icon img {
+ width: 1.6rem;
+ height: 1.6rem;
+}
+
+/* ── Article footer ─────────────────────────────────────────────────────────
+ Ink styling for the reused end-of-article components (LastUpdated,
+ EditOnGithub, Feedback, ArticleJourney), which emit legacy class names that
+ main.css used to style. Scoped so the generic classes only apply here. */
+.ink-article-footer {
+ margin-top: 3.5rem;
+ padding-top: 2rem;
+ border-top: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+.ink-article-footer__meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem 1.4rem;
+ font-size: var(--text-sm);
+ color: var(--muted-foreground);
+}
+.ink-article-footer__meta .last-updated {
+ margin: 0;
+}
+.ink-article-footer__feedback {
+ padding: 1.5rem 1.75rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--muted);
+}
+.ink-article-footer__feedback h2 {
+ margin: 0 0 0.35rem;
+ font-family: var(--font-display);
+ font-size: var(--text-h5);
+ line-height: var(--text-h5--line-height);
+ font-weight: 700;
+ color: var(--foreground);
+}
+.ink-article-footer__feedback p {
+ margin: 0 0 1rem;
+ color: var(--muted-foreground);
+}
+.ink-article-footer__feedback p:last-of-type {
+ margin-bottom: 1.15rem;
+}
+
+/* Prev/next journey cards: the shared interactive-tile language via tokens. */
+.ink-article-footer .article-journey {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+.ink-article-footer .article-journey__button {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 1.1rem 1.3rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--card);
+ text-decoration: none;
+ transition:
+ border-color var(--duration-slow) var(--ease-out),
+ translate var(--duration-slow) var(--ease-out),
+ box-shadow var(--duration-slow) var(--ease-out),
+ background var(--duration-slow) var(--ease-out);
+}
+.ink-article-footer .article-journey__button:hover {
+ border-color: var(--border-hover);
+ background: var(--card-hover);
+ translate: 0 -2px;
+ box-shadow: var(--shadow-glow);
+}
+.ink-article-footer .article-journey__button--previous {
+ grid-column: 1;
+}
+.ink-article-footer .article-journey__button--next {
+ grid-column: 2;
+ /* DOM order is already [content][icon]; pack it to the right rather than reversing
+ (reversing would put the arrow before the text). */
+ justify-content: flex-end;
+ text-align: right;
+}
+/* Circular chevron badge that nudges outward on hover. */
+.ink-article-footer .article-journey__icon-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: 2.4rem;
+ height: 2.4rem;
+ border-radius: 9999px;
+ background: color-mix(in oklab, var(--primary) 12%, transparent);
+ transition:
+ transform var(--duration-base) ease,
+ background var(--duration-base) ease;
+}
+.ink-article-footer
+ .article-journey__button:hover
+ .article-journey__icon-wrapper {
+ background: color-mix(in oklab, var(--primary) 20%, transparent);
+}
+.ink-article-footer
+ .article-journey__button--next:hover
+ .article-journey__icon-wrapper {
+ transform: translateX(3px);
+}
+.ink-article-footer
+ .article-journey__button--previous:hover
+ .article-journey__icon-wrapper {
+ transform: translateX(-3px);
+}
+.ink-article-footer .article-journey__icon {
+ display: block;
+ flex-shrink: 0;
+ width: auto;
+ height: 0.85rem;
+}
+.ink-article-footer .article-journey__icon path {
+ stroke: var(--primary);
+}
+.ink-article-footer .article-journey__button--previous .article-journey__icon {
+ transform: rotate(180deg);
+}
+.ink-article-footer .article-journey__content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ min-width: 0;
+}
+.ink-article-footer .article-journey__label {
+ color: var(--primary);
+}
+.ink-article-footer .article-journey__title {
+ color: var(--foreground);
+ font-family: var(--font-display);
+ font-weight: 700;
+ line-height: var(--text-title--line-height);
+ font-size: var(--text-title);
+}
+@media (max-width: 640px) {
+ .ink-article-footer .article-journey {
+ grid-template-columns: 1fr;
+ }
+ .ink-article-footer .article-journey__button--previous,
+ .ink-article-footer .article-journey__button--next {
+ grid-column: 1;
+ }
+}
+
+/* ── FontAwesome content icons ──────────────────────────────────────────────
+ Authors embed icons like in
+ markdown; the color utilities lived in main.css. Tinted with Ink's adaptive
+ tokens so they flip in dark mode; inert on a host that doesn't load FA. */
+/* Custom Octopus glyph: `.fa-octopus` is not a standard FA icon; its class→glyph
+ mapping lived only in main.css. The glyph (\e082) ships in the fa-brands
+ webfont the host loads. */
+.fa-octopus::before {
+ content: '\e082';
+ font-family: fa-brands;
+}
+.prose [class*='fa-'].blue {
+ color: var(--primary);
+}
+.prose [class*='fa-'].green {
+ color: var(--success-border);
+}
+.prose [class*='fa-'].red {
+ color: var(--problem-border);
+}
+.prose [class*='fa-'].grey {
+ color: var(--muted-foreground);
+}
+
+/* ── Eyebrow (shell consumers on host markup) ───────────────────────────────
+ The package's .ink-eyebrow recipe (styles/components.css), applied to the two
+ host-markup contracts the shell can't tag with the class. Keep values in
+ lockstep with the package rule. */
+.ink-search .result-path,
+.ink-article-footer .article-journey__label {
+ font-size: var(--text-xs);
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+/* ── Popover surface ────────────────────────────────────────────────────────
+ ONE surface recipe for every floating panel in the shell: the sidebar
+ tooltip, the CopyMarkdown dropdown, and the search results panel. */
+.ink-nav-tip,
+.octo-copy-md__options,
+.ink-search .site-search-results {
+ background: var(--popover);
+ color: var(--popover-foreground);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+}
+
+/* ── Sidebar nav (SidebarNav.astro) ─────────────────────────────────────────
+ All nav text is 1rem; hierarchy comes from indent, rails, weight and the
+ active highlight, not size. */
+.ink-nav__item {
+ margin: 0;
+ list-style: none;
+}
+.ink-nav__link,
+.ink-nav__summary {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ border-radius: var(--radius-md);
+ outline: none;
+ text-decoration: none;
+ transition:
+ color var(--duration-fast) ease,
+ background-color var(--duration-fast) ease;
+}
+.ink-nav__text {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Top-level section: identical typography whether collapsed (link) or open
+ (summary); only the chevron + revealed children differ. */
+.ink-nav__link--top,
+.ink-nav__summary--top {
+ padding: 0.47rem 0.6rem 0.47rem 0.65rem;
+ font-size: var(--text-sm);
+ font-weight: 400;
+ line-height: var(--text-sm--line-height);
+ color: var(--sidebar-foreground);
+}
+.ink-nav__summary--top,
+.ink-nav__summary--nested {
+ cursor: pointer;
+ list-style: none;
+}
+.ink-nav__summary--top::-webkit-details-marker,
+.ink-nav__summary--nested::-webkit-details-marker {
+ display: none;
+}
+
+/* Nested section toggle + leaf link. */
+.ink-nav__summary--nested {
+ padding: 0.42rem 0.55rem 0.42rem 0.45rem;
+ font-size: var(--text-sm);
+ font-weight: 400;
+ line-height: var(--text-sm--line-height);
+ color: var(--sidebar-foreground);
+}
+.ink-nav__link--leaf {
+ padding: 0.4rem 0.55rem 0.4rem 0.45rem;
+ font-size: var(--text-sm);
+ font-weight: 400;
+ line-height: var(--text-sm--line-height);
+ color: var(--sidebar-foreground);
+}
+/* One hover recipe for every row shape. */
+.ink-nav__link--top:hover,
+.ink-nav__summary--top:hover,
+.ink-nav__summary--nested:hover,
+.ink-nav__link--leaf:hover {
+ background: var(--surface-hover);
+ color: var(--foreground);
+}
+.ink-nav__section--nested[open] > .ink-nav__summary--nested {
+ color: var(--foreground);
+}
+
+/* Tree connector dot before nested items. */
+.ink-nav__node {
+ flex: none;
+ position: relative;
+ width: 0.55rem;
+ height: 0.55rem;
+ margin-left: 0.1rem;
+}
+.ink-nav__node::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 4px;
+ height: 4px;
+ border-radius: 9999px;
+ background: color-mix(in oklab, var(--muted-foreground) 45%, transparent);
+ transition:
+ background var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease,
+ transform var(--duration-fast) ease;
+}
+.ink-nav__link--leaf:hover .ink-nav__node::before,
+.ink-nav__summary--nested:hover .ink-nav__node::before {
+ background: color-mix(in oklab, var(--primary) 70%, transparent);
+}
+.ink-nav__section--nested[open]
+ > .ink-nav__summary--nested
+ .ink-nav__node::before {
+ background: color-mix(in oklab, var(--primary) 60%, transparent);
+}
+
+/* Chevrons. Toggle = down caret that flips up when open (disclosure); the
+ collapsed-link "navigate" chevron (--nav) points right and nudges on hover. */
+.ink-nav__chevron {
+ flex: none;
+ height: 1rem;
+ width: 1rem;
+ color: color-mix(in oklab, var(--muted-foreground) 70%, transparent);
+ transition:
+ transform var(--duration-base) var(--ease-out),
+ color var(--duration-fast) ease;
+}
+.ink-nav__section[open] > .ink-nav__summary > .ink-nav__chevron {
+ transform: rotate(180deg);
+ color: var(--primary);
+}
+.ink-nav__summary:hover > .ink-nav__chevron {
+ color: var(--foreground);
+}
+.ink-nav__chevron--nav {
+ color: color-mix(in oklab, var(--muted-foreground) 45%, transparent);
+ transition:
+ transform var(--duration-base) ease,
+ color var(--duration-fast) ease;
+}
+.ink-nav__link--top:hover .ink-nav__chevron--nav {
+ transform: translateX(2px);
+ color: color-mix(in oklab, var(--primary) 80%, transparent);
+}
+
+/* Top-level grouping rhythm. */
+.ink-nav__section--top {
+ margin-bottom: 1.75rem;
+}
+.ink-nav__section--top:last-child {
+ margin-bottom: 0;
+}
+
+/* Nested group: tree-guide rail + open tint. */
+.ink-nav__list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.ink-nav__list--top > .ink-nav__item + .ink-nav__item,
+.ink-nav__list--nested > .ink-nav__item + .ink-nav__item {
+ margin-top: 1px;
+}
+.ink-nav__list--nested {
+ position: relative;
+ margin-left: 0.7rem;
+ padding-left: 0.55rem;
+ border-left: 1px solid color-mix(in oklab, var(--border) 90%, transparent);
+}
+.ink-nav__section.is-trail > .ink-nav__list--nested {
+ border-left-color: color-mix(in oklab, var(--primary) 40%, var(--border));
+}
+.ink-nav__section--nested[open] {
+ border-radius: var(--radius-md);
+ background: color-mix(in oklab, var(--muted) 22%, transparent);
+}
+
+/* Active leaf: tinted bg + glowing brand rail. */
+.ink-nav__link[aria-current] {
+ color: var(--primary);
+ font-weight: 700;
+ background: var(--wash-primary);
+}
+.ink-nav__link[aria-current] .ink-nav__text {
+ color: var(--primary);
+}
+.ink-nav__link[aria-current]::before {
+ content: '';
+ position: absolute;
+ top: 0.32rem;
+ bottom: 0.32rem;
+ left: -0.56rem;
+ width: 3px;
+ border-radius: 9999px;
+ background: var(--primary);
+ box-shadow: var(--glow-dot);
+}
+.ink-nav__link[aria-current] .ink-nav__node::before {
+ background: var(--primary);
+ transform: scale(1.35);
+ box-shadow: var(--glow-dot);
+}
+.ink-nav__link--top[aria-current]::before {
+ left: -0.65rem;
+}
+
+.ink-nav__link:focus-visible,
+.ink-nav__summary:focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ink-nav__chevron,
+ .ink-nav__chevron--nav,
+ .ink-nav__node::before,
+ .ink-nav__link,
+ .ink-nav__summary {
+ transition: none;
+ }
+}
+
+/* ── Sidebar scroll container ───────────────────────────────────────────────
+ A sticky wrapper holds the scroller plus top/bottom fade scrims. The shared
+ scroll-shade behavior toggles data-at-top/bottom so each scrim shows only
+ when there's hidden content that way. */
+.ink-navscroll {
+ position: sticky;
+ top: var(--header-h);
+}
+.ink-navscroll__viewport {
+ max-height: calc(100vh - var(--header-h));
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+}
+.ink-navscroll__fade {
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2.75rem;
+ z-index: 1;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity var(--duration-slow) ease;
+}
+.ink-navscroll__fade--top {
+ top: 0;
+ background: linear-gradient(to bottom, var(--sidebar), transparent);
+}
+.ink-navscroll__fade--bottom {
+ bottom: 0;
+ background: linear-gradient(to top, var(--sidebar), transparent);
+}
+.ink-navscroll[data-at-top='false'] .ink-navscroll__fade--top {
+ opacity: 1;
+}
+.ink-navscroll[data-at-bottom='false'] .ink-navscroll__fade--bottom {
+ opacity: 1;
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-navscroll__fade {
+ transition: none;
+ }
+}
+
+/* Tooltip revealing a truncated nav label. Fixed so it escapes the sidebar's
+ scroll clip; shown by the script only when the label is actually cut off.
+ Surface comes from the shared popover rule above. */
+.ink-nav-tip {
+ position: fixed;
+ z-index: 60;
+ max-width: 20rem;
+ padding: 0.35rem 0.65rem;
+ border-radius: var(--radius-sm);
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+ text-align: left;
+ pointer-events: none;
+ opacity: 0;
+ transform: translateY(-50%);
+ transition: opacity var(--duration-fast) ease;
+}
+.ink-nav-tip[data-show] {
+ opacity: 1;
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-nav-tip {
+ transition: none;
+ }
+}
+
+/* ── Header search re-skin ──────────────────────────────────────────────────
+ Styles the REAL site search markup (themes/octopus/components/Search.astro)
+ + engine (search.js), both unchanged; main.css is not loaded on Ink pages, so
+ the entire look lives here, scoped under `.ink-search` (the wrapper added in
+ Header.astro). Engine contract: results show by toggling `.is-active` on
+ `[data-site-search]`; the dropdown is `[data-site-search-results]`. */
+.ink-search {
+ --search-dropdown-height: 70vh;
+ --search-dropdown-duration: 0ms;
+ /* Centered column, compact at rest, expanding on interaction. The flex-1 zones
+ either side keep it centered as the width animates. max-width guards against
+ overflow at small widths. */
+ width: 23rem;
+ max-width: calc(100vw - 30rem);
+ transition: width var(--duration-slow) var(--ease-out);
+}
+/* Expand only when the input is engaged or there's a query/results - never on
+ hover or when the clear (X) button is focused, so the X can't dodge the cursor
+ while you try to click it. */
+.ink-search:has(.site-search-query:focus),
+.ink-search:has(.site-search-query:not(:placeholder-shown)),
+.ink-search:has(.site-search.is-active) {
+ width: 36rem;
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-search {
+ transition: none;
+ }
+}
+.ink-search .site-search__wrapper {
+ position: relative;
+ width: 100%;
+}
+/* The engine adds an overlay div + a mobile fallback link we don't use here. */
+.ink-search .site-search__overlay,
+.ink-search .site-search__mobile {
+ display: none;
+}
+
+/* Input box: rounded field with subtle border, muted bg, left search icon. */
+.ink-search .site-search {
+ position: relative;
+ margin: 0;
+ width: 100%;
+}
+.ink-search .site-search fieldset {
+ position: relative;
+ display: flex;
+ align-items: center;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+.ink-search .site-search__icon {
+ position: absolute;
+ left: 0.75rem;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 16px;
+ height: 16px;
+ pointer-events: none;
+ color: var(--muted-foreground);
+}
+.ink-search .site-search__icon path {
+ fill: currentColor;
+}
+.ink-search .site-search-query {
+ width: 100%;
+ height: 2.25rem;
+ padding: 0 2.5rem 0 2.25rem;
+ font-size: var(--text-base);
+ color: var(--foreground);
+ background: color-mix(in oklab, var(--muted) 50%, transparent);
+ border: 1px solid var(--input);
+ border-radius: var(--radius-lg);
+ outline: none;
+ transition:
+ background-color var(--duration-fast) ease,
+ border-color var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease;
+}
+.ink-search .site-search-query::placeholder {
+ color: var(--muted-foreground);
+}
+.ink-search .site-search-query:hover {
+ border-color: var(--border);
+ background: color-mix(in oklab, var(--muted) 70%, transparent);
+}
+.ink-search .site-search-query:focus {
+ background: var(--background);
+ border-color: color-mix(in oklab, var(--primary) 50%, transparent);
+ box-shadow: var(--focus-ring);
+}
+.ink-search .site-search:focus-within .site-search__icon {
+ color: var(--primary);
+}
+
+/* Clear button (engine wires [data-site-search-remove]). */
+.ink-search .site-search__remove-btn {
+ position: absolute;
+ right: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+ display: grid;
+ place-items: center;
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--muted-foreground);
+ cursor: pointer;
+ opacity: 0;
+ transition:
+ opacity var(--duration-fast) ease,
+ background-color var(--duration-fast) ease,
+ color var(--duration-fast) ease;
+}
+.ink-search .site-search__remove-btn svg path {
+ fill: currentColor;
+}
+.ink-search
+ .site-search-query:not(:placeholder-shown)
+ ~ .site-search__remove-btn,
+.ink-search .site-search.is-active .site-search__remove-btn {
+ opacity: 1;
+}
+.ink-search .site-search__remove-btn:hover {
+ background: var(--muted);
+ color: var(--foreground);
+}
+
+/* Results dropdown: popover panel (surface shared above), hidden until the
+ engine adds `.is-active`. */
+.ink-search .site-search-results {
+ display: none;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: calc(100% + 0.4rem);
+ z-index: 50;
+ max-height: min(70vh, 28rem);
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 0 0.4rem;
+ scrollbar-width: thin;
+}
+.ink-search .site-search.is-active ~ .site-search-results {
+ display: block;
+}
+/* Scroll shades: sticky pseudo-element scrims pinned to the viewport edges
+ (negative margins cancel their flow space). The shared scroll-shade behavior
+ toggles data-at-top/bottom so each appears only when there's more to scroll. */
+.ink-search .site-search-results::before,
+.ink-search .site-search-results::after {
+ content: '';
+ position: sticky;
+ left: 0;
+ z-index: 2;
+ display: block;
+ height: 1.75rem;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity var(--duration-slow) ease;
+}
+.ink-search .site-search-results::before {
+ top: 0;
+ margin-bottom: -1.75rem;
+ background: linear-gradient(to bottom, var(--popover), transparent);
+}
+.ink-search .site-search-results::after {
+ bottom: 0;
+ margin-top: -1.75rem;
+ background: linear-gradient(to top, var(--popover), transparent);
+}
+.ink-search .site-search-results[data-at-top='false']::before {
+ opacity: 1;
+}
+.ink-search .site-search-results[data-at-bottom='false']::after {
+ opacity: 1;
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-search .site-search-results::before,
+ .ink-search .site-search-results::after {
+ transition: none;
+ }
+}
+.ink-search .site-search-results__list {
+ list-style: none;
+ margin: 0;
+ padding: 0.4rem 0;
+}
+.ink-search .site-search-results__item {
+ list-style: none;
+ margin: 0;
+}
+.ink-search .site-search-results__item + .site-search-results__item {
+ margin-top: 0.45rem;
+}
+/* Each result is a card with a left accent rail that lights up on hover/focus. */
+.ink-search .result-wrapper {
+ position: relative;
+ display: block;
+ padding: 0.85rem 1rem 0.9rem 1.1rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--card);
+ text-decoration: none;
+ color: inherit;
+ overflow: hidden;
+ transition:
+ border-color var(--duration-fast) ease,
+ box-shadow var(--duration-fast) ease,
+ background-color var(--duration-fast) ease,
+ transform var(--duration-fast) ease;
+}
+.ink-search .result-wrapper::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 3px;
+ background: var(--primary);
+ opacity: 0;
+ transform: scaleY(0.4);
+ transition:
+ opacity var(--duration-fast) ease,
+ transform var(--duration-fast) ease;
+}
+.ink-search .result-wrapper:hover,
+.ink-search .result-wrapper:focus,
+.ink-search .result-wrapper:focus-visible {
+ border-color: var(--border-hover);
+ background: var(--card-hover);
+ box-shadow: var(--shadow-sm);
+ outline: none;
+}
+.ink-search .result-wrapper:hover::before,
+.ink-search .result-wrapper:focus::before,
+.ink-search .result-wrapper:focus-visible::before {
+ opacity: 1;
+ transform: scaleY(1);
+}
+@media (prefers-reduced-motion: reduce) {
+ .ink-search .result-wrapper,
+ .ink-search .result-wrapper::before {
+ transition: none;
+ }
+}
+.ink-search .result-title {
+ display: block;
+ font-family: var(--font-display);
+ font-size: var(--text-title);
+ font-weight: 700;
+ line-height: var(--text-title--line-height);
+ letter-spacing: -0.01em;
+ color: var(--foreground);
+}
+.ink-search .result-wrapper:hover .result-title,
+.ink-search .result-wrapper:focus .result-title,
+.ink-search .result-wrapper:focus-visible .result-title {
+ color: var(--primary);
+}
+.ink-search .result-title mark {
+ background: color-mix(in oklab, var(--primary) 18%, transparent);
+ color: var(--primary);
+ font-weight: 700;
+ border-radius: var(--radius-sm);
+ padding: 0 0.18em;
+}
+/* Breadcrumb eyebrow: small, uppercase, sits above the title. */
+.ink-search .result-path {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.25rem;
+ margin-bottom: 0.35rem;
+ color: color-mix(in oklab, var(--muted-foreground) 85%, transparent);
+}
+.ink-search .result-path__segment:last-child {
+ color: var(--primary);
+}
+.ink-search .result-path__icon {
+ display: inline-flex;
+ align-items: center;
+ opacity: 0.65;
+ color: var(--muted-foreground);
+}
+/* Chevron in the path ships with a hardcoded stroke; follow the theme instead. */
+.ink-search .result-path__icon svg path {
+ stroke: currentColor;
+}
+/* Brighter, larger description for legibility (raw muted was too faint). */
+.ink-search .result-description {
+ margin: 0.35rem 0 0;
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+ color: color-mix(in oklab, var(--foreground) 78%, var(--muted-foreground));
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.ink-search .result-description mark {
+ background: color-mix(in oklab, var(--primary) 13%, transparent);
+ color: var(--foreground);
+ font-weight: 700;
+ border-radius: var(--radius-sm);
+ padding: 0 0.16em;
+}
+/* Optional matched-headings sublist (gated by site_features.search 'headings'). */
+.ink-search .result-headings {
+ list-style: none;
+ margin: 0.55rem 0 0.15rem 0.15rem;
+ padding: 0.1rem 0 0.1rem 0.85rem;
+ border-left: 2px solid color-mix(in oklab, var(--primary) 30%, var(--border));
+}
+.ink-search .result-headings a {
+ display: block;
+ padding: 0.28rem 0.55rem;
+ border-radius: var(--radius-sm);
+ font-size: var(--text-sm);
+ line-height: var(--text-sm--line-height);
+ color: color-mix(in oklab, var(--foreground) 70%, var(--muted-foreground));
+ text-decoration: none;
+ transition:
+ color var(--duration-fast) ease,
+ background-color var(--duration-fast) ease;
+}
+.ink-search .result-headings a:hover {
+ color: var(--foreground);
+ background: var(--wash-primary);
+}
+.ink-search .result-headings mark {
+ background: transparent;
+ color: var(--primary);
+ font-weight: 700;
+}
+
+/* Empty-state heading + "see more" button. */
+.ink-search .search-results__heading {
+ margin: 0;
+ padding: 1.6rem 0.75rem;
+ text-align: center;
+ font-size: var(--text-sm);
+ font-weight: 400;
+ color: color-mix(in oklab, var(--foreground) 65%, var(--muted-foreground));
+}
+.ink-search .show-more {
+ display: block;
+ width: calc(100% - 0.5rem);
+ margin: 0.5rem auto 0.4rem;
+ padding: 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: transparent;
+ color: var(--primary);
+ font-size: var(--text-sm);
+ font-weight: 700;
+ letter-spacing: 0.01em;
+ cursor: pointer;
+ transition:
+ background-color var(--duration-fast) ease,
+ border-color var(--duration-fast) ease,
+ color var(--duration-fast) ease;
+}
+.ink-search .show-more:hover,
+.ink-search .show-more:focus-visible {
+ background: var(--wash-primary);
+ border-color: var(--border-hover);
+ outline: none;
+}
+
+/* ── CopyMarkdown ("Use with AI" dropdown) ──────────────────────────────────
+ `.octo-copy-md*` is the CopyMarkdown component's markup contract (also styled
+ by main.css on legacy pages - do not rename). Dropdown surface comes from the
+ shared popover rule above. */
+.octo-copy-md {
+ display: inline-flex;
+ flex: none;
+}
+.octo-copy-md__menu {
+ position: relative;
+ margin: 0;
+}
+.octo-copy-md__menu > summary {
+ list-style: none;
+}
+.octo-copy-md__menu > summary::-webkit-details-marker {
+ display: none;
+}
+.octo-copy-md__trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ height: 2.25rem;
+ padding: 0 0.65rem 0 0.6rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+ color: var(--muted-foreground);
+ font-family: var(--font-sans);
+ font-size: var(--text-sm);
+ font-weight: 400;
+ line-height: 1;
+ cursor: pointer;
+ user-select: none;
+ transition:
+ background-color var(--duration-fast) ease,
+ color var(--duration-fast) ease,
+ border-color var(--duration-fast) ease;
+}
+.octo-copy-md__trigger:hover,
+.octo-copy-md__trigger:focus-visible,
+.octo-copy-md__menu[open] > .octo-copy-md__trigger {
+ background: var(--surface-hover);
+ color: var(--foreground);
+ border-color: var(--border-hover);
+ outline: none;
+}
+.octo-copy-md__trigger-icon,
+.octo-copy-md__trigger-caret {
+ flex: none;
+ display: block;
+ background: currentColor;
+ -webkit-mask-position: center;
+ -webkit-mask-repeat: no-repeat;
+ -webkit-mask-size: contain;
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+}
+.octo-copy-md__trigger-icon {
+ width: 1rem;
+ height: 1rem;
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M9 13h6M9 17h6'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M9 13h6M9 17h6'/%3E%3C/svg%3E");
+}
+.octo-copy-md__trigger-caret {
+ width: 0.7rem;
+ height: 0.7rem;
+ opacity: 0.75;
+ margin-left: 0.05rem;
+ transition: transform var(--duration-base) var(--ease-out);
+ -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
+}
+.octo-copy-md__menu[open]
+ > .octo-copy-md__trigger
+ > .octo-copy-md__trigger-caret {
+ transform: rotate(180deg);
+ opacity: 1;
+}
+
+.octo-copy-md__options {
+ position: absolute;
+ top: calc(100% + 0.45rem);
+ right: 0;
+ z-index: 20;
+ min-width: 14rem;
+ margin: 0;
+ padding: 0.3rem;
+ list-style: none;
+ opacity: 0;
+ transform: translateY(-4px);
+ pointer-events: none;
+ transition:
+ opacity var(--duration-fast) ease,
+ transform var(--duration-fast) ease;
+}
+.octo-copy-md__menu[open] > .octo-copy-md__options {
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: auto;
+}
+.octo-copy-md__options li {
+ list-style: none;
+ margin: 0;
+}
+.octo-copy-md__option {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ width: 100%;
+ padding: 0.5rem 0.65rem;
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ font-size: var(--text-sm);
+ font-weight: 400;
+ line-height: var(--text-sm--line-height);
+ text-align: left;
+ text-decoration: none;
+ cursor: pointer;
+ transition:
+ background-color var(--duration-fast) ease,
+ color var(--duration-fast) ease;
+}
+.octo-copy-md__option:hover,
+.octo-copy-md__option:focus-visible {
+ background: var(--surface-hover);
+ outline: none;
+}
+.octo-copy-md__option::before {
+ content: '';
+ flex: none;
+ width: 1rem;
+ height: 1rem;
+ background: var(--muted-foreground);
+ transition: background-color var(--duration-fast) ease;
+ -webkit-mask: var(--ink-action-icon, none) center / contain no-repeat;
+ mask: var(--ink-action-icon, none) center / contain no-repeat;
+}
+.octo-copy-md__option:hover::before,
+.octo-copy-md__option:focus-visible::before {
+ background: var(--primary);
+}
+.octo-copy-md__option--copy {
+ --ink-action-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='9' width='13' height='13' rx='2'/%3E%3Cpath d='M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1'/%3E%3C/svg%3E");
+}
+.octo-copy-md__option--view {
+ --ink-action-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'/%3E%3Cpolyline points='15 3 21 3 21 9'/%3E%3Cline x1='10' y1='14' x2='21' y2='3'/%3E%3C/svg%3E");
+}
+.octo-copy-md__option--all {
+ --ink-action-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' y1='15' x2='12' y2='3'/%3E%3C/svg%3E");
+}
+
+.octo-copy-md__sr-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .octo-copy-md__trigger,
+ .octo-copy-md__trigger-caret,
+ .octo-copy-md__option,
+ .octo-copy-md__options {
+ transition: none;
+ }
+}
diff --git a/src/pages/components.mdx b/src/pages/components.mdx
index bcab867c6f..f309e93b0c 100644
--- a/src/pages/components.mdx
+++ b/src/pages/components.mdx
@@ -11,11 +11,8 @@ navSitemap: false
navMenu: false
robots: noindex, follow
---
-import Card from 'src/components/Card.astro';
-import IconTile from 'src/components/IconTile.astro';
-import Image from 'src/components/Image.astro';
-import Link from 'src/components/Link.astro';
-import Separator from 'src/components/Separator.astro';
+import Card from 'src/ink/core/Card.astro';
+import Image from 'src/ink/core/Image.astro';
## Component Usage Guide
@@ -23,15 +20,15 @@ This guide is designed specifically to understand how to incorporate and use new
## Important Notes on Using MDX
-To use Astro components like `Card` and `Separator` in your articles, you will need to ensure a couple of things:
+To use Astro components like `Card` and `Image` in your articles, you will need to ensure a couple of things:
1. **File Extension**: Change the file extension from `.md` to `.mdx`. This change allows the Astro framework to process the file as an MDX document, enabling the embedding of interactive components directly within the content.
2. **Import Components**: At the beginning of your `.mdx` file, import each component you plan to use. Here is an example of how to import components:
```
-import Card from 'src/components/Card.astro';
-import Separator from 'src/components/Separator.astro';
+import Card from 'src/ink/core/Card.astro';
+import Image from 'src/ink/core/Image.astro';
```
## Components usage
@@ -129,54 +126,6 @@ The Card component displays content in a card format, suitable for highlights, f
-### IconTile
-
-The IconTile component is used for creating clickable tiles with an icon, a title, and a description.
-
-1. **iconAlt**: Alternative text for the icon (not required with font awesome icon)
-
-2. **title**: Keep it short for this component
-
-3. **description**: Keep it short for this component
-
-
-
-
-
- ```
-
- ```
-
-
-
- ```
-
- ```
-
-
-
### Image
@@ -213,48 +162,6 @@ The IconTile component is used for creating clickable tiles with an icon, a titl
-### Link
-
-The Link component is designed to provide a standardized way to display links with icons
-
-1. **iconAlt**: Alternative text for the icon (not required with font awesome icon)
-
-2. **noFollow**: Optional; Accepts `true/false` values. If `true`, adds `rel="nofollow"` to the link
-
-3. **newTab**: Optional;; Accepts `true/false` values. If `true`, the link opens in a new tab `target="_blank"`.
-
-
-
-### Separator
-
-
-
-
-
## Layout
### Grid
#### 2 columns
@@ -301,24 +208,24 @@ The Link component is designed to provide a standardized way to display links wi
-
-
-
@@ -326,27 +233,15 @@ The Link component is designed to provide a standardized way to display links wi
diff --git a/src/pages/docs.mdx b/src/pages/docs.mdx
index 481cf9215f..65b00dd5e0 100644
--- a/src/pages/docs.mdx
+++ b/src/pages/docs.mdx
@@ -10,7 +10,7 @@ description: Need help with Octopus Deploy? Check out our comprehensive document
navOrder: 0
---
import RecentlyUpdated from '@components/RecentlyUpdated.astro';
-import Card from 'src/components/Card.astro';
+import Card from 'src/ink/core/Card.astro';
Octopus Deploy is a sophisticated, best-of-breed continuous delivery (CD) platform for modern software teams. Octopus offers powerful release orchestration, deployment automation, and runbook automation, while handling the scale, complexity and governance expectations of even the largest organizations with the most complex deployment challenges.
diff --git a/src/pages/docs/deployments/patterns/index.mdx b/src/pages/docs/deployments/patterns/index.mdx
index cb64d93403..f991112915 100644
--- a/src/pages/docs/deployments/patterns/index.mdx
+++ b/src/pages/docs/deployments/patterns/index.mdx
@@ -7,7 +7,7 @@ description: Common deployment patterns and practices, and their practical imple
navOrder: 180
---
-import Card from 'src/components/Card.astro';
+import Card from 'src/ink/core/Card.astro';
This section describes a number of common deployment patterns and practices, and their practical implementation with Octopus.
diff --git a/src/pages/docs/getting-started/first-deployment/deploy-a-package.mdx b/src/pages/docs/getting-started/first-deployment/deploy-a-package.mdx
index 79f86886dc..fe37870242 100644
--- a/src/pages/docs/getting-started/first-deployment/deploy-a-package.mdx
+++ b/src/pages/docs/getting-started/first-deployment/deploy-a-package.mdx
@@ -7,7 +7,7 @@ description: Deploy a sample package using Octopus Deploy. Create a deploy packa
navOrder: 80
hideInThisSection: true
---
-import Image from "src/components/Image.astro";
+import Image from "src/ink/core/Image.astro";
Deploying software with Octopus often involves deploying packages, for example, `.zip`, `.nupkg`, `.jar`, `.tar`, etc. In this section, we'll walk you through the steps to deploy a sample hello world package to your deployment target.
diff --git a/src/pages/docs/infrastructure/index.mdx b/src/pages/docs/infrastructure/index.mdx
index 7f7e03ec4d..8f45b71a82 100644
--- a/src/pages/docs/infrastructure/index.mdx
+++ b/src/pages/docs/infrastructure/index.mdx
@@ -13,7 +13,7 @@ hideInThisSection: true
---
import DeploymentTargets from "src/shared-content/infrastructure/deployment-targets.include.mdx";
-import Image from "src/components/Image.astro";
+import Image from "src/ink/core/Image.astro";
Octopus lets you map and organize your infrastructure, keeping it separate from your deployment process. Instead of hard-coding machines or resource names in your process, you can configure steps to run on targets based on tags called target tags. This means you don't have to change your deployment process when you add or remove hosting resources.
diff --git a/src/pages/docs/kubernetes/index.mdx b/src/pages/docs/kubernetes/index.mdx
index 36de1bd74c..6680e06dec 100644
--- a/src/pages/docs/kubernetes/index.mdx
+++ b/src/pages/docs/kubernetes/index.mdx
@@ -9,7 +9,7 @@ description: Octopus Deploy provides support for deploying Kubernetes resources.
navOrder: 27
---
import RecentlyUpdated from '@components/RecentlyUpdated.astro'; // Test fixture for tests/llm-endpoints.spec.ts (STABLE_MDX_PATH); keep these imports.
-import Card from 'src/components/Card.astro';
+import Card from 'src/ink/core/Card.astro';
Octopus Deploy makes it easy to manage your Kubernetes resources, whether you're starting simple or want complete control over a complex setup. This section has everything you need to know about using Octopus for Kubernetes CD.
diff --git a/src/pages/docs/kubernetes/steps/kustomize.mdx b/src/pages/docs/kubernetes/steps/kustomize.mdx
index b1b91fbaab..938f0a9587 100644
--- a/src/pages/docs/kubernetes/steps/kustomize.mdx
+++ b/src/pages/docs/kubernetes/steps/kustomize.mdx
@@ -7,7 +7,7 @@ description: Use Deploy with Kustomize to deploy resources to a Kubernetes clust
navOrder: 40
---
-import Image from "src/components/Image.astro";
+import Image from "src/ink/core/Image.astro";
Octopus supports the deployment of Kubernetes resources through the `Deploy with Kustomize` step.
diff --git a/src/pages/docs/projects/variables/getting-started.mdx b/src/pages/docs/projects/variables/getting-started.mdx
index 3fa49e5b7b..01d8a6a790 100644
--- a/src/pages/docs/projects/variables/getting-started.mdx
+++ b/src/pages/docs/projects/variables/getting-started.mdx
@@ -7,7 +7,7 @@ icon: fa-solid fa-play
description: A guide to getting started with variables in Octopus Deploy.
navOrder: 10
---
-import Card from 'src/components/Card.astro';
+import Card from 'src/ink/core/Card.astro';
You can manage the variables for your projects, by navigating to your project in Octopus and selecting Project Variables:
diff --git a/src/pages/docs/ui-mvp.astro b/src/pages/docs/ui-mvp.astro
new file mode 100644
index 0000000000..8242d6748d
--- /dev/null
+++ b/src/pages/docs/ui-mvp.astro
@@ -0,0 +1,183 @@
+---
+// Ink component showcase. Renders through the real InkLayout (the same shell every
+// docs page uses) so the demo reflects the true integrated result. Nav, breadcrumbs,
+// and TOC are derived by InkLayout; headings below are declared so the TOC populates.
+import InkLayout from '../../layouts/ink-docs/InkLayout.astro';
+import Card from '../../ink/core/LinkCard.astro';
+import Badge from '../../ink/core/Badge.astro';
+import Button from '../../ink/core/Button.astro';
+import Callout from '../../ink/core/Callout.astro';
+import CodeBlock from '../../ink/core/CodeBlock.astro';
+
+const frontmatter = {
+ title: 'Deploying a release',
+ description:
+ 'Create a release from your deployment process and promote it through your environments - from the dashboard or the CLI.',
+ // Markdown pages get `file` auto-injected; this hand-built page supplies it so the
+ // shared EditOnGithub link resolves to this source file.
+ file: new URL(import.meta.url).pathname,
+};
+
+const headings = [
+ { depth: 2, slug: 'before-you-begin', text: 'Before you begin' },
+ { depth: 2, slug: 'create-a-release', text: 'Create a release' },
+ { depth: 3, slug: 'promote', text: 'Promote to an environment' },
+ { depth: 2, slug: 'deploy-cli', text: 'Deploy from the CLI' },
+ { depth: 2, slug: 'next-steps', text: 'Next steps' },
+];
+---
+
+
+
+
Guide
+
+
+ 10 min read
+
+
Beginner
+
+
+
+ A release is a snapshot of your deployment process and the variables
+ and packages it references. Once you have a release, you deploy it to an environment
+ such as Development , Test , or
+ Production .
+
+
+ Before you begin
+
+ Make sure you have a project with a configured deployment process and at
+ least one deployment target registered in an environment.
+
+
+
+ You need the Project deployer role (or higher) to create and deploy
+ releases. See Managing users and teams .
+
+
+ Create a release
+
+ From your project dashboard, choose Create release . Octopus
+ assigns a version number based on your versioning strategy:
+
+
+
+ Open the project and select Create release .
+ Review the package versions selected for the release.
+ Add release notes (optional but recommended).
+ Select Save .
+
+
+
+
+
+ The release editor, showing the package versions selected for this
+ release.
+
+
+
+
+
+ Releases move through your lifecycle phases. The dashboard shows which
+ environments a release has reached:
+
+
+
+
+ Environment Status Last deployed
+
+
+
+ Development
+ Success
+ 2 hours ago
+
+
+ Test
+ Success
+ 1 hour ago
+
+
+ Production
+ Awaiting approval
+ -
+
+
+
+
+
+ Deployments to Production in this lifecycle require manual approval.
+ The release will pause until an authorized user intervenes.
+
+
+ Deploy from the CLI
+ You can script the same flow with the Octopus CLI:
+
+ octopus release deploy \
+ --project "Web Store" \
+ --version "2024.1.0" \
+ --environment "Production" \
+ --progress
+
+
+ The --progress flag streams the deployment log to your terminal so
+ it integrates cleanly with CI pipelines.
+
+
+
+ Once the deployment completes, the dashboard and the CLI both report a green
+ Success status for that environment.
+
+
+ Next steps
+
+
+
+ Define the steps Octopus runs when it deploys your release.
+
+
+ Parameterize deployments per environment, tenant, and target.
+
+
+ Automate routine operations like backups and failover.
+
+
+ Route releases through different lifecycles and rules.
+
+
+
+
+ Read the deployment guide
+ Browse the CLI reference
+
+
diff --git a/src/shared-content/infrastructure/deployment-targets.include.mdx b/src/shared-content/infrastructure/deployment-targets.include.mdx
index 0f6fec2354..8d8032e3c3 100644
--- a/src/shared-content/infrastructure/deployment-targets.include.mdx
+++ b/src/shared-content/infrastructure/deployment-targets.include.mdx
@@ -1,4 +1,4 @@
-import Image from "src/components/Image.astro";
+import Image from "src/ink/core/Image.astro";
With Octopus Deploy, you can deploy software to Windows servers, Linux servers, Microsoft Azure, AWS, Kubernetes clusters, cloud regions, or an offline package drop. Regardless of where you're deploying your software, these machines and services are known as your deployment targets.
diff --git a/src/themes/octopus/components/HeadMeta.astro b/src/themes/octopus/components/HeadMeta.astro
new file mode 100644
index 0000000000..f3b0159493
--- /dev/null
+++ b/src/themes/octopus/components/HeadMeta.astro
@@ -0,0 +1,127 @@
+---
+// Shared contents (everything except the wrapper, charset, and the
+// stylesheet links). Single source of truth for the SEO/meta/OG/JsonLd/HEADER_SCRIPTS so
+// HtmlHead (legacy, with main.css) and the Ink docs Head (no main.css, web fonts) can't
+// drift apart. Each caller supplies its own charset + stylesheet links, then renders this.
+import { Accelerator } from 'astro-accelerator-utils';
+import type { Frontmatter } from 'astro-accelerator-utils/types/Frontmatter';
+import { SITE, OPEN_GRAPH, HEADER_SCRIPTS } from '@config';
+import { getEligibleSlugs } from '@util/mdxContent';
+import JsonLd from '@components/JsonLd.astro';
+
+const accelerator = new Accelerator(SITE);
+
+type Props = {
+ lang: string;
+ frontmatter: Frontmatter;
+ headings: { depth: number; slug: string; text: string }[];
+};
+const { frontmatter } = Astro.props;
+
+const imageSrc = frontmatter.bannerImage?.src ?? OPEN_GRAPH.image.src;
+const imageAlt = frontmatter.bannerImage?.alt ?? OPEN_GRAPH.image.alt;
+const robots = frontmatter.robots ?? 'index, follow';
+const canonicalImageSrc = new URL(imageSrc, Astro.site);
+const canonicalURL = accelerator.urlFormatter.formatUrl(
+ new URL(Astro.url.pathname, Astro.site + SITE.subfolder)
+);
+const socialTitle = await accelerator.markdown.getTextFrom(frontmatter?.title);
+const title = `${accelerator.markdown.titleCase(socialTitle)} ${frontmatter.titleAdditional ? ` ${frontmatter.titleAdditional}` : ''} | ${SITE.title}`;
+const pageMeta =
+ frontmatter?.meta && frontmatter?.meta?.length > 0 ? frontmatter.meta : [];
+
+const authorList = accelerator.authors.forPost(frontmatter);
+const authorMeta =
+ authorList.mainAuthor?.frontmatter?.meta &&
+ authorList.mainAuthor?.frontmatter?.meta?.length > 0
+ ? authorList.mainAuthor.frontmatter.meta
+ : [];
+
+const autoMeta = (name: string) => {
+ return pageMeta.filter((m) => m.name.toLowerCase() === name).length === 0;
+};
+
+const docsPath = Astro.url.pathname.replace(/\/$/, '');
+const subfolderPrefix = SITE.subfolder.replace(/\/$/, '') + '/';
+const docsSlug = docsPath.startsWith(subfolderPrefix)
+ ? docsPath.slice(subfolderPrefix.length)
+ : null;
+const eligibleSlugs = getEligibleSlugs();
+const markdownAlternateHref =
+ docsSlug && eligibleSlugs.has(docsSlug) ? SITE.url + docsPath + '.md' : null;
+---
+
+
{title}
+
+
+
+{
+ autoMeta('viewport') && (
+
+ )
+}
+{
+ autoMeta('format-detection') && (
+
+ )
+}
+{
+ autoMeta('theme-color') && (
+
+ )
+}
+{autoMeta('canonical') &&
}
+{
+ markdownAlternateHref && (
+
+ )
+}
+{
+ SITE.feedUrl && (
+
+ )
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{pageMeta.map((m) =>
)}
+{authorMeta.map((m) =>
)}
+
+
diff --git a/src/themes/octopus/components/HtmlHead.astro b/src/themes/octopus/components/HtmlHead.astro
index a43ae28e60..d346ee263c 100644
--- a/src/themes/octopus/components/HtmlHead.astro
+++ b/src/themes/octopus/components/HtmlHead.astro
@@ -1,139 +1,27 @@
---
-import { Accelerator } from 'astro-accelerator-utils';
+// Legacy site : charset + the full stylesheet stack (including main.css) + the
+// shared HeadMeta (SEO/OG/JsonLd/HEADER_SCRIPTS). The meta lives in HeadMeta so this and
+// the Ink docs Head can't drift apart.
import type { Frontmatter } from 'astro-accelerator-utils/types/Frontmatter';
-import { SITE, OPEN_GRAPH, HEADER_SCRIPTS } from '@config';
-import { getEligibleSlugs } from '@util/mdxContent';
-import JsonLd from '@components/JsonLd.astro';
+import { SITE } from '@config';
import { ASSETS, ASSETS_ORIGIN } from 'src/lib/sharedNavbarFooter';
+import HeadMeta from './HeadMeta.astro';
-const accelerator = new Accelerator(SITE);
-const stats = new accelerator.statistics('octopus/components/HtmlHead.astro');
-stats.start();
-
-// Properties
type Props = {
lang: string;
frontmatter: Frontmatter;
headings: { depth: number; slug: string; text: string }[];
};
-const { frontmatter } = Astro.props;
-
-// Logic
-const imageSrc = frontmatter.bannerImage?.src ?? OPEN_GRAPH.image.src;
-const imageAlt = frontmatter.bannerImage?.alt ?? OPEN_GRAPH.image.alt;
-const robots = frontmatter.robots ?? 'index, follow';
-const canonicalImageSrc = new URL(imageSrc, Astro.site);
-const canonicalURL = accelerator.urlFormatter.formatUrl(
- new URL(Astro.url.pathname, Astro.site + SITE.subfolder)
-);
-const socialTitle = await accelerator.markdown.getTextFrom(frontmatter?.title);
-const title = `${accelerator.markdown.titleCase(socialTitle)} ${frontmatter.titleAdditional ? ` ${frontmatter.titleAdditional}` : ''} | ${SITE.title}`;
-const pageMeta =
- frontmatter?.meta && frontmatter?.meta?.length > 0 ? frontmatter.meta : [];
-
-const authorList = accelerator.authors.forPost(frontmatter);
-const authorMeta =
- authorList.mainAuthor?.frontmatter?.meta &&
- authorList.mainAuthor?.frontmatter?.meta?.length > 0
- ? authorList.mainAuthor.frontmatter.meta
- : [];
-
-const autoMeta = (name: string) => {
- return pageMeta.filter((m) => m.name.toLowerCase() === name).length === 0;
-};
-
-const docsPath = Astro.url.pathname.replace(/\/$/, '');
-const subfolderPrefix = SITE.subfolder.replace(/\/$/, '') + '/';
-const docsSlug = docsPath.startsWith(subfolderPrefix)
- ? docsPath.slice(subfolderPrefix.length)
- : null;
-const eligibleSlugs = getEligibleSlugs();
-const markdownAlternateHref =
- docsSlug && eligibleSlugs.has(docsSlug) ? SITE.url + docsPath + '.md' : null;
-
-stats.stop();
+const { frontmatter, headings, lang } = Astro.props;
---
-
{title}
-
-
-
- {
- autoMeta('viewport') && (
-
- )
- }
- {
- autoMeta('format-detection') && (
-
- )
- }
- {
- autoMeta('theme-color') && (
-
- )
- }
- {autoMeta('canonical') &&
}
- {
- markdownAlternateHref && (
-
- )
- }
- {
- SITE.feedUrl && (
-
- )
- }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {pageMeta.map((m) =>
)}
- {authorMeta.map((m) =>
)}
-
-
+