Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/studio/[[...tool]]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const metadata = {
title: "PauseAI UK Watchdog — Studio",
robots: { index: false },
};

export default function StudioLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}
10 changes: 10 additions & 0 deletions app/studio/[[...tool]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";

import { NextStudio } from "next-sanity/studio";
import config from "@/sanity.config";

export const dynamic = "force-static";

export default function StudioPage() {
return <NextStudio config={config} />;
}
23 changes: 23 additions & 0 deletions lib/categoryMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Short, plain-language descriptions of each incident category, keyed by slug.
const categoryDescriptions: Record<string, string> = {
"broken-commitment":
"A company walks back a public safety or governance promise.",
"deceptive-advocacy":
"Misleading lobbying or public messaging on AI policy.",
safetywashing:
"Overstating safety work to seem more responsible than it is.",
whistleblower:
"Restrictive NDAs or retaliation against staff who speak up.",
"mission-conflict":
"Actions that cut against the company's own stated mission.",
"consumer-harm":
"Products that put users — including children — at risk.",
"benchmark-gaming":
"Gaming evaluations or benchmarks to inflate results.",
"data-practices":
"Questionable handling of user data or training data.",
};

export function categoryDescription(slug: string): string {
return categoryDescriptions[slug] ?? "";
}
19 changes: 19 additions & 0 deletions lib/companyLogos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Slug → primary domain, used to render each company's favicon as a logo
// (mirrors how AI Lab Watch shows lab logos).
const companyDomains: Record<string, string> = {
openai: "openai.com",
anthropic: "anthropic.com",
xai: "x.ai",
microsoft: "microsoft.com",
meta: "meta.com",
"google-deepmind": "deepmind.google",
deepseek: "deepseek.com",
mistral: "mistral.ai",
};

export function companyLogo(slug: string): string | null {
const domain = companyDomains[slug];
return domain
? `https://www.google.com/s2/favicons?domain=${domain}&sz=128`
: null;
}
59 changes: 59 additions & 0 deletions lib/messagingRules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// PauseAI messaging guideline rules. Single source of truth used by both the
// Sanity schema validators (Studio shows them inline) and, if you wire one up,
// a pre-commit lint of staged copy.

export type RuleLevel = "error" | "warning";

export interface MessagingRule {
level: RuleLevel;
pattern: RegExp;
message: string;
}

export const RULES: MessagingRule[] = [
{
level: "error",
pattern: /Pause AI/,
message: "Brand is one word: write 'PauseAI', not 'Pause AI'.",
},
{
level: "warning",
pattern: /\bactivists?\b|grassroots/i,
message:
"Prefer 'citizens / advocates / our community / civic movement'.",
},
{
level: "warning",
pattern: /tech bro|big ai|\bcorporations?\b/i,
message:
"Prefer 'a handful of labs racing in a way nobody consented to'.",
},
{
level: "warning",
pattern: /existential risk/i,
message: "Prefer 'extinction risk' (less abstract).",
},
{
level: "warning",
pattern: /\btreaty\b/i,
message: "Prefer 'international agreement'.",
},
{
level: "warning",
pattern: /kill us all|ban all ai/i,
message:
"Prefer 'trajectory towards loss of control' + sourced claims.",
},
{
level: "warning",
pattern: /\bAGI\b/,
message: "Prefer 'smarter-than-human AI' or 'frontier AI'.",
},
];

export function firstHit(text: string, level: RuleLevel): string | null {
for (const rule of RULES) {
if (rule.level === level && rule.pattern.test(text)) return rule.message;
}
return null;
}
32 changes: 32 additions & 0 deletions lib/queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { groq } from "next-sanity";

export const incidentsQuery = groq`
*[_type == "incident"] | order(date desc) {
_id,
title,
"slug": slug.current,
date,
summary,
severity,
pullQuote{ text, attribution, "imageUrl": image.asset->url },
featured,
"companies": companies[]->{ _id, name, "slug": slug.current, swatch },
"categories": categories[]->{ _id, name, "slug": slug.current },
sources
}
`;

export const companiesQuery = groq`
*[_type == "company"] | order(name asc) {
_id, name, "slug": slug.current, swatch,
"count": count(*[_type == "incident" && references(^._id)])
}
`;

export const incidentBySlugQuery = groq`
*[_type == "incident" && slug.current == $slug][0]{
...,
"companies": companies[]->{ _id, name, "slug": slug.current, swatch },
"categories": categories[]->{ _id, name, "slug": slug.current }
}
`;
10 changes: 10 additions & 0 deletions lib/sanity-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createClient } from "next-sanity";
import { apiVersion, dataset, projectId, useCdn } from "@/lib/sanity/env";

export const sanityClient = createClient({
projectId,
dataset,
apiVersion,
useCdn,
perspective: "published",
});
12 changes: 12 additions & 0 deletions lib/sanity-write.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Server-only Sanity client with write access. Used by /api/submit. Do not
// import from client code — SANITY_WRITE_TOKEN must stay on the server.
import { createClient } from "@sanity/client";
import { apiVersion, dataset, projectId } from "@/lib/sanity/env";

export const sanityWriteClient = createClient({
projectId,
dataset,
apiVersion,
token: process.env.SANITY_WRITE_TOKEN,
useCdn: false,
});
94 changes: 94 additions & 0 deletions lib/sanity/INTEGRATION_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Watchdog integration — design notes

This directory is the foundation of merging the standalone
[PauseAI Watchdog](https://github.com/ylevtov/ai-integrity-watch) into
pauseai.uk. **Draft PR / not yet wired into any route.**

## What "Watchdog" is

A public, editor-curated record of integrity incidents at frontier AI labs —
broken safety commitments, safetywashing, deceptive advocacy, etc. — with
sourced reports, filterable by company and incident type, and a public form
for the community to submit stories. Originally lived at
https://ai-integrity-watch.vercel.app.

## What this PR adds (foundation only)

- **Sanity v3 CMS dependency** (`@sanity/client`, `next-sanity`, `sanity`,
`@sanity/vision`, `@sanity/image-url`, `styled-components`). Adds editor
self-service for the Watchdog content; pauseai.uk's other pages remain
static (untouched).
- **`lib/sanity/schemas/`** — four document types:
- `incident` — the core: title, slug, date, summary, optional body,
pull quote (text + attribution + portrait), severity, refs to
`company`/`category`, sources[], `featured` flag.
- `company` — name, slug, swatch, blurb.
- `category` — name, slug, description.
- `submission` — inbound from the public form, with a `status`
field (new/reviewing/accepted/rejected) so editors triage before
creating a real `incident`.
- **`lib/sanity/messagingValidation.ts`** — drop-in field validator that
surfaces brand-spelling errors and discouraged-term warnings inline in
Studio as editors type. Backed by the shared
**`lib/messagingRules.ts`** module.
- **`lib/sanity-client.ts`** / **`lib/sanity-write.ts`** — read + server-only
write clients.
- **`lib/queries.ts`** / **`lib/types.ts`** — GROQ queries and the matching
TypeScript types for the Next.js pages to consume.
- **`lib/companyLogos.ts`** / **`lib/categoryMeta.ts`** — small map helpers
(slug → favicon domain, slug → category description) for the UI layer.
- **`sanity.config.ts`** + **`app/studio/[[...tool]]/`** — the embedded
Studio at `/studio` so editors don't need a separate deploy.
- **`.env.example`** — the three Sanity vars the deploy needs.

## What's still TODO (next pieces of the PR / follow-ups)

UI surface — deferred to a follow-up commit so the architectural decision
(adopt Sanity yes/no) can be reviewed independently:

- [ ] `app/watchdog/page.tsx` — main feed (incident list, filters)
- [ ] `app/watchdog/[slug]/page.tsx` — incident detail
- [ ] `app/watchdog/submit/page.tsx` — submission form
- [ ] `app/watchdog/about/page.tsx` — credits + AI Lab Watch attribution
- [ ] `components/watchdog/*` — IncidentCard, IncidentFeed,
CompanyFilter, CategoryFilter, SubmitForm
- [ ] `app/api/submit/route.ts` — validates + writes a `submission` doc
- [ ] `app/api/revalidate/route.ts` — Sanity webhook target for ISR refresh
- [ ] `app/watchdog/watchdog.css` — scoped CSS using the existing
pauseai.uk design tokens (`--bg`, `--accent`, `--surface`, …);
the standalone Watchdog uses Tailwind, so the UI port is a
mechanical Tailwind → vanilla-CSS translation, not a redesign
- [ ] `components/Nav.tsx` — add the "Watchdog" link
- [ ] Optional: lift across the messaging-rule pre-commit hook
(`scripts/check-messaging.ts` + `.githooks/pre-commit`) from the
standalone repo

## Decisions for the maintainer

1. **Adopt Sanity as a dependency?** This PR's question. The other
content (events, news, people, stories) stays static — only the
Watchdog routes use Sanity. Reversible: dropping Sanity later means
migrating ~10–20 incidents to `lib/data/incidents.ts`, losing
editor self-service and the submission form, but it's not destructive.
2. **Mount point.** Currently planned at `/watchdog/`. The existing
`/track-record/` is a personal one-year retrospective, so they don't
collide. Could also be `/integrity-incidents/` if you'd prefer.
3. **Studio location.** Currently at `/studio` (same domain). Alternative:
a separate subdomain like `watchdog-admin.pauseai.uk` if you'd prefer
to keep the public site free of any admin tier.

## Deploy / env work needed when this lands

- Add `NEXT_PUBLIC_SANITY_PROJECT_ID`, `NEXT_PUBLIC_SANITY_DATASET`,
`NEXT_PUBLIC_SANITY_API_VERSION`, `SANITY_WRITE_TOKEN`,
`SANITY_REVALIDATE_SECRET` to Netlify env vars.
- Add `https://pauseai.uk` (and any preview domains) to the Sanity CORS
allowlist, with **Allow credentials** checked.
- Invite editors at sanity.io/manage → project → Members.

## Original Watchdog repo (for context / lifted content)

`ylevtov/ai-integrity-watch` — live at
https://ai-integrity-watch.vercel.app. Code here is adapted from there
to fit pauseai.uk's conventions (vanilla CSS w/ design tokens instead of
Tailwind, `lib/`/`app/`/`components/` at the root, etc.).
8 changes: 8 additions & 0 deletions lib/sanity/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const projectId =
process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || "placeholder";
export const dataset =
process.env.NEXT_PUBLIC_SANITY_DATASET || "production";
export const apiVersion =
process.env.NEXT_PUBLIC_SANITY_API_VERSION || "2024-11-01";

export const useCdn = process.env.NODE_ENV === "production";
20 changes: 20 additions & 0 deletions lib/sanity/messagingValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { firstHit } from "@/lib/messagingRules";

// Drop-in for a defineField `validation` callback. Surfaces brand-spelling
// errors and discouraged-term warnings live in Studio as the editor types.
//
// Usage:
// validation: messagingValidation
// or composed with other rules:
// validation: (Rule) => [Rule.required(), ...messagingValidation(Rule)],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const messagingValidation = (Rule: any) => [
Rule.custom((value: unknown) => {
const text = typeof value === "string" ? value : "";
return firstHit(text, "error") ?? true;
}),
Rule.custom((value: unknown) => {
const text = typeof value === "string" ? value : "";
return firstHit(text, "warning") ?? true;
}).warning(),
];
28 changes: 28 additions & 0 deletions lib/sanity/schemas/category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { defineField, defineType } from "sanity";
import { messagingValidation } from "@/lib/sanity/messagingValidation";

export const category = defineType({
name: "category",
title: "Category",
type: "document",
fields: [
defineField({
name: "name",
type: "string",
validation: (r) => [r.required(), ...messagingValidation(r)],
}),
defineField({
name: "slug",
type: "slug",
options: { source: "name" },
validation: (r) => r.required(),
}),
defineField({
name: "description",
type: "text",
rows: 2,
validation: messagingValidation,
}),
],
preview: { select: { title: "name" } },
});
34 changes: 34 additions & 0 deletions lib/sanity/schemas/company.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { defineField, defineType } from "sanity";
import { messagingValidation } from "@/lib/sanity/messagingValidation";

export const company = defineType({
name: "company",
title: "Company",
type: "document",
fields: [
defineField({
name: "name",
type: "string",
validation: (r) => [r.required(), ...messagingValidation(r)],
}),
defineField({
name: "slug",
type: "slug",
options: { source: "name" },
validation: (r) => r.required(),
}),
defineField({
name: "swatch",
title: "Swatch (hex)",
type: "string",
description: "Tag colour, e.g. #E1F5EE.",
}),
defineField({
name: "blurb",
type: "text",
rows: 2,
validation: messagingValidation,
}),
],
preview: { select: { title: "name" } },
});
Loading