@sarimarcus/content-sites-ui 0.15.0 → 0.16.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,155 @@
1
+ // Site header controller: dropdowns (click, hover with a close delay, ArrowDown/ArrowUp/Escape, outside click,
2
+ // focus leaving), the mobile menu (toggle, Escape from anywhere, focus into the panel on open), the single-open
3
+ // mobile accordion and the Ctrl/Cmd+K search shortcut. Every root is bound once; document listeners are bound once
4
+ // per page however many headers it holds, so re-running it on astro:page-load is safe.
5
+
6
+ const CLOSE_DELAY = 150;
7
+ let documentBound = false;
8
+
9
+ const roots = () => Array.from(document.querySelectorAll<HTMLElement>('[data-site-nav]'));
10
+ const dropdownButtons = (scope: ParentNode = document) =>
11
+ Array.from(scope.querySelectorAll<HTMLButtonElement>('[data-nav-dropdown-button]'));
12
+ const controlled = (el: Element) => {
13
+ const id = el.getAttribute('aria-controls');
14
+ return id ? document.getElementById(id) : null;
15
+ };
16
+
17
+ function setDropdown(button: HTMLButtonElement, open: boolean): void {
18
+ if (open) dropdownButtons().forEach((other) => other !== button && other.setAttribute('aria-expanded', 'false'));
19
+ button.setAttribute('aria-expanded', String(open));
20
+ }
21
+
22
+ function setMobileMenu(root: HTMLElement, toggle: HTMLButtonElement, open: boolean, focus: boolean): void {
23
+ const panel = controlled(toggle);
24
+ if (!panel) return;
25
+ toggle.setAttribute('aria-expanded', String(open));
26
+ toggle.setAttribute('aria-label', (open ? toggle.dataset.closeLabel : toggle.dataset.openLabel) ?? '');
27
+ panel.classList.toggle('hidden', !open);
28
+ root.classList.toggle('mobile-menu-open', open);
29
+ document.body.classList.toggle('mobile-menu-open', open);
30
+ if (!focus) return;
31
+ if (open) panel.querySelector<HTMLElement>('button, a')?.focus();
32
+ else toggle.focus();
33
+ }
34
+
35
+ function bindDropdown(dropdown: HTMLElement): void {
36
+ const button = dropdown.querySelector<HTMLButtonElement>('[data-nav-dropdown-button]');
37
+ const menu = button && controlled(button);
38
+ if (!button || !menu) return;
39
+ let timer: ReturnType<typeof setTimeout> | undefined;
40
+ // A pointer that hovered the dropdown open then clicks the trigger means "open", not "toggle closed".
41
+ let openedByHover = false;
42
+
43
+ button.addEventListener('click', () => {
44
+ const open = openedByHover || button.getAttribute('aria-expanded') !== 'true';
45
+ openedByHover = false;
46
+ setDropdown(button, open);
47
+ });
48
+ button.addEventListener('keydown', (event) => {
49
+ if (event.key === 'Escape') {
50
+ setDropdown(button, false);
51
+ button.focus();
52
+ } else if (event.key === 'ArrowDown') {
53
+ event.preventDefault();
54
+ setDropdown(button, true);
55
+ menu.querySelector<HTMLElement>('a')?.focus();
56
+ }
57
+ });
58
+ menu.addEventListener('keydown', (event) => {
59
+ const links = Array.from(menu.querySelectorAll<HTMLElement>('a'));
60
+ const index = links.indexOf(document.activeElement as HTMLElement);
61
+ if (event.key === 'ArrowDown') {
62
+ event.preventDefault();
63
+ (links[index + 1] ?? links[0])?.focus();
64
+ } else if (event.key === 'ArrowUp') {
65
+ event.preventDefault();
66
+ if (index <= 0) button.focus();
67
+ else links[index - 1]?.focus();
68
+ } else if (event.key === 'Escape') {
69
+ setDropdown(button, false);
70
+ button.focus();
71
+ }
72
+ });
73
+ dropdown.addEventListener('mouseenter', () => {
74
+ clearTimeout(timer);
75
+ openedByHover = button.getAttribute('aria-expanded') !== 'true';
76
+ setDropdown(button, true);
77
+ });
78
+ dropdown.addEventListener('mouseleave', () => {
79
+ openedByHover = false;
80
+ timer = setTimeout(() => setDropdown(button, false), CLOSE_DELAY);
81
+ });
82
+ dropdown.addEventListener('focusout', (event) => {
83
+ const next = event.relatedTarget;
84
+ if (next instanceof Node && dropdown.contains(next)) return;
85
+ button.setAttribute('aria-expanded', 'false');
86
+ });
87
+ }
88
+
89
+ function bindAccordion(panel: HTMLElement): void {
90
+ const buttons = Array.from(panel.querySelectorAll<HTMLButtonElement>('[data-mobile-section-button]'));
91
+ buttons.forEach((button) => {
92
+ button.addEventListener('click', () => {
93
+ const open = button.getAttribute('aria-expanded') !== 'true';
94
+ if (open) {
95
+ buttons.forEach((other) => {
96
+ if (other === button) return;
97
+ other.setAttribute('aria-expanded', 'false');
98
+ controlled(other)?.classList.add('hidden');
99
+ });
100
+ }
101
+ button.setAttribute('aria-expanded', String(open));
102
+ controlled(button)?.classList.toggle('hidden', !open);
103
+ });
104
+ });
105
+ }
106
+
107
+ function searchPathOf(root: HTMLElement): string | null {
108
+ const path = root.dataset.searchPath;
109
+ if (!path) return null;
110
+ const compiled = (root.dataset.searchBase ?? '/').replace(/\/$/, '');
111
+ return (window.location.pathname.indexOf(compiled) === 0 ? compiled : '') + path;
112
+ }
113
+
114
+ function bindDocument(): void {
115
+ if (documentBound) return;
116
+ documentBound = true;
117
+
118
+ document.addEventListener('click', (event) => {
119
+ const target = event.target;
120
+ if (!(target instanceof Element) || target.closest('[data-nav-dropdown]')) return;
121
+ dropdownButtons().forEach((button) => button.setAttribute('aria-expanded', 'false'));
122
+ });
123
+
124
+ document.addEventListener('keydown', (event) => {
125
+ if (event.key === 'Escape') {
126
+ roots().forEach((root) => {
127
+ const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
128
+ if (toggle?.getAttribute('aria-expanded') === 'true') setMobileMenu(root, toggle, false, true);
129
+ });
130
+ return;
131
+ }
132
+ if (!(event.metaKey || event.ctrlKey) || event.key !== 'k') return;
133
+ const searchPath = roots().map(searchPathOf).find(Boolean);
134
+ if (!searchPath) return;
135
+ // On the search page itself the page input owns the shortcut.
136
+ const current = window.location.pathname;
137
+ if (current === searchPath || current === `${searchPath}/`) return;
138
+ event.preventDefault();
139
+ window.location.assign(`${searchPath}?focus=1`);
140
+ });
141
+ }
142
+
143
+ export function initNavigation(): void {
144
+ bindDocument();
145
+ roots().forEach((root) => {
146
+ if (root.hasAttribute('data-site-nav-ready')) return;
147
+ root.setAttribute('data-site-nav-ready', '');
148
+ root.querySelectorAll<HTMLElement>('[data-nav-dropdown]').forEach(bindDropdown);
149
+ const toggle = root.querySelector<HTMLButtonElement>('[data-mobile-menu-toggle]');
150
+ const panel = toggle && controlled(toggle);
151
+ if (!toggle || !panel) return;
152
+ toggle.addEventListener('click', () => setMobileMenu(root, toggle, toggle.getAttribute('aria-expanded') !== 'true', true));
153
+ bindAccordion(panel);
154
+ });
155
+ }
@@ -0,0 +1,33 @@
1
+ import type { NavTone } from './types.ts';
2
+
3
+ // Literal class strings per tone, so Tailwind's scanner sees every utility the navigation components emit.
4
+ export const TONES: Record<NavTone, {
5
+ text: string;
6
+ hoverText: string;
7
+ groupHoverText: string;
8
+ strongHover: string;
9
+ groupStrongHover: string;
10
+ ring: string;
11
+ icon: string;
12
+ iconDim: string;
13
+ tile: string;
14
+ }> = {
15
+ primary: {
16
+ text: 'text-primary', hoverText: 'hover:text-primary', groupHoverText: 'group-hover:text-primary',
17
+ strongHover: 'hover:text-primary-strong', groupStrongHover: 'group-hover:text-primary-strong',
18
+ ring: 'focus-visible:ring-primary', icon: 'text-primary', iconDim: 'text-primary/60',
19
+ tile: 'bg-primary/10 group-hover:bg-primary/20',
20
+ },
21
+ secondary: {
22
+ text: 'text-secondary', hoverText: 'hover:text-secondary', groupHoverText: 'group-hover:text-secondary',
23
+ strongHover: 'hover:text-secondary-strong', groupStrongHover: 'group-hover:text-secondary-strong',
24
+ ring: 'focus-visible:ring-secondary', icon: 'text-secondary', iconDim: 'text-secondary/60',
25
+ tile: 'bg-secondary/10 group-hover:bg-secondary/20',
26
+ },
27
+ accent: {
28
+ text: 'text-accent', hoverText: 'hover:text-accent', groupHoverText: 'group-hover:text-accent',
29
+ strongHover: 'hover:text-accent-strong', groupStrongHover: 'group-hover:text-accent-strong',
30
+ ring: 'focus-visible:ring-accent', icon: 'text-accent', iconDim: 'text-accent/60',
31
+ tile: 'bg-accent/10 group-hover:bg-accent/20',
32
+ },
33
+ };
@@ -0,0 +1,33 @@
1
+ // Public contracts for site chrome. Sites own every value: labels, hrefs, icons, logos and copy.
2
+
3
+ /** Who the site is, as the header, footer and copyright line name it. */
4
+ export interface SiteIdentity {
5
+ name: string;
6
+ tagline?: string;
7
+ homeHref?: string;
8
+ }
9
+
10
+ export interface NavLink {
11
+ label: string;
12
+ href: string;
13
+ description?: string;
14
+ /** Lucide icon name for the ui Icon primitive. */
15
+ icon?: string;
16
+ }
17
+
18
+ export interface NavGroup {
19
+ id: string;
20
+ label: string;
21
+ icon?: string;
22
+ links: NavLink[];
23
+ }
24
+
25
+ export interface FooterSection {
26
+ title: string;
27
+ /** Lucide icon name shown before the heading. */
28
+ icon?: string;
29
+ links: NavLink[];
30
+ }
31
+
32
+ /** The colour role a navigation item is tinted with. */
33
+ export type NavTone = 'primary' | 'secondary' | 'accent';
@@ -0,0 +1,26 @@
1
+ ---
2
+ /** The empty-state suggestion chips of a search page; each button carries the query it runs (data-search-suggest). */
3
+ interface Props {
4
+ label: string;
5
+ suggestions: { label: string; query: string }[];
6
+ }
7
+
8
+ const { label, suggestions } = Astro.props;
9
+ ---
10
+
11
+ <p class="search-suggestions__label">{label}</p>
12
+ <ul class="search-suggestions">
13
+ {
14
+ suggestions.map((s) => (
15
+ <li>
16
+ <button
17
+ type="button"
18
+ class="search-chip search-chip--suggest"
19
+ data-search-suggest={s.query}
20
+ >
21
+ {s.label}
22
+ </button>
23
+ </li>
24
+ ))
25
+ }
26
+ </ul>
@@ -0,0 +1,5 @@
1
+ // Public surface of @sarimarcus/content-sites-ui/search: search-page building blocks. The site keeps its page, its
2
+ // type taxonomy, its copy and its Pagefind index.
3
+ export { default as SearchSuggestions } from './SearchSuggestions.astro';
4
+ export { appendResults } from './renderResult.ts';
5
+ export type { PagefindResultFragment, SearchTaxonomy, SearchTypeMeta } from './renderResult.ts';
@@ -0,0 +1,117 @@
1
+ // Pagefind result cards for a site's search page. The site owns its type taxonomy (labels, icons) and passes it in;
2
+ // the icons themselves are server-rendered by the site into <template id="search-type-icons">.
3
+ import { normalizePagePath } from '@sarimarcus/content-sites-core/url';
4
+
5
+ export interface SearchTypeMeta {
6
+ label: string;
7
+ pluralLabel?: string;
8
+ iconName?: string;
9
+ }
10
+
11
+ export interface SearchTaxonomy<T extends string> {
12
+ types: Record<T, SearchTypeMeta>;
13
+ resolveType: (meta: Record<string, string> | undefined) => T | null;
14
+ /** Title shown when a result has none. */
15
+ untitled?: string;
16
+ }
17
+
18
+ export interface PagefindResultFragment {
19
+ url: string;
20
+ raw_url?: string;
21
+ excerpt: string;
22
+ meta?: Record<string, string>;
23
+ sub_results?: Array<{ title?: string; url?: string; excerpt?: string }>;
24
+ }
25
+
26
+ // Type-icon SVGs are server-rendered into <template id="search-type-icons">
27
+ // (one Lucide icon per type) and cloned at render time. No emojis in code.
28
+ let parser: DOMParser | undefined;
29
+ const iconCache = new Map<string, Element>();
30
+ function getTypeIcon(type: string): Element | null {
31
+ const cached = iconCache.get(type);
32
+ if (cached) return cached.cloneNode(true) as Element;
33
+ const tpl = document.getElementById('search-type-icons') as HTMLTemplateElement | null;
34
+ const source = tpl?.content.querySelector(`[data-icon-for="${type}"] > *`);
35
+ if (!source) return null;
36
+ iconCache.set(type, source);
37
+ return source.cloneNode(true) as Element;
38
+ }
39
+
40
+ // Pagefind generates excerpts at build time from indexed content, wrapping
41
+ // matched terms in <mark>. We parse into a detached document so any unsafe
42
+ // nodes (script/style/iframe) and on* event handlers are stripped before
43
+ // reaching the live DOM.
44
+ function setExcerptHtml(target: HTMLElement, html: string): void {
45
+ const doc = (parser ??= new DOMParser()).parseFromString(`<div>${html}</div>`, 'text/html');
46
+ const root = doc.body.firstElementChild;
47
+ if (!root) {
48
+ target.textContent = '';
49
+ return;
50
+ }
51
+ root.querySelectorAll('script, style, iframe, object, embed').forEach((n) => n.remove());
52
+ root.querySelectorAll('*').forEach((n) => {
53
+ [...n.attributes].forEach((attr) => {
54
+ if (attr.name.startsWith('on')) n.removeAttribute(attr.name);
55
+ });
56
+ });
57
+ target.replaceChildren(
58
+ ...Array.from(root.childNodes).map((n) => target.ownerDocument.importNode(n, true)),
59
+ );
60
+ }
61
+
62
+ function renderResultElement<T extends string>(fragment: PagefindResultFragment, index: number, taxonomy: SearchTaxonomy<T>): HTMLLIElement {
63
+ const meta = fragment.meta ?? {};
64
+ const title = meta.title ?? taxonomy.untitled ?? 'Untitled';
65
+ const url = normalizePagePath(fragment.url);
66
+ const type = taxonomy.resolveType(meta);
67
+ const typeMeta = type ? taxonomy.types[type] : null;
68
+
69
+ const li = document.createElement('li');
70
+ li.className = 'search-card';
71
+ li.dataset.type = type ?? 'unknown';
72
+ li.dataset.index = String(index);
73
+
74
+ const link = document.createElement('a');
75
+ link.className = 'search-card__link';
76
+ link.href = url;
77
+ link.dataset.searchResultLink = '';
78
+ li.appendChild(link);
79
+
80
+ const body = document.createElement('div');
81
+ body.className = 'search-card__body';
82
+ link.appendChild(body);
83
+
84
+ if (type && typeMeta) {
85
+ const badge = document.createElement('span');
86
+ badge.className = 'search-card__badge';
87
+ const iconNode = getTypeIcon(type);
88
+ if (iconNode) badge.appendChild(iconNode);
89
+ badge.appendChild(document.createTextNode(typeMeta.label));
90
+ body.appendChild(badge);
91
+ }
92
+
93
+ const titleEl = document.createElement('h3');
94
+ titleEl.className = 'search-card__title';
95
+ titleEl.textContent = title;
96
+ body.appendChild(titleEl);
97
+
98
+ const excerptEl = document.createElement('p');
99
+ excerptEl.className = 'search-card__excerpt';
100
+ setExcerptHtml(excerptEl, fragment.excerpt);
101
+ body.appendChild(excerptEl);
102
+
103
+ return li;
104
+ }
105
+
106
+ export function appendResults<T extends string>(
107
+ container: HTMLElement,
108
+ fragments: PagefindResultFragment[],
109
+ startIndex: number,
110
+ taxonomy: SearchTaxonomy<T>,
111
+ ): void {
112
+ const frag = document.createDocumentFragment();
113
+ fragments.forEach((f, i) => {
114
+ frag.appendChild(renderResultElement(f, startIndex + i, taxonomy));
115
+ });
116
+ container.appendChild(frag);
117
+ }
@@ -0,0 +1,274 @@
1
+ /* Silktide Consent Manager overrides, shared by every site. Colours come from the semantic theme roles; a site tunes
2
+ the few surfaces that differ through these optional hooks (defaults in brackets):
3
+ --consent-surface [--background] banner, modal, secondary buttons, toggle thumb
4
+ --consent-surface-hover [surface] secondary button hover
5
+ --consent-border [accent 20%] banner/modal border, fieldset and row separators
6
+ --consent-backdrop [overlay 60%] modal backdrop
7
+ --consent-shadow [overlay 15%] banner/modal shadow
8
+ --consent-thumb-shadow [overlay 30%] toggle thumb shadow
9
+ --consent-link / -hover [secondary / secondary-strong] the preferences link
10
+ --consent-toggle-off [muted-foreground 35% on surface] off toggle track
11
+ --consent-toggle-border [transparent] off toggle track outline */
12
+
13
+ /* CLS fix: keep wrapper out of document flow so it never causes layout shift */
14
+ #stcm-wrapper {
15
+ position: fixed !important;
16
+ bottom: 0 !important;
17
+ left: 0 !important;
18
+ right: 0 !important;
19
+ z-index: 9999 !important;
20
+ }
21
+
22
+ /* Hide floating re-open button (all possible selectors) */
23
+ #stcm-icon,
24
+ [id^="stcm-icon"],
25
+ .stcm-icon {
26
+ display: none !important;
27
+ visibility: hidden !important;
28
+ pointer-events: none !important;
29
+ }
30
+
31
+ /* Backdrop — blur only on capable devices to avoid mobile jank */
32
+ #stcm-backdrop {
33
+ background: var(--consent-backdrop, color-mix(in oklch, var(--overlay) 60%, transparent)) !important;
34
+ }
35
+ @media (hover: hover) {
36
+ #stcm-backdrop {
37
+ backdrop-filter: blur(2px) !important;
38
+ }
39
+ }
40
+
41
+ /* Outer container — strip any default border/outline */
42
+ #stcm-container,
43
+ [id^="stcm-container"],
44
+ .stcm-container {
45
+ border: none !important;
46
+ outline: none !important;
47
+ }
48
+
49
+ /* Banner & Modal container — compact */
50
+ #stcm-banner,
51
+ #stcm-modal {
52
+ background: var(--consent-surface, var(--background)) !important;
53
+ color: var(--foreground) !important;
54
+ border-radius: 0.75rem !important;
55
+ border: 1px solid var(--consent-border, color-mix(in oklch, var(--accent) 20%, transparent)) !important;
56
+ box-shadow: 0 8px 32px var(--consent-shadow, color-mix(in oklch, var(--overlay) 15%, transparent)) !important;
57
+ font-family: var(--font-body) !important;
58
+ max-width: 480px !important;
59
+ padding: 1rem 1.25rem !important;
60
+ outline: none !important;
61
+ }
62
+
63
+ /* Type hierarchy: title > category legend > body (Dark Skies 5d13f8a — the legend used to inherit the title's size) */
64
+ #stcm-banner h1, #stcm-banner h2, #stcm-banner h3,
65
+ #stcm-modal h1, #stcm-modal h2, #stcm-modal h3 {
66
+ font-family: var(--font-display) !important;
67
+ color: var(--foreground) !important;
68
+ font-size: 1.0625rem !important;
69
+ line-height: 1.3 !important;
70
+ margin-bottom: 0.25rem !important;
71
+ }
72
+
73
+ /* Category fieldsets — reset browser default groove border */
74
+ #stcm-modal fieldset,
75
+ #stcm-banner fieldset {
76
+ border: none !important;
77
+ border-top: 1px solid var(--consent-border, color-mix(in oklch, var(--accent) 20%, transparent)) !important;
78
+ padding: 0 !important;
79
+ margin: 0 !important;
80
+ }
81
+
82
+ /* Category labels (Essential / Analytics / Marketing) — <legend> elements */
83
+ #stcm-modal legend,
84
+ #stcm-banner legend {
85
+ color: var(--foreground) !important;
86
+ font-family: var(--font-display) !important;
87
+ font-weight: 600 !important;
88
+ font-size: 0.875rem !important;
89
+ line-height: 1.3 !important;
90
+ }
91
+
92
+ /* Body text — compact */
93
+ #stcm-banner p, #stcm-modal p,
94
+ #stcm-banner a, #stcm-modal a {
95
+ color: var(--muted-foreground) !important;
96
+ font-family: var(--font-body) !important;
97
+ font-size: 0.8125rem !important;
98
+ line-height: 1.4 !important;
99
+ margin-bottom: 0.5rem !important;
100
+ }
101
+
102
+ /* Actions block — tighter than Silktide's 24px margin-top: on short, hero-heavy mobile viewports the banner reached
103
+ into above-the-fold controls (SLA-2290). */
104
+ #stcm-banner .stcm-actions {
105
+ margin-top: 0.75rem !important;
106
+ }
107
+
108
+ /* Buttons — base */
109
+ .stcm-button {
110
+ border: none !important;
111
+ outline: none !important;
112
+ border-radius: 0.5rem !important;
113
+ font-family: var(--font-body) !important;
114
+ font-size: 0.8125rem !important;
115
+ font-weight: 500 !important;
116
+ padding: 0.5rem 1rem !important;
117
+ cursor: pointer !important;
118
+ transition: background 0.15s, color 0.15s, border-color 0.15s !important;
119
+ }
120
+
121
+ /* Override Silktide default black box-shadow on button focus (#050505 ring) */
122
+ #stcm-wrapper #stcm-banner button:focus,
123
+ #stcm-wrapper #stcm-modal button:focus,
124
+ #stcm-wrapper #stcm-icon:focus {
125
+ box-shadow: none !important;
126
+ outline: none !important;
127
+ }
128
+
129
+ /* Accessible focus styles — keyboard only */
130
+ .stcm-button:focus-visible,
131
+ .stcm-preferences-button:focus-visible,
132
+ .stcm-modal-close:focus-visible {
133
+ outline: 2px solid var(--primary) !important;
134
+ outline-offset: 2px !important;
135
+ box-shadow: none !important;
136
+ }
137
+
138
+ /* Primary button (Save / Accept) */
139
+ .stcm-button-primary {
140
+ background: var(--primary) !important;
141
+ color: var(--primary-foreground) !important;
142
+ border: none !important;
143
+ }
144
+ .stcm-button-primary:hover {
145
+ background: var(--primary-strong) !important;
146
+ }
147
+
148
+ /* Secondary button (Reject / Manage) */
149
+ .stcm-button-secondary,
150
+ .stcm-reject-all {
151
+ background: var(--consent-surface, var(--background)) !important;
152
+ color: var(--primary) !important;
153
+ border: 1px solid color-mix(in oklch, var(--primary) 40%, transparent) !important;
154
+ }
155
+ .stcm-button-secondary:hover,
156
+ .stcm-reject-all:hover {
157
+ background: var(--consent-surface-hover, var(--consent-surface, var(--background))) !important;
158
+ border-color: color-mix(in oklch, var(--primary) 70%, transparent) !important;
159
+ }
160
+
161
+ /* Preferences/settings button */
162
+ .stcm-preferences-button {
163
+ background: transparent !important;
164
+ color: var(--consent-link, var(--secondary)) !important;
165
+ border: none !important;
166
+ text-decoration: underline !important;
167
+ font-size: 0.8125rem !important;
168
+ padding: 0 !important;
169
+ }
170
+ .stcm-preferences-button:hover {
171
+ color: var(--consent-link-hover, var(--secondary-strong)) !important;
172
+ }
173
+
174
+ /* Toggle — track (off): must clear 3:1 non-text contrast against the modal surface */
175
+ .stcm-toggle-track {
176
+ background: var(--consent-toggle-off, color-mix(in oklch, var(--muted-foreground) 35%, var(--consent-surface, var(--background)))) !important;
177
+ box-shadow: inset 0 0 0 1px var(--consent-toggle-border, transparent) !important;
178
+ border-radius: 9999px !important;
179
+ }
180
+
181
+ /* Toggle — on state. `.stcm-toggle-on` is the sibling "ON" label, not an ancestor of the track, so target the
182
+ checked input instead (Dark Skies 5d13f8a). */
183
+ .stcm-toggle input:checked + .stcm-toggle-track {
184
+ background: var(--primary) !important;
185
+ border-color: var(--primary) !important;
186
+ }
187
+
188
+ /* Toggle — thumb */
189
+ .stcm-toggle-thumb {
190
+ background: var(--consent-surface, var(--background)) !important;
191
+ box-shadow: 0 1px 3px var(--consent-thumb-shadow, color-mix(in oklch, var(--overlay) 30%, transparent)) !important;
192
+ }
193
+
194
+ /* Consent row separator */
195
+ .stcm-consent-row {
196
+ border-color: var(--consent-border, color-mix(in oklch, var(--accent) 20%, transparent)) !important;
197
+ }
198
+
199
+ /* Close button */
200
+ .stcm-modal-close {
201
+ color: var(--primary) !important;
202
+ background: transparent !important;
203
+ border: 1px solid color-mix(in oklch, var(--primary) 30%, transparent) !important;
204
+ border-radius: 0.5rem !important;
205
+ }
206
+ .stcm-modal-close svg,
207
+ .stcm-modal-close svg * {
208
+ stroke: var(--primary) !important;
209
+ fill: var(--primary) !important;
210
+ }
211
+ .stcm-modal-close:hover {
212
+ color: var(--primary-strong) !important;
213
+ border-color: color-mix(in oklch, var(--primary) 60%, transparent) !important;
214
+ }
215
+ .stcm-modal-close:hover svg,
216
+ .stcm-modal-close:hover svg * {
217
+ stroke: var(--primary-strong) !important;
218
+ fill: var(--primary-strong) !important;
219
+ }
220
+
221
+ /* Silktide credit link, brand badge, logo — hide */
222
+ .stcm-credit-link,
223
+ .stcm-logo,
224
+ .stcm-brand,
225
+ [class*="stcm-brand"],
226
+ [class*="stcm-credit"] {
227
+ display: none !important;
228
+ }
229
+
230
+ /* Suppress browser focus outline on auto-focused containers */
231
+ #stcm-banner:focus,
232
+ #stcm-modal:focus,
233
+ #stcm-banner:focus-visible,
234
+ #stcm-modal:focus-visible,
235
+ #stcm-container:focus,
236
+ #stcm-container:focus-visible,
237
+ [id^="stcm"]:focus,
238
+ [id^="stcm"]:focus-visible {
239
+ outline: none !important;
240
+ box-shadow: 0 8px 32px var(--consent-shadow, color-mix(in oklch, var(--overlay) 15%, transparent)) !important;
241
+ }
242
+
243
+ /* Mobile — even more compact */
244
+ @media (max-width: 640px) {
245
+ #stcm-banner,
246
+ #stcm-modal {
247
+ max-width: calc(100vw - 1.5rem) !important;
248
+ padding: 0.75rem 1rem !important;
249
+ margin: 0.75rem !important;
250
+ border-radius: 0.625rem !important;
251
+ }
252
+ #stcm-banner h1, #stcm-banner h2, #stcm-banner h3 {
253
+ font-size: 0.9375rem !important;
254
+ }
255
+ #stcm-banner p, #stcm-modal p {
256
+ font-size: 0.75rem !important;
257
+ margin-bottom: 0.375rem !important;
258
+ }
259
+ .stcm-button {
260
+ font-size: 0.75rem !important;
261
+ padding: 0.4375rem 0.875rem !important;
262
+ }
263
+ /* SLA-2290: keep the two primary actions side by side on mobile too — Silktide stacks them below 600px, and one
264
+ line materially shrinks how far the banner reaches into the fold. */
265
+ #stcm-banner .stcm-actions {
266
+ flex-direction: row !important;
267
+ flex-wrap: wrap !important;
268
+ gap: 0.5rem !important;
269
+ margin-top: 0.5rem !important;
270
+ }
271
+ #stcm-banner .stcm-actions .stcm-button {
272
+ flex: 1 1 auto !important;
273
+ }
274
+ }