@sarimarcus/content-sites-ui 0.15.1 → 0.17.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,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
+ }