@astronox/core 0.2.2 → 0.2.3

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 (38) hide show
  1. package/package.json +1 -1
  2. package/src/admin/cache.ts +217 -0
  3. package/src/admin/index.ts +1 -0
  4. package/src/auth/firebase/client.ts +37 -7
  5. package/src/client/formSubmission.ts +177 -0
  6. package/src/client/index.ts +1 -0
  7. package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
  8. package/src/components/content/CardBody/CardBody.css +6 -2
  9. package/src/components/content/CardFooter/CardFooter.astro +9 -0
  10. package/src/components/content/CardFooter/CardFooter.css +45 -3
  11. package/src/components/content/CardLink/CardLink.astro +12 -3
  12. package/src/components/content/CardLink/CardLink.css +32 -0
  13. package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
  14. package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
  15. package/src/components/content/MarkdownContent/contracts.ts +1 -1
  16. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +7 -4
  17. package/src/components/data-display/TextPair/TextPair.css +8 -2
  18. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
  19. package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
  20. package/src/components/forms/Textarea/Textarea.astro +3 -0
  21. package/src/components/forms/Textarea/Textarea.css +8 -0
  22. package/src/components/index.ts +8 -0
  23. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
  24. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
  25. package/src/components/navigation/Footer/Footer.astro +102 -0
  26. package/src/components/navigation/Footer/Footer.css +155 -0
  27. package/src/components/navigation/Footer/contracts.ts +17 -0
  28. package/src/composition/Grid/Grid.css +16 -0
  29. package/src/data/firestore/client/contracts.ts +23 -0
  30. package/src/data/firestore/client/firebase.ts +45 -1
  31. package/src/display/contracts.ts +4 -1
  32. package/src/layouts/BaseLayout.astro +8 -1
  33. package/src/patterns/Combobox/comboboxController.ts +69 -0
  34. package/src/patterns/Table/TableBody.css +3 -3
  35. package/src/patterns/Table/TableCell.css +5 -1
  36. package/src/styles/base.css +24 -7
  37. package/src/styles/style.css +1 -0
  38. package/src/styles/tokens.css +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astronox/core",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Reusable Astro components, patterns, styles, and infrastructure for AstroNox sites.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -0,0 +1,217 @@
1
+ import type { AdminPage } from './contracts';
2
+
3
+ export type AdminResourceCacheSerializer<TItem, TStored> = {
4
+ parse(value: unknown): TItem | null;
5
+ serialize(item: TItem): TStored;
6
+ };
7
+
8
+ export type AdminResourceCacheConfig<TItem, TStored> = {
9
+ collectionKey: string;
10
+ editorKey: string;
11
+ getKey(item: TItem): string;
12
+ serializer: AdminResourceCacheSerializer<TItem, TStored>;
13
+ sort(items: readonly TItem[]): TItem[];
14
+ ttlMs?: number;
15
+ };
16
+
17
+ type StoredPage<TStored> = {
18
+ expiresAt: number;
19
+ items: TStored[];
20
+ nextCursor?: string;
21
+ };
22
+
23
+ type StoredEditor<TStored> = {
24
+ actorId: string;
25
+ expiresAt: number;
26
+ item: TStored;
27
+ };
28
+
29
+ const localCursorPrefix = 'cache:';
30
+
31
+ export function createAdminResourceCache<TItem, TStored>({
32
+ collectionKey,
33
+ editorKey,
34
+ getKey,
35
+ serializer,
36
+ sort,
37
+ ttlMs = 10 * 60 * 1000,
38
+ }: AdminResourceCacheConfig<TItem, TStored>) {
39
+ const collectionCacheKey = (actorId: string) =>
40
+ `${collectionKey}:${encodeURIComponent(actorId)}`;
41
+ const editorCacheKey = (id: string) =>
42
+ `${editorKey}:${encodeURIComponent(id)}`;
43
+
44
+ const readSnapshot = (actorId: string): AdminPage<TItem> | null => {
45
+ try {
46
+ const key = collectionCacheKey(actorId);
47
+ const raw = sessionStorage.getItem(key);
48
+ if (!raw) return null;
49
+ const stored = JSON.parse(raw) as Partial<StoredPage<TStored>>;
50
+ if (
51
+ typeof stored.expiresAt !== 'number' ||
52
+ stored.expiresAt <= Date.now() ||
53
+ !Array.isArray(stored.items)
54
+ ) {
55
+ sessionStorage.removeItem(key);
56
+ return null;
57
+ }
58
+
59
+ const items: TItem[] = [];
60
+ for (const value of stored.items) {
61
+ const item = serializer.parse(value);
62
+ if (!item) {
63
+ sessionStorage.removeItem(key);
64
+ return null;
65
+ }
66
+ items.push(item);
67
+ }
68
+
69
+ return {
70
+ items: sort(items),
71
+ nextCursor:
72
+ typeof stored.nextCursor === 'string' ? stored.nextCursor : undefined,
73
+ };
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+
79
+ const write = (actorId: string, page: AdminPage<TItem>): void => {
80
+ try {
81
+ const payload: StoredPage<TStored> = {
82
+ expiresAt: Date.now() + ttlMs,
83
+ items: sort(page.items).map(serializer.serialize),
84
+ nextCursor: page.nextCursor,
85
+ };
86
+ sessionStorage.setItem(
87
+ collectionCacheKey(actorId),
88
+ JSON.stringify(payload),
89
+ );
90
+ } catch {
91
+ // Storage is an optimization and must never block administration.
92
+ }
93
+ };
94
+
95
+ return {
96
+ editorKey,
97
+ isLocalCursor(cursor?: string): boolean {
98
+ return cursor?.startsWith(localCursorPrefix) === true;
99
+ },
100
+ read(
101
+ actorId: string,
102
+ input?: { cursor?: string; limit?: number },
103
+ ): AdminPage<TItem> | null {
104
+ const cached = readSnapshot(actorId);
105
+ if (!cached) return null;
106
+ if (input?.cursor && !input.cursor.startsWith(localCursorPrefix)) {
107
+ return null;
108
+ }
109
+ const offset = input?.cursor
110
+ ? Number.parseInt(input.cursor.slice(localCursorPrefix.length), 10)
111
+ : 0;
112
+ if (!Number.isSafeInteger(offset) || offset < 0) return null;
113
+ const limit = Math.max(1, Math.floor(input?.limit ?? 25));
114
+ const end = Math.min(cached.items.length, offset + limit);
115
+ return {
116
+ items: cached.items.slice(offset, end),
117
+ nextCursor:
118
+ end < cached.items.length
119
+ ? `${localCursorPrefix}${end}`
120
+ : cached.nextCursor,
121
+ };
122
+ },
123
+ find(actorId: string, id: string): TItem | null {
124
+ return (
125
+ readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ??
126
+ null
127
+ );
128
+ },
129
+ readEditor(actorId: string, id: string): TItem | null {
130
+ try {
131
+ const key = editorCacheKey(id);
132
+ const raw = sessionStorage.getItem(key);
133
+ if (!raw) return null;
134
+ const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
135
+ if (
136
+ stored.actorId !== actorId ||
137
+ typeof stored.expiresAt !== 'number' ||
138
+ stored.expiresAt <= Date.now()
139
+ ) {
140
+ sessionStorage.removeItem(key);
141
+ return null;
142
+ }
143
+ return serializer.parse(stored.item);
144
+ } catch {
145
+ return null;
146
+ }
147
+ },
148
+ write,
149
+ append(actorId: string, page: AdminPage<TItem>): void {
150
+ const cached = readSnapshot(actorId);
151
+ if (!cached) return;
152
+ const items = new Map(cached.items.map((item) => [getKey(item), item]));
153
+ page.items.forEach((item) => items.set(getKey(item), item));
154
+ write(actorId, {
155
+ items: [...items.values()],
156
+ nextCursor: page.nextCursor,
157
+ });
158
+ },
159
+ upsert(actorId: string, item: TItem): void {
160
+ const cached = readSnapshot(actorId);
161
+ if (!cached) return;
162
+ const items = new Map(
163
+ cached.items.map((candidate) => [getKey(candidate), candidate]),
164
+ );
165
+ items.set(getKey(item), item);
166
+ write(actorId, {
167
+ items: [...items.values()],
168
+ nextCursor: cached.nextCursor,
169
+ });
170
+ },
171
+ remove(actorId: string, id: string): void {
172
+ const cached = readSnapshot(actorId);
173
+ if (cached) {
174
+ write(actorId, {
175
+ items: cached.items.filter((item) => getKey(item) !== id),
176
+ nextCursor: cached.nextCursor,
177
+ });
178
+ }
179
+ try {
180
+ sessionStorage.removeItem(editorCacheKey(id));
181
+ } catch {
182
+ // Cleanup is best effort.
183
+ }
184
+ },
185
+ writeEditor(actorId: string, item: TItem): boolean {
186
+ try {
187
+ const payload: StoredEditor<TStored> = {
188
+ actorId,
189
+ expiresAt: Date.now() + ttlMs,
190
+ item: serializer.serialize(item),
191
+ };
192
+ sessionStorage.setItem(
193
+ editorCacheKey(getKey(item)),
194
+ JSON.stringify(payload),
195
+ );
196
+ return true;
197
+ } catch {
198
+ return false;
199
+ }
200
+ },
201
+ clear(actorId: string): void {
202
+ try {
203
+ sessionStorage.removeItem(collectionCacheKey(actorId));
204
+ for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
205
+ const key = sessionStorage.key(index);
206
+ if (!key?.startsWith(`${editorKey}:`)) continue;
207
+ const raw = sessionStorage.getItem(key);
208
+ if (!raw) continue;
209
+ const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
210
+ if (stored.actorId === actorId) sessionStorage.removeItem(key);
211
+ }
212
+ } catch {
213
+ // Cleanup is best effort.
214
+ }
215
+ },
216
+ } as const;
217
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './contracts';
2
+ export * from './cache';
2
3
  export * from './module';
@@ -1,21 +1,47 @@
1
- import { getApps, initializeApp } from 'firebase/app';
1
+ import { getApps, initializeApp, type FirebaseApp } from 'firebase/app';
2
2
  import {
3
+ browserLocalPersistence,
4
+ browserPopupRedirectResolver,
3
5
  GoogleAuthProvider,
4
6
  getAuth,
7
+ initializeAuth,
5
8
  onAuthStateChanged,
6
9
  signInWithPopup,
7
10
  signOut,
11
+ type Auth,
8
12
  type User,
9
13
  } from 'firebase/auth';
10
14
  import type { AuthActor } from '../contracts';
11
15
  import type { FirebaseClientConfig } from './contracts';
12
16
 
13
17
  export type FirebaseGoogleAuthClient = {
14
- observe(listener: (actor: AuthActor | null) => void): () => void;
18
+ observe(
19
+ listener: (actor: AuthActor | null) => void,
20
+ onError?: (reason: unknown) => void,
21
+ ): () => void;
15
22
  signIn(): Promise<AuthActor>;
16
23
  signOut(): Promise<void>;
17
24
  };
18
25
 
26
+ function initializeBrowserAuth(app: FirebaseApp): Auth {
27
+ try {
28
+ return initializeAuth(app, {
29
+ persistence: browserLocalPersistence,
30
+ popupRedirectResolver: browserPopupRedirectResolver,
31
+ });
32
+ } catch (reason) {
33
+ if (
34
+ typeof reason === 'object' &&
35
+ reason !== null &&
36
+ 'code' in reason &&
37
+ reason.code === 'auth/already-initialized'
38
+ ) {
39
+ return getAuth(app);
40
+ }
41
+ throw reason;
42
+ }
43
+ }
44
+
19
45
  function toAuthActor(user: User): AuthActor {
20
46
  return {
21
47
  id: user.uid,
@@ -32,15 +58,19 @@ export function createFirebaseGoogleAuthClient(
32
58
  const app =
33
59
  getApps().find((candidate) => candidate.name === config.projectId) ??
34
60
  initializeApp(config, config.projectId);
35
- const auth = getAuth(app);
61
+ const auth = initializeBrowserAuth(app);
36
62
  const provider = new GoogleAuthProvider();
37
63
  provider.setCustomParameters({ prompt: 'select_account' });
38
64
 
39
65
  return {
40
- observe(listener) {
41
- return onAuthStateChanged(auth, (user) => {
42
- listener(user ? toAuthActor(user) : null);
43
- });
66
+ observe(listener, onError) {
67
+ return onAuthStateChanged(
68
+ auth,
69
+ (user) => {
70
+ listener(user ? toAuthActor(user) : null);
71
+ },
72
+ onError,
73
+ );
44
74
  },
45
75
  async signIn() {
46
76
  const result = await signInWithPopup(auth, provider);
@@ -0,0 +1,177 @@
1
+ export type FormSubmissionMode = 'create' | 'edit';
2
+
3
+ export type FormSubmissionState = {
4
+ dirty: boolean;
5
+ pending: boolean;
6
+ };
7
+
8
+ export type FormSubmissionResult<TValue> = {
9
+ status: 'saved' | 'unchanged';
10
+ value: TValue;
11
+ };
12
+
13
+ export type FormSubmissionControllerConfig<TValue> = {
14
+ form: HTMLFormElement;
15
+ mode: FormSubmissionMode;
16
+ readValue(): TValue;
17
+ save(value: TValue): Promise<void>;
18
+ onSuccess(result: FormSubmissionResult<TValue>): void | Promise<void>;
19
+ onError?(reason: unknown): void;
20
+ onStateChange?(state: FormSubmissionState): void;
21
+ changeEvents?: readonly string[];
22
+ equals?(left: TValue, right: TValue): boolean;
23
+ };
24
+
25
+ export type FormSubmissionController = {
26
+ destroy(): void;
27
+ refresh(): void;
28
+ };
29
+
30
+ const defaultChangeEvents = ['input', 'change'] as const;
31
+
32
+ export function createFormSubmissionController<TValue>({
33
+ form,
34
+ mode,
35
+ readValue,
36
+ save,
37
+ onSuccess,
38
+ onError,
39
+ onStateChange,
40
+ changeEvents = defaultChangeEvents,
41
+ equals = areStructurallyEqual,
42
+ }: FormSubmissionControllerConfig<TValue>): FormSubmissionController {
43
+ let baseline!: TValue;
44
+ if (mode === 'edit') baseline = readValue();
45
+ let dirty = mode === 'create';
46
+ let pending = false;
47
+ let destroyed = false;
48
+
49
+ const publishState = (): void => {
50
+ onStateChange?.({ dirty, pending });
51
+ };
52
+
53
+ const refresh = (): void => {
54
+ if (destroyed || pending) return;
55
+
56
+ let nextDirty = true;
57
+ if (mode === 'edit') {
58
+ try {
59
+ nextDirty = !equals(baseline, readValue());
60
+ } catch {
61
+ // An incomplete value is still a change; native validation owns errors.
62
+ }
63
+ }
64
+
65
+ if (nextDirty === dirty) return;
66
+ dirty = nextDirty;
67
+ publishState();
68
+ };
69
+
70
+ const handleChange = (): void => refresh();
71
+
72
+ const handleSubmit = async (event: SubmitEvent): Promise<void> => {
73
+ event.preventDefault();
74
+ if (pending || !form.reportValidity()) return;
75
+
76
+ let value: TValue;
77
+ try {
78
+ value = readValue();
79
+ } catch (reason) {
80
+ onError?.(reason);
81
+ return;
82
+ }
83
+
84
+ if (mode === 'edit' && equals(baseline, value)) {
85
+ await onSuccess({ status: 'unchanged', value });
86
+ return;
87
+ }
88
+
89
+ pending = true;
90
+ publishState();
91
+
92
+ try {
93
+ await save(value);
94
+ baseline = value;
95
+ dirty = false;
96
+ pending = false;
97
+ publishState();
98
+ await onSuccess({ status: 'saved', value });
99
+ } catch (reason) {
100
+ pending = false;
101
+ refresh();
102
+ publishState();
103
+ onError?.(reason);
104
+ }
105
+ };
106
+
107
+ for (const eventName of new Set(changeEvents)) {
108
+ form.addEventListener(eventName, handleChange);
109
+ }
110
+ form.addEventListener('submit', handleSubmit);
111
+ publishState();
112
+
113
+ return {
114
+ destroy(): void {
115
+ if (destroyed) return;
116
+ destroyed = true;
117
+ for (const eventName of new Set(changeEvents)) {
118
+ form.removeEventListener(eventName, handleChange);
119
+ }
120
+ form.removeEventListener('submit', handleSubmit);
121
+ },
122
+ refresh,
123
+ };
124
+ }
125
+
126
+ function areStructurallyEqual<TValue>(left: TValue, right: TValue): boolean {
127
+ return compareValues(left, right, new WeakMap<object, object>());
128
+ }
129
+
130
+ function compareValues(
131
+ left: unknown,
132
+ right: unknown,
133
+ visited: WeakMap<object, object>,
134
+ ): boolean {
135
+ if (Object.is(left, right)) return true;
136
+ if (left instanceof Date || right instanceof Date) {
137
+ return (
138
+ left instanceof Date &&
139
+ right instanceof Date &&
140
+ left.getTime() === right.getTime()
141
+ );
142
+ }
143
+ if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
144
+ return false;
145
+ }
146
+
147
+ const knownRight = visited.get(left);
148
+ if (knownRight) return knownRight === right;
149
+ visited.set(left, right);
150
+
151
+ if (Array.isArray(left) || Array.isArray(right)) {
152
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
153
+ return (
154
+ left.length === right.length &&
155
+ left.every((value, index) => compareValues(value, right[index], visited))
156
+ );
157
+ }
158
+
159
+ const leftPrototype = Object.getPrototypeOf(left);
160
+ const rightPrototype = Object.getPrototypeOf(right);
161
+ if (leftPrototype !== rightPrototype) return false;
162
+
163
+ const leftKeys = Object.keys(left).sort();
164
+ const rightKeys = Object.keys(right).sort();
165
+ if (
166
+ leftKeys.length !== rightKeys.length ||
167
+ leftKeys.some((key, index) => key !== rightKeys[index])
168
+ ) {
169
+ return false;
170
+ }
171
+
172
+ const leftRecord = left as Record<string, unknown>;
173
+ const rightRecord = right as Record<string, unknown>;
174
+ return leftKeys.every((key) =>
175
+ compareValues(leftRecord[key], rightRecord[key], visited),
176
+ );
177
+ }
@@ -1,5 +1,6 @@
1
1
  export * from './clipboard';
2
2
  export * from './focus';
3
+ export * from './formSubmission';
3
4
  export * from './preferences';
4
5
  export * from './scroll';
5
6
  export * from '../components/content/MarkdownContent/markdownContentRenderer';
@@ -11,9 +11,20 @@
11
11
  block-size: var(--nox-section-compact-block-size);
12
12
  min-block-size: 0;
13
13
  box-shadow: var(--nox-shadow-surface);
14
+ opacity: 0.8;
15
+ }
16
+
17
+ .scroll-to-top.nox-button:is(:focus-visible, :active) {
18
+ opacity: 1;
14
19
  }
15
20
 
16
21
  html:has(dialog[open]) .scroll-to-top,
17
22
  html:has(:fullscreen) .scroll-to-top {
18
23
  display: none;
19
24
  }
25
+
26
+ @media (hover: hover) and (pointer: fine) {
27
+ .scroll-to-top.nox-button:hover {
28
+ opacity: 1;
29
+ }
30
+ }
@@ -56,8 +56,7 @@
56
56
  }
57
57
 
58
58
  .card-body a {
59
- color: var(--nox-link);
60
- text-underline-offset: 0.2em;
59
+ color: var(--nox-content-link-color);
61
60
  }
62
61
 
63
62
  .card-body strong {
@@ -65,6 +64,11 @@
65
64
  font-weight: var(--nox-font-weight-strong);
66
65
  }
67
66
 
67
+ .card-body a strong {
68
+ color: inherit;
69
+ font-weight: var(--nox-content-link-emphasis-font-weight);
70
+ }
71
+
68
72
  @container (width < 32rem) {
69
73
  .card-body__content {
70
74
  --nox-content-padding: var(--nox-space-4);
@@ -18,6 +18,7 @@ const {
18
18
  }: Props = Astro.props;
19
19
  const resolvedTone = tone ?? dataTone;
20
20
  const hasAction = Astro.slots.has('action');
21
+ const hasLeadingAction = Astro.slots.has('leading-action');
21
22
  const hasMedia = Boolean(media);
22
23
  ---
23
24
 
@@ -28,10 +29,18 @@ const hasMedia = Boolean(media);
28
29
  data-surface
29
30
  data-surface-sizing="content"
30
31
  data-has-action={hasAction ? true : undefined}
32
+ data-has-leading-action={hasLeadingAction ? true : undefined}
31
33
  data-has-media={hasMedia ? true : undefined}
32
34
  data-tone={resolvedTone}
33
35
  >
34
36
  <div class="card-footer__layout">
37
+ {
38
+ hasLeadingAction ? (
39
+ <div class="card-footer__leading-action">
40
+ <slot name="leading-action" />
41
+ </div>
42
+ ) : null
43
+ }
35
44
  {
36
45
  media ? (
37
46
  <div class="card-footer__media" aria-hidden="true">
@@ -31,6 +31,10 @@
31
31
  grid-template-columns: minmax(0, 1fr) var(--card-footer-size);
32
32
  }
33
33
 
34
+ .card-footer[data-has-leading-action] .card-footer__layout {
35
+ grid-template-columns: var(--card-footer-size) minmax(0, 1fr);
36
+ }
37
+
34
38
  .card-footer[data-has-media][data-has-action] .card-footer__layout {
35
39
  grid-template-columns:
36
40
  var(--card-footer-size)
@@ -38,6 +42,31 @@
38
42
  var(--card-footer-size);
39
43
  }
40
44
 
45
+ .card-footer[data-has-leading-action][data-has-action]
46
+ .card-footer__layout {
47
+ grid-template-columns:
48
+ var(--card-footer-size)
49
+ minmax(0, 1fr)
50
+ var(--card-footer-size);
51
+ }
52
+
53
+ .card-footer[data-has-leading-action][data-has-media]
54
+ .card-footer__layout {
55
+ grid-template-columns:
56
+ var(--card-footer-size)
57
+ var(--card-footer-size)
58
+ minmax(0, 1fr);
59
+ }
60
+
61
+ .card-footer[data-has-leading-action][data-has-media][data-has-action]
62
+ .card-footer__layout {
63
+ grid-template-columns:
64
+ var(--card-footer-size)
65
+ var(--card-footer-size)
66
+ minmax(0, 1fr)
67
+ var(--card-footer-size);
68
+ }
69
+
41
70
  .card-footer__media {
42
71
  display: grid;
43
72
  min-width: 0;
@@ -64,29 +93,42 @@
64
93
  align-items: center;
65
94
  padding: var(--nox-space-3) var(--nox-space-5);
66
95
  font-size: var(--nox-font-size-sm);
67
- font-weight: var(--nox-font-weight-medium);
96
+ font-weight: var(--nox-font-weight-regular);
68
97
  line-height: var(--nox-line-height-tight);
69
98
  overflow-wrap: anywhere;
70
99
  }
71
100
 
72
101
  .card-footer__content a {
73
- color: var(--nox-link);
74
- text-underline-offset: 0.2em;
102
+ color: var(--nox-content-link-color);
103
+ font-weight: var(--nox-content-link-emphasis-font-weight);
75
104
  }
76
105
 
77
106
  .card-footer__content strong {
78
107
  font-weight: var(--nox-font-weight-strong);
79
108
  }
80
109
 
110
+ .card-footer__content a strong {
111
+ font-weight: var(--nox-content-link-emphasis-font-weight);
112
+ }
113
+
114
+ .card-footer__leading-action,
81
115
  .card-footer__action {
82
116
  display: grid;
83
117
  min-width: 0;
84
118
  min-height: var(--card-footer-size);
85
119
  place-items: stretch;
86
120
  overflow: hidden;
121
+ }
122
+
123
+ .card-footer__leading-action {
124
+ border-inline-end: var(--nox-border-width) solid var(--surface-border-color);
125
+ }
126
+
127
+ .card-footer__action {
87
128
  border-inline-start: var(--nox-border-width) solid var(--surface-border-color);
88
129
  }
89
130
 
131
+ .card-footer__leading-action > .nox-button[data-button-borderless],
90
132
  .card-footer__action > .nox-button[data-button-borderless] {
91
133
  --nox-button-background: transparent;
92
134
  }
@@ -20,6 +20,7 @@ const {
20
20
  compactContent,
21
21
  compactText,
22
22
  compactVisual = 'show',
23
+ density = 'default',
23
24
  disabled = false,
24
25
  direction,
25
26
  full = false,
@@ -32,11 +33,15 @@ const {
32
33
  mediaSize = 'default',
33
34
  meta,
34
35
  mode = 'link',
36
+ textLayout = 'inline',
37
+ textTreatment = 'default',
35
38
  tone,
36
39
  title,
37
40
  ...attrs
38
41
  }: Props = Astro.props;
39
- const accessibleLabel = `${label} : ${title}${meta !== undefined ? ` ${meta}` : ''}`;
42
+ const accessibleLabel = [label, title, meta]
43
+ .filter((value) => value !== undefined && value !== '')
44
+ .join(' ');
40
45
  const isDisabled = disabled || (mode === 'link' && !href);
41
46
  const isInteractive = mode === 'link' && !isDisabled;
42
47
  const Root = isInteractive ? 'a' : 'span';
@@ -65,6 +70,7 @@ const resolvedTone = tone ?? dataTone;
65
70
  data-collapse={collapse}
66
71
  data-compact-content={resolvedCompactContent}
67
72
  data-compact-visual={compactVisual}
73
+ data-density={density}
68
74
  data-disabled={isDisabled ? 'true' : 'false'}
69
75
  data-direction={direction}
70
76
  data-full={full ? 'true' : 'false'}
@@ -74,6 +80,7 @@ const resolvedTone = tone ?? dataTone;
74
80
  data-media-position={hasVisual ? mediaPosition : undefined}
75
81
  data-media-size={hasVisual ? mediaSize : undefined}
76
82
  data-mode={mode}
83
+ data-text-treatment={textTreatment}
77
84
  data-tone={resolvedTone}
78
85
  >
79
86
  <span class="card-link__layout">
@@ -109,12 +116,14 @@ const resolvedTone = tone ?? dataTone;
109
116
  <span class="card-link__compact-text" aria-hidden="true">
110
117
  {compactText}
111
118
  </span>
112
- <span class="card-link__label">{label}</span>
119
+ {label ? <span class="card-link__label">{label}</span> : null}
113
120
  <TextPair
114
121
  class="card-link__text"
115
- layout="inline"
122
+ layout={textLayout}
116
123
  primary={title}
124
+ primaryWeight={isInteractive ? 'medium' : 'strong'}
117
125
  secondary={meta}
126
+ secondaryWeight={textTreatment === 'heading' ? 'regular' : 'medium'}
118
127
  />
119
128
  </span>
120
129
  </span>