From 8a11dbb6703f6a714862b30a358a636d1a2131ac Mon Sep 17 00:00:00 2001 From: Samuel Lison <45985592+TemujinCalidius@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:21:22 +1000 Subject: [PATCH 1/4] fix(bluesky): normalize the handle on read, closing the env + legacy-row gaps (#257) (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #258 normalized the handle at WRITE time (admin save + Test), but every consumer reads credentials via getBlueskyCredentials(), which returned the handle raw. All 12 call sites feed it straight into agent.login(), so a `@`-prefixed handle still failed with a misleading InvalidEmail — and did so silently, since crossposts, polls and DM sync run as background jobs. Two paths were affected, not one: - BLUESKY_HANDLE (env) is never normalized anywhere — nothing touches it. - DB rows written before #258 were stored raw, and no migration backfills them. Normalize both branches of getBlueskyCredentials()'s return instead: one choke point, no call sites change, and it's robust to rows written by hand or by a future migration. Also normalize getIntegrationStatus()'s handle so the admin panel shows what we actually log in with, regardless of source. Tests: env-fallback normalization, legacy raw DB row, and status reporting — all three fail against the pre-fix code. Docs: note the leading-@ tolerance in .env.example + docs/bluesky-integration.md. Co-authored-by: Samuel Lison Co-authored-by: Claude Opus 4.8 --- .env.example | 1 + CHANGELOG.md | 5 +++++ docs/bluesky-integration.md | 5 +++++ src/lib/__tests__/integrations.test.ts | 27 ++++++++++++++++++++++++++ src/lib/integrations.ts | 22 +++++++++++++++++---- 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 3eac4b8..f5ac4fd 100644 --- a/.env.example +++ b/.env.example @@ -131,6 +131,7 @@ FEDI_DOMAIN=example.com # editing or restart, and the app password is stored encrypted at rest. These # env vars remain as an alternative / fallback (an admin-panel value wins). # Get an app password at: https://bsky.app/settings/app-passwords +# Handle without a leading "@" (a pasted "@you.bsky.social" is stripped anyway). # BLUESKY_HANDLE=yourhandle.bsky.social # BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx diff --git a/CHANGELOG.md b/CHANGELOG.md index 629f6c5..1f69439 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Fixed +- **Bluesky crossposting now works when your handle is configured as `@you.bsky.social`** (#257) — the leading `@` is stripped (and the handle trimmed + lowercased) wherever credentials are read, not just when saved from the admin panel. Previously a `BLUESKY_HANDLE=@you.bsky.social` in `.env.local` — or a handle saved before v1.14.0 — failed every crosspost, poll and DM sync with a misleading `InvalidEmail` error, **silently**, because those run as background jobs. + ## 1.15.0 (2026-07-17) ### Added diff --git a/docs/bluesky-integration.md b/docs/bluesky-integration.md index d7d3d3e..628de79 100644 --- a/docs/bluesky-integration.md +++ b/docs/bluesky-integration.md @@ -52,6 +52,9 @@ BLUESKY_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx If you use a custom domain as your Bluesky handle, use that (e.g. `BLUESKY_HANDLE=yourdomain.com`). Restart FediHome after setting env variables. +Write the handle **without a leading `@`**. A pasted `@you.bsky.social` is +stripped (and trimmed + lowercased) automatically, so either form works. + ## What Gets Crossposted | Content Type | Bluesky Format | @@ -112,6 +115,8 @@ The `BLUESKY_HANDLE` or `BLUESKY_APP_PASSWORD` env var is missing or empty. Chec - Make sure `BLUESKY_HANDLE` is your full handle (e.g., `name.bsky.social`, not just `name`) - Verify the app password is correct — regenerate it if unsure - If you changed your Bluesky handle recently, update the env var +- A leading `@` is handled for you — if you're on a build older than v1.16.0, a + `@`-prefixed handle failed with a misleading `InvalidEmail` error (#257) ### Images not appearing on Bluesky diff --git a/src/lib/__tests__/integrations.test.ts b/src/lib/__tests__/integrations.test.ts index 4026862..ada8512 100644 --- a/src/lib/__tests__/integrations.test.ts +++ b/src/lib/__tests__/integrations.test.ts @@ -98,6 +98,33 @@ describe("integrations — Bluesky credentials", () => { expect(handle.create.value).toBe("me.bsky.social"); }); + // #257: every caller feeds this handle straight into agent.login(), so a raw + // `@handle` from EITHER source fails with InvalidEmail — silently, in + // background jobs. Normalizing only on write missed both of these. + it("normalizes the env-fallback handle (#257) — BLUESKY_HANDLE is never normalized at write", async () => { + process.env.BLUESKY_HANDLE = " @Env.BSKY.social "; + process.env.BLUESKY_APP_PASSWORD = "env-pw"; + expect(await getBlueskyCredentials()).toEqual({ handle: "env.bsky.social", password: "env-pw" }); + }); + + it("normalizes a DB handle stored raw before #258 (no migration backfills those rows)", async () => { + const enc = encryptSecret("db-pw")!; + vi.mocked(prisma.siteSetting.findMany).mockResolvedValue( + rows({ "integration.bluesky.handle": "@Db.bsky.social", "integration.bluesky.password": enc }) as never, + ); + expect(await getBlueskyCredentials()).toEqual({ handle: "db.bsky.social", password: "db-pw" }); + }); + + it("reports a normalized handle in the integration status, from either source (#257)", async () => { + process.env.BLUESKY_HANDLE = "@Env.BSKY.social"; + process.env.BLUESKY_APP_PASSWORD = "env-pw"; + expect((await getIntegrationStatus()).bluesky).toMatchObject({ + configured: true, + handle: "env.bsky.social", + source: "env", + }); + }); + it("clear deletes both rows", async () => { await clearBlueskyCredentials(); expect(prisma.siteSetting.deleteMany).toHaveBeenCalledWith({ diff --git a/src/lib/integrations.ts b/src/lib/integrations.ts index 0663d22..98ce930 100644 --- a/src/lib/integrations.ts +++ b/src/lib/integrations.ts @@ -57,16 +57,25 @@ export function normalizeBlueskyHandle(handle: string): string { return handle.trim().replace(/^@+/, "").toLowerCase(); } -/** Resolved Bluesky credentials: DB (decrypted) first, else the env vars. */ +/** + * Resolved Bluesky credentials: DB (decrypted) first, else the env vars. + * + * Normalizes on READ (#257), not just on write. Every caller feeds `handle` + * straight into `agent.login()`, so a raw `@handle` from either source fails + * with `InvalidEmail` — silently, since most callers are background jobs + * (crosspost, polls). Both branches need it: `BLUESKY_HANDLE` is never + * normalized at all, and DB rows written before #258 were stored raw with no + * migration to backfill them. Normalizing here covers every call site at once. + */ export async function getBlueskyCredentials(): Promise { const o = await readRows([KEYS.bskyHandle, KEYS.bskyPassword]); if (o[KEYS.bskyHandle] && o[KEYS.bskyPassword]) { const password = decryptSecret(o[KEYS.bskyPassword]); - if (password) return { handle: o[KEYS.bskyHandle], password }; + if (password) return { handle: normalizeBlueskyHandle(o[KEYS.bskyHandle]), password }; } const eh = process.env.BLUESKY_HANDLE; const ep = process.env.BLUESKY_APP_PASSWORD; - return eh && ep ? { handle: eh, password: ep } : null; + return eh && ep ? { handle: normalizeBlueskyHandle(eh), password: ep } : null; } export async function setBlueskyCredentials( @@ -166,7 +175,12 @@ export async function getIntegrationStatus(): Promise { return { bluesky: { configured: bskyDb || bskyEnv, - handle: o[KEYS.bskyHandle] ?? process.env.BLUESKY_HANDLE ?? null, + // Normalized so the admin panel shows the same handle we actually log in + // with, whichever source it came from (#257). + handle: (() => { + const h = o[KEYS.bskyHandle] ?? process.env.BLUESKY_HANDLE; + return h ? normalizeBlueskyHandle(h) : null; + })(), source: bskyDb ? "db" : bskyEnv ? "env" : null, }, threads: { From 6bf69c54f3f48f9695b6ae3f3ca7dfe7f84cc3a2 Mon Sep 17 00:00:00 2001 From: Samuel Lison <45985592+TemujinCalidius@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:24:49 +1000 Subject: [PATCH 2/4] chore(deps): @atproto/api 0.20.30, tailwindcss 4.3.3; hold sharp at 0.34.5 (#274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @atproto/api 0.20.29 → 0.20.30 - tailwindcss + @tailwindcss/postcss 4.3.2 → 4.3.3 Deliberately NOT bumping sharp 0.34.5 → 0.35.3, despite triage verifying it green. Caret on a 0.x pins the minor, so our ^0.34.5 (>=0.34.5 <0.35.0) and ^0.35.3 are disjoint with next 16.2.10's optionalDependencies.sharp ^0.34.5 — npm can't dedupe, so we'd get ours hoisted plus next's nested, i.e. two full libvips native toolchains (~16MB each) in the standalone image, with next's optimizer loading 0.34.5 while our code loads 0.35.3 in the same process. tsc/vitest/next build all pass either way because none of them assert dependency-tree shape — the duplicate is invisible to every gate we run. Only `npm ls sharp --all` shows it. Revisit when next widens its optional pin. Verified: npm ls sharp --all → exactly one, deduped under next. Audit unchanged: 3 moderate, all the known postcss-inside-next chain (#12). Co-authored-by: Samuel Lison Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 3 + package-lock.json | 150 +++++++++++++++++++++++----------------------- package.json | 6 +- 3 files changed, 81 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f69439..bbdcc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Changed +- Dependency refresh: `@atproto/api` 0.20.30, `tailwindcss` + `@tailwindcss/postcss` 4.3.3. + ### Fixed - **Bluesky crossposting now works when your handle is configured as `@you.bsky.social`** (#257) — the leading `@` is stripped (and the handle trimmed + lowercased) wherever credentials are read, not just when saved from the admin panel. Previously a `BLUESKY_HANDLE=@you.bsky.social` in `.env.local` — or a handle saved before v1.14.0 — failed every crosspost, poll and DM sync with a misleading `InvalidEmail` error, **silently**, because those run as background jobs. diff --git a/package-lock.json b/package-lock.json index b40ea2c..81baeca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,12 @@ "hasInstallScript": true, "license": "ISC", "dependencies": { - "@atproto/api": "^0.20.29", + "@atproto/api": "^0.20.30", "@fedify/fedify": "^2.3.2", "@fedify/next": "^2.3.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/postcss": "^4.3.3", "@types/node": "^26.1.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -37,7 +37,7 @@ "remark-gfm": "^4.0.1", "sanitize-html": "^2.17.6", "sharp": "^0.34.5", - "tailwindcss": "^4.3.0", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "web-push": "^3.6.7" }, @@ -66,9 +66,9 @@ } }, "node_modules/@atproto/api": { - "version": "0.20.29", - "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.29.tgz", - "integrity": "sha512-K42whyTLPetooPs8pZXQjPyo2eT/ki+tFEnDkOvzwqCNdR97N5l4+vPi/sXJIzez0XOjB9EDYnMR0bcMZHu1fg==", + "version": "0.20.30", + "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.20.30.tgz", + "integrity": "sha512-t/9pPW+60AFeefiT186z4Z16yz4UmNGtb0EYVyCIHkwYk6nat57OvJA3oEbp4NK9yd/REsVCTBk6gOk8PNWyQQ==", "license": "MIT", "dependencies": { "@atproto/common-web": "^0.5.6", @@ -2970,47 +2970,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -3024,9 +3024,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -3040,9 +3040,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -3056,9 +3056,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -3072,9 +3072,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -3088,9 +3088,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -3104,9 +3104,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -3120,9 +3120,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -3136,9 +3136,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -3152,9 +3152,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3181,9 +3181,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -3197,9 +3197,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -3213,16 +3213,16 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", - "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "postcss": "^8.5.15", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" } }, "node_modules/@tokenizer/inflate": { @@ -5321,9 +5321,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.24.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -11655,9 +11655,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "license": "MIT" }, "node_modules/tapable": { diff --git a/package.json b/package.json index b563d24..bcc940f 100644 --- a/package.json +++ b/package.json @@ -20,12 +20,12 @@ "author": "", "license": "ISC", "dependencies": { - "@atproto/api": "^0.20.29", + "@atproto/api": "^0.20.30", "@fedify/fedify": "^2.3.2", "@fedify/next": "^2.3.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@tailwindcss/postcss": "^4.3.0", + "@tailwindcss/postcss": "^4.3.3", "@types/node": "^26.1.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -47,7 +47,7 @@ "remark-gfm": "^4.0.1", "sanitize-html": "^2.17.6", "sharp": "^0.34.5", - "tailwindcss": "^4.3.0", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "web-push": "^3.6.7" }, From 556f017f0681566d72703395bd376f39ab00cd56 Mon Sep 17 00:00:00 2001 From: Samuel Lison <45985592+TemujinCalidius@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:33:48 +1000 Subject: [PATCH 3/4] feat(theme): add "Editorial", a second theme, and turn on the theme picker (#250) (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the first alternative theme and the admin control to select it. These land together on purpose: a picker with one theme is dead UI, and a theme with no picker is unreachable. Editorial is pure data — warm sepia surfaces, a terracotta accent, sage moss, and an inverted type pairing (sans headlines over serif body). No new machinery: buildThemeStyle already diffs a theme's tokens against the default and injects the delta, and resolveLayout already honours its layout preset (feed: "list"). Adding a theme is one file plus one registry line. Constraint worth recording: every theme must be DARK for now. ~56 files and the global body class hardcode Tailwind neutrals (text-white, text-gray-200) that no theme token can reach, because @theme extends the palette rather than replacing it — on a paper ground text-white is 1.07:1, i.e. an invisible site. themes.test.ts now encodes this as a contrast invariant over every registered theme, so it can't be violated by accident (verified: a light surface-950 fails it). Light themes need those utilities migrated to tokens first. Fonts only swap round because buildThemeStyle can't register @font-face and only Inter + Source Serif 4 are self-hosted; mono is untouched so it diffs out. Also fixes two things a second theme would otherwise have exposed: - viewport.themeColor and the PWA manifest hardcoded the default's #0a0a0f, so the browser chrome and installed-app splash clashed with any other theme. Both now derive from the active theme's surface-950 (static `viewport` had to become `generateViewport` — Next forbids exporting both). - The Site settings save wrote `layout.feed || "cards"`, pinning an explicit override on first save even if untouched, which would stop Editorial's list preset from ever applying. Feed layout now offers "Inherit from theme". Verified: default injects nothing and still reports #0a0a0f (pixel-identical); editorial injects all 18 tokens and reports #17120e; both eyeballed via THEME= next dev. 490 tests. Co-authored-by: Samuel Lison Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 6 ++ src/app/admin/site/SiteSettingsClient.tsx | 23 ++++++-- src/app/layout.tsx | 12 ++-- src/app/manifest.ts | 8 ++- src/lib/__tests__/site-settings.test.ts | 1 + src/lib/__tests__/themes.test.ts | 69 ++++++++++++++++++++++- src/lib/themes/editorial.ts | 64 +++++++++++++++++++++ src/lib/themes/registry.ts | 15 +++-- 8 files changed, 182 insertions(+), 16 deletions(-) create mode 100644 src/lib/themes/editorial.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bbdcc55..aefb364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Unreleased +### Added +- **A second theme — "Editorial"** (#250) — warm sepia surfaces, a terracotta accent, serif body copy under sans headlines, and the compact list feed: a reading-first counterpoint to the default's cool glassy Cards. Pick it in **Admin → Site settings → Appearance → Theme** (no restart), or set `THEME=editorial`. Default instances render **identically** — nothing changes unless you choose it. +- **A theme picker** in the admin panel, alongside the feed-layout control. Feed layout gained an explicit **"Inherit from theme"** option, so each theme's own preset applies unless you deliberately override it. + ### Changed - Dependency refresh: `@atproto/api` 0.20.30, `tailwindcss` + `@tailwindcss/postcss` 4.3.3. ### Fixed +- The **browser/PWA theme colour now follows your active theme** (#250) — the address-bar tint, iOS status bar and installed-app splash were hardcoded to the default theme's near-black, so they clashed the moment a different theme was selected. +- **Saving Site settings no longer silently pins your feed layout** (#250) — the first save wrote an explicit `cards` override even if you'd never touched the control, which stopped a theme's own feed preset from ever applying. Feed layout now defaults to "Inherit from theme". - **Bluesky crossposting now works when your handle is configured as `@you.bsky.social`** (#257) — the leading `@` is stripped (and the handle trimmed + lowercased) wherever credentials are read, not just when saved from the admin panel. Previously a `BLUESKY_HANDLE=@you.bsky.social` in `.env.local` — or a handle saved before v1.14.0 — failed every crosspost, poll and DM sync with a misleading `InvalidEmail` error, **silently**, because those run as background jobs. ## 1.15.0 (2026-07-17) diff --git a/src/app/admin/site/SiteSettingsClient.tsx b/src/app/admin/site/SiteSettingsClient.tsx index 8f9d8e5..437dc68 100644 --- a/src/app/admin/site/SiteSettingsClient.tsx +++ b/src/app/admin/site/SiteSettingsClient.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import Link from "next/link"; import type { RuntimeSiteConfig } from "@/lib/site-settings"; +// Pure data + math (no prisma / server-only), so it's safe in a client bundle. +import { THEMES } from "@/lib/themes"; /** * Site settings (#59): the safe appearance/feature config, editable in-app. @@ -76,7 +78,11 @@ export default function SiteSettingsClient({ "download.macos.enabled": String(cfg.download.macosEnabled), "download.macos.releaseUrl": cfg.download.macosReleaseUrl, "download.macos.appStoreUrl": cfg.download.macosAppStoreUrl, - "layout.feed": cfg.layout.feed || "cards", + "theme.id": cfg.theme.id || "default", // "" would fail validation and 400 the whole batch + // Sent as-is: "" means "inherit the theme's feed variant". Coercing it to + // "cards" here would pin an override on first save and stop a theme's own + // preset (e.g. Editorial's list) from ever applying. + "layout.feed": cfg.layout.feed, }; if (await post(settings)) setHasOverrides(true); }; @@ -90,7 +96,7 @@ export default function SiteSettingsClient({ "footer.webringUrl", "footer.webringLabel", "footer.badgeSrc", "footer.badgeHref", "footer.badgeAlt", "footer.fundingUrl", "footer.fundingLabel", "download.macos.enabled", "download.macos.releaseUrl", "download.macos.appStoreUrl", - "layout.feed", + "theme.id", "layout.feed", ].map((k) => [k, null]), ); if (await post(cleared)) { @@ -105,6 +111,7 @@ export default function SiteSettingsClient({ const setFooter = (patch: Partial) => setCfg((c) => ({ ...c, footer: { ...c.footer, ...patch } })); const setDownload = (patch: Partial) => setCfg((c) => ({ ...c, download: { ...c.download, ...patch } })); const setLayout = (patch: Partial) => setCfg((c) => ({ ...c, layout: { ...c.layout, ...patch } })); + const setTheme = (patch: Partial) => setCfg((c) => ({ ...c, theme: { ...c.theme, ...patch } })); const text = (label: string, value: string, onChange: (v: string) => void, placeholder = "") => (