@jant/core 0.6.9 → 0.6.11

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 (69) hide show
  1. package/dist/{app-C-jxWmAV.js → app-CpmficmQ.js} +912 -423
  2. package/dist/app-DqKkZenB.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-BhHHVvSY.css +2 -0
  5. package/dist/client/_assets/{client-DWy1LEEk.js → client-Dd9U383b.js} +1 -1
  6. package/dist/client/_assets/{client-auth-Blg-a5Ep.js → client-auth-DkpSdIDz.js} +101 -99
  7. package/dist/{export-C2DIB7mm.js → export-Ba7NJImL.js} +93 -93
  8. package/dist/{github-sync-7XQ5ZM6z.js → github-sync-BD4w2m8-.js} +2 -2
  9. package/dist/{github-sync-BEFCfLKK.js → github-sync-Cb4_6_i7.js} +1 -1
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +3 -0
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +9 -4
  16. package/src/client/components/compose-types.ts +4 -0
  17. package/src/client/components/jant-compose-editor.ts +111 -0
  18. package/src/client/components/jant-settings-general.ts +18 -3
  19. package/src/client/components/settings-types.ts +2 -0
  20. package/src/client/compose-bridge.ts +25 -8
  21. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
  22. package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
  23. package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
  24. package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
  25. package/src/client/tiptap/create-editor.ts +3 -0
  26. package/src/client/tiptap/extensions.ts +4 -0
  27. package/src/client/tiptap/inline-image-upload.ts +174 -50
  28. package/src/client/tiptap/link-toolbar.ts +63 -1
  29. package/src/client/tiptap/rehost-images.ts +104 -0
  30. package/src/i18n/locales/public/en.po +10 -0
  31. package/src/i18n/locales/public/en.ts +1 -1
  32. package/src/i18n/locales/public/zh-Hans.po +10 -0
  33. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  34. package/src/i18n/locales/public/zh-Hant.po +10 -0
  35. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  36. package/src/i18n/locales/settings/en.po +258 -18
  37. package/src/i18n/locales/settings/en.ts +1 -1
  38. package/src/i18n/locales/settings/zh-Hans.po +258 -18
  39. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  40. package/src/i18n/locales/settings/zh-Hant.po +258 -18
  41. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  42. package/src/lib/__tests__/feed.test.ts +5 -1
  43. package/src/lib/__tests__/upload-sideload.test.ts +78 -0
  44. package/src/lib/__tests__/url-fetch.test.ts +181 -0
  45. package/src/lib/feed.ts +6 -3
  46. package/src/lib/upload.ts +111 -0
  47. package/src/lib/url-fetch.ts +263 -0
  48. package/src/routes/api/__tests__/uploads.test.ts +63 -1
  49. package/src/routes/api/uploads.ts +52 -0
  50. package/src/routes/dash/settings.tsx +7 -2
  51. package/src/routes/feed/__tests__/feed.test.ts +58 -19
  52. package/src/routes/feed/feed.ts +37 -28
  53. package/src/routes/pages/featured.tsx +17 -0
  54. package/src/routes/pages/latest.tsx +25 -0
  55. package/src/services/__tests__/media.test.ts +168 -1
  56. package/src/services/media.ts +111 -0
  57. package/src/services/post.ts +1 -1
  58. package/src/styles/tokens.css +15 -0
  59. package/src/styles/ui.css +45 -2
  60. package/src/ui/__tests__/color-themes.test.ts +2 -2
  61. package/src/ui/color-themes.ts +32 -0
  62. package/src/ui/compose/ComposeDialog.tsx +16 -0
  63. package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
  64. package/src/ui/dash/settings/GeneralContent.tsx +16 -0
  65. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
  66. package/src/ui/layouts/BaseLayout.tsx +14 -2
  67. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +4 -4
  68. package/dist/app-DqHzOwL5.js +0 -6
  69. package/dist/client/_assets/client-CGf2m3qp.css +0 -2
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
2
- import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
3
3
  import { eq } from "drizzle-orm";
4
4
  import {
5
5
  createTestDatabase,
@@ -1546,3 +1546,170 @@ describe("MediaService", () => {
1546
1546
  });
1547
1547
  });
1548
1548
  });
1549
+
1550
+ /** Minimal valid PNG header (signature + IHDR with width 4, height 6). */
1551
+ function createPngBytes(): Uint8Array {
1552
+ return new Uint8Array([
1553
+ 0x89,
1554
+ 0x50,
1555
+ 0x4e,
1556
+ 0x47,
1557
+ 0x0d,
1558
+ 0x0a,
1559
+ 0x1a,
1560
+ 0x0a, // signature
1561
+ 0x00,
1562
+ 0x00,
1563
+ 0x00,
1564
+ 0x0d, // IHDR length (13)
1565
+ 0x49,
1566
+ 0x48,
1567
+ 0x44,
1568
+ 0x52, // "IHDR"
1569
+ 0x00,
1570
+ 0x00,
1571
+ 0x00,
1572
+ 0x04, // width = 4
1573
+ 0x00,
1574
+ 0x00,
1575
+ 0x00,
1576
+ 0x06, // height = 6
1577
+ 0x08,
1578
+ 0x06,
1579
+ 0x00,
1580
+ 0x00,
1581
+ 0x00, // bit depth, color type, ...
1582
+ ]);
1583
+ }
1584
+
1585
+ describe("MediaService.ingestFromUrl", () => {
1586
+ let db: Database;
1587
+ let mediaService: ReturnType<typeof createMediaService>;
1588
+
1589
+ beforeEach(() => {
1590
+ const testDb = createTestDatabase();
1591
+ db = testDb.db as unknown as Database;
1592
+ mediaService = createMediaService(db, DEFAULT_TEST_SITE_ID);
1593
+ });
1594
+
1595
+ afterEach(() => {
1596
+ vi.restoreAllMocks();
1597
+ });
1598
+
1599
+ const deps = () => ({
1600
+ storage: createMockStorage(),
1601
+ storageDriver: "local",
1602
+ maxFileSizeMB: 25,
1603
+ });
1604
+
1605
+ it("fetches a remote image, stores it, and creates a media row", async () => {
1606
+ const bytes = createPngBytes();
1607
+ vi.stubGlobal(
1608
+ "fetch",
1609
+ vi.fn(
1610
+ async () =>
1611
+ new Response(bytes, { headers: { "content-type": "image/png" } }),
1612
+ ),
1613
+ );
1614
+
1615
+ const d = deps();
1616
+ const media = await mediaService.ingestFromUrl(
1617
+ { url: "https://example.com/photo.png", alt: "A photo" },
1618
+ d,
1619
+ );
1620
+
1621
+ expect(media.mimeType).toBe("image/png");
1622
+ expect(media.mediaKind).toBe("image");
1623
+ expect(media.width).toBe(4);
1624
+ expect(media.height).toBe(6);
1625
+ expect(media.alt).toBe("A photo");
1626
+ expect(media.postId).toBeNull();
1627
+
1628
+ const stored = d.storage.files.get(media.storageKey);
1629
+ expect(stored).toBeDefined();
1630
+ expect(stored?.contentType).toBe("image/png");
1631
+ expect(stored?.contentDisposition).toBe("inline");
1632
+ });
1633
+
1634
+ it("stores SVG with attachment disposition (XSS-safe direct navigation)", async () => {
1635
+ const svg = new TextEncoder().encode(
1636
+ '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
1637
+ );
1638
+ vi.stubGlobal(
1639
+ "fetch",
1640
+ vi.fn(
1641
+ async () =>
1642
+ new Response(svg, {
1643
+ headers: { "content-type": "image/svg+xml" },
1644
+ }),
1645
+ ),
1646
+ );
1647
+
1648
+ const d = deps();
1649
+ const media = await mediaService.ingestFromUrl(
1650
+ { url: "https://example.com/icon.svg" },
1651
+ d,
1652
+ );
1653
+
1654
+ expect(media.mimeType).toBe("image/svg+xml");
1655
+ const stored = d.storage.files.get(media.storageKey);
1656
+ expect(stored?.contentDisposition).toBe("attachment");
1657
+ });
1658
+
1659
+ it("rejects content that isn't a real image (content-type spoofing)", async () => {
1660
+ const html = new TextEncoder().encode(
1661
+ "<!doctype html><script>alert(1)</script>",
1662
+ );
1663
+ vi.stubGlobal(
1664
+ "fetch",
1665
+ vi.fn(
1666
+ // Server lies: claims PNG, returns HTML.
1667
+ async () =>
1668
+ new Response(html, { headers: { "content-type": "image/png" } }),
1669
+ ),
1670
+ );
1671
+
1672
+ await expect(
1673
+ mediaService.ingestFromUrl(
1674
+ { url: "https://example.com/evil.png" },
1675
+ deps(),
1676
+ ),
1677
+ ).rejects.toThrow(/supported image/i);
1678
+ });
1679
+
1680
+ it("rejects an oversize image", async () => {
1681
+ const stream = new ReadableStream<Uint8Array>({
1682
+ start(controller) {
1683
+ controller.enqueue(new Uint8Array(2 * 1024 * 1024));
1684
+ controller.close();
1685
+ },
1686
+ });
1687
+ vi.stubGlobal(
1688
+ "fetch",
1689
+ vi.fn(
1690
+ async () =>
1691
+ new Response(stream, { headers: { "content-type": "image/png" } }),
1692
+ ),
1693
+ );
1694
+
1695
+ await expect(
1696
+ mediaService.ingestFromUrl(
1697
+ { url: "https://example.com/huge.png" },
1698
+ { ...deps(), maxFileSizeMB: 1 },
1699
+ ),
1700
+ ).rejects.toThrow(/too large/i);
1701
+ });
1702
+
1703
+ it("rejects a private/SSRF URL before fetching", async () => {
1704
+ const fetchMock = vi.fn();
1705
+ vi.stubGlobal("fetch", fetchMock);
1706
+
1707
+ await expect(
1708
+ mediaService.ingestFromUrl(
1709
+ { url: "http://169.254.169.254/latest/meta-data" },
1710
+ deps(),
1711
+ ),
1712
+ ).rejects.toThrow(/private address/i);
1713
+ expect(fetchMock).not.toHaveBeenCalled();
1714
+ });
1715
+ });
@@ -32,10 +32,18 @@ import { renderTiptapJson } from "../lib/tiptap-render.js";
32
32
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
33
33
  import {
34
34
  generateStorageKey,
35
+ imageExtensionForMimeType,
36
+ isAllowedSideloadImageType,
35
37
  SITE_ASSET_STORAGE_KEY_LIKE_PATTERN,
38
+ sniffImageMimeType,
36
39
  toMediaKind,
37
40
  validateUploadFileMetadata,
38
41
  } from "../lib/upload.js";
42
+ import {
43
+ IMAGE_DIMENSION_PEEK_BYTES,
44
+ parseImageDimensions,
45
+ } from "../lib/image-dimensions.js";
46
+ import { assertPublicHttpUrl, fetchImageBytes } from "../lib/url-fetch.js";
39
47
  import type {
40
48
  Media,
41
49
  MediaKind,
@@ -57,6 +65,26 @@ import type { HostedControlPlaneClient } from "../lib/hosted-control-plane.js";
57
65
 
58
66
  const DEFAULT_MEDIA_POSITION = "a0";
59
67
 
68
+ /**
69
+ * Derive a display filename from a remote image URL, falling back to
70
+ * `image.<ext>` when the path has no usable basename.
71
+ *
72
+ * @param url - The remote image URL
73
+ * @param ext - Extension to use when the URL path lacks one
74
+ * @returns A sanitized original filename
75
+ */
76
+ function remoteImageName(url: URL, ext: string): string {
77
+ let base: string;
78
+ try {
79
+ base = decodeURIComponent(url.pathname.split("/").pop() ?? "");
80
+ } catch {
81
+ base = url.pathname.split("/").pop() ?? "";
82
+ }
83
+ base = base.trim().replace(/[\r\n"\\]+/g, "");
84
+ if (!base) return `image.${ext}`;
85
+ return base.includes(".") ? base : `${base}.${ext}`;
86
+ }
87
+
60
88
  /**
61
89
  * Recycle-window length for deleted media storage objects. On delete we
62
90
  * hard-remove the DB row but defer deleting the underlying storage object
@@ -187,6 +215,12 @@ export interface TextAttachmentDeps {
187
215
  maxFileSizeMB: number;
188
216
  }
189
217
 
218
+ export interface IngestFromUrlDeps {
219
+ storage: StorageDriver;
220
+ storageDriver: string;
221
+ maxFileSizeMB: number;
222
+ }
223
+
190
224
  export interface MediaService {
191
225
  assertCanWriteBytes(additionalBytes: number): Promise<void>;
192
226
  getById(id: string): Promise<Media | null>;
@@ -195,6 +229,24 @@ export interface MediaService {
195
229
  getByPostIds(postIds: string[]): Promise<Map<string, Media[]>>;
196
230
  list(filters?: MediaFilters): Promise<Media[]>;
197
231
  create(data: CreateMediaData): Promise<Media>;
232
+ /**
233
+ * Fetch a remote image URL server-side and store it as the site's own media.
234
+ *
235
+ * Used to rehost images pasted from external articles: the server downloads
236
+ * the bytes (bypassing browser CORS), verifies they are a real image format,
237
+ * and stores them. The created row has `postId = null` like other compose
238
+ * uploads (reaped by the orphan sweep if the post is never published).
239
+ *
240
+ * @param input.url - The remote http(s) image URL
241
+ * @param input.alt - Optional alt text
242
+ * @param deps - Storage driver, provider name, and the max file size
243
+ * @returns The created media row
244
+ * @throws {ValidationError} For unsafe URLs, non-image content, or oversize files
245
+ */
246
+ ingestFromUrl(
247
+ input: { url: string; alt?: string },
248
+ deps: IngestFromUrlDeps,
249
+ ): Promise<Media>;
198
250
  /**
199
251
  * Validate media IDs: checks count limit and verifies all IDs exist in the database.
200
252
  * No-op when the array is empty.
@@ -788,6 +840,65 @@ export function createMediaService(
788
840
  return toMedia(result[0]!);
789
841
  },
790
842
 
843
+ async ingestFromUrl(input, deps) {
844
+ const url = assertPublicHttpUrl(input.url);
845
+ const maxBytes = deps.maxFileSizeMB * 1024 * 1024;
846
+
847
+ const { bytes } = await fetchImageBytes(url, {
848
+ maxBytes,
849
+ timeoutMs: 15000,
850
+ });
851
+
852
+ // Trust the bytes, not the server's content-type header: only store data
853
+ // we can positively identify as a supported image format. This blocks
854
+ // content-type spoofing (e.g. an HTML/script payload served as an image).
855
+ const sniffed = sniffImageMimeType(bytes);
856
+ const mimeType =
857
+ sniffed && isAllowedSideloadImageType(sniffed) ? sniffed : null;
858
+ if (!mimeType) {
859
+ throw new ValidationError(
860
+ "That URL didn't return a supported image. Try a different image.",
861
+ );
862
+ }
863
+
864
+ await assertCanWriteBytes(bytes.byteLength);
865
+
866
+ const dimensions = parseImageDimensions(
867
+ mimeType,
868
+ bytes.subarray(0, IMAGE_DIMENSION_PEEK_BYTES),
869
+ );
870
+ const ext = imageExtensionForMimeType(mimeType) ?? "bin";
871
+ const { id, filename, storageKey } = generateStorageKey(
872
+ siteId,
873
+ `image.${ext}`,
874
+ );
875
+ const originalName = remoteImageName(url, ext);
876
+
877
+ // SVG can carry scripts. Display still works via <img> (browsers disable
878
+ // scripting there), and attachment disposition makes direct navigation to
879
+ // the raw object download instead of render — neutralizing the XSS vector.
880
+ await deps.storage.put(storageKey, bytes, {
881
+ contentType: mimeType,
882
+ contentDisposition:
883
+ mimeType === "image/svg+xml" ? "attachment" : "inline",
884
+ cacheControl: "public, max-age=31536000, immutable",
885
+ });
886
+
887
+ return this.create({
888
+ id,
889
+ filename,
890
+ originalName,
891
+ mimeType,
892
+ size: bytes.byteLength,
893
+ storageKey,
894
+ provider: deps.storageDriver,
895
+ mediaKind: "image",
896
+ width: dimensions?.width,
897
+ height: dimensions?.height,
898
+ alt: input.alt?.trim() || undefined,
899
+ });
900
+ },
901
+
791
902
  async createTextAttachment(data, deps) {
792
903
  if (!deps.storage) {
793
904
  throw new ConfigurationError(
@@ -2483,7 +2483,7 @@ export function createPostService(
2483
2483
  .select()
2484
2484
  .from(posts)
2485
2485
  .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
2486
- .orderBy(posts.createdAt);
2486
+ .orderBy(posts.createdAt, posts.id);
2487
2487
 
2488
2488
  return hydratePosts(rows);
2489
2489
  },
@@ -381,6 +381,21 @@
381
381
  @media (max-width: 760px) {
382
382
  :root {
383
383
  --site-padding: 1.875rem;
384
+ }
385
+ }
386
+
387
+ /* Tufte two-column → single-column collapse.
388
+ *
389
+ * Below this width the post column is already narrowed to `min(100%, 35rem)`
390
+ * (preset.css) and there is no room for a 45% sidenote gutter, so all content
391
+ * goes full-width. Single images (MediaGallery getSingleVisualWidth) and link
392
+ * previews (.link-preview) read this token directly, so they collapse here too.
393
+ *
394
+ * Keep this breakpoint in sync with the post-column collapse (preset.css /
395
+ * ui.css feed-divider, both `max-width: 1024px`) and the sidenote float→inline
396
+ * collapse (ui.css). They are one layout switch; do not let them drift apart. */
397
+ @media (max-width: 1024px) {
398
+ :root {
384
399
  --layout-content-width: 100%;
385
400
  }
386
401
  }
package/src/styles/ui.css CHANGED
@@ -2237,7 +2237,7 @@
2237
2237
  }
2238
2238
 
2239
2239
  .post-header-block {
2240
- margin-bottom: 1rem;
2240
+ margin-bottom: 1.5rem;
2241
2241
  }
2242
2242
 
2243
2243
  .post-header-block-detail {
@@ -2433,7 +2433,12 @@
2433
2433
  display: none;
2434
2434
  }
2435
2435
 
2436
- @media (max-width: 760px) {
2436
+ /* Collapse sidenotes from floated margin notes to inline tap-to-toggle at the
2437
+ same width the post column drops to `min(100%, 35rem)` (preset.css) and the
2438
+ Tufte gutter disappears — see --layout-content-width in tokens.css. Below
2439
+ 1024px a floated `width: 50%; margin-right: -60%` note has no gutter to land
2440
+ in and overflows the right edge, so it must inline instead. */
2441
+ @media (max-width: 1024px) {
2437
2442
  label.margin-toggle:not(.sidenote-number) {
2438
2443
  display: inline;
2439
2444
  cursor: pointer;
@@ -11110,6 +11115,24 @@
11110
11115
  line-height: 1.28;
11111
11116
  }
11112
11117
 
11118
+ .theme-preview-accent {
11119
+ display: inline-flex;
11120
+ align-items: center;
11121
+ gap: 0.3rem;
11122
+ white-space: nowrap;
11123
+ color: var(--preview-muted-light);
11124
+ }
11125
+
11126
+ .theme-preview-swatch {
11127
+ flex: none;
11128
+ width: 0.62rem;
11129
+ height: 0.62rem;
11130
+ border-radius: 3px;
11131
+ background: var(--preview-primary-light);
11132
+ box-shadow: 0 0 0 1px
11133
+ color-mix(in srgb, var(--preview-fg-light) 18%, transparent);
11134
+ }
11135
+
11113
11136
  .theme-preview-meta {
11114
11137
  color: var(--preview-muted-light);
11115
11138
  line-height: 1.55;
@@ -11143,6 +11166,16 @@
11143
11166
  color: var(--preview-muted-dark);
11144
11167
  }
11145
11168
 
11169
+ .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-accent {
11170
+ color: var(--preview-muted-dark);
11171
+ }
11172
+
11173
+ .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-swatch {
11174
+ background: var(--preview-primary-dark);
11175
+ box-shadow: 0 0 0 1px
11176
+ color-mix(in srgb, var(--preview-fg-dark) 18%, transparent);
11177
+ }
11178
+
11146
11179
  .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-divider {
11147
11180
  border-color: var(--preview-border-dark);
11148
11181
  }
@@ -11671,6 +11704,16 @@
11671
11704
  color: var(--preview-muted-dark);
11672
11705
  }
11673
11706
 
11707
+ .theme-preview-panel[data-theme-preview-mode="auto"] .theme-preview-accent {
11708
+ color: var(--preview-muted-dark);
11709
+ }
11710
+
11711
+ .theme-preview-panel[data-theme-preview-mode="auto"] .theme-preview-swatch {
11712
+ background: var(--preview-primary-dark);
11713
+ box-shadow: 0 0 0 1px
11714
+ color-mix(in srgb, var(--preview-fg-dark) 18%, transparent);
11715
+ }
11716
+
11674
11717
  .theme-preview-panel[data-theme-preview-mode="auto"]
11675
11718
  .theme-preview-divider {
11676
11719
  border-color: var(--preview-border-dark);
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import { BUILTIN_COLOR_THEMES } from "../color-themes.js";
3
3
 
4
4
  describe("BUILTIN_COLOR_THEMES", () => {
5
- it("contains 13 themes", () => {
6
- expect(BUILTIN_COLOR_THEMES).toHaveLength(14);
5
+ it("contains 15 themes", () => {
6
+ expect(BUILTIN_COLOR_THEMES).toHaveLength(15);
7
7
  });
8
8
 
9
9
  it("keeps Tufte as the first theme", () => {
@@ -624,6 +624,38 @@ export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
624
624
  },
625
625
  }),
626
626
 
627
+ // Pure white with neutral grays — zero tint, clean and clinical
628
+ defineTheme({
629
+ id: "snow",
630
+ name: "Snow",
631
+ light: {
632
+ bg: "oklch(1 0 0)",
633
+ fg: "oklch(0.205 0 0)",
634
+ primary: "oklch(0.25 0 0)",
635
+ primaryFg: "oklch(0.99 0 0)",
636
+ siteAccent: "oklch(0.37 0 0)",
637
+ muted: "oklch(0.965 0 0)",
638
+ mutedFg: "oklch(0.5 0 0)",
639
+ border: "oklch(0.91 0 0)",
640
+ readingTitle: "oklch(0.17 0 0)",
641
+ readingHeading: "oklch(0.205 0 0)",
642
+ readingBody: "oklch(0.24 0 0)",
643
+ readingQuote: "oklch(0.4 0 0)",
644
+ dashBg: "oklch(0.975 0 0)",
645
+ },
646
+ dark: {
647
+ bg: "oklch(0.17 0 0)",
648
+ fg: "oklch(0.92 0 0)",
649
+ primary: "oklch(0.85 0 0)",
650
+ primaryFg: "oklch(0.17 0 0)",
651
+ siteAccent: "oklch(0.78 0 0)",
652
+ muted: "oklch(0.235 0 0)",
653
+ mutedFg: "oklch(0.65 0 0)",
654
+ border: "oklch(0.3 0 0)",
655
+ dashBg: "oklch(0.15 0 0)",
656
+ },
657
+ }),
658
+
627
659
  // Deep coffee brown — rich and grounded
628
660
  defineTheme({
629
661
  id: "espresso",
@@ -43,6 +43,22 @@ export const ComposeForm: FC<ComposeFormProps> = ({
43
43
  comment: "@context: Close compose dialog",
44
44
  }),
45
45
  ),
46
+ imageNotRehosted: i18n._(
47
+ msg({
48
+ message:
49
+ "An image couldn't be saved to your library — its original link was kept.",
50
+ comment:
51
+ "@context: Toast when a single pasted remote image couldn't be rehosted (e.g. blocked by the source's hotlink protection)",
52
+ }),
53
+ ),
54
+ imagesNotRehosted: i18n._(
55
+ msg({
56
+ message:
57
+ "{count} images couldn't be saved to your library — their original links were kept.",
58
+ comment:
59
+ "@context: Toast when several pasted remote images couldn't be rehosted; {count} is the number of images",
60
+ }),
61
+ ),
46
62
  note: i18n._(
47
63
  msg({
48
64
  message: "Note",