@inferencesh/ui 0.0.0-stage → 0.1.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 (165) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +68 -2
  3. package/components.json +22 -0
  4. package/package.json +231 -4
  5. package/src/components/audio-player.tsx +549 -0
  6. package/src/components/button.tsx +13 -0
  7. package/src/components/chart.tsx +403 -0
  8. package/src/components/circuit.tsx +276 -0
  9. package/src/components/code-block/code-block.tsx +260 -0
  10. package/src/components/code-block/index.ts +18 -0
  11. package/src/components/code-block/languages.ts +311 -0
  12. package/src/components/code-block/styles.ts +39 -0
  13. package/src/components/code-block/tokenizer.ts +290 -0
  14. package/src/components/code-block/types.ts +38 -0
  15. package/src/components/code-block/utils.ts +39 -0
  16. package/src/components/code.tsx +48 -0
  17. package/src/components/cookie-consent.tsx +137 -0
  18. package/src/components/copy-button.tsx +44 -0
  19. package/src/components/country-dropdown.tsx +188 -0
  20. package/src/components/glow-loader-fast.tsx +32 -0
  21. package/src/components/glow-loader-mono.tsx +33 -0
  22. package/src/components/glow-loader-sm-fast.tsx +32 -0
  23. package/src/components/glow-loader-sm-mono.tsx +34 -0
  24. package/src/components/glow-loader-sm.tsx +32 -0
  25. package/src/components/glow-loader.tsx +32 -0
  26. package/src/components/glow-logo.tsx +32 -0
  27. package/src/components/gradient.tsx +80 -0
  28. package/src/components/index.ts +45 -0
  29. package/src/components/inline-sidebar.tsx +351 -0
  30. package/src/components/input-color.tsx +349 -0
  31. package/src/components/logo-base.tsx +213 -0
  32. package/src/components/logo-color.tsx +23 -0
  33. package/src/components/logo-full.tsx +71 -0
  34. package/src/components/logo-svg.tsx +23 -0
  35. package/src/components/menu-navigation.tsx +359 -0
  36. package/src/components/multi-select.tsx +721 -0
  37. package/src/components/pastel-gradient.tsx +25 -0
  38. package/src/components/pixel-symmetry.tsx +151 -0
  39. package/src/components/progress.tsx +28 -0
  40. package/src/components/scroll-more.tsx +131 -0
  41. package/src/components/select.tsx +85 -0
  42. package/src/components/shimmering-text.tsx +108 -0
  43. package/src/components/sidebar-light.tsx +102 -0
  44. package/src/components/sonner.tsx +38 -0
  45. package/src/components/theme-switcher-sidebar.tsx +56 -0
  46. package/src/components/theme-switcher-simple.tsx +110 -0
  47. package/src/components/theme-switcher.tsx +47 -0
  48. package/src/components/toggle-group.tsx +55 -0
  49. package/src/components/tooltip-button.tsx +91 -0
  50. package/src/components/tooltip.tsx +71 -0
  51. package/src/components/tree-view.tsx +499 -0
  52. package/src/components/unglow-loader.tsx +33 -0
  53. package/src/components/waveform.tsx +1632 -0
  54. package/src/components/zoomable-image.tsx +76 -0
  55. package/src/docs/api-docs.tsx +763 -0
  56. package/src/docs/callout.tsx +160 -0
  57. package/src/docs/code-group.tsx +215 -0
  58. package/src/docs/copy-menu.tsx +143 -0
  59. package/src/docs/doc-tabs.tsx +84 -0
  60. package/src/docs/index.ts +16 -0
  61. package/src/docs/steps.tsx +105 -0
  62. package/src/docs/table-of-contents.tsx +174 -0
  63. package/src/examples/guide-markdown.ts +129 -0
  64. package/src/examples/index.ts +1153 -0
  65. package/src/global.d.ts +1 -0
  66. package/src/hooks/use-copy-to-clipboard.ts +36 -0
  67. package/src/hooks/use-mobile.ts +19 -0
  68. package/src/lib/display-name.ts +15 -0
  69. package/src/lib/example-input.ts +130 -0
  70. package/src/lib/plans.ts +44 -0
  71. package/src/lib/safe-content.ts +137 -0
  72. package/src/lib/schema.ts +320 -0
  73. package/src/lib/storage.ts +108 -0
  74. package/src/markdown/docs-markdown-renderer.tsx +518 -0
  75. package/src/markdown/helpers.ts +95 -0
  76. package/src/markdown/index.ts +24 -0
  77. package/src/markdown/markdown-renderer.tsx +232 -0
  78. package/src/markdown/youtube-embed.tsx +43 -0
  79. package/src/og/image.ts +312 -0
  80. package/src/og/index.tsx +430 -0
  81. package/src/og/query.ts +26 -0
  82. package/src/shadcn/accordion.tsx +71 -0
  83. package/src/shadcn/alert-dialog.tsx +187 -0
  84. package/src/shadcn/alert.tsx +75 -0
  85. package/src/shadcn/aspect-ratio.tsx +22 -0
  86. package/src/shadcn/attachment.tsx +207 -0
  87. package/src/shadcn/avatar.tsx +106 -0
  88. package/src/shadcn/badge.tsx +51 -0
  89. package/src/shadcn/breadcrumb.tsx +124 -0
  90. package/src/shadcn/bubble.tsx +127 -0
  91. package/src/shadcn/button-group.tsx +87 -0
  92. package/src/shadcn/button.tsx +57 -0
  93. package/src/shadcn/calendar.tsx +219 -0
  94. package/src/shadcn/card.tsx +102 -0
  95. package/src/shadcn/carousel.tsx +242 -0
  96. package/src/shadcn/chart.tsx +372 -0
  97. package/src/shadcn/checkbox.tsx +28 -0
  98. package/src/shadcn/collapsible.tsx +19 -0
  99. package/src/shadcn/combobox.tsx +297 -0
  100. package/src/shadcn/command.tsx +194 -0
  101. package/src/shadcn/context-menu.tsx +270 -0
  102. package/src/shadcn/dialog.tsx +158 -0
  103. package/src/shadcn/direction.tsx +4 -0
  104. package/src/shadcn/drawer.tsx +227 -0
  105. package/src/shadcn/dropdown-menu.tsx +265 -0
  106. package/src/shadcn/empty.tsx +103 -0
  107. package/src/shadcn/field.tsx +238 -0
  108. package/src/shadcn/form.tsx +173 -0
  109. package/src/shadcn/hover-card.tsx +50 -0
  110. package/src/shadcn/index.ts +53 -0
  111. package/src/shadcn/input-group.tsx +156 -0
  112. package/src/shadcn/input-otp.tsx +84 -0
  113. package/src/shadcn/input.tsx +19 -0
  114. package/src/shadcn/item.tsx +201 -0
  115. package/src/shadcn/kbd.tsx +26 -0
  116. package/src/shadcn/label.tsx +19 -0
  117. package/src/shadcn/marker.tsx +70 -0
  118. package/src/shadcn/menubar.tsx +278 -0
  119. package/src/shadcn/message-scroller.tsx +131 -0
  120. package/src/shadcn/message.tsx +91 -0
  121. package/src/shadcn/native-select.tsx +60 -0
  122. package/src/shadcn/navigation-menu.tsx +167 -0
  123. package/src/shadcn/pagination.tsx +130 -0
  124. package/src/shadcn/popover.tsx +87 -0
  125. package/src/shadcn/progress.tsx +82 -0
  126. package/src/shadcn/radio-group.tsx +35 -0
  127. package/src/shadcn/resizable.tsx +49 -0
  128. package/src/shadcn/scroll-area.tsx +52 -0
  129. package/src/shadcn/select.tsx +200 -0
  130. package/src/shadcn/separator.tsx +22 -0
  131. package/src/shadcn/sheet.tsx +136 -0
  132. package/src/shadcn/sidebar.tsx +721 -0
  133. package/src/shadcn/skeleton.tsx +13 -0
  134. package/src/shadcn/slider.tsx +51 -0
  135. package/src/shadcn/sonner.tsx +49 -0
  136. package/src/shadcn/spinner.tsx +10 -0
  137. package/src/shadcn/switch.tsx +29 -0
  138. package/src/shadcn/table.tsx +115 -0
  139. package/src/shadcn/tabs.tsx +79 -0
  140. package/src/shadcn/textarea.tsx +17 -0
  141. package/src/shadcn/toggle-group.tsx +89 -0
  142. package/src/shadcn/toggle.tsx +44 -0
  143. package/src/shadcn/tooltip.tsx +63 -0
  144. package/src/theme/config.ts +48 -0
  145. package/src/theme/css/base.css +92 -0
  146. package/src/theme/css/bitboy.css +75 -0
  147. package/src/theme/css/dark.css +47 -0
  148. package/src/theme/css/designer.css +122 -0
  149. package/src/theme/css/fonts.css +33 -0
  150. package/src/theme/css/globals.css +14 -0
  151. package/src/theme/css/hackintosh.css +57 -0
  152. package/src/theme/css/llama.css +103 -0
  153. package/src/theme/css/safelist.css +39 -0
  154. package/src/theme/css/shadcn.css +77 -0
  155. package/src/theme/css/theme.css +216 -0
  156. package/src/theme/embed-theme.ts +146 -0
  157. package/src/theme/fonts/FiraCode-VF.woff2 +0 -0
  158. package/src/theme/fonts/Hack-Regular.woff2 +0 -0
  159. package/src/theme/fonts/LICENSE-FiraCode.txt +93 -0
  160. package/src/theme/fonts/LICENSE-Hack.txt +45 -0
  161. package/src/theme/fonts/LICENSE-MonaSans.txt +93 -0
  162. package/src/theme/fonts/Mona-Sans.woff2 +0 -0
  163. package/src/theme/index.ts +15 -0
  164. package/src/utils/cn.ts +3 -0
  165. package/src/utils/index.ts +2 -0
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Shared OG card chrome for inference shell and app.inference.sh.
3
+ *
4
+ * Why this module exists
5
+ * ---------------------
6
+ * These cards were hand-ported between js/web and js/app and then drifted:
7
+ * different description colour, size, casing and wrapping, a different footer
8
+ * accent, and a duplicated copy of fitTitle. Worse, both copies carried the
9
+ * SAME bug — a single Inter face registered at weight 700, so every string on
10
+ * every card rendered bold no matter what weight it asked for. Identical bugs
11
+ * in two files are invisible to a drift check; the only real fix is one file.
12
+ *
13
+ * What is deliberately NOT here
14
+ * ----------------------------
15
+ * The satori + resvg call. js/web runs on Node and returns a Response;
16
+ * js/app runs on Bun and returns a Uint8Array, and neither satori nor
17
+ * @resvg/resvg-js is installed in js/app outside its Docker runner. Keeping
18
+ * this module dependency-free (react only) means it imports cleanly in both
19
+ * runtimes and in vitest. Each consumer keeps its own three-line render call
20
+ * and passes OG_FONTS straight through — so the font set, which is what was
21
+ * actually wrong, still lives in exactly one place.
22
+ */
23
+
24
+ import type { ReactNode } from 'react'
25
+
26
+ import { fetchOgImage, OgImageRejected, redactUrl, type FetchOgImageOptions } from './image'
27
+ export { canonicalOgSearch } from './query'
28
+
29
+ export {
30
+ DEFAULT_OG_IMAGE_HOSTS,
31
+ OG_IMAGE_LIMITS,
32
+ checkOgImageUrl,
33
+ configureOgImageHosts,
34
+ fetchOgImage,
35
+ getOgImageHosts,
36
+ parseOgImageHosts,
37
+ readImageInfo,
38
+ OgImageRejected,
39
+ type FetchOgImageOptions,
40
+ type ImageInfo,
41
+ } from './image'
42
+
43
+ export const OG_SIZE = { width: 1200, height: 630 }
44
+
45
+ export const OG_CACHE_HEADERS = {
46
+ 'Content-Type': 'image/png',
47
+ 'Cache-Control': 'public, max-age=3600, s-maxage=86400',
48
+ }
49
+
50
+ export const CATEGORY_COLORS: Record<string, string> = {
51
+ image: '#FF569D',
52
+ video: '#FF569D',
53
+ audio: '#FF569D',
54
+ chat: '#FF569D',
55
+ text: '#FF569D',
56
+ social: '#FF569D',
57
+ other: '#FF569D',
58
+ }
59
+
60
+ const LOGO_URL = 'https://cloud.inference.sh/web/assets/logo.png'
61
+ // The logo is a constant of ours, so it does not depend on the configurable
62
+ // allowlist that guards app-supplied card images.
63
+ const LOGO_FETCH = { allowedHosts: [new URL(LOGO_URL).hostname] }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Fonts
67
+ // ---------------------------------------------------------------------------
68
+
69
+ // Satori needs static TTF/OTF, so these are instances of the same variable
70
+ // fonts the sites use (theme/fonts), cut with fontTools at the weights the
71
+ // type spec uses and served by js/web from public/fonts/og. Mona Sans is
72
+ // pinned to opsz 16 like the site CSS, and renamed inside the file because
73
+ // its licence reserves the name for unmodified copies.
74
+ //
75
+ // Satori picks a face by declared weight, so each cut is registered at the
76
+ // nearest hundred: 420 as 400, 520 as 500. Ask for 400 or 500 in styles.
77
+ const OG_FONT_BASE = 'https://inference.sh/fonts/og'
78
+
79
+ const OG_FONT_FILES = [
80
+ { name: 'Mona Sans', weight: 400, file: 'og-sans-420.ttf' },
81
+ { name: 'Mona Sans', weight: 500, file: 'og-sans-520.ttf' },
82
+ ] as const
83
+
84
+ // The wordmark stays in Hack.
85
+ const HACK_FILE = 'Hack-Regular.ttf'
86
+ const HACK_URL = 'https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/ttf/Hack-Regular.ttf'
87
+
88
+ /** How long one font download may take before the card fails instead of hanging. */
89
+ export const OG_FONT_FETCH_TIMEOUT_MS = 10_000
90
+
91
+ /** Font stacks for card styles; names match OG_FONT_FILES. */
92
+ export const OG_DISPLAY_FONT = 'Mona Sans, sans-serif'
93
+ export const OG_SANS_FONT = 'Mona Sans, system-ui, sans-serif'
94
+
95
+ export interface OgFont {
96
+ name: string
97
+ data: ArrayBuffer
98
+ weight: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
99
+ style: 'normal'
100
+ }
101
+
102
+ /** Reads a card font by file name (og-sans-420.ttf, og-sans-520.ttf, Hack-Regular.ttf). */
103
+ export type OgFontReader = (file: string) => Promise<ArrayBuffer>
104
+
105
+ let fontsPromise: Promise<OgFont[]> | null = null
106
+ let fontReader: OgFontReader | null = null
107
+
108
+ /**
109
+ * Has loadOgFonts read the fonts with read (js/web: from its own build on
110
+ * disk) instead of downloading them from inference.sh and jsDelivr, which
111
+ * puts the site's own edge and a CDN on every cold render path. A file read
112
+ * fails over to the download. Call before the first render.
113
+ */
114
+ export function configureOgFontReader(read: OgFontReader | null): void {
115
+ fontReader = read
116
+ fontsPromise = null
117
+ }
118
+
119
+ async function fetchFont(url: string): Promise<ArrayBuffer> {
120
+ const res = await fetch(url, { signal: AbortSignal.timeout(OG_FONT_FETCH_TIMEOUT_MS) })
121
+ if (!res.ok) throw new Error(`Font fetch failed: ${res.status} ${url}`)
122
+ return res.arrayBuffer()
123
+ }
124
+
125
+ async function readFont(file: string, url: string): Promise<ArrayBuffer> {
126
+ if (fontReader) {
127
+ try {
128
+ return await fontReader(file)
129
+ } catch (err) {
130
+ console.warn(`[og] font ${file} not readable (${err instanceof Error ? err.message : err}), downloading it`)
131
+ }
132
+ }
133
+ return fetchFont(url)
134
+ }
135
+
136
+ export function loadOgFonts(): Promise<OgFont[]> {
137
+ if (!fontsPromise) {
138
+ fontsPromise = Promise.all([
139
+ ...OG_FONT_FILES.map(async (f) => ({
140
+ name: f.name,
141
+ data: await readFont(f.file, `${OG_FONT_BASE}/${f.file}`),
142
+ weight: f.weight,
143
+ style: 'normal' as const,
144
+ })),
145
+ readFont(HACK_FILE, HACK_URL).then((data) => ({
146
+ name: 'Hack',
147
+ data,
148
+ weight: 400 as const,
149
+ style: 'normal' as const,
150
+ })),
151
+ ]).catch((err) => {
152
+ // Don't cache failures — allow the next call to retry
153
+ fontsPromise = null
154
+ throw err
155
+ })
156
+ }
157
+ return fontsPromise
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Images
162
+ // ---------------------------------------------------------------------------
163
+
164
+ // Data URIs of fetched images, least recently used first out. Bounded by
165
+ // total size as well as count: at the 5 MB per-image cap, 100 entries could
166
+ // otherwise hold ~670 MB of base64.
167
+ const MAX_IMAGE_CACHE = 100
168
+ const MAX_IMAGE_CACHE_CHARS = 32 * 1024 * 1024
169
+ const imageCache = new Map<string, string>()
170
+ let imageCacheChars = 0
171
+
172
+ function cacheImage(url: string, dataUri: string) {
173
+ if (dataUri.length > MAX_IMAGE_CACHE_CHARS) return
174
+ while (
175
+ imageCache.size > 0 &&
176
+ (imageCache.size >= MAX_IMAGE_CACHE || imageCacheChars + dataUri.length > MAX_IMAGE_CACHE_CHARS)
177
+ ) {
178
+ const [oldestKey, oldest] = imageCache.entries().next().value as [string, string]
179
+ imageCache.delete(oldestKey)
180
+ imageCacheChars -= oldest.length
181
+ }
182
+ imageCache.set(url, dataUri)
183
+ imageCacheChars += dataUri.length
184
+ }
185
+
186
+ // Card-image fetches in flight at once, and how many more may wait for a turn.
187
+ // Each one holds up to 5 MB of body plus its base64 copy until its card is
188
+ // drawn, so a burst of distinct cards must not fetch them all at once. A
189
+ // fetch that cannot get a turn is skipped: the card renders without it.
190
+ const MAX_IMAGE_FETCHES = 4
191
+ const MAX_QUEUED_IMAGE_FETCHES = 16
192
+ const MAX_IMAGE_FETCH_WAIT_MS = 5000
193
+ let activeImageFetches = 0
194
+ const imageFetchWaiters: Array<() => void> = []
195
+ // Requests for the same image while it is being fetched share that fetch.
196
+ const inFlightImages = new Map<string, Promise<string | null>>()
197
+
198
+ class ImageFetchBusy extends Error {}
199
+
200
+ function acquireImageFetch(): Promise<void> {
201
+ if (activeImageFetches < MAX_IMAGE_FETCHES) {
202
+ activeImageFetches++
203
+ return Promise.resolve()
204
+ }
205
+ if (imageFetchWaiters.length >= MAX_QUEUED_IMAGE_FETCHES) {
206
+ return Promise.reject(new ImageFetchBusy('busy'))
207
+ }
208
+ return new Promise((resolve, reject) => {
209
+ const turn = () => {
210
+ clearTimeout(timer)
211
+ activeImageFetches++
212
+ resolve()
213
+ }
214
+ const timer = setTimeout(() => {
215
+ const i = imageFetchWaiters.indexOf(turn)
216
+ if (i >= 0) imageFetchWaiters.splice(i, 1)
217
+ reject(new ImageFetchBusy('busy, waited too long'))
218
+ }, MAX_IMAGE_FETCH_WAIT_MS)
219
+ imageFetchWaiters.push(turn)
220
+ })
221
+ }
222
+
223
+ function releaseImageFetch(): void {
224
+ activeImageFetches--
225
+ imageFetchWaiters.shift()?.()
226
+ }
227
+
228
+ /**
229
+ * An image as a data URI for satori, or null when it may not or could not be
230
+ * used (see ./image.ts for the rules). Callers render without the image on
231
+ * null. Each refusal logs one line, without the URL's query.
232
+ */
233
+ export async function fetchImageAsDataUri(
234
+ url: string,
235
+ opts?: FetchOgImageOptions,
236
+ ): Promise<string | null> {
237
+ const cached = imageCache.get(url)
238
+ if (cached !== undefined) {
239
+ imageCache.delete(url)
240
+ imageCache.set(url, cached)
241
+ return cached
242
+ }
243
+ const pending = inFlightImages.get(url)
244
+ if (pending) return pending
245
+ const fetching = fetchImageUncached(url, opts).finally(() => inFlightImages.delete(url))
246
+ inFlightImages.set(url, fetching)
247
+ return fetching
248
+ }
249
+
250
+ async function fetchImageUncached(url: string, opts?: FetchOgImageOptions): Promise<string | null> {
251
+ let acquired = false
252
+ try {
253
+ await acquireImageFetch()
254
+ acquired = true
255
+ const { bytes, info } = await fetchOgImage(url, opts)
256
+ const dataUri = `data:${info.type};base64,${Buffer.from(bytes).toString('base64')}`
257
+ cacheImage(url, dataUri)
258
+ return dataUri
259
+ } catch (err) {
260
+ const reason =
261
+ err instanceof OgImageRejected || err instanceof ImageFetchBusy
262
+ ? err.message
263
+ : err instanceof Error && err.name === 'AbortError'
264
+ ? 'timeout'
265
+ : err instanceof Error
266
+ ? `fetch failed: ${err.message}`
267
+ : 'fetch failed'
268
+ console.warn(`[og] image skipped (${reason}): ${redactUrl(url)}`)
269
+ return null
270
+ } finally {
271
+ if (acquired) releaseImageFetch()
272
+ }
273
+ }
274
+
275
+ /** Test hook: empty the image cache. */
276
+ export function clearOgImageCache(): void {
277
+ imageCache.clear()
278
+ imageCacheChars = 0
279
+ }
280
+
281
+ /**
282
+ * Warm the font and logo caches at startup so the first card request does not
283
+ * pay for two network fetches. Rejects when a font fails to load (the loader
284
+ * does not cache the failure, so a later call or card request tries again);
285
+ * an unreachable logo resolves to null and is not cached either. Callers must
286
+ * handle the rejection: the OG server wraps it in prewarmWithRetry
287
+ * (js/app server/prewarm.ts).
288
+ */
289
+ export function prewarmOg(): Promise<unknown> {
290
+ return Promise.all([loadOgFonts(), fetchImageAsDataUri(LOGO_URL, LOGO_FETCH)])
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Layout
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /** Font size and truncation for OG card titles. */
298
+ export function fitTitle(text: string, maxLines: number = 2) {
299
+ const len = text.length
300
+ let fontSize: number
301
+ let maxChars: number
302
+
303
+ if (maxLines <= 2) {
304
+ fontSize = len <= 20 ? 82 : len <= 30 ? 72 : len <= 45 ? 60 : 52
305
+ maxChars = 60
306
+ } else {
307
+ fontSize = len <= 20 ? 82 : len <= 35 ? 68 : len <= 55 ? 56 : 48
308
+ maxChars = 100
309
+ }
310
+
311
+ const truncated = text.length > maxChars ? text.slice(0, maxChars) + '...' : text
312
+ return { fontSize, text: truncated }
313
+ }
314
+
315
+ /** Truncate a card description to the length the layout can hold. */
316
+ export function fitDescription(text: string, maxChars = 120): string {
317
+ return text.length > maxChars ? text.slice(0, maxChars) + '...' : text
318
+ }
319
+
320
+ /** Shared card description styling — regular weight, kept clear of the artwork. */
321
+ export const DESCRIPTION_STYLE = {
322
+ color: '#666666',
323
+ fontSize: 38,
324
+ fontWeight: 400,
325
+ fontFamily: OG_SANS_FONT,
326
+ lineHeight: 1.3,
327
+ maxWidth: 700,
328
+ } as const
329
+
330
+ /** Shared "namespace/" eyebrow styling. */
331
+ export const EYEBROW_STYLE = {
332
+ color: '#FF569D',
333
+ fontSize: 38,
334
+ fontWeight: 400,
335
+ fontFamily: OG_SANS_FONT,
336
+ } as const
337
+
338
+ /** Shared card title styling; pass the fontSize from fitTitle(). */
339
+ export const TITLE_STYLE = {
340
+ color: '#111111',
341
+ fontFamily: OG_DISPLAY_FONT,
342
+ fontWeight: 400,
343
+ letterSpacing: '-0.02em',
344
+ lineHeight: 1.1,
345
+ } as const
346
+
347
+ /** Black card, logo top-left, content in the middle, footer at the bottom. */
348
+ export async function ogLayout(opts: {
349
+ children: ReactNode
350
+ footer?: ReactNode
351
+ backgroundImageUrl?: string
352
+ }): Promise<ReactNode> {
353
+ const logoSrc = await fetchImageAsDataUri(LOGO_URL, LOGO_FETCH)
354
+ const bgSrc = opts.backgroundImageUrl
355
+ ? await fetchImageAsDataUri(opts.backgroundImageUrl)
356
+ : null
357
+
358
+ return (
359
+ <div
360
+ style={{
361
+ background: '#fafafa',
362
+ width: '100%',
363
+ height: '100%',
364
+ display: 'flex',
365
+ position: 'relative',
366
+ fontFamily: OG_SANS_FONT,
367
+ }}
368
+ >
369
+ {bgSrc && (
370
+ <div
371
+ style={{
372
+ position: 'absolute',
373
+ top: 0,
374
+ right: 0,
375
+ width: '60%',
376
+ height: '100%',
377
+ display: 'flex',
378
+ }}
379
+ >
380
+ <img
381
+ src={bgSrc}
382
+ width={720}
383
+ height={630}
384
+ style={{ objectFit: 'cover', width: '100%', height: '100%' }}
385
+ />
386
+ <div
387
+ style={{
388
+ position: 'absolute',
389
+ top: 0,
390
+ left: 0,
391
+ width: '100%',
392
+ height: '100%',
393
+ background:
394
+ 'linear-gradient(to right, #fafafa 0%, #fafafacc 30%, #fafafa33 70%, transparent 100%)',
395
+ }}
396
+ />
397
+ </div>
398
+ )}
399
+ <div
400
+ style={{
401
+ position: 'relative',
402
+ width: '100%',
403
+ height: '100%',
404
+ display: 'flex',
405
+ flexDirection: 'column',
406
+ justifyContent: 'space-between',
407
+ padding: '48px 56px',
408
+ }}
409
+ >
410
+ <div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
411
+ {logoSrc && <img src={logoSrc} width={48} height={48} />}
412
+ <span
413
+ style={{
414
+ color: '#111111',
415
+ fontSize: 36,
416
+ fontWeight: 400,
417
+ fontFamily: 'Hack, monospace',
418
+ }}
419
+ >
420
+ inference shell
421
+ </span>
422
+ </div>
423
+ {opts.children}
424
+ {opts.footer && (
425
+ <div style={{ display: 'flex', alignItems: 'center' }}>{opts.footer}</div>
426
+ )}
427
+ </div>
428
+ </div>
429
+ )
430
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Canonical query strings for OG image routes.
3
+ *
4
+ * The cards are served with a public s-maxage, and the edge keys its cache on
5
+ * the full query string, so `?namespace=x&name=y&r=<random>` misses the cache
6
+ * every time and forces a fresh render (and card-image fetch). Routes that
7
+ * take parameters redirect any other spelling to the one canonical query:
8
+ * the known keys only, first value each, in a fixed order.
9
+ */
10
+
11
+ /**
12
+ * The canonical search string ("" or "?a=1&b=2") for these keys, or null when
13
+ * `search` already is it. Idempotent: the result maps to null.
14
+ */
15
+ export function canonicalOgSearch(search: string, keys: readonly string[]): string | null {
16
+ const params = new URLSearchParams(search)
17
+ const canonical = new URLSearchParams()
18
+ for (const key of keys) {
19
+ const value = params.get(key)
20
+ if (value !== null) canonical.set(key, value)
21
+ }
22
+ const query = canonical.toString()
23
+ const want = query ? `?${query}` : ''
24
+ const have = search === '?' ? '' : search
25
+ return have === want ? null : want
26
+ }
@@ -0,0 +1,71 @@
1
+ import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
2
+ import { cn } from "cn"
3
+ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
4
+
5
+ function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
6
+ return (
7
+ <AccordionPrimitive.Root
8
+ data-slot="accordion"
9
+ className={cn("flex w-full flex-col", className)}
10
+ {...props}
11
+ />
12
+ )
13
+ }
14
+
15
+ function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
16
+ return (
17
+ <AccordionPrimitive.Item
18
+ data-slot="accordion-item"
19
+ className={cn("not-last:border-b", className)}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ function AccordionTrigger({
26
+ className,
27
+ children,
28
+ ...props
29
+ }: AccordionPrimitive.Trigger.Props) {
30
+ return (
31
+ <AccordionPrimitive.Header className="flex">
32
+ <AccordionPrimitive.Trigger
33
+ data-slot="accordion-trigger"
34
+ className={cn(
35
+ "group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
36
+ className
37
+ )}
38
+ {...props}
39
+ >
40
+ {children}
41
+ <ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
42
+ <ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
43
+ </AccordionPrimitive.Trigger>
44
+ </AccordionPrimitive.Header>
45
+ )
46
+ }
47
+
48
+ function AccordionContent({
49
+ className,
50
+ children,
51
+ ...props
52
+ }: AccordionPrimitive.Panel.Props) {
53
+ return (
54
+ <AccordionPrimitive.Panel
55
+ data-slot="accordion-content"
56
+ className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
57
+ {...props}
58
+ >
59
+ <div
60
+ className={cn(
61
+ "h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
62
+ className
63
+ )}
64
+ >
65
+ {children}
66
+ </div>
67
+ </AccordionPrimitive.Panel>
68
+ )
69
+ }
70
+
71
+ export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }