@moldea.ai/website-ui 1.2.2 → 1.6.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 (26) hide show
  1. package/README.md +164 -3
  2. package/dist/evaluation-replay/types.d.ts +1 -1
  3. package/dist/evaluation-replay/types.d.ts.map +1 -1
  4. package/dist/markdown/index.d.ts +7 -0
  5. package/dist/markdown/index.d.ts.map +1 -1
  6. package/dist/markdown.js +33 -6
  7. package/dist/markdown.js.map +1 -1
  8. package/package.json +22 -1
  9. package/src/components/accordion/accordion.component.astro +103 -0
  10. package/src/components/action-link/action-link.component.astro +6 -1
  11. package/src/components/breadcrumbs/breadcrumbs.component.astro +1 -1
  12. package/src/components/code-block/code-block.component.astro +22 -0
  13. package/src/components/connection-label/connection-label.component.astro +26 -0
  14. package/src/components/dialog/dialog.component.astro +266 -0
  15. package/src/components/documentation-shell/documentation-outline.component.astro +1 -4
  16. package/src/components/documentation-shell/documentation-shell.component.astro +2 -12
  17. package/src/components/evaluation-replay/evaluation-replay-markdown.component.astro +1 -1
  18. package/src/components/file-preview/file-preview.component.astro +55 -0
  19. package/src/components/hero-backdrop/hero-backdrop.component.astro +9 -0
  20. package/src/components/inline-brand-text/inline-brand-text.component.astro +1 -1
  21. package/src/components/local-search/local-search.component.astro +37 -17
  22. package/src/components/result-summary/result-summary.component.astro +64 -0
  23. package/src/components/site-header/site-header.component.astro +39 -32
  24. package/src/components/status-badge/status-badge.component.astro +8 -2
  25. package/src/styles.css +52 -6
  26. package/src/tokens.css +2 -2
@@ -14,6 +14,7 @@ export interface Props {
14
14
  noResultsMessage?: string;
15
15
  placeholder: string;
16
16
  searchIndexUrl: string;
17
+ shouldFocusOnLoad?: boolean;
17
18
  }
18
19
 
19
20
  const {
@@ -27,6 +28,7 @@ const {
27
28
  noResultsMessage = 'No matching documentation was found.',
28
29
  placeholder,
29
30
  searchIndexUrl,
31
+ shouldFocusOnLoad = false,
30
32
  } = Astro.props;
31
33
  ---
32
34
 
@@ -36,6 +38,7 @@ const {
36
38
  data-initial-prompt={initialPrompt}
37
39
  data-loading-message={loadingMessage}
38
40
  data-no-results-message={noResultsMessage}
41
+ data-search-focus-on-load={shouldFocusOnLoad ? 'true' : undefined}
39
42
  >
40
43
  <form
41
44
  class="mt-10"
@@ -116,6 +119,20 @@ const {
116
119
  return item;
117
120
  };
118
121
 
122
+ /**
123
+ * Loads and validates the shared index on the first submitted query.
124
+ * @returns The public search documents, or null when the index is unavailable.
125
+ */
126
+ const loadSearchDocuments = async (url: string): Promise<ISearchDocument[] | null> => {
127
+ try {
128
+ const response = await fetch(url);
129
+ if (!response.ok) return null;
130
+ return parseSearchDocuments(await response.json());
131
+ } catch {
132
+ return null;
133
+ }
134
+ };
135
+
119
136
  const initializeSearch = (root: HTMLElement): void => {
120
137
  const form = root.querySelector<HTMLFormElement>('[data-search-form]');
121
138
  const input = root.querySelector<HTMLInputElement>('[data-search-input]');
@@ -131,15 +148,12 @@ const {
131
148
  if (!searchIndexUrl) return;
132
149
 
133
150
  root.dataset.searchInitialized = 'true';
134
- const documentsPromise = fetch(searchIndexUrl).then(async (response) => {
135
- if (!response.ok) {
136
- throw new Error('The documentation search index could not be loaded.');
137
- }
138
-
139
- return parseSearchDocuments(await response.json());
140
- });
151
+ let documentsPromise: Promise<ISearchDocument[] | null> | undefined;
152
+ let latestRequest: symbol;
141
153
 
142
154
  const runSearch = async (query: string): Promise<void> => {
155
+ const request = Symbol();
156
+ latestRequest = request;
143
157
  results.replaceChildren();
144
158
 
145
159
  if (!query.trim()) {
@@ -149,19 +163,22 @@ const {
149
163
 
150
164
  status.textContent = root.dataset.loadingMessage ?? '';
151
165
 
152
- try {
153
- const matches = searchDocuments(query, await documentsPromise);
154
-
155
- for (const match of matches) {
156
- results.append(createSearchResult(match));
157
- }
166
+ const pendingDocuments = (documentsPromise ??= loadSearchDocuments(searchIndexUrl));
167
+ const documents = await pendingDocuments;
168
+ if (documents === null && documentsPromise === pendingDocuments) documentsPromise = undefined;
169
+ // A replacement or cleared query owns the UI even while the index is still loading.
170
+ if (latestRequest !== request || !root.isConnected) return;
158
171
 
159
- status.textContent = matches.length
160
- ? `${matches.length} result${matches.length === 1 ? '' : 's'} for “${query}”.`
161
- : (root.dataset.noResultsMessage ?? '');
162
- } catch {
172
+ if (documents === null) {
163
173
  status.textContent = root.dataset.failureMessage ?? '';
174
+ return;
164
175
  }
176
+
177
+ const matches = searchDocuments(query, documents);
178
+ results.replaceChildren(...matches.map(createSearchResult));
179
+ status.textContent = matches.length
180
+ ? `${matches.length} result${matches.length === 1 ? '' : 's'} for “${query}”.`
181
+ : (root.dataset.noResultsMessage ?? '');
165
182
  };
166
183
 
167
184
  form.addEventListener('submit', (event) => {
@@ -180,6 +197,9 @@ const {
180
197
  const initializeLocalSearch = (): void => {
181
198
  for (const root of document.querySelectorAll<HTMLElement>('[data-local-search]')) {
182
199
  initializeSearch(root);
200
+ if (root.dataset.searchFocusOnLoad === 'true') {
201
+ root.querySelector<HTMLInputElement>('[data-search-input]')?.focus({ preventScroll: true });
202
+ }
183
203
  }
184
204
  };
185
205
 
@@ -0,0 +1,64 @@
1
+ ---
2
+ import InlineBrandText from '../inline-brand-text/inline-brand-text.component.astro';
3
+ import type { IStatusBadgeTone } from '../status-badge/status-badge.component.astro';
4
+
5
+ // balanced presentation shared by outcome cards and slotted dialog headings
6
+ export interface Props {
7
+ title: string;
8
+ description: string;
9
+ tone?: IStatusBadgeTone;
10
+ headingId?: string;
11
+ as?: 'p' | 'h2' | 'h3';
12
+ hideIconOnMobile?: boolean;
13
+ ariaLabel?: string;
14
+ }
15
+
16
+ const {
17
+ title,
18
+ description,
19
+ tone = 'neutral',
20
+ headingId,
21
+ as: Title = 'p',
22
+ hideIconOnMobile = false,
23
+ ariaLabel = 'Result',
24
+ } = Astro.props;
25
+ const toneClasses = {
26
+ danger: 'border-destructive/40 bg-destructive/10',
27
+ info: 'border-info/40 bg-info/10',
28
+ neutral: 'border-border bg-muted',
29
+ success: 'border-border bg-secondary',
30
+ warning: 'border-warning/60 bg-warning/15',
31
+ } as const;
32
+ ---
33
+
34
+ <div
35
+ class="flex min-w-0 items-center gap-3"
36
+ role="group"
37
+ aria-label={ariaLabel}
38
+ >
39
+ {
40
+ Astro.slots.has('icon') && (
41
+ <span
42
+ class:list={[
43
+ 'size-10 shrink-0 items-center justify-center rounded-lg border',
44
+ hideIconOnMobile ? 'hidden sm:flex' : 'flex',
45
+ toneClasses[tone],
46
+ ]}
47
+ aria-hidden="true"
48
+ >
49
+ <slot name="icon" />
50
+ </span>
51
+ )
52
+ }
53
+ <div class="min-w-0 flex-1">
54
+ <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
55
+ <Title
56
+ id={headingId}
57
+ class="text-sm leading-5 font-semibold"
58
+ ><InlineBrandText text={title} /></Title
59
+ >
60
+ <slot name="status" />
61
+ </div>
62
+ <p class="text-xs leading-5 text-muted-foreground"><InlineBrandText text={description} /></p>
63
+ </div>
64
+ </div>
@@ -1,9 +1,11 @@
1
1
  ---
2
2
  import { GitFork, Menu, Search } from '@lucide/astro';
3
3
 
4
+ import ActionLink from '../action-link/action-link.component.astro';
4
5
  import ThemeControl from '../theme-control/theme-control.component.astro';
5
6
 
6
7
  export interface ISiteHeaderNavigationItem {
8
+ compactLabel?: string;
7
9
  href: string;
8
10
  isActive: boolean;
9
11
  label: string;
@@ -51,7 +53,9 @@ const breakpointClasses = {
51
53
  const responsiveClasses = breakpointClasses[breakpoint];
52
54
  ---
53
55
 
54
- <header class="sticky top-0 z-50 border-b border-border bg-background/92 backdrop-blur-xl">
56
+ <header
57
+ class="sticky top-0 z-50 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85"
58
+ >
55
59
  <div class="page-shell flex min-h-16 items-center justify-between gap-4">
56
60
  <slot name="brand" />
57
61
 
@@ -60,29 +64,41 @@ const responsiveClasses = breakpointClasses[breakpoint];
60
64
  aria-label={navigationAriaLabel}
61
65
  >
62
66
  {
63
- navigationItems.map(({ href, isActive, label }) => (
67
+ navigationItems.map(({ href, isActive, label, compactLabel }) => (
64
68
  <a
65
- class:list={[
66
- 'rounded-lg px-3 py-2 text-sm transition-colors',
67
- isActive
68
- ? 'bg-secondary/70 font-bold text-foreground dark:bg-secondary/50'
69
- : 'font-semibold text-muted-foreground hover:bg-muted hover:text-foreground',
70
- ]}
69
+ class="rounded-lg navigation-link px-3 py-2 text-sm whitespace-nowrap"
71
70
  href={href}
71
+ aria-label={compactLabel ? label : undefined}
72
72
  aria-current={isActive ? 'page' : undefined}
73
73
  >
74
- {label}
74
+ {compactLabel ? (
75
+ <>
76
+ <span
77
+ class="xl:hidden"
78
+ aria-hidden="true"
79
+ >
80
+ {compactLabel}
81
+ </span>
82
+ <span
83
+ class="hidden xl:inline"
84
+ aria-hidden="true"
85
+ >
86
+ {label}
87
+ </span>
88
+ </>
89
+ ) : (
90
+ label
91
+ )}
75
92
  </a>
76
93
  ))
77
94
  }
78
95
  </nav>
79
96
 
80
97
  <div class:list={['items-center gap-2', responsiveClasses.desktop]}>
81
- <a
82
- class:list={[
83
- 'inline-flex size-10 items-center justify-center rounded-lg transition-colors',
84
- searchIsActive ? 'bg-secondary/70 dark:bg-secondary/50' : 'hover:bg-muted',
85
- ]}
98
+ <ActionLink
99
+ size="icon"
100
+ variant="ghost"
101
+ class="aria-[current=page]:bg-secondary"
86
102
  href={searchHref}
87
103
  aria-label={searchAriaLabel}
88
104
  aria-current={searchIsActive ? 'page' : undefined}
@@ -91,9 +107,10 @@ const responsiveClasses = breakpointClasses[breakpoint];
91
107
  class="size-4"
92
108
  aria-hidden="true"
93
109
  />
94
- </a>
95
- <a
96
- class="inline-flex size-10 items-center justify-center rounded-lg hover:bg-muted"
110
+ </ActionLink>
111
+ <ActionLink
112
+ size="icon"
113
+ variant="ghost"
97
114
  href={sourceHref}
98
115
  target="_blank"
99
116
  rel="noopener noreferrer"
@@ -103,14 +120,14 @@ const responsiveClasses = breakpointClasses[breakpoint];
103
120
  class="size-4"
104
121
  aria-hidden="true"
105
122
  />
106
- </a>
123
+ </ActionLink>
107
124
  <ThemeControl storageKey={themeStorageKey} />
108
125
  <slot name="desktop-actions" />
109
126
  </div>
110
127
 
111
128
  <details class:list={['group relative', responsiveClasses.mobile]}>
112
129
  <summary
113
- class="flex size-11 list-none items-center justify-center rounded-lg border border-input bg-background hover:bg-muted [&::-webkit-details-marker]:hidden"
130
+ class="flex size-11 list-none items-center justify-center rounded-lg border border-input surface-link bg-background [&::-webkit-details-marker]:hidden"
114
131
  aria-label={mobileMenuLabel}
115
132
  >
116
133
  <Menu
@@ -129,12 +146,7 @@ const responsiveClasses = breakpointClasses[breakpoint];
129
146
  {
130
147
  navigationItems.map(({ href, isActive, label }) => (
131
148
  <a
132
- class:list={[
133
- 'rounded-lg px-3 py-3 transition-colors',
134
- isActive
135
- ? 'bg-secondary/70 font-bold text-foreground dark:bg-secondary/50'
136
- : 'font-semibold text-muted-foreground hover:bg-muted hover:text-foreground',
137
- ]}
149
+ class="rounded-lg navigation-link px-3 py-3"
138
150
  href={href}
139
151
  aria-current={isActive ? 'page' : undefined}
140
152
  >
@@ -143,19 +155,14 @@ const responsiveClasses = breakpointClasses[breakpoint];
143
155
  ))
144
156
  }
145
157
  <a
146
- class:list={[
147
- 'rounded-lg px-3 py-3 transition-colors',
148
- searchIsActive
149
- ? 'bg-secondary/70 font-bold text-foreground dark:bg-secondary/50'
150
- : 'font-semibold text-muted-foreground hover:bg-muted hover:text-foreground',
151
- ]}
158
+ class="rounded-lg navigation-link px-3 py-3"
152
159
  href={searchHref}
153
160
  aria-current={searchIsActive ? 'page' : undefined}
154
161
  >
155
162
  Search
156
163
  </a>
157
164
  <a
158
- class="rounded-lg px-3 py-3 font-semibold hover:bg-muted"
165
+ class="rounded-lg navigation-link px-3 py-3"
159
166
  href={sourceHref}
160
167
  target="_blank"
161
168
  rel="noopener noreferrer"
@@ -4,11 +4,16 @@ export type IStatusBadgeTone = 'danger' | 'info' | 'neutral' | 'success' | 'warn
4
4
  export interface Props {
5
5
  borderStyle?: 'dashed' | 'dotted' | 'solid';
6
6
  label: string;
7
+ size?: 'md' | 'sm';
7
8
  tone: IStatusBadgeTone;
8
9
  truncate?: boolean;
9
10
  }
10
11
 
11
- const { borderStyle = 'solid', label, tone, truncate = false } = Astro.props;
12
+ const { borderStyle = 'solid', label, size = 'md', tone, truncate = false } = Astro.props;
13
+ const sizeClasses = {
14
+ md: 'min-h-6 px-2.5 py-1 text-[0.68rem] font-bold tracking-[0.08em]',
15
+ sm: 'min-h-5 px-1.5 py-0 text-[0.625rem] leading-4 font-semibold tracking-wide',
16
+ } as const;
12
17
  const borderStyleClasses = {
13
18
  dashed: 'border-dashed',
14
19
  dotted: 'border-dotted',
@@ -25,9 +30,10 @@ const toneClasses = {
25
30
 
26
31
  <span
27
32
  class:list={[
28
- 'min-h-6 max-w-full rounded-full border px-2.5 py-1 text-start text-[0.68rem] font-bold tracking-[0.08em] uppercase',
33
+ 'max-w-full rounded-full border text-start uppercase',
29
34
  truncate ? 'block min-w-0 truncate' : 'inline-flex items-center break-words whitespace-normal',
30
35
  borderStyleClasses[borderStyle],
36
+ sizeClasses[size],
31
37
  toneClasses[tone],
32
38
  ]}
33
39
  data-status-badge={tone}
package/src/styles.css CHANGED
@@ -21,7 +21,7 @@
21
21
  }
22
22
 
23
23
  body {
24
- @apply min-w-80 bg-background text-foreground;
24
+ @apply bg-background text-foreground;
25
25
  margin: 0;
26
26
  overflow-wrap: break-word;
27
27
  }
@@ -98,7 +98,7 @@
98
98
  }
99
99
 
100
100
  .interactive-card {
101
- @apply transition duration-200 ease-out hover:-translate-y-0.5 hover:border-foreground/25 active:translate-y-px motion-reduce:transform-none motion-reduce:transition-none;
101
+ @apply transition duration-200 ease-out hover:-translate-y-0.5 hover:border-foreground/25 focus-visible:ring-offset-0 active:translate-y-px motion-reduce:translate-none motion-reduce:transform-none motion-reduce:transition-none motion-reduce:hover:translate-none motion-reduce:active:translate-none;
102
102
  }
103
103
 
104
104
  .action-control {
@@ -125,6 +125,10 @@
125
125
  @apply border-transparent bg-transparent px-0! text-primary underline-offset-4 not-disabled:not-aria-disabled:hover:underline dark:text-primary-foreground dark:not-disabled:not-aria-disabled:hover:text-primary-foreground/80;
126
126
  }
127
127
 
128
+ .text-action-link {
129
+ @apply border-transparent bg-transparent px-0! font-semibold text-foreground link-feedback not-disabled:not-aria-disabled:active:not-aria-[haspopup]:translate-none;
130
+ }
131
+
128
132
  .action-size-sm {
129
133
  @apply min-h-8 gap-1.5 px-3 py-1.5 text-sm;
130
134
  }
@@ -185,11 +189,11 @@
185
189
  list-style: decimal;
186
190
  }
187
191
 
188
- .prose-moldea a,
189
- .text-link {
190
- @apply font-medium text-link underline decoration-link/40 underline-offset-4 hover:decoration-current;
192
+ .prose-moldea a {
193
+ @apply text-link;
191
194
  }
192
195
 
196
+ .inline-code,
193
197
  .prose-moldea code:not(pre code) {
194
198
  @apply rounded-md bg-code px-1.5 py-1 font-mono text-[0.86em] font-semibold text-code-foreground;
195
199
  }
@@ -198,7 +202,18 @@
198
202
  @apply max-w-full overflow-x-auto rounded-xl border bg-code p-5 text-sm leading-6 text-code-foreground shadow-inset;
199
203
  }
200
204
 
201
- .prose-moldea-wrap-code pre {
205
+ /* Preserve code, structured output, and file-tree alignment by default. */
206
+ .code-block,
207
+ .prose-moldea pre {
208
+ @apply max-w-full overflow-x-auto outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
209
+ white-space: pre;
210
+ overflow-wrap: normal;
211
+ word-break: normal;
212
+ }
213
+
214
+ /* Only explicitly identified plain text wraps; unlabelled fences remain lossless. */
215
+ .code-block[data-code-language='text'],
216
+ .prose-moldea pre:has(> code:is(.language-text, .language-txt, .language-plaintext)) {
202
217
  white-space: pre-wrap;
203
218
  overflow-wrap: anywhere;
204
219
  }
@@ -295,6 +310,37 @@
295
310
  }
296
311
  }
297
312
 
313
+ /* platform TextLink feedback belongs to high-contrast text, not every linked surface */
314
+ @utility link-feedback {
315
+ @apply not-disabled:not-aria-disabled:hover:opacity-70 focus-visible:ring-current focus-visible:ring-offset-0 not-disabled:not-aria-disabled:active:opacity-60;
316
+ }
317
+
318
+ @utility text-link {
319
+ /* keep the foreground opaque: a second dark-mode fade fails contrast when pressed */
320
+ color: var(--link);
321
+ @apply rounded-sm font-medium underline decoration-current underline-offset-4 link-feedback aria-[current=page]:font-semibold;
322
+ }
323
+
324
+ @utility plain-link {
325
+ @apply rounded-sm text-muted-foreground not-disabled:not-aria-disabled:hover:text-foreground focus-visible:ring-foreground focus-visible:ring-offset-0 not-disabled:not-aria-disabled:active:text-foreground;
326
+ }
327
+
328
+ @utility surface-link {
329
+ @apply not-disabled:not-aria-disabled:hover:bg-secondary not-disabled:not-aria-disabled:active:bg-secondary/80 aria-[current=page]:bg-secondary aria-[current=page]:font-semibold;
330
+ }
331
+
332
+ @utility surface-link-warning {
333
+ @apply border-warning/60 bg-warning/15 text-warning-foreground not-disabled:not-aria-disabled:hover:bg-warning/20 not-disabled:not-aria-disabled:active:bg-warning/25 dark:text-foreground;
334
+ }
335
+
336
+ @utility surface-link-danger {
337
+ @apply border-destructive/50 bg-destructive/10 not-disabled:not-aria-disabled:hover:bg-destructive/20 not-disabled:not-aria-disabled:active:bg-destructive/25 dark:bg-destructive/20 dark:not-disabled:not-aria-disabled:hover:bg-destructive/30 dark:not-disabled:not-aria-disabled:active:bg-destructive/25;
338
+ }
339
+
340
+ @utility navigation-link {
341
+ @apply font-semibold text-muted-foreground not-aria-[current=page]:hover:bg-secondary not-aria-[current=page]:hover:text-foreground not-aria-[current=page]:active:bg-secondary/80 aria-[current=page]:bg-secondary/70 aria-[current=page]:font-bold aria-[current=page]:text-foreground dark:aria-[current=page]:bg-secondary/50;
342
+ }
343
+
298
344
  @media (prefers-color-scheme: dark) {
299
345
  html:not(.light):not(.dark) {
300
346
  color-scheme: dark;
package/src/tokens.css CHANGED
@@ -130,7 +130,7 @@
130
130
  --info-foreground: oklch(99% 0.005 255);
131
131
  --sidebar: oklch(97.8% 0.005 198);
132
132
  --sidebar-foreground: var(--foreground);
133
- --link: oklch(42% 0.12 255.341);
133
+ --link: var(--primary);
134
134
  --code-background: oklch(95.1% 0.007 226);
135
135
  --code-foreground: oklch(20% 0.026 232);
136
136
  --syntax-comment: oklch(51% 0.024 220);
@@ -174,7 +174,7 @@
174
174
  --info-foreground: oklch(20% 0.05 255);
175
175
  --sidebar: oklch(25.3% 0.031 258);
176
176
  --sidebar-foreground: var(--foreground);
177
- --link: oklch(78% 0.1 244);
177
+ --link: var(--primary-foreground);
178
178
  --code-background: oklch(23.8% 0.027 258);
179
179
  --code-foreground: oklch(94% 0.01 215);
180
180
  --syntax-comment: oklch(72% 0.025 220);