@valentinkolb/cloud 0.5.1 → 0.5.3
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/package.json +1 -1
- package/scripts/build.ts +32 -2
- package/src/_internal/define-app.ts +5 -14
- package/src/_internal/static-assets.ts +86 -0
- package/src/api/admin-core-settings.ts +22 -0
- package/src/services/notifications/index.ts +88 -0
- package/src/styles/global.css +7 -20
- package/src/styles/utilities-markdown-editor.css +2 -2
package/package.json
CHANGED
package/scripts/build.ts
CHANGED
|
@@ -21,11 +21,13 @@
|
|
|
21
21
|
* it ships a `scripts/build-extras.ts` that this script runs at the end.
|
|
22
22
|
*/
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
-
import { cp, mkdir, readdir, rm } from "node:fs/promises";
|
|
25
|
-
import { dirname, resolve } from "node:path";
|
|
24
|
+
import { cp, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
25
|
+
import { dirname, extname, resolve } from "node:path";
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
|
+
import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
|
|
27
28
|
import tailwind from "bun-plugin-tailwind";
|
|
28
29
|
import { Glob, CryptoHasher } from "bun";
|
|
30
|
+
import { promisify } from "node:util";
|
|
29
31
|
|
|
30
32
|
const appId = process.env.APP_ID;
|
|
31
33
|
if (!appId) throw new Error("APP_ID env var required");
|
|
@@ -47,6 +49,8 @@ if (!existsSync(appDir)) throw new Error(`Unknown app dir: ${appDir} (set APP_DI
|
|
|
47
49
|
|
|
48
50
|
const dist = resolve(root, "dist");
|
|
49
51
|
const distPublic = resolve(dist, "public");
|
|
52
|
+
const compressBrotli = promisify(brotliCompress);
|
|
53
|
+
const compressGzip = promisify(gzip);
|
|
50
54
|
|
|
51
55
|
await rm(dist, { recursive: true, force: true });
|
|
52
56
|
await mkdir(distPublic, { recursive: true });
|
|
@@ -59,6 +63,29 @@ const islandId = (file: string): string => {
|
|
|
59
63
|
return new CryptoHasher("md5").update(rel).digest("hex").slice(0, 12);
|
|
60
64
|
};
|
|
61
65
|
|
|
66
|
+
const compressibleExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".svg", ".txt", ".xml"]);
|
|
67
|
+
|
|
68
|
+
async function precompressDistAssets(dir: string): Promise<void> {
|
|
69
|
+
if (!existsSync(dir)) return;
|
|
70
|
+
|
|
71
|
+
for await (const file of new Glob("**/*").scan({ cwd: dir, absolute: true, onlyFiles: true })) {
|
|
72
|
+
if (file.endsWith(".br") || file.endsWith(".gz")) continue;
|
|
73
|
+
if (!compressibleExtensions.has(extname(file))) continue;
|
|
74
|
+
|
|
75
|
+
const source = await readFile(file);
|
|
76
|
+
const [br, gz] = await Promise.all([
|
|
77
|
+
compressBrotli(source, {
|
|
78
|
+
params: {
|
|
79
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: zlibConstants.BROTLI_MAX_QUALITY,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
compressGzip(source, { level: zlibConstants.Z_BEST_COMPRESSION }),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
await Promise.all([writeFile(`${file}.br`, br), writeFile(`${file}.gz`, gz)]);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
// Register the app's SSR plugin (Solid JSX transform + island bundler).
|
|
63
90
|
// In the monorepo this resolves via `packages/<id>/src/config`; in standalone
|
|
64
91
|
// it resolves via the appDir path (because the script's relative imports
|
|
@@ -133,4 +160,7 @@ if (existsSync(extras)) {
|
|
|
133
160
|
await import(extras);
|
|
134
161
|
}
|
|
135
162
|
|
|
163
|
+
await precompressDistAssets(resolve(dist, "public"));
|
|
164
|
+
await precompressDistAssets(resolve(dist, "_ssr"));
|
|
165
|
+
|
|
136
166
|
console.log(`Built ${appId} → ${dist}`);
|
|
@@ -9,7 +9,6 @@ import type { SsrConfig } from "@valentinkolb/ssr";
|
|
|
9
9
|
import { createConfig as createSsrConfig } from "@valentinkolb/ssr";
|
|
10
10
|
import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
|
|
11
11
|
import { Hono } from "hono";
|
|
12
|
-
import { serveStatic } from "hono/bun";
|
|
13
12
|
import { generateSpecs } from "hono-openapi";
|
|
14
13
|
import type { AppCapabilities, AppLifecycle, AppMeta, AppSearchContext, CloudContext } from "../contracts/app";
|
|
15
14
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
@@ -23,6 +22,7 @@ import { createSettingsAPI, type SettingsAPI } from "../services/settings/api";
|
|
|
23
22
|
import { registerSettings, type SettingDef } from "../services/settings/defaults";
|
|
24
23
|
import { createHeartbeat } from "./heartbeat";
|
|
25
24
|
import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
|
|
25
|
+
import { servePublicAsset } from "./static-assets";
|
|
26
26
|
|
|
27
27
|
/** Cache-busting version stamp — changes on every server start / rebuild. */
|
|
28
28
|
const v = Date.now();
|
|
@@ -252,6 +252,9 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
|
|
|
252
252
|
<meta name="mobile-web-app-capable" content="yes">
|
|
253
253
|
<link rel="icon" href="/branding/favicon">
|
|
254
254
|
<style data-cloud-css-layers>@layer theme, base, components, utilities;</style>
|
|
255
|
+
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
256
|
+
<link rel="stylesheet" href="/public/fonts.css?v=${v}">
|
|
257
|
+
<link rel="stylesheet" href="/public/tabler-icons.css?v=${v}">
|
|
255
258
|
<link rel="stylesheet" href="/public/${opts.id}/app.css?v=${v}">
|
|
256
259
|
<link rel="stylesheet" href="/public/global.css?v=${v}">
|
|
257
260
|
<script>${themeBootstrapScript}</script>
|
|
@@ -349,19 +352,7 @@ export const defineApp = <const S extends AppSettingsMap = {}>(opts: AppOptions<
|
|
|
349
352
|
|
|
350
353
|
const server = new Hono()
|
|
351
354
|
.route(ssrMountPath, routes(config))
|
|
352
|
-
.
|
|
353
|
-
"/public/*",
|
|
354
|
-
serveStatic({
|
|
355
|
-
root: "./",
|
|
356
|
-
onFound: (_path, c) => {
|
|
357
|
-
c.header("Cache-Control", isDevelopment ? "no-store" : "public, max-age=31536000, immutable");
|
|
358
|
-
},
|
|
359
|
-
}),
|
|
360
|
-
)
|
|
361
|
-
// serveStatic calls next() on miss — terminate /public/* here so a
|
|
362
|
-
// missing asset is a clean 404 instead of falling through to the app
|
|
363
|
-
// fetch (which might render an HTML page for the missing path).
|
|
364
|
-
.all("/public/*", (c) => c.notFound());
|
|
355
|
+
.all("/public/*", servePublicAsset(isDevelopment));
|
|
365
356
|
|
|
366
357
|
if (startOpts.capabilities?.search) {
|
|
367
358
|
const searchRun = startOpts.capabilities.search.run;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { resolve, sep } from "node:path";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
|
|
4
|
+
const publicRoot = resolve(process.cwd(), "public");
|
|
5
|
+
|
|
6
|
+
function decodePathname(pathname: string): string | null {
|
|
7
|
+
try {
|
|
8
|
+
return decodeURIComponent(pathname);
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function resolvePublicAsset(pathname: string): string | null {
|
|
15
|
+
const decoded = decodePathname(pathname);
|
|
16
|
+
if (!decoded?.startsWith("/public/")) return null;
|
|
17
|
+
|
|
18
|
+
const path = resolve(process.cwd(), decoded.slice(1));
|
|
19
|
+
if (path !== publicRoot && !path.startsWith(`${publicRoot}${sep}`)) return null;
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolean {
|
|
24
|
+
if (!header) return false;
|
|
25
|
+
|
|
26
|
+
return header.split(",").some((part) => {
|
|
27
|
+
const [name, ...params] = part.trim().split(";");
|
|
28
|
+
if (name?.trim().toLowerCase() !== encoding) return false;
|
|
29
|
+
return !params.some((param) => {
|
|
30
|
+
const [key, value] = param.trim().split("=");
|
|
31
|
+
return key?.toLowerCase() === "q" && Number(value) === 0;
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
|
|
37
|
+
const suffix = encoding === "br" ? ".br" : ".gz";
|
|
38
|
+
const file = Bun.file(`${path}${suffix}`);
|
|
39
|
+
return (await file.exists()) ? file : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function servePublicAsset(isDevelopment: boolean) {
|
|
43
|
+
return async (c: Context) => {
|
|
44
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD") {
|
|
45
|
+
c.header("Allow", "GET, HEAD");
|
|
46
|
+
return c.text("Method Not Allowed", 405);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const path = resolvePublicAsset(new URL(c.req.url).pathname);
|
|
50
|
+
if (!path) return c.notFound();
|
|
51
|
+
|
|
52
|
+
const sourceFile = Bun.file(path);
|
|
53
|
+
if (!(await sourceFile.exists())) return c.notFound();
|
|
54
|
+
|
|
55
|
+
const acceptEncoding = c.req.header("Accept-Encoding") ?? null;
|
|
56
|
+
let selected = sourceFile;
|
|
57
|
+
let selectedEncoding: "br" | "gzip" | null = null;
|
|
58
|
+
|
|
59
|
+
if (acceptsEncoding(acceptEncoding, "br")) {
|
|
60
|
+
const br = await encodedFile(path, "br");
|
|
61
|
+
if (br) {
|
|
62
|
+
selected = br;
|
|
63
|
+
selectedEncoding = "br";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!selectedEncoding && acceptsEncoding(acceptEncoding, "gzip")) {
|
|
68
|
+
const gz = await encodedFile(path, "gzip");
|
|
69
|
+
if (gz) {
|
|
70
|
+
selected = gz;
|
|
71
|
+
selectedEncoding = "gzip";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const headers = new Headers({
|
|
76
|
+
"Cache-Control": isDevelopment ? "no-store" : "public, max-age=31536000, immutable",
|
|
77
|
+
"Content-Length": String(selected.size),
|
|
78
|
+
"Content-Type": sourceFile.type || "application/octet-stream",
|
|
79
|
+
Vary: "Accept-Encoding",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
|
|
83
|
+
|
|
84
|
+
return new Response(c.req.method === "HEAD" ? null : selected, { headers });
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -11,10 +11,14 @@ import { z } from "zod";
|
|
|
11
11
|
import { listApps } from "../_internal/registry";
|
|
12
12
|
import { auth, v, type AuthContext } from "../server";
|
|
13
13
|
import { settingsDeleteLegacyKeys, settingsListLegacyKeys } from "../services";
|
|
14
|
+
import { sendEmail } from "../services/notifications/email";
|
|
14
15
|
import * as settings from "../services/settings";
|
|
15
16
|
import { SETTINGS_MAP } from "../services/settings/defaults";
|
|
16
17
|
|
|
17
18
|
const BulkUpdateSchema = z.record(z.string(), z.unknown());
|
|
19
|
+
const TestEmailSchema = z.object({
|
|
20
|
+
recipient: z.email(),
|
|
21
|
+
});
|
|
18
22
|
|
|
19
23
|
type FieldErrors = Record<string, string>;
|
|
20
24
|
|
|
@@ -28,6 +32,24 @@ const app = new Hono<AuthContext>()
|
|
|
28
32
|
.delete("/legacy", auth.requireRole("admin"), async (c) => {
|
|
29
33
|
return c.json(await settingsDeleteLegacyKeys(await liveSettingKeys()));
|
|
30
34
|
})
|
|
35
|
+
.post("/test-email", auth.requireRole("admin"), v("json", TestEmailSchema), async (c) => {
|
|
36
|
+
const { recipient } = c.req.valid("json");
|
|
37
|
+
const sentAt = new Date().toISOString();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await sendEmail(recipient, "Cloud test email", {
|
|
41
|
+
rawHtml: `
|
|
42
|
+
<p>This is a test email from Cloud.</p>
|
|
43
|
+
<p>If you received this message, SMTP delivery is configured correctly.</p>
|
|
44
|
+
<p style="margin-top:24px;color:#71717a;font-size:12px;">Sent at ${sentAt}</p>
|
|
45
|
+
`,
|
|
46
|
+
});
|
|
47
|
+
return c.json({ ok: true });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
const message = error instanceof Error ? error.message : "Failed to send test email";
|
|
50
|
+
return c.json({ message }, 500);
|
|
51
|
+
}
|
|
52
|
+
})
|
|
31
53
|
.put(
|
|
32
54
|
"/",
|
|
33
55
|
auth.requireRole("admin"),
|
|
@@ -9,6 +9,11 @@ const log = logger("notifications");
|
|
|
9
9
|
export type NotificationType = "email";
|
|
10
10
|
export type NotificationStatus = "sent" | "pending" | "error";
|
|
11
11
|
export type NotificationStatusSummary = Record<NotificationStatus, number>;
|
|
12
|
+
export type NotificationSearchSummary = NotificationStatusSummary & {
|
|
13
|
+
total: number;
|
|
14
|
+
system: number;
|
|
15
|
+
latestCreatedAt: Date | null;
|
|
16
|
+
};
|
|
12
17
|
|
|
13
18
|
/**
|
|
14
19
|
* Computes notification delivery status from sent/error timestamps.
|
|
@@ -63,6 +68,15 @@ const emptyStatusSummary = (): NotificationStatusSummary => ({
|
|
|
63
68
|
error: 0,
|
|
64
69
|
});
|
|
65
70
|
|
|
71
|
+
const emptySearchSummary = (): NotificationSearchSummary => ({
|
|
72
|
+
total: 0,
|
|
73
|
+
sent: 0,
|
|
74
|
+
pending: 0,
|
|
75
|
+
error: 0,
|
|
76
|
+
system: 0,
|
|
77
|
+
latestCreatedAt: null,
|
|
78
|
+
});
|
|
79
|
+
|
|
66
80
|
type DbNotificationRow = {
|
|
67
81
|
id: string;
|
|
68
82
|
type: NotificationType;
|
|
@@ -316,6 +330,79 @@ export const getStatusSummary = async (options?: {
|
|
|
316
330
|
return summary;
|
|
317
331
|
};
|
|
318
332
|
|
|
333
|
+
/**
|
|
334
|
+
* Count statuses across all entries matching a search term in the current access scope.
|
|
335
|
+
*/
|
|
336
|
+
export const getSearchSummary = async (options: {
|
|
337
|
+
search: string;
|
|
338
|
+
sentBy?: string;
|
|
339
|
+
isAdmin?: boolean;
|
|
340
|
+
}): Promise<NotificationSearchSummary> => {
|
|
341
|
+
const { sentBy, isAdmin } = options;
|
|
342
|
+
const search = options.search.trim();
|
|
343
|
+
if (!search) return emptySearchSummary();
|
|
344
|
+
|
|
345
|
+
const searchPattern = `%${escapeLikePattern(search)}%`;
|
|
346
|
+
let rows: Array<{
|
|
347
|
+
total: number | string;
|
|
348
|
+
sent: number | string;
|
|
349
|
+
pending: number | string;
|
|
350
|
+
error: number | string;
|
|
351
|
+
system: number | string;
|
|
352
|
+
latest_created_at: Date | null;
|
|
353
|
+
}> = [];
|
|
354
|
+
|
|
355
|
+
if (isAdmin) {
|
|
356
|
+
rows = await sql`
|
|
357
|
+
SELECT
|
|
358
|
+
COUNT(*)::int AS total,
|
|
359
|
+
COUNT(*) FILTER (WHERE sent_at IS NOT NULL)::int AS sent,
|
|
360
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NULL)::int AS pending,
|
|
361
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NOT NULL)::int AS error,
|
|
362
|
+
COUNT(*) FILTER (WHERE sent_by IS NULL)::int AS system,
|
|
363
|
+
MAX(created_at) AS latest_created_at
|
|
364
|
+
FROM notifications.messages
|
|
365
|
+
WHERE subject ILIKE ${searchPattern} ESCAPE '\'
|
|
366
|
+
OR content ILIKE ${searchPattern} ESCAPE '\'
|
|
367
|
+
OR recipient ILIKE ${searchPattern} ESCAPE '\'
|
|
368
|
+
`;
|
|
369
|
+
} else if (sentBy) {
|
|
370
|
+
rows = await sql`
|
|
371
|
+
SELECT
|
|
372
|
+
COUNT(*)::int AS total,
|
|
373
|
+
COUNT(*) FILTER (WHERE sent_at IS NOT NULL)::int AS sent,
|
|
374
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NULL)::int AS pending,
|
|
375
|
+
COUNT(*) FILTER (WHERE sent_at IS NULL AND error IS NOT NULL)::int AS error,
|
|
376
|
+
COUNT(*) FILTER (WHERE sent_by IS NULL)::int AS system,
|
|
377
|
+
MAX(created_at) AS latest_created_at
|
|
378
|
+
FROM notifications.messages
|
|
379
|
+
WHERE sent_by = ${sentBy}
|
|
380
|
+
AND (
|
|
381
|
+
subject ILIKE ${searchPattern} ESCAPE '\'
|
|
382
|
+
OR content ILIKE ${searchPattern} ESCAPE '\'
|
|
383
|
+
OR recipient ILIKE ${searchPattern} ESCAPE '\'
|
|
384
|
+
)
|
|
385
|
+
`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const row = rows[0];
|
|
389
|
+
if (!row) return emptySearchSummary();
|
|
390
|
+
|
|
391
|
+
const toNumber = (value: number | string) => {
|
|
392
|
+
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : value;
|
|
393
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
total: toNumber(row.total),
|
|
398
|
+
sent: toNumber(row.sent),
|
|
399
|
+
pending: toNumber(row.pending),
|
|
400
|
+
error: toNumber(row.error),
|
|
401
|
+
system: toNumber(row.system),
|
|
402
|
+
latestCreatedAt: row.latest_created_at,
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
|
|
319
406
|
/**
|
|
320
407
|
* Get a single notification by ID.
|
|
321
408
|
*/
|
|
@@ -481,4 +568,5 @@ export const notifications = {
|
|
|
481
568
|
getPendingSystemCount,
|
|
482
569
|
sendAllPendingSystem,
|
|
483
570
|
getStatusSummary,
|
|
571
|
+
getSearchSummary,
|
|
484
572
|
};
|
package/src/styles/global.css
CHANGED
|
@@ -9,25 +9,12 @@
|
|
|
9
9
|
@custom-variant dark (&:where(.dark, .dark *));
|
|
10
10
|
@custom-variant light (html:not(.dark) &);
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@import "@fontsource/ibm-plex-sans/400.css";
|
|
19
|
-
@import "@fontsource/ibm-plex-sans/400-italic.css";
|
|
20
|
-
@import "@fontsource/ibm-plex-sans/500.css";
|
|
21
|
-
@import "@fontsource/ibm-plex-sans/600.css";
|
|
22
|
-
@import "@fontsource/ibm-plex-sans/700.css";
|
|
23
|
-
@import "@fontsource/ibm-plex-mono/400.css";
|
|
24
|
-
@import "@fontsource/ibm-plex-mono/400-italic.css";
|
|
25
|
-
@import "@fontsource/ibm-plex-mono/500.css";
|
|
26
|
-
@import "@fontsource/ibm-plex-mono/600.css";
|
|
27
|
-
@import "@fontsource/ibm-plex-sans-condensed/400.css";
|
|
28
|
-
@import "@fontsource/ibm-plex-sans-condensed/500.css";
|
|
29
|
-
@import "@fontsource/ibm-plex-sans-condensed/600.css";
|
|
30
|
-
@import "@fontsource/ibm-plex-sans-condensed/700.css";
|
|
12
|
+
/* IBM Plex font-face rules are emitted as `/public/fonts.css` during the core
|
|
13
|
+
build. Keeping them out of global.css avoids Bun inlining the font binaries
|
|
14
|
+
as base64 into the render-blocking stylesheet. */
|
|
15
|
+
/* Tabler icon font rules are emitted as `/public/tabler-icons.css` during the
|
|
16
|
+
core build and preloaded by the shared HTML template to reduce icon flicker
|
|
17
|
+
without changing icon layout semantics. */
|
|
31
18
|
|
|
32
19
|
@import "./tokens.css";
|
|
33
20
|
@import "./utilities-buttons.css";
|
|
@@ -174,7 +161,7 @@
|
|
|
174
161
|
beat stdlib's one-class embedded <style> the same way the dark-mode
|
|
175
162
|
overrides above do. font-family is otherwise unset by stdlib, so the
|
|
176
163
|
chart would inherit the app sans — we pin the numeric labels to the
|
|
177
|
-
platform mono stack (IBM Plex Mono via
|
|
164
|
+
platform mono stack (IBM Plex Mono via `/public/fonts.css`). */
|
|
178
165
|
.stdlib-chart .stdlib-chart-tick-label,
|
|
179
166
|
.stdlib-chart .stdlib-chart-bar-value,
|
|
180
167
|
.stdlib-chart .stdlib-chart-axis-label {
|
|
@@ -146,8 +146,8 @@
|
|
|
146
146
|
4. `text-rendering: geometricPrecision` — disables kerning so
|
|
147
147
|
even non-monospace fallback fonts don't shift glyph X
|
|
148
148
|
positions across layers. */
|
|
149
|
-
/* IBM Plex Mono is shipped via
|
|
150
|
-
+ 600 weights
|
|
149
|
+
/* IBM Plex Mono is shipped via `/public/fonts.css` (regular + italic +
|
|
150
|
+
500 + 600 weights). Pinning the font here means the
|
|
151
151
|
editor looks identical across OSes and we control the italic
|
|
152
152
|
variant precisely. The bold/heading "weight" is faked via
|
|
153
153
|
text-shadow further down so we never actually request a weight
|