@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.
- package/dist/{app-C-jxWmAV.js → app-CpmficmQ.js} +912 -423
- package/dist/app-DqKkZenB.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BhHHVvSY.css +2 -0
- package/dist/client/_assets/{client-DWy1LEEk.js → client-Dd9U383b.js} +1 -1
- package/dist/client/_assets/{client-auth-Blg-a5Ep.js → client-auth-DkpSdIDz.js} +101 -99
- package/dist/{export-C2DIB7mm.js → export-Ba7NJImL.js} +93 -93
- package/dist/{github-sync-7XQ5ZM6z.js → github-sync-BD4w2m8-.js} +2 -2
- package/dist/{github-sync-BEFCfLKK.js → github-sync-Cb4_6_i7.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +62 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +3 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +9 -4
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-settings-general.ts +18 -3
- package/src/client/components/settings-types.ts +2 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +139 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/link-toolbar.ts +63 -1
- package/src/client/tiptap/rehost-images.ts +104 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +258 -18
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +258 -18
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +258 -18
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +5 -1
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/feed.ts +6 -3
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +7 -2
- package/src/routes/feed/__tests__/feed.test.ts +58 -19
- package/src/routes/feed/feed.ts +37 -28
- package/src/routes/pages/featured.tsx +17 -0
- package/src/routes/pages/latest.tsx +25 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/media.ts +111 -0
- package/src/services/post.ts +1 -1
- package/src/styles/tokens.css +15 -0
- package/src/styles/ui.css +45 -2
- package/src/ui/__tests__/color-themes.test.ts +2 -2
- package/src/ui/color-themes.ts +32 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
- package/src/ui/dash/settings/GeneralContent.tsx +16 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
- package/src/ui/layouts/BaseLayout.tsx +14 -2
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +4 -4
- package/dist/app-DqHzOwL5.js +0 -6
- 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
|
+
});
|
package/src/services/media.ts
CHANGED
|
@@ -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(
|
package/src/services/post.ts
CHANGED
|
@@ -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
|
},
|
package/src/styles/tokens.css
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
6
|
-
expect(BUILTIN_COLOR_THEMES).toHaveLength(
|
|
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", () => {
|
package/src/ui/color-themes.ts
CHANGED
|
@@ -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",
|