@astronox/core 0.3.1 → 0.4.1

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.
Files changed (39) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/ads/adsense/AdsenseHead.astro +55 -0
  4. package/src/ads/adsense/AdsenseUnit.astro +45 -0
  5. package/src/ads/adsense/index.ts +3 -1
  6. package/src/ads/adsense/provider.ts +41 -0
  7. package/src/ads/adsense/runtime.ts +284 -0
  8. package/src/ads/contracts.ts +67 -5
  9. package/src/components/content/CardHeader/CardHeader.astro +20 -0
  10. package/src/components/content/CardHeader/CardHeader.css +21 -0
  11. package/src/components/feedback/Tooltip/tooltipController.ts +37 -5
  12. package/src/components/monetization/AdSlot/AdSlot.astro +0 -3
  13. package/src/components/monetization/AdSlot/AdSlot.css +5 -28
  14. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +2 -0
  15. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -6
  16. package/src/components/navigation/Footer/Footer.astro +8 -0
  17. package/src/components/navigation/Footer/Footer.css +20 -6
  18. package/src/components/navigation/Footer/contracts.ts +1 -0
  19. package/src/composition/Grid/Grid.css +1 -1
  20. package/src/composition/SurfaceGroup/SurfaceGroup.astro +2 -1
  21. package/src/composition/SurfaceGroup/SurfaceGroup.css +18 -18
  22. package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +9 -0
  23. package/src/config/environment.ts +66 -2
  24. package/src/layouts/BaseLayout.astro +6 -1
  25. package/src/patterns/Combobox/comboboxController.ts +51 -12
  26. package/src/patterns/Dropdown/dropdownController.ts +17 -6
  27. package/src/patterns/Fullscreen/FullscreenControl.astro +3 -30
  28. package/src/patterns/Fullscreen/fullscreenController.ts +18 -22
  29. package/src/patterns/Table/Table.css +8 -2
  30. package/src/patterns/Table/TableFilterPanel.astro +84 -0
  31. package/src/patterns/Table/TableFilterPanel.css +139 -0
  32. package/src/patterns/Table/TableSkeleton.css +0 -3
  33. package/src/patterns/Table/tableFilterPanelController.ts +91 -0
  34. package/src/patterns/Table/tableProgressiveController.ts +78 -16
  35. package/src/patterns/Table/tableScrollPosition.ts +41 -0
  36. package/src/patterns/Table/tableSortController.ts +6 -3
  37. package/src/patterns/index.ts +2 -0
  38. package/src/styles/style.css +1 -0
  39. package/src/ads/adsense.ts +0 -112
package/README.md CHANGED
@@ -63,7 +63,7 @@ component labels can extend `defaultCoreMessages` through
63
63
  claims, Firestore, and app-owned mappings.
64
64
  - [`R2 publication`](docs/integrations/r2-publication.md): full provider
65
65
  snapshots and build-time public projection.
66
- - [`AdSense`](docs/integrations/adsense.md): provider setup, consent, slots, and
67
- fallback canvas.
66
+ - [`AdSense`](docs/integrations/adsense.md): typed configuration, responsive
67
+ units, host policy, Privacy & Messaging, and deployment.
68
68
  - [`release workflow`](docs/release-workflow.md): local links, verification, and
69
69
  consumer pinning order.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -0,0 +1,55 @@
1
+ ---
2
+ import type { AdsenseAdsConfig } from '../contracts';
3
+ import {
4
+ renderAdsenseControlledMessagingScript,
5
+ renderAdsenseErrorProtectionScript,
6
+ toAdsenseClientId,
7
+ } from './provider';
8
+
9
+ type Props = {
10
+ config: AdsenseAdsConfig;
11
+ offerwallEligible: boolean;
12
+ };
13
+
14
+ const { config, offerwallEligible }: Props = Astro.props;
15
+ const clientId = toAdsenseClientId(config.publisherId);
16
+ const runtimeConfig = JSON.stringify({
17
+ allowedHosts: config.allowedHosts,
18
+ clientId,
19
+ messaging: {
20
+ adBlockingRecovery: config.messaging.adBlockingRecovery,
21
+ offerwall: config.messaging.offerwall,
22
+ offerwallEligible,
23
+ },
24
+ publisherId: config.publisherId,
25
+ }).replaceAll('<', '\\u003c');
26
+ const errorProtectionScript = config.messaging.errorProtectionCode
27
+ ? renderAdsenseErrorProtectionScript({
28
+ allowedHosts: config.allowedHosts,
29
+ code: config.messaging.errorProtectionCode,
30
+ })
31
+ : undefined;
32
+ const controlledMessagingScript = renderAdsenseControlledMessagingScript(
33
+ config.messaging.offerwall && offerwallEligible,
34
+ );
35
+ ---
36
+
37
+ <meta name="google-adsense-account" content={clientId} />
38
+ <script
39
+ is:inline
40
+ type="application/json"
41
+ data-astronox-adsense-config
42
+ set:html={runtimeConfig}
43
+ />
44
+ <script is:inline set:html={controlledMessagingScript} />
45
+ {
46
+ errorProtectionScript ? (
47
+ <script is:inline set:html={errorProtectionScript} />
48
+ ) : null
49
+ }
50
+
51
+ <script>
52
+ import { initAdsenseFromDocument } from './runtime';
53
+
54
+ initAdsenseFromDocument();
55
+ </script>
@@ -0,0 +1,45 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import AdSlot from '../../components/monetization/AdSlot/AdSlot.astro';
4
+ import { toAdsenseClientId } from './provider';
5
+
6
+ type Props = Omit<HTMLAttributes<'aside'>, 'aria-label'> & {
7
+ format: 'display' | 'multiplex';
8
+ label: string;
9
+ publisherId: string;
10
+ size?: 'compact' | 'standard' | 'tall';
11
+ slotId: string;
12
+ };
13
+
14
+ const {
15
+ format,
16
+ label,
17
+ publisherId,
18
+ size = 'standard',
19
+ slotId,
20
+ ...attrs
21
+ }: Props = Astro.props;
22
+ const unitAttributes =
23
+ format === 'multiplex'
24
+ ? { 'data-ad-format': 'autorelaxed' }
25
+ : {
26
+ 'data-ad-format': 'auto',
27
+ 'data-full-width-responsive': 'true',
28
+ };
29
+ ---
30
+
31
+ <AdSlot
32
+ {...attrs}
33
+ data-adsense-format={format}
34
+ data-adsense-unit
35
+ label={label}
36
+ size={size}
37
+ state="loading"
38
+ >
39
+ <ins
40
+ {...unitAttributes}
41
+ class="adsbygoogle"
42
+ style="display:block"
43
+ data-ad-client={toAdsenseClientId(publisherId)}
44
+ data-ad-slot={slotId}></ins>
45
+ </AdSlot>
@@ -1 +1,3 @@
1
- export * from '../adsense';
1
+ export { default as AdsenseHead } from './AdsenseHead.astro';
2
+ export { default as AdsenseUnit } from './AdsenseUnit.astro';
3
+ export * from './provider';
@@ -0,0 +1,41 @@
1
+ const adsenseSellerId = 'f08c47fec0942fa0';
2
+
3
+ export function toAdsenseClientId(publisherId: string): string {
4
+ return `ca-${publisherId}`;
5
+ }
6
+
7
+ export function renderAdsenseAdsTxt(publisherId: string): string {
8
+ return `google.com, ${publisherId}, DIRECT, ${adsenseSellerId}\n`;
9
+ }
10
+
11
+ export function renderAdsenseErrorProtectionScript({
12
+ allowedHosts,
13
+ code,
14
+ }: {
15
+ allowedHosts: readonly string[];
16
+ code: string;
17
+ }): string {
18
+ const hosts = JSON.stringify(
19
+ allowedHosts.map((host) => host.toLowerCase()),
20
+ ).replaceAll('<', '\\u003c');
21
+
22
+ return `if (${hosts}.includes(window.location.hostname.toLowerCase())) {\n${code}\n}`;
23
+ }
24
+
25
+ export function renderAdsenseControlledMessagingScript(
26
+ offerwallAllowed: boolean,
27
+ ): string {
28
+ return `window.googlefc = window.googlefc || {};
29
+ window.googlefc.controlledMessagingFunction = function (message) {
30
+ if (${String(offerwallAllowed)}) {
31
+ message.proceed(true);
32
+ return;
33
+ }
34
+ var offerwall = window.googlefc.MessageTypeEnum && window.googlefc.MessageTypeEnum.OFFERWALL;
35
+ if (typeof offerwall === 'number') {
36
+ message.proceed(false, [offerwall]);
37
+ return;
38
+ }
39
+ message.proceed(true);
40
+ };`;
41
+ }
@@ -0,0 +1,284 @@
1
+ import { setAdSlotState } from '../slot';
2
+
3
+ const adsenseScriptTimeoutMs = 12_000;
4
+ const adsenseUnitTimeoutMs = 30_000;
5
+
6
+ type ControlledMessage = {
7
+ proceed(allowed: boolean, messageTypes?: readonly number[]): void;
8
+ };
9
+
10
+ type GoogleFundingChoicesCallback =
11
+ (() => void) | { CONSENT_API_READY: () => void };
12
+
13
+ type GoogleFundingChoices = {
14
+ callbackQueue?: {
15
+ push(callback: GoogleFundingChoicesCallback): number;
16
+ };
17
+ controlledMessagingFunction?: (message: ControlledMessage) => void;
18
+ MessageTypeEnum?: {
19
+ OFFERWALL: number;
20
+ };
21
+ showRevocationMessage?: () => void;
22
+ };
23
+
24
+ type AdsenseRuntimeConfig = {
25
+ allowedHosts: readonly string[];
26
+ clientId: string;
27
+ messaging: {
28
+ adBlockingRecovery: boolean;
29
+ offerwall: boolean;
30
+ offerwallEligible: boolean;
31
+ };
32
+ publisherId: string;
33
+ };
34
+
35
+ declare global {
36
+ interface Window {
37
+ adsbygoogle?: Record<string, unknown>[];
38
+ googlefc?: GoogleFundingChoices;
39
+ }
40
+ }
41
+
42
+ let adsenseScript: Promise<void> | undefined;
43
+
44
+ export function initAdsenseFromDocument(): void {
45
+ const config = readRuntimeConfig();
46
+ if (!config || !isAllowedHost(config.allowedHosts)) {
47
+ setAllAdUnitsState('unavailable');
48
+ return;
49
+ }
50
+
51
+ protectAdAnnotations();
52
+ configureControlledMessaging(config);
53
+ initPrivacySettingsControls();
54
+ if (config.messaging.adBlockingRecovery) {
55
+ loadAdBlockingRecovery(config.publisherId);
56
+ }
57
+
58
+ void loadAdsenseScript(config.clientId)
59
+ .then(initAdUnits)
60
+ .catch(() => setAllAdUnitsState('blocked'));
61
+ }
62
+
63
+ function readRuntimeConfig(): AdsenseRuntimeConfig | undefined {
64
+ const element = document.querySelector<HTMLScriptElement>(
65
+ 'script[data-astronox-adsense-config]',
66
+ );
67
+ if (!element?.textContent) return undefined;
68
+
69
+ try {
70
+ return JSON.parse(element.textContent) as AdsenseRuntimeConfig;
71
+ } catch {
72
+ return undefined;
73
+ }
74
+ }
75
+
76
+ function isAllowedHost(allowedHosts: readonly string[]): boolean {
77
+ const hostname = window.location.hostname.toLowerCase();
78
+ if (
79
+ hostname === 'localhost' ||
80
+ hostname.endsWith('.localhost') ||
81
+ hostname === '127.0.0.1' ||
82
+ hostname === '::1'
83
+ ) {
84
+ return false;
85
+ }
86
+ return allowedHosts.some((host) => host.toLowerCase() === hostname);
87
+ }
88
+
89
+ function protectAdAnnotations(): void {
90
+ const protect = (root: ParentNode): void => {
91
+ if (
92
+ root instanceof HTMLElement &&
93
+ root.dataset.adAnnotation === 'exclude'
94
+ ) {
95
+ root.classList.add('google-anno-skip');
96
+ }
97
+ for (const element of root.querySelectorAll<HTMLElement>(
98
+ '[data-ad-annotation="exclude"]',
99
+ )) {
100
+ element.classList.add('google-anno-skip');
101
+ }
102
+ };
103
+
104
+ protect(document);
105
+ new MutationObserver((records) => {
106
+ for (const record of records) {
107
+ for (const node of record.addedNodes) {
108
+ if (node instanceof HTMLElement) protect(node);
109
+ }
110
+ }
111
+ }).observe(document.documentElement, { childList: true, subtree: true });
112
+ }
113
+
114
+ function configureControlledMessaging(config: AdsenseRuntimeConfig): void {
115
+ const googlefc = (window.googlefc ??= {});
116
+ googlefc.controlledMessagingFunction = (message) => {
117
+ const allowOfferwall =
118
+ config.messaging.offerwall && config.messaging.offerwallEligible;
119
+ if (allowOfferwall) {
120
+ message.proceed(true);
121
+ return;
122
+ }
123
+
124
+ const offerwallType = window.googlefc?.MessageTypeEnum?.OFFERWALL;
125
+ if (typeof offerwallType === 'number') {
126
+ message.proceed(false, [offerwallType]);
127
+ return;
128
+ }
129
+
130
+ message.proceed(true);
131
+ };
132
+ }
133
+
134
+ function initPrivacySettingsControls(): void {
135
+ const googlefc = (window.googlefc ??= {});
136
+ googlefc.callbackQueue ??= [];
137
+ googlefc.callbackQueue.push({
138
+ CONSENT_API_READY: () => {
139
+ for (const control of document.querySelectorAll<HTMLButtonElement>(
140
+ '[data-ads-consent-settings]:not([data-ads-consent-initialized])',
141
+ )) {
142
+ control.dataset.adsConsentInitialized = 'true';
143
+ control.hidden = false;
144
+ control.addEventListener('click', () => {
145
+ const showRevocationMessage = window.googlefc?.showRevocationMessage;
146
+ if (showRevocationMessage) {
147
+ window.googlefc?.callbackQueue?.push(showRevocationMessage);
148
+ }
149
+ });
150
+ }
151
+ },
152
+ });
153
+ }
154
+
155
+ function loadAdBlockingRecovery(publisherId: string): void {
156
+ if (document.querySelector('script[data-astronox-ad-recovery]')) return;
157
+
158
+ const script = document.createElement('script');
159
+ script.async = true;
160
+ script.dataset.astronoxAdRecovery = '';
161
+ script.src = `https://fundingchoicesmessages.google.com/i/${encodeURIComponent(publisherId)}?ers=1`;
162
+ document.head.append(script);
163
+ signalFundingChoicesPresence();
164
+ }
165
+
166
+ function signalFundingChoicesPresence(): void {
167
+ if (document.querySelector('iframe[name="googlefcPresent"]')) return;
168
+ if (!document.body) {
169
+ window.setTimeout(signalFundingChoicesPresence, 0);
170
+ return;
171
+ }
172
+
173
+ const frame = document.createElement('iframe');
174
+ frame.name = 'googlefcPresent';
175
+ frame.hidden = true;
176
+ frame.style.display = 'none';
177
+ document.body.append(frame);
178
+ }
179
+
180
+ function loadAdsenseScript(clientId: string): Promise<void> {
181
+ if (adsenseScript) return adsenseScript;
182
+
183
+ const loading = new Promise<void>((resolve, reject) => {
184
+ const existing = document.querySelector<HTMLScriptElement>(
185
+ 'script[data-astronox-adsense]',
186
+ );
187
+ if (existing?.dataset.loaded === 'true') {
188
+ resolve();
189
+ return;
190
+ }
191
+
192
+ const script = existing ?? document.createElement('script');
193
+ const timeout = window.setTimeout(() => {
194
+ reject(new Error('AdSense loading timed out'));
195
+ }, adsenseScriptTimeoutMs);
196
+ script.addEventListener(
197
+ 'load',
198
+ () => {
199
+ window.clearTimeout(timeout);
200
+ script.dataset.loaded = 'true';
201
+ resolve();
202
+ },
203
+ { once: true },
204
+ );
205
+ script.addEventListener(
206
+ 'error',
207
+ () => {
208
+ window.clearTimeout(timeout);
209
+ reject(new Error('AdSense failed to load'));
210
+ },
211
+ { once: true },
212
+ );
213
+
214
+ if (!existing) {
215
+ script.async = true;
216
+ script.crossOrigin = 'anonymous';
217
+ script.dataset.astronoxAdsense = '';
218
+ script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(clientId)}`;
219
+ document.head.append(script);
220
+ }
221
+ });
222
+ adsenseScript = loading;
223
+ void loading.catch(() => {
224
+ if (adsenseScript === loading) adsenseScript = undefined;
225
+ });
226
+
227
+ return loading;
228
+ }
229
+
230
+ function initAdUnits(): void {
231
+ for (const slot of document.querySelectorAll<HTMLElement>(
232
+ '[data-adsense-unit]:not([data-adsense-initialized])',
233
+ )) {
234
+ initAdUnit(slot);
235
+ }
236
+ }
237
+
238
+ function initAdUnit(slot: HTMLElement): void {
239
+ slot.dataset.adsenseInitialized = 'true';
240
+ setAdSlotState(slot, 'loading');
241
+ const advertisement = slot.querySelector<HTMLElement>('.adsbygoogle');
242
+ if (!advertisement) {
243
+ setAdSlotState(slot, 'unavailable');
244
+ return;
245
+ }
246
+
247
+ const timeout = window.setTimeout(() => {
248
+ setAdSlotState(slot, 'unavailable');
249
+ }, adsenseUnitTimeoutMs);
250
+ const sync = (): void => {
251
+ if (advertisement.dataset.adStatus === 'filled') {
252
+ window.clearTimeout(timeout);
253
+ observer.disconnect();
254
+ setAdSlotState(slot, 'filled');
255
+ } else if (advertisement.dataset.adStatus === 'unfilled') {
256
+ window.clearTimeout(timeout);
257
+ observer.disconnect();
258
+ setAdSlotState(slot, 'unavailable');
259
+ }
260
+ };
261
+ const observer = new MutationObserver(sync);
262
+ observer.observe(advertisement, {
263
+ attributes: true,
264
+ attributeFilter: ['data-ad-status'],
265
+ });
266
+
267
+ try {
268
+ window.adsbygoogle ??= [];
269
+ window.adsbygoogle.push({});
270
+ sync();
271
+ } catch {
272
+ window.clearTimeout(timeout);
273
+ observer.disconnect();
274
+ setAdSlotState(slot, 'unavailable');
275
+ }
276
+ }
277
+
278
+ function setAllAdUnitsState(state: 'blocked' | 'unavailable'): void {
279
+ for (const slot of document.querySelectorAll<HTMLElement>(
280
+ '[data-adsense-unit]',
281
+ )) {
282
+ setAdSlotState(slot, state);
283
+ }
284
+ }
@@ -1,11 +1,73 @@
1
1
  export type AdSlotState =
2
2
  'idle' | 'loading' | 'filled' | 'blocked' | 'unavailable';
3
3
 
4
- export type AdConsentProvider = {
5
- canLoadAds(): boolean | Promise<boolean>;
4
+ export type DisabledAdsConfig = {
5
+ enabled: false;
6
6
  };
7
7
 
8
- export interface AdProvider {
9
- load(): Promise<void>;
10
- request(slot: HTMLElement): Promise<void>;
8
+ export type AdsenseAdsConfig = {
9
+ enabled: true;
10
+ provider: 'adsense';
11
+ allowedHosts: readonly string[];
12
+ publisherId: string;
13
+ units: {
14
+ pageEnd: string;
15
+ };
16
+ messaging: {
17
+ adBlockingRecovery: boolean;
18
+ offerwall: boolean;
19
+ errorProtectionCode?: string;
20
+ };
21
+ };
22
+
23
+ export type AdsConfig = DisabledAdsConfig | AdsenseAdsConfig;
24
+
25
+ export const adAnnotationExclusionAttributes = {
26
+ 'data-ad-annotation': 'exclude',
27
+ } as const;
28
+
29
+ export function defineAdsConfig<const TConfig extends AdsConfig>(
30
+ config: TConfig,
31
+ ): TConfig {
32
+ if (!config.enabled) return config;
33
+
34
+ if (config.allowedHosts.length === 0) {
35
+ throw new Error('Ad configuration requires at least one allowed hostname');
36
+ }
37
+ for (const host of config.allowedHosts) {
38
+ if (!isValidHostname(host)) {
39
+ throw new Error(`Invalid advertising hostname: ${host}`);
40
+ }
41
+ }
42
+ if (!/^pub-\d+$/.test(config.publisherId)) {
43
+ throw new Error('Invalid AdSense publisher id');
44
+ }
45
+ if (!/^\d+$/.test(config.units.pageEnd)) {
46
+ throw new Error('Invalid AdSense page-end slot id');
47
+ }
48
+ if (/<\/script/i.test(config.messaging.errorProtectionCode ?? '')) {
49
+ throw new Error(
50
+ 'AdSense error protection must contain the script body only',
51
+ );
52
+ }
53
+
54
+ return config;
55
+ }
56
+
57
+ function isValidHostname(host: string): boolean {
58
+ if (!host || host !== host.trim() || host.includes('/')) return false;
59
+ if (
60
+ host.toLowerCase() === 'localhost' ||
61
+ host.toLowerCase().endsWith('.localhost') ||
62
+ host === '127.0.0.1' ||
63
+ host === '::1'
64
+ ) {
65
+ return false;
66
+ }
67
+
68
+ try {
69
+ return new URL(`https://${host}`).hostname === host.toLowerCase();
70
+ } catch {
71
+ return false;
72
+ }
11
73
  }
@@ -17,6 +17,12 @@ type Props = HTMLAttributes<'header'> &
17
17
  headingLevel?: HeadingLevel;
18
18
  sticky?: boolean;
19
19
  tone?: Tone;
20
+ trigger?: {
21
+ controls: string;
22
+ expanded: boolean;
23
+ hidden?: boolean;
24
+ label: string;
25
+ };
20
26
  };
21
27
 
22
28
  const {
@@ -29,6 +35,7 @@ const {
29
35
  headingLevel = 2,
30
36
  sticky = false,
31
37
  tone,
38
+ trigger,
32
39
  ...attrs
33
40
  }: Props = Astro.props;
34
41
  const HeadingTag = `h${headingLevel}` as const;
@@ -80,6 +87,19 @@ const resolvedTone = tone ?? dataTone;
80
87
  ) : null
81
88
  }
82
89
  </Inline>
90
+ {
91
+ trigger ? (
92
+ <button
93
+ class="card-header__trigger"
94
+ type="button"
95
+ aria-controls={trigger.controls}
96
+ aria-expanded={trigger.expanded}
97
+ aria-label={trigger.label}
98
+ hidden={trigger.hidden}
99
+ data-card-header-trigger
100
+ />
101
+ ) : null
102
+ }
83
103
  </div>
84
104
 
85
105
  {
@@ -90,12 +90,33 @@
90
90
  }
91
91
 
92
92
  .card-header__main {
93
+ position: relative;
93
94
  display: grid;
94
95
  min-width: 0;
95
96
  align-content: center;
96
97
  padding: var(--nox-space-3) var(--nox-space-5);
97
98
  }
98
99
 
100
+ .card-header__main:has(> .card-header__trigger) > [data-composition='inline'] {
101
+ pointer-events: none;
102
+ }
103
+
104
+ .card-header__trigger {
105
+ position: absolute;
106
+ z-index: 1;
107
+ inset: 0;
108
+ padding: 0;
109
+ border: 0;
110
+ background: transparent;
111
+ color: inherit;
112
+ cursor: pointer;
113
+ }
114
+
115
+ .card-header__trigger:focus-visible {
116
+ outline: var(--nox-focus-ring-width) solid var(--nox-tone-focus-ring);
117
+ outline-offset: calc(var(--nox-focus-ring-width) * -1);
118
+ }
119
+
99
120
  .card-header[data-has-end='true'] .card-header__main {
100
121
  padding-block: var(--nox-space-1);
101
122
  }