@jant/core 0.6.0 → 0.6.2

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 (52) hide show
  1. package/dist/app-CUZaVgsC.js +6 -0
  2. package/dist/{app-BIkkbVQk.js → app-Ct9c4zYF.js} +298 -59
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-Bp2IPjDe.js +275 -0
  5. package/dist/client/_assets/client-YVrRjAid.css +2 -0
  6. package/dist/client/_assets/{client-auth-D1jDQgbH.js → client-auth-C4hQWqH1.js} +4 -4
  7. package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
  8. package/dist/{export-Bbn86HmS.js → export-O2w3AsZX.js} +4 -4
  9. package/dist/{github-api-Bh0PH3zr.js → github-api-UD4u_7fa.js} +1 -1
  10. package/dist/{github-app-D0GvNnqp.js → github-app-DeX6Td1O.js} +1 -1
  11. package/dist/{github-sync-dXsiZa_e.js → github-sync-BUzIYouS.js} +3 -3
  12. package/dist/{github-sync-CBQPRZ8H.js → github-sync-D49RADci.js} +3 -3
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +6 -6
  15. package/dist/{url-umUptr5z.js → url-XF0GbKGO.js} +22 -1
  16. package/package.json +1 -1
  17. package/src/client/__tests__/image-processor.test.ts +64 -0
  18. package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
  19. package/src/client/components/jant-compose-editor.ts +2 -2
  20. package/src/client/components/jant-media-lightbox.ts +33 -5
  21. package/src/client/image-processor.ts +89 -30
  22. package/src/client/media-scroll-hint.ts +62 -9
  23. package/src/i18n/coverage.generated.ts +2 -2
  24. package/src/i18n/locales/settings/zh-Hans.po +24 -24
  25. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  26. package/src/i18n/locales/settings/zh-Hant.po +24 -24
  27. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  28. package/src/lib/__tests__/structured-data.test.ts +87 -0
  29. package/src/lib/post-display.ts +78 -1
  30. package/src/lib/render.tsx +28 -0
  31. package/src/lib/structured-data.ts +113 -0
  32. package/src/lib/url.ts +26 -0
  33. package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
  34. package/src/routes/api/internal/sites.ts +19 -0
  35. package/src/routes/pages/home.tsx +21 -1
  36. package/src/routes/pages/page.tsx +53 -2
  37. package/src/services/export-theme/assets/client-site.css +1 -1
  38. package/src/services/export-theme/assets/client-site.js +30 -29
  39. package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
  40. package/src/services/site-admin.ts +53 -1
  41. package/src/styles/site-media.css +70 -24
  42. package/src/styles/ui.css +24 -18
  43. package/src/ui/feed/ThreadPreview.tsx +0 -1
  44. package/src/ui/feed/__tests__/thread-preview.test.ts +0 -1
  45. package/src/ui/layouts/BaseLayout.tsx +110 -16
  46. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
  47. package/src/ui/pages/PostPage.tsx +0 -1
  48. package/src/ui/shared/MediaGallery.tsx +50 -7
  49. package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
  50. package/dist/app-Bcr5_wZI.js +0 -6
  51. package/dist/client/_assets/client-Bo7sKkAQ.js +0 -274
  52. package/dist/client/_assets/client-QHRvzZwk.css +0 -2
@@ -130,7 +130,6 @@ export const ThreadPreview: FC<ThreadPreviewProps> = ({
130
130
  {secondReplyItem}
131
131
  {gapItem}
132
132
  {penultimateItem}
133
- <div class="thread-context-fade" aria-hidden="true" />
134
133
  </div>
135
134
  <button
136
135
  type="button"
@@ -230,7 +230,6 @@ describe("getThreadPreviewState", () => {
230
230
  expect(html).toContain("thread-context-shell");
231
231
  expect(html).toContain("data-thread-context");
232
232
  expect(html).toContain("data-collapsed");
233
- expect(html).toContain("thread-context-fade");
234
233
  expect(html).toContain("data-thread-context-toggle");
235
234
  expect(html).toContain('aria-expanded="false"');
236
235
  expect(html).toMatch(/data-label-more="[^"]+"/);
@@ -19,7 +19,7 @@ import {
19
19
  } from "../../lib/asset-path.js";
20
20
  import { getJantIconHref } from "../../lib/jant-branding.js";
21
21
  import { getThemeBrowserColors, resolveBuiltinTheme } from "../../lib/theme.js";
22
- import { isFullUrl, toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
22
+ import { toAbsoluteAssetUrl, toPublicPath } from "../../lib/url.js";
23
23
  import {
24
24
  CLIENT_AUTH_JS_FILE,
25
25
  CLIENT_CJK_CSS_FILE,
@@ -52,6 +52,26 @@ export interface BaseLayoutProps {
52
52
  faviconUrl?: string;
53
53
  faviconVersion?: string;
54
54
  socialImageUrl?: string;
55
+ /**
56
+ * Alt text describing an explicitly provided `socialImageUrl`. Ignored when
57
+ * the social image falls back to the site avatar or the Jant default.
58
+ */
59
+ socialImageAlt?: string;
60
+ /** Pixel width of an explicitly provided `socialImageUrl`, when known. */
61
+ socialImageWidth?: number;
62
+ /** Pixel height of an explicitly provided `socialImageUrl`, when known. */
63
+ socialImageHeight?: number;
64
+ /**
65
+ * JSON-LD structured data object (or array of objects) rendered as a
66
+ * `<script type="application/ld+json">`. Skipped when the page is noindex.
67
+ */
68
+ jsonLd?: unknown;
69
+ /** Open Graph object type. Defaults to "website". */
70
+ ogType?: "website" | "article";
71
+ /** ISO 8601 publish time, rendered as `article:published_time` for articles. */
72
+ articlePublishedTime?: string;
73
+ /** ISO 8601 modified time, rendered as `article:modified_time` for articles. */
74
+ articleModifiedTime?: string;
55
75
  /**
56
76
  * Absolute canonical URL for the current page. Rendered as
57
77
  * `<link rel="canonical">` when set. Use on pages whose primary content is
@@ -75,6 +95,13 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
75
95
  faviconUrl,
76
96
  faviconVersion,
77
97
  socialImageUrl,
98
+ socialImageAlt,
99
+ socialImageWidth,
100
+ socialImageHeight,
101
+ jsonLd,
102
+ ogType,
103
+ articlePublishedTime,
104
+ articleModifiedTime,
78
105
  canonicalHref,
79
106
  noindex,
80
107
  isAuthenticated = false,
@@ -91,10 +118,13 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
91
118
 
92
119
  // Read favicon/noindex from appConfig when not provided as prop
93
120
  const appConfig = c ? c.get("appConfig") : undefined;
121
+ // Use `||` instead of `??` so empty strings (the unset state for
122
+ // `appConfig.siteAvatarUrl`) fall through to the Jant default; otherwise
123
+ // sites without a custom avatar render no og:image / twitter:image at all.
94
124
  const resolvedSocialImagePath =
95
- socialImageUrl ??
96
- faviconUrl ??
97
- appConfig?.siteAvatarUrl ??
125
+ socialImageUrl ||
126
+ faviconUrl ||
127
+ appConfig?.siteAvatarUrl ||
98
128
  getJantIconHref("socialImage", appConfig?.sitePathPrefix || "");
99
129
  const resolvedFaviconVersion =
100
130
  faviconVersion ?? (appConfig?.faviconVersion || undefined);
@@ -179,16 +209,34 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
179
209
  sitePathPrefix,
180
210
  )
181
211
  : toPublicPath("/apple-touch-icon.png", sitePathPrefix));
182
- const socialImageHref =
183
- resolvedSocialImagePath &&
184
- (isFullUrl(resolvedSocialImagePath) ||
185
- resolvedSocialImagePath.startsWith("//")
186
- ? resolvedSocialImagePath
187
- : toAbsoluteSiteUrl(
188
- resolvedSocialImagePath,
189
- appConfig?.siteUrl || "",
190
- sitePathPrefix,
191
- ));
212
+ const socialImageHref = resolvedSocialImagePath
213
+ ? toAbsoluteAssetUrl(
214
+ resolvedSocialImagePath,
215
+ appConfig?.siteUrl || "",
216
+ sitePathPrefix,
217
+ )
218
+ : "";
219
+ // Dimensions / alt only describe an explicitly provided social image. The
220
+ // fallbacks (site avatar, Jant default) are square branding marks, so they
221
+ // keep the small `summary` card and a generic site-name alt.
222
+ const hasExplicitSocialImage = Boolean(socialImageUrl);
223
+ const socialImageAltText = hasExplicitSocialImage
224
+ ? socialImageAlt
225
+ : siteName || undefined;
226
+ const socialImageWidthValue = hasExplicitSocialImage
227
+ ? socialImageWidth
228
+ : undefined;
229
+ const socialImageHeightValue = hasExplicitSocialImage
230
+ ? socialImageHeight
231
+ : undefined;
232
+ // `summary_large_image` only looks good for genuine landscape content; a
233
+ // portrait or square image gets center-cropped into a thin banner.
234
+ const useLargeTwitterCard =
235
+ hasExplicitSocialImage &&
236
+ socialImageWidthValue !== undefined &&
237
+ socialImageHeightValue !== undefined &&
238
+ socialImageWidthValue > socialImageHeightValue &&
239
+ socialImageWidthValue >= 300;
192
240
  const mainFeedHref = appConfig ? toPublicPath("/feed", sitePathPrefix) : null;
193
241
  const latestFeedHref = appConfig
194
242
  ? toPublicPath("/feed/latest", sitePathPrefix)
@@ -261,16 +309,46 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
261
309
  <title>{title}</title>
262
310
  {description && <meta name="description" content={description} />}
263
311
  <meta property="og:title" content={title} />
264
- <meta property="og:type" content="website" />
312
+ <meta property="og:type" content={ogType ?? "website"} />
313
+ {ogType === "article" && articlePublishedTime && (
314
+ <meta
315
+ property="article:published_time"
316
+ content={articlePublishedTime}
317
+ />
318
+ )}
319
+ {ogType === "article" && articleModifiedTime && (
320
+ <meta
321
+ property="article:modified_time"
322
+ content={articleModifiedTime}
323
+ />
324
+ )}
265
325
  {description && (
266
326
  <meta property="og:description" content={description} />
267
327
  )}
268
328
  {socialImageHref && (
269
329
  <meta property="og:image" content={socialImageHref} />
270
330
  )}
331
+ {socialImageHref && socialImageWidthValue !== undefined && (
332
+ <meta
333
+ property="og:image:width"
334
+ content={String(socialImageWidthValue)}
335
+ />
336
+ )}
337
+ {socialImageHref && socialImageHeightValue !== undefined && (
338
+ <meta
339
+ property="og:image:height"
340
+ content={String(socialImageHeightValue)}
341
+ />
342
+ )}
343
+ {socialImageHref && socialImageAltText && (
344
+ <meta property="og:image:alt" content={socialImageAltText} />
345
+ )}
271
346
  {siteName && <meta property="og:site_name" content={siteName} />}
272
347
  {currentUrl && <meta property="og:url" content={currentUrl} />}
273
- <meta name="twitter:card" content="summary" />
348
+ <meta
349
+ name="twitter:card"
350
+ content={useLargeTwitterCard ? "summary_large_image" : "summary"}
351
+ />
274
352
  <meta name="twitter:title" content={title} />
275
353
  {description && (
276
354
  <meta name="twitter:description" content={description} />
@@ -278,9 +356,25 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
278
356
  {socialImageHref && (
279
357
  <meta name="twitter:image" content={socialImageHref} />
280
358
  )}
359
+ {socialImageHref && socialImageAltText && (
360
+ <meta name="twitter:image:alt" content={socialImageAltText} />
361
+ )}
281
362
  {resolvedNoindex && (
282
363
  <meta name="robots" content="noindex, nofollow" />
283
364
  )}
365
+ {!resolvedNoindex && jsonLd != null && (
366
+ <script
367
+ type="application/ld+json"
368
+ // JSON.stringify output with `<` / `>` escaped to \u-sequences
369
+ // so a value containing `</script>` cannot break out of the tag.
370
+ // JSON parsers decode the escapes transparently.
371
+ dangerouslySetInnerHTML={{
372
+ __html: JSON.stringify(jsonLd)
373
+ .replace(/</g, "\\u003c")
374
+ .replace(/>/g, "\\u003e"),
375
+ }}
376
+ />
377
+ )}
284
378
  {canonicalHref && <link rel="canonical" href={canonicalHref} />}
285
379
  <link rel="icon" href={resolvedFaviconHref} sizes="16x16 32x32" />
286
380
  <link rel="apple-touch-icon" href={resolvedAppleTouchHref} />
@@ -12,6 +12,7 @@ function createContext(
12
12
  assetBasePath?: string;
13
13
  sitePathPrefix?: string;
14
14
  siteUrl?: string;
15
+ siteAvatarUrl?: string;
15
16
  themeMode?: "auto" | "light" | "dark";
16
17
  themeId?: string;
17
18
  defaultThemeId?: string;
@@ -22,6 +23,7 @@ function createContext(
22
23
  mainRssFeed,
23
24
  sitePathPrefix: overrides?.sitePathPrefix ?? "",
24
25
  siteUrl: overrides?.siteUrl ?? "https://example.com",
26
+ siteAvatarUrl: overrides?.siteAvatarUrl,
25
27
  siteLanguage: "en",
26
28
  noindex: false,
27
29
  customCSS: "",
@@ -101,6 +103,27 @@ describe("BaseLayout", () => {
101
103
  );
102
104
  });
103
105
 
106
+ it("falls back to the bundled social image when appConfig.siteAvatarUrl is an empty string", async () => {
107
+ // resolve-config initializes siteAvatarUrl to "" (not undefined) when no
108
+ // avatar is configured. Regression test: the fallback chain must skip
109
+ // empty strings so og:image / twitter:image are never blank.
110
+ const { BaseLayout } = await loadBaseLayout();
111
+ const html = renderToString(
112
+ BaseLayout({
113
+ title: "Jant",
114
+ c: createContext("featured", { siteAvatarUrl: "" }),
115
+ children: "Test",
116
+ }),
117
+ );
118
+
119
+ expect(html).toContain(
120
+ 'meta property="og:image" content="https://example.com/_/brand/assets/jant-social-preview.png"',
121
+ );
122
+ expect(html).toContain(
123
+ 'meta name="twitter:image" content="https://example.com/_/brand/assets/jant-social-preview.png"',
124
+ );
125
+ });
126
+
104
127
  it("uses an explicit social image when provided", async () => {
105
128
  const { BaseLayout } = await loadBaseLayout();
106
129
  const html = renderToString(
@@ -119,6 +142,129 @@ describe("BaseLayout", () => {
119
142
  );
120
143
  });
121
144
 
145
+ it("defaults og:type to website without article timestamps", async () => {
146
+ const { BaseLayout } = await loadBaseLayout();
147
+ const html = renderToString(
148
+ BaseLayout({
149
+ title: "Jant",
150
+ children: "Test",
151
+ }),
152
+ );
153
+
154
+ expect(html).toContain('meta property="og:type" content="website"');
155
+ expect(html).not.toContain("article:published_time");
156
+ expect(html).not.toContain("article:modified_time");
157
+ });
158
+
159
+ it("renders article og:type with published and modified timestamps", async () => {
160
+ const { BaseLayout } = await loadBaseLayout();
161
+ const html = renderToString(
162
+ BaseLayout({
163
+ title: "A post",
164
+ ogType: "article",
165
+ articlePublishedTime: "2026-01-02T03:04:05.000Z",
166
+ articleModifiedTime: "2026-03-04T05:06:07.000Z",
167
+ children: "Test",
168
+ }),
169
+ );
170
+
171
+ expect(html).toContain('meta property="og:type" content="article"');
172
+ expect(html).toContain(
173
+ 'meta property="article:published_time" content="2026-01-02T03:04:05.000Z"',
174
+ );
175
+ expect(html).toContain(
176
+ 'meta property="article:modified_time" content="2026-03-04T05:06:07.000Z"',
177
+ );
178
+ });
179
+
180
+ it("keeps the small twitter card and omits dimensions for the default social image", async () => {
181
+ const { BaseLayout } = await loadBaseLayout();
182
+ const html = renderToString(
183
+ BaseLayout({
184
+ title: "Jant",
185
+ children: "Test",
186
+ }),
187
+ );
188
+
189
+ expect(html).toContain('meta name="twitter:card" content="summary"');
190
+ expect(html).not.toContain('content="summary_large_image"');
191
+ expect(html).not.toContain("og:image:width");
192
+ });
193
+
194
+ it("renders dimensions, alt, and a large twitter card for a landscape post image", async () => {
195
+ const { BaseLayout } = await loadBaseLayout();
196
+ const html = renderToString(
197
+ BaseLayout({
198
+ title: "A post",
199
+ socialImageUrl: "https://cdn.example.com/photo.jpg",
200
+ socialImageWidth: 1600,
201
+ socialImageHeight: 900,
202
+ socialImageAlt: "A wide landscape photo",
203
+ children: "Test",
204
+ }),
205
+ );
206
+
207
+ expect(html).toContain('meta property="og:image:width" content="1600"');
208
+ expect(html).toContain('meta property="og:image:height" content="900"');
209
+ expect(html).toContain(
210
+ 'meta property="og:image:alt" content="A wide landscape photo"',
211
+ );
212
+ expect(html).toContain(
213
+ 'meta name="twitter:image:alt" content="A wide landscape photo"',
214
+ );
215
+ expect(html).toContain(
216
+ 'meta name="twitter:card" content="summary_large_image"',
217
+ );
218
+ });
219
+
220
+ it("keeps the small twitter card for a portrait post image", async () => {
221
+ const { BaseLayout } = await loadBaseLayout();
222
+ const html = renderToString(
223
+ BaseLayout({
224
+ title: "A post",
225
+ socialImageUrl: "https://cdn.example.com/tall.jpg",
226
+ socialImageWidth: 600,
227
+ socialImageHeight: 900,
228
+ children: "Test",
229
+ }),
230
+ );
231
+
232
+ expect(html).toContain('meta name="twitter:card" content="summary"');
233
+ expect(html).not.toContain('content="summary_large_image"');
234
+ });
235
+
236
+ it("renders JSON-LD structured data and escapes script-breaking characters", async () => {
237
+ const { BaseLayout } = await loadBaseLayout();
238
+ const html = renderToString(
239
+ BaseLayout({
240
+ title: "A post",
241
+ jsonLd: {
242
+ "@type": "BlogPosting",
243
+ headline: "Mind the </script> gap",
244
+ },
245
+ children: "Test",
246
+ }),
247
+ );
248
+
249
+ expect(html).toContain('<script type="application/ld+json">');
250
+ expect(html).toContain("\\u003c/script\\u003e");
251
+ expect(html).not.toContain("</script> gap");
252
+ });
253
+
254
+ it("skips JSON-LD when the page is noindex", async () => {
255
+ const { BaseLayout } = await loadBaseLayout();
256
+ const html = renderToString(
257
+ BaseLayout({
258
+ title: "A post",
259
+ noindex: true,
260
+ jsonLd: { "@type": "WebSite" },
261
+ children: "Test",
262
+ }),
263
+ );
264
+
265
+ expect(html).not.toContain("application/ld+json");
266
+ });
267
+
122
268
  it("exposes the main and alternate feed links without duplicating featured", async () => {
123
269
  const { BaseLayout } = await loadBaseLayout();
124
270
  const html = renderToString(
@@ -67,7 +67,6 @@ const ThreadDetail: FC<{ post: PostView; threadPosts: PostView[] }> = ({
67
67
  data-collapsed=""
68
68
  >
69
69
  {ancestors.map((tp) => renderThreadItem(tp, post.id))}
70
- <div class="thread-context-fade" aria-hidden="true" />
71
70
  </div>
72
71
  <button
73
72
  type="button"
@@ -289,12 +289,15 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
289
289
  ? JSON.stringify(lightboxItems)
290
290
  : undefined
291
291
  }
292
- class={`flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth snap-x snap-mandatory"}`}
292
+ class={`flex gap-2 ${singleVisual ? "" : "overflow-x-auto scroll-smooth"}`}
293
293
  style={
294
294
  singleVisual
295
295
  ? undefined
296
296
  : "scrollbar-width: none; -ms-overflow-style: none;"
297
297
  }
298
+ tabindex={singleVisual ? undefined : 0}
299
+ role={singleVisual ? undefined : "group"}
300
+ aria-label={singleVisual ? undefined : "Media gallery"}
298
301
  >
299
302
  {galleryItems.map((item) => {
300
303
  if (item._kind === "image") {
@@ -329,7 +332,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
329
332
  key={item.id}
330
333
  href={item.url}
331
334
  data-lightbox-index={item._lbIdx}
332
- class={`${singleVisual ? "" : "shrink-0 snap-start"} media-visual-frame`}
335
+ class={`${singleVisual ? "" : "shrink-0"} media-visual-frame`}
333
336
  style={{
334
337
  ...(singleVisual
335
338
  ? {
@@ -391,7 +394,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
391
394
  return (
392
395
  <div
393
396
  key={item.id}
394
- class={`${singleVisual ? "" : "shrink-0 snap-start"} media-video-wrap media-video-wrap-short`}
397
+ class={`${singleVisual ? "" : "shrink-0"} media-video-wrap media-video-wrap-short`}
395
398
  style={
396
399
  singleVisual
397
400
  ? {
@@ -464,7 +467,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
464
467
  key={item.id}
465
468
  href={item.url}
466
469
  data-lightbox-index={item._lbIdx}
467
- class={`${singleVisual ? "" : "shrink-0 snap-start"} media-video-wrap media-visual-frame`}
470
+ class={`${singleVisual ? "" : "shrink-0"} media-video-wrap media-visual-frame`}
468
471
  style={
469
472
  singleVisual
470
473
  ? {
@@ -502,7 +505,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
502
505
  return (
503
506
  <div
504
507
  key={item.id}
505
- class={`media-gallery-card media-audio-card shrink-0 snap-start${item.waveform ? " has-waveform" : ""}`}
508
+ class={`media-gallery-card media-audio-card shrink-0${item.waveform ? " has-waveform" : ""}`}
506
509
  style={{
507
510
  width: `${docCardWidth}px`,
508
511
  height: `${rowHeight}px`,
@@ -593,7 +596,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
593
596
  href={item.url}
594
597
  target="_blank"
595
598
  rel="noopener noreferrer"
596
- class="media-gallery-card shrink-0 snap-start"
599
+ class="media-gallery-card shrink-0"
597
600
  style={{
598
601
  width: `${docCardWidth}px`,
599
602
  height: `${rowHeight}px`,
@@ -627,7 +630,7 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
627
630
  ? `${postPermalink}/text/${item.id}`
628
631
  : undefined
629
632
  }
630
- class="media-gallery-card shrink-0 snap-start"
633
+ class="media-gallery-card shrink-0"
631
634
  style={{
632
635
  width: `${docCardWidth}px`,
633
636
  height: `${rowHeight}px`,
@@ -650,6 +653,46 @@ export const MediaGallery: FC<MediaGalleryProps> = ({
650
653
  );
651
654
  })}
652
655
  </div>
656
+ {!singleVisual && (
657
+ <>
658
+ <button
659
+ type="button"
660
+ class="media-gallery-nav media-gallery-nav-prev"
661
+ tabindex={-1}
662
+ aria-label="Scroll to previous media"
663
+ >
664
+ <svg
665
+ viewBox="0 0 24 24"
666
+ fill="none"
667
+ stroke="currentColor"
668
+ stroke-width="2.5"
669
+ stroke-linecap="round"
670
+ stroke-linejoin="round"
671
+ aria-hidden="true"
672
+ >
673
+ <path d="M15 18l-6-6 6-6" />
674
+ </svg>
675
+ </button>
676
+ <button
677
+ type="button"
678
+ class="media-gallery-nav media-gallery-nav-next"
679
+ tabindex={-1}
680
+ aria-label="Scroll to next media"
681
+ >
682
+ <svg
683
+ viewBox="0 0 24 24"
684
+ fill="none"
685
+ stroke="currentColor"
686
+ stroke-width="2.5"
687
+ stroke-linecap="round"
688
+ stroke-linejoin="round"
689
+ aria-hidden="true"
690
+ >
691
+ <path d="M9 18l6-6-6-6" />
692
+ </svg>
693
+ </button>
694
+ </>
695
+ )}
653
696
  </div>
654
697
  )}
655
698
  </>
@@ -108,4 +108,35 @@ describe("MediaGallery", () => {
108
108
  expect(html).not.toContain("media-video-play-overlay");
109
109
  expect(html).not.toContain("media-short-video-progress");
110
110
  });
111
+
112
+ it("adds scroll arrows and a focusable strip for multi-item galleries", () => {
113
+ const html = renderToString(
114
+ MediaGallery({
115
+ attachments: [
116
+ createMediaView({ id: "m-1", width: 1600, height: 900 }),
117
+ createMediaView({ id: "m-2", width: 1600, height: 900 }),
118
+ createMediaView({ id: "m-3", width: 1600, height: 900 }),
119
+ ],
120
+ }),
121
+ );
122
+
123
+ expect(html).toContain("media-gallery-nav-prev");
124
+ expect(html).toContain("media-gallery-nav-next");
125
+ expect(html).toContain('aria-label="Scroll to previous media"');
126
+ expect(html).toContain('aria-label="Scroll to next media"');
127
+ // The strip is a keyboard tab stop so Arrow keys can scroll it.
128
+ expect(html).toContain('tabindex="0"');
129
+ expect(html).toContain('role="group"');
130
+ });
131
+
132
+ it("omits scroll arrows and the tab stop for a single visual", () => {
133
+ const html = renderToString(
134
+ MediaGallery({
135
+ attachments: [createMediaView({ width: 1600, height: 900 })],
136
+ }),
137
+ );
138
+
139
+ expect(html).not.toContain("media-gallery-nav");
140
+ expect(html).not.toContain('tabindex="0"');
141
+ });
111
142
  });
@@ -1,6 +0,0 @@
1
- import "./url-umUptr5z.js";
2
- import { t as createApp } from "./app-BIkkbVQk.js";
3
- import "./export-Bbn86HmS.js";
4
- import "./env-C7e2Nlnt.js";
5
- import "./github-sync-CBQPRZ8H.js";
6
- export { createApp };