diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbed008..816401e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,27 +6,116 @@ This file records notable project changes. It follows the
## [Unreleased]
+## [1.0.4] - 2026-09-12
+
### Added
+- ESLint is an enforced gate instead of a skipped one. `eslint.config.mjs` applies
+ `eslint-config-next` core-web-vitals plus the TypeScript rules to the whole
+ workspace, `pnpm lint` runs `eslint . --max-warnings=0` so warnings fail the
+ build, `pnpm lint:fix` applies autofixes, and `scripts/do-prechecks.py` runs the
+ gate between typecheck and the production build. Every relaxation is scoped to
+ named files and states why (`@next/next/no-img-element`, the `react-hooks/*`
+ families, `no-console` under `scripts/`, `no-require-imports` for the lazy jsdom
+ `require()`), and `docs/DEVELOPMENT.md` records the deferred work.
+- Two dependency pins are documented as load-bearing in the config and the dev
+ guide: ESLint stays on 9.x because eslint-plugin-react and eslint-plugin-jsx-a11y
+ peer ranges stop at `^9` (ESLint 10 removed `context.getFilename`), and
+ TypeScript stays on 6.x because typescript-eslint hard-errors on the TypeScript 7
+ native API.
- `scripts/do-prechecks.py` runs the local quality gates in fail-fast order
(`pnpm prechecks`, `pnpm prechecks:quick`), including static release-metadata,
SPDX, migration-journal, and repository-hygiene checks that CI cannot catch
before a push. `--install-hook` wires it to `git pre-commit`.
- `scripts/test_do_prechecks.py` covers the precheck tooling itself and runs as
one of its gates.
+- `tests/components/CodeBlockTheme.test.tsx` asserts the highlighter palette
+ follows a `` flip with no navigation; it fails against the
+ previous mount-effect implementation.
+
+### Performance
+
+- The site banner and the fixed-size avatars now render through `next/image`
+ instead of ``. `Banner` uses `fill` + `priority` + `sizes="100vw"` inside its
+ existing `relative h-64 overflow-hidden` wrapper and keeps the configured focal
+ point through `objectPosition`; `ArticleCard`, `UserMenu` and `UsersManager` pass
+ explicit 20-36px dimensions plus `sizes`, so a small avatar no longer downloads
+ the full-size OAuth photo.
+- `next.config.mjs` accepts `*.googleusercontent.com` in place of the single
+ `lh3.googleusercontent.com` host, so Google accounts served from another
+ `*.googleusercontent.com` host render instead of throwing while rendering the
+ post list.
+
+### Changed
+
+- Dependency refresh across the workspace (`pnpm update --latest`): Next 16.3.5
+ with `@next/bundle-analyzer`/`eslint-config-next` 16.3.5, React 19.3 and matching
+ React types, Tiptap 3.31.3, Mermaid 12, Zod 4.6.2, `@simplewebauthn/browser` 14
+ and `@simplewebauthn/server` 14.0.1, AWS SDK 3.1131, marked 18.0.12,
+ lucide-react 1.45, dompurify 3.4.15, jszip 3.10.2, and on the tooling side
+ Vitest 5 with `@vitest/coverage-v8` 5, Vite 8.3, Playwright 1.63 and
+ Puppeteer 25.10.
+- `pnpm-workspace.yaml` peer rules track what is actually installed:
+ `@simplewebauthn/browser`/`@simplewebauthn/server` `>=14` (next-auth/`@auth/core`
+ still declare older peers and this app runs its own 2FA implementation) and
+ `eslint` `>=9 <10`.
+- Internal anchors in the home page footer and the comment sign-in panel use
+ `next/link`, and 48 literal `"`/`'` characters in JSX text are escaped as
+ `"`/`'`.
+- README stack badges follow `package.json` (Next.js 16.3, React 19.3), the README
+ gate table and `docs/DEVELOPMENT.md` describe the now-active ESLint gate, and
+ `docs/AGENTS.md` lists `pnpm lint` with the other standard commands.
+- Tests follow the implementation: the `UserMenu` avatar assertion now expects the
+ optimizer URL carrying the original avatar in its `url` parameter and
+ `sizes="36px"`, and the remaining test edits drop unused bindings and
+ already-dead fixtures.
### Fixed
+- Code blocks follow theme changes. `CodeBlock` sampled `` once in
+ a mount effect while `ThemeToggle` rewrites that attribute in place, so toggling
+ the theme left every already-rendered code block in the previous palette until a
+ reload. The palette now comes from a `useSyncExternalStore` subscription (a
+ `MutationObserver` on `data-theme` plus a
+ `matchMedia('(prefers-color-scheme: dark)')` listener), with the light palette as
+ the server snapshot so hydration output is unchanged and the `matchMedia` lookups
+ guarded for jsdom.
+- Reaction anti-abuse fails closed: `ReactionButtons` records its mount timestamp
+ in an effect, keeping render pure, and reports "now" if a handler somehow runs
+ first, so the server-side minimum submit-time check rejects instead of passing.
+- `src/app/api/rum/route.ts` exports `runtime = "nodejs"`; it had been a local
+ `const` that never configured the route.
+- `PostForm`'s featured-image preview effect no longer reads the state it owns: the
+ object URL is created and revoked strictly from the selected file.
- Removed the stray top-level `tiptap-markdown` field from `package.json`, which
duplicated the `devDependencies` entry and was ignored by npm/pnpm.
-- Synced the README version badge with `package.json` (1.0.3).
- Added the missing `Apache-2.0` SPDX headers to `src/lib/rateLimit.ts` and
`scripts/iframe-allowlist.ts`.
+### Removed
+
+- Dead code the lint run exposed: the `Server-Timing` header computation on the post
+ page that was never attached to a response, the placeholder audit-log fetch in
+ `AuditLogSection` (now an explicit "no endpoint yet" state), the per-request
+ `performance.now()` in `proxy()`, the unused query id in
+ `createDatabaseInterceptor()`, and unused imports, variables and `catch` bindings
+ across `src/` and `tests/`.
+
+### Security
+
+- `lodash-es` is overridden to `>=4.17.24`, closing GHSA-f23m-r3pf-42rh (prototype
+ pollution) reached through `mermaid -> chevrotain`; the override stays inside the
+ range chevrotain declares and dedupes with the copy `dagre-d3-es` already used.
+- `postcss` is pinned to 8.5.28 and `qs` overridden to `>=6.16.0` for the remaining
+ advisories; `qs` is reachable only through `@lhci/cli > express`, so it is
+ dev-only. `pnpm audit` now reports no known vulnerabilities in production and
+ development dependencies.
+
### Chores
- Ignore `__pycache__/` and `*.py[cod]` so the Python gate tooling leaves no
noise in `git status`.
+- Bumped the application version from `1.0.3` to `1.0.4`.
## [1.0.3] - 2026-08-30
diff --git a/README.md b/README.md
index b9638c7..10b4e10 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,11 @@ Narravo is a self-hostable blog engine built with Next.js App Router, React, Typ
[](https://github.com/sphildreth/narravo/actions/workflows/ci.yml)
[](./LICENSE)
-
+


-
-
+
+

Demo site: [Knowledge Tome](https://www.shildreth.com)
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 53f6982..13f39b6 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -68,19 +68,25 @@ Notes for specific gates:
The gate is green today because four families of pre-existing patterns are exempted
per file, not globally. Each needs a real change with its own review:
-- `@next/next/no-img-element` – avatars (Gravatar), imported WordPress media and
- admin previews use plain `
`. Switching to `next/image` first needs an
- `images.remotePatterns` allowlist in `next.config.mjs`, since the optimizer
- proxies those remote URLs.
+- `@next/next/no-img-element` – five call sites keep `
`: the post featured
+ image and comment attachments (the uploads table stores no intrinsic width or
+ height, so `next/image` would need a fixed crop box instead of the natural-aspect
+ layout), the lightbox (deliberately the original bytes of arbitrary imported
+ media) and the two admin upload previews (`blob:` object URLs the optimizer cannot
+ proxy). Avatars and the site banner do use `next/image`; converting the featured
+ image properly starts with recording dimensions at upload time.
- `react-hooks/rules-of-hooks`, `static-components`, `refs`, `purity`,
`immutability` – `TiptapEditor` declares `EditorToolbar`/`ImageBubbleMenu` inside
render, reads and writes refs in the render body, and builds its upload session id
with `Date.now()`/`Math.random()`. Hoisting them changes editor initialisation
order, so do it with the skipped Playwright specs enabled.
-- `react-hooks/set-state-in-effect` – `CodeBlock`, `ImageLightbox`,
- `RenderTimeBadge` and `PostForm` sync external state (theme attribute, portal
- mount, `Server-Timing`, object-URL previews) with `setState` inside an effect.
- `useSyncExternalStore` or event-driven resets are the intended replacements.
+- `react-hooks/set-state-in-effect` – `ImageLightbox`, `RenderTimeBadge` and
+ `PostForm` sync external state (portal mount, `Server-Timing`, object-URL
+ previews) with `setState` inside an effect; `useSyncExternalStore` or event-driven
+ resets are the intended replacements. `CodeBlock` was the fourth case and is done:
+ it sampled `` once on mount, so toggling the theme left rendered
+ code in the old palette until reload (covered by
+ `tests/components/CodeBlockTheme.test.tsx`).
- `react-hooks/purity` on the post page – it times its own server render with
`performance.now()`.
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 065816c..ef8caaa 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -67,18 +67,21 @@ const config = [
},
},
{
- // STAGED ADOPTION (tech debt): these components render remote user-supplied or
- // Gravatar images (`
`). Moving them to next/image first requires an
- // `images.remotePatterns` policy, because the optimizer proxies those URLs.
+ // STAGED ADOPTION (tech debt): `
` is deliberate in these five places, for
+ // different reasons:
+ // * Post featured image, comment attachments: uploads store no intrinsic
+ // dimensions, so next/image would need a fixed crop box instead of the
+ // natural-aspect layout these render with today.
+ // * ImageLightbox: shows the original bytes of arbitrary imported (WordPress)
+ // media; those hosts are not in images.remotePatterns on purpose.
+ // * PostForm / AppearanceManager previews: `blob:` object URLs of a file that
+ // has not been uploaded yet, which the optimizer cannot proxy.
+ // Avatars and the site banner already go through next/image; keep it that way.
files: [
"src/app/\\(public\\)/\\[slug\\]/page.tsx",
- "src/components/ArticleCard.tsx",
- "src/components/Banner.tsx",
"src/components/ImageLightbox.tsx",
"src/components/admin/appearance/AppearanceManager.tsx",
"src/components/admin/posts/PostForm.tsx",
- "src/components/admin/users/UsersManager.tsx",
- "src/components/auth/UserMenu.tsx",
"src/components/comments/CommentNode.tsx",
],
rules: {
@@ -95,13 +98,13 @@ const config = [
},
},
{
- // STAGED ADOPTION (tech debt): components that sync external state (theme
- // attribute, portal mount, Server-Timing entries, upload preview) with setState
- // inside an effect. React's compiler rules want useSyncExternalStore or
- // event-driven resets here; each change alters rendering, so they are scheduled
- // separately from this dependency refresh.
+ // STAGED ADOPTION (tech debt): components that sync external state (portal mount,
+ // Server-Timing entries, upload preview) with setState inside an effect. React's
+ // compiler rules want useSyncExternalStore or event-driven resets here; each
+ // change alters rendering, so they are scheduled separately.
+ // CodeBlock used to be here: it now subscribes to through
+ // useSyncExternalStore, which also fixed palettes not following ThemeToggle.
files: [
- "src/components/CodeBlock.tsx",
"src/components/ImageLightbox.tsx",
"src/components/RenderTimeBadge.tsx",
"src/components/admin/posts/PostForm.tsx",
diff --git a/next.config.mjs b/next.config.mjs
index a779522..b320aa3 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -38,8 +38,12 @@ const nextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
+ // Avatars come from the only two OAuth providers (GitHub, Google) and render
+ // through next/image, so their hosts must be listed here. Google serves
+ // account photos from lh3..lh6.googleusercontent.com and ggpht mirrors, hence
+ // the wildcard instead of a single host.
{ protocol: 'https', hostname: 'avatars.githubusercontent.com' },
- { protocol: 'https', hostname: 'lh3.googleusercontent.com' },
+ { protocol: 'https', hostname: '*.googleusercontent.com' },
{ protocol: 'https', hostname: 'i.pravatar.cc' },
{ protocol: 'https', hostname: 'stackoverflow.com' },
...(s3Hostname ? [{ protocol: 'https', hostname: s3Hostname }] : []),
diff --git a/package.json b/package.json
index a635599..6389ffa 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "narravo-next",
"private": true,
- "version": "1.0.3",
+ "version": "1.0.4",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13",
diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx
index 155d358..cb84cd2 100644
--- a/src/components/ArticleCard.tsx
+++ b/src/components/ArticleCard.tsx
@@ -69,7 +69,14 @@ export default function ArticleCard({ post }: { post: ArticleCardPost }) {
{post.author?.name && (
");
+
+ expect(pre.style.backgroundColor).toBe("white");
+
+ document.documentElement.dataset.theme = "dark";
+
+ // MutationObserver delivers on a microtask, so poll for the re-render.
+ await expect.poll(() => pre.style.backgroundColor).toBe("rgb(30, 30, 30)");
+ });
+});
diff --git a/tests/components/auth/UserMenu.test.tsx b/tests/components/auth/UserMenu.test.tsx
index 3399f21..3fd5f93 100644
--- a/tests/components/auth/UserMenu.test.tsx
+++ b/tests/components/auth/UserMenu.test.tsx
@@ -36,7 +36,11 @@ describe("UserMenu", () => {
const img = screen.getByAltText("");
expect(img).toBeInTheDocument();
- expect(img).toHaveAttribute("src", "https://example.com/avatar.jpg");
+ // The avatar goes through next/image, so `src` is the optimizer URL and the
+ // original avatar lives in its `url` parameter; `sizes` is what keeps the
+ // fetched variant small for a 36px box.
+ expect(decodeURIComponent(img.getAttribute("src") ?? "")).toContain("https://example.com/avatar.jpg");
+ expect(img).toHaveAttribute("sizes", "36px");
});
it("should display initials when image is not provided", () => {