diff --git a/.react-doctor/false-positives.md b/.react-doctor/false-positives.md
new file mode 100644
index 00000000..ee32cf7f
--- /dev/null
+++ b/.react-doctor/false-positives.md
@@ -0,0 +1,66 @@
+# React Doctor review predicates
+
+These are source-backed review notes, not scanner suppressions. Recheck every
+predicate when the referenced code changes. No rule is disabled by this file.
+
+## Hydration of catalogue offsets
+
+`react-doctor/no-hydration-branch-on-browser-global` at `ProjectListSection`
+and the collections directory is a false positive only while both use
+`useCatalogueOffset`. That hook must keep its `useHydrated()` gate: the initial
+server and hydration snapshot use the fixed display-slot offset, and browser
+storage is read after hydration. `apps/web/tests/catalogue-offset-hydration.test.tsx`
+checks both catalogue identities with a conflicting stored offset and asserts
+that hydration reports no recoverable error. Do not exempt direct render-time
+storage access elsewhere.
+
+## Native select names supplied by FieldLabel
+
+`react-doctor/control-has-associated-label` on `ad-tier`, `ad-status` and
+`ad-order-status` is a false positive while each select's ID matches its adjacent
+`FieldLabel.htmlFor`. The shared FieldLabel renders a native label. The advertising
+admin and order tests exercise these controls using their accessible labels.
+
+## Member project form drafts
+
+`react-doctor/no-derived-useState` in `MemberProjectEditor` is intentional only
+while its caller in `src/routes/_main/managed/$id.tsx` keys the editor by both
+project ID and revision. The editor owns unsaved input; a new project/revision
+remounts it. A continuously mounted editor receiving a different project would
+need a new review.
+
+## Mutation cache ownership
+
+The member editor's `react-doctor/query-mutation-missing-invalidation` finding
+does not account for its `save` handler, which awaits `mutateAsync`, catalogue
+invalidation, ownership-key invalidation and router invalidation. Those steps
+must remain on the successful save path.
+
+The project report mutation returns a receipt and closes the reporting viewer's
+dialog; that viewer has no report-inbox query. Do not add admin-only cache reads
+or invalidation solely to silence the detector. Review again if the reporting
+viewer starts displaying a cached report list.
+
+## Authentication return destination
+
+`react-doctor/clickjacking-redirect-risk` in `src/routes/auth/route.tsx` is a false
+positive only while the destination passes through `resolveReturnTo` immediately
+before redirecting. That resolver rejects external origins, protocol-relative
+paths, backslashes, control characters, malformed encodings and auth loops. The
+malicious-destination cases in `apps/web/tests/sign-in-form.test.tsx` protect this
+contract. This does not exempt unrelated redirects or framing configuration.
+
+## JSON serialization contract test
+
+The `JSON.parse(JSON.stringify(result))` assertion in
+`apps/web/tests/public-content-prepare.test.ts` verifies that the public content
+payload survives JSON serialization unchanged. It is not an application deep
+clone. Replacing it with structuredClone would remove the serialization check.
+
+## Static text line keys
+
+Index keys on fitted OG text lines are used by a one-shot static image renderer,
+with no browser reconciliation or row-local state. Commit-message lines likewise
+belong to an immutable commit keyed by commit identity. These predicates do not
+exempt editable, reorderable, filterable or stateful lists. The interactive
+catalogue pagination and GitHub statistics now use semantic keys.
diff --git a/apps/web/src/components/block-note/code-block.tsx b/apps/web/src/components/block-note/code-block.tsx
index 4085662e..b277d3a2 100644
--- a/apps/web/src/components/block-note/code-block.tsx
+++ b/apps/web/src/components/block-note/code-block.tsx
@@ -297,6 +297,7 @@ export const BNCustomCodeBlock = createReactBlockSpec(
return {
...baseSpec.implementation,
+ renderFrame: undefined,
render: CustomCodeBlock,
toExternalHTML: CodeBlockExternalHTML,
}
diff --git a/apps/web/src/components/block-note/schema.ts b/apps/web/src/components/block-note/schema.ts
index 9938f647..ab0880af 100644
--- a/apps/web/src/components/block-note/schema.ts
+++ b/apps/web/src/components/block-note/schema.ts
@@ -1,5 +1,6 @@
-import { BlockNoteSchema, SyntaxHighlightingExtension } from '@blocknote/core'
+import { BlockNoteSchema } from '@blocknote/core'
import type { BlockNoteEditor, InlineContent } from '@blocknote/core'
+import { SyntaxHighlightingExtension } from '@blocknote/core/extensions'
import {
BNCustomCodeBlock,
diff --git a/apps/web/src/components/category-combobox-view.tsx b/apps/web/src/components/category-combobox-view.tsx
index f0724b5b..84994253 100644
--- a/apps/web/src/components/category-combobox-view.tsx
+++ b/apps/web/src/components/category-combobox-view.tsx
@@ -45,7 +45,8 @@ export const CategoryComboboxView = ({
const items = categories
.filter((category) => category.isLeaf)
.map((category) => category.slug)
- const stale = value.filter((slug) => !items.includes(slug))
+ const itemSlugs = new Set(items)
+ const stale = value.filter((slug) => !itemSlugs.has(slug))
return (
- {editing
- ? 'Configure artwork, placements, and schedule. Advertiser purchases are managed in Orders.'
- : 'Published, paused, and draft campaigns. All dates use UTC.'}
-
-
+
{error && (
{error}
@@ -182,7 +170,7 @@ export function AdsAdmin({
+ {editing
+ ? 'Configure artwork, placements, and schedule. Advertiser purchases are managed in Orders.'
+ : 'Published, paused, and draft campaigns. All dates use UTC.'}
+
- No automatic billing. Request another month at US$
- {order.amount / 100}, then pay through Ko-fi using the link that
- appears after your request. Dates extend only after the team
- confirms the new payment. If your period has ended, a new month
- starts at confirmation.
-
-
-
+
)}
>
)}
@@ -367,23 +221,25 @@ function AdMaterialForm({
return
}
setSaving(true)
- try {
- onSaved(
- await client.adBilling.submit({
- body: {
- ...access,
- material: parsed.data,
- expectedVersion: order.version,
- },
- })
- )
- } catch {
- setError(
- 'Unable to submit. Check uploads, or refresh the order if another change was made. Your input is retained.'
- )
- } finally {
+ await (async () => {
+ try {
+ onSaved(
+ await client.adBilling.submit({
+ body: {
+ ...access,
+ material: parsed.data,
+ expectedVersion: order.version,
+ },
+ })
+ )
+ } catch {
+ setError(
+ 'Unable to submit. Check uploads, or refresh the order if another change was made. Your input is retained.'
+ )
+ }
+ })().finally(() => {
setSaving(false)
- }
+ })
}}
>
@@ -483,3 +339,213 @@ function AdMaterialForm({
)
}
+
+function AdOrderSummary({
+ order,
+ loadedAt,
+}: {
+ order: AdOrderView
+ loadedAt: number
+}) {
+ return (
+
+ Pay for one month of {AD_PLANS[order.plan].name} on Ko-fi. Use
+ the contact email above and include your order reference in the
+ message so we can match your payment.
+
+
+ Already paid? Wait for our team to confirm receipt by email; do
+ not pay again. Opening Ko-fi does not update your payment
+ status.{' '}
+ {order.paidAt
+ ? 'Your campaign dates extend after the team confirms this renewal payment.'
+ : 'Your first advertising month starts after materials are approved.'}
+
+ No automatic billing. Request another month at US$
+ {order.amount / 100}, then pay through Ko-fi using the link that appears
+ after your request. Dates extend only after the team confirms the new
+ payment. If your period has ended, a new month starts at confirmation.
+