@f5-sales-demo/docs-theme 4.11.6 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ export const ROOT = 'https://f5-sales-demo.github.io/shared/';
2
+ export const CONTRACT = 'v1';
3
+ const hash = /^[a-f0-9]{64}$/;
4
+ export function validateAsset(asset) {
5
+ if (!asset || !hash.test(asset.sha256) || !Number.isSafeInteger(asset.bytes) || asset.bytes < 1) {
6
+ throw new Error('Invalid shared asset receipt');
7
+ }
8
+ const url = new URL(asset.url);
9
+ if (
10
+ url.href !== `${ROOT}assets/${asset.sha256}.${asset.extension}` ||
11
+ !/^(css|js|json|woff2|svg|png|txt)$/.test(asset.extension)
12
+ ) {
13
+ throw new Error('Shared asset must use its canonical content-addressed root URL');
14
+ }
15
+ return asset;
16
+ }
17
+ export function validatePointer(pointer) {
18
+ if (pointer?.contract !== CONTRACT) throw new Error('Unsupported shared contract');
19
+ const release = pointer.release;
20
+ if (
21
+ !release ||
22
+ !hash.test(release.sha256) ||
23
+ !Number.isSafeInteger(release.bytes) ||
24
+ release.bytes < 1 ||
25
+ release.url !== `${ROOT}v1/releases/${release.sha256}.json`
26
+ )
27
+ throw new Error('Invalid shared release pointer');
28
+ return pointer;
29
+ }
30
+ export function validateManifest(manifest) {
31
+ if (manifest?.contract !== CONTRACT || !manifest.assets || typeof manifest.assets !== 'object')
32
+ throw new Error('Unsupported shared manifest');
33
+ for (const required of ['css', 'runtime', 'data', 'logo', 'favicon']) {
34
+ if (!manifest.assets[required]) throw new Error(`Missing shared asset: ${required}`);
35
+ }
36
+ const types = { css: 'css', runtime: 'js', data: 'json', logo: 'svg', favicon: 'svg' };
37
+ for (const [name, asset] of Object.entries(manifest.assets)) {
38
+ validateAsset(asset);
39
+ if (types[name] && asset.extension !== types[name]) throw new Error(`Incorrect shared asset type: ${name}`);
40
+ }
41
+ return manifest;
42
+ }
43
+ export function validateConsumer(consumer) {
44
+ if (
45
+ consumer?.contract !== CONTRACT ||
46
+ !/^f5-sales-demo\/[a-z0-9_.-]+$/.test(consumer.repository) ||
47
+ !/^\/(?:[a-zA-Z0-9_.-]+\/)*$/.test(consumer.documentBase) ||
48
+ typeof consumer.locale !== 'string' ||
49
+ !consumer.mounts ||
50
+ !['desktop', 'mobile'].every(
51
+ (key) => typeof consumer.mounts[key] === 'string' && /^#[a-z][a-z0-9-]*$/.test(consumer.mounts[key]),
52
+ )
53
+ ) {
54
+ throw new Error('Invalid shared consumer contract');
55
+ }
56
+ return consumer;
57
+ }
58
+ export function validateData(data) {
59
+ if (
60
+ !Array.isArray(data?.menu?.items) ||
61
+ !data.menu.items.length ||
62
+ !Array.isArray(data.searchSources) ||
63
+ !data.routing
64
+ )
65
+ throw new Error('Invalid shared menu data');
66
+ const labels = new Set();
67
+ const destinations = new Set();
68
+ const visit = (value) => {
69
+ if (!value || typeof value !== 'object') return;
70
+ for (const [key, child] of Object.entries(value)) {
71
+ if (key === 'href') {
72
+ const url = new URL(child);
73
+ if (url.protocol !== 'https:' || url.username || url.password)
74
+ throw new Error('Menu destinations require public HTTPS URLs');
75
+ } else if (/translations$/i.test(key)) {
76
+ if (
77
+ !child ||
78
+ typeof child !== 'object' ||
79
+ Object.values(child).some((text) => typeof text !== 'string' || !text.trim())
80
+ )
81
+ throw new Error('Invalid supplied translation');
82
+ } else visit(child);
83
+ }
84
+ };
85
+ visit(data.menu);
86
+ for (const item of data.menu.items) {
87
+ if (!item.label?.trim() || labels.has(item.label)) throw new Error('Duplicate or missing menu label');
88
+ labels.add(item.label);
89
+ for (const category of item.content?.categories || []) {
90
+ const links = new Set();
91
+ for (const link of category.items) {
92
+ if (!link.label?.trim() || links.has(link.href)) throw new Error('Duplicate or missing menu entry');
93
+ links.add(link.href);
94
+ if (destinations.has(link.href)) throw new Error('Duplicate global menu destination');
95
+ destinations.add(link.href);
96
+ }
97
+ }
98
+ }
99
+ const sources = new Set();
100
+ for (const source of data.searchSources) {
101
+ if (!source.repo || !source.label || sources.has(source.repo))
102
+ throw new Error('Duplicate or missing Search source');
103
+ sources.add(source.repo);
104
+ }
105
+ for (const key of ['locales', 'unlocalized', 'englishOnly']) {
106
+ if (!Array.isArray(data.routing[key]) || data.routing[key].some((value) => typeof value !== 'string'))
107
+ throw new Error('Invalid locale routing');
108
+ }
109
+ return data;
110
+ }
@@ -0,0 +1,15 @@
1
+ export function langToSlug(lang) {
2
+ return lang.toLowerCase();
3
+ }
4
+ export function localizeEcosystemHref(href, locale) {
5
+ const routing = globalThis.F5SharedRoutingV1;
6
+ if (!routing?.locales.includes(locale)) return href;
7
+ const url = new URL(href, document.baseURI);
8
+ if (url.hostname !== 'f5-sales-demo.github.io') return href;
9
+ const parts = url.pathname.split('/').filter(Boolean);
10
+ if (!parts.length || routing.locales.includes(parts[0]) || routing.unlocalized.includes(parts[0])) return href;
11
+ if (parts[1] && routing.locales.includes(parts[1])) return href;
12
+ parts.splice(1, 0, routing.englishOnly.includes(parts[0]) ? 'en' : locale);
13
+ url.pathname = `/${parts.join('/')}/`;
14
+ return url.href;
15
+ }
@@ -0,0 +1,124 @@
1
+ import type { MegaMenuConfig } from '@f5-sales-demo/starlight-mega-menu';
2
+ import MegaMenu from '@f5-sales-demo/starlight-mega-menu/components/MegaMenu.tsx';
3
+ import MegaMenuMobile from '@f5-sales-demo/starlight-mega-menu/components/MegaMenuMobile.tsx';
4
+ import { flushSync } from 'react-dom';
5
+ import { createRoot } from 'react-dom/client';
6
+ import { searchSource } from '../utils/search-source.ts';
7
+
8
+ interface Consumer {
9
+ repository: string;
10
+ mounts: { desktop: string; mobile: string };
11
+ }
12
+ interface Asset {
13
+ url: string;
14
+ }
15
+ interface Release {
16
+ data: {
17
+ menu: MegaMenuConfig;
18
+ routing: { locales: string[]; unlocalized: string[]; englishOnly: string[] };
19
+ searchSources: Array<{ repo: string; label: string }>;
20
+ };
21
+ manifest: { assets: Record<string, Asset> };
22
+ }
23
+ interface Prepared {
24
+ commit(): void;
25
+ dispose(): void;
26
+ }
27
+ declare global {
28
+ interface Window {
29
+ F5SharedV1: Map<string, { prepare(consumer: Consumer, release: Release): Prepared }>;
30
+ F5SharedSearchV1: { mergeIndex: Array<{ bundlePath: string }> };
31
+ }
32
+ var F5SharedRoutingV1: Release['data']['routing'];
33
+ }
34
+ // All imports are bundled once by the publisher. No downstream React island is involved.
35
+ window.F5SharedV1 ||= new Map();
36
+ const registry = window.F5SharedV1;
37
+ registry.set(import.meta.url, {
38
+ prepare(consumer: Consumer, release: Release) {
39
+ const desktop = document.querySelector(consumer.mounts.desktop);
40
+ const mobile = document.querySelector(consumer.mounts.mobile);
41
+ if (!desktop || !mobile) throw new Error('Missing ecosystem mount');
42
+ const desktopStage = document.createElement('div');
43
+ const mobileStage = document.createElement('div');
44
+ const roots = [createRoot(desktopStage), createRoot(mobileStage)];
45
+ globalThis.F5SharedRoutingV1 = release.data.routing;
46
+ flushSync(() => {
47
+ roots[0].render(<MegaMenu config={release.data.menu} />);
48
+ roots[1].render(<MegaMenuMobile config={release.data.menu} />);
49
+ });
50
+ let observer: MutationObserver;
51
+ const dismissPreferences = (event: KeyboardEvent) => {
52
+ const preferences = document.querySelector<HTMLDetailsElement>(
53
+ '.compact-preferences[open], .provider-local-nav[open]',
54
+ );
55
+ if (event.key === 'Escape' && preferences) {
56
+ preferences.open = false;
57
+ preferences.querySelector('summary')?.focus();
58
+ }
59
+ };
60
+ return {
61
+ commit() {
62
+ desktop.replaceChildren(desktopStage);
63
+ mobile.replaceChildren(mobileStage);
64
+ // Branding URLs switch only with the coordinated shell release.
65
+ for (const element of document.querySelectorAll<HTMLImageElement>('[data-f5-shared-brand]')) {
66
+ const asset = release.manifest.assets[element.dataset.f5SharedBrand || ''];
67
+ if (asset) element.src = asset.url;
68
+ }
69
+ const favicon = document.querySelector<HTMLLinkElement>('[data-f5-shared-favicon]');
70
+ if (favicon) favicon.href = release.manifest.assets.favicon.url;
71
+ const annotate = () => {
72
+ for (const link of document.querySelectorAll<HTMLAnchorElement>('.pagefind-ui__result-link')) {
73
+ const title = link.closest('.pagefind-ui__result-title');
74
+ if (!title || title.querySelector('.search-project-source')) continue;
75
+ const label = searchSource(link.href, release.data.searchSources);
76
+ if (!label) continue;
77
+ const source = document.createElement('span');
78
+ source.className = 'search-project-source';
79
+ source.textContent = label;
80
+ title.append(source);
81
+ }
82
+ };
83
+ observer = new MutationObserver(annotate);
84
+ observer.observe(document.body, { childList: true, subtree: true });
85
+ annotate();
86
+ // Pagefind remains the local Starlight implementation. Its merge bundles are supplied here.
87
+ const current = consumer.repository.split('/')[1];
88
+ window.F5SharedSearchV1 = {
89
+ mergeIndex: release.data.searchSources
90
+ .filter((source) => source.repo !== current)
91
+ .map((source) => ({
92
+ bundlePath: `https://f5-sales-demo.github.io/${source.repo === 'f5-sales-demo.github.io' ? '' : `${source.repo}/`}pagefind/`,
93
+ })),
94
+ };
95
+ document.addEventListener('keydown', dismissPreferences);
96
+ // Supply the focus trap missing in the archived mobile renderer.
97
+ document.addEventListener('keydown', trap);
98
+ },
99
+ dispose() {
100
+ for (const root of roots) root.unmount();
101
+ observer?.disconnect();
102
+ document.removeEventListener('keydown', dismissPreferences);
103
+ document.removeEventListener('keydown', trap);
104
+ },
105
+ };
106
+ },
107
+ });
108
+ function trap(event: KeyboardEvent) {
109
+ if (event.key !== 'Tab') return;
110
+ const dialog = document.querySelector<HTMLElement>('.smm-mobile-overlay');
111
+ if (!dialog) return;
112
+ const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button, a, summary, [tabindex="0"]')).filter(
113
+ (element) => element.getClientRects().length,
114
+ );
115
+ const first = controls[0],
116
+ last = controls.at(-1);
117
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === dialog)) {
118
+ event.preventDefault();
119
+ last?.focus();
120
+ } else if (!event.shiftKey && (document.activeElement === last || document.activeElement === dialog)) {
121
+ event.preventDefault();
122
+ first?.focus();
123
+ }
124
+ }
@@ -0,0 +1,16 @@
1
+ // Keep Starlight's Search and Pagefind implementation local; inject only release-owned federation.
2
+ export function sharedSearchIntegration() {
3
+ return {
4
+ name: 'f5-shared-search',
5
+ enforce: 'pre',
6
+ transform(code, id) {
7
+ if (!id.includes('/@astrojs/starlight/components/Search.astro')) return;
8
+ return code
9
+ .replace('...pagefindUserConfig,', '...pagefindUserConfig, ...(window.F5SharedSearchV1 || {}),')
10
+ .replace(
11
+ 'const { PagefindUI } = await import',
12
+ 'await window.F5SharedReadyV1; const { PagefindUI } = await import',
13
+ );
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,19 @@
1
+ import baseline from './baseline.json' with { type: 'json' };
2
+
3
+ export { baseline };
4
+ export function sharedMode(repository = process.env.GITHUB_REPOSITORY || '') {
5
+ const requested = process.env.DOCS_SHARED_MODE || 'auto';
6
+ if (!['auto', 'local', 'publish', 'consume'].includes(requested)) throw new Error('Invalid DOCS_SHARED_MODE');
7
+ const root = repository === 'f5-sales-demo/f5-sales-demo.github.io';
8
+ const mode =
9
+ requested === 'auto'
10
+ ? root
11
+ ? 'publish'
12
+ : repository.startsWith('f5-sales-demo/')
13
+ ? 'consume'
14
+ : 'local'
15
+ : requested;
16
+ if ((mode === 'publish') !== root && mode !== 'local')
17
+ throw new Error('Only the landing repository publishes the shared tree');
18
+ return mode;
19
+ }
@@ -1,32 +1,3 @@
1
- export const federatedSearchSites = [
2
- { repo: 'docs-builder', label: 'Documentation builder' },
3
- { repo: 'docs-theme', label: 'Shared documentation theme' },
4
- { repo: 'f5-sales-demo.github.io', label: 'Sales demo portal' },
5
- { repo: 'administration', label: 'Administration' },
6
- { repo: 'nginx', label: 'NGINX One' },
7
- { repo: 'observability', label: 'Observability' },
8
- { repo: 'statistics', label: 'Statistics' },
9
- { repo: 'was', label: 'Web App Scanning' },
10
- { repo: 'multi-cloud-networking', label: 'Multi-Cloud Networking' },
11
- { repo: 'canada', label: 'Canada topology' },
12
- { repo: 'dns', label: 'DNS Management' },
13
- { repo: 'cdn', label: 'Content Delivery Network' },
14
- { repo: 'bot-standard', label: 'Bot Defense Standard' },
15
- { repo: 'bot-advanced', label: 'Bot Defense Advanced' },
16
- { repo: 'ddos', label: 'DDoS Mitigation' },
17
- { repo: 'webapp-api-protection', label: 'Web App & API Protection' },
18
- { repo: 'api-protection', label: 'API Protection' },
19
- { repo: 'xcsh', label: 'xcsh' },
20
- { repo: 'xcsh-action', label: 'xcsh GitHub Action' },
21
- { repo: 'csd', label: 'Client-Side Defense' },
22
- { repo: 'docs-icons', label: 'Icon packages' },
23
- { repo: 'marketplace', label: 'xcsh marketplace' },
24
- { repo: 'api-specs', label: 'API specifications' },
25
- { repo: 'api-specs-enriched', label: 'Enriched API specifications' },
26
- { repo: 'cdn-simulator', label: 'CDN simulator' },
27
- { repo: 'origin-server', label: 'Origin server' },
28
- { repo: 'traffic-generator', label: 'Traffic generator' },
29
- { repo: 'demo-resources', label: 'Demo resource catalog' },
30
- { repo: 'xcsh-chrome-extension', label: 'xcsh Chrome extension' },
31
- { repo: 'console', label: 'Console catalog' },
32
- ];
1
+ // Explicit local builds may supply their own federation in createF5xcDocsConfig.
2
+ export const localSearchSites: Array<{ repo: string; label: string }> = [];
3
+ export const federatedSearchSites = localSearchSites;
@@ -1,34 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { expect, it } from 'vitest';
2
2
  import { defaultMegaMenuItems } from '../../config';
3
- import { localizeEcosystemHref } from './localize-ecosystem-href';
4
3
 
5
- describe('showcase navigation', () => {
6
- it('uses the audience taxonomy without duplicate project destinations', () => {
7
- expect(defaultMegaMenuItems.map((item) => item.label)).toEqual([
8
- 'Demos',
9
- 'Demo environment',
10
- 'Operations',
11
- 'Developer tools',
12
- 'Ecosystem',
13
- 'F5 services',
14
- ]);
15
- const links = defaultMegaMenuItems.flatMap((item) => item.content?.categories?.flatMap((c) => c.items) ?? []);
16
- const urls = links.map((item) => item.href);
17
- expect(new Set(urls).size).toBe(urls.length);
18
- expect(urls).not.toContain('https://f5-sales-demo.github.io/');
19
- expect(links.find((l) => l.href.includes('/api-protection/'))?.label).toBe('API Protection');
20
- expect(links.find((l) => l.href.includes('/ddos/'))?.label).toBe('DDoS Mitigation');
21
- expect(defaultMegaMenuItems[1].content?.footer?.label).toBe('Demo resource catalog');
22
- });
23
-
24
- it('keeps explicit English catalog destinations valid across locale selection', () => {
25
- for (const locale of ['en', 'fr', 'ar']) {
26
- expect(localizeEcosystemHref('https://f5-sales-demo.github.io/en/demos/', locale)).toBe(
27
- 'https://f5-sales-demo.github.io/en/demos/',
28
- );
29
- expect(localizeEcosystemHref('https://f5-sales-demo.github.io/en/ecosystem/', locale)).toBe(
30
- 'https://f5-sales-demo.github.io/en/ecosystem/',
31
- );
32
- }
33
- });
34
- });
4
+ it('leaves production menu content to the root publisher', () => expect(defaultMegaMenuItems).toEqual([]));
@@ -1,33 +1,9 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { defaultMegaMenuItems, federatedSearchBundlePaths, federatedSearchSites } from '../../config';
1
+ import { expect, it } from 'vitest';
3
2
  import { localizeEcosystemHref } from './localize-ecosystem-href';
4
3
 
5
- describe('Statistics discovery', () => {
6
- it('lists Statistics beside monitoring tools exactly once', () => {
7
- const networking = defaultMegaMenuItems.find((item) => item.label === 'Operations');
8
- const category = networking?.content?.categories?.find((item) => item.title === 'Queries and administration');
9
- const statistics = category?.items?.filter((item) => item.label === 'Statistics');
10
- expect(statistics).toHaveLength(1);
11
- expect(statistics?.[0]).toMatchObject({
12
- href: 'https://f5-sales-demo.github.io/statistics/en/',
13
- description: 'Query access logs, application metrics, and security telemetry through the API.',
14
- });
15
- expect(category?.items?.map((item) => item.label)).toEqual(['Statistics', 'Observability', 'Administration']);
16
- });
17
- it('adds Statistics to federated search and avoids self-index duplication', () => {
18
- expect(federatedSearchSites.filter((item) => item.repo === 'statistics')).toHaveLength(1);
19
- expect(federatedSearchBundlePaths('https://f5-sales-demo.github.io', '/')).toContain(
20
- 'https://f5-sales-demo.github.io/statistics/pagefind/',
4
+ it('retains explicit English Statistics destinations', () => {
5
+ for (const locale of ['en', 'fr', 'ar'])
6
+ expect(localizeEcosystemHref('https://f5-sales-demo.github.io/statistics/en/', locale)).toBe(
7
+ 'https://f5-sales-demo.github.io/statistics/en/',
21
8
  );
22
- expect(federatedSearchBundlePaths('https://f5-sales-demo.github.io', '/statistics')).not.toContain(
23
- 'https://f5-sales-demo.github.io/statistics/pagefind/',
24
- );
25
- });
26
- it('keeps the published English destination for every reader locale', () => {
27
- for (const locale of ['en', 'fr', 'ja']) {
28
- expect(localizeEcosystemHref('https://f5-sales-demo.github.io/statistics/en/', locale)).toBe(
29
- 'https://f5-sales-demo.github.io/statistics/en/',
30
- );
31
- }
32
- });
33
9
  });
@@ -0,0 +1,242 @@
1
+
2
+ @layer starlight.core {
3
+ .sl-mega-menu-header {
4
+ display: flex;
5
+ gap: var(--sl-nav-gap);
6
+ justify-content: space-between;
7
+ align-items: center;
8
+ height: 100%;
9
+ }
10
+
11
+ .title-wrapper {
12
+ /* Prevent long titles overflowing and covering the search and menu buttons on narrow viewports. */
13
+ overflow: clip;
14
+ /* Avoid clipping focus ring around link inside title wrapper. */
15
+ padding: 0.25rem;
16
+ margin: -0.25rem;
17
+ min-width: 0;
18
+ }
19
+
20
+ .right-group { display: none; }
21
+ .compact-preferences { position: relative; flex-shrink: 0; }
22
+ .compact-preferences summary {
23
+ cursor: pointer;
24
+ padding: 0.75rem;
25
+ min-height: 44px;
26
+ list-style: none;
27
+ }
28
+ .preferences-panel {
29
+ position: absolute;
30
+ inset-inline-end: 0;
31
+ top: calc(100% + 0.5rem);
32
+ display: grid;
33
+ gap: 0.5rem;
34
+ padding: 0.75rem;
35
+ background: var(--sl-color-bg-nav);
36
+ border: 1px solid var(--sl-color-gray-5);
37
+ border-radius: 0.5rem;
38
+ }
39
+ .search-project-source {
40
+ display: block;
41
+ font-size: var(--sl-text-sm);
42
+ font-weight: 400;
43
+ color: var(--sl-color-gray-2);
44
+ margin-top: 0.25rem;
45
+ }
46
+ @media (min-width: 90rem) {
47
+ .right-group { display: flex; }
48
+ .compact-preferences { display: none; }
49
+ }
50
+
51
+ .right-group,
52
+ .social-icons {
53
+ gap: 1rem;
54
+ align-items: center;
55
+ }
56
+ .social-icons::after {
57
+ content: '';
58
+ height: 2rem;
59
+ border-inline-end: 1px solid var(--sl-color-gray-5);
60
+ }
61
+
62
+ /* Desktop nav is hidden on small screens */
63
+ .smm-desktop-nav {
64
+ display: none;
65
+ }
66
+
67
+ /* Mobile toggle is visible on small screens */
68
+ .smm-mobile-toggle {
69
+ display: flex;
70
+ align-items: center;
71
+ }
72
+
73
+ @media (min-width: 90rem) {
74
+ :root[data-has-sidebar] {
75
+ --__sidebar-pad: calc(2 * var(--sl-nav-pad-x));
76
+ }
77
+ :root:not([data-has-toc]) {
78
+ --__toc-width: 0rem;
79
+ }
80
+ .sl-mega-menu-header {
81
+ --__sidebar-width: max(0rem, var(--sl-content-inline-start, 0rem) - var(--sl-nav-pad-x));
82
+ --__main-column-fr: calc(
83
+ (
84
+ 100% + var(--__sidebar-pad, 0rem) - var(--__toc-width, var(--sl-sidebar-width)) -
85
+ (2 * var(--__toc-width, var(--sl-nav-pad-x))) - var(--sl-content-inline-start, 0rem) -
86
+ var(--sl-content-width)
87
+ ) / 2
88
+ );
89
+ display: grid;
90
+ grid-template-columns:
91
+ /* 1 (site title) */
92
+ minmax(0, 1fr)
93
+ /* 2 (mega-menu nav) */
94
+ auto
95
+ /* 3 (search box) */
96
+ auto
97
+ /* 4 (right items) */
98
+ auto;
99
+ align-content: center;
100
+ }
101
+
102
+ /* Show desktop nav, hide mobile toggle */
103
+ .smm-desktop-nav {
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ min-width: 0;
108
+ }
109
+
110
+ .smm-mobile-toggle {
111
+ display: none;
112
+ }
113
+ }
114
+ }
115
+
116
+
117
+ .site-title {
118
+ align-items: center;
119
+ gap: var(--sl-nav-gap);
120
+ font-size: var(--sl-text-h4);
121
+ font-weight: 600;
122
+ color: var(--sl-color-text-accent);
123
+ text-decoration: none;
124
+ white-space: nowrap;
125
+ min-width: 0;
126
+ }
127
+ .site-title span {
128
+ overflow: hidden;
129
+ text-overflow: ellipsis;
130
+ white-space: nowrap;
131
+ min-width: 0;
132
+ }
133
+ .site-title img {
134
+ height: calc(var(--sl-nav-height) - 2 * var(--sl-nav-pad-y));
135
+ width: auto;
136
+ max-width: 100%;
137
+ object-fit: contain;
138
+ object-position: 0 50%;
139
+ flex-shrink: 0;
140
+ }
141
+
142
+
143
+ .project-links {
144
+ display: flex;
145
+ flex-wrap: wrap;
146
+ gap: 0.5rem 1.5rem;
147
+ border-top: 1px solid var(--sl-color-gray-5);
148
+ margin-top: 2rem;
149
+ padding-block: 1rem;
150
+ }
151
+ .project-links a { padding-block: 0.5rem; }
152
+
153
+
154
+ .sl-link-card {
155
+ display: grid;
156
+ grid-template-columns: minmax(0, 1fr) auto;
157
+ gap: 0.5rem;
158
+ border: 1px solid var(--sl-color-gray-5);
159
+ border-radius: 0.5rem;
160
+ padding: 1rem;
161
+ box-shadow: var(--f5-shadow-low);
162
+ position: relative;
163
+ transition: box-shadow var(--f5-transition-base),
164
+ transform var(--f5-transition-base);
165
+ }
166
+
167
+ .sl-link-card.has-icon {
168
+ grid-template-columns: auto minmax(0, 1fr) auto;
169
+ }
170
+
171
+ .sl-link-card a {
172
+ text-decoration: none;
173
+ line-height: var(--sl-line-height-headings);
174
+ }
175
+
176
+ /* a11y fix for https://github.com/withastro/starlight/issues/487 */
177
+ .sl-link-card a::before {
178
+ content: '';
179
+ position: absolute;
180
+ inset: 0;
181
+ }
182
+
183
+ .sl-link-card .stack {
184
+ min-width: 0;
185
+ overflow-wrap: anywhere;
186
+ display: grid;
187
+ align-content: start;
188
+ gap: 0.5rem;
189
+ }
190
+
191
+ .sl-link-card .title {
192
+ color: var(--sl-color-white);
193
+ font-weight: 600;
194
+ font-size: var(--sl-text-lg);
195
+ }
196
+
197
+ .sl-link-card .description {
198
+ color: var(--sl-color-gray-3);
199
+ line-height: 1.5;
200
+ margin: 0;
201
+ }
202
+
203
+ .sl-link-card .icon {
204
+ color: var(--sl-color-gray-3);
205
+ }
206
+
207
+ .sl-link-card .card-icon {
208
+ color: var(--sl-color-white);
209
+ align-self: start;
210
+ position: relative;
211
+ width: 3rem;
212
+ height: 3rem;
213
+ }
214
+
215
+ .sl-link-card .card-icon svg {
216
+ position: absolute;
217
+ inset: 0;
218
+ width: 100%;
219
+ height: 100%;
220
+ }
221
+
222
+ .sl-link-card:focus-within {
223
+ outline: 2px solid var(--sl-color-text-accent);
224
+ outline-offset: 3px;
225
+ }
226
+
227
+ /* Hover state */
228
+ .sl-link-card:hover {
229
+ background: var(--sl-color-gray-7, var(--sl-color-gray-6));
230
+ border-color: var(--sl-color-gray-2);
231
+ box-shadow: var(--f5-shadow-mid);
232
+ transform: translateY(-1px);
233
+ }
234
+
235
+ .sl-link-card:hover .icon {
236
+ color: var(--sl-color-white);
237
+ }
238
+
239
+ .provider-local-nav { position: relative; flex-shrink: 0; }
240
+ .provider-local-nav summary { padding: .5rem; cursor: pointer; }
241
+ .provider-local-nav nav { position: absolute; inset-inline-start: 0; top: 100%; display: grid; min-width: 12rem; gap: .5rem; padding: .75rem; background: var(--sl-color-bg-nav); border: 1px solid var(--sl-color-gray-5); border-radius: .5rem; z-index: 10; }
242
+ [data-f5-fallback] { padding:.5rem; }