Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

Altug's feedback: visibility, appearance, Add people, avatars, highlighting and a visual pass (#23)

Repositories and packages are public or private (internal becomes private). An Appearance setting, one Add people flow for orgs and repositories, profile pictures for people and orgs, Shiki syntax highlighting, and a visual pass over every page in light and dark. Only owners can invite owners, and the last owner can't be demoted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QMFviVKD61QaDPxdrh7C3f
Canberk Demirci authored · GitHub committed signed1 parent 7800b99commit d10e3db84a384e2f2350b2061c102f68166aed50Browse files
42 files changed+2487−389
Files (42)
modified.github/workflows/live-test.yml+48−0View file
@@ -46,6 +46,54 @@
4646 echo "OWNER=$login" >> "$GITHUB_ENV"
4747 echo "testing as $login in $login/$REPO"
4848
49+ - name: Profile picture upload (WASM resize in the Worker)
50+ if: steps.token.outputs.skip != 'true'
51+ run: |
52+ set -euo pipefail
53+ av="$DIFFINERY/api/avatars/$OWNER"
54+ # Keep the account's current picture, if it has one, to put it back afterwards.
55+ had=$(curl -fsS -o before.webp -w '%{content_type}' "$av?s=256&v=1")
56+ restore() {
57+ if [ "$had" = image/webp ]; then
58+ curl -fsS -X PUT "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" -H "content-type: image/webp" --data-binary @before.webp | jq -r '"restored the previous picture: \(.avatar_url)"'
59+ else
60+ curl -fsS -o /dev/null -X DELETE "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN"
61+ fi
62+ }
63+ trap restore EXIT
64+ # A 600×400 PNG gradient; the server crops it to a square and makes small WebPs.
65+ python3 - <<'EOF'
66+ import struct, zlib
67+ w, h = 600, 400
68+ rows = b"".join(b"\0" + bytes(c for x in range(w) for c in (x * 255 // w, y * 255 // h, 128)) for y in range(h))
69+ chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d))
70+ open("avatar.png", "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b""))
71+ EOF
72+ url=$(curl -fsS -X PUT "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" -H "content-type: image/png" --data-binary @avatar.png | jq -r .avatar_url)
73+ echo "uploaded: $url"
74+ v=${url##*v=}
75+ type=$(curl -fsS -o small.webp -w '%{content_type}' "$av?s=64&v=$v")
76+ [ "$type" = image/webp ] || { echo "::error::expected image/webp, got $type"; exit 1; }
77+ python3 - <<'EOF'
78+ import struct, sys
79+ b = open("small.webp", "rb").read()
80+ ok = b[:4] == b"RIFF" and b[8:12] == b"WEBP"
81+ if b[12:16] == b"VP8 ":
82+ size = (struct.unpack("<H", b[26:28])[0] & 0x3FFF, struct.unpack("<H", b[28:30])[0] & 0x3FFF)
83+ elif b[12:16] == b"VP8X":
84+ size = (int.from_bytes(b[24:27], "little") + 1, int.from_bytes(b[27:30], "little") + 1)
85+ else:
86+ size = None
87+ print(f"64 px avatar: {len(b)} bytes, {size}")
88+ if not ok or size != (64, 64) or len(b) > 5000:
89+ print("::error::the 64 px avatar is not a 64x64 WebP under 5 KB"); sys.exit(1)
90+ EOF
91+ curl -fsS -X DELETE "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" >/dev/null
92+ # A `v` that is not current bypasses the edge cache, so this sees the removal from any colo.
93+ type=$(curl -fsS -o /dev/null -w '%{content_type}' "$av?s=64&v=1")
94+ [[ "$type" == image/svg+xml* ]] || { echo "::error::after removal expected the initials SVG, got $type"; exit 1; }
95+ echo "removed: initials again ($type)"
96+
4997 - name: Email sending is configured
5098 if: steps.token.outputs.skip != 'true'
5199 run: |
modifiedREADME.md+1−1View file
@@ -232,7 +232,7 @@
232232 macOS, GPUs or private networks.
233233 - Reusable workflows: `jobs.<id>.uses: ./.github/workflows/x.yml` from the same
234234 commit, or `owner/repo/.github/workflows/x.yml@ref` from another repository
235- on this instance (public or internal ones, or private ones with the same
235+ on this instance (public ones, or private ones with the same
236236 owner). `with`, `secrets` (a mapping or `inherit`), `on.workflow_call`
237237 outputs and nesting up to four levels deep all work. A matrix on the calling
238238 job isn't supported yet.
modifiedapps/cli/src/commands/migrate.ts+2−1View file
@@ -101,7 +101,8 @@
101101 ctx.out(`Using existing repository ${to.owner}/${to.name}`);
102102 return { repo: existing.data, created: false };
103103 }
104- const visibility = forcePrivate ? "private" : (gh.visibility ?? (gh.private ? "private" : "public"));
104+ // Diffinery has no "internal": a GitHub internal repository becomes private.
105+ const visibility = forcePrivate || gh.private || (gh.visibility && gh.visibility !== "public") ? "private" : "public";
105106 const body = { name: to.name, description: gh.description ?? undefined, visibility };
106107 const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body);
107108 ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`);
modifiedapps/cli/src/commands/repo.ts+2−3View file
@@ -11,7 +11,7 @@
1111 full_name: string;
1212 owner: { login: string; type: string };
1313 private: boolean;
14- visibility: "public" | "internal" | "private";
14+ visibility: "public" | "private";
1515 description: string | null;
1616 default_branch: string;
1717 archived: boolean;
@@ -30,7 +30,6 @@
3030 options: {
3131 org: { type: "string", desc: "Create it in this organization", arg: "ORG" },
3232 private: { type: "boolean", desc: "Only collaborators can see it (default)" },
33- internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" },
3433 public: { type: "boolean", desc: "Anyone can see it" },
3534 description: { type: "string", short: "d", desc: "Description", arg: "TEXT" },
3635 "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" },
@@ -39,7 +38,7 @@
3938 minArgs: 1,
4039 maxArgs: 1,
4140 async run(ctx, o, [name]) {
42- const visibility = exactlyOne(o, ["private", "internal", "public"], "private");
41+ const visibility = exactlyOne(o, ["private", "public"], "private");
4342 const api = await ctx.authedApi();
4443 const org = str(o, "org");
4544 const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") });
modifiedapps/diffinery/islands/kumo.tsx+6−1View file
@@ -140,8 +140,13 @@
140140 radio: (p: RadioProps) => <KRadio p={p} />,
141141 };
142142
143-/** Kumo's dark theme keys off data-mode; follow the system setting like the rest of the site. */
143+/** Kumo's dark theme keys off data-mode: the Appearance setting (data-theme) if chosen, else the system setting. */
144144 function syncMode() {
145+ const fixed = document.documentElement.dataset.theme;
146+ if (fixed === "light" || fixed === "dark") {
147+ document.documentElement.dataset.mode = fixed;
148+ return;
149+ }
145150 const mq = matchMedia("(prefers-color-scheme: dark)");
146151 const apply = () => (document.documentElement.dataset.mode = mq.matches ? "dark" : "light");
147152 apply();
addedapps/diffinery/migrations/0008_profile_appearance_avatars.sql+13−0View file
@@ -1,0 +1,13 @@
1+-- Altug's feedback: profile pictures, the Appearance setting, and no more "internal" visibility.
2+
3+-- Profile pictures for people and organizations. The WebP files live in R2 under
4+-- avatars/<account id>/<version>/<size>.webp; NULL means the generated initials.
5+ALTER TABLE accounts ADD COLUMN avatar_version INTEGER;
6+
7+-- Appearance: 'light', 'dark' or NULL (follow the system setting).
8+ALTER TABLE accounts ADD COLUMN appearance TEXT;
9+
10+-- Repositories and packages are public or private. "Internal" meant private to the
11+-- organization, which private already covers, so existing internal ones become private.
12+UPDATE repos SET visibility = 'private' WHERE visibility = 'internal';
13+UPDATE packages SET visibility = 'private' WHERE visibility = 'internal';
modifiedapps/diffinery/package.json+5−0View file
@@ -19,8 +19,13 @@
1919 "@diffinery/git-core": "workspace:*",
2020 "@diffinery/shared": "workspace:*",
2121 "@diffinery/workflow-yaml": "workspace:*",
22+ "@jsquash/jpeg": "1.6.0",
23+ "@jsquash/png": "3.1.1",
24+ "@jsquash/resize": "2.1.1",
25+ "@jsquash/webp": "1.5.0",
2226 "@simplewebauthn/server": "14.0.3",
2327 "hono": "^4.13.0",
28+ "shiki": "4.5.0",
2429 "yaml": "^2.9.1"
2530 },
2631 "devDependencies": {
addedapps/diffinery/scripts/make-ui-icons.mjs+72−0View file
@@ -1,0 +1,72 @@
1+// Renders the UI icons from Phosphor (the icon set Kumo uses) into static SVG strings:
2+// node scripts/make-ui-icons.mjs -> src/web/icons.ui.generated.ts
3+import { createElement } from "react";
4+import { renderToStaticMarkup } from "react-dom/server";
5+import * as P from "@phosphor-icons/react/dist/ssr";
6+import { writeFile } from "node:fs/promises";
7+
8+/** name -> [Phosphor icon, weight] */
9+const MAP = {
10+ success: ["CheckCircle", "fill"],
11+ failure: ["XCircle", "fill"],
12+ running: ["CircleNotch", "bold"],
13+ queued: ["DotsThreeCircle", "fill"],
14+ cancelled: ["Prohibit", "bold"],
15+ skipped: ["MinusCircle", "regular"],
16+ neutral: ["MinusCircle", "regular"],
17+ issueOpen: ["Record", "regular"],
18+ issueClosed: ["CheckCircle", "regular"],
19+ pr: ["GitPullRequest", "regular"],
20+ prMerged: ["GitMerge", "regular"],
21+ prClosed: ["GitPullRequest", "regular"],
22+ dir: ["FolderSimple", "fill"],
23+ file: ["File", "regular"],
24+ submodule: ["FolderSimpleDashed", "regular"],
25+ branch: ["GitBranch", "regular"],
26+ tag: ["Tag", "regular"],
27+ commit: ["GitCommit", "regular"],
28+ lock: ["LockSimple", "regular"],
29+ globe: ["Globe", "regular"],
30+ search: ["MagnifyingGlass", "regular"],
31+ bell: ["Bell", "regular"],
32+ plus: ["Plus", "regular"],
33+ gear: ["Gear", "regular"],
34+ play: ["PlayCircle", "regular"],
35+ code: ["Code", "regular"],
36+ comment: ["ChatCircle", "regular"],
37+ eye: ["Eye", "regular"],
38+ bot: ["Robot", "regular"],
39+ history: ["ClockCounterClockwise", "regular"],
40+ copy: ["Copy", "regular"],
41+ download: ["DownloadSimple", "regular"],
42+ edit: ["PencilSimple", "regular"],
43+ x: ["X", "regular"],
44+ check: ["Check", "regular"],
45+ people: ["Users", "regular"],
46+ key: ["Key", "regular"],
47+ shield: ["ShieldCheck", "regular"],
48+ repo: ["BookBookmark", "regular"],
49+ org: ["Buildings", "regular"],
50+ zap: ["Lightning", "regular"],
51+ package: ["Package", "regular"],
52+ upload: ["UploadSimple", "regular"],
53+ trash: ["Trash", "regular"],
54+ userPlus: ["UserPlus", "regular"],
55+ envelope: ["EnvelopeSimple", "regular"],
56+ sun: ["Sun", "regular"],
57+ moon: ["Moon", "regular"],
58+ signOut: ["SignOut", "regular"],
59+ caret: ["CaretDown", "regular"],
60+ link: ["LinkSimple", "regular"],
61+ rocket: ["RocketLaunch", "regular"],
62+};
63+
64+const out = {};
65+for (const [name, [icon, weight]] of Object.entries(MAP)) {
66+ const C = P[icon] ?? P[`${icon}Icon`];
67+ if (!C) throw new Error(`no Phosphor icon ${icon}`);
68+ out[name] = renderToStaticMarkup(createElement(C, { size: 16, weight, "aria-hidden": "true", focusable: "false" }));
69+}
70+const body = Object.entries(out).map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`).join("\n");
71+await writeFile(new URL("../src/web/icons.ui.generated.ts", import.meta.url), `// Generated by scripts/make-ui-icons.mjs from Phosphor icons (MIT). Do not edit.\nexport const UI_ICONS = {\n${body}\n} as const;\n`);
72+console.log(`${Object.keys(out).length} icons`);
modifiedapps/diffinery/src/app.ts+6−2View file
@@ -12,6 +12,7 @@
1212 import { git } from "./http/git.js";
1313 import { runner } from "./http/runner.js";
1414 import { api, wellKnown } from "./http/api.js";
15+import { avatars } from "./http/avatars.js";
1516 import { mailApi } from "./mail/api.js";
1617 import { files } from "./http/files.js";
1718 import { nodeId, numericId } from "./http/gh.js";
@@ -46,7 +47,9 @@
4647 c.env = d1.env;
4748 const dir = new Directory(c.env);
4849 c.set("dir", dir);
49- c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir));
50+ // Avatar images are public and on every page many times over: skip the session lookup for them.
51+ const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && c.req.path.startsWith("/api/avatars/");
52+ c.set("principal", avatar ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir));
5053 await next();
5154 if (c.res.status === 101) return;
5255 const h = c.res.headers;
@@ -55,7 +58,7 @@
5558 h.set(BOOKMARK_HEADER, bookmark);
5659 // Browsers: remember it once they have written something or are signed in.
5760 const browser = !token && (!["GET", "HEAD"].includes(c.req.method) || cookie !== undefined || getCookie(c, SESSION_COOKIE) !== undefined);
58- if (browser && bookmark !== cookie) h.append("set-cookie", `${BOOKMARK_COOKIE}=${bookmark}; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=Lax`);
61+ if (browser && !avatar && bookmark !== cookie) h.append("set-cookie", `${BOOKMARK_COOKIE}=${bookmark}; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=Lax`);
5962 }
6063 h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload");
6164 h.set("x-content-type-options", "nosniff");
@@ -66,6 +69,7 @@
6669
6770 app.get("/healthz", (c) => c.json({ ok: true }));
6871
72+app.route("/", avatars);
6973 app.route("/", wellKnown);
7074 app.route("/", git);
7175 app.route("/", runner);
modifiedapps/diffinery/src/http/api.ts+4−4View file
@@ -109,9 +109,9 @@
109109 api.get("/user/repos", async (c) => c.json(paginate(c, (await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r)))));
110110 api.post("/user/repos", async (c) => {
111111 const a = requireAccount(c);
112- const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
112+ const b = await body<{ name: string; description?: string; private?: boolean; visibility?: string; default_branch?: string }>(c);
113113 const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a;
114- const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
114+ const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "public" : "private"), defaultBranch: b.default_branch });
115115 c.executionCtx.waitUntil(repoCreated(c.env, c.get("dir"), r, a, { viaToken: viaToken(c) }));
116116 return c.json(repoJson(c, r), 201);
117117 });
@@ -131,8 +131,8 @@
131131 const a = requireAccount(c);
132132 const org = await c.get("dir").accountByLogin(c.req.param("org"));
133133 if (!org || org.kind !== "org") throw new HttpError(404, "organization not found");
134- const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c);
135- const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch });
134+ const b = await body<{ name: string; description?: string; private?: boolean; visibility?: string; default_branch?: string }>(c);
135+ const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "public" : "private"), defaultBranch: b.default_branch });
136136 c.executionCtx.waitUntil(repoCreated(c.env, c.get("dir"), r, a, { viaToken: viaToken(c) }));
137137 return c.json(repoJson(c, r), 201);
138138 });
addedapps/diffinery/src/http/avatars.ts+229−0View file
@@ -1,0 +1,229 @@
1+import { Hono } from "hono";
2+import type { Env } from "../env.js";
3+import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js";
4+import type { Account } from "../lib/directory.js";
5+import { AVATAR_SIZES, MAX_AVATAR_BYTES, makeAvatarImages, type AvatarSize } from "../lib/image.js";
6+import { HttpError, isValidName } from "../lib/util.js";
7+
8+/**
9+ * Profile pictures for people, organizations and agents.
10+ *
11+ * GET /api/avatars/:login?s=64|128|256[&v=<version>] the picture (WebP) or generated initials (SVG)
12+ * PUT /api/v3/user/avatar raw image body (Diffinery extension)
13+ * DELETE /api/v3/user/avatar
14+ * PUT /api/v3/orgs/:org/avatar org owners and admins
15+ * DELETE /api/v3/orgs/:org/avatar
16+ *
17+ * Uploads become square WebPs in R2 at avatars/<account id>/<version>/<size>.webp, and
18+ * accounts.avatar_version points at the current set (NULL: initials). Every upload gets a new
19+ * version, so `?v=` URLs are immutable; the old version's files are deleted.
20+ */
21+export const avatars = new Hono<{ Bindings: Env; Variables: AppVars }>();
22+
23+// ---------------------------------------------------------------- helpers shared with the web UI
24+
25+/** Stable hue for a login's initials avatar. */
26+export function avatarHue(login: string): number {
27+ let h = 0;
28+ for (let i = 0; i < login.length; i++) h = (h * 31 + login.charCodeAt(i)) >>> 0;
29+ return h % 360;
30+}
31+
32+export const avatarInitial = (login: string): string => (login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase();
33+
34+/** The stored size for a requested pixel size: the smallest one at least that big. */
35+export function avatarSize(px: number): AvatarSize {
36+ return AVATAR_SIZES.find((s) => px <= s) ?? AVATAR_SIZES[AVATAR_SIZES.length - 1]!;
37+}
38+
39+/** Path of a login's avatar image; pass the account's avatar_version so a new upload shows at once. */
40+export function avatarPath(login: string, size?: number, version?: number | null): string {
41+ const q = new URLSearchParams();
42+ if (size && size !== 128) q.set("s", String(avatarSize(size)));
43+ if (version) q.set("v", String(version));
44+ const qs = q.toString();
45+ return `/api/avatars/${encodeURIComponent(login)}${qs ? `?${qs}` : ""}`;
46+}
47+
48+export const avatarUrl = (c: AppContext, login: string, version?: number | null): string => `${c.env.PUBLIC_URL.replace(/\/$/, "")}${avatarPath(login, undefined, version)}`;
49+
50+/** The initials avatar as a square SVG, matching the `.av` look (font size as at 20, 40 and 96 CSS px). */
51+export function initialsSvg(login: string, size: AvatarSize): string {
52+ const font = size <= 64 ? 55 : size <= 128 ? 45 : 42;
53+ return (
54+ `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">` +
55+ `<rect width="100" height="100" fill="hsl(${avatarHue(login)},55%,55%)"/>` +
56+ `<text x="50" y="50" dy=".35em" text-anchor="middle" fill="#fff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue','Noto Sans',Arial,sans-serif" font-size="${font}" font-weight="700">${avatarInitial(login)}</text>` +
57+ `</svg>`
58+ );
59+}
60+
61+const objectKey = (accountId: string, version: number, size: AvatarSize) => `avatars/${accountId}/${version}/${size}.webp`;
62+
63+const SHORT = "public, max-age=600, stale-while-revalidate=86400";
64+const IMMUTABLE = "public, max-age=86400, immutable";
65+/** What the Cache API keeps: short, because a purge only reaches the colo it runs in. */
66+const EDGE = "public, max-age=600";
67+
68+const edgeCache = (): Cache | null => (typeof caches === "undefined" ? null : (caches as unknown as { default: Cache }).default);
69+
70+function cacheKey(origin: string, login: string, size: AvatarSize, version: number | null): Request {
71+ return new Request(`${origin}/api/avatars/${encodeURIComponent(login)}?s=${size}${version ? `&v=${version}` : ""}`);
72+}
73+
74+const origin = (c: AppContext) => new URL(c.req.url).origin;
75+
76+/** Forget cached copies of a login's avatar (every size, without `v` and with each given version). */
77+export async function purgeAvatarCache(c: AppContext, login: string, versions: Array<number | null | undefined>): Promise<void> {
78+ const cache = edgeCache();
79+ if (!cache) return;
80+ const vs = [null, ...new Set(versions.filter((v): v is number => !!v))];
81+ await Promise.all(AVATAR_SIZES.flatMap((s) => vs.map((v) => cache.delete(cacheKey(origin(c), login, s, v)).catch(() => false))));
82+}
83+
84+/** Delete every stored file of an account's avatars except the version to keep. */
85+async function deleteStored(env: Env, accountId: string, keep: number | null): Promise<void> {
86+ const prefix = `avatars/${accountId}/`;
87+ const listed = await env.BUCKET.list({ prefix, limit: 1000 });
88+ const stale = listed.objects.map((o) => o.key).filter((k) => keep === null || !k.startsWith(`${prefix}${keep}/`));
89+ if (stale.length) await env.BUCKET.delete(stale);
90+}
91+
92+/** Store a new picture for an account (a user, an org or an agent) and return its version. */
93+export async function setAvatar(c: AppContext, account: Account, bytes: Uint8Array): Promise<number> {
94+ const images = await makeAvatarImages(bytes);
95+ const version = Math.max((account.avatar_version ?? 0) + 1, Math.floor(Date.now() / 1000));
96+ await Promise.all([...images].map(([size, webp]) => c.env.BUCKET.put(objectKey(account.id, version, size), webp, { httpMetadata: { contentType: "image/webp", cacheControl: IMMUTABLE } })));
97+ await c.get("dir").setAvatarVersion(account.id, version);
98+ await Promise.all([purgeAvatarCache(c, account.login, [account.avatar_version, version]), deleteStored(c.env, account.id, version)]);
99+ return version;
100+}
101+
102+/** Go back to initials. */
103+export async function removeAvatar(c: AppContext, account: Account): Promise<void> {
104+ await c.get("dir").setAvatarVersion(account.id, null);
105+ await Promise.all([purgeAvatarCache(c, account.login, [account.avatar_version]), deleteStored(c.env, account.id, null)]);
106+}
107+
108+/** Read a request body, refusing more than `limit` bytes without buffering them. */
109+async function readLimited(req: Request, limit: number): Promise<Uint8Array> {
110+ const tooBig = () => new HttpError(413, "That picture is larger than 5 MB. Use a smaller file.");
111+ if (Number(req.headers.get("content-length") ?? 0) > limit) throw tooBig();
112+ if (!req.body) throw new HttpError(422, "Send the picture as the request body.");
113+ const parts: Uint8Array[] = [];
114+ let total = 0;
115+ const reader = req.body.getReader();
116+ for (;;) {
117+ const { done, value } = await reader.read();
118+ if (done) break;
119+ total += value.byteLength;
120+ if (total > limit) {
121+ await reader.cancel().catch(() => {});
122+ throw tooBig();
123+ }
124+ parts.push(value);
125+ }
126+ const out = new Uint8Array(total);
127+ let at = 0;
128+ for (const p of parts) {
129+ out.set(p, at);
130+ at += p.byteLength;
131+ }
132+ return out;
133+}
134+
135+// ---------------------------------------------------------------- GET /api/avatars/:login
136+
137+avatars.get("/api/avatars/:login", async (c) => {
138+ const login = c.req.param("login");
139+ const size = avatarSize(Number(c.req.query("s")) || 128);
140+ const v = /^\d{1,15}$/.test(c.req.query("v") ?? "") ? Number(c.req.query("v")) : null;
141+ const cache = edgeCache();
142+ const key = cacheKey(origin(c), login, size, v);
143+ const hit = await cache?.match(key);
144+ if (hit) {
145+ const res = new Response(hit.body, hit);
146+ res.headers.set("cache-control", v ? IMMUTABLE : SHORT);
147+ return res;
148+ }
149+
150+ // Unknown logins get initials too, so the route does not tell who exists.
151+ const account = isValidName(login) ? await c.get("dir").accountByLogin(login) : null;
152+ const current = account?.avatar_version ?? null;
153+ const obj = account && current ? await c.env.BUCKET.get(objectKey(account.id, current, size)) : null;
154+ const headers = new Headers({ "x-content-type-options": "nosniff" });
155+ let body: ArrayBuffer | string;
156+ if (obj) {
157+ body = await obj.arrayBuffer();
158+ headers.set("content-type", "image/webp");
159+ } else {
160+ body = initialsSvg(login, size);
161+ headers.set("content-type", "image/svg+xml; charset=utf-8");
162+ headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'");
163+ }
164+ // A `v` that is not the current version (an old link, or a guess) is served but neither cached here nor marked immutable.
165+ const versioned = v !== null && v === current;
166+ if (v === null || versioned) {
167+ const stored = new Response(body, { headers });
168+ stored.headers.set("cache-control", EDGE);
169+ c.executionCtx.waitUntil(cache?.put(key, stored).catch(() => {}) ?? Promise.resolve());
170+ }
171+ headers.set("cache-control", versioned ? IMMUTABLE : SHORT);
172+ return new Response(body, { headers });
173+});
174+
175+// ---------------------------------------------------------------- REST
176+
177+/** Only full-account credentials change a profile: browser sessions, or tokens with write or admin scope for all repositories. */
178+function requireProfileWrite(c: AppContext): Account {
179+ const me = requireAccount(c);
180+ const p = c.get("principal");
181+ if (p.type === "account" && p.via === "token" && (p.repoIds || !p.scopes.some((s) => s === "write" || s === "admin"))) {
182+ throw new HttpError(403, "changing a profile picture needs a write or admin token that is not limited to repositories");
183+ }
184+ return me;
185+}
186+
187+async function orgForAvatar(c: AppContext, me: Account): Promise<Account> {
188+ const dir = c.get("dir");
189+ const org = await dir.accountByLogin(c.req.param("org") ?? "");
190+ if (!org || org.kind !== "org") throw new HttpError(404, "not found");
191+ const role = await dir.orgRole(org.id, me.id);
192+ if (!role && !me.site_admin) throw new HttpError(404, "not found");
193+ if (role !== "owner" && role !== "admin" && !me.site_admin) throw new HttpError(403, "only organization owners and admins can change its picture");
194+ return org;
195+}
196+
197+function audit(c: AppContext, me: Account, action: "avatar.update" | "avatar.remove", target: Account): void {
198+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action, target: target.login, detail: { kind: target.kind, via: "api" } }));
199+}
200+
201+avatars.put("/api/v3/user/avatar", async (c) => {
202+ const me = requireProfileWrite(c);
203+ const version = await setAvatar(c, me, await readLimited(c.req.raw, MAX_AVATAR_BYTES));
204+ audit(c, me, "avatar.update", me);
205+ return c.json({ avatar_url: avatarUrl(c, me.login, version) });
206+});
207+
208+avatars.delete("/api/v3/user/avatar", async (c) => {
209+ const me = requireProfileWrite(c);
210+ await removeAvatar(c, me);
211+ audit(c, me, "avatar.remove", me);
212+ return c.json({ avatar_url: avatarUrl(c, me.login) });
213+});
214+
215+avatars.put("/api/v3/orgs/:org/avatar", async (c) => {
216+ const me = requireProfileWrite(c);
217+ const org = await orgForAvatar(c, me);
218+ const version = await setAvatar(c, org, await readLimited(c.req.raw, MAX_AVATAR_BYTES));
219+ audit(c, me, "avatar.update", org);
220+ return c.json({ avatar_url: avatarUrl(c, org.login, version) });
221+});
222+
223+avatars.delete("/api/v3/orgs/:org/avatar", async (c) => {
224+ const me = requireProfileWrite(c);
225+ const org = await orgForAvatar(c, me);
226+ await removeAvatar(c, org);
227+ audit(c, me, "avatar.remove", org);
228+ return c.json({ avatar_url: avatarUrl(c, org.login) });
229+});
modifiedapps/diffinery/src/http/files.ts+1−12View file
@@ -10,7 +10,7 @@
1010 /**
1111 * Byte routes outside /api/v3, at the paths GitHub clients expect:
1212 * release asset uploads (`upload_url`), browser downloads of release assets
13- * and source archives, and avatars for the `avatar_url` in user objects.
13+ * and source archives.
1414 */
1515 export const files = new Hono<{ Bindings: Env; Variables: AppVars }>();
1616
@@ -119,14 +119,3 @@
119119 return archiveResponse(c, stub, repo.owner_login, repo.name, m[1]!, m[2] as "zip" | "tar.gz");
120120 });
121121
122-// Letter avatars for `avatar_url`: a coloured square with the login's first letter.
123-files.get("/api/avatars/:login", (c) => {
124- const login = c.req.param("login").slice(0, 100);
125- let hash = 0;
126- for (const ch of login) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
127- const hue = hash % 360;
128- const letter = (login.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase();
129- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="12" fill="hsl(${hue} 55% 45%)"/><text x="48" y="64" font-family="system-ui, sans-serif" font-size="48" font-weight="600" text-anchor="middle" fill="#fff">${letter}</text></svg>`;
130- return c.body(svg, 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'" });
131-});
132-
modifiedapps/diffinery/src/http/gh.ts+1−1View file
@@ -77,7 +77,7 @@
7777 owner_kind: string;
7878 name: string;
7979 description: string;
80- visibility: "private" | "internal" | "public";
80+ visibility: "private" | "public";
8181 default_branch: string;
8282 archived: number;
8383 created_at: string;
modifiedapps/diffinery/src/lib/directory.ts+100−13View file
@@ -15,6 +15,10 @@
1515 site_admin: number;
1616 owner_id: string | null;
1717 agent_policy: string | null;
18+ /** Profile picture version (R2 avatars/<id>/<version>/<size>.webp), null for initials. */
19+ avatar_version: number | null;
20+ /** "light", "dark" or null (follow the system). */
21+ appearance: string | null;
1822 created_at: string;
1923 }
2024
@@ -25,7 +29,7 @@
2529 owner_kind: "user" | "org" | "agent";
2630 name: string;
2731 description: string;
28- visibility: "private" | "internal" | "public";
32+ visibility: "private" | "public";
2933 default_branch: string;
3034 archived: number;
3135 created_at: string;
@@ -37,6 +41,14 @@
3741
3842 export type TokenScope = "read" | "write" | "admin";
3943
44+/** Repositories are public or private. GitHub's "internal" (private to the organization) maps to private. */
45+export function repoVisibility(v: unknown): Repo["visibility"] | undefined {
46+ if (v === undefined || v === null || v === "") return undefined;
47+ if (v === "public") return "public";
48+ if (v === "private" || v === "internal") return "private";
49+ throw new HttpError(422, "visibility must be public or private");
50+}
51+
4052 /** Data access for the global directory in D1. */
4153 export class Directory {
4254 constructor(private env: Env) {}
@@ -110,6 +122,15 @@
110122 }
111123 }
112124
125+ async setAppearance(id: string, appearance: "light" | "dark" | null): Promise<void> {
126+ await this.db.prepare("UPDATE accounts SET appearance = ? WHERE id = ?").bind(appearance, id).run();
127+ }
128+
129+ /** Point an account at a new profile picture version (null: back to initials). */
130+ async setAvatarVersion(id: string, version: number | null): Promise<void> {
131+ await this.db.prepare("UPDATE accounts SET avatar_version = ? WHERE id = ?").bind(version, id).run();
132+ }
133+
113134 async createOrg(login: string, name: string, creator: Account): Promise<Account> {
114135 if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name");
115136 if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken");
@@ -137,19 +158,76 @@
137158 return r.results;
138159 }
139160
140- async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
161+ async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null, by?: Account): Promise<void> {
141162 const previous = await this.orgRole(orgId, userId);
142163 await this.writeOrgMember(orgId, userId, role);
143164 if (previous !== role) {
144165 const org = await this.account(orgId);
145- if (org) await enqueueMail(this.env, [{ kind: "org_membership", data: { org: org.login, role, previous }, to: { accountId: userId }, reason: "account", at: now() }]);
166+ if (!org) return;
167+ await enqueueMail(this.env, [{ kind: "org_membership", data: { org: org.login, role, previous, by: by && by.id !== userId ? by.login : undefined }, to: { accountId: userId }, reason: "account", at: now() }]);
168+ // A heads-up on Diffinery too when someone else adds you.
169+ if (role && !previous && by && by.id !== userId) await this.notify([userId], { kind: "org_membership", title: `${by.login} added you to ${org.login} as ${role === "admin" ? "an admin" : `a${role === "owner" ? "n" : ""} ${role}`}`, url: `/${org.login}` });
146170 }
147171 }
148172
173+ /**
174+ * Add someone to an organization by username or email, like GitHub's "Add people": people with an
175+ * account join right away and get a heads-up; an email without an account gets an invitation that
176+ * creates the account and joins the organization.
177+ */
178+ async addOrgPerson(org: Account, by: Account, who: string, role: "owner" | "admin" | "member"): Promise<{ added: Account } | { invited: { email: string; url: string } }> {
179+ const q = who.trim().replace(/^@/, "");
180+ if (!q) throw new HttpError(422, "enter a username or an email address");
181+ const email = q.includes("@");
182+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) throw new HttpError(422, "enter a valid email address");
183+ const user = email ? await this.accountByEmail(q) : await this.accountByLogin(q);
184+ if (user) {
185+ if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; agents belong to an organization through Settings → Agents`);
186+ if (user.status === "disabled") throw new HttpError(422, `${user.login}'s account is disabled`);
187+ if (await this.orgRole(org.id, user.id)) throw new HttpError(422, `${user.login} is already a member of ${org.login}`);
188+ // An admin vouching for someone waiting for an invite lets them in.
189+ if (user.status === "pending") await this.setStatus(user.id, "active");
190+ await this.setOrgMember(org.id, user.id, role, by);
191+ return { added: user };
192+ }
193+ if (!email) throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`);
194+ const pending = await this.db.prepare("SELECT 1 FROM invites WHERE org_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?").bind(org.id, q, now()).first();
195+ if (pending) throw new HttpError(422, `${q} already has a pending invitation; revoke it first to send a new one`);
196+ const inv = await this.createInvite(by, { email: q, orgId: org.id, role });
197+ return { invited: { email: q, url: inv.url } };
198+ }
199+
200+ /** Add a collaborator by username (a person or an agent) or email; a new email gets an invitation. */
201+ async addRepoPerson(repo: Repo, by: Account, who: string, role: Role): Promise<{ added: Account } | { invited: { email: string; url: string } }> {
202+ const q = who.trim().replace(/^@/, "");
203+ if (!q) throw new HttpError(422, "enter a username or an email address");
204+ const email = q.includes("@");
205+ if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) throw new HttpError(422, "enter a valid email address");
206+ const account = email ? await this.accountByEmail(q) : await this.accountByLogin(q);
207+ if (account) {
208+ if (account.kind === "org") throw new HttpError(422, `${account.login} is an organization; add its people instead`);
209+ if (account.status === "disabled") throw new HttpError(422, `${account.login}'s account is disabled`);
210+ if (account.status === "pending") await this.setStatus(account.id, "active");
211+ await this.setCollaborator(repo.id, account.id, role);
212+ if (account.id !== by.id) await this.notify([account.id], { repoId: repo.id, kind: "repo_access", title: `${by.login} gave you ${role} access to ${repo.owner_login}/${repo.name}`, url: `/${repo.owner_login}/${repo.name}` });
213+ return { added: account };
214+ }
215+ if (!email) throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`);
216+ const inv = await this.createInvite(by, { email: q, repoId: repo.id, role });
217+ return { invited: { email: q, url: inv.url } };
218+ }
219+
220+ async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<void> {
221+ await this.db.prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND (org_id IS ? OR ? IS NULL) AND (repo_id IS ? OR ? IS NULL)").bind(id, scope.orgId ?? null, scope.orgId ?? null, scope.repoId ?? null, scope.repoId ?? null).run();
222+ }
223+
149224 private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> {
150- if (role === null) {
225+ // Removing or demoting the last owner would leave nobody able to manage the organization.
226+ if (role !== "owner" && (await this.orgRole(orgId, userId)) === "owner") {
151227 const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>();
152- if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner");
228+ if (!owners?.n) throw new HttpError(422, "an organization needs at least one owner; make someone else an owner first");
229+ }
230+ if (role === null) {
153231 await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run();
154232 // Removing someone revokes their tokens' reach immediately: roles are computed per request.
155233 return;
@@ -237,6 +315,10 @@
237315 // ---------------------------------------------------------------- invites
238316
239317 async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> {
318+ if (input.orgId) {
319+ if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner");
320+ if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners");
321+ }
240322 const token = randomToken(24);
241323 const id = newId();
242324 await this.db
@@ -261,9 +343,14 @@
261343 return { id, url, token };
262344 }
263345
264- async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> {
265- const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>();
266- return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null }));
346+ async invites(filter: { orgId?: string; repoId?: string; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> {
347+ const r = await this.db
348+ .prepare(
349+ `SELECT i.*, a.login AS inviter FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`,
350+ )
351+ .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : []))
352+ .all<Record<string, string | null>>();
353+ return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null, invitedBy: i.inviter ?? null }));
267354 }
268355
269356 async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> {
@@ -330,7 +417,7 @@
330417 let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id";
331418 const subjectId = account.kind === "agent" ? account.owner_id! : account.id;
332419 if (!account.site_admin) {
333- q += ` WHERE (r.visibility IN ('internal', 'public') OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`;
420+ q += ` WHERE (r.visibility = 'public' OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`;
334421 params.push(subjectId, subjectId, account.id, subjectId);
335422 } else q += " WHERE 1 = 1";
336423 if (ownerId) {
@@ -351,7 +438,7 @@
351438 ).results;
352439 }
353440
354- async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string }): Promise<Repo> {
441+ async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: string; defaultBranch?: string }): Promise<Repo> {
355442 if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'");
356443 if (owner.kind === "org") {
357444 const role = await this.orgRole(owner.id, creator.kind === "agent" ? creator.owner_id! : creator.id);
@@ -363,18 +450,18 @@
363450 const branch = input.defaultBranch || "main";
364451 await this.db
365452 .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
366- .bind(id, owner.id, input.name, input.description ?? "", input.visibility ?? "private", branch, t, t)
453+ .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t)
367454 .run();
368455 const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" };
369456 await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta);
370457 return (await this.repoById(id))!;
371458 }
372459
373- async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string; archived?: boolean }): Promise<Repo> {
460+ async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: string; defaultBranch?: string; archived?: boolean }): Promise<Repo> {
374461 if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name");
375462 await this.db
376463 .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?")
377- .bind(patch.name ?? null, patch.description ?? null, patch.visibility ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id)
464+ .bind(patch.name ?? null, patch.description ?? null, repoVisibility(patch.visibility) ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id)
378465 .run();
379466 if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch });
380467 return (await this.repoById(repo.id))!;
addedapps/diffinery/src/lib/image.ts+281−0View file
@@ -1,0 +1,281 @@
1+/**
2+ * Profile pictures: check an uploaded JPEG, PNG or WebP and turn it into small square WebPs.
3+ *
4+ * Everything runs in the Worker on jSquash's WASM codecs. Wrangler bundles each `*.wasm` import
5+ * as a compiled WebAssembly.Module (its default CompiledWasm rule), and each codec gets its module
6+ * through `init(module)`, so nothing is fetched at runtime.
7+ *
8+ * Memory: a decoded picture costs width × height × 4 bytes, twice over (the codec's WASM heap and
9+ * the JS copy), so dimensions are read from the file header and checked before decoding. Right after
10+ * decoding, the centre square is box-filtered in plain JS to at most ~2× the largest size we keep, and
11+ * only that small copy goes through the resampler and the encoder.
12+ */
13+import decodeJpeg, { init as initJpegDecode } from "@jsquash/jpeg/decode.js";
14+import { decode as decodePng, init as initPngDecode } from "@jsquash/png/decode.js";
15+import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js";
16+import encodeWebp, { init as initWebpEncode } from "@jsquash/webp/encode.js";
17+import resize, { initResize } from "@jsquash/resize";
18+import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm?module";
19+import PNG_DEC_WASM from "@jsquash/png/codec/pkg/squoosh_png_bg.wasm?module";
20+import WEBP_DEC_WASM from "@jsquash/webp/codec/dec/webp_dec.wasm?module";
21+// @jsquash/webp's encoder picks its SIMD build when the runtime supports WASM SIMD, which V8 (workerd, Node) does.
22+import WEBP_ENC_WASM from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?module";
23+import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm?module";
24+import { HttpError } from "./util.js";
25+
26+/** Square sizes we store, in pixels: 2× the 20, 40 (`.av.lg`) and 96 (`.av.xl`, rounded up) CSS pixel avatars. */
27+export const AVATAR_SIZES = [64, 128, 256] as const;
28+export type AvatarSize = (typeof AVATAR_SIZES)[number];
29+export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
30+/** Decoding holds the pixels twice (4 bytes each); 3072² keeps that well under a Worker's 128 MB. Browsers shrink to 512 px before uploading. */
31+export const MAX_AVATAR_SIDE = 3072;
32+/** WebP quality: small files that still look crisp at 2×. */
33+export const AVATAR_QUALITY = 80;
34+
35+export const UNSUPPORTED_IMAGE = "Use a JPEG, PNG or WebP image.";
36+
37+export type ImageFormat = "jpeg" | "png" | "webp";
38+
39+export interface Pixels {
40+ data: Uint8ClampedArray;
41+ width: number;
42+ height: number;
43+}
44+
45+// Workers and Node have no ImageData; the codecs construct it, so provide the plain shape they need.
46+const g = globalThis as { ImageData?: unknown };
47+g.ImageData ??= class ImageData {
48+ constructor(
49+ public data: Uint8ClampedArray,
50+ public width: number,
51+ public height: number,
52+ ) {}
53+};
54+
55+// ---------------------------------------------------------------- headers
56+
57+/** The format from the file's magic bytes (the content type is not trusted). */
58+export function sniffImage(b: Uint8Array): ImageFormat | null {
59+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg";
60+ if (b.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((x, i) => b[i] === x)) return "png";
61+ if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return "webp";
62+ return null;
63+}
64+
65+const ascii = (b: Uint8Array, at: number, n: number): string => String.fromCharCode(...b.subarray(at, at + n));
66+const u16be = (b: Uint8Array, at: number): number => (b[at]! << 8) | b[at + 1]!;
67+const u16le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8);
68+const u24le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16);
69+const u32be = (b: Uint8Array, at: number): number => ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!) >>> 0;
70+
71+/** Pixel dimensions from the file header, without decoding; null when the header is malformed. */
72+export function imageSize(b: Uint8Array, format: ImageFormat): { width: number; height: number } | null {
73+ if (format === "png") {
74+ if (b.length < 24 || ascii(b, 12, 4) !== "IHDR") return null;
75+ return { width: u32be(b, 16), height: u32be(b, 20) };
76+ }
77+ if (format === "webp") {
78+ if (b.length < 30) return null;
79+ const chunk = ascii(b, 12, 4);
80+ // Lossy: frame tag (3 bytes), start code 9d 01 2a, then 14-bit width and height.
81+ if (chunk === "VP8 ") return b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a ? { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff } : null;
82+ // Lossless: signature 0x2f, then width-1 and height-1 in 14 bits each.
83+ if (chunk === "VP8L") {
84+ if (b[20] !== 0x2f) return null;
85+ const bits = (b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24)) >>> 0;
86+ return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
87+ }
88+ // Extended: canvas width-1 and height-1 as 24-bit numbers.
89+ if (chunk === "VP8X") return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 };
90+ return null;
91+ }
92+ // JPEG: walk the segments to the first start-of-frame marker.
93+ let i = 2;
94+ while (i + 9 < b.length) {
95+ if (b[i] !== 0xff) return null;
96+ const m = b[i + 1]!;
97+ if (m === 0xff) {
98+ i++;
99+ continue;
100+ }
101+ if (m === 0x01 || (m >= 0xd0 && m <= 0xd9)) {
102+ i += 2;
103+ continue;
104+ }
105+ if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: u16be(b, i + 7), height: u16be(b, i + 5) };
106+ i += 2 + u16be(b, i + 2);
107+ }
108+ return null;
109+}
110+
111+/** The EXIF orientation (1–8) of a JPEG; 1 when there is none. */
112+export function jpegOrientation(b: Uint8Array): number {
113+ let i = 2;
114+ while (i + 4 < b.length && b[i] === 0xff) {
115+ const m = b[i + 1]!;
116+ if (m === 0xda || m === 0xd9) break; // start of scan: no more metadata
117+ const len = u16be(b, i + 2);
118+ if (m === 0xe1 && len >= 8 && ascii(b, i + 4, 6) === "Exif\0\0") {
119+ const t = i + 10; // TIFF header
120+ const le = ascii(b, t, 2) === "II";
121+ const r16 = (at: number) => (le ? u16le(b, at) : u16be(b, at));
122+ const r32 = (at: number) => (le ? (u16le(b, at) | (u16le(b, at + 2) << 16)) >>> 0 : u32be(b, at));
123+ if (t + 8 > b.length) return 1;
124+ const ifd = t + r32(t + 4);
125+ if (ifd + 2 > b.length) return 1;
126+ const n = r16(ifd);
127+ for (let k = 0; k < n; k++) {
128+ const e = ifd + 2 + k * 12;
129+ if (e + 12 > b.length || e + 12 > i + 2 + len) break;
130+ if (r16(e) === 0x0112) {
131+ const o = r16(e + 8);
132+ return o >= 1 && o <= 8 ? o : 1;
133+ }
134+ }
135+ return 1;
136+ }
137+ i += 2 + len;
138+ }
139+ return 1;
140+}
141+
142+// ---------------------------------------------------------------- pixels
143+
144+/** The centred square of a width × height picture. */
145+export function squareCrop(width: number, height: number): { x: number; y: number; side: number } {
146+ const side = Math.min(width, height);
147+ return { x: Math.floor((width - side) / 2), y: Math.floor((height - side) / 2), side };
148+}
149+
150+/** Integer box-filter factor that brings `side` down to no less than `min` pixels. */
151+export function shrinkFactor(side: number, min: number): number {
152+ return Math.max(1, Math.floor(side / min));
153+}
154+
155+/**
156+ * Cut the centred square out of a picture and box-filter it by an integer factor, so the result is
157+ * between `min` and 2 × `min` pixels on a side (or the square itself when it is smaller than that).
158+ * Alpha-weighted, so transparent pixels do not darken the edges. Always returns a fresh buffer.
159+ */
160+export function cropAndShrink(img: Pixels, min = 512): Pixels {
161+ const crop = squareCrop(img.width, img.height);
162+ const k = shrinkFactor(crop.side, min);
163+ const side = Math.floor(crop.side / k);
164+ const x0 = crop.x + Math.floor((crop.side - side * k) / 2);
165+ const y0 = crop.y + Math.floor((crop.side - side * k) / 2);
166+ const src = img.data;
167+ const out = new Uint8ClampedArray(side * side * 4);
168+ const n = k * k;
169+ for (let oy = 0; oy < side; oy++) {
170+ for (let ox = 0; ox < side; ox++) {
171+ let r = 0,
172+ gr = 0,
173+ bl = 0,
174+ a = 0;
175+ for (let dy = 0; dy < k; dy++) {
176+ let p = ((y0 + oy * k + dy) * img.width + x0 + ox * k) * 4;
177+ for (let dx = 0; dx < k; dx++, p += 4) {
178+ const al = src[p + 3]!;
179+ r += src[p]! * al;
180+ gr += src[p + 1]! * al;
181+ bl += src[p + 2]! * al;
182+ a += al;
183+ }
184+ }
185+ const q = (oy * side + ox) * 4;
186+ if (a > 0) {
187+ out[q] = r / a;
188+ out[q + 1] = gr / a;
189+ out[q + 2] = bl / a;
190+ }
191+ out[q + 3] = a / n;
192+ }
193+ }
194+ return { data: out, width: side, height: side };
195+}
196+
197+/** Apply an EXIF orientation to a square picture, so it shows the way the camera held it. */
198+export function orientSquare(img: Pixels, orientation: number): Pixels {
199+ if (orientation <= 1 || orientation > 8 || img.width !== img.height) return img;
200+ const s = img.width;
201+ const e = s - 1;
202+ const src = new Uint32Array(img.data.buffer, img.data.byteOffset, s * s);
203+ const out = new Uint8ClampedArray(s * s * 4);
204+ const dst = new Uint32Array(out.buffer);
205+ // For each displayed pixel (x, y), the stored pixel it comes from.
206+ const from: Record<number, (x: number, y: number) => number> = {
207+ 2: (x, y) => y * s + (e - x), // mirrored
208+ 3: (x, y) => (e - y) * s + (e - x), // rotated 180°
209+ 4: (x, y) => (e - y) * s + x, // flipped
210+ 5: (x, y) => x * s + y, // transposed
211+ 6: (x, y) => (e - x) * s + y, // rotated 90° clockwise
212+ 7: (x, y) => (e - x) * s + (e - y), // transversed
213+ 8: (x, y) => x * s + (e - y), // rotated 90° counter-clockwise
214+ };
215+ const f = from[orientation]!;
216+ for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) dst[y * s + x] = src[f(x, y)]!;
217+ return { data: out, width: s, height: s };
218+}
219+
220+// ---------------------------------------------------------------- codecs
221+
222+type InitWith = (module: WebAssembly.Module) => Promise<unknown>;
223+let shared: Promise<unknown> | undefined;
224+
225+/** Encoder and resampler are small and shared; decoders get a fresh instance per picture (below). */
226+function sharedCodecs(): Promise<unknown> {
227+ shared ??= Promise.all([(initWebpEncode as unknown as InitWith)(WEBP_ENC_WASM), initResize(RESIZE_WASM), initPngDecode(PNG_DEC_WASM)]).catch((e) => {
228+ shared = undefined;
229+ throw e;
230+ });
231+ return shared;
232+}
233+
234+async function decode(bytes: Uint8Array, format: ImageFormat): Promise<Pixels> {
235+ const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
236+ // A WASM heap never shrinks, so a new Emscripten instance per picture lets a big decode's heap be collected.
237+ if (format === "jpeg") {
238+ await (initJpegDecode as unknown as InitWith)(JPEG_DEC_WASM);
239+ return decodeJpeg(buf);
240+ }
241+ if (format === "webp") {
242+ await (initWebpDecode as unknown as InitWith)(WEBP_DEC_WASM);
243+ return decodeWebp(buf);
244+ }
245+ return decodePng(buf);
246+}
247+
248+/** Resample a square picture with Lanczos3 (no-op at the same size). */
249+async function resample(img: Pixels, size: number): Promise<Pixels> {
250+ if (img.width === size && img.height === size) return img;
251+ return resize(img as Parameters<typeof resize>[0], { width: size, height: size, method: "lanczos3", fitMethod: "stretch", premultiply: true, linearRGB: true });
252+}
253+
254+/**
255+ * Check, decode, centre-crop, scale and encode an uploaded picture.
256+ * Returns one WebP per entry of AVATAR_SIZES. Client mistakes throw HttpError 413/422.
257+ */
258+export async function makeAvatarImages(bytes: Uint8Array): Promise<Map<AvatarSize, Uint8Array>> {
259+ if (bytes.byteLength > MAX_AVATAR_BYTES) throw new HttpError(413, "That picture is larger than 5 MB. Use a smaller file.");
260+ const format = sniffImage(bytes);
261+ if (!format) throw new HttpError(422, UNSUPPORTED_IMAGE);
262+ const dims = imageSize(bytes, format);
263+ if (!dims || !dims.width || !dims.height) throw new HttpError(422, `That picture could not be read. ${UNSUPPORTED_IMAGE}`);
264+ if (dims.width > MAX_AVATAR_SIDE || dims.height > MAX_AVATAR_SIDE) throw new HttpError(422, `That picture is ${dims.width}×${dims.height} pixels; the limit is ${MAX_AVATAR_SIDE}×${MAX_AVATAR_SIDE}.`);
265+ await sharedCodecs();
266+ let decoded: Pixels;
267+ try {
268+ decoded = await decode(bytes, format);
269+ } catch {
270+ throw new HttpError(422, `That picture could not be read. ${UNSUPPORTED_IMAGE}`);
271+ }
272+ if (decoded.width > MAX_AVATAR_SIDE || decoded.height > MAX_AVATAR_SIDE) throw new HttpError(422, `That picture is larger than ${MAX_AVATAR_SIDE}×${MAX_AVATAR_SIDE} pixels.`);
273+ let square = cropAndShrink(decoded, 2 * AVATAR_SIZES[AVATAR_SIZES.length - 1]!);
274+ if (format === "jpeg") square = orientSquare(square, jpegOrientation(bytes));
275+ const out = new Map<AvatarSize, Uint8Array>();
276+ for (const size of AVATAR_SIZES) {
277+ const px = await resample(square, size);
278+ out.set(size, new Uint8Array(await encodeWebp(px as Parameters<typeof encodeWebp>[0], { quality: AVATAR_QUALITY, method: 6 })));
279+ }
280+ return out;
281+}
modifiedapps/diffinery/src/mail/samples.ts+1−1View file
@@ -74,7 +74,7 @@
7474 item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }),
7575 item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }),
7676 item("access-request", "Access request", { kind: "access_request", reason: "admin", data: { email: "newcomer@example.com", login: "newcomer", note: "I'm joining the platform team next week." } }),
77- item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member" } }),
77+ item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "octo-dev" } }),
7878 item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }),
7979 item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }),
8080 item("repo-deleted", "Repository deleted", { kind: "repo_deleted", reason: "admin", data: { repo, actor: "kim" } }),
modifiedapps/diffinery/src/mail/templates.ts+7−4View file
@@ -81,7 +81,7 @@
8181 .map((x) => {
8282 if (typeof x === "string") return esc(x);
8383 if ("b" in x) return `<strong style="font-weight:600">${esc(x.b)}</strong>`;
84- if ("code" in x) return `<code style="font-family:${MONO};font-size:12.5px;background:${C.codeBg};border-radius:4px;padding:1px 5px">${esc(x.code)}</code>`;
84+ if ("code" in x) return `<code class="code" style="font-family:${MONO};font-size:12.5px;background:${C.codeBg};border-radius:4px;padding:1px 5px">${esc(x.code)}</code>`;
8585 return `<a href="${esc(x.href)}" style="color:${C.accent};text-decoration:none">${esc(x.a)}</a>`;
8686 })
8787 .join("");
@@ -185,7 +185,7 @@
185185 @media (prefers-color-scheme:dark){
186186 .bg{background:#0a0a0a !important}.card{background:#171717 !important;border-color:#2e2e2e !important}
187187 .fg,.fg a,h1.fg{color:#f4f4f5 !important}.muted{color:#a3a3a3 !important}.box{border-color:#2e2e2e !important}
188-.box td{border-color:#2e2e2e !important}.md pre,.md code,pre.md{background:#262626 !important;color:#f4f4f5 !important}
188+.box td{border-color:#2e2e2e !important}.md,.md td,.md th{color:#e4e4e7 !important}.md pre,.md code,pre.md,code.code{background:#262626 !important;color:#f4f4f5 !important}
189189 .btn2{background:#262626 !important;color:#f4f4f5 !important;border-color:#3f3f46 !important}}
190190 @media (max-width:620px){.card{border-radius:0 !important;padding:22px 18px !important}.outer{padding:0 !important}}
191191 </style></head>
@@ -571,7 +571,10 @@
571571 preheader: d.role ? `You can see ${d.org}'s repositories according to your role.` : `You no longer have access to ${d.org}'s private repositories.`,
572572 eyebrow: { text: "Organization", tone: d.role ? "blue" : "neutral" },
573573 title,
574- blocks: [{ kv: [["Organization", d.org], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : [])],
574+ blocks: [
575+ { kv: [["Organization", d.org], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : []), ...(d.by ? ([[d.role && !d.previous ? "Added by" : "Changed by", d.by]] as Array<[string, string]>) : [])] },
576+ ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : []),
577+ ],
575578 context: { label: d.org, url },
576579 url: d.role ? url : undefined,
577580 };
@@ -582,7 +585,7 @@
582585 const title: Inline[] = d.role ? (d.previous ? ["Your access to ", { b: d.repo }, ` changed to ${roleText(d.role)}`] : ["You now have ", roleText(d.role), " access to ", { b: d.repo }]) : ["Your access to ", { b: d.repo }, " was removed"];
583586 return {
584587 subject: `[${d.repo}] ${inlineText(title)}`,
585- preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's internal or public.",
588+ preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's public.",
586589 eyebrow: { text: "Repository access", tone: d.role ? "blue" : "neutral" },
587590 title,
588591 blocks: [{ kv: [["Repository", d.repo], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: "Open repository", url } } as Block] : [])],
modifiedapps/diffinery/src/mail/types.ts+1−1View file
@@ -84,7 +84,7 @@
8484 | { kind: "welcome"; data: { login: string } }
8585 | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } }
8686 | { kind: "access_request"; data: { email: string; login: string; note: string } }
87- | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null } }
87+ | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } }
8888 | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } }
8989 | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } }
9090 | { kind: "repo_deleted"; data: { repo: string; actor: string } }
modifiedapps/diffinery/src/mail/web.tsx+4−4View file
@@ -396,20 +396,20 @@
396396 </div>
397397 <div class="row mt mb">
398398 <Form action="/settings/notifications/test" t={t} class="inline">
399- <button class="btn sm">Send me a test email</button>
399+ <button class="btn">Send me a test email</button>
400400 </Form>
401401 <Form action="/settings/admin/email/samples" t={t} class="inline" confirm={`Send all ${samples.length} templates to ${me.email}?`}>
402- <button class="btn sm">Send every template to me</button>
402+ <button class="btn">Send every template to me</button>
403403 </Form>
404404 </div>
405405
406406 <h2>Templates</h2>
407407 <form method="get" action="/settings/admin/email" class="row mb">
408408 <KSelect name="sample" options={samples.map((s) => ({ value: s.id, label: s.label }))} value={pick.id} label="Template" autosubmit />
409- <a class="btn sm" href={`/settings/admin/email/preview/${pick.id}`} target="_blank" rel="noopener">
409+ <a class="btn" href={`/settings/admin/email/preview/${pick.id}`} target="_blank" rel="noopener">
410410 Open HTML
411411 </a>
412- <a class="btn sm" href={`/settings/admin/email/preview/${pick.id}?format=text`} target="_blank" rel="noopener">
412+ <a class="btn" href={`/settings/admin/email/preview/${pick.id}?format=text`} target="_blank" rel="noopener">
413413 Plain text
414414 </a>
415415 </form>
modifiedapps/diffinery/src/repo/actions.ts+1−1View file
@@ -618,7 +618,7 @@
618618 else if (ref.owner.toLowerCase() === m.owner.toLowerCase() && ref.repo.toLowerCase() === m.name.toLowerCase()) repoId = m.repoId;
619619 else {
620620 const target = await new Directory(this.env).repo(ref.owner, ref.repo);
621- // Like GitHub: public and internal workflows can be shared; private ones only within the same owner.
621+ // Like GitHub: public workflows can be shared; private ones only within the same owner.
622622 if (!target || (target.visibility === "private" && target.owner_id !== m.ownerId)) throw new Error(`repository ${ref.owner}/${ref.repo} not found, or its workflows are not shared with ${m.owner}/${m.name}`);
623623 repoId = target.id;
624624 }
modifiedapps/diffinery/src/text-modules.d.ts+6−0View file
@@ -3,3 +3,9 @@
33 const text: string;
44 export default text;
55 }
6+
7+// Compiled WebAssembly modules (wrangler's default CompiledWasm rule; the wasm plugin in vitest.config.ts under tests).
8+declare module "*.wasm?module" {
9+ const module: WebAssembly.Module;
10+ export default module;
11+}
modifiedapps/diffinery/src/web/account.tsx+320−153View file
@@ -12,7 +12,11 @@
1212 import { Auth } from "../auth/sessions.js";
1313 import { repoCreated } from "../mail/notify.js";
1414 import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js";
15-import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
15+import { deleteCookie, setCookie } from "hono/cookie";
16+import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js";
17+import type { FormData } from "./csrf.js";
18+import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js";
19+import { MAX_AVATAR_BYTES } from "../lib/image.js";
1620
1721 // ---------------------------------------------------------------- shared bits
1822
@@ -58,6 +62,69 @@
5862 return role === "owner" || role === "admin";
5963 }
6064
65+// ---------------------------------------------------------------- profile pictures (people and organizations)
66+
67+const TOO_BIG = "That picture is larger than 5 MB. Use a smaller file.";
68+
69+/** Current picture, an upload control and a Remove button. Uploads go to `action`, removal to `${action}/delete`. */
70+const AvatarSection: FC<{ account: Account; action: string; t: string }> = ({ account, action, t }) => (
71+ <div class="box">
72+ <div class="head">
73+ <h2>Profile picture</h2>
74+ </div>
75+ <div class="body avpick">
76+ <span data-avatar-preview>
77+ <Avatar login={account.login} kind={account.kind} size="xl" version={account.avatar_version} />
78+ </span>
79+ <div class="grow">
80+ <Form action={action} t={t} avatar>
81+ <div class="field">
82+ <label for="avatar-file">Upload a new picture</label>
83+ <input id="avatar-file" name="avatar" type="file" accept="image/jpeg,image/png,image/webp" required />
84+ <div class="hint">JPEG, PNG or WebP, up to 5 MB. It is cropped to a centred square.</div>
85+ </div>
86+ <div class="row">
87+ <button class="btn primary">Save picture</button>
88+ <span class="muted small" data-avatar-status aria-live="polite"></span>
89+ </div>
90+ </Form>
91+ {account.avatar_version ? (
92+ <Form action={`${action}/delete`} t={t} class="mt" confirm={account.kind === "org" ? `Remove the picture of ${account.login}?` : "Remove your profile picture?"}>
93+ <button class="btn sm danger">Remove picture</button>
94+ <span class="muted small" style="margin-left:8px">
95+ Shows initials instead.
96+ </span>
97+ </Form>
98+ ) : null}
99+ </div>
100+ </div>
101+ </div>
102+);
103+
104+/** Refuse oversized uploads before the form body is parsed. */
105+function avatarSizeGuard(web: WebApp, path: string, back: (c: AppContext) => string): void {
106+ web.use(path, async (c, next) => {
107+ if (c.req.method === "POST" && Number(c.req.header("content-length") ?? 0) > MAX_AVATAR_BYTES + 64 * 1024) {
108+ flash(c, "error", TOO_BIG);
109+ return c.redirect(back(c), 303);
110+ }
111+ await next();
112+ });
113+}
114+
115+async function uploadAvatar(c: AppContext, f: FormData, me: Account, account: Account): Promise<void> {
116+ const file = f.file("avatar");
117+ if (!file) throw new HttpError(422, "Choose a picture to upload.");
118+ if (file.size > MAX_AVATAR_BYTES) throw new HttpError(413, TOO_BIG);
119+ await setAvatar(c, account, new Uint8Array(await file.arrayBuffer()));
120+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "avatar.update", target: account.login, detail: { kind: account.kind, via: "web" } }));
121+}
122+
123+async function deleteAvatar(c: AppContext, me: Account, account: Account): Promise<void> {
124+ await removeAvatar(c, account);
125+ c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "avatar.remove", target: account.login, detail: { kind: account.kind, via: "web" } }));
126+}
127+
61128 const SCOPES: TokenScope[] = ["read", "write", "admin"];
62129
63130 // ---------------------------------------------------------------- landing / dashboard
@@ -95,6 +162,17 @@
95162 </div>
96163 );
97164
165+/** The icon for a notification, by the event that raised it. */
166+function noteIcon(kind: string): string {
167+ if (kind === "run_failed") return ICONS.failure;
168+ if (kind.startsWith("pull_request")) return ICONS.pr;
169+ if (kind === "issues") return ICONS.issueOpen;
170+ if (kind.endsWith("comment")) return ICONS.comment;
171+ if (kind === "org_membership") return ICONS.org;
172+ if (kind === "repo_access") return ICONS.repo;
173+ return ICONS.bell;
174+}
175+
98176 export function registerAccount(web: WebApp): void {
99177 web.get("/", async (c) => {
100178 const p = c.get("principal");
@@ -165,7 +243,7 @@
165243 <div class="nav">
166244 {orgs.map((o) => (
167245 <a href={`/${o.login}`}>
168- <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span>
246+ <Avatar login={o.login} version={o.avatar_version} /> {o.login} <span class="faint small">{o.role}</span>
169247 </a>
170248 ))}
171249 <a href="/settings/orgs" class="muted">
@@ -279,7 +357,6 @@
279357 items={(
280358 [
281359 ["private", "Private", "Only you, collaborators and organization members can see it."],
282- ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."],
283360 ["public", "Public", "Anyone on the internet can see and clone it."],
284361 ] as const
285362 ).map(([v, l, d]) => ({ value: v, label: l, description: d }))}
@@ -308,7 +385,7 @@
308385 try {
309386 const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner);
310387 if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner");
311- const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private";
388+ const visibility = (["private", "public"] as const).find((v) => v === values.visibility) ?? "private";
312389 const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" });
313390 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` }));
314391 c.executionCtx.waitUntil(repoCreated(c.env, dir, repo, me, { viaToken: false }));
@@ -343,8 +420,11 @@
343420 </a>
344421 </div>
345422 <Form action="/notifications/read" t={t} class="inline">
346- <button class="btn sm">Mark all as read</button>
423+ <button class="btn">{raw(ICONS.check)} Mark all as read</button>
347424 </Form>
425+ <a class="btn icon" href="/settings/notifications" title="Notification settings" aria-label="Notification settings">
426+ {raw(ICONS.gear)}
427+ </a>
348428 </div>
349429 </div>
350430 <div class="box">
@@ -352,8 +432,8 @@
352432 <ul class="list">
353433 {notes.map((n) => (
354434 <li>
355- <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}>
356- {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)}
435+ <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : n.kind === "run_failed" ? "color:var(--red)" : "color:var(--accent)"}>
436+ {raw(noteIcon(n.kind))}
357437 </span>
358438 <div class="grow">
359439 <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}>
@@ -494,13 +574,13 @@
494574
495575 // ---------------------------------------------------------------- /settings
496576
497-export type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit";
577+export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit";
498578
499579 export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => (
500580 <aside class="side">
501581 <section>
502582 <div class="row mb">
503- <Avatar login={me.login} size="lg" />
583+ <Avatar login={me.login} size="lg" version={me.avatar_version} />
504584 <div>
505585 <b>{me.name ?? me.login}</b>
506586 <div class="muted small">{me.login}</div>
@@ -510,6 +590,9 @@
510590 <a href="/settings" class={tab === "profile" ? "on" : ""}>
511591 Profile
512592 </a>
593+ <a href="/settings/appearance" class={tab === "appearance" ? "on" : ""}>
594+ Appearance
595+ </a>
513596 <a href="/settings/security" class={tab === "security" ? "on" : ""}>
514597 Passkeys and sessions
515598 </a>
@@ -527,8 +610,9 @@
527610 </a>
528611 {me.site_admin ? (
529612 <>
613+ <h4>Site administration</h4>
530614 <a href="/settings/admin" class={tab === "admin" ? "on" : ""}>
531- Site administration
615+ People and invitations
532616 </a>
533617 <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}>
534618 Email
@@ -550,7 +634,7 @@
550634 <p class="muted">Copy it now — it will not be shown again.</p>
551635 <div class="row">
552636 <div class="secret grow">{token}</div>
553- <CopyButton text={token} />
637+ <CopyButton text={token} size="base" />
554638 </div>
555639 {note}
556640 <details class="mt">
@@ -587,6 +671,7 @@
587671 "Settings",
588672 <>
589673 <h1>Profile</h1>
674+ <AvatarSection account={me} action="/settings/avatar" t={t} />
590675 <Form action="/settings/profile" t={t} class="box">
591676 <div class="body">
592677 <div class="field">
@@ -625,11 +710,72 @@
625710 async () => {
626711 const login = f.s("login").trim();
627712 await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() });
713+ // The old login's cached picture must not outlive the rename.
714+ if (login && login !== me.login) c.executionCtx.waitUntil(purgeAvatarCache(c, me.login, [me.avatar_version]));
628715 },
629716 "Profile saved.",
630717 );
631718 });
632719
720+ // Appearance
721+ web.get("/settings/appearance", async (c) => {
722+ const me = requireAccount(c);
723+ const current = me.appearance === "light" || me.appearance === "dark" ? me.appearance : "auto";
724+ return page(
725+ c,
726+ me,
727+ "appearance",
728+ "Appearance",
729+ <>
730+ <h1>Appearance</h1>
731+ <p class="muted">Choose how Diffinery looks to you. The choice follows your account to every browser you sign in to.</p>
732+ <Form action="/settings/appearance" t={csrfToken(c)} class="box">
733+ <div class="body">
734+ <div class="field">
735+ <label>Theme</label>
736+ <KRadio
737+ name="appearance"
738+ label="Theme"
739+ value={current}
740+ items={(
741+ [
742+ ["auto", "Sync with system", "Light or dark, following your device's setting."],
743+ ["light", "Light", "Always light."],
744+ ["dark", "Dark", "Always dark."],
745+ ] as const
746+ ).map(([v, l, d]) => ({ value: v, label: <span class="theme-opt"><span class={`theme-prev ${v}`} aria-hidden="true"><i /><i /><i /></span>{l}</span>, description: d }))}
747+ />
748+ </div>
749+ <button class="btn primary">Save appearance</button>
750+ </div>
751+ </Form>
752+ </>,
753+ );
754+ });
755+
756+ post(web, "/settings/appearance", async (c, f) => {
757+ const me = requireAccount(c);
758+ const v = f.s("appearance");
759+ const appearance = v === "light" || v === "dark" ? v : null;
760+ await c.get("dir").setAppearance(me.id, appearance);
761+ // Signed-out pages (sign-in, invites) in this browser keep the choice too.
762+ if (appearance) setCookie(c, APPEARANCE_COOKIE, appearance, { path: "/", secure: true, sameSite: "Lax", maxAge: 400 * 86400 });
763+ else deleteCookie(c, APPEARANCE_COOKIE, { path: "/", secure: true });
764+ flash(c, "notice", "Appearance saved.");
765+ return c.redirect("/settings/appearance", 303);
766+ });
767+
768+ avatarSizeGuard(web, "/settings/avatar", () => "/settings");
769+ post(web, "/settings/avatar", async (c, f) => {
770+ const me = requireAccount(c);
771+ return act(c, "/settings", () => uploadAvatar(c, f, me, me), "Profile picture updated.");
772+ });
773+
774+ post(web, "/settings/avatar/delete", async (c) => {
775+ const me = requireAccount(c);
776+ return act(c, "/settings", () => deleteAvatar(c, me, me), "Profile picture removed.");
777+ });
778+
633779 // Passkeys and sessions
634780 web.get("/settings/security", async (c) => {
635781 const me = requireAccount(c);
@@ -677,13 +823,15 @@
677823 <div class="empty">No passkeys yet. Add one so you can sign in.</div>
678824 )}
679825 <div class="body" style="border-top:1px solid var(--border)">
680- <form data-passkey="register" data-next="/settings/security" class="row" style="align-items:flex-end">
681- <div class="field grow m0">
682- <label for="pklabel">Add a passkey</label>
683- <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
684- <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div>
826+ <form data-passkey="register" data-next="/settings/security">
827+ <div class="formrow">
828+ <div class="field grow">
829+ <label for="pklabel">Add a passkey</label>
830+ <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} />
831+ </div>
832+ <button class="btn primary">{raw(ICONS.key)} Add a passkey</button>
685833 </div>
686- <button class="btn primary">Add a passkey</button>
834+ <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div>
687835 </form>
688836 <div class="mt">
689837 <div class="flash error" data-passkey-msg role="alert" hidden></div>
@@ -824,7 +972,7 @@
824972 </div>
825973 <div class="field">
826974 <label>Scope</label>
827- <KRadio name="scope" label="Scope" orientation="horizontal" value="write" items={SCOPES.map((s) => ({ value: s, label: s }))} />
975+ <KRadio name="scope" label="Scope" orientation="horizontal" value="write" items={SCOPES.map((s) => ({ value: s, label: cap(s) }))} />
828976 <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div>
829977 </div>
830978 <div class="field">
@@ -907,7 +1055,7 @@
9071055 return (
9081056 <li style="display:block">
9091057 <div class="row">
910- <Actor login={a.login} kind="agent" />
1058+ <Actor login={a.login} kind="agent" version={a.avatar_version} />
9111059 <span class="muted small">
9121060 owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status}
9131061 </span>
@@ -941,7 +1089,7 @@
9411089 </div>
9421090 <div class="field m0">
9431091 <label>Scope</label>
944- <KSelect name="scope" label="Scope" value="write" options={SCOPES.map((s) => ({ value: s, label: s }))} />
1092+ <KSelect name="scope" label="Scope" value="write" options={SCOPES.map((s) => ({ value: s, label: cap(s) }))} />
9451093 </div>
9461094 <div class="field m0" style="width:110px">
9471095 <label>Days</label>
@@ -981,14 +1129,14 @@
9811129 name="owner"
9821130 block
9831131 label="Owner"
984- value=""
985- options={[{ value: "", label: `${me.login} (you)` }, ...orgs.map((o) => ({ value: String(o.id), label: `${o.login} (organization)` }))]}
1132+ value="me"
1133+ options={[{ value: "me", label: `${me.login} (you)` }, ...orgs.map((o) => ({ value: String(o.id), label: `${o.login} (organization)` }))]}
9861134 />
9871135 <div class="hint">An agent can never do more than its owner.</div>
9881136 </div>
9891137 <div class="field grow">
9901138 <label for="arole">Maximum role</label>
991- <KSelect id="arole" name="maxRole" block label="Maximum role" value="write" options={ROLES.map((r) => ({ value: r, label: r }))} />
1139+ <KSelect id="arole" name="maxRole" block label="Maximum role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} />
9921140 </div>
9931141 </div>
9941142 <button class="btn primary">Create agent</button>
@@ -1008,7 +1156,7 @@
10081156 "/settings/agents",
10091157 async () => {
10101158 const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write";
1011- const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } });
1159+ const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") && f.s("owner") !== "me" ? f.s("owner") : undefined, policy: { maxRole } });
10121160 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } }));
10131161 },
10141162 "Agent created. Create a token for it below.",
@@ -1065,7 +1213,7 @@
10651213 <ul class="list">
10661214 {orgs.map((o) => (
10671215 <li>
1068- <Avatar login={o.login} />
1216+ <Avatar login={o.login} version={o.avatar_version} />
10691217 <div class="grow">
10701218 <a class="title" href={`/${o.login}`}>
10711219 {o.login}
@@ -1137,7 +1285,7 @@
11371285 <p class="muted">{invite.note}</p>
11381286 <div class="row">
11391287 <div class="secret grow">{invite.url}</div>
1140- <CopyButton text={invite.url} />
1288+ <CopyButton text={invite.url} size="base" />
11411289 </div>
11421290 </div>
11431291 </div>
@@ -1151,7 +1299,7 @@
11511299 <ul class="list">
11521300 {requests.map((r) => (
11531301 <li>
1154- <Avatar login={r.account.login} />
1302+ <Avatar login={r.account.login} version={r.account.avatar_version} />
11551303 <div class="grow">
11561304 <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span>
11571305 {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null}
@@ -1175,13 +1323,15 @@
11751323 <div class="head">
11761324 <h2>Invite someone to this instance</h2>
11771325 </div>
1178- <div class="body row" style="align-items:flex-end">
1179- <div class="field grow m0">
1180- <label for="iemail">Email address</label>
1181- <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1182- <div class="hint">Opening the link lets them pick a username and create a passkey.</div>
1326+ <div class="body">
1327+ <div class="formrow">
1328+ <div class="field grow">
1329+ <label for="iemail">Email address</label>
1330+ <input id="iemail" name="email" type="email" required placeholder="friend@example.com" />
1331+ </div>
1332+ <button class="btn primary">{raw(ICONS.envelope)} Send invitation</button>
11831333 </div>
1184- <button class="btn primary">Create invite</button>
1334+ <div class="hint">We email them a link. Opening it lets them pick a username and create a passkey.</div>
11851335 </div>
11861336 </Form>
11871337 <div class="box">
@@ -1203,7 +1353,7 @@
12031353 {users.map((u) => (
12041354 <tr>
12051355 <td>
1206- <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null}
1356+ <Actor login={u.login} kind="user" version={u.avatar_version} /> {u.site_admin ? <span class="badge blue">admin</span> : null}
12071357 </td>
12081358 <td class="small">{u.email ?? "—"}</td>
12091359 <td class="small">{ago(u.created_at)}</td>
@@ -1212,7 +1362,7 @@
12121362 <span class="badge green">{u.status}</span>
12131363 ) : (
12141364 <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1215- <KSelect name="status" size="sm" label={`Status of ${u.login}`} value={u.status} options={(["active", "pending", "disabled"] as const).map((s) => ({ value: s, label: s }))} />
1365+ <KSelect name="status" size="sm" label={`Status of ${u.login}`} value={u.status} options={(["active", "pending", "disabled"] as const).map((s) => ({ value: s, label: cap(s) }))} />
12161366 <button class="btn sm">Save</button>
12171367 </Form>
12181368 )}
@@ -1324,8 +1474,24 @@
13241474
13251475 // ---------------------------------------------------------------- /:owner profiles
13261476
1327-type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings";
1477+type OrgTab = "repositories" | "people" | "agents" | "settings";
13281478
1479+const ROLE_LABEL: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" };
1480+
1481+/** The invitation link right after inviting someone by email (it's also in their inbox). */
1482+const InviteOnce: FC<{ email: string; url: string }> = ({ email, url }) => (
1483+ <div class="box" style="border-color:var(--amber)">
1484+ <div class="body">
1485+ <h3>Invitation sent to {email}</h3>
1486+ <p class="muted">They don't have an account yet, so we emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: opening it creates their account and passkey and adds them here.</p>
1487+ <div class="row nowrap">
1488+ <div class="secret grow">{url}</div>
1489+ <CopyButton text={url} size="base" />
1490+ </div>
1491+ </div>
1492+ </div>
1493+);
1494+
13291495 function registerProfiles(web: WebApp): void {
13301496 const orgContext = async (c: AppContext) => {
13311497 const me = requireAccount(c);
@@ -1355,7 +1521,7 @@
13551521 { title: acct.login },
13561522 <div style="max-width:720px;margin:24px auto" class="box">
13571523 <div class="body row" style="gap:18px">
1358- <Avatar login={acct.login} kind="agent" size="xl" />
1524+ <Avatar login={acct.login} kind="agent" size="xl" version={acct.avatar_version} />
13591525 <div>
13601526 <h1 class="m0">
13611527 {acct.name ?? acct.login} <span class="badge agent">agent</span>
@@ -1388,7 +1554,7 @@
13881554 <div class="grid left">
13891555 <aside class="side">
13901556 <section>
1391- <Avatar login={acct.login} size="xl" />
1557+ <Avatar login={acct.login} size="xl" version={acct.avatar_version} />
13921558 <h1 class="mt m0">{acct.name ?? acct.login}</h1>
13931559 <p class="muted">{acct.login}</p>
13941560 {acct.id === me.id ? (
@@ -1403,7 +1569,7 @@
14031569 <div class="nav">
14041570 {orgs.map((o) => (
14051571 <a href={`/${o.login}`}>
1406- <Avatar login={o.login} /> {o.login}
1572+ <Avatar login={o.login} version={o.avatar_version} /> {o.login}
14071573 </a>
14081574 ))}
14091575 </div>
@@ -1437,8 +1603,9 @@
14371603 const isAdmin = !!me.site_admin || role === "owner" || role === "admin";
14381604 const isOwner = !!me.site_admin || role === "owner";
14391605 const requested = (c.req.query("tab") ?? "repositories") as OrgTab;
1440- const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories";
1441- if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only");
1606+ // The old Invites tab is part of People now.
1607+ const tab: OrgTab = invite || (requested as string) === "invites" ? "people" : (["repositories", "people", "agents", "settings"] as const).includes(requested) ? requested : "repositories";
1608+ if (tab === "settings" && !isOwner) throw new HttpError(403, "organization owners only");
14421609 let body: Child = null;
14431610 const base = `/${acct.login}`;
14441611 if (tab === "repositories") {
@@ -1458,10 +1625,39 @@
14581625 </>
14591626 );
14601627 } else if (tab === "people") {
1461- const members = await dir.orgMembers(acct.id);
1628+ const [members, invites] = await Promise.all([dir.orgMembers(acct.id), isAdmin ? dir.invites({ orgId: acct.id, pending: true }) : Promise.resolve([])]);
1629+ const roles = (isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: ROLE_LABEL[r]! }));
14621630 body = (
14631631 <>
1464- <h2>People</h2>
1632+ <div class="row between mb">
1633+ <h2 class="m0">People</h2>
1634+ <span class="muted small">
1635+ {members.length} member{members.length === 1 ? "" : "s"}
1636+ {invites.length ? ` · ${invites.length} pending invitation${invites.length === 1 ? "" : "s"}` : ""}
1637+ </span>
1638+ </div>
1639+ {invite ? <InviteOnce email={invite.email} url={invite.url} /> : null}
1640+ {isAdmin ? (
1641+ <Form action={`${base}/-/org/people`} t={t} class="box">
1642+ <div class="head">
1643+ <h3>{raw(ICONS.userPlus)} Add people</h3>
1644+ </div>
1645+ <div class="body">
1646+ <div class="formrow">
1647+ <div class="field grow">
1648+ <label for="who">Username or email</label>
1649+ <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="octocat or name@example.com" />
1650+ </div>
1651+ <div class="field">
1652+ <label for="role">Role</label>
1653+ <KSelect id="role" name="role" label="Role" value="member" options={roles} />
1654+ </div>
1655+ <button class="btn primary">Add</button>
1656+ </div>
1657+ <div class="hint">People who already have an account join right away and get a heads-up by email and on Diffinery. Anyone else gets an email invitation: opening it creates their account and adds them to {acct.login}.</div>
1658+ </div>
1659+ </Form>
1660+ ) : null}
14651661 <div class="box">
14661662 <table class="tbl">
14671663 <thead>
@@ -1475,17 +1671,17 @@
14751671 {members.map((m) => (
14761672 <tr>
14771673 <td>
1478- <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null}
1674+ <Actor login={m.login} kind="user" version={m.avatar_version} /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null}
14791675 </td>
14801676 <td>
14811677 {isAdmin && (isOwner || m.role !== "owner") ? (
1482- <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
1678+ <Form action={`${base}/-/org/members`} t={t} class="row nowrap" style="gap:6px">
14831679 <input type="hidden" name="login" value={m.login} />
1484- <KSelect name="role" size="sm" label={`Role of ${m.login}`} value={m.role} options={(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ({ value: r, label: r }))} />
1680+ <KSelect name="role" size="sm" label={`Role of ${m.login}`} value={m.role} options={(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ({ value: r, label: ROLE_LABEL[r]! }))} />
14851681 <button class="btn sm">Save</button>
14861682 </Form>
14871683 ) : (
1488- <span class="badge">{m.role}</span>
1684+ <span class="badge">{ROLE_LABEL[m.role] ?? m.role}</span>
14891685 )}
14901686 </td>
14911687 {isAdmin ? (
@@ -1504,105 +1700,36 @@
15041700 </tbody>
15051701 </table>
15061702 </div>
1507- {isAdmin ? (
1508- <div class="row" style="align-items:stretch">
1509- <Form action={`${base}/-/org/members`} t={t} class="box grow">
1510- <div class="head">
1511- <h3>Add an existing user</h3>
1512- </div>
1513- <div class="body row" style="align-items:flex-end">
1514- <div class="field grow m0">
1515- <label>Username</label>
1516- <input name="login" type="text" required />
1517- </div>
1518- <div class="field m0">
1519- <label>Role</label>
1520- <KSelect name="role" block label="Role" value="member" options={(isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: r }))} />
1521- </div>
1522- <button class="btn primary">Add</button>
1523- </div>
1524- </Form>
1525- <div class="box grow">
1526- <div class="head">
1527- <h3>Invite by email</h3>
1528- </div>
1529- <div class="body">
1530- <p class="muted small">
1531- New people join from an invite link, where they create a passkey. <a href={`${base}?tab=invites`}>Create an invite →</a>
1532- </p>
1533- </div>
1703+ {invites.length ? (
1704+ <div class="box">
1705+ <div class="head">
1706+ <h3>{raw(ICONS.envelope)} Pending invitations</h3>
15341707 </div>
1535- </div>
1536- ) : null}
1537- </>
1538- );
1539- } else if (tab === "invites") {
1540- const invites = await dir.invites({ orgId: acct.id });
1541- body = (
1542- <>
1543- <h2>Invites</h2>
1544- {invite ? (
1545- <div class="box" style="border-color:var(--amber)">
1546- <div class="body">
1547- <h3>Invite created for {invite.email}</h3>
1548- <p class="muted">We emailed this link to them; you can also send it yourself. It expires in 14 days. Opening it lets them create a passkey and join.</p>
1549- <div class="row">
1550- <div class="secret grow">{invite.url}</div>
1551- <CopyButton text={invite.url} />
1552- </div>
1553- </div>
1554- </div>
1555- ) : null}
1556- <Form action={`${base}/-/org/invites`} t={t} class="box">
1557- <div class="head">
1558- <h3>Invite someone</h3>
1559- </div>
1560- <div class="body row" style="align-items:flex-end">
1561- <div class="field grow m0">
1562- <label>Email address</label>
1563- <input name="email" type="email" required placeholder="friend@example.com" />
1564- </div>
1565- <div class="field m0">
1566- <label>Role</label>
1567- <KSelect name="role" block label="Role" value="member" options={(isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: r }))} />
1568- </div>
1569- <button class="btn primary">Create invite</button>
1570- </div>
1571- </Form>
1572- <div class="box">
1573- {invites.length ? (
15741708 <table class="tbl">
1575- <thead>
1576- <tr>
1577- <th>Email</th>
1578- <th>Role</th>
1579- <th>Created</th>
1580- <th>Status</th>
1581- </tr>
1582- </thead>
15831709 <tbody>
15841710 {invites.map((i) => (
15851711 <tr>
1586- <td>{i.email}</td>
1587- <td>{i.role}</td>
1588- <td class="small">{ago(i.createdAt)}</td>
1589- <td class="small">
1590- {i.acceptedAt ? (
1591- <span class="badge green">accepted</span>
1592- ) : Date.parse(i.expiresAt) < Date.now() ? (
1593- <span class="badge">expired</span>
1594- ) : (
1595- <span class="badge amber">pending</span>
1596- )}
1712+ <td>
1713+ <b>{i.email}</b>
1714+ <div class="muted small">
1715+ Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""}
1716+ <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })}
1717+ </div>
15971718 </td>
1719+ <td>
1720+ <span class="badge">{ROLE_LABEL[i.role] ?? i.role}</span>
1721+ </td>
1722+ <td class="right">
1723+ <Form action={`${base}/-/org/invites/${i.id}/revoke`} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}>
1724+ <button class="btn sm danger">Revoke</button>
1725+ </Form>
1726+ </td>
15981727 </tr>
15991728 ))}
16001729 </tbody>
16011730 </table>
1602- ) : (
1603- <div class="empty">No invites yet.</div>
1604- )}
1605- </div>
1731+ </div>
1732+ ) : null}
16061733 </>
16071734 );
16081735 } else if (tab === "agents") {
@@ -1622,7 +1749,7 @@
16221749 <ul class="list">
16231750 {agents.map((a) => (
16241751 <li>
1625- <Actor login={a.login} kind="agent" />
1752+ <Actor login={a.login} kind="agent" version={a.avatar_version} />
16261753 <span class="grow" />
16271754 <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span>
16281755 <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span>
@@ -1639,6 +1766,7 @@
16391766 body = (
16401767 <>
16411768 <h2>Settings</h2>
1769+ <AvatarSection account={acct} action={`${base}/-/org/avatar`} t={t} />
16421770 <Form action={`${base}/-/org/settings`} t={t} class="box">
16431771 <div class="body">
16441772 <div class="field">
@@ -1661,7 +1789,7 @@
16611789 { title: acct.login },
16621790 <>
16631791 <div class="row mb" style="gap:16px">
1664- <Avatar login={acct.login} size="lg" />
1792+ <Avatar login={acct.login} size="lg" version={acct.avatar_version} />
16651793 <div>
16661794 <h1 class="m0">{acct.name ?? acct.login}</h1>
16671795 <div class="muted small">
@@ -1675,7 +1803,6 @@
16751803 [
16761804 ["repositories", "Repositories", true],
16771805 ["people", "People", true],
1678- ["invites", "Invites", isAdmin],
16791806 ["agents", "Agents", true],
16801807 ["settings", "Settings", isOwner],
16811808 ] as const
@@ -1695,22 +1822,44 @@
16951822
16961823 web.get("/:owner", async (c) => profile(c));
16971824
1698- post(web, "/:owner/-/org/invites", async (c, f) => {
1825+ // Add people: a username or an email. Existing accounts join now; new emails get an invitation.
1826+ post(web, "/:owner/-/org/people", async (c, f) => {
16991827 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
1700- if (!isAdmin) throw new HttpError(403, "organization admins only");
1701- const email = f.s("email").trim();
1828+ const back = `/${org.login}?tab=people`;
17021829 const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member";
1703- if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners");
1704- if (!/^[^\s@]+@[^\s@]+$/.test(email)) {
1705- return act(c, `/${org.login}?tab=invites`, async () => {
1706- throw new HttpError(422, "enter a valid email address");
1707- });
1708- }
1709- const inv = await dir.createInvite(me, { email, orgId: org.id, role });
1710- c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } }));
1711- return profile(c, { url: inv.url, email });
1830+ let invited: { email: string; url: string } | undefined;
1831+ const res = await act(c, back, async () => {
1832+ if (!isAdmin) throw new HttpError(403, "organization admins only");
1833+ if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can add owners");
1834+ const r = await dir.addOrgPerson(org, me, f.s("who"), role);
1835+ if ("added" in r) {
1836+ flash(c, "notice", `Added ${r.added.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. We let them know.`);
1837+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.member.set", target: org.login, detail: { user: r.added.login, role } }));
1838+ } else {
1839+ invited = r.invited;
1840+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email: r.invited.email, role } }));
1841+ }
1842+ });
1843+ return invited ? profile(c, invited) : res;
17121844 });
17131845
1846+ // The old invite form posts here.
1847+ post(web, "/:owner/-/org/invites", async (c) => c.redirect(`/${prm(c, "owner")}?tab=people`, 303));
1848+
1849+ post(web, "/:owner/-/org/invites/:id/revoke", async (c) => {
1850+ const { me, org, isAdmin, dir } = await orgContext(c);
1851+ return act(
1852+ c,
1853+ `/${org.login}?tab=people`,
1854+ async () => {
1855+ if (!isAdmin) throw new HttpError(403, "organization admins only");
1856+ await dir.revokeInvite(prm(c, "id"), { orgId: org.id });
1857+ c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite.revoke", target: org.login, detail: { id: prm(c, "id") } }));
1858+ },
1859+ "Invitation revoked.",
1860+ );
1861+ });
1862+
17141863 post(web, "/:owner/-/org/members", async (c, f) => {
17151864 const { me, org, isAdmin, isOwner, dir } = await orgContext(c);
17161865 const back = `/${org.login}?tab=people`;
@@ -1721,8 +1870,8 @@
17211870 const current = await dir.orgRole(org.id, user.id);
17221871 const role = f.s("role");
17231872 if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners");
1724- if (role === "remove") await dir.setOrgMember(org.id, user.id, null);
1725- else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role);
1873+ if (role === "remove") await dir.setOrgMember(org.id, user.id, null, me);
1874+ else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role, me);
17261875 else throw new HttpError(422, "invalid role");
17271876 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } }));
17281877 });
@@ -1734,8 +1883,26 @@
17341883 if (!isOwner) throw new HttpError(403, "organization owners only");
17351884 const login = f.s("login").trim();
17361885 await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() });
1886+ if (login && login !== org.login) c.executionCtx.waitUntil(purgeAvatarCache(c, org.login, [org.avatar_version]));
17371887 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } }));
17381888 return `/${login || org.login}?tab=settings`;
17391889 }, "Organization updated.");
17401890 });
1891+
1892+ avatarSizeGuard(web, "/:owner/-/org/avatar", (c) => `/${c.req.param("owner")}?tab=settings`);
1893+ post(web, "/:owner/-/org/avatar", async (c, f) => {
1894+ const { me, org, isOwner } = await orgContext(c);
1895+ return act(c, `/${org.login}?tab=settings`, async () => {
1896+ if (!isOwner) throw new HttpError(403, "organization owners only");
1897+ await uploadAvatar(c, f, me, org);
1898+ }, "Organization picture updated.");
1899+ });
1900+
1901+ post(web, "/:owner/-/org/avatar/delete", async (c) => {
1902+ const { me, org, isOwner } = await orgContext(c);
1903+ return act(c, `/${org.login}?tab=settings`, async () => {
1904+ if (!isOwner) throw new HttpError(403, "organization owners only");
1905+ await deleteAvatar(c, me, org);
1906+ }, "Organization picture removed.");
1907+ });
17411908 }
modifiedapps/diffinery/src/web/assets.ts+91−67View file
@@ -1,5 +1,28 @@
1+import { UI_ICONS } from "./icons.ui.generated.js";
2+
13 /** Inline stylesheet, client script and icons for the web UI (no external assets). */
24
5+/**
6+ * Dark theme tokens. Settings → Appearance puts data-theme="light" or "dark" on <html>; without it the
7+ * system setting decides. Dark-only rules elsewhere use the same two selectors (see `dark()`).
8+ */
9+const DARK_TOKENS = `--canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0);
10+--fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0);
11+--border:oklch(32% 0 0);--border2:oklch(26.9% 0 0);
12+--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22);
13+--link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0);
14+--green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17);
15+--amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37);
16+--purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22);
17+--add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5);
18+--code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)`;
19+const under = (prefix: string, sel: string): string => sel.split(",").map((x) => `${prefix} ${x.trim()}`).join(",");
20+/** A dark-mode rule: applies with data-theme="dark", and with the system setting unless data-theme="light". */
21+export const dark = (sel: string, decls: string): string =>
22+ `@media (prefers-color-scheme:dark){${under(":root:not([data-theme=light])", sel)}{${decls}}}${under(":root[data-theme=dark]", sel)}{${decls}}`;
23+const DARK = `@media (prefers-color-scheme:dark){:root:not([data-theme=light]){${DARK_TOKENS}}}:root[data-theme=dark]{${DARK_TOKENS}}
24+:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark}`;
25+
326 export const CSS = `
427 /* Cascade order: the site's own styles first, then Kumo's (build/islands/kumo.css, confined to .dfy-k
528 islands), then island layout. kumo-reset reverts site styles inside islands so Kumo controls look
@@ -21,18 +44,10 @@
2144 --code-bg:oklch(96% 0 0);--shadow:0 0 0 1px oklch(0% 0 0 / .12),0 8px 24px oklch(0% 0 0 / .08);--r:.5rem;--r-sm:.375rem;
2245 --mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;
2346 --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"}
24-@media (prefers-color-scheme:dark){:root{
25---canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0);
26---fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0);
27---border:oklch(32% 0 0);--border2:oklch(26.9% 0 0);
28---accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22);
29---link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0);
30---green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17);
31---amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37);
32---purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22);
33---add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5);
34---code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)}}
47+${DARK}
3548 *{box-sizing:border-box}
49+/* Phosphor icons sit on the text's center line when inline; flex containers center them themselves. */
50+svg[viewBox="0 0 256 256"]{vertical-align:-3px;flex:none}
3651 html{-webkit-text-size-adjust:100%}
3752 body{margin:0;background:var(--canvas);color:var(--fg);font:14px/1.5 var(--sans)}
3853 a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
@@ -58,32 +73,44 @@
5873 .flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)}
5974 .flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)}
6075 .flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)}
61-.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important}
76+/* Controls follow Kumo sizes: base 36px (buttons, inputs, selects), sm 26px. */
77+.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:36px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 14px/1 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important;vertical-align:middle;flex:none}
78+.btn svg{flex:none}
6279 .btn:hover{background:var(--bg-hover);border-color:var(--fg3)}
6380 .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);box-shadow:inset 0 1px 0 0 oklch(100% 0 0 / .15)}.btn.primary:hover{background:var(--accent-hover)}
6481 .btn.green{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.green:hover{background:var(--accent-hover)}
6582 .btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff}
66-.btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0}
83+.btn.sm{height:26px;padding:0 8px;gap:4px;font-size:12px;border-radius:var(--r-sm)}.btn.sm svg{width:14px;height:14px}.btn.icon{width:36px;padding:0}.btn.sm.icon{width:26px}
84+.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0;height:auto}
6785 .btn[disabled]{opacity:.5;cursor:not-allowed}
6886 input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),textarea{
69-width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit}
70-textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre}
87+width:100%;height:36px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit}
88+input[type=file]{display:block;width:100%;font:inherit;font-size:13px;color:var(--fg2);padding:6px 0}
89+input[type=file]::file-selector-button{height:30px;margin-right:10px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1 var(--sans);cursor:pointer}
90+input[type=file]::file-selector-button:hover{background:var(--bg-hover)}
91+.checklist{display:flex;flex-direction:column;align-items:flex-start;gap:8px}
92+textarea{height:auto;padding:8px 12px;line-height:1.5;min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre}
7193 input:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--focus) 25%,transparent);outline-offset:0;border-color:color-mix(in srgb,var(--focus) 50%,transparent)}
7294 :focus-visible{outline:2px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px}
7395 label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400}
7496 .field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400}
7597 .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.row.end{justify-content:flex-end}.row.between{justify-content:space-between}
7698 .grow{flex:1;min-width:0}
99+.row.nowrap{flex-wrap:nowrap}
100+/* A one-line form: fields and the submit button share a baseline at the control's bottom edge. */
101+.formrow{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap}.formrow>.field{margin:0}.formrow>.field.grow{min-width:220px}
102+.formrow+.hint{font-size:12px;color:var(--fg2);margin-top:8px}
77103 .grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px}
78104 .grid.left{grid-template-columns:260px minmax(0,1fr)}
79105 @media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}}
80106 .box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden}
81107 .box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap}
82108 .box>.body{padding:14px}
83-.box>.head h2,.box>.head h3{margin:0;font-size:14px}
109+.box>.head h2,.box>.head h3{margin:0;font-size:14px;display:inline-flex;align-items:center;gap:6px}
84110 .list{list-style:none;margin:0;padding:0}
85111 .list>li{display:flex;gap:10px;align-items:flex-start;padding:10px 14px;border-top:1px solid var(--border2)}
86112 .list>li:first-child{border-top:0}.list>li:hover{background:var(--bg2)}
113+.list>li>.st:first-child,.list>li>svg:first-child{margin-top:2px}
87114 .list .title{font-weight:600;color:var(--fg)}.list .title:hover{color:var(--accent)}
88115 .list .meta{font-size:12px;color:var(--fg2);margin-top:2px}
89116 .empty{padding:32px 16px;text-align:center;color:var(--fg2)}
@@ -98,8 +125,13 @@
98125 .state{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-weight:600;font-size:13px;color:#fff}
99126 .state.open{background:var(--green)}.state.closed{background:var(--red)}.state.merged{background:var(--purple)}.state.draft{background:var(--fg3)}
100127 .lbl{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:550;line-height:20px;background:color-mix(in srgb,var(--lc) 18%,transparent);color:color-mix(in srgb,var(--lc) 70%,var(--fg));border:1px solid color-mix(in srgb,var(--lc) 40%,transparent);margin-right:4px}
101-.av{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:hsl(var(--h) 55% 55%);color:#fff;font-size:11px;font-weight:700;flex:none;vertical-align:middle}
102-.av.lg{width:40px;height:40px;font-size:18px}.av.xl{width:96px;height:96px;font-size:40px}.av.bot{border-radius:6px}
128+.av{display:inline-block;width:20px;height:20px;border-radius:50%;flex:none;vertical-align:middle;object-fit:cover;background:var(--bg3);outline:1px solid var(--border);outline-offset:-1px}
129+.av.lg{width:40px;height:40px}.av.xl{width:96px;height:96px}.av.bot{border-radius:6px}.av.xl.bot{border-radius:16px}
130+input[type=file]{max-width:100%;font:13px/1.4 var(--sans);color:var(--fg2)}
131+input[type=file]::file-selector-button{margin-right:10px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer}
132+input[type=file]::file-selector-button:hover{background:var(--bg-hover);border-color:var(--fg3)}
133+.avpick{display:flex;gap:20px;align-items:center;flex-wrap:wrap}.avpick .grow{min-width:240px}
134+.avpick [data-avatar-status]:empty{display:none}
103135 .who{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--fg)}
104136 .repohead{padding:18px 0 0;border-bottom:1px solid var(--border);background:var(--bg2)}
105137 .repohead h1{font-size:19px;font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px}
@@ -110,8 +142,9 @@
110142 .tabs a.on{color:var(--fg);font-weight:600;border-bottom-color:var(--accent)}
111143 .tabs .n{background:var(--bg3);border-radius:999px;padding:0 7px;font-size:12px;color:var(--fg2)}
112144 .subnav{display:flex;gap:0;border:1px solid var(--border);border-radius:7px;overflow:hidden;width:max-content;max-width:100%}
113-.subnav a{padding:5px 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0}
145+.subnav a{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0}
114146 .subnav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.subnav a:hover{text-decoration:none;background:var(--bg-hover)}
147+.side .nav h4{font-size:11px;color:var(--fg3);text-transform:uppercase;letter-spacing:.04em;margin:14px 10px 4px}
115148 .side h3{font-size:12px;color:var(--fg2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px}
116149 .side section{padding:0 0 16px;margin:0 0 16px;border-bottom:1px solid var(--border2)}
117150 .side .nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--fg)}.side .nav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.side .nav a:hover{background:var(--bg-hover);text-decoration:none}
@@ -131,6 +164,7 @@
131164 .clone{display:flex;gap:6px;align-items:center}
132165 .clone input{font-family:var(--mono);font-size:12px}
133166 pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px}
167+pre.cmd.one{padding:7px 12px;line-height:20px;margin:0;white-space:pre-wrap;word-break:break-all}
134168 .code-wrap{overflow:auto}
135169 table.blob{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12.5px;line-height:20px}
136170 table.blob td{padding:0 12px;vertical-align:top;white-space:pre}
@@ -148,6 +182,10 @@
148182 table.diff .addc{visibility:hidden;border:0;background:var(--accent);color:#fff;border-radius:4px;width:18px;height:18px;line-height:16px;cursor:pointer;font-weight:700;padding:0}
149183 table.diff tr:hover .addc{visibility:visible}
150184 table.diff tr.thread td{background:var(--bg2);padding:8px 12px;font-family:var(--sans);font-size:14px;white-space:normal}
185+/* Shiki tokens carry both GitHub themes as custom properties; untokened text keeps the site's color. */
186+.sh>span{color:var(--shiki-light);font-style:var(--shiki-light-font-style);font-weight:var(--shiki-light-font-weight);text-decoration:var(--shiki-light-text-decoration)}
187+:root[data-theme=dark] .sh>span{color:var(--shiki-dark);font-style:var(--shiki-dark-font-style);font-weight:var(--shiki-dark-font-weight);text-decoration:var(--shiki-dark-text-decoration)}
188+@media (prefers-color-scheme:dark){:root:not([data-theme=light]) .sh>span{color:var(--shiki-dark);font-style:var(--shiki-dark-font-style);font-weight:var(--shiki-dark-font-weight);text-decoration:var(--shiki-dark-text-decoration)}}
151189 .filehead{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:52px;z-index:5;flex-wrap:wrap}
152190 details.file{border:1px solid var(--border);border-radius:var(--r);margin:0 0 14px;overflow:clip}
153191 details.file>summary{list-style:none}details.file>summary::-webkit-details-marker{display:none}
@@ -222,9 +260,12 @@
222260 table.tbl{width:100%;border-collapse:collapse;font-size:13px}
223261 table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap}
224262 table.tbl td{padding:8px 12px;border-top:1px solid var(--border2);vertical-align:middle}
225-.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:7px;padding:10px 12px;word-break:break-all}
263+.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:var(--r);padding:7px 12px;line-height:20px;min-height:36px;word-break:break-all}
226264 .pager{display:flex;gap:8px;justify-content:center;margin:16px 0}
227265 .sha{font-family:var(--mono);font-size:12px;padding:1px 6px;border-radius:5px;background:var(--bg3);color:var(--fg)}
266+/* Buttons joined into one control (copy · sha · browse). */
267+.btngroup{display:inline-flex;flex:none}.btngroup>.btn{border-radius:0;margin-left:-1px}.btngroup>.btn:first-child{border-radius:var(--r-sm) 0 0 var(--r-sm);margin-left:0}.btngroup>.btn:last-child{border-radius:0 var(--r-sm) var(--r-sm) 0}.btngroup>.btn:hover{position:relative;z-index:1}
268+.btn.mono{font-family:var(--mono)}
228269 a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)}
229270 .right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0}
230271 form.inline{display:inline}
@@ -237,7 +278,7 @@
237278 .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
238279 .task-box{display:inline-block;width:14px;height:14px;margin:0 4px -2px 0;border-radius:4px;box-shadow:inset 0 0 0 1px var(--border);background:var(--bg)}
239280 .task-box.on{background:var(--fg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='white' d='M232.49 80.49l-128 128a12 12 0 0 1-17 0l-56-56a12 12 0 1 1 17-17L96 183 215.51 63.51a12 12 0 0 1 17 17Z'/%3E%3C/svg%3E") center/10px no-repeat;box-shadow:none}
240-@media (prefers-color-scheme:dark){.task-box.on{filter:invert(1)}}
281+${dark(".task-box.on", "filter:invert(1)")}
241282 }
242283 @layer kumo-reset{.dfy-k,.dfy-k :not(svg,svg *){all:revert}}
243284 @layer islands{
@@ -261,6 +302,12 @@
261302 .kf-dot{border-radius:50%}.kf-dot.on{box-shadow:inset 0 0 0 5px var(--fg)}
262303 .kf-radio{display:flex;flex-direction:column;gap:8px}.kf-radio.horizontal{flex-direction:row;gap:16px;flex-wrap:wrap}
263304 .kf-desc{display:block;color:var(--fg2);font-size:12px}
305+.theme-opt{display:flex;align-items:center;gap:8px}
306+.theme-prev{display:inline-flex;flex-direction:column;justify-content:center;gap:2px;width:30px;height:20px;padding:0 5px;border-radius:4px;box-shadow:0 0 0 1px var(--border);flex:none}
307+.theme-prev i{display:block;height:2px;border-radius:2px;background:#a1a1aa}.theme-prev i:first-child{width:55%;background:oklch(48.8% 0.243 264.376)}.theme-prev i:last-child{width:75%}
308+.theme-prev.light{background:#fff}.theme-prev.light i:not(:first-child){background:#d4d4d8}
309+.theme-prev.dark{background:#1c1c1e}.theme-prev.dark i:not(:first-child){background:#52525b}
310+.theme-prev.auto{background:linear-gradient(135deg,#fff 50%,#1c1c1e 50%)}
264311 }
265312 `;
266313
@@ -271,7 +318,7 @@
271318 const enc=p=>p.split('/').map(encodeURIComponent).join('/');
272319 d.addEventListener('click',e=>{
273320 const c=e.target.closest('[data-copy]');
274- if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.textContent;c.textContent='Copied';setTimeout(()=>{c.textContent=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return}
321+ if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.innerHTML;if(c.dataset.copyIcon)c.innerHTML=${JSON.stringify(UI_ICONS.check)};else c.textContent='Copied';setTimeout(()=>{c.innerHTML=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return}
275322 const p=e.target.closest('[data-palette]');if(p){e.preventDefault();openPalette();return}
276323 const a=e.target.closest('[data-comment-line]');if(a){e.preventDefault();lineComment(a);return}
277324 const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()}
@@ -310,6 +357,26 @@
310357 row.querySelector('[name=side]').value=btn.dataset.side||'RIGHT';row.querySelector('.where').textContent=btn.dataset.path+':'+btn.dataset.commentLine;
311358 row.querySelector('textarea').focus();
312359 }
360+// Profile pictures: preview the centred square, then upload it at most 512 px wide (the server makes the small WebPs).
361+// Files this browser cannot decode (HEIC in Chrome, say) go up as they are; the server's answer comes back as the flash message.
362+let webpOk=null;
363+async function squareBlob(file){const bmp=await createImageBitmap(file);const side=Math.min(bmp.width,bmp.height),out=Math.min(512,side);
364+ const cv=d.createElement('canvas');cv.width=cv.height=out;const ctx=cv.getContext('2d');ctx.imageSmoothingQuality='high';
365+ ctx.drawImage(bmp,(bmp.width-side)/2,(bmp.height-side)/2,side,side,0,0,out,out);if(bmp.close)bmp.close();
366+ if(webpOk===null){const t=d.createElement('canvas');t.width=t.height=1;webpOk=t.toDataURL('image/webp').startsWith('data:image/webp')}
367+ return new Promise((ok,no)=>cv.toBlob(x=>x?ok(x):no(new Error('encode failed')),webpOk?'image/webp':'image/png',0.92))}
368+$$('form[data-avatar]').forEach(f=>{const inp=$('input[type=file]',f),st=$('[data-avatar-status]',f),box=f.closest('.avpick')||d,img=$('[data-avatar-preview] img',box);
369+ if(!inp)return;let blob=null,url='';const orig=img&&img.getAttribute('src');const say=t=>{if(st)st.textContent=t};
370+ inp.addEventListener('change',async()=>{blob=null;if(url){URL.revokeObjectURL(url);url=''}const file=inp.files&&inp.files[0];
371+ if(!file){if(img&&orig)img.src=orig;say('');return}
372+ try{blob=await squareBlob(file);url=URL.createObjectURL(blob);if(img){img.loading='eager';img.src=url}say('Preview. Save to use this picture.')}
373+ catch(_){blob=null;if(img&&orig)img.src=orig;say('No preview for this file; it will be checked when you save.')}});
374+ f.addEventListener('submit',async e=>{const file=inp.files&&inp.files[0];if(e.defaultPrevented||!file)return;e.preventDefault();
375+ const fd=new FormData(f);if(blob)fd.set(inp.name,blob,blob.type==='image/webp'?'avatar.webp':'avatar.png');
376+ say('Uploading…');$$('button',f).forEach(x=>{x.disabled=true});
377+ // The answer is a redirect that sets the flash message; follow it by reloading the page.
378+ try{await fetch(f.action,{method:'POST',body:fd,credentials:'same-origin',redirect:'manual'})}catch(_){}
379+ location.reload()})});
313380 // Palette
314381 const dlg=$('#palette'),inp=dlg&&$('input',dlg),ul=dlg&&$('ul',dlg);let items=null,shown=[],sel=0,loading=false;
315382 function base(){const a=[['Dashboard','/'],['New repository','/new'],['Notifications','/notifications'],['Settings','/settings'],['Tokens','/settings/tokens'],['Agents','/settings/agents']].map(([t,u])=>({t,u,k:'Go to'}));
@@ -372,53 +439,10 @@
372439 connect()}
373440 })();`;
374441
375-/** Inline SVG icons (16×16, stroke-based). Static strings only. */
376-const I = (body: string, extra = "") => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${body}</svg>`;
377-
442+/** Inline SVG icons (16×16, Phosphor, see scripts/make-ui-icons.mjs) plus the logo. Static strings only. */
378443 export const ICONS = {
379444 logo: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>`,
380- success: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'),
381- package: I('<path d="M8 1.5l6 3v7l-6 3-6-3v-7z"/><path d="M2 4.5l6 3 6-3M8 7.5v7"/>'),
382- failure: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>'),
383- running: I('<circle cx="8" cy="8" r="6.5" opacity=".25"/><path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"/>'),
384- queued: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/>'),
385- cancelled: I('<circle cx="8" cy="8" r="6.5"/><path d="M3.4 12.6l9.2-9.2"/>'),
386- skipped: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'),
387- neutral: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'),
388- issueOpen: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/>'),
389- issueClosed: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'),
390- pr: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V6.5a2 2 0 0 0-2-2H7.5M9 3l-1.5 1.5L9 6"/>'),
391- prMerged: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="8" r="1.8"/><path d="M4 5.3v5.4M4 5.3c0 2 1.5 2.7 6.2 2.7"/>'),
392- prClosed: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V8M10.3 2.3l3.4 3.4M13.7 2.3l-3.4 3.4"/>'),
393- dir: I('<path d="M1.8 3.5h4.3l1.5 1.6h6.6v7.4H1.8z" fill="currentColor" fill-opacity=".15"/>'),
394- file: I('<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5z"/><path d="M9 1.8v3.5h3.5"/>'),
395- submodule: I('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 8h6"/>'),
396- branch: I('<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 6.7c0 3-7 2-7 4.1"/>'),
397- tag: I('<path d="M2 2.5h5.5l6.5 6.5-5 5-6.5-6.5z"/><circle cx="5" cy="5.5" r="1" fill="currentColor"/>'),
398- commit: I('<circle cx="8" cy="8" r="2.5"/><path d="M1 8h4.5M10.5 8H15"/>'),
399- lock: I('<rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'),
400- globe: I('<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2.2 2 10.8 0 13M8 1.5c-2 2.2-2 10.8 0 13"/>'),
401- search: I('<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/>'),
402- bell: I('<path d="M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11zM6.5 14h3"/>'),
403- plus: I('<path d="M8 3v10M3 8h10"/>'),
404- gear: I('<circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/>'),
405- play: I('<path d="M5 3.5v9l7-4.5z"/>'),
406- code: I('<path d="M5.5 4.5L2 8l3.5 3.5M10.5 4.5L14 8l-3.5 3.5"/>'),
407- comment: I('<path d="M2.5 3h11v7.5H7L4 13v-2.5H2.5z"/>'),
408- eye: I('<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>'),
409- bot: I('<rect x="2.5" y="5" width="11" height="8" rx="2"/><path d="M8 2.5V5M5.5 9h.01M10.5 9h.01"/>'),
410- history: I('<path d="M2 8a6 6 0 1 0 1.8-4.3M2 2.5v3h3"/><path d="M8 5v3l2 1.5"/>'),
411- copy: I('<rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M3 10.5H2.5v-8h8V3"/>'),
412- download: I('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'),
413- edit: I('<path d="M10.5 2.5l3 3-8 8H2.5v-3z"/>'),
414- x: I('<path d="M4 4l8 8M12 4l-8 8"/>'),
415- check: I('<path d="M3 8.5l3 3 7-7"/>'),
416- people: I('<circle cx="6" cy="5.5" r="2.3"/><path d="M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4"/><path d="M10.5 3.4a2.3 2.3 0 0 1 0 4.4M12 9.7c1.3.5 2.2 1.9 2.2 3.8"/>'),
417- key: I('<circle cx="5" cy="11" r="3"/><path d="M7.2 8.8L13.5 2.5M11 5l2 2"/>'),
418- shield: I('<path d="M8 1.5l5.5 2v4c0 3.5-2.4 6-5.5 7-3.1-1-5.5-3.5-5.5-7v-4z"/>'),
419- repo: I('<path d="M3 2.5h9.5v9H4.5A1.5 1.5 0 0 0 3 13z"/><path d="M3 13a1.5 1.5 0 0 0 1.5 1.5h8v-3"/>'),
420- org: I('<rect x="2.5" y="2" width="11" height="12.5" rx="1"/><path d="M5.5 5h1M9.5 5h1M5.5 8h1M9.5 8h1M7 14.5V11.5h2v3"/>'),
421- zap: I('<path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/>'),
445+ ...UI_ICONS,
422446 } as const;
423447
424448 export type IconName = keyof typeof ICONS;
modifiedapps/diffinery/src/web/auth.tsx+6−2View file
@@ -360,7 +360,8 @@
360360 return c.json({ redirect: "/settings/security" });
361361 } else if (s.kind === "invite") {
362362 // Re-check: the invite or the username may have been taken while the browser was busy.
363- if (!(await auth.inviteByHash(s.inviteHash))) throw new HttpError(404, "This invite was used or expired in the meantime.");
363+ const inv = await auth.inviteByHash(s.inviteHash);
364+ if (!inv) throw new HttpError(404, "This invite was used or expired in the meantime.");
364365 if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`);
365366 if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`);
366367 // Creating the account accepts every open invite for this email, including this one.
@@ -368,7 +369,10 @@
368369 await auth.addPasskey({ ...key, account_id: account.id });
369370 c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } }));
370371 flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`);
371- redirect = "/";
372+ // Land where they were invited to: the organization or the repository.
373+ const repo = inv.repo_id ? await dir.repoById(inv.repo_id) : null;
374+ const org = !repo && inv.org_id ? await dir.account(inv.org_id) : null;
375+ redirect = repo ? `/${repo.owner_login}/${repo.name}` : org ? `/${org.login}` : "/";
372376 } else if (s.kind === "link") {
373377 if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime.");
374378 const found = await dir.account(s.accountId);
modifiedapps/diffinery/src/web/code.tsx+7−3View file
@@ -10,6 +10,7 @@
1010 import { csrfToken, type FormData } from "./csrf.js";
1111 import { CheckName } from "./checks.js";
1212 import { ReleasesSection } from "./releases.js";
13+import { highlightLines, langForPath } from "./highlight.js";
1314 import {
1415 CommitRow,
1516 CopyButton,
@@ -37,6 +38,7 @@
3738 splitRefPath,
3839 statusOf,
3940 Status,
41+ tokens,
4042 type DiffFile,
4143 type WebApp,
4244 prm,
@@ -76,7 +78,7 @@
7678 const CloneBox: FC<{ url: string }> = ({ url }) => (
7779 <div class="clone">
7880 <input type="text" readonly value={url} aria-label="Clone URL" />
79- <CopyButton text={url} />
81+ <CopyButton text={url} size="base" />
8082 </div>
8183 );
8284
@@ -581,15 +583,17 @@
581583 </div>
582584 );
583585 } else {
586+ const shown = lines.map((l) => l.replace(/\r$/, ""));
587+ const toks = await highlightLines(shown, langForPath(path));
584588 content = (
585589 <div class="code-wrap">
586590 <table class="blob">
587- {lines.map((line, i) => (
591+ {shown.map((line, i) => (
588592 <tr id={`L${i + 1}`}>
589593 <td class="ln">
590594 <a href={`#L${i + 1}`}>{i + 1}</a>
591595 </td>
592- <td>{line.replace(/\r$/, "")}</td>
596+ {toks?.[i] ? <td class="sh">{tokens(toks[i])}</td> : <td>{line}</td>}
593597 </tr>
594598 ))}
595599 </table>
modifiedapps/diffinery/src/web/collab.tsx+9−10View file
@@ -14,6 +14,7 @@
1414 import { CheckName } from "./checks.js";
1515 import {
1616 Actor,
17+ Avatar,
1718 CommitRow,
1819 DiffStats,
1920 DiffView,
@@ -143,15 +144,15 @@
143144 <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" />
144145 </form>
145146 <div class="row">
146- <a class="btn sm" href={`${base}/labels`}>
147- Labels
147+ <a class="btn" href={`${base}/labels`}>
148+ {raw(ICONS.tag)} Labels
148149 </a>
149150 {kind === "pull" ? (
150- <a class="btn sm primary" href={`${base}/compare`}>
151+ <a class="btn primary" href={`${base}/compare`}>
151152 New pull request
152153 </a>
153154 ) : isAccount && !repo.archived ? (
154- <a class="btn sm primary" href={`${base}/issues/new`}>
155+ <a class="btn primary" href={`${base}/issues/new`}>
155156 New issue
156157 </a>
157158 ) : null}
@@ -544,9 +545,7 @@
544545 <div class="row" style="gap:4px">
545546 {participants.map((a) => (
546547 <a href={`/${encodeURIComponent(a.login)}`} title={a.login}>
547- <span class={`av ${a.kind === "agent" ? "bot" : ""}`} style={`--h:${[...a.login].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360}`}>
548- {(a.login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()}
549- </span>
548+ <Avatar login={a.login} kind={a.kind} />
550549 </a>
551550 ))}
552551 </div>
@@ -560,7 +559,7 @@
560559 if (!can) return null;
561560 return (
562561 <details class="menu">
563- <summary class="btn sm">Edit</summary>
562+ <summary class="btn">{raw(ICONS.edit)} Edit</summary>
564563 <div class="pop right" style="width:min(560px,90vw)">
565564 <Form action={`${repoUrl(x.l.repo)}/issues/${x.issue.number}/edit`} t={csrfToken(x.c)} style="padding:8px">
566565 <div class="field">
@@ -571,7 +570,7 @@
571570 <label>Description</label>
572571 <textarea name="body">{x.issue.body}</textarea>
573572 </div>
574- <button class="btn primary sm">Save</button>
573+ <button class="btn primary">Save</button>
575574 </Form>
576575 </div>
577576 </details>
@@ -590,7 +589,7 @@
590589 <div class="row">
591590 <TitleEditor x={x} />
592591 {i.kind === "issue" ? (
593- <a class="btn sm primary" href={`${base}/issues/new`}>
592+ <a class="btn primary" href={`${base}/issues/new`}>
594593 New issue
595594 </a>
596595 ) : null}
modifiedapps/diffinery/src/web/csrf.ts+7−0View file
@@ -35,6 +35,8 @@
3535 b(name: string): boolean;
3636 /** Field names present in the form. */
3737 keys(): string[];
38+ /** An uploaded file (multipart forms); null when the field is missing or empty. */
39+ file(name: string): File | null;
3840 }
3941
4042 /** Form bodies bigger than this are refused before they are read (routes taking files pass a larger cap). */
@@ -70,6 +72,11 @@
7072 return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off";
7173 },
7274 keys: () => Object.keys(raw),
75+ file: (name) => {
76+ const v = raw[name];
77+ const f = (Array.isArray(v) ? v : [v]).find((x): x is File => typeof x === "object" && x !== null && typeof (x as File).arrayBuffer === "function");
78+ return f && f.size > 0 ? f : null;
79+ },
7380 };
7481 }
7582
addedapps/diffinery/src/web/highlight.ts+326−0View file
@@ -1,0 +1,326 @@
1+/**
2+ * Syntax highlighting with Shiki (JavaScript regex engine, fine-grained grammars
3+ * loaded on first use). Tokens carry both GitHub themes as CSS variables
4+ * (--shiki-light / --shiki-dark); the site stylesheet picks one. Everything here
5+ * fails soft: an unknown language, an oversized input or an error means plain text.
6+ */
7+import { createHighlighterCore, type HighlighterCore, type LanguageRegistration, type ThemedToken, type ThemeRegistration } from "shiki/core";
8+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
9+import { escapeHtml } from "./markdown.js";
10+
11+type Grammar = () => Promise<{ default: LanguageRegistration[] }>;
12+
13+const GRAMMARS = {
14+ astro: () => import("shiki/langs/astro.mjs"),
15+ bat: () => import("shiki/langs/bat.mjs"),
16+ c: () => import("shiki/langs/c.mjs"),
17+ clojure: () => import("shiki/langs/clojure.mjs"),
18+ cmake: () => import("shiki/langs/cmake.mjs"),
19+ cpp: () => import("shiki/langs/cpp.mjs"),
20+ csharp: () => import("shiki/langs/csharp.mjs"),
21+ css: () => import("shiki/langs/css.mjs"),
22+ dart: () => import("shiki/langs/dart.mjs"),
23+ diff: () => import("shiki/langs/diff.mjs"),
24+ docker: () => import("shiki/langs/docker.mjs"),
25+ dotenv: () => import("shiki/langs/dotenv.mjs"),
26+ elixir: () => import("shiki/langs/elixir.mjs"),
27+ erlang: () => import("shiki/langs/erlang.mjs"),
28+ fish: () => import("shiki/langs/fish.mjs"),
29+ fsharp: () => import("shiki/langs/fsharp.mjs"),
30+ go: () => import("shiki/langs/go.mjs"),
31+ graphql: () => import("shiki/langs/graphql.mjs"),
32+ groovy: () => import("shiki/langs/groovy.mjs"),
33+ haskell: () => import("shiki/langs/haskell.mjs"),
34+ hcl: () => import("shiki/langs/hcl.mjs"),
35+ html: () => import("shiki/langs/html.mjs"),
36+ ini: () => import("shiki/langs/ini.mjs"),
37+ java: () => import("shiki/langs/java.mjs"),
38+ javascript: () => import("shiki/langs/javascript.mjs"),
39+ json: () => import("shiki/langs/json.mjs"),
40+ json5: () => import("shiki/langs/json5.mjs"),
41+ jsonc: () => import("shiki/langs/jsonc.mjs"),
42+ jsx: () => import("shiki/langs/jsx.mjs"),
43+ julia: () => import("shiki/langs/julia.mjs"),
44+ kotlin: () => import("shiki/langs/kotlin.mjs"),
45+ less: () => import("shiki/langs/less.mjs"),
46+ lua: () => import("shiki/langs/lua.mjs"),
47+ make: () => import("shiki/langs/make.mjs"),
48+ markdown: () => import("shiki/langs/markdown.mjs"),
49+ nginx: () => import("shiki/langs/nginx.mjs"),
50+ nix: () => import("shiki/langs/nix.mjs"),
51+ "objective-c": () => import("shiki/langs/objective-c.mjs"),
52+ ocaml: () => import("shiki/langs/ocaml.mjs"),
53+ perl: () => import("shiki/langs/perl.mjs"),
54+ php: () => import("shiki/langs/php.mjs"),
55+ powershell: () => import("shiki/langs/powershell.mjs"),
56+ proto: () => import("shiki/langs/proto.mjs"),
57+ python: () => import("shiki/langs/python.mjs"),
58+ r: () => import("shiki/langs/r.mjs"),
59+ ruby: () => import("shiki/langs/ruby.mjs"),
60+ rust: () => import("shiki/langs/rust.mjs"),
61+ sass: () => import("shiki/langs/sass.mjs"),
62+ scala: () => import("shiki/langs/scala.mjs"),
63+ scss: () => import("shiki/langs/scss.mjs"),
64+ shellscript: () => import("shiki/langs/shellscript.mjs"),
65+ sql: () => import("shiki/langs/sql.mjs"),
66+ svelte: () => import("shiki/langs/svelte.mjs"),
67+ swift: () => import("shiki/langs/swift.mjs"),
68+ terraform: () => import("shiki/langs/terraform.mjs"),
69+ toml: () => import("shiki/langs/toml.mjs"),
70+ tsx: () => import("shiki/langs/tsx.mjs"),
71+ typescript: () => import("shiki/langs/typescript.mjs"),
72+ vue: () => import("shiki/langs/vue.mjs"),
73+ xml: () => import("shiki/langs/xml.mjs"),
74+ yaml: () => import("shiki/langs/yaml.mjs"),
75+ zig: () => import("shiki/langs/zig.mjs"),
76+} satisfies Record<string, Grammar>;
77+
78+export type Lang = keyof typeof GRAMMARS;
79+export const LANGS = Object.keys(GRAMMARS) as Lang[];
80+
81+const EXT: Record<string, Lang> = {
82+ ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx",
83+ js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx",
84+ json: "json", webmanifest: "json", geojson: "json", jsonc: "jsonc", json5: "json5",
85+ yaml: "yaml", yml: "yaml", toml: "toml",
86+ md: "markdown", markdown: "markdown", mdown: "markdown", mkd: "markdown",
87+ html: "html", htm: "html", xhtml: "html",
88+ css: "css", scss: "scss", sass: "sass", less: "less",
89+ py: "python", pyi: "python", pyw: "python", go: "go", rs: "rust", java: "java", kt: "kotlin", kts: "kotlin", swift: "swift",
90+ c: "c", h: "c", cc: "cpp", cpp: "cpp", cxx: "cpp", "c++": "cpp", hpp: "cpp", hh: "cpp", hxx: "cpp", ino: "cpp",
91+ cs: "csharp", csx: "csharp", php: "php", phtml: "php", rb: "ruby", rake: "ruby", gemspec: "ruby", ru: "ruby",
92+ sh: "shellscript", bash: "shellscript", zsh: "shellscript", ksh: "shellscript", command: "shellscript", fish: "fish",
93+ ps1: "powershell", psm1: "powershell", psd1: "powershell", bat: "bat", cmd: "bat",
94+ sql: "sql", graphql: "graphql", gql: "graphql",
95+ xml: "xml", xsd: "xml", xsl: "xml", xslt: "xml", svg: "xml", plist: "xml", csproj: "xml", fsproj: "xml", vbproj: "xml", props: "xml", targets: "xml", resx: "xml", xaml: "xml", rss: "xml", atom: "xml",
96+ lua: "lua", dart: "dart", scala: "scala", sc: "scala", sbt: "scala", r: "r", pl: "perl", pm: "perl",
97+ ex: "elixir", exs: "elixir", erl: "erlang", hrl: "erlang", hs: "haskell",
98+ vue: "vue", svelte: "svelte", astro: "astro", tf: "terraform", tfvars: "terraform", hcl: "hcl", nomad: "hcl",
99+ ini: "ini", cfg: "ini", conf: "ini", properties: "ini", env: "dotenv",
100+ mk: "make", mak: "make", cmake: "cmake", diff: "diff", patch: "diff", proto: "proto", zig: "zig",
101+ m: "objective-c", groovy: "groovy", gradle: "groovy", gvy: "groovy", clj: "clojure", cljs: "clojure", cljc: "clojure", edn: "clojure",
102+ ml: "ocaml", mli: "ocaml", fs: "fsharp", fsi: "fsharp", fsx: "fsharp", jl: "julia", nix: "nix",
103+};
104+
105+const NAMES: Record<string, Lang | null> = {
106+ dockerfile: "docker", containerfile: "docker", makefile: "make", gnumakefile: "make", "cmakelists.txt": "cmake",
107+ gemfile: "ruby", rakefile: "ruby", podfile: "ruby", vagrantfile: "ruby", brewfile: "ruby", guardfile: "ruby", fastfile: "ruby", "gemfile.lock": null,
108+ jenkinsfile: "groovy", "nginx.conf": "nginx", "cargo.lock": "toml", "go.mod": null, "go.sum": null,
109+ ".bashrc": "shellscript", ".bash_profile": "shellscript", ".bash_aliases": "shellscript", ".zshrc": "shellscript", ".zprofile": "shellscript", ".zshenv": "shellscript", ".profile": "shellscript",
110+ ".gitconfig": "ini", ".editorconfig": "ini", ".npmrc": "ini", ".gitmodules": "ini",
111+ ".babelrc": "json", ".eslintrc": "json", ".prettierrc": "json", ".swcrc": "json",
112+ ".gitignore": null, ".dockerignore": null, ".npmignore": null, ".gitattributes": null, ".prettierignore": null, ".eslintignore": null,
113+};
114+
115+/** Shiki language for a repository path, or null when unknown (rendered as plain text). */
116+export function langForPath(path: string): Lang | null {
117+ const name = (path.split("/").pop() ?? "").toLowerCase();
118+ if (name in NAMES) return NAMES[name]!;
119+ if (name === ".env" || name.startsWith(".env.")) return "dotenv";
120+ if (name.startsWith("dockerfile.") || name.endsWith(".dockerfile")) return "docker";
121+ if (name.startsWith("makefile.")) return "make";
122+ if (/^(tsconfig|jsconfig)(\.[\w-]+)*\.json$/.test(name) || /(^|\/)\.vscode\/[^/]+\.json$/i.test(path) || name === "devcontainer.json") return "jsonc";
123+ const dot = name.lastIndexOf(".");
124+ return dot >= 0 ? (EXT[name.slice(dot + 1)] ?? null) : null;
125+}
126+
127+const ALIASES: Record<string, Lang> = {
128+ ...EXT,
129+ typescript: "typescript", javascript: "javascript", node: "javascript", python: "python", python3: "python", py3: "python", golang: "go", rust: "rust",
130+ kotlin: "kotlin", csharp: "csharp", "c#": "csharp", cpp: "cpp", "objective-c": "objective-c", objc: "objective-c", objectivec: "objective-c",
131+ ruby: "ruby", shell: "shellscript", shellscript: "shellscript", console: "shellscript", shellsession: "shellscript", powershell: "powershell", pwsh: "powershell", ps: "powershell",
132+ batch: "bat", docker: "docker", dockerfile: "docker", make: "make", makefile: "make", perl: "perl", elixir: "elixir", erlang: "erlang", haskell: "haskell",
133+ terraform: "terraform", nginx: "nginx", dotenv: "dotenv", protobuf: "proto", "f#": "fsharp", fsharp: "fsharp", ocaml: "ocaml", julia: "julia", clojure: "clojure",
134+ postgres: "sql", postgresql: "sql", psql: "sql", mysql: "sql", sqlite: "sql", plpgsql: "sql", jsonl: "json", "objective-cpp": "cpp",
135+};
136+
137+/** Shiki language for a Markdown fence info string ("ts", "yaml", "c++" …), or null. */
138+export function langForFence(info: string): Lang | null {
139+ const name = info.trim().toLowerCase().replace(/^\{?\.?/, "").replace(/\}$/, "");
140+ return name in GRAMMARS ? (name as Lang) : (ALIASES[name] ?? null);
141+}
142+
143+// ---------------------------------------------------------------- highlighter
144+
145+/** Highlighting limits: beyond them, plain text. Workers have little CPU to spare. */
146+export const LIMITS = { lines: 2000, bytes: 200_000, lineLength: 2000 };
147+
148+export interface Tok {
149+ text: string;
150+ /** CSS custom properties (--shiki-light etc.); absent for the default foreground. */
151+ style?: string;
152+}
153+export type TokLine = Tok[];
154+
155+const PAIR = "github-pair";
156+
157+/**
158+ * GitHub's light and dark default themes share one rule list (same scopes and font styles,
159+ * different colors). Tokenizing once with a theme whose rule colors are
160+ * placeholders, then decoding each placeholder to both palettes, gives exactly
161+ * Shiki's dual-theme output at half the CPU (dual themes tokenize once per theme).
162+ */
163+function pairThemes(light: ThemeRegistration, dark: ThemeRegistration): { theme: ThemeRegistration; palette: Map<string, [string, string]> } {
164+ const lr = light.tokenColors ?? [];
165+ const dr = dark.tokenColors ?? [];
166+ const fg = (t: ThemeRegistration) => (t.fg ?? t.colors?.["editor.foreground"] ?? "").toUpperCase();
167+ const palette = new Map<string, [string, string]>([["#000000", [fg(light), fg(dark)]]]);
168+ if (lr.length !== dr.length || !palette.get("#000000")!.every(Boolean)) throw new Error("themes do not pair");
169+ const tokenColors = lr.map((r, i) => {
170+ const d = dr[i]!;
171+ if (JSON.stringify(r.scope) !== JSON.stringify(d.scope) || r.settings.fontStyle !== d.settings.fontStyle || !r.settings.foreground !== !d.settings.foreground) throw new Error("themes do not pair");
172+ const settings: { fontStyle?: string; foreground?: string } = r.settings.fontStyle !== undefined ? { fontStyle: r.settings.fontStyle } : {};
173+ if (r.settings.foreground) {
174+ settings.foreground = `#${(i + 1).toString(16).padStart(6, "0").toUpperCase()}`;
175+ palette.set(settings.foreground, [r.settings.foreground.toUpperCase(), d.settings.foreground!.toUpperCase()]);
176+ }
177+ return { scope: r.scope, settings };
178+ });
179+ return { theme: { name: PAIR, type: "light", fg: "#000000", bg: "#ffffff", colors: { "editor.foreground": "#000000", "editor.background": "#ffffff" }, tokenColors }, palette };
180+}
181+
182+let core: Promise<HighlighterCore> | null = null;
183+let palette = new Map<string, [string, string]>();
184+const styles = new Map<string, string | undefined>();
185+const loading = new Map<Lang, Promise<void>>();
186+
187+function highlighter(): Promise<HighlighterCore> {
188+ core ??= Promise.all([import("shiki/themes/github-light-default.mjs"), import("shiki/themes/github-dark-default.mjs")])
189+ .then(([l, d]) => {
190+ const pair = pairThemes(l.default, d.default);
191+ palette = pair.palette;
192+ return createHighlighterCore({ themes: [pair.theme], langs: [], engine: createJavaScriptRegexEngine({ forgiving: true }) });
193+ })
194+ .catch((e) => {
195+ core = null;
196+ throw e;
197+ });
198+ return core;
199+}
200+
201+/** The highlighter with these grammars loaded; null when anything fails. */
202+export async function ready(langs: Iterable<Lang>): Promise<HighlighterCore | null> {
203+ try {
204+ const hl = await highlighter();
205+ await Promise.all(
206+ [...new Set(langs)].map((l) => {
207+ let p = loading.get(l);
208+ if (!p) {
209+ p = hl.loadLanguage(GRAMMARS[l]()).catch((e) => {
210+ loading.delete(l);
211+ throw e;
212+ });
213+ loading.set(l, p);
214+ }
215+ return p;
216+ }),
217+ );
218+ return hl;
219+ } catch (e) {
220+ console.warn("highlight: load failed", e);
221+ return null;
222+ }
223+}
224+
225+/** Shiki's dual-theme custom properties for a placeholder color and font style; undefined for plain text. */
226+function styleOf(color: string | undefined, fontStyle: number | undefined): string | undefined {
227+ const key = `${color}/${fontStyle}`;
228+ if (styles.has(key)) return styles.get(key);
229+ const plain = palette.get("#000000")!;
230+ const [l, d] = (color && palette.get(color.toUpperCase())) || plain;
231+ const fs = fontStyle && fontStyle > 0 ? fontStyle : 0;
232+ let style: string | undefined;
233+ if (l !== plain[0] || d !== plain[1] || fs) {
234+ const deco = [fs & 4 ? "underline" : "", fs & 8 ? "line-through" : ""].filter(Boolean).join(" ");
235+ const font = (side: string) => `${fs & 1 ? `;--shiki-${side}-font-style:italic` : ""}${fs & 2 ? `;--shiki-${side}-font-weight:bold` : ""}${deco ? `;--shiki-${side}-text-decoration:${deco}` : ""}`;
236+ style = `--shiki-light:${l}${font("light")};--shiki-dark:${d}${font("dark")}`;
237+ }
238+ styles.set(key, style);
239+ return style;
240+}
241+
242+function toks(line: ThemedToken[]): TokLine {
243+ const out: TokLine = [];
244+ for (const t of line) {
245+ const style = styleOf(t.color, t.fontStyle);
246+ const last = out[out.length - 1];
247+ if (last && last.style === style) last.text += t.content;
248+ else out.push(style ? { text: t.content, style } : { text: t.content });
249+ }
250+ return out;
251+}
252+
253+/** Tokenize code with a highlighter that already has `lang` loaded; null when over the limits or on error. */
254+export function tokenize(hl: HighlighterCore, code: string, lang: Lang): TokLine[] | null {
255+ if (code.length > LIMITS.bytes) return null;
256+ try {
257+ const { tokens } = hl.codeToTokens(code, { lang, theme: PAIR, tokenizeMaxLineLength: LIMITS.lineLength, tokenizeTimeLimit: 200 });
258+ return tokens.map(toks);
259+ } catch (e) {
260+ console.warn("highlight: tokenize failed", e);
261+ return null;
262+ }
263+}
264+
265+/** Highlight whole-file lines (as shown in the file view); null means render plain text. */
266+export async function highlightLines(lines: string[], lang: Lang | null): Promise<TokLine[] | null> {
267+ if (!lang || !lines.length || lines.length > LIMITS.lines) return null;
268+ let bytes = 0;
269+ for (const l of lines) bytes += l.length + 1;
270+ if (bytes > LIMITS.bytes) return null;
271+ const hl = await ready([lang]);
272+ const out = hl && tokenize(hl, lines.join("\n"), lang);
273+ return out && out.length === lines.length ? out : null;
274+}
275+
276+/**
277+ * Tokens for each line of a unified-diff hunk. The old side (context and deletions)
278+ * and the new side (context and additions) are tokenized as contiguous snippets so
279+ * grammar state carries from line to line, then mapped back to rows. Rows that are
280+ * not code ("\ No newline at end of file") get null, as does every row on failure.
281+ */
282+export function diffLineTokens(lines: string[], tokenizeSide: (code: string) => TokLine[] | null): Array<TokLine | null> {
283+ const olds: string[] = [];
284+ const news: string[] = [];
285+ for (const l of lines) {
286+ const k = l[0];
287+ if (k === "\\") continue;
288+ if (k !== "+") olds.push(l.slice(1));
289+ if (k !== "-") news.push(l.slice(1));
290+ }
291+ const o = olds.length ? tokenizeSide(olds.join("\n")) : [];
292+ const n = news.length ? tokenizeSide(news.join("\n")) : [];
293+ if (!o || !n || o.length !== olds.length || n.length !== news.length) return lines.map(() => null);
294+ let oi = 0;
295+ let ni = 0;
296+ return lines.map((l) => {
297+ const k = l[0];
298+ if (k === "\\") return null;
299+ if (k === "+") return n[ni++] ?? null;
300+ if (k === "-") return o[oi++] ?? null;
301+ oi++;
302+ return n[ni++] ?? null;
303+ });
304+}
305+
306+/** Render tokens as HTML (for the Markdown renderer, which builds strings). */
307+export const tokensHtml = (lines: TokLine[]): string =>
308+ lines.map((l) => l.map((t) => (t.style ? `<span style="${escapeHtml(t.style)}">${escapeHtml(t.text)}</span>` : escapeHtml(t.text))).join("")).join("\n");
309+
310+/** A sync fence highlighter for renderMarkdown, with the grammars the document's fences name already loaded. */
311+export async function fenceHighlighter(langs: string[]): Promise<((code: string, info: string) => string | null) | undefined> {
312+ const wanted = [...new Set(langs.map(langForFence).filter((l): l is Lang => l !== null))].slice(0, 8);
313+ if (!wanted.length) return undefined;
314+ const hl = await ready(wanted);
315+ if (!hl) return undefined;
316+ let budget = LIMITS.lines;
317+ return (code, info) => {
318+ const lang = langForFence(info);
319+ if (!lang || !wanted.includes(lang)) return null;
320+ const n = code.split("\n").length;
321+ if (n > budget) return null;
322+ budget -= n;
323+ const t = tokenize(hl, code, lang);
324+ return t ? tokensHtml(t) : null;
325+ };
326+}
addedapps/diffinery/src/web/icons.ui.generated.ts+55−0View file
@@ -1,0 +1,55 @@
1+// Generated by scripts/make-ui-icons.mjs from Phosphor icons (MIT). Do not edit.
2+export const UI_ICONS = {
3+ success: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z\"></path></svg>",
4+ failure: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z\"></path></svg>",
5+ running: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M236,128a108,108,0,0,1-216,0c0-42.52,24.73-81.34,63-98.9A12,12,0,1,1,93,50.91C63.24,64.57,44,94.83,44,128a84,84,0,0,0,168,0c0-33.17-19.24-63.43-49-77.09A12,12,0,1,1,173,29.1C211.27,46.66,236,85.48,236,128Z\"></path></svg>",
6+ queued: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.13,104.13,0,0,0,128,24ZM84,140a12,12,0,1,1,12-12A12,12,0,0,1,84,140Zm44,0a12,12,0,1,1,12-12A12,12,0,0,1,128,140Zm44,0a12,12,0,1,1,12-12A12,12,0,0,1,172,140Z\"></path></svg>",
7+ cancelled: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm84,108a83.6,83.6,0,0,1-16.75,50.28L77.72,60.75A84,84,0,0,1,212,128ZM44,128A83.6,83.6,0,0,1,60.75,77.72L178.28,195.25A84,84,0,0,1,44,128Z\"></path></svg>",
8+ skipped: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M176,128a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h80A8,8,0,0,1,176,128Zm56,0A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z\"></path></svg>",
9+ neutral: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M176,128a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h80A8,8,0,0,1,176,128Zm56,0A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z\"></path></svg>",
10+ issueOpen: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm0-160a72,72,0,1,0,72,72A72.08,72.08,0,0,0,128,56Zm0,128a56,56,0,1,1,56-56A56.06,56.06,0,0,1,128,184Z\"></path></svg>",
11+ issueClosed: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z\"></path></svg>",
12+ pr: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M104,64A32,32,0,1,0,64,95v66a32,32,0,1,0,16,0V95A32.06,32.06,0,0,0,104,64ZM56,64A16,16,0,1,1,72,80,16,16,0,0,1,56,64ZM88,192a16,16,0,1,1-16-16A16,16,0,0,1,88,192Zm120-31V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H144a8,8,0,0,0-8,8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Zm-8,47a16,16,0,1,1,16-16A16,16,0,0,1,200,208Z\"></path></svg>",
13+ prMerged: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,112a32.05,32.05,0,0,0-30.69,23l-42.21-6a8,8,0,0,1-4.95-2.71L94.43,84.55A32,32,0,1,0,72,87v82a32,32,0,1,0,16,0V101.63l30,35a24,24,0,0,0,14.83,8.14l44,6.28A32,32,0,1,0,208,112ZM64,56A16,16,0,1,1,80,72,16,16,0,0,1,64,56ZM96,200a16,16,0,1,1-16-16A16,16,0,0,1,96,200Zm112-40a16,16,0,1,1,16-16A16,16,0,0,1,208,160Z\"></path></svg>",
14+ prClosed: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M104,64A32,32,0,1,0,64,95v66a32,32,0,1,0,16,0V95A32.06,32.06,0,0,0,104,64ZM56,64A16,16,0,1,1,72,80,16,16,0,0,1,56,64ZM88,192a16,16,0,1,1-16-16A16,16,0,0,1,88,192Zm120-31V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H144a8,8,0,0,0-8,8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Zm-8,47a16,16,0,1,1,16-16A16,16,0,0,1,200,208Z\"></path></svg>",
15+ dir: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M232,88V200.89A15.13,15.13,0,0,1,216.89,216H40a16,16,0,0,1-16-16V64A16,16,0,0,1,40,48H93.33a16.12,16.12,0,0,1,9.6,3.2L130.67,72H216A16,16,0,0,1,232,88Z\"></path></svg>",
16+ file: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z\"></path></svg>",
17+ submodule: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M24,80V64A16,16,0,0,1,40,48H93.33a16.12,16.12,0,0,1,9.6,3.2L132.8,73.6a8,8,0,1,1-9.6,12.8L93.33,64H40V80a8,8,0,0,1-16,0ZM88,200H40v-8a8,8,0,0,0-16,0v8.62A15.4,15.4,0,0,0,39.38,216H88a8,8,0,0,0,0-16Zm72,0H128a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm64-56a8,8,0,0,0-8,8v48H200a8,8,0,0,0,0,16h16.89A15.13,15.13,0,0,0,232,200.89V152A8,8,0,0,0,224,144Zm-8-72H168a8,8,0,0,0,0,16h48v24a8,8,0,0,0,16,0V88A16,16,0,0,0,216,72ZM32,160a8,8,0,0,0,8-8V120a8,8,0,0,0-16,0v32A8,8,0,0,0,32,160Z\"></path></svg>",
18+ branch: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M232,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H96a23.84,23.84,0,0,0-8,1.38V95a32,32,0,1,0-16,0v66a32,32,0,1,0,16,0V144a8,8,0,0,1,8-8h88a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,232,64ZM64,64A16,16,0,1,1,80,80,16,16,0,0,1,64,64ZM96,192a16,16,0,1,1-16-16A16,16,0,0,1,96,192ZM200,80a16,16,0,1,1,16-16A16,16,0,0,1,200,80Z\"></path></svg>",
19+ tag: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z\"></path></svg>",
20+ commit: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M248,120H183.42a56,56,0,0,0-110.84,0H8a8,8,0,0,0,0,16H72.58a56,56,0,0,0,110.84,0H248a8,8,0,0,0,0-16ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z\"></path></svg>",
21+ lock: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Z\"></path></svg>",
22+ globe: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z\"></path></svg>",
23+ search: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z\"></path></svg>",
24+ bell: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z\"></path></svg>",
25+ plus: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z\"></path></svg>",
26+ gear: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z\"></path></svg>",
27+ play: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48.24-94.78-64-40A8,8,0,0,0,100,88v80a8,8,0,0,0,12.24,6.78l64-40a8,8,0,0,0,0-13.56ZM116,153.57V102.43L156.91,128Z\"></path></svg>",
28+ code: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M69.12,94.15,28.5,128l40.62,33.85a8,8,0,1,1-10.24,12.29l-48-40a8,8,0,0,1,0-12.29l48-40a8,8,0,0,1,10.24,12.3Zm176,27.7-48-40a8,8,0,1,0-10.24,12.3L227.5,128l-40.62,33.85a8,8,0,1,0,10.24,12.29l48-40a8,8,0,0,0,0-12.29ZM162.73,32.48a8,8,0,0,0-10.25,4.79l-64,176a8,8,0,0,0,4.79,10.26A8.14,8.14,0,0,0,96,224a8,8,0,0,0,7.52-5.27l64-176A8,8,0,0,0,162.73,32.48Z\"></path></svg>",
29+ comment: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-6.54-.67L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Z\"></path></svg>",
30+ eye: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z\"></path></svg>",
31+ bot: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z\"></path></svg>",
32+ history: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M136,80v43.47l36.12,21.67a8,8,0,0,1-8.24,13.72l-40-24A8,8,0,0,1,120,128V80a8,8,0,0,1,16,0Zm-8-48A95.44,95.44,0,0,0,60.08,60.15C52.81,67.51,46.35,74.59,40,82V64a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H72a8,8,0,0,0,0-16H49c7.15-8.42,14.27-16.35,22.39-24.57a80,80,0,1,1,1.66,114.75,8,8,0,1,0-11,11.64A96,96,0,1,0,128,32Z\"></path></svg>",
33+ copy: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z\"></path></svg>",
34+ download: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z\"></path></svg>",
35+ edit: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z\"></path></svg>",
36+ x: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z\"></path></svg>",
37+ check: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z\"></path></svg>",
38+ people: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M117.25,157.92a60,60,0,1,0-66.5,0A95.83,95.83,0,0,0,3.53,195.63a8,8,0,1,0,13.4,8.74,80,80,0,0,1,134.14,0,8,8,0,0,0,13.4-8.74A95.83,95.83,0,0,0,117.25,157.92ZM40,108a44,44,0,1,1,44,44A44.05,44.05,0,0,1,40,108Zm210.14,98.7a8,8,0,0,1-11.07-2.33A79.83,79.83,0,0,0,172,168a8,8,0,0,1,0-16,44,44,0,1,0-16.34-84.87,8,8,0,1,1-5.94-14.85,60,60,0,0,1,55.53,105.64,95.83,95.83,0,0,1,47.22,37.71A8,8,0,0,1,250.14,206.7Z\"></path></svg>",
39+ key: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z\"></path></svg>",
40+ shield: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z\"></path></svg>",
41+ repo: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,24H72A32,32,0,0,0,40,56V224a8,8,0,0,0,8,8H192a8,8,0,0,0,0-16H56a16,16,0,0,1,16-16H208a8,8,0,0,0,8-8V32A8,8,0,0,0,208,24ZM120,40h48v72L148.79,97.6a8,8,0,0,0-9.6,0L120,112Zm80,144H72a31.82,31.82,0,0,0-16,4.29V56A16,16,0,0,1,72,40h32v88a8,8,0,0,0,12.8,6.4L144,114l27.21,20.4A8,8,0,0,0,176,136a8,8,0,0,0,8-8V40h16Z\"></path></svg>",
42+ org: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M240,208H224V96a16,16,0,0,0-16-16H144V32a16,16,0,0,0-24.88-13.32L39.12,72A16,16,0,0,0,32,85.34V208H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM208,96V208H144V96ZM48,85.34,128,32V208H48ZM112,112v16a8,8,0,0,1-16,0V112a8,8,0,1,1,16,0Zm-32,0v16a8,8,0,0,1-16,0V112a8,8,0,1,1,16,0Zm0,56v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Zm32,0v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Z\"></path></svg>",
43+ zap: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z\"></path></svg>",
44+ package: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32l80.34,44-29.77,16.3-80.35-44ZM128,120,47.66,76l33.9-18.56,80.34,44ZM40,90l80,43.78v85.79L40,175.82Zm176,85.78h0l-80,43.79V133.82l32-17.51V152a8,8,0,0,0,16,0V107.55L216,90v85.77Z\"></path></svg>",
45+ upload: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0ZM93.66,77.66,120,51.31V144a8,8,0,0,0,16,0V51.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,77.66Z\"></path></svg>",
46+ trash: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z\"></path></svg>",
47+ userPlus: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136Zm-57.87,58.85a8,8,0,0,1-12.26,10.3C165.75,181.19,138.09,168,108,168s-57.75,13.19-77.87,37.15a8,8,0,0,1-12.25-10.3c14.94-17.78,33.52-30.41,54.17-37.17a68,68,0,1,1,71.9,0C164.6,164.44,183.18,177.07,198.13,194.85ZM108,152a52,52,0,1,0-52-52A52.06,52.06,0,0,0,108,152Z\"></path></svg>",
48+ envelope: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z\"></path></svg>",
49+ sun: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z\"></path></svg>",
50+ moon: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z\"></path></svg>",
51+ signOut: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z\"></path></svg>",
52+ caret: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z\"></path></svg>",
53+ link: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M165.66,90.34a8,8,0,0,1,0,11.32l-64,64a8,8,0,0,1-11.32-11.32l64-64A8,8,0,0,1,165.66,90.34ZM215.6,40.4a56,56,0,0,0-79.2,0L106.34,70.45a8,8,0,0,0,11.32,11.32l30.06-30a40,40,0,0,1,56.57,56.56l-30.07,30.06a8,8,0,0,0,11.31,11.32L215.6,119.6a56,56,0,0,0,0-79.2ZM138.34,174.22l-30.06,30.06a40,40,0,1,1-56.56-56.57l30.05-30.05a8,8,0,0,0-11.32-11.32L40.4,136.4a56,56,0,0,0,79.2,79.2l30.06-30.07a8,8,0,0,0-11.32-11.31Z\"></path></svg>",
54+ rocket: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M223.85,47.12a16,16,0,0,0-15-15c-12.58-.75-44.73.4-71.41,27.07L132.69,64H74.36A15.91,15.91,0,0,0,63,68.68L28.7,103a16,16,0,0,0,9.07,27.16l38.47,5.37,44.21,44.21,5.37,38.49a15.94,15.94,0,0,0,10.78,12.92,16.11,16.11,0,0,0,5.1.83A15.91,15.91,0,0,0,153,227.3L187.32,193A15.91,15.91,0,0,0,192,181.64V123.31l4.77-4.77C223.45,91.86,224.6,59.71,223.85,47.12ZM74.36,80h42.33L77.16,119.52,40,114.34Zm74.41-9.45a76.65,76.65,0,0,1,59.11-22.47,76.46,76.46,0,0,1-22.42,59.16L128,164.68,91.32,128ZM176,181.64,141.67,216l-5.19-37.17L176,139.31Zm-74.16,9.5C97.34,201,82.29,224,40,224a8,8,0,0,1-8-8c0-42.29,23-57.34,32.86-61.85a8,8,0,0,1,6.64,14.56c-6.43,2.93-20.62,12.36-23.12,38.91,26.55-2.5,36-16.69,38.91-23.12a8,8,0,1,1,14.56,6.64Z\"></path></svg>",
55+} as const;
modifiedapps/diffinery/src/web/markdown.ts+17−2View file
@@ -20,6 +20,8 @@
2020
2121 export interface MarkdownOptions {
2222 base?: MarkdownBase;
23+ /** Syntax highlighter for fenced code: HTML for the code, or null for plain text. */
24+ highlight?: (code: string, lang: string) => string | null;
2325 }
2426
2527 const MAX_SOURCE = 512 * 1024;
@@ -393,8 +395,10 @@
393395 i++;
394396 }
395397 i++;
396- const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : "";
397- parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`);
398+ const named = /^[\w+#.-]{1,30}$/.test(lang) ? lang : "";
399+ const hl = named && opts.highlight ? opts.highlight(body.join("\n"), named) : null;
400+ const cls = named ? ` class="language-${escapeHtml(named)}${hl !== null ? " sh" : ""}"` : "";
401+ parts.push(`<pre><code${cls}>${hl ?? escapeHtml(body.join("\n"))}</code></pre>`);
398402 continue;
399403 }
400404 // Indented code (4 spaces), only outside lists.
@@ -536,6 +540,17 @@
536540 return parts.join("\n");
537541 }
538542
543+/** Languages named by the document's code fences, to load grammars before rendering. */
544+export function fenceLangs(source: string): string[] {
545+ if (source.length > MAX_SOURCE) return [];
546+ const out = new Set<string>();
547+ for (const line of source.split("\n")) {
548+ const lang = FENCE.exec(line)?.[2];
549+ if (lang && /^[\w+#.-]{1,30}$/.test(lang)) out.add(lang);
550+ }
551+ return [...out];
552+}
553+
539554 /** Render Markdown to sanitized HTML. */
540555 export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string {
541556 if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`;
modifiedapps/diffinery/src/web/packages.tsx+5−5View file
@@ -92,16 +92,16 @@
9292 <div class="body">
9393 <p class="muted small m0">Sign in with any username and a token from Settings → Tokens as the password (the read scope pulls, write pushes).</p>
9494 <div class="row mt">
95- <pre class="secret grow m0 mono">{login}</pre>
95+ <pre class="cmd one grow m0 mono">{login}</pre>
9696 </div>
9797 <div class="row mt">
98- <pre class="secret grow m0 mono">{pull}</pre>
99- <CopyButton text={pull} />
98+ <pre class="cmd one grow m0 mono">{pull}</pre>
99+ <CopyButton text={pull} size="base" />
100100 </div>
101101 <p class="muted small mt">
102102 In a workflow, the job's <code>GITHUB_TOKEN</code> signs in. It can push to packages of its own repository (with <code>permissions: packages: write</code> when the workflow sets permissions) and pull its owner's packages.
103103 </p>
104- <pre class="secret m0 mono small">{workflow}</pre>
104+ <pre class="cmd m0 mono small">{workflow}</pre>
105105 </div>
106106 </div>
107107 );
@@ -118,7 +118,7 @@
118118 { title: `${owner.login} packages` },
119119 <>
120120 <div class="row mb" style="gap:12px">
121- <Avatar login={owner.login} size="lg" />
121+ <Avatar login={owner.login} size="lg" version={owner.avatar_version} />
122122 <div>
123123 <h1 class="m0">
124124 <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages
modifiedapps/diffinery/src/web/releases.tsx+4−4View file
@@ -118,7 +118,7 @@
118118 <Badges r={r} />
119119 </h2>
120120 <div class="muted small" style="margin-top:4px">
121- <span class="st faint">{raw(ICONS.tag)}</span>{" "}
121+ <span class="faint">{raw(ICONS.tag)}</span>{" "}
122122 <a class="mono" href={`${base}/tree/${encPath(r.tagName)}`}>
123123 {r.tagName}
124124 </a>{" "}
@@ -200,11 +200,11 @@
200200 <div class="row between mb">
201201 <h1 class="m0">Releases</h1>
202202 <div class="row">
203- <a class="btn sm" href={`${base}/tags`}>
203+ <a class="btn" href={`${base}/tags`}>
204204 {raw(ICONS.tag)} Tags
205205 </a>
206206 {canWrite ? (
207- <a class="btn sm green" href={`${base}/releases/new`}>
207+ <a class="btn green" href={`${base}/releases/new`}>
208208 Draft a new release
209209 </a>
210210 ) : null}
@@ -295,7 +295,7 @@
295295 Attached: {r.assets.map((a) => a.name).join(", ")}. Remove files from the <a href={tagHref(base, r.tagName)}>release page</a>.
296296 </p>
297297 ) : null}
298- <div class="field">
298+ <div class="field checklist">
299299 <KCheckbox name="prerelease" checked={v.prerelease} label="Set as a pre-release (not production ready)" />
300300 <KCheckbox name="latest" checked={v.latest} label="Set as the latest release" />
301301 {r ? null : <KCheckbox name="generate" checked={v.generate} label="Generate release notes from merged pull requests (when the notes are empty)" />}
modifiedapps/diffinery/src/web/repo-settings.tsx+39−47View file
@@ -8,7 +8,7 @@
88 import { ICONS } from "./assets.js";
99 import { KCheckbox, KRadio, KSelect } from "./kumo.js";
1010 import { csrfToken, type FormData } from "./csrf.js";
11-import { Actor, CopyButton, Form, ROLES, Time, act, ago, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
11+import { Actor, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js";
1212
1313 type Loaded = Awaited<ReturnType<typeof loadRepo>>;
1414 type Tab = "general" | "access" | "branches" | "secrets" | "webhooks";
@@ -64,7 +64,7 @@
6464 <p class="muted">{note}</p>
6565 <div class="row">
6666 <div class="secret grow">{value}</div>
67- <CopyButton text={value} />
67+ <CopyButton text={value} size="base" />
6868 </div>
6969 </div>
7070 </div>
@@ -222,8 +222,8 @@
222222 </div>
223223 <div class="field">
224224 <label>Visibility</label>
225- <KRadio name="visibility" label="Visibility" orientation="horizontal" value={repo.visibility} items={(["private", "internal", "public"] as const).map((v) => ({ value: v, label: v }))} />
226- <div class="hint">Internal repositories are visible to everyone signed in to this instance; public ones to anyone.</div>
225+ <KRadio name="visibility" label="Visibility" orientation="horizontal" value={repo.visibility} items={(["private", "public"] as const).map((v) => ({ value: v, label: v === "public" ? "Public" : "Private" }))} />
226+ <div class="hint">Private repositories are visible to you, collaborators and organization members; public ones to anyone.</div>
227227 </div>
228228 <button class="btn primary">Save changes</button>
229229 </div>
@@ -280,7 +280,26 @@
280280 </>
281281 )}
282282 </p>
283- {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days. Opening it lets them create a passkey and join." /> : null}
283+ {invite ? <Once title={`Invitation sent to ${invite.email}`} value={invite.url} note="They don't have an account yet, so we emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: opening it creates their account and passkey and gives them access." /> : null}
284+ <Form action={`${base}/settings/access/people`} t={t} class="box">
285+ <div class="head">
286+ <h3>{raw(ICONS.userPlus)} Add people</h3>
287+ </div>
288+ <div class="body">
289+ <div class="formrow">
290+ <div class="field grow">
291+ <label for="who">Username or email</label>
292+ <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="octocat, review-bot or name@example.com" />
293+ </div>
294+ <div class="field">
295+ <label for="role">Role</label>
296+ <KSelect id="role" name="role" label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} />
297+ </div>
298+ <button class="btn primary">Add</button>
299+ </div>
300+ <div class="hint">People and agents with an account get access right away and a heads-up. Anyone else gets an email invitation: opening it creates their account with access to this repository.</div>
301+ </div>
302+ </Form>
284303 <div class="box">
285304 {collaborators.length ? (
286305 <table class="tbl">
@@ -300,7 +319,7 @@
300319 <td>
301320 <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap">
302321 <input type="hidden" name="login" value={x.account.login} />
303- <KSelect name="role" size="sm" label={`Role of ${x.account.login}`} value={x.role} options={ROLES.map((r) => ({ value: r, label: r }))} />
322+ <KSelect name="role" size="sm" label={`Role of ${x.account.login}`} value={x.role} options={ROLES.map((r) => ({ value: r, label: cap(r) }))} />
304323 <button class="btn sm">Save</button>
305324 </Form>
306325 </td>
@@ -319,40 +338,6 @@
319338 <div class="empty">No direct collaborators.</div>
320339 )}
321340 </div>
322- <div class="row" style="align-items:stretch">
323- <Form action={`${base}/settings/access`} t={t} class="box grow">
324- <div class="head">
325- <h3>Add by username</h3>
326- </div>
327- <div class="body">
328- <div class="field">
329- <label>Username (person or agent)</label>
330- <input name="login" type="text" required />
331- </div>
332- <div class="field">
333- <label>Role</label>
334- <KSelect name="role" block label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: r }))} />
335- </div>
336- <button class="btn primary">Add collaborator</button>
337- </div>
338- </Form>
339- <Form action={`${base}/settings/access/invite`} t={t} class="box grow">
340- <div class="head">
341- <h3>Invite by email</h3>
342- </div>
343- <div class="body">
344- <div class="field">
345- <label>Email address</label>
346- <input name="email" type="email" required placeholder="friend@example.com" />
347- </div>
348- <div class="field">
349- <label>Role</label>
350- <KSelect name="role" block label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: r }))} />
351- </div>
352- <button class="btn primary">Create invite link</button>
353- </div>
354- </Form>
355- </div>
356341 <div class="box">
357342 <div class="body small muted">
358343 <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access.
@@ -599,7 +584,7 @@
599584 `${repoUrl(l.repo)}/settings`,
600585 async () => {
601586 const name = f.s("name").trim();
602- const visibility = (["private", "internal", "public"] as const).find((v) => v === f.s("visibility"));
587+ const visibility = (["private", "public"] as const).find((v) => v === f.s("visibility"));
603588 const defaultBranch = f.s("default_branch").trim();
604589 if (name && name !== l.repo.name && (await dir.repo(l.repo.owner_login, name))) throw new HttpError(422, "a repository with that name already exists");
605590 const updated = await dir.updateRepo(l.repo, {
@@ -660,15 +645,22 @@
660645 });
661646 });
662647
663- post(web, "/:owner/:repo/settings/access/invite", async (c, f) => {
648+ post(web, "/:owner/:repo/settings/access/people", async (c, f) => {
664649 const l = await load(c);
665650 const me = requireAccount(c);
666- const email = f.s("email").trim();
667651 const role = ROLES.find((r) => r === f.s("role")) ?? "write";
668- if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address");
669- const inv = await c.get("dir").createInvite(me, { email, repoId: l.repo.id, role });
670- audit(c, l, "repo.invite", { email, role });
671- return accessPage(c, l, { url: inv.url, email });
652+ let invited: { email: string; url: string } | undefined;
653+ const res = await act(c, `${repoUrl(l.repo)}/settings/access`, async () => {
654+ const r = await c.get("dir").addRepoPerson(l.repo, me, f.s("who"), role);
655+ if ("added" in r) {
656+ flash(c, "notice", `${r.added.login} now has ${role} access. We let them know.`);
657+ audit(c, l, "repo.collaborator.set", { login: r.added.login, role });
658+ } else {
659+ invited = r.invited;
660+ audit(c, l, "repo.invite", { email: r.invited.email, role });
661+ }
662+ });
663+ return invited ? accessPage(c, l, invited) : res;
672664 });
673665
674666 post(web, "/:owner/:repo/settings/branches", async (c, f) => {
modifiedapps/diffinery/src/web/ui.tsx+97−42View file
@@ -1,7 +1,7 @@
11 /** @jsxImportSource hono/jsx */
22 import type { Hono } from "hono";
33 import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4-import { raw } from "hono/html";
4+import { html, raw } from "hono/html";
55 import type { Child, FC, PropsWithChildren } from "hono/jsx";
66 import type { Account, Repo } from "../lib/directory.js";
77 import type { Env } from "../env.js";
@@ -11,10 +11,12 @@
1111 import type { repoStub } from "../http/common.js";
1212 import { CSS, ICONS, JS, type IconName } from "./assets.js";
1313 import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js";
14-import { renderMarkdown, type MarkdownBase } from "./markdown.js";
14+import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js";
15+import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js";
1516 import { KSelect, kumoAssets } from "./kumo.js";
1617 import { MailStore, type WatchLevel } from "../mail/prefs.js";
1718 import { SeoHead, SITE } from "./seo.js";
19+import { avatarPath } from "../http/avatars.js";
1820
1921 export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>;
2022 export type Stub = ReturnType<typeof repoStub>;
@@ -103,22 +105,19 @@
103105 }
104106 };
105107
106-function hue(s: string): number {
107- let h = 0;
108- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
109- return h % 360;
110-}
108+/** CSS pixels of each avatar size, and the stored square (2×) served for it. */
109+const AVATAR_PX = { sm: [20, 64], lg: [40, 128], xl: [96, 256] } as const;
111110
112-export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl" }> = ({ login, kind, size }) => (
113- <span class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} style={`--h:${hue(login)}`} aria-hidden="true">
114- {(login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()}
115- </span>
116-);
111+/** Profile picture, or generated initials. Pass the account's avatar_version when it is at hand, so a new upload shows at once. */
112+export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl"; version?: number | null }> = ({ login, kind, size, version }) => {
113+ const [css, px] = AVATAR_PX[size ?? "sm"];
114+ return <img class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} src={avatarPath(login, px, version)} width={css} height={css} alt="" loading="lazy" decoding="async" />;
115+};
117116
118117 /** Linked actor name with an "agent" badge and the human it acts for. */
119-export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean }> = ({ login, kind, onBehalfOf, avatar = true }) => (
118+export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean; version?: number | null }> = ({ login, kind, onBehalfOf, avatar = true, version }) => (
120119 <span class="who">
121- {avatar ? <Avatar login={login} kind={kind} /> : null}
120+ {avatar ? <Avatar login={login} kind={kind} version={version} /> : null}
122121 {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>}
123122 {kind === "agent" ? <span class="badge agent">agent</span> : null}
124123 {kind === "system" ? <span class="badge">bot</span> : null}
@@ -141,20 +140,30 @@
141140 );
142141 };
143142
144-export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = ({ text, base, empty }) =>
145- text.trim() ? <div class="markdown">{raw(renderMarkdown(text, { base }))}</div> : <p class="muted m0"><em>{empty ?? "No description provided."}</em></p>;
143+export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = async ({ text, base, empty }) =>
144+ text.trim() ? (
145+ <div class="markdown">{raw(renderMarkdown(text, { base, highlight: await fenceHighlighter(fenceLangs(text)) }))}</div>
146+ ) : (
147+ <p class="muted m0">
148+ <em>{empty ?? "No description provided."}</em>
149+ </p>
150+ );
146151
147-/** CSRF-protected POST form. */
148-export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string }>> = (p) => (
149- <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style}>
152+/** Highlighted code tokens (children of an element with class "sh"). */
153+export const tokens = (line: TokLine): Child[] => line.map((t) => (t.style ? <span style={t.style}>{t.text}</span> : t.text));
154+
155+/** CSRF-protected POST form. `avatar` makes it a multipart picture upload (shrunk in the browser first, see assets.ts). */
156+export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string; avatar?: boolean }>> = (p) => (
157+ <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style} enctype={p.avatar ? "multipart/form-data" : undefined} data-avatar={p.avatar ? "1" : undefined}>
150158 <input type="hidden" name={CSRF_FIELD} value={p.t} />
151159 {p.children}
152160 </form>
153161 );
154162
155-export const CopyButton: FC<{ text: string; label?: string; class?: string }> = ({ text, label, class: cls }) => (
156- <button type="button" class={`btn sm ${cls ?? ""}`} data-copy={text} title="Copy to clipboard">
157- {label ?? "Copy"}
163+/** Copy-to-clipboard button: small by default; `size="base"` next to 36px fields, `icon` for an icon-only button. */
164+export const CopyButton: FC<{ text: string; label?: string; class?: string; size?: "sm" | "base"; icon?: boolean }> = ({ text, label, class: cls, size = "sm", icon }) => (
165+ <button type="button" class={`btn ${size === "sm" ? "sm" : ""} ${icon ? "icon" : ""} ${cls ?? ""}`} data-copy={text} data-copy-icon={icon ? "1" : undefined} title="Copy to clipboard" aria-label={icon ? "Copy to clipboard" : undefined}>
166+ {icon ? raw(ICONS.copy) : (label ?? "Copy")}
158167 </button>
159168 );
160169
@@ -290,7 +299,7 @@
290299 </div>
291300 ) : null}
292301 <h1>
293- <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span>
302+ <span class="st faint">{raw(vis === "public" ? ICONS.globe : ICONS.lock)}</span>
294303 <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a>
295304 <span class="faint">/</span>
296305 <strong>
@@ -333,7 +342,8 @@
333342 "default-src 'self'",
334343 `script-src 'nonce-${nonce}'`,
335344 "style-src 'self' 'unsafe-inline'",
336- "img-src 'self' https: data:",
345+ // blob: previews a picture before it is uploaded.
346+ "img-src 'self' https: data: blob:",
337347 `connect-src 'self' wss://${host} ws://${host}`,
338348 "font-src 'self'",
339349 "object-src 'none'",
@@ -356,6 +366,16 @@
356366 return renderPage(c, opts, body);
357367 }
358368
369+export type Appearance = "light" | "dark";
370+export const APPEARANCE_COOKIE = "dfy_theme";
371+
372+/** The chosen theme, or null to follow the system. Signed-out visitors keep the last choice made in this browser. */
373+export function appearanceOf(c: AppContext): Appearance | null {
374+ const p = c.get("principal");
375+ const v = p?.type === "account" ? p.account.appearance : getCookie(c, APPEARANCE_COOKIE);
376+ return v === "light" || v === "dark" ? v : null;
377+}
378+
359379 function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Response | Promise<Response> {
360380 csrfToken(c);
361381 const nonce = randomToken(16);
@@ -370,12 +390,13 @@
370390 c.header("cache-control", "private, no-store");
371391 const r = opts.repo;
372392 const title = opts.home ? `${SITE.name}: ${SITE.tagline}` : opts.title ? `${opts.title} · ${SITE.name}` : SITE.name;
393+ const theme = appearanceOf(c);
373394 const page = (
374- <html lang="en">
395+ <html lang="en" data-theme={theme ?? undefined} data-mode={theme ?? undefined}>
375396 <head>
376397 <meta charset="utf-8" />
377398 <meta name="viewport" content="width=device-width, initial-scale=1" />
378- <meta name="color-scheme" content="light dark" />
399+ <meta name="color-scheme" content={theme ?? "light dark"} />
379400 <title>{title}</title>
380401 {SeoHead(c, {
381402 title,
@@ -416,7 +437,7 @@
416437 {raw(ICONS.bell)}
417438 </a>
418439 <a href="/settings" title="Settings" class="who">
419- <Avatar login={me.login} /> <span>{me.login}</span>
440+ <Avatar login={me.login} version={me.avatar_version} /> <span>{me.login}</span>
420441 </a>
421442 </>
422443 ) : null}
@@ -463,7 +484,8 @@
463484 </body>
464485 </html>
465486 );
466- return c.html(`<!doctype html>${page}`, (opts.status ?? 200) as 200);
487+ // `html` waits for async components (highlighted Markdown and diffs).
488+ return c.html(html`<!doctype html>${page}`, (opts.status ?? 200) as 200);
467489 }
468490
469491 /** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */
@@ -549,7 +571,8 @@
549571 <details class="menu">
550572 <summary class="btn sm">
551573 {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)}
552- <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> ▾
574+ <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span>
575+ {raw(ICONS.caret)}
553576 </summary>
554577 <div class="pop">
555578 <h4>Branches</h4>
@@ -641,15 +664,40 @@
641664 }
642665
643666 const LINE_BUDGET = 20000;
667+const HIGHLIGHT_BUDGET = 3000;
644668
669+const diffLines = (f: DiffFile): number => f.hunks.reduce((n, h) => n + h.lines.length, 0);
670+
671+/** Syntax tokens per file, hunk and line, for the rendered files that fit the highlighting budget. */
672+async function diffTokens(files: DiffFile[]): Promise<Map<DiffFile, Array<Array<TokLine | null>>>> {
673+ const out = new Map<DiffFile, Array<Array<TokLine | null>>>();
674+ const picked: Array<[DiffFile, Lang]> = [];
675+ let budget = LINE_BUDGET;
676+ let hlBudget = HIGHLIGHT_BUDGET;
677+ for (const f of files) {
678+ const lines = diffLines(f);
679+ const rendered = budget > 0 && !f.binary && !f.truncated;
680+ budget -= lines;
681+ const lang = langForPath(f.path);
682+ if (!rendered || !lang || lines > LIMITS.lines || lines > hlBudget) continue;
683+ hlBudget -= lines;
684+ picked.push([f, lang]);
685+ }
686+ const hl = picked.length ? await ready(picked.map(([, l]) => l)) : null;
687+ if (hl) for (const [f, lang] of picked) out.set(f, f.hunks.map((h) => diffLineTokens(h.lines, (code) => tokenize(hl, code, lang))));
688+ return out;
689+}
690+
645691 /** Unified diff with line numbers; each file is collapsible. */
646-export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = ({ files, opts = {} }) => {
692+export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = async ({ files, opts = {} }) => {
693+ const toks = await diffTokens(files);
647694 let budget = LINE_BUDGET;
648695 return (
649696 <div>
650697 {files.map((f) => {
651- const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0);
698+ const lines = diffLines(f);
652699 const big = lines > 800;
700+ const ft = toks.get(f);
653701 const render = budget > 0 && !f.binary && !f.truncated;
654702 budget -= lines;
655703 const threads = opts.threads?.get(f.path) ?? [];
@@ -678,7 +726,8 @@
678726 ) : (
679727 <div class="code-wrap">
680728 <table class="diff">
681- {f.hunks.map((h) => {
729+ {f.hunks.map((h, hi) => {
730+ const ht = ft?.[hi];
682731 let oldN = h.oldStart;
683732 let newN = h.newStart;
684733 return (
@@ -691,8 +740,9 @@
691740 @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@
692741 </td>
693742 </tr>
694- {h.lines.map((l) => {
743+ {h.lines.map((l, li) => {
695744 const k = l[0];
745+ const lt = ht?.[li];
696746 if (k === "\\") {
697747 return (
698748 <tr class="meta">
@@ -721,9 +771,9 @@
721771 ) : null}
722772 <td class="ln">{o ?? ""}</td>
723773 <td class="ln">{n ?? ""}</td>
724- <td class="code">
774+ <td class={lt ? "code sh" : "code"}>
725775 {k === "+" || k === "-" ? k : " "}
726- {l.slice(1)}
776+ {lt ? tokens(lt) : l.slice(1)}
727777 </td>
728778 </tr>
729779 {here.length ? (
@@ -781,13 +831,15 @@
781831 </div>
782832 </div>
783833 {extra}
784- <CopyButton text={sha} label="Copy" />
785- <a class="sha" href={`${base}/commit/${sha}`}>
786- {shortSha(sha)}
787- </a>
788- <a class="btn sm" href={`${base}/tree/${sha}`} title="Browse the repository at this point">
789- {raw(ICONS.code)}
790- </a>
834+ <span class="btngroup">
835+ <CopyButton text={sha} icon />
836+ <a class="btn sm mono" href={`${base}/commit/${sha}`} title="View commit">
837+ {shortSha(sha)}
838+ </a>
839+ <a class="btn sm icon" href={`${base}/tree/${sha}`} title="Browse the repository at this point" aria-label="Browse the repository at this point">
840+ {raw(ICONS.code)}
841+ </a>
842+ </span>
791843 </li>
792844 );
793845
@@ -809,3 +861,6 @@
809861 }
810862
811863 export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const;
864+
865+/** "write" → "Write", for showing a stored value as a label. */
866+export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
modifiedapps/diffinery/test/e2e/e2e.test.mjs+103−1View file
@@ -6,7 +6,8 @@
66 // protection, workflows triggered by push and pull_request and executed by a
77 // self-hosted runner (the same agent that runs in Cloudflare Containers),
88 // check runs, branch protection with required checks and human review for
9-// agent-authored PRs, merging, the REST contents API and the MCP server.
9+// agent-authored PRs, merging, the REST contents API, the MCP server and
10+// profile pictures (WASM image processing in the Worker).
1011 import { spawn, spawnSync, execFileSync } from "node:child_process";
1112 import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
1213 import { tmpdir } from "node:os";
@@ -14,6 +15,7 @@
1415 import { join, dirname, resolve } from "node:path";
1516 import { fileURLToPath } from "node:url";
1617 import { createServer } from "node:net";
18+import { crc32, deflateSync } from "node:zlib";
1719 import { test, before, after } from "node:test";
1820 import assert from "node:assert/strict";
1921
@@ -1042,3 +1044,103 @@
10421044 await gh.rest.actions.updateRepoVariable({ ...R, name: "COLOR", value: "green" });
10431045 assert.equal((await gh.rest.actions.getRepoVariable({ ...R, name: "COLOR" })).data.value, "green");
10441046 });
1047+
1048+/** A small opaque RGBA PNG (a gradient), built by hand so the test needs no image tools. */
1049+function pngGradient(width, height) {
1050+ const chunk = (type, body) => {
1051+ const out = Buffer.alloc(12 + body.length);
1052+ out.writeUInt32BE(body.length, 0);
1053+ out.write(type, 4, "latin1");
1054+ body.copy(out, 8);
1055+ out.writeUInt32BE(crc32(out.subarray(4, 8 + body.length)), 8 + body.length);
1056+ return out;
1057+ };
1058+ const ihdr = Buffer.alloc(13);
1059+ ihdr.writeUInt32BE(width, 0);
1060+ ihdr.writeUInt32BE(height, 4);
1061+ ihdr.set([8, 6, 0, 0, 0], 8);
1062+ const raw = Buffer.alloc((width * 4 + 1) * height);
1063+ for (let y = 0; y < height; y++)
1064+ for (let x = 0; x < width; x++) raw.set([(x * 255) / width, (y * 255) / height, 128, 255], y * (width * 4 + 1) + 1 + x * 4);
1065+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
1066+}
1067+
1068+/** Width and height of a lossy or extended WebP, or null when it is not one. */
1069+function webpSize(b) {
1070+ if (b.toString("latin1", 0, 4) !== "RIFF" || b.toString("latin1", 8, 12) !== "WEBP") return null;
1071+ const chunk = b.toString("latin1", 12, 16);
1072+ if (chunk === "VP8 ") return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff };
1073+ if (chunk === "VP8X") return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
1074+ return null;
1075+}
1076+
1077+test("profile pictures: upload, resize, serve, cache headers and removal", async () => {
1078+ const me = await ok("GET", "/user");
1079+ const avatar = async (login, query = "") => {
1080+ const r = await fetch(`${base}/api/avatars/${login}${query}`);
1081+ assert.equal(r.status, 200);
1082+ return { type: r.headers.get("content-type"), cache: r.headers.get("cache-control"), csp: r.headers.get("content-security-policy"), body: Buffer.from(await r.arrayBuffer()) };
1083+ };
1084+ const put = (path, body, tok = token) => fetch(`${base}/api/v3${path}`, { method: "PUT", headers: { authorization: `Bearer ${tok}`, "content-type": "image/png" }, body });
1085+
1086+ // Before any upload: generated initials, also for logins that don't exist.
1087+ const initials = await avatar(me.login, "?s=64");
1088+ assert.match(initials.type, /^image\/svg\+xml/);
1089+ assert.equal(initials.csp, "default-src 'none'; style-src 'unsafe-inline'");
1090+ assert.match((await avatar("no-such-user-here")).type, /^image\/svg\+xml/);
1091+
1092+ // REST upload: a 600×400 PNG becomes centred square WebPs.
1093+ const up = await put("/user/avatar", pngGradient(600, 400));
1094+ assert.equal(up.status, 200, await up.clone().text());
1095+ const { avatar_url } = await up.json();
1096+ const version = new URL(avatar_url).searchParams.get("v");
1097+ assert.match(version ?? "", /^\d+$/);
1098+ for (const s of [64, 128, 256]) {
1099+ const a = await avatar(me.login, `?s=${s}&v=${version}`);
1100+ assert.equal(a.type, "image/webp");
1101+ assert.deepEqual(webpSize(a.body), { width: s, height: s });
1102+ assert.ok(a.body.length < 10_000, `${s}px WebP is ${a.body.length} bytes`);
1103+ assert.match(a.cache, /immutable/);
1104+ }
1105+ const current = await avatar(me.login, "?s=64");
1106+ assert.equal(current.type, "image/webp");
1107+ assert.match(current.cache, /max-age=600/);
1108+
1109+ // Only JPEG, PNG and WebP, sniffed from the bytes.
1110+ const gif = await put("/user/avatar", Buffer.from("GIF89a\x01\x00\x01\x00\x00\x00\x00;", "latin1"));
1111+ assert.equal(gif.status, 422);
1112+ assert.equal((await gif.json()).message, "Use a JPEG, PNG or WebP image.");
1113+ // Read-only tokens can't change a profile.
1114+ const ro = (await ok("POST", "/diffinery/tokens", { name: "ro", scopes: ["read"] })).token;
1115+ assert.equal((await put("/user/avatar", pngGradient(8, 8), ro)).status, 403);
1116+
1117+ // The web form (multipart, CSRF double-submit) takes the original file and the server resizes it.
1118+ const page = await fetch(`${base}/settings`);
1119+ const csrf = /name="_csrf" value="([^"]+)"/.exec(await page.text())?.[1];
1120+ const cookie = /dfy_csrf=([^;]+)/.exec(page.headers.get("set-cookie") ?? "")?.[1];
1121+ assert.ok(csrf && cookie);
1122+ const form = new FormData();
1123+ form.set("_csrf", csrf);
1124+ form.set("avatar", new Blob([pngGradient(300, 500)], { type: "image/png" }), "me.png");
1125+ const posted = await fetch(`${base}/settings/avatar`, { method: "POST", body: form, redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" } });
1126+ assert.equal(posted.status, 303);
1127+ assert.match(decodeURIComponent(decodeURIComponent(posted.headers.get("set-cookie") ?? "")), /"kind":"notice","message":"Profile picture updated\."/);
1128+ const settings = await (await fetch(`${base}/settings`)).text();
1129+ const newVersion = /\/api\/avatars\/[^"?]+\?s=256&amp;v=(\d+)/.exec(settings)?.[1];
1130+ assert.ok(newVersion && newVersion !== version, "the settings page links the new version");
1131+ // An old version's URL gets the current picture, but not as an immutable response.
1132+ assert.match((await avatar(me.login, `?s=64&v=${version}`)).cache, /max-age=600/);
1133+
1134+ // Organizations: owners and admins.
1135+ await ok("POST", "/user/orgs", { login: "picture-org" });
1136+ const org = await put("/orgs/picture-org/avatar", pngGradient(64, 64));
1137+ assert.equal(org.status, 200, await org.clone().text());
1138+ assert.equal((await avatar("picture-org", "?s=128")).type, "image/webp");
1139+ assert.equal((await fetch(`${base}/api/v3/orgs/picture-org/avatar`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } })).status, 200);
1140+ assert.match((await avatar("picture-org", "?s=128")).type, /^image\/svg\+xml/);
1141+
1142+ // Removal falls back to initials straight away (the cached copies are purged).
1143+ const del = await fetch(`${base}/api/v3/user/avatar`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } });
1144+ assert.deepEqual(await del.json(), { avatar_url: `${base}/api/avatars/${me.login}` });
1145+ assert.match((await avatar(me.login, "?s=64")).type, /^image\/svg\+xml/);
1146+});
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+61−4View file
@@ -148,7 +148,8 @@
148148 assert.match(await text(page), /invited as pal@example\.com/);
149149 await page.fill("#login", "pal");
150150 await page.fill("#name", "Pal Friend");
151- await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]);
151+ // New people land in the organization they were invited to.
152+ await Promise.all([page.waitForURL(`${base}/crew`), page.click("text=Create a passkey and join")]);
152153 assert.match(await text(page), /Welcome to Diffinery, pal/);
153154 assert.equal((await pal.credentials()).length, 1);
154155 const cookies = await pal.context.cookies(base);
@@ -230,12 +231,12 @@
230231 await page.waitForSelector(".dfy-k[data-mounted]");
231232 assert.equal(await nativeControls(page), 0);
232233 await page.fill("#name", "kumo");
233- await page.getByRole("radio", { name: /Internal/ }).click();
234+ await page.getByRole("radio", { name: /Public/ }).click();
234235 await Promise.all([page.waitForURL(`${base}/pal/kumo`), page.click("button:has-text('Create repository')")]);
235236 await page.goto(`${base}/pal/kumo/settings`);
236237 await page.waitForSelector(".dfy-k[data-mounted]");
237- assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "internal");
238- assert.equal(await page.getByRole("radio", { name: "internal" }).getAttribute("aria-checked"), "true");
238+ assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "public");
239+ assert.equal(await page.getByRole("radio", { name: "Public" }).getAttribute("aria-checked"), "true");
239240 await page.goto(`${base}/settings/tokens`);
240241 await page.fill("#tname", "kumo");
241242 await page.click("#repos");
@@ -251,6 +252,62 @@
251252 assert.equal(kumo.status, 200);
252253 assert.match(kumo.headers.get("cache-control") ?? "", /immutable/);
253254
255+ // ------------------------------------------------------------ Add people: one form for usernames and emails
256+ await boss.page.goto(`${base}/crew?tab=people`);
257+ await boss.page.fill("#who", "pal");
258+ await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("form:has(#who) button:has-text('Add')")]);
259+ assert.match(await text(boss.page), /pal is already a member of crew/);
260+ await boss.page.fill("#who", "newbie@example.com");
261+ await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("form:has(#who) button:has-text('Add')")]);
262+ assert.match(await text(boss.page), /Invitation sent to newbie@example\.com/);
263+ assert.match(await boss.page.textContent(".secret"), new RegExp(`^${base}/invite/`));
264+ await boss.page.goto(`${base}/crew?tab=people`);
265+ assert.match(await text(boss.page), /Pending invitations/);
266+ await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("tr:has-text('newbie@example.com') button:has-text('Revoke')")]);
267+ assert.match(await text(boss.page), /Invitation revoked/);
268+ assert.doesNotMatch(await text(boss.page), /newbie@example\.com/);
269+ // An existing account joins right away and hears about it on Diffinery.
270+ await page.goto(`${base}/settings/orgs`);
271+ await Promise.all([page.waitForLoadState("load"), page.fill("#olog", "palorg")]);
272+ await Promise.all([page.waitForURL(/\/palorg/), page.click("button:has-text('Create organization')")]);
273+ await page.fill("#who", "boss");
274+ await Promise.all([page.waitForLoadState("load"), page.click("form:has(#who) button:has-text('Add')")]);
275+ assert.match(await text(page), /Added boss to palorg as member/);
276+ await boss.page.goto(`${base}/notifications`);
277+ assert.match(await text(boss.page), /pal added you to palorg as a member/);
278+ // The last owner can't step down: the organization would have nobody to manage it.
279+ await page.goto(`${base}/palorg?tab=people`);
280+ await Promise.all([
281+ page.waitForLoadState("load"),
282+ page.evaluate(() => {
283+ const csrf = document.querySelector("form input[type=hidden]");
284+ const f = document.createElement("form");
285+ f.method = "post";
286+ f.action = "/palorg/-/org/members";
287+ for (const [k, v] of [[csrf.name, csrf.value], ["login", "pal"], ["role", "member"]]) f.append(Object.assign(document.createElement("input"), { type: "hidden", name: k, value: v }));
288+ document.body.append(f);
289+ f.submit();
290+ }),
291+ ]);
292+ assert.match(await text(page), /needs at least one owner/);
293+ await page.goto(`${base}/palorg?tab=people`);
294+ assert.match(await page.textContent("tr:has-text('pal')"), /Owner/);
295+
296+ // ------------------------------------------------------------ Appearance
297+ await page.goto(`${base}/settings/appearance`);
298+ await page.waitForSelector(".dfy-k[data-mounted]");
299+ await page.getByRole("radio", { name: /Dark/ }).click();
300+ await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Save appearance')")]);
301+ assert.equal(await page.getAttribute("html", "data-theme"), "dark");
302+ assert.equal(await page.getAttribute("html", "data-mode"), "dark");
303+ await page.goto(`${base}/`);
304+ assert.equal(await page.getAttribute("html", "data-theme"), "dark");
305+ await page.goto(`${base}/settings/appearance`);
306+ await page.waitForSelector(".dfy-k[data-mounted]");
307+ await page.getByRole("radio", { name: /Sync with system/ }).click();
308+ await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Save appearance')")]);
309+ assert.equal(await page.getAttribute("html", "data-theme"), null);
310+
254311 // Screenshots for review (kept with E2E_KEEP=1).
255312 if (process.env.E2E_SCREENSHOTS) {
256313 const out = process.env.E2E_SCREENSHOTS;
modifiedapps/diffinery/test/e2e/security.test.mjs+10−0View file
@@ -105,6 +105,8 @@
105105 token("t3", "u-rita", T.rita, ["write"]),
106106 token("t4", "u-dora", T.dora, ["write"]),
107107 token("t5", "a-orphan", T.orphan, ["write"]),
108+ account("o-guild", "guild", "org", "active"),
109+ `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`,
108110 session("s1", "u-penny", S.penny),
109111 session("s2", "u-dora", S.dora),
110112 ].join("; "),
@@ -299,3 +301,11 @@
299301 ws.close();
300302 assert.ok(!seen.some((m) => m.includes("Secret plans") || m.includes(`"id":${draft.id}`)), `draft leaked: ${seen.join("\n")}`);
301303 });
304+
305+test("security: organization admins can't hand out ownership", async () => {
306+ // rita is an admin of guild, olive its owner.
307+ await status(403, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "owner" }, T.rita);
308+ await status(422, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "superuser" }, T.rita);
309+ await status(201, "POST", "/orgs/guild/invitations", { email: "helper@example.com", role: "admin" }, T.rita);
310+ await status(201, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "owner" }, T.olive);
311+});
addedapps/diffinery/test/unit/avatars.test.ts+30−0View file
@@ -1,0 +1,30 @@
1+import { describe, expect, it } from "vitest";
2+import { avatarHue, avatarInitial, avatarPath, avatarSize, initialsSvg } from "../../src/http/avatars.js";
3+
4+describe("avatar URLs and initials", () => {
5+ it("snaps requested sizes to the stored ones", () => {
6+ expect([1, 20, 64, 65, 128, 129, 256, 4000].map(avatarSize)).toEqual([64, 64, 64, 128, 128, 256, 256, 256]);
7+ });
8+
9+ it("builds image paths with an optional size and version", () => {
10+ expect(avatarPath("dev")).toBe("/api/avatars/dev");
11+ expect(avatarPath("dev", 128)).toBe("/api/avatars/dev");
12+ expect(avatarPath("dev", 64, 1791892043)).toBe("/api/avatars/dev?s=64&v=1791892043");
13+ expect(avatarPath("a.b", 256, null)).toBe("/api/avatars/a.b?s=256");
14+ });
15+
16+ it("draws initials like the old .av span: same hue hash, first letter or digit", () => {
17+ // The hash the web UI used before pictures (h * 31 + code, mod 360).
18+ const old = (s: string) => [...s].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360;
19+ for (const login of ["dev", "acme", "Claude-bot", "x"]) expect(avatarHue(login)).toBe(old(login));
20+ expect(avatarInitial("dev")).toBe("D");
21+ expect(avatarInitial("-9lives")).toBe("9");
22+ expect(avatarInitial("---")).toBe("?");
23+ const svg = initialsSvg("dev", 64);
24+ expect(svg).toContain(`fill="hsl(${avatarHue("dev")},55%,55%)"`);
25+ expect(svg).toContain('width="64" height="64"');
26+ expect(svg).toMatch(/>D<\/text><\/svg>$/);
27+ // Logins are restricted, but the SVG never carries more than one [A-Za-z0-9?] character of them.
28+ expect(initialsSvg("<script>", 128)).toMatch(/>S<\/text>/);
29+ });
30+});
addedapps/diffinery/test/unit/highlight.test.ts+202−0View file
@@ -1,0 +1,202 @@
1+import { describe, expect, it } from "vitest";
2+import { diffLineTokens, fenceHighlighter, highlightLines, langForFence, langForPath, LANGS, LIMITS, ready, tokenize, type Lang, type TokLine } from "../../src/web/highlight.js";
3+import { fenceLangs, renderMarkdown } from "../../src/web/markdown.js";
4+
5+const text = (line: TokLine | null | undefined) => line?.map((t) => t.text).join("") ?? null;
6+
7+describe("language detection", () => {
8+ it("maps file names and extensions", () => {
9+ const cases: Record<string, string | null> = {
10+ "src/app.ts": "typescript",
11+ "src/App.tsx": "tsx",
12+ "lib/x.mjs": "javascript",
13+ "lib/x.cjs": "javascript",
14+ "a/b.jsx": "jsx",
15+ "Dockerfile": "docker",
16+ "deploy/Dockerfile.prod": "docker",
17+ "web.dockerfile": "docker",
18+ "Makefile": "make",
19+ "GNUmakefile": "make",
20+ ".github/workflows/ci.yml": "yaml",
21+ "config.yaml": "yaml",
22+ "inc/x.h": "c",
23+ "inc/x.hpp": "cpp",
24+ "src/x.cc": "cpp",
25+ "run.sh": "shellscript",
26+ "x.bash": "shellscript",
27+ ".zshrc": "shellscript",
28+ ".env": "dotenv",
29+ ".env.local": "dotenv",
30+ ".gitignore": null,
31+ "tsconfig.json": "jsonc",
32+ "tsconfig.build.json": "jsonc",
33+ "package.json": "json",
34+ "Cargo.toml": "toml",
35+ "main.go": "go",
36+ "lib.rs": "rust",
37+ "App.vue": "vue",
38+ "Page.svelte": "svelte",
39+ "main.tf": "terraform",
40+ "nginx.conf": "nginx",
41+ "Gemfile": "ruby",
42+ "build.gradle": "groovy",
43+ "api.proto": "proto",
44+ "fix.patch": "diff",
45+ "x.m": "objective-c",
46+ "core.clj": "clojure",
47+ "README": null,
48+ "LICENSE": null,
49+ "notes.txt": null,
50+ "image.png": null,
51+ };
52+ for (const [path, lang] of Object.entries(cases)) expect([path, langForPath(path)]).toEqual([path, lang]);
53+ });
54+
55+ it("maps fence names and aliases", () => {
56+ expect(langForFence("ts")).toBe("typescript");
57+ expect(langForFence("TypeScript")).toBe("typescript");
58+ expect(langForFence("yml")).toBe("yaml");
59+ expect(langForFence("bash")).toBe("shellscript");
60+ expect(langForFence("sh")).toBe("shellscript");
61+ expect(langForFence("console")).toBe("shellscript");
62+ expect(langForFence("c++")).toBe("cpp");
63+ expect(langForFence("c#")).toBe("csharp");
64+ expect(langForFence("golang")).toBe("go");
65+ expect(langForFence("dockerfile")).toBe("docker");
66+ expect(langForFence("hcl")).toBe("hcl");
67+ expect(langForFence("{.python}")).toBe("python");
68+ expect(langForFence("text")).toBeNull();
69+ expect(langForFence("mermaid")).toBeNull();
70+ });
71+});
72+
73+describe("tokens", () => {
74+ it("tokenizes with both themes as CSS variables and leaves default text bare", async () => {
75+ const out = await highlightLines(["const a = 1; // one", "", "export {};"], "typescript");
76+ expect(out).toHaveLength(3);
77+ expect(out!.map(text)).toEqual(["const a = 1; // one", "", "export {};"]);
78+ const kw = out![0]!.find((t) => t.text === "const")!;
79+ expect(kw.style).toMatch(/^--shiki-light:#[0-9A-F]{6};--shiki-dark:#[0-9A-F]{6}$/i);
80+ expect(out![0]!.some((t) => !t.style)).toBe(true);
81+ expect(out![0]!.every((t) => !t.style || /^(--shiki-[a-z-]+:[^;"<>]+;?)+$/.test(t.style))).toBe(true);
82+ });
83+
84+ it("loads every grammar on the JavaScript regex engine", async () => {
85+ for (const lang of LANGS) {
86+ const hl = await ready([lang]);
87+ expect([lang, hl !== null]).toEqual([lang, true]);
88+ const out = tokenize(hl!, "x = 1 # a\n// b\n<a href='c'>d</a>", lang);
89+ expect([lang, out?.length]).toEqual([lang, 3]);
90+ }
91+ }, 60_000);
92+
93+ it("falls back to plain text for unknown languages and oversized input", async () => {
94+ expect(await highlightLines(["x"], null)).toBeNull();
95+ expect(await highlightLines(Array.from({ length: LIMITS.lines + 1 }, () => "x"), "typescript")).toBeNull();
96+ expect(await highlightLines(["x".repeat(LIMITS.bytes + 1)], "typescript")).toBeNull();
97+ });
98+
99+ it("does not tokenize very long lines", async () => {
100+ const long = `const s = "${"a".repeat(LIMITS.lineLength + 10)}";`;
101+ const out = await highlightLines([long, "let b = 2;"], "typescript");
102+ expect(out!.map(text)).toEqual([long, "let b = 2;"]);
103+ expect(out![0]).toEqual([{ text: long }]);
104+ expect(out![1]!.find((t) => t.text === "let")?.style).toBeTruthy();
105+ });
106+
107+ it("highlights a 1,500-line TypeScript file quickly", async () => {
108+ const chunk = [
109+ "import { readFile } from \"node:fs/promises\";",
110+ "/** A documented interface. */",
111+ "export interface Item<T extends object = {}> { id: number; name: string; meta?: T }",
112+ "export async function load(path: string): Promise<Item[]> {",
113+ " const raw = await readFile(path, \"utf8\"); // read",
114+ " return JSON.parse(raw).map((x: any, i: number) => ({ id: i, name: `item ${x.name}` }));",
115+ "}",
116+ "class Store { #items = new Map<number, Item>(); get size() { return this.#items.size; } }",
117+ "const re = /^(?:[a-z]+)\\d*$/gi;",
118+ "",
119+ ];
120+ const lines = Array.from({ length: 1500 }, (_, i) => chunk[i % chunk.length]!);
121+ await ready(["typescript"]);
122+ const t0 = performance.now();
123+ const out = await highlightLines(lines, "typescript");
124+ const ms = performance.now() - t0;
125+ console.log(`highlight: 1,500-line TypeScript file in ${ms.toFixed(0)} ms`);
126+ expect(out).toHaveLength(1500);
127+ expect(ms).toBeLessThan(5000);
128+ });
129+});
130+
131+describe("paired GitHub themes", () => {
132+ it("match Shiki's own dual-theme output", async () => {
133+ const { createHighlighterCore } = await import("shiki/core");
134+ const { createJavaScriptRegexEngine } = await import("shiki/engine/javascript");
135+ const samples: Array<[Lang, string]> = [
136+ ["typescript", 'import { a } from "b";\n/** doc @param x */\nexport class C<T> extends D { #x = 1; get y(): T { return `v${this.#x}` as T } }\nconst re = /a+/g;'],
137+ ["markdown", "# Title\n\n*em* **bold** ~~gone~~ [link](https://x.y)\n\n> quote\n\n- item"],
138+ ["python", "@dataclass\nclass A:\n def f(self, x: int = 2) -> str:\n return f\"{x!r}\" # c"],
139+ ["diff", "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new"],
140+ ["yaml", "on:\n push:\n branches: [main] # c\njobs: { a: 1 }"],
141+ ];
142+ const ref = await createHighlighterCore({ themes: [import("shiki/themes/github-light-default.mjs"), import("shiki/themes/github-dark-default.mjs")], langs: samples.map(([l]) => import(`shiki/langs/${l}.mjs`)), engine: createJavaScriptRegexEngine({ forgiving: true }) });
143+ const hl = await ready(samples.map(([l]) => l));
144+ for (const [lang, code] of samples) {
145+ const { tokens: theirs, fg } = ref.codeToTokens(code, { lang, themes: { light: "github-light-default", dark: "github-dark-default" }, defaultColor: false });
146+ const ours = tokenize(hl!, code, lang)!;
147+ const flat = (lines: Array<Array<{ text: string; style?: string }>>) => lines.map((l) => l.flatMap((t) => [...t.text].map((ch) => `${ch}\t${t.style ?? ""}`)));
148+ // Shiki spells out the default foreground and token backgrounds; ours leaves both to the site.
149+ const style = (s: Record<string, string> = {}) => Object.entries(s).filter(([k]) => !k.endsWith("-bg")).map(([k, v]) => `${k}:${v}`).join(";");
150+ const expected = theirs.map((l) => l.map((t) => ({ text: t.content, style: style(t.htmlStyle).toLowerCase() === fg!.toLowerCase() ? undefined : style(t.htmlStyle) })));
151+ expect([lang, flat(ours)]).toEqual([lang, flat(expected)]);
152+ }
153+ });
154+});
155+
156+describe("diff mapping", () => {
157+ // Each token records the snippet it was tokenized from.
158+ const fake = (code: string): TokLine[] => code.split("\n").map((l) => [{ text: l, style: code.replace(/\n/g, "|") }]);
159+
160+ it("maps old and new side tokens back to rows", () => {
161+ const hunk = [" a", "-b", "-c", "+B", " d", "\\ No newline at end of file", "+e"];
162+ const rows = diffLineTokens(hunk, fake);
163+ expect(rows.map(text)).toEqual(["a", "b", "c", "B", "d", null, "e"]);
164+ expect(rows.map((r) => r?.[0]?.style ?? null)).toEqual(["a|B|d|e", "a|b|c|d", "a|b|c|d", "a|B|d|e", "a|B|d|e", null, "a|B|d|e"]);
165+ });
166+
167+ it("treats empty lines as context and fails soft", () => {
168+ expect(diffLineTokens(["", "+x"], fake).map(text)).toEqual(["", "x"]);
169+ expect(diffLineTokens([" a", "+b"], () => null)).toEqual([null, null]);
170+ expect(diffLineTokens([" a", "+b"], () => [])).toEqual([null, null]);
171+ });
172+
173+ it("carries grammar state across rows of a hunk", async () => {
174+ const hl = await ready(["typescript"]);
175+ const hunk = [" /* start of a", "-old comment", "+new comment", " end */ const x = 1;"];
176+ const rows = diffLineTokens(hunk, (code) => tokenize(hl!, code, "typescript"));
177+ expect(rows.map(text)).toEqual(["/* start of a", "old comment", "new comment", "end */ const x = 1;"]);
178+ const comment = rows[0]![0]!.style;
179+ expect(rows[1]![0]!.style).toBe(comment);
180+ expect(rows[2]![0]!.style).toBe(comment);
181+ expect(rows[3]!.find((t) => t.text.includes("const"))?.style).not.toBe(comment);
182+ });
183+});
184+
185+describe("markdown fences", () => {
186+ it("highlights named fences and escapes code", async () => {
187+ const md = "# T\n\n```ts\nconst a = \"<b>\" && 1;\n```\n\n```\nplain <i>\n```\n\n```mermaid\ngraph\n```\n";
188+ expect(fenceLangs(md)).toEqual(["ts", "mermaid"]);
189+ const html = renderMarkdown(md, { highlight: await fenceHighlighter(fenceLangs(md)) });
190+ expect(html).toContain('<code class="language-ts sh">');
191+ expect(html).toMatch(/<span style="--shiki-light:#[0-9A-F]{6};--shiki-dark:#[0-9A-F]{6}">const<\/span>/i);
192+ expect(html).toContain("&quot;&lt;b&gt;&quot;");
193+ expect(html).not.toContain("<b>");
194+ expect(html).toContain("<pre><code>plain &lt;i&gt;</code></pre>");
195+ expect(html).toContain('<code class="language-mermaid">graph</code>');
196+ });
197+
198+ it("renders plain code without a highlighter", async () => {
199+ expect(await fenceHighlighter(["text", "mermaid"])).toBeUndefined();
200+ expect(renderMarkdown("```ts\nlet x\n```")).toBe('<pre><code class="language-ts">let x</code></pre>');
201+ });
202+});
addedapps/diffinery/test/unit/image.test.ts+242−0View file
@@ -1,0 +1,242 @@
1+import { crc32, deflateSync } from "node:zlib";
2+import { describe, expect, it } from "vitest";
3+import encodeJpeg, { init as initJpegEncode } from "@jsquash/jpeg/encode.js";
4+import decodeWebpWasm, { init as initWebpDecode } from "@jsquash/webp/decode.js";
5+import encodeWebp from "@jsquash/webp/encode.js";
6+import JPEG_ENC_WASM from "@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm?module";
7+import WEBP_DEC_WASM from "@jsquash/webp/codec/dec/webp_dec.wasm?module";
8+import { AVATAR_SIZES, MAX_AVATAR_BYTES, cropAndShrink, imageSize, jpegOrientation, makeAvatarImages, orientSquare, shrinkFactor, sniffImage, squareCrop, type Pixels } from "../../src/lib/image.js";
9+import { HttpError } from "../../src/lib/util.js";
10+
11+// ---------------------------------------------------------------- fixtures, generated here
12+
13+type Paint = (x: number, y: number) => [number, number, number, number?];
14+
15+function pixels(width: number, height: number, paint: Paint): Pixels {
16+ const data = new Uint8ClampedArray(width * height * 4);
17+ for (let y = 0; y < height; y++)
18+ for (let x = 0; x < width; x++) {
19+ const [r, g, b, a = 255] = paint(x, y);
20+ data.set([r, g, b, a], (y * width + x) * 4);
21+ }
22+ return { data, width, height };
23+}
24+
25+/** A minimal RGBA PNG: IHDR, one IDAT (filter 0 rows), IEND. */
26+function png(img: Pixels): Uint8Array {
27+ const chunk = (type: string, body: Uint8Array) => {
28+ const out = new Uint8Array(12 + body.length);
29+ const v = new DataView(out.buffer);
30+ v.setUint32(0, body.length);
31+ out.set(new TextEncoder().encode(type), 4);
32+ out.set(body, 8);
33+ v.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length)));
34+ return out;
35+ };
36+ const ihdr = new Uint8Array(13);
37+ new DataView(ihdr.buffer).setUint32(0, img.width);
38+ new DataView(ihdr.buffer).setUint32(4, img.height);
39+ ihdr.set([8, 6, 0, 0, 0], 8); // 8-bit RGBA
40+ const raw = new Uint8Array((img.width * 4 + 1) * img.height);
41+ for (let y = 0; y < img.height; y++) raw.set(img.data.subarray(y * img.width * 4, (y + 1) * img.width * 4), y * (img.width * 4 + 1) + 1);
42+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", new Uint8Array())]);
43+}
44+
45+let jpegReady: Promise<unknown> | undefined;
46+async function jpeg(img: Pixels, quality = 85): Promise<Uint8Array> {
47+ jpegReady ??= (initJpegEncode as unknown as (m: WebAssembly.Module) => Promise<unknown>)(JPEG_ENC_WASM);
48+ await jpegReady;
49+ return new Uint8Array(await encodeJpeg(img as Parameters<typeof encodeJpeg>[0], { quality }));
50+}
51+
52+async function decodeWebp(bytes: ArrayBuffer): Promise<Pixels> {
53+ await (initWebpDecode as unknown as (m: WebAssembly.Module) => Promise<unknown>)(WEBP_DEC_WASM);
54+ return (await decodeWebpWasm(bytes)) as Pixels;
55+}
56+
57+async function webp(img: Pixels, lossless = false): Promise<Uint8Array> {
58+ await makeAvatarImages(png(pixels(1, 1, () => [0, 0, 0]))); // initialises the shared encoder
59+ return new Uint8Array(await encodeWebp(img as Parameters<typeof encodeWebp>[0], { quality: 80, lossless: lossless ? 1 : 0 }));
60+}
61+
62+/** Insert an EXIF APP1 segment carrying only an orientation tag right after SOI. */
63+function withOrientation(jpg: Uint8Array, orientation: number, le = true): Uint8Array {
64+ const tiff = new Uint8Array(26);
65+ const v = new DataView(tiff.buffer);
66+ tiff.set(new TextEncoder().encode(le ? "II" : "MM"), 0);
67+ v.setUint16(2, 42, le);
68+ v.setUint32(4, 8, le); // IFD0 offset
69+ v.setUint16(8, 1, le); // one entry
70+ v.setUint16(10, 0x0112, le); // Orientation
71+ v.setUint16(12, 3, le); // SHORT
72+ v.setUint32(14, 1, le); // count
73+ v.setUint16(18, orientation, le);
74+ const body = Buffer.concat([Buffer.from("Exif\0\0", "binary"), tiff]);
75+ const seg = Buffer.concat([Buffer.from([0xff, 0xe1, (body.length + 2) >> 8, (body.length + 2) & 0xff]), body]);
76+ return Buffer.concat([jpg.subarray(0, 2), seg, jpg.subarray(2)]);
77+}
78+
79+/** A photo-like test card: smooth gradients plus fine noise, which is what makes real photos expensive to encode. */
80+const photo: Paint = (x, y) => {
81+ const n = ((x * 7919 + y * 104729) ^ (x * y)) % 23;
82+ return [(x * 0.21 + Math.sin(y / 37) * 40 + 90 + n) & 255, (y * 0.17 + Math.cos(x / 53) * 50 + 100 + n) & 255, (150 + Math.sin((x + y) / 71) * 60 + n) & 255];
83+};
84+
85+const px = (img: Pixels, x: number, y: number) => [...img.data.subarray((y * img.width + x) * 4, (y * img.width + x) * 4 + 4)];
86+
87+async function status(p: Promise<unknown>): Promise<{ status: number; message: string }> {
88+ try {
89+ await p;
90+ } catch (e) {
91+ if (e instanceof HttpError) return { status: e.status, message: e.detail };
92+ throw e;
93+ }
94+ throw new Error("expected a rejection");
95+}
96+
97+// ---------------------------------------------------------------- tests
98+
99+describe("image format sniffing", () => {
100+ it("recognises JPEG, PNG and WebP by their magic bytes only", async () => {
101+ const img = pixels(3, 2, () => [10, 20, 30]);
102+ expect(sniffImage(await jpeg(img))).toBe("jpeg");
103+ expect(sniffImage(png(img))).toBe("png");
104+ expect(sniffImage(await webp(img))).toBe("webp");
105+ expect(sniffImage(new TextEncoder().encode("GIF89a\x01\x00\x01\x00"))).toBeNull();
106+ expect(sniffImage(new TextEncoder().encode("\0\0\0\x18ftypheic"))).toBeNull();
107+ expect(sniffImage(new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'/>"))).toBeNull();
108+ expect(sniffImage(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt "))).toBeNull();
109+ expect(sniffImage(new Uint8Array())).toBeNull();
110+ });
111+
112+ it("reads dimensions from headers without decoding", async () => {
113+ const img = pixels(37, 21, photo);
114+ expect(imageSize(await jpeg(img), "jpeg")).toEqual({ width: 37, height: 21 });
115+ expect(imageSize(withOrientation(await jpeg(img), 6), "jpeg")).toEqual({ width: 37, height: 21 });
116+ expect(imageSize(png(img), "png")).toEqual({ width: 37, height: 21 });
117+ expect(imageSize(await webp(img), "webp")).toEqual({ width: 37, height: 21 }); // VP8
118+ expect(imageSize(await webp(img, true), "webp")).toEqual({ width: 37, height: 21 }); // VP8L
119+ expect(imageSize(await webp(pixels(37, 21, (x) => [1, 2, 3, x * 6])), "webp")).toEqual({ width: 37, height: 21 }); // VP8X (alpha)
120+ expect(imageSize(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 2]), "jpeg")).toBeNull();
121+ });
122+
123+ it("reads the EXIF orientation of a JPEG", async () => {
124+ const jpg = await jpeg(pixels(8, 8, photo));
125+ expect(jpegOrientation(jpg)).toBe(1);
126+ expect(jpegOrientation(withOrientation(jpg, 6))).toBe(6);
127+ expect(jpegOrientation(withOrientation(jpg, 8, false))).toBe(8);
128+ expect(jpegOrientation(withOrientation(jpg, 42))).toBe(1);
129+ });
130+});
131+
132+describe("crop and scale math", () => {
133+ it("takes the centred square", () => {
134+ expect(squareCrop(3000, 2000)).toEqual({ x: 500, y: 0, side: 2000 });
135+ expect(squareCrop(2000, 3001)).toEqual({ x: 0, y: 500, side: 2000 });
136+ expect(squareCrop(64, 64)).toEqual({ x: 0, y: 0, side: 64 });
137+ });
138+
139+ it("box-filters by an integer factor, keeping at least the minimum", () => {
140+ expect(shrinkFactor(4096, 512)).toBe(8);
141+ expect(shrinkFactor(2000, 512)).toBe(3); // 666 px
142+ expect(shrinkFactor(1023, 512)).toBe(1);
143+ expect(shrinkFactor(100, 512)).toBe(1);
144+ expect(cropAndShrink(pixels(3000, 2000, () => [1, 2, 3])).width).toBe(666);
145+ expect(cropAndShrink(pixels(300, 200, () => [1, 2, 3])).width).toBe(200);
146+ });
147+
148+ it("averages colour weighted by alpha and crops off the sides", () => {
149+ // 4×2: red | green green | blue. The centre square is the two green columns.
150+ const img = pixels(4, 2, (x) => (x === 0 ? [255, 0, 0] : x === 3 ? [0, 0, 255] : [0, 200, 0]));
151+ const out = cropAndShrink(img, 1);
152+ expect(out.width).toBe(1);
153+ expect(px(out, 0, 0)).toEqual([0, 200, 0, 255]);
154+ // A transparent black pixel next to opaque white: still white, half transparent.
155+ const half = cropAndShrink(pixels(2, 1, (x) => (x ? [255, 255, 255, 255] : [0, 0, 0, 0])), 1);
156+ expect(px(half, 0, 0)).toEqual([0, 0, 0, 0]); // 2×1 crops to the centre 1×1 (x = 0)
157+ const quad = cropAndShrink(pixels(2, 2, (x) => (x ? [255, 255, 255, 255] : [0, 0, 0, 0])), 1);
158+ expect(px(quad, 0, 0)).toEqual([255, 255, 255, 128]);
159+ });
160+
161+ it("applies EXIF orientations to a square", () => {
162+ const A = [1, 0, 0, 255],
163+ B = [2, 0, 0, 255],
164+ C = [3, 0, 0, 255],
165+ D = [4, 0, 0, 255];
166+ const img = pixels(2, 2, (x, y) => [[A, B], [C, D]][y]![x]! as [number, number, number, number]);
167+ const grid = (o: number) => {
168+ const r = orientSquare(img, o);
169+ return [px(r, 0, 0)[0], px(r, 1, 0)[0], px(r, 0, 1)[0], px(r, 1, 1)[0]];
170+ };
171+ expect(grid(1)).toEqual([1, 2, 3, 4]);
172+ expect(grid(2)).toEqual([2, 1, 4, 3]); // mirrored
173+ expect(grid(3)).toEqual([4, 3, 2, 1]); // 180°
174+ expect(grid(4)).toEqual([3, 4, 1, 2]); // flipped
175+ expect(grid(5)).toEqual([1, 3, 2, 4]); // transposed
176+ expect(grid(6)).toEqual([3, 1, 4, 2]); // 90° clockwise
177+ expect(grid(7)).toEqual([4, 2, 3, 1]); // transversed
178+ expect(grid(8)).toEqual([2, 4, 1, 3]); // 90° counter-clockwise
179+ });
180+});
181+
182+describe("decode → crop → resize → encode", () => {
183+ it("turns a landscape photo into small square WebPs of the centre", async () => {
184+ // Red and blue bars outside the centre square; the avatar must be all photo, no bars.
185+ const jpg = await jpeg(pixels(1200, 800, (x, y) => (x < 200 ? [255, 0, 0] : x >= 1000 ? [0, 0, 255] : photo(x, y))));
186+ const out = await makeAvatarImages(jpg);
187+ expect([...out.keys()]).toEqual([...AVATAR_SIZES]);
188+ const sizes: Record<number, number> = {};
189+ for (const [size, bytes] of out) {
190+ sizes[size] = bytes.byteLength;
191+ expect(sniffImage(bytes)).toBe("webp");
192+ expect(imageSize(bytes, "webp")).toEqual({ width: size, height: size });
193+ const back = await decodeWebp(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
194+ for (const [x, y] of [
195+ [0, 0],
196+ [size - 1, size / 2],
197+ ]) {
198+ const [r, , b] = px(back as Pixels, x!, y!);
199+ expect(r! < 200 || b! < 200).toBe(true); // neither pure red nor pure blue survived the crop
200+ }
201+ }
202+ // A few KB each.
203+ console.log("avatar WebP bytes (1200×800 synthetic photo):", sizes);
204+ expect(sizes[256]!).toBeLessThan(20_000);
205+ expect(sizes[64]!).toBeLessThan(sizes[256]!);
206+ });
207+
208+ it("keeps transparency from a PNG and accepts WebP input", async () => {
209+ const pic = pixels(300, 300, (x, y) => ((x - 150) ** 2 + (y - 150) ** 2 < 120 ** 2 ? [30, 120, 220] : [0, 0, 0, 0]));
210+ const fromPng = await makeAvatarImages(png(pic));
211+ const corner = px((await decodeWebp(fromPng.get(64)!.slice().buffer)) as Pixels, 0, 0);
212+ const centre = px((await decodeWebp(fromPng.get(64)!.slice().buffer)) as Pixels, 32, 32);
213+ expect(corner[3]).toBe(0);
214+ expect(centre[3]).toBe(255);
215+ const fromWebp = await makeAvatarImages(await webp(pixels(100, 50, photo)));
216+ expect(imageSize(fromWebp.get(256)!, "webp")).toEqual({ width: 256, height: 256 });
217+ });
218+
219+ it("rotates a JPEG by its EXIF orientation", async () => {
220+ // Stored: red top half, blue bottom half. Orientation 6 (rotate 90° clockwise) shows red on the right.
221+ const jpg = withOrientation(await jpeg(pixels(300, 300, (_x, y) => (y < 150 ? [230, 20, 20] : [20, 20, 230])), 95), 6);
222+ const out = await makeAvatarImages(jpg);
223+ const img = (await decodeWebp(out.get(128)!.slice().buffer)) as Pixels;
224+ const [lr, , lb] = px(img, 10, 64);
225+ const [rr, , rb] = px(img, 117, 64);
226+ expect(lb!).toBeGreaterThan(lr!);
227+ expect(rr!).toBeGreaterThan(rb!);
228+ });
229+
230+ it("rejects what it cannot or should not handle", async () => {
231+ expect(await status(makeAvatarImages(new TextEncoder().encode("GIF89a\x01\x00\x01\x00")))).toEqual({ status: 422, message: "Use a JPEG, PNG or WebP image." });
232+ expect((await status(makeAvatarImages(new Uint8Array(MAX_AVATAR_BYTES + 1)))).status).toBe(413);
233+ // A header claiming 5000×10: refused before anything is decoded.
234+ const wide = png(pixels(1, 1, () => [0, 0, 0]));
235+ new DataView(wide.buffer, wide.byteOffset).setUint32(16, 5000);
236+ expect(await status(makeAvatarImages(wide))).toMatchObject({ status: 422, message: expect.stringContaining("3072") });
237+ // A plausible JPEG header (64×64 baseline frame) followed by junk.
238+ const junk = Uint8Array.from([0xff, 0xd8, 0xff, 0xc0, 0, 17, 8, 0, 64, 0, 64, 3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1, ...Array.from({ length: 200 }, (_, i) => (i * 37) & 0xff)]);
239+ expect(imageSize(junk, "jpeg")).toEqual({ width: 64, height: 64 });
240+ expect(await status(makeAvatarImages(junk))).toMatchObject({ status: 422, message: expect.stringContaining("could not be read") });
241+ });
242+});
addedapps/diffinery/vitest.config.ts+23−0View file
@@ -1,0 +1,23 @@
1+import { defineConfig } from "vitest/config";
2+
3+// The path is base64-encoded so the id does not look like a node_modules file, which vitest would hand to Node unprocessed.
4+const PREFIX = "virtual:wasm-module/";
5+
6+export default defineConfig({
7+ plugins: [
8+ {
9+ // `import mod from "x.wasm?module"` gives a compiled WebAssembly.Module, like wrangler's CompiledWasm rule in the Worker.
10+ name: "diffinery:wasm-module",
11+ enforce: "pre",
12+ async resolveId(source, importer) {
13+ if (!source.endsWith(".wasm?module")) return null;
14+ const file = await this.resolve(source.slice(0, -"?module".length), importer, { skipSelf: true });
15+ return file ? PREFIX + Buffer.from(file.id).toString("base64url") : null;
16+ },
17+ load(id) {
18+ if (!id.startsWith(PREFIX)) return null;
19+ return `import { readFileSync } from "node:fs";\nexport default new WebAssembly.Module(readFileSync(${JSON.stringify(Buffer.from(id.slice(PREFIX.length), "base64url").toString())}));`;
20+ },
21+ },
22+ ],
23+});
modifiedpnpm-lock.yaml+42−0View file
@@ -48,12 +48,27 @@
4848 '@diffinery/workflow-yaml':
4949 specifier: workspace:*
5050 version: link:../../packages/workflow-yaml
51+ '@jsquash/jpeg':
52+ specifier: 1.6.0
53+ version: 1.6.0
54+ '@jsquash/png':
55+ specifier: 3.1.1
56+ version: 3.1.1
57+ '@jsquash/resize':
58+ specifier: 2.1.1
59+ version: 2.1.1
60+ '@jsquash/webp':
61+ specifier: 1.5.0
62+ version: 1.5.0
5163 '@simplewebauthn/server':
5264 specifier: 14.0.3
5365 version: 14.0.3
5466 hono:
5567 specifier: ^4.13.0
5668 version: 4.13.13
69+ shiki:
70+ specifier: 4.5.0
71+ version: 4.5.0
5772 yaml:
5873 specifier: ^2.9.1
5974 version: 2.9.1
@@ -1260,6 +1275,18 @@
12601275 '@jridgewell/trace-mapping@0.3.9':
12611276 resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
12621277
1278+ '@jsquash/jpeg@1.6.0':
1279+ resolution: {integrity: sha512-zwN46Awh1VM6gXlIcALwb5WzqK5H2e6+Awcs1QP8AvS8ohsK/sbE4esvmH4jhlhW7+CgiUUww66vg0aTnlSIMA==}
1280+
1281+ '@jsquash/png@3.1.1':
1282+ resolution: {integrity: sha512-C10pc+0H6j0h8fENOfnGOvkXCmvpSQTDGlfGd0sHphZhPSGTyLjIrHba0FaZZdsKqA/wlmhYicUHb92vfZphaw==}
1283+
1284+ '@jsquash/resize@2.1.1':
1285+ resolution: {integrity: sha512-0R5UL1ZLHUT+carjVikcE1QfA+kfNQ2YamYyGVRmhfh4zttU5EY3bQBGxPIPtY2xIAw1P4Kgyxm2xrceRw1r2w==}
1286+
1287+ '@jsquash/webp@1.5.0':
1288+ resolution: {integrity: sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==}
1289+
12631290 '@levischuck/tiny-cbor@0.2.11':
12641291 resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
12651292
@@ -2230,6 +2257,9 @@
22302257 jsdom:
22312258 optional: true
22322259
2260+ wasm-feature-detect@1.9.0:
2261+ resolution: {integrity: sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==}
2262+
22332263 why-is-node-running@2.3.0:
22342264 resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
22352265 engines: {node: '>=8'}
@@ -2969,6 +2999,16 @@
29692999 '@jridgewell/resolve-uri': 3.1.2
29703000 '@jridgewell/sourcemap-codec': 1.6.0
29713001
3002+ '@jsquash/jpeg@1.6.0': {}
3003+
3004+ '@jsquash/png@3.1.1': {}
3005+
3006+ '@jsquash/resize@2.1.1': {}
3007+
3008+ '@jsquash/webp@1.5.0':
3009+ dependencies:
3010+ wasm-feature-detect: 1.9.0
3011+
29723012 '@levischuck/tiny-cbor@0.2.11': {}
29733013
29743014 '@octokit/auth-token@6.0.0': {}
@@ -4141,6 +4181,8 @@
41414181 transitivePeerDependencies:
41424182 - msw
41434183
4184+ wasm-feature-detect@1.9.0: {}
4185+
41444186 why-is-node-running@2.3.0:
41454187 dependencies:
41464188 siginfo: 2.0.0